Feat/gallery #9

Merged
math-pixel merged 11 commits from feat/galerie into develop 2026-05-29 07:00:37 +00:00
4 changed files with 594 additions and 0 deletions
Showing only changes of commit 626dc47bbe - Show all commits
+209
View File
@@ -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" },
];
+174
View File
@@ -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;
+203
View File
@@ -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 (
<div className="gallery-viewer-error" role="status">
Ce modèle ne peut pas être affiché pour le moment.
</div>
);
}
return this.props.children;
}
}
function GalleryModelPreview({ model }: GalleryModelProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(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 (
<group ref={groupRef}>
<primitive object={modelScene} />
</group>
);
}
function GalleryScene({ model }: GalleryModelProps): React.JSX.Element {
return (
<>
<SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
/>
<ambientLight intensity={0.75} />
<directionalLight position={[6, 8, 4]} intensity={2.1} />
<Bounds fit clip observe margin={1.35}>
<Center>
<GalleryModelPreview model={model} />
</Center>
</Bounds>
<OrbitControls
makeDefault
enableDamping
autoRotate
autoRotateSpeed={0.5}
/>
</>
);
}
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 (
<main className="gallery-page">
<section className="gallery-hero" aria-labelledby="gallery-title">
<p className="gallery-eyebrow">Galerie des modèles</p>
<h1 id="gallery-title">Merci aux designers de La Fabrik</h1>
<p>
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.
</p>
</section>
<section className="gallery-viewer-panel" aria-label="Viewer 3D">
<div className="gallery-viewer-header">
<div>
<p className="gallery-model-count">
{activeModelIndex + 1} / {modelCount}
</p>
<h2>{activeModel.name}</h2>
<code>{activeModel.path}</code>
</div>
<div className="gallery-controls" aria-label="Navigation des modèles">
<button
type="button"
onClick={goToPreviousModel}
aria-label="Modèle précédent"
>
</button>
<button
type="button"
onClick={goToNextModel}
aria-label="Modèle suivant"
>
</button>
</div>
</div>
<div className="gallery-canvas-frame">
<GalleryViewerErrorBoundary resetKey={activeModel.id}>
<Canvas
camera={{ position: [3.5, 2.4, 4.5], fov: 45 }}
dpr={[1, 2]}
>
<Suspense fallback={null}>
<GalleryScene key={activeModel.id} model={activeModel} />
</Suspense>
</Canvas>
</GalleryViewerErrorBoundary>
</div>
<p className="gallery-help-text">
Utilise les flèches pour changer de modèle. Tu peux tourner autour du
modèle avec la souris ou le doigt.
</p>
</section>
</main>
);
}
+8
View File
@@ -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),
]);