3 Commits

Author SHA1 Message Date
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
4 changed files with 387 additions and 43 deletions
+7 -3
View File
@@ -4,14 +4,15 @@ La galerie est disponible sur `/gallery`. Elle permet de parcourir les modèles
## Objectif ## 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. 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 ## Utilisation
1. Ouvrir `/gallery`. 1. Ouvrir `/gallery`.
2. Utiliser les flèches en bas de l'écran pour passer au modèle précédent ou suivant. 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. 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. 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 ## Fonctionnement
@@ -19,7 +20,10 @@ Cette page sert à remercier et valoriser le travail des designers du projet La
- Le viewer utilise `@react-three/fiber` et `@react-three/drei`. - Le viewer utilise `@react-three/fiber` et `@react-three/drei`.
- `OrbitControls` permet de manipuler la caméra autour du modèle. - `OrbitControls` permet de manipuler la caméra autour du modèle.
- `Bounds` et `Center` recadrent automatiquement le modèle actif. - `Bounds` et `Center` recadrent automatiquement le modèle actif.
- `SkyModel` réutilise la skybox du jeu. - `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. - 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. - Un diagnostic simple inspecte les matériaux chargés pour signaler les textures absentes ou non exploitables.
+57 -10
View File
@@ -8,12 +8,16 @@ interface SkyModelProps {
modelPath: string; modelPath: string;
fallbackModelPath?: string | undefined; fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined; fallbackScale?: number | undefined;
materialSide?: THREE.Side | undefined;
scale?: number | undefined; scale?: number | undefined;
unlit?: boolean | undefined;
} }
interface SkyModelContentProps { interface SkyModelContentProps {
materialSide: THREE.Side;
modelPath: string; modelPath: string;
scale: number; scale: number;
unlit: boolean;
} }
interface SkyModelErrorBoundaryProps { interface SkyModelErrorBoundaryProps {
@@ -54,23 +58,37 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({ export function SkyModel({
fallbackModelPath, fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE, fallbackScale = SKY_MODEL_SCALE,
materialSide = THREE.BackSide,
modelPath, modelPath,
scale = SKY_MODEL_SCALE, scale = SKY_MODEL_SCALE,
unlit = false,
}: SkyModelProps): React.JSX.Element { }: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? ( const fallback = fallbackModelPath ? (
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} /> <SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale}
unlit={unlit}
/>
) : null; ) : null;
return ( return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}> <SkyModelErrorBoundary key={modelPath} fallback={fallback}>
<SkyModelContent modelPath={modelPath} scale={scale} /> <SkyModelContent
materialSide={materialSide}
modelPath={modelPath}
scale={scale}
unlit={unlit}
/>
</SkyModelErrorBoundary> </SkyModelErrorBoundary>
); );
} }
function SkyModelContent({ function SkyModelContent({
materialSide,
modelPath, modelPath,
scale, scale,
unlit,
}: SkyModelContentProps): React.JSX.Element { }: SkyModelContentProps): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
@@ -78,7 +96,10 @@ function SkyModelContent({
scope: "SkyModel", scope: "SkyModel",
scale, scale,
}); });
const model = useMemo(() => createSkyModel(scene), [scene]); const model = useMemo(
() => createSkyModel(scene, materialSide, unlit),
[materialSide, scene, unlit],
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -102,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); const model = scene.clone(true);
model.traverse((object) => { model.traverse((object) => {
@@ -112,20 +137,42 @@ function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
if (!(object instanceof THREE.Mesh)) return; if (!(object instanceof THREE.Mesh)) return;
object.material = Array.isArray(object.material) object.material = Array.isArray(object.material)
? object.material.map(createSkyMaterial) ? object.material.map((material) =>
: createSkyMaterial(object.material); createSkyMaterial(material, materialSide, unlit),
)
: createSkyMaterial(object.material, materialSide, unlit);
}); });
return model; return model;
} }
function createSkyMaterial<T extends THREE.Material>(material: T): T { function createSkyMaterial<T extends THREE.Material>(
const skyMaterial = material.clone(); material: T,
skyMaterial.side = THREE.BackSide; materialSide: THREE.Side,
unlit: boolean,
): THREE.Material {
const skyMaterial = unlit
? createUnlitSkyMaterial(material)
: material.clone();
skyMaterial.side = materialSide;
skyMaterial.depthTest = false; skyMaterial.depthTest = false;
skyMaterial.depthWrite = 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 { function disposeSkyModelMaterials(model: THREE.Object3D): void {
+126 -23
View File
@@ -36,8 +36,9 @@ canvas {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: #05070c; background: #050505;
color: #f8fafc; color: #f4efe7;
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
} }
.gallery-title { .gallery-title {
@@ -46,7 +47,7 @@ canvas {
right: clamp(18px, 3vw, 38px); right: clamp(18px, 3vw, 38px);
z-index: 2; z-index: 2;
margin: 0; margin: 0;
color: rgba(248, 250, 252, 0.92); color: #f4efe7;
font-size: clamp(18px, 2vw, 26px); font-size: clamp(18px, 2vw, 26px);
font-weight: 700; font-weight: 700;
letter-spacing: 0.32em; letter-spacing: 0.32em;
@@ -78,12 +79,11 @@ canvas {
grid-template-columns: 54px minmax(190px, 340px) 54px; grid-template-columns: 54px minmax(190px, 340px) 54px;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(248, 250, 252, 0.18); border: 2px solid #d8d0c4;
border-radius: 999px; border-radius: 0;
background: rgba(3, 7, 18, 0.72); background: #050505;
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.32); box-shadow: none;
transform: translateX(50%); transform: translateX(50%);
backdrop-filter: blur(18px);
} }
.gallery-bottom-bar button { .gallery-bottom-bar button {
@@ -93,7 +93,7 @@ canvas {
height: 54px; height: 54px;
border: 0; border: 0;
background: transparent; background: transparent;
color: rgba(248, 250, 252, 0.82); color: #f4efe7;
cursor: pointer; cursor: pointer;
transition: transition:
background 160ms ease, background 160ms ease,
@@ -102,8 +102,8 @@ canvas {
.gallery-bottom-bar button:hover, .gallery-bottom-bar button:hover,
.gallery-bottom-bar button:focus-visible { .gallery-bottom-bar button:focus-visible {
background: rgba(248, 250, 252, 0.1); background: #f4efe7;
color: #f8fafc; color: #050505;
outline: none; outline: none;
} }
@@ -112,15 +112,15 @@ canvas {
place-items: center; place-items: center;
min-height: 54px; min-height: 54px;
padding: 0 20px; padding: 0 20px;
border-right: 1px solid rgba(248, 250, 252, 0.14); border-right: 2px solid #d8d0c4;
border-left: 1px solid rgba(248, 250, 252, 0.14); border-left: 2px solid #d8d0c4;
text-align: center; text-align: center;
} }
.gallery-model-info span { .gallery-model-info span {
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
color: #f8fafc; color: #f4efe7;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.03em; letter-spacing: 0.03em;
@@ -131,7 +131,7 @@ canvas {
.gallery-model-info small { .gallery-model-info small {
margin-top: 2px; margin-top: 2px;
color: rgba(203, 213, 225, 0.62); color: #a9a196;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -147,26 +147,125 @@ canvas {
gap: 8px; gap: 8px;
max-width: min(320px, calc(100vw - 36px)); max-width: min(320px, calc(100vw - 36px));
padding: 10px 13px; padding: 10px 13px;
border: 1px solid rgba(248, 250, 252, 0.14); border: 2px solid #d8d0c4;
border-radius: 999px; border-radius: 0;
background: rgba(3, 7, 18, 0.58); background: #050505;
color: rgba(226, 232, 240, 0.86); color: #d8d0c4;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
backdrop-filter: blur(16px);
} }
.gallery-texture-status--ok { .gallery-texture-status--ok {
color: #bbf7d0; color: #d8d0c4;
} }
.gallery-texture-status--warning { .gallery-texture-status--warning {
color: #fde68a; color: #f4efe7;
} }
.gallery-texture-status--loading { .gallery-texture-status--loading {
color: rgba(226, 232, 240, 0.72); 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) { @media (max-width: 720px) {
@@ -196,6 +295,10 @@ canvas {
left: auto; left: auto;
transform: translateX(50%); transform: translateX(50%);
} }
.gallery-light-panel {
top: 78px;
}
} }
/* Docs layout */ /* Docs layout */
+197 -7
View File
@@ -19,26 +19,53 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
SlidersHorizontal,
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import * as THREE from "three"; import * as THREE from "three";
import { SkyModel } from "@/components/three/world/SkyModel"; 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 { import {
GAME_SCENE_FALLBACK_SKY_MODEL_PATH, GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE, GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH, GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE, GAME_SCENE_SKY_MODEL_SCALE,
} from "@/data/world/environmentConfig"; } from "@/data/world/environmentConfig";
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
interface GalleryModelProps { interface GalleryModelProps {
model: GalleryModel; model: GalleryModel;
} }
interface GallerySceneProps extends GalleryModelProps { interface GallerySceneProps extends GalleryModelProps {
lighting: GalleryLightingConfig;
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void; 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 { interface TextureDiagnostic {
modelId: string | null; modelId: string | null;
status: "loading" | "ok" | "warning"; status: "loading" | "ok" | "warning";
@@ -70,6 +97,14 @@ const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
summary: "Analyse des textures...", 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< class GalleryViewerErrorBoundary extends Component<
GalleryViewerErrorBoundaryProps, GalleryViewerErrorBoundaryProps,
GalleryViewerErrorBoundaryState GalleryViewerErrorBoundaryState
@@ -105,12 +140,18 @@ class GalleryViewerErrorBoundary extends Component<
function GalleryModelPreview({ function GalleryModelPreview({
model, model,
onTextureDiagnosticReady, onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element { }: GalleryModelPreviewProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const { animations, scene } = useGLTF(model.path); const { animations, scene } = useGLTF(model.path);
const modelScene = useMemo(() => scene.clone(true), [scene]); const modelScene = useMemo(() => createGalleryModelScene(scene), [scene]);
const { actions } = useAnimations(animations, groupRef); const { actions } = useAnimations(animations, groupRef);
useEffect(() => {
return () => {
disposeGalleryModelMaterials(modelScene);
};
}, [modelScene]);
useEffect(() => { useEffect(() => {
onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene)); onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene));
}, [model.id, modelScene, onTextureDiagnosticReady]); }, [model.id, modelScene, onTextureDiagnosticReady]);
@@ -138,7 +179,53 @@ function GalleryModelPreview({
); );
} }
function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D {
const modelScene = scene.clone(true);
modelScene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.material = Array.isArray(object.material)
? object.material.map(createGalleryMaterial)
: createGalleryMaterial(object.material);
});
return modelScene;
}
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({ function GalleryScene({
lighting,
model, model,
onTextureDiagnosticReady, onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element { }: GallerySceneProps): React.JSX.Element {
@@ -147,11 +234,12 @@ function GalleryScene({
<SkyModel <SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH} fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE} fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
materialSide={THREE.DoubleSide}
modelPath={GAME_SCENE_SKY_MODEL_PATH} modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE} scale={GAME_SCENE_SKY_MODEL_SCALE}
unlit
/> />
<ambientLight intensity={0.75} /> <GalleryLighting lighting={lighting} />
<directionalLight position={[6, 8, 4]} intensity={2.1} />
<Bounds fit clip observe margin={1.35}> <Bounds fit clip observe margin={1.35}>
<Center> <Center>
<GalleryModelPreview <GalleryModelPreview
@@ -165,8 +253,28 @@ function GalleryScene({
enableDamping enableDamping
autoRotate autoRotate
autoRotateSpeed={0.5} autoRotateSpeed={0.5}
minPolarAngle={Math.PI * 0.18} minPolarAngle={0}
maxPolarAngle={Math.PI * 0.48} 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}
/> />
</> </>
); );
@@ -190,6 +298,62 @@ function TextureStatusBadge({
); );
} }
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( function getTextureDiagnostic(
modelId: string, modelId: string,
modelScene: THREE.Object3D, modelScene: THREE.Object3D,
@@ -245,6 +409,10 @@ function getTextureDiagnostic(
export function GalleryPage(): React.JSX.Element { export function GalleryPage(): React.JSX.Element {
const [activeModelIndex, setActiveModelIndex] = useState(0); const [activeModelIndex, setActiveModelIndex] = useState(0);
const [lightPanelOpen, setLightPanelOpen] = useState(false);
const [lighting, setLighting] = useState<GalleryLightingConfig>({
...LIGHTING_DEFAULTS,
});
const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>( const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>(
LOADING_TEXTURE_DIAGNOSTIC, LOADING_TEXTURE_DIAGNOSTIC,
); );
@@ -267,6 +435,20 @@ export function GalleryPage(): React.JSX.Element {
); );
}; };
const handleLightChange = (
key: keyof GalleryLightingConfig,
value: number,
): void => {
setLighting((currentLighting) => ({
...currentLighting,
[key]: value,
}));
};
const resetLighting = (): void => {
setLighting({ ...LIGHTING_DEFAULTS });
};
return ( return (
<main className="gallery-page"> <main className="gallery-page">
<h1 className="gallery-title">GALERIE</h1> <h1 className="gallery-title">GALERIE</h1>
@@ -276,6 +458,7 @@ export function GalleryPage(): React.JSX.Element {
<Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}> <Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}>
<Suspense fallback={null}> <Suspense fallback={null}>
<GalleryScene <GalleryScene
lighting={lighting}
model={activeModel} model={activeModel}
onTextureDiagnosticReady={setTextureDiagnostic} onTextureDiagnosticReady={setTextureDiagnostic}
/> />
@@ -308,6 +491,13 @@ export function GalleryPage(): React.JSX.Element {
</nav> </nav>
<TextureStatusBadge diagnostic={activeTextureDiagnostic} /> <TextureStatusBadge diagnostic={activeTextureDiagnostic} />
<GalleryLightingPanel
lighting={lighting}
onChange={handleLightChange}
onReset={resetLighting}
onToggle={() => setLightPanelOpen((open) => !open)}
open={lightPanelOpen}
/>
</main> </main>
); );
} }