Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a412c7b00 | |||
| e9fb36f9dc | |||
| 36180279b2 | |||
| 626dc47bbe |
@@ -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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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 que l'expérience principale pour garder une ambiance visuelle cohérente.
|
||||
|
||||
## 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. 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.
|
||||
- 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.
|
||||
@@ -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";
|
||||
|
||||
@@ -80,6 +80,12 @@ function SkyModelContent({
|
||||
});
|
||||
const model = useMemo(() => createSkyModel(scene), [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disposeSkyModelMaterials(model);
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
useFrame(() => {
|
||||
groupRef.current?.position.copy(camera.position);
|
||||
});
|
||||
@@ -122,5 +128,20 @@ function createSkyMaterial<T extends THREE.Material>(material: T): T {
|
||||
return skyMaterial as T;
|
||||
}
|
||||
|
||||
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");
|
||||
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
+168
@@ -30,6 +30,174 @@ canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Model gallery */
|
||||
.gallery-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #05070c;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
position: absolute;
|
||||
top: clamp(18px, 3vw, 34px);
|
||||
right: clamp(18px, 3vw, 38px);
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
color: rgba(248, 250, 252, 0.92);
|
||||
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: 1px solid rgba(248, 250, 252, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(3, 7, 18, 0.72);
|
||||
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.32);
|
||||
transform: translateX(50%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(248, 250, 252, 0.82);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button:hover,
|
||||
.gallery-bottom-bar button:focus-visible {
|
||||
background: rgba(248, 250, 252, 0.1);
|
||||
color: #f8fafc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gallery-model-info {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 54px;
|
||||
padding: 0 20px;
|
||||
border-right: 1px solid rgba(248, 250, 252, 0.14);
|
||||
border-left: 1px solid rgba(248, 250, 252, 0.14);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gallery-model-info span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
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: rgba(203, 213, 225, 0.62);
|
||||
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: 1px solid rgba(248, 250, 252, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(3, 7, 18, 0.58);
|
||||
color: rgba(226, 232, 240, 0.86);
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.gallery-texture-status--ok {
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.gallery-texture-status--warning {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.gallery-texture-status--loading {
|
||||
color: rgba(226, 232, 240, 0.72);
|
||||
}
|
||||
|
||||
@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%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Docs layout */
|
||||
.docs-page {
|
||||
display: grid;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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,
|
||||
TriangleAlert,
|
||||
} from "lucide-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 GallerySceneProps extends GalleryModelProps {
|
||||
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
|
||||
}
|
||||
|
||||
interface TextureDiagnostic {
|
||||
modelId: string | null;
|
||||
status: "loading" | "ok" | "warning";
|
||||
summary: string;
|
||||
}
|
||||
|
||||
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...",
|
||||
};
|
||||
|
||||
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,
|
||||
}: GallerySceneProps): 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(() => {
|
||||
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 GalleryScene({
|
||||
model,
|
||||
onTextureDiagnosticReady,
|
||||
}: GallerySceneProps): 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}
|
||||
onTextureDiagnosticReady={onTextureDiagnosticReady}
|
||||
/>
|
||||
</Center>
|
||||
</Bounds>
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
autoRotate
|
||||
autoRotateSpeed={0.5}
|
||||
minPolarAngle={Math.PI * 0.18}
|
||||
maxPolarAngle={Math.PI * 0.48}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 getTextureDiagnostic(
|
||||
modelId: string,
|
||||
modelScene: THREE.Object3D,
|
||||
): TextureDiagnostic {
|
||||
let textureCount = 0;
|
||||
let missingTextureImageCount = 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 (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 [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,
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
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} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user