Files
La-Fabrik/src/pages/gallery/page.tsx
T
Tom Boullay cf71148935
🔍 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
fix: smooth gallery preview seams
2026-05-25 18:02:36 +02:00

504 lines
13 KiB
TypeScript

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 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);
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 {
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;
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 [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>
);
}