Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 054cb975da | |||
| cf71148935 | |||
| 1b2241df49 | |||
| d7351e5f37 | |||
| 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,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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
+271
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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