diff --git a/src/data/galleryModels.ts b/src/data/galleryModels.ts new file mode 100644 index 0000000..6a913a6 --- /dev/null +++ b/src/data/galleryModels.ts @@ -0,0 +1,209 @@ +export interface GalleryModel { + id: string; + name: string; + path: string; +} + +export const galleryModels: GalleryModel[] = [ + { id: "arbre", name: "Arbre", path: "/models/arbre/model.gltf" }, + { + id: "arbre-animated", + name: "Arbre animé", + path: "/models/arbre-animated/model.gltf", + }, + { id: "blocking", name: "Blocking", path: "/models/blocking/model.gltf" }, + { + id: "boiteauxlettres", + name: "Boîte aux lettres", + path: "/models/boiteauxlettres/model.gltf", + }, + { + id: "boiteimmeuble", + name: "Boîte immeuble", + path: "/models/boiteimmeuble/model.gltf", + }, + { id: "buisson", name: "Buisson", path: "/models/buisson/model.gltf" }, + { + id: "buisson-animated", + name: "Buisson animé", + path: "/models/buisson-animated/model.gltf", + }, + { id: "cable1", name: "Câble 1", path: "/models/cable1/model.gltf" }, + { id: "cable2", name: "Câble 2", path: "/models/cable2/model.gltf" }, + { + id: "champdeble", + name: "Champ de blé", + path: "/models/champdeble/model.gltf", + }, + { + id: "champdeble-animated", + name: "Champ de blé animé", + path: "/models/champdeble-animated/model.gltf", + }, + { + id: "champdesoja", + name: "Champ de soja", + path: "/models/champdesoja/model.gltf", + }, + { + id: "champdesoja-animated", + name: "Champ de soja animé", + path: "/models/champdesoja-animated/model.gltf", + }, + { + id: "champsdetournesol", + name: "Champ de tournesol", + path: "/models/champsdetournesol/model.gltf", + }, + { + id: "champsdetournesol-animated", + name: "Champ de tournesol animé", + path: "/models/champsdetournesol-animated/model.gltf", + }, + { id: "chemins", name: "Chemins", path: "/models/chemins/model.gltf" }, + { id: "cloud", name: "Nuage", path: "/models/cloud/model.glb" }, + { + id: "createurdepluie", + name: "Créateur de pluie", + path: "/models/createurdepluie/model.gltf", + }, + { id: "ebike", name: "E-bike", path: "/models/ebike/model.gltf" }, + { id: "ecole", name: "École", path: "/models/ecole/model.gltf" }, + { id: "elec", name: "Électricité", path: "/models/elec/model.gltf" }, + { + id: "electricienne", + name: "Électricienne", + path: "/models/electricienne/model.gltf", + }, + { + id: "entreetuyaux", + name: "Entrée tuyaux", + path: "/models/entreetuyaux/model.gltf", + }, + { id: "eolienne", name: "Éolienne", path: "/models/eolienne/model.gltf" }, + { + id: "fermeverticale", + name: "Ferme verticale", + path: "/models/fermeverticale/model.gltf", + }, + { id: "fermier", name: "Fermier", path: "/models/fermier/model.gltf" }, + { + id: "fermier-animated", + name: "Fermier animé", + path: "/models/fermier-animated/model.gltf", + }, + { id: "galet", name: "Galet", path: "/models/galet/model.gltf" }, + { id: "gant_l", name: "Gant gauche", path: "/models/gant_l/model.gltf" }, + { + id: "gant_l_pad", + name: "Pad gant gauche", + path: "/models/gant_l_pad/model.gltf", + }, + { id: "gant_r", name: "Gant droit", path: "/models/gant_r/model.gltf" }, + { + id: "gant_r_pad", + name: "Pad gant droit", + path: "/models/gant_r_pad/model.gltf", + }, + { + id: "generateur", + name: "Générateur", + path: "/models/generateur/model.gltf", + }, + { id: "gerant", name: "Gérant", path: "/models/gerant/model.gltf" }, + { + id: "gerant-animated", + name: "Gérant animé", + path: "/models/gerant-animated/model.gltf", + }, + { + id: "habitant1", + name: "Habitant 1", + path: "/models/habitant1/model.gltf", + }, + { + id: "habitant1-animated", + name: "Habitant 1 animé", + path: "/models/habitant1-animated/model.gltf", + }, + { + id: "habitant2", + name: "Habitant 2", + path: "/models/habitant2/model.gltf", + }, + { + id: "habitant2-animated", + name: "Habitant 2 animé", + path: "/models/habitant2-animated/model.gltf", + }, + { id: "immeuble1", name: "Immeuble", path: "/models/immeuble1/model.gltf" }, + { id: "lafabrik", name: "La Fabrik", path: "/models/lafabrik/model.gltf" }, + { id: "maison1", name: "Maison", path: "/models/maison1/model.gltf" }, + { + id: "packderelance", + name: "Pack de relance", + path: "/models/packderelance/model.gltf", + }, + { + id: "panneauaffichage", + name: "Panneau d'affichage", + path: "/models/panneauaffichage/model.gltf", + }, + { + id: "panneauclassique", + name: "Panneau classique", + path: "/models/panneauclassique/model.gltf", + }, + { + id: "panneaufleche", + name: "Panneau flèche", + path: "/models/panneaufleche/model.gltf", + }, + { + id: "panneausolaire", + name: "Panneau solaire", + path: "/models/panneausolaire/model.gltf", + }, + { + id: "parcebike", + name: "Parc e-bike", + path: "/models/parcebike/model.gltf", + }, + { + id: "persoprincipal", + name: "Personnage principal", + path: "/models/persoprincipal/model.gltf", + }, + { + id: "persoprincipal-animated", + name: "Personnage principal animé", + path: "/models/persoprincipal-animated/model.gltf", + }, + { id: "potager", name: "Potager", path: "/models/potager/potager.gltf" }, + { id: "puce", name: "Puce", path: "/models/puce/model.gltf" }, + { id: "pylone", name: "Pylône", path: "/models/pylone/model.gltf" }, + { + id: "refroidisseur", + name: "Refroidisseur", + path: "/models/refroidisseur/model.gltf", + }, + { id: "sapin", name: "Sapin", path: "/models/sapin/model.gltf" }, + { + id: "sapin-animated", + name: "Sapin animé", + path: "/models/sapin-animated/model.gltf", + }, + { id: "talkie", name: "Talkie", path: "/models/talkie/model.gltf" }, + { id: "terrain", name: "Terrain", path: "/models/terrain/model.gltf" }, + { + id: "tuyauxlac", + name: "Tuyaux lac", + path: "/models/tuyauxlac/model.gltf", + }, + { + id: "tuyauxpuzzle", + name: "Tuyaux puzzle", + path: "/models/tuyauxpuzzle/model.gltf", + }, + { id: "vase", name: "Vase", path: "/models/vase/model.gltf" }, +]; diff --git a/src/index.css b/src/index.css index d52eddb..7ba0e98 100644 --- a/src/index.css +++ b/src/index.css @@ -30,6 +30,180 @@ canvas { display: block; } +/* Model gallery */ +.gallery-page { + display: grid; + grid-template-columns: minmax(280px, 0.78fr) minmax(0, 1.22fr); + gap: clamp(18px, 4vw, 54px); + width: 100vw; + height: 100vh; + padding: clamp(18px, 4vw, 56px); + box-sizing: border-box; + overflow: auto; + background: + radial-gradient( + circle at 22% 18%, + rgba(96, 165, 250, 0.2), + transparent 32% + ), + radial-gradient( + circle at 86% 8%, + rgba(52, 211, 153, 0.16), + transparent 28% + ), + #05070c; + color: #f8fafc; +} + +.gallery-hero { + align-self: center; + max-width: 580px; +} + +.gallery-eyebrow, +.gallery-model-count { + margin: 0 0 12px; + color: #7dd3fc; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.gallery-hero h1 { + margin: 0; + font-size: clamp(44px, 8vw, 92px); + line-height: 0.94; + letter-spacing: -0.075em; +} + +.gallery-hero p:last-child { + max-width: 44rem; + margin: 24px 0 0; + color: #cbd5e1; + font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: clamp(16px, 2vw, 20px); + line-height: 1.65; +} + +.gallery-viewer-panel { + display: grid; + grid-template-rows: auto minmax(360px, 1fr) auto; + min-height: min(760px, calc(100vh - 112px)); + overflow: hidden; + border: 1px solid rgba(226, 232, 240, 0.18); + border-radius: 28px; + background: rgba(8, 13, 24, 0.74); + box-shadow: 0 24px 90px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(18px); +} + +.gallery-viewer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 22px; + border-bottom: 1px solid rgba(226, 232, 240, 0.14); +} + +.gallery-viewer-header h2 { + margin: 0 0 8px; + font-size: clamp(26px, 3vw, 42px); + line-height: 1; + letter-spacing: -0.055em; +} + +.gallery-viewer-header code { + color: #94a3b8; + font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + word-break: break-word; +} + +.gallery-controls { + display: flex; + gap: 10px; +} + +.gallery-controls button { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border: 1px solid rgba(248, 250, 252, 0.24); + border-radius: 999px; + background: rgba(248, 250, 252, 0.08); + color: #f8fafc; + cursor: pointer; + font-size: 24px; + transition: + background 160ms ease, + transform 160ms ease; +} + +.gallery-controls button:hover, +.gallery-controls button:focus-visible { + background: rgba(125, 211, 252, 0.24); + outline: none; + transform: translateY(-1px); +} + +.gallery-canvas-frame { + position: relative; + min-height: 360px; +} + +.gallery-viewer-error { + display: grid; + place-items: center; + height: 100%; + min-height: 360px; + padding: 24px; + color: #fecaca; + text-align: center; +} + +.gallery-help-text { + margin: 0; + padding: 16px 22px 20px; + border-top: 1px solid rgba(226, 232, 240, 0.14); + color: #cbd5e1; + font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +@media (max-width: 900px) { + .gallery-page { + grid-template-columns: 1fr; + min-height: 100vh; + height: auto; + } + + .gallery-hero { + align-self: start; + } + + .gallery-viewer-panel { + min-height: 620px; + } +} + +@media (max-width: 560px) { + .gallery-viewer-header { + flex-direction: column; + } + + .gallery-controls { + width: 100%; + } + + .gallery-controls button { + flex: 1; + } +} + /* Docs layout */ .docs-page { display: grid; diff --git a/src/pages/galerie/page.tsx b/src/pages/galerie/page.tsx new file mode 100644 index 0000000..997ef1a --- /dev/null +++ b/src/pages/galerie/page.tsx @@ -0,0 +1,203 @@ +import { + Bounds, + Center, + OrbitControls, + useAnimations, + useGLTF, +} from "@react-three/drei"; +import { Canvas } from "@react-three/fiber"; +import { + Component, + Suspense, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import * as THREE from "three"; +import { SkyModel } from "@/components/three/world/SkyModel"; +import { + GAME_SCENE_FALLBACK_SKY_MODEL_PATH, + GAME_SCENE_FALLBACK_SKY_MODEL_SCALE, + GAME_SCENE_SKY_MODEL_PATH, + GAME_SCENE_SKY_MODEL_SCALE, +} from "@/data/world/environmentConfig"; +import { galleryModels, type GalleryModel } from "@/data/galleryModels"; + +interface GalleryModelProps { + model: GalleryModel; +} + +interface GalleryViewerErrorBoundaryProps { + children: ReactNode; + resetKey: string; +} + +interface GalleryViewerErrorBoundaryState { + hasError: boolean; +} + +class GalleryViewerErrorBoundary extends Component< + GalleryViewerErrorBoundaryProps, + GalleryViewerErrorBoundaryState +> { + constructor(props: GalleryViewerErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): GalleryViewerErrorBoundaryState { + return { hasError: true }; + } + + componentDidUpdate(previousProps: GalleryViewerErrorBoundaryProps): void { + if (previousProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + render(): ReactNode { + if (this.state.hasError) { + return ( +
+ Ce modèle ne peut pas être affiché pour le moment. +
+ ); + } + + return this.props.children; + } +} + +function GalleryModelPreview({ model }: GalleryModelProps): React.JSX.Element { + const groupRef = useRef(null); + const { animations, scene } = useGLTF(model.path); + const modelScene = useMemo(() => scene.clone(true), [scene]); + const { actions } = useAnimations(animations, groupRef); + + useEffect(() => { + const animationActions = Object.values(actions).filter( + (action): action is THREE.AnimationAction => Boolean(action), + ); + + for (const action of animationActions) { + action.reset().play(); + } + + return () => { + for (const action of animationActions) { + action.stop(); + } + }; + }, [actions]); + + return ( + + + + ); +} + +function GalleryScene({ model }: GalleryModelProps): React.JSX.Element { + return ( + <> + + + + +
+ +
+
+ + + ); +} + +export function GalleryPage(): React.JSX.Element { + const [activeModelIndex, setActiveModelIndex] = useState(0); + const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]!; + const modelCount = galleryModels.length; + + const goToPreviousModel = (): void => { + setActiveModelIndex((currentIndex) => + currentIndex === 0 ? modelCount - 1 : currentIndex - 1, + ); + }; + + const goToNextModel = (): void => { + setActiveModelIndex((currentIndex) => + currentIndex === modelCount - 1 ? 0 : currentIndex + 1, + ); + }; + + return ( +
+
+

Galerie des modèles

+

Merci aux designers de La Fabrik

+

+ Une vitrine simple pour parcourir les modèles 3D du projet dans leur + propre canvas, avec la même skybox que l'expérience principale. +

+
+ +
+
+
+

+ {activeModelIndex + 1} / {modelCount} +

+

{activeModel.name}

+ {activeModel.path} +
+
+ + +
+
+ +
+ + + + + + + +
+ +

+ Utilise les flèches pour changer de modèle. Tu peux tourner autour du + modèle avec la souris ou le doigt. +

+
+
+ ); +} diff --git a/src/router.tsx b/src/router.tsx index 97fb9da..4065a5e 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,6 +6,7 @@ import { } from "@tanstack/react-router"; import { HomePage } from "@/pages/page"; import { EditorPage } from "@/pages/editor/page"; +import { GalleryPage } from "@/pages/galerie/page"; import { DocsAnimationRoute, DocsAudioRoute, @@ -43,6 +44,12 @@ const editorRoute = createRoute({ component: EditorPage, }); +const galleryRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/galerie", + component: GalleryPage, +}); + const docsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/docs", @@ -78,6 +85,7 @@ const docsChildRoutes = [ const routeTree = rootRoute.addChildren([ indexRoute, editorRoute, + galleryRoute, docsRoute.addChildren(docsChildRoutes), ]);