8 Commits

Author SHA1 Message Date
Tom Boullay 054cb975da fix: hide gallery export planes
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-25 19:13:02 +02:00
Tom Boullay cf71148935 fix: smooth gallery preview seams
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-25 18:02:36 +02:00
Tom Boullay 1b2241df49 feat: add gallery lighting controls 2026-05-25 17:57:51 +02:00
Tom Boullay d7351e5f37 fix: render gallery skybox unlit double-sided 2026-05-25 17:53:46 +02:00
Tom Boullay 6a412c7b00 fix: stabilize gallery skybox rendering 2026-05-25 17:31:27 +02:00
Tom Boullay e9fb36f9dc style: simplify gallery UI and rename route 2026-05-25 17:13:21 +02:00
Tom Boullay 36180279b2 docs: document model gallery 2026-05-25 16:24:12 +02:00
Tom Boullay 626dc47bbe feat: add model gallery viewer 2026-05-25 16:23:36 +02:00
10 changed files with 1198 additions and 18 deletions
+9 -6
View File
@@ -25,12 +25,13 @@ The current prototype puts the player in a repair-oriented world where they prog
## Routes
| Route | Purpose |
| --------- | --------------------------------------------------- |
| `/` | Playable 3D experience |
| `/?debug` | Playable scene with debug GUI and overlays |
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
| `/docs` | In-app documentation index |
| Route | Purpose |
| ---------- | --------------------------------------------------- |
| `/` | Playable 3D experience |
| `/?debug` | Playable scene with debug GUI and overlays |
| `/editor` | Local map, dialogue, subtitle, and cinematic editor |
| `/gallery` | 3D model gallery for browsing project assets |
| `/docs` | In-app documentation index |
## Tech Stack
@@ -98,6 +99,7 @@ Useful local URLs:
```txt
http://localhost:5173/?debug
http://localhost:5173/editor
http://localhost:5173/gallery
http://localhost:5173/docs
```
@@ -148,6 +150,7 @@ WS ws://localhost:8000/ws
| `docs/user/features.md` | Implemented feature inventory |
| `docs/user/main-feature.md` | User-facing repair-game walkthrough |
| `docs/user/editor.md` | Editor user guide |
| `docs/user/gallery.md` | Model gallery user guide |
| `docs/code-review-preparation.md` | French code-review preparation support |
## Current Caveats
+46
View File
@@ -0,0 +1,46 @@
# Galerie des modèles
La galerie est disponible sur `/gallery`. Elle permet de parcourir les modèles 3D présents dans `public/models/` sans lancer la boucle de gameplay principale.
## Objectif
Cette page sert à remercier et valoriser le travail des designers du projet La Fabrik. Chaque modèle est affiché dans un canvas dédié, avec la même skybox et le même lighting que l'expérience principale.
## Utilisation
1. Ouvrir `/gallery`.
2. Utiliser les flèches en bas de l'écran pour passer au modèle précédent ou suivant.
3. Tourner autour du modèle avec la souris ou le doigt.
4. Utiliser le bouton de réglages à droite pour ouvrir ou fermer le panneau lumière.
5. Lire le diagnostic texture discret pour savoir si le modèle chargé semble correct côté textures.
## Fonctionnement
- La liste des modèles est déclarée dans `src/data/galleryModels.ts`.
- Le viewer utilise `@react-three/fiber` et `@react-three/drei`.
- `OrbitControls` permet de manipuler la caméra autour du modèle.
- `Bounds` et `Center` recadrent automatiquement le modèle actif.
- `SkyModel` réutilise la skybox du jeu, avec un matériau non éclairé uniquement dans la galerie pour éviter que certaines faces deviennent noires avec une caméra orbitale libre.
- Les lumières reprennent les valeurs par défaut du jeu, puis peuvent être ajustées dans le panneau latéral.
- `OrbitControls` autorise une orbite verticale complète pour inspecter le dessous des modèles.
- Le viewer désactive les normal maps dans la preview pour limiter les coutures visibles sur certains exports découpés en plusieurs meshes.
- Les animations GLTF présentes dans un modèle sont lancées automatiquement.
- Un diagnostic simple inspecte les matériaux chargés pour signaler les textures absentes ou non exploitables.
## Ajouter un modèle
1. Ajouter le dossier du modèle dans `public/models/{nom}`.
2. Vérifier que le modèle possède un fichier chargeable, par exemple `model.gltf`, `model.glb` ou un nom explicite comme `potager.gltf`.
3. Ajouter une entrée dans `src/data/galleryModels.ts` avec un `id`, un `name` et un `path`.
Exemple :
```ts
{ id: "nouveau-modele", name: "Nouveau modèle", path: "/models/nouveau-modele/model.gltf" }
```
## Limites connues
- Le navigateur ne liste pas automatiquement les dossiers de `public/models/`, donc la liste reste déclarative.
- Les modèles très lourds peuvent prendre du temps à charger.
- La galerie est un viewer simple : elle ne remplace pas les outils d'inspection avancée comme Blender ou le viewer d'upload.
+79 -11
View File
@@ -1,6 +1,6 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { Component, useMemo, useRef, type ReactNode } from "react";
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
@@ -8,12 +8,16 @@ interface SkyModelProps {
modelPath: string;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
materialSide?: THREE.Side | undefined;
scale?: number | undefined;
unlit?: boolean | undefined;
}
interface SkyModelContentProps {
materialSide: THREE.Side;
modelPath: string;
scale: number;
unlit: boolean;
}
interface SkyModelErrorBoundaryProps {
@@ -54,23 +58,37 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
materialSide = THREE.BackSide,
modelPath,
scale = SKY_MODEL_SCALE,
unlit = false,
}: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? (
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
<SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale}
unlit={unlit}
/>
) : null;
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
<SkyModelContent modelPath={modelPath} scale={scale} />
<SkyModelContent
materialSide={materialSide}
modelPath={modelPath}
scale={scale}
unlit={unlit}
/>
</SkyModelErrorBoundary>
);
}
function SkyModelContent({
materialSide,
modelPath,
scale,
unlit,
}: SkyModelContentProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
@@ -78,7 +96,16 @@ function SkyModelContent({
scope: "SkyModel",
scale,
});
const model = useMemo(() => createSkyModel(scene), [scene]);
const model = useMemo(
() => createSkyModel(scene, materialSide, unlit),
[materialSide, scene, unlit],
);
useEffect(() => {
return () => {
disposeSkyModelMaterials(model);
};
}, [model]);
useFrame(() => {
groupRef.current?.position.copy(camera.position);
@@ -96,7 +123,11 @@ function SkyModelContent({
);
}
function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
function createSkyModel(
scene: THREE.Object3D,
materialSide: THREE.Side,
unlit: boolean,
): THREE.Object3D {
const model = scene.clone(true);
model.traverse((object) => {
@@ -106,20 +137,57 @@ function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
if (!(object instanceof THREE.Mesh)) return;
object.material = Array.isArray(object.material)
? object.material.map(createSkyMaterial)
: createSkyMaterial(object.material);
? object.material.map((material) =>
createSkyMaterial(material, materialSide, unlit),
)
: createSkyMaterial(object.material, materialSide, unlit);
});
return model;
}
function createSkyMaterial<T extends THREE.Material>(material: T): T {
const skyMaterial = material.clone();
skyMaterial.side = THREE.BackSide;
function createSkyMaterial<T extends THREE.Material>(
material: T,
materialSide: THREE.Side,
unlit: boolean,
): THREE.Material {
const skyMaterial = unlit
? createUnlitSkyMaterial(material)
: material.clone();
skyMaterial.side = materialSide;
skyMaterial.depthTest = false;
skyMaterial.depthWrite = false;
return skyMaterial as T;
return skyMaterial;
}
function createUnlitSkyMaterial(
material: THREE.Material,
): THREE.MeshBasicMaterial {
const sourceMaterial = material as THREE.MeshStandardMaterial;
return new THREE.MeshBasicMaterial({
color: sourceMaterial.color?.clone() ?? new THREE.Color("#ffffff"),
map: sourceMaterial.map ?? null,
opacity: sourceMaterial.opacity,
toneMapped: false,
transparent: sourceMaterial.transparent,
});
}
function disposeSkyModelMaterials(model: THREE.Object3D): void {
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
if (Array.isArray(object.material)) {
for (const material of object.material) {
material.dispose();
}
return;
}
object.material.dispose();
});
}
useGLTF.preload("/models/skybox/skybox.gltf");
+7 -1
View File
@@ -109,6 +109,12 @@ export const docGroups: DocGroup[] = [
subtitle: "Components and usage",
meta: "15",
},
{
path: "/docs/gallery",
title: "Model Gallery",
subtitle: "Browsing 3D assets",
meta: "16",
},
],
},
{
@@ -118,7 +124,7 @@ export const docGroups: DocGroup[] = [
path: "/docs/code-review",
title: "Code Review Prep",
subtitle: "Presentation support",
meta: "16",
meta: "17",
},
],
},
+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" },
];
+271
View File
@@ -30,6 +30,277 @@ canvas {
display: block;
}
/* Model gallery */
.gallery-page {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #050505;
color: #f4efe7;
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
}
.gallery-title {
position: absolute;
top: clamp(18px, 3vw, 34px);
right: clamp(18px, 3vw, 38px);
z-index: 2;
margin: 0;
color: #f4efe7;
font-size: clamp(18px, 2vw, 26px);
font-weight: 700;
letter-spacing: 0.32em;
line-height: 1;
}
.gallery-canvas-frame {
position: relative;
width: 100%;
height: 100%;
}
.gallery-viewer-error {
display: grid;
place-items: center;
height: 100%;
min-height: 360px;
padding: 24px;
color: #fecaca;
text-align: center;
}
.gallery-bottom-bar {
position: absolute;
right: 50%;
bottom: clamp(18px, 4vw, 44px);
z-index: 2;
display: grid;
grid-template-columns: 54px minmax(190px, 340px) 54px;
align-items: center;
overflow: hidden;
border: 2px solid #d8d0c4;
border-radius: 0;
background: #050505;
box-shadow: none;
transform: translateX(50%);
}
.gallery-bottom-bar button {
display: grid;
place-items: center;
width: 54px;
height: 54px;
border: 0;
background: transparent;
color: #f4efe7;
cursor: pointer;
transition:
background 160ms ease,
color 160ms ease;
}
.gallery-bottom-bar button:hover,
.gallery-bottom-bar button:focus-visible {
background: #f4efe7;
color: #050505;
outline: none;
}
.gallery-model-info {
display: grid;
place-items: center;
min-height: 54px;
padding: 0 20px;
border-right: 2px solid #d8d0c4;
border-left: 2px solid #d8d0c4;
text-align: center;
}
.gallery-model-info span {
max-width: 100%;
overflow: hidden;
color: #f4efe7;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.03em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.gallery-model-info small {
margin-top: 2px;
color: #a9a196;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 11px;
font-weight: 600;
}
.gallery-texture-status {
position: absolute;
left: clamp(18px, 3vw, 38px);
bottom: clamp(22px, 4vw, 50px);
z-index: 2;
display: inline-flex;
align-items: center;
gap: 8px;
max-width: min(320px, calc(100vw - 36px));
padding: 10px 13px;
border: 2px solid #d8d0c4;
border-radius: 0;
background: #050505;
color: #d8d0c4;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12px;
font-weight: 700;
}
.gallery-texture-status--ok {
color: #d8d0c4;
}
.gallery-texture-status--warning {
color: #f4efe7;
}
.gallery-texture-status--loading {
color: #a9a196;
}
.gallery-light-panel {
position: absolute;
top: 108px;
right: 0;
z-index: 3;
display: flex;
align-items: flex-start;
transform: translateX(260px);
transition: transform 180ms ease;
}
.gallery-light-panel.is-open {
transform: translateX(0);
}
.gallery-light-panel-toggle {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 2px solid #d8d0c4;
border-right: 0;
border-radius: 0;
background: #050505;
color: #f4efe7;
cursor: pointer;
}
.gallery-light-panel-toggle:hover,
.gallery-light-panel-toggle:focus-visible {
background: #f4efe7;
color: #050505;
outline: none;
}
.gallery-light-panel-content {
width: 236px;
padding: 16px;
border: 2px solid #d8d0c4;
border-right: 0;
border-radius: 0;
background: #050505;
box-shadow: none;
}
.gallery-light-panel-content header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.gallery-light-panel-content header span {
color: #f4efe7;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
}
.gallery-light-panel-content header button {
border: 0;
background: transparent;
color: #a9a196;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.gallery-light-panel-content header button:hover,
.gallery-light-panel-content header button:focus-visible {
color: #f4efe7;
outline: none;
}
.gallery-light-control {
display: grid;
gap: 8px;
margin-top: 12px;
}
.gallery-light-control span {
display: flex;
align-items: center;
justify-content: space-between;
color: #d8d0c4;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12px;
font-weight: 700;
}
.gallery-light-control strong {
color: #f4efe7;
font-variant-numeric: tabular-nums;
}
.gallery-light-control input {
width: 100%;
accent-color: #f4efe7;
}
@media (max-width: 720px) {
.gallery-title {
right: 50%;
transform: translateX(50%);
}
.gallery-bottom-bar {
grid-template-columns: 48px minmax(150px, 1fr) 48px;
width: calc(100vw - 36px);
}
.gallery-bottom-bar button,
.gallery-model-info {
min-height: 50px;
}
.gallery-bottom-bar button {
width: 48px;
height: 50px;
}
.gallery-texture-status {
right: 50%;
bottom: calc(clamp(18px, 4vw, 44px) + 66px);
left: auto;
transform: translateX(50%);
}
.gallery-light-panel {
top: 78px;
}
}
/* Docs layout */
.docs-page {
display: grid;
+13
View File
@@ -0,0 +1,13 @@
import gallery from "../../../../docs/user/gallery.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsGalleryPage(): React.JSX.Element {
return (
<DocsDocument
content={gallery}
frContent={gallery}
meta="16"
title="Model Gallery"
/>
);
}
+549
View File
@@ -0,0 +1,549 @@
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 {
ArrowLeft,
ArrowRight,
CheckCircle2,
SlidersHorizontal,
TriangleAlert,
} from "lucide-react";
import * as THREE from "three";
import { SkyModel } from "@/components/three/world/SkyModel";
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
import {
AMBIENT_LIGHT_COLOR,
LIGHTING_DEFAULTS,
SUN_LIGHT_COLOR,
} from "@/data/world/lightingConfig";
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";
interface GalleryModelProps {
model: GalleryModel;
}
interface GallerySceneProps extends GalleryModelProps {
lighting: GalleryLightingConfig;
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
}
interface GalleryModelPreviewProps extends GalleryModelProps {
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
}
interface GalleryLightingConfig {
ambientIntensity: number;
sunIntensity: number;
sunX: number;
sunY: number;
sunZ: number;
}
interface GalleryLightControl {
key: keyof GalleryLightingConfig;
label: string;
min: number;
max: number;
step: number;
}
interface TextureDiagnostic {
modelId: string | null;
status: "loading" | "ok" | "warning";
summary: string;
}
interface GalleryModelScene extends THREE.Object3D {
userData: THREE.Object3D["userData"] & {
hiddenExportPlaneCount?: number;
};
}
interface GalleryViewerErrorBoundaryProps {
children: ReactNode;
resetKey: string;
}
interface GalleryViewerErrorBoundaryState {
hasError: boolean;
}
const TEXTURE_SLOTS = [
"map",
"normalMap",
"roughnessMap",
"metalnessMap",
"aoMap",
"emissiveMap",
"alphaMap",
] as const;
const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
modelId: null,
status: "loading",
summary: "Analyse des textures...",
};
const GALLERY_LIGHT_CONTROLS: GalleryLightControl[] = [
{ key: "ambientIntensity", label: "Ambiance", min: 0, max: 5, step: 0.1 },
{ key: "sunIntensity", label: "Soleil", min: 0, max: 8, step: 0.1 },
{ key: "sunX", label: "Soleil X", min: -100, max: 100, step: 1 },
{ key: "sunY", label: "Soleil Y", min: -100, max: 150, step: 1 },
{ key: "sunZ", label: "Soleil Z", min: -100, max: 100, step: 1 },
];
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,
onTextureDiagnosticReady,
}: GalleryModelPreviewProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { animations, scene } = useGLTF(model.path);
const modelScene = useMemo(() => createGalleryModelScene(scene), [scene]);
const { actions } = useAnimations(animations, groupRef);
useEffect(() => {
return () => {
disposeGalleryModelMaterials(modelScene);
};
}, [modelScene]);
useEffect(() => {
onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene));
}, [model.id, modelScene, onTextureDiagnosticReady]);
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 createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D {
const modelScene = scene.clone(true) as GalleryModelScene;
const exportPlaneMeshes: THREE.Mesh[] = [];
modelScene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
if (isExportPlaneMesh(object)) {
exportPlaneMeshes.push(object);
return;
}
object.material = Array.isArray(object.material)
? object.material.map(createGalleryMaterial)
: createGalleryMaterial(object.material);
});
for (const mesh of exportPlaneMeshes) {
mesh.parent?.remove(mesh);
}
modelScene.userData.hiddenExportPlaneCount = exportPlaneMeshes.length;
return modelScene;
}
function isExportPlaneMesh(mesh: THREE.Mesh): boolean {
const name = mesh.name.toLowerCase();
if (name !== "plan" && name !== "plane") return false;
mesh.geometry.computeBoundingBox();
const boundingBox = mesh.geometry.boundingBox;
if (!boundingBox) return false;
const size = new THREE.Vector3();
boundingBox.getSize(size);
const dimensions = [size.x, size.y, size.z];
const flatDimensions = dimensions.filter((dimension) => dimension <= 0.001);
const largestDimension = Math.max(...dimensions);
return flatDimensions.length > 0 && largestDimension > 1;
}
function createGalleryMaterial(material: THREE.Material): THREE.Material {
const galleryMaterial = material.clone();
const materialWithNormalMap = galleryMaterial as THREE.Material & {
normalMap?: THREE.Texture | null;
};
galleryMaterial.side = THREE.DoubleSide;
if (materialWithNormalMap.normalMap) {
materialWithNormalMap.normalMap = null;
galleryMaterial.needsUpdate = true;
}
return galleryMaterial;
}
function disposeGalleryModelMaterials(modelScene: THREE.Object3D): void {
modelScene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
if (Array.isArray(object.material)) {
for (const material of object.material) {
material.dispose();
}
return;
}
object.material.dispose();
});
}
function GalleryScene({
lighting,
model,
onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element {
return (
<>
<SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
materialSide={THREE.DoubleSide}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
unlit
/>
<GalleryLighting lighting={lighting} />
<Bounds fit clip observe margin={1.35}>
<Center>
<GalleryModelPreview
model={model}
onTextureDiagnosticReady={onTextureDiagnosticReady}
/>
</Center>
</Bounds>
<OrbitControls
makeDefault
enableDamping
autoRotate
autoRotateSpeed={0.5}
minPolarAngle={0}
maxPolarAngle={Math.PI}
/>
</>
);
}
function GalleryLighting({
lighting,
}: {
lighting: GalleryLightingConfig;
}): React.JSX.Element {
return (
<>
<ambientLight
intensity={lighting.ambientIntensity}
color={AMBIENT_LIGHT_COLOR}
/>
<directionalLight
position={[lighting.sunX, lighting.sunY, lighting.sunZ]}
intensity={lighting.sunIntensity}
color={SUN_LIGHT_COLOR}
/>
</>
);
}
function TextureStatusBadge({
diagnostic,
}: {
diagnostic: TextureDiagnostic;
}): React.JSX.Element {
const hasWarning = diagnostic.status === "warning";
const Icon = hasWarning ? TriangleAlert : CheckCircle2;
return (
<div
className={`gallery-texture-status gallery-texture-status--${diagnostic.status}`}
>
<Icon aria-hidden="true" size={15} strokeWidth={2.1} />
<span>{diagnostic.summary}</span>
</div>
);
}
function GalleryLightingPanel({
lighting,
onChange,
onReset,
onToggle,
open,
}: {
lighting: GalleryLightingConfig;
onChange: (key: keyof GalleryLightingConfig, value: number) => void;
onReset: () => void;
onToggle: () => void;
open: boolean;
}): React.JSX.Element {
return (
<aside className={`gallery-light-panel ${open ? "is-open" : ""}`}>
<button
type="button"
className="gallery-light-panel-toggle"
onClick={onToggle}
aria-expanded={open}
aria-label={
open ? "Fermer les réglages lumière" : "Ouvrir les réglages lumière"
}
>
<SlidersHorizontal aria-hidden="true" size={18} strokeWidth={1.8} />
</button>
<div className="gallery-light-panel-content" aria-hidden={!open}>
<header>
<span>LIGHTS</span>
<button type="button" onClick={onReset}>
Reset
</button>
</header>
{GALLERY_LIGHT_CONTROLS.map((control) => (
<label key={control.key} className="gallery-light-control">
<span>
{control.label}
<strong>{lighting[control.key].toFixed(1)}</strong>
</span>
<input
type="range"
min={control.min}
max={control.max}
step={control.step}
value={lighting[control.key]}
onChange={(event) =>
onChange(control.key, Number(event.currentTarget.value))
}
/>
</label>
))}
</div>
</aside>
);
}
function getTextureDiagnostic(
modelId: string,
modelScene: THREE.Object3D,
): TextureDiagnostic {
let textureCount = 0;
let missingTextureImageCount = 0;
const hiddenExportPlaneCount =
(modelScene as GalleryModelScene).userData.hiddenExportPlaneCount ?? 0;
modelScene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
const materials = Array.isArray(object.material)
? object.material
: [object.material];
for (const material of materials) {
const materialRecord = material as unknown as Record<string, unknown>;
for (const textureSlot of TEXTURE_SLOTS) {
const texture = materialRecord[textureSlot];
if (!(texture instanceof THREE.Texture)) continue;
textureCount += 1;
if (!texture.image) {
missingTextureImageCount += 1;
}
}
}
});
if (missingTextureImageCount > 0) {
return {
modelId,
status: "warning",
summary: `${missingTextureImageCount} texture(s) à vérifier`,
};
}
if (hiddenExportPlaneCount > 0) {
return {
modelId,
status: "warning",
summary: `${hiddenExportPlaneCount} plan(s) d'export masqué(s)`,
};
}
if (textureCount === 0) {
return {
modelId,
status: "warning",
summary: "Aucune texture détectée",
};
}
return {
modelId,
status: "ok",
summary: `${textureCount} texture(s) OK`,
};
}
export function GalleryPage(): React.JSX.Element {
const [activeModelIndex, setActiveModelIndex] = useState(0);
const [lightPanelOpen, setLightPanelOpen] = useState(false);
const [lighting, setLighting] = useState<GalleryLightingConfig>({
...LIGHTING_DEFAULTS,
});
const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>(
LOADING_TEXTURE_DIAGNOSTIC,
);
const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]!;
const modelCount = galleryModels.length;
const activeTextureDiagnostic =
textureDiagnostic.modelId === activeModel.id
? textureDiagnostic
: LOADING_TEXTURE_DIAGNOSTIC;
const goToPreviousModel = (): void => {
setActiveModelIndex((currentIndex) =>
currentIndex === 0 ? modelCount - 1 : currentIndex - 1,
);
};
const goToNextModel = (): void => {
setActiveModelIndex((currentIndex) =>
currentIndex === modelCount - 1 ? 0 : currentIndex + 1,
);
};
const handleLightChange = (
key: keyof GalleryLightingConfig,
value: number,
): void => {
setLighting((currentLighting) => ({
...currentLighting,
[key]: value,
}));
};
const resetLighting = (): void => {
setLighting({ ...LIGHTING_DEFAULTS });
};
return (
<main className="gallery-page">
<h1 className="gallery-title">GALERIE</h1>
<div className="gallery-canvas-frame" aria-label="Viewer 3D">
<GalleryViewerErrorBoundary resetKey={activeModel.id}>
<Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}>
<Suspense fallback={null}>
<GalleryScene
lighting={lighting}
model={activeModel}
onTextureDiagnosticReady={setTextureDiagnostic}
/>
</Suspense>
</Canvas>
</GalleryViewerErrorBoundary>
</div>
<nav className="gallery-bottom-bar" aria-label="Navigation des modèles">
<button
type="button"
onClick={goToPreviousModel}
aria-label="Modèle précédent"
>
<ArrowLeft aria-hidden="true" size={22} strokeWidth={1.8} />
</button>
<div className="gallery-model-info">
<span>{activeModel.name}</span>
<small>
{activeModelIndex + 1} / {modelCount}
</small>
</div>
<button
type="button"
onClick={goToNextModel}
aria-label="Modèle suivant"
>
<ArrowRight aria-hidden="true" size={22} strokeWidth={1.8} />
</button>
</nav>
<TextureStatusBadge diagnostic={activeTextureDiagnostic} />
<GalleryLightingPanel
lighting={lighting}
onChange={handleLightChange}
onReset={resetLighting}
onToggle={() => setLightPanelOpen((open) => !open)}
open={lightPanelOpen}
/>
</main>
);
}
+10
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/gallery/page";
import {
DocsAnimationRoute,
DocsAudioRoute,
@@ -13,6 +14,7 @@ import {
DocsCodeReviewRoute,
DocsEditorRoute,
DocsFeaturesRoute,
DocsGalleryRoute,
DocsHandTrackingRoute,
DocsInteractionRoute,
DocsLayoutRoute,
@@ -43,6 +45,12 @@ const editorRoute = createRoute({
component: EditorPage,
});
const galleryRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/gallery",
component: GalleryPage,
});
const docsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/docs",
@@ -66,6 +74,7 @@ const docsChildRoutes = [
{ path: "main-feature", component: DocsMainFeatureRoute },
{ path: "editor", component: DocsEditorRoute },
{ path: "animation", component: DocsAnimationRoute },
{ path: "gallery", component: DocsGalleryRoute },
{ path: "code-review", component: DocsCodeReviewRoute },
].map(({ path, component }) =>
createRoute({
@@ -78,6 +87,7 @@ const docsChildRoutes = [
const routeTree = rootRoute.addChildren([
indexRoute,
editorRoute,
galleryRoute,
docsRoute.addChildren(docsChildRoutes),
]);
+5
View File
@@ -87,6 +87,10 @@ const LazyDocsAnimationPage = lazyNamed(
() => import("@/pages/docs/animation/page"),
"DocsAnimationPage",
);
const LazyDocsGalleryPage = lazyNamed(
() => import("@/pages/docs/gallery/page"),
"DocsGalleryPage",
);
const LazyDocsCodeReviewPage = lazyNamed(
() => import("@/pages/docs/code-review/page"),
"DocsCodeReviewPage",
@@ -119,6 +123,7 @@ export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage);
export const DocsGalleryRoute = createDocsRoute(LazyDocsGalleryPage);
export const DocsCodeReviewRoute = createDocsRoute(LazyDocsCodeReviewPage);
export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage);
export const DocsThreeDebuggingRoute = createDocsRoute(