Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf71148935 | |||
| 1b2241df49 | |||
| d7351e5f37 |
@@ -4,14 +4,15 @@ La galerie est disponible sur `/gallery`. Elle permet de parcourir les modèles
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -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,10 @@ function SkyModelContent({
|
||||
scope: "SkyModel",
|
||||
scale,
|
||||
});
|
||||
const model = useMemo(() => createSkyModel(scene), [scene]);
|
||||
const model = useMemo(
|
||||
() => createSkyModel(scene, materialSide, unlit),
|
||||
[materialSide, scene, unlit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
model.traverse((object) => {
|
||||
@@ -112,20 +137,42 @@ 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 {
|
||||
|
||||
+126
-23
@@ -36,8 +36,9 @@ canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #05070c;
|
||||
color: #f8fafc;
|
||||
background: #050505;
|
||||
color: #f4efe7;
|
||||
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
@@ -46,7 +47,7 @@ canvas {
|
||||
right: clamp(18px, 3vw, 38px);
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
color: rgba(248, 250, 252, 0.92);
|
||||
color: #f4efe7;
|
||||
font-size: clamp(18px, 2vw, 26px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
@@ -78,12 +79,11 @@ canvas {
|
||||
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);
|
||||
border: 2px solid #d8d0c4;
|
||||
border-radius: 0;
|
||||
background: #050505;
|
||||
box-shadow: none;
|
||||
transform: translateX(50%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.gallery-bottom-bar button {
|
||||
@@ -93,7 +93,7 @@ canvas {
|
||||
height: 54px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(248, 250, 252, 0.82);
|
||||
color: #f4efe7;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
@@ -102,8 +102,8 @@ canvas {
|
||||
|
||||
.gallery-bottom-bar button:hover,
|
||||
.gallery-bottom-bar button:focus-visible {
|
||||
background: rgba(248, 250, 252, 0.1);
|
||||
color: #f8fafc;
|
||||
background: #f4efe7;
|
||||
color: #050505;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -112,15 +112,15 @@ canvas {
|
||||
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);
|
||||
border-right: 2px solid #d8d0c4;
|
||||
border-left: 2px solid #d8d0c4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gallery-model-info span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: #f8fafc;
|
||||
color: #f4efe7;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -131,7 +131,7 @@ canvas {
|
||||
|
||||
.gallery-model-info small {
|
||||
margin-top: 2px;
|
||||
color: rgba(203, 213, 225, 0.62);
|
||||
color: #a9a196;
|
||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -147,26 +147,125 @@ canvas {
|
||||
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);
|
||||
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;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.gallery-texture-status--ok {
|
||||
color: #bbf7d0;
|
||||
color: #d8d0c4;
|
||||
}
|
||||
|
||||
.gallery-texture-status--warning {
|
||||
color: #fde68a;
|
||||
color: #f4efe7;
|
||||
}
|
||||
|
||||
.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) {
|
||||
@@ -196,6 +295,10 @@ canvas {
|
||||
left: auto;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.gallery-light-panel {
|
||||
top: 78px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Docs layout */
|
||||
|
||||
+197
-7
@@ -19,26 +19,53 @@ 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";
|
||||
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
|
||||
|
||||
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";
|
||||
@@ -70,6 +97,14 @@ const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
|
||||
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
|
||||
@@ -105,12 +140,18 @@ class GalleryViewerErrorBoundary extends Component<
|
||||
function GalleryModelPreview({
|
||||
model,
|
||||
onTextureDiagnosticReady,
|
||||
}: GallerySceneProps): React.JSX.Element {
|
||||
}: GalleryModelPreviewProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { animations, scene } = useGLTF(model.path);
|
||||
const modelScene = useMemo(() => scene.clone(true), [scene]);
|
||||
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]);
|
||||
@@ -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({
|
||||
lighting,
|
||||
model,
|
||||
onTextureDiagnosticReady,
|
||||
}: GallerySceneProps): React.JSX.Element {
|
||||
@@ -147,11 +234,12 @@ function GalleryScene({
|
||||
<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
|
||||
/>
|
||||
<ambientLight intensity={0.75} />
|
||||
<directionalLight position={[6, 8, 4]} intensity={2.1} />
|
||||
<GalleryLighting lighting={lighting} />
|
||||
<Bounds fit clip observe margin={1.35}>
|
||||
<Center>
|
||||
<GalleryModelPreview
|
||||
@@ -165,8 +253,28 @@ function GalleryScene({
|
||||
enableDamping
|
||||
autoRotate
|
||||
autoRotateSpeed={0.5}
|
||||
minPolarAngle={Math.PI * 0.18}
|
||||
maxPolarAngle={Math.PI * 0.48}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -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(
|
||||
modelId: string,
|
||||
modelScene: THREE.Object3D,
|
||||
@@ -245,6 +409,10 @@ function getTextureDiagnostic(
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -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 (
|
||||
<main className="gallery-page">
|
||||
<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]}>
|
||||
<Suspense fallback={null}>
|
||||
<GalleryScene
|
||||
lighting={lighting}
|
||||
model={activeModel}
|
||||
onTextureDiagnosticReady={setTextureDiagnostic}
|
||||
/>
|
||||
@@ -308,6 +491,13 @@ export function GalleryPage(): React.JSX.Element {
|
||||
</nav>
|
||||
|
||||
<TextureStatusBadge diagnostic={activeTextureDiagnostic} />
|
||||
<GalleryLightingPanel
|
||||
lighting={lighting}
|
||||
onChange={handleLightChange}
|
||||
onReset={resetLighting}
|
||||
onToggle={() => setLightPanelOpen((open) => !open)}
|
||||
open={lightPanelOpen}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user