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),
]);