fix: issue in galley mode

This commit is contained in:
Tom Boullay
2026-05-29 02:18:17 +02:00
parent 054cb975da
commit 47e50d9318
860 changed files with 428 additions and 88 deletions
+13 -59
View File
@@ -4,24 +4,23 @@ export interface GalleryModel {
path: string;
}
/**
* List of 3D models available in the gallery.
* Only includes models that exist in `/public/models/`.
*/
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: "blocking", name: "Blocking", path: "/models/blocking/terrain.gltf" },
{
id: "boiteimmeuble",
name: "Boîte immeuble",
path: "/models/boiteimmeuble/model.gltf",
},
{
id: "boitesimple",
name: "Boîte simple",
path: "/models/boitesimple/model.gltf",
},
{ id: "buisson", name: "Buisson", path: "/models/buisson/model.gltf" },
{
id: "buisson-animated",
@@ -30,38 +29,7 @@ export const galleryModels: GalleryModel[] = [
},
{ 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",
@@ -112,9 +80,9 @@ export const galleryModels: GalleryModel[] = [
},
{ id: "gerant", name: "Gérant", path: "/models/gerant/model.gltf" },
{
id: "gerant-animated",
id: "gerant_anim",
name: "Gérant animé",
path: "/models/gerant-animated/model.gltf",
path: "/models/gerant_anim/model.gltf",
},
{
id: "habitant1",
@@ -159,16 +127,6 @@ export const galleryModels: GalleryModel[] = [
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",
@@ -188,11 +146,7 @@ export const galleryModels: GalleryModel[] = [
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: "skybox", name: "Skybox", path: "/models/skybox/skybox.gltf" },
{ id: "talkie", name: "Talkie", path: "/models/talkie/model.gltf" },
{ id: "terrain", name: "Terrain", path: "/models/terrain/model.gltf" },
{
+211
View File
@@ -299,6 +299,217 @@ canvas {
.gallery-light-panel {
top: 78px;
}
.gallery-keyboard-hints {
display: none;
}
}
/* Gallery - Header */
.gallery-header {
position: absolute;
top: clamp(18px, 3vw, 34px);
right: clamp(18px, 3vw, 38px);
z-index: 2;
text-align: right;
}
.gallery-header .gallery-title {
position: static;
transform: none;
}
.gallery-subtitle {
margin: 6px 0 0;
color: #a9a196;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
}
/* Gallery - Loading */
.gallery-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #f4efe7;
}
.gallery-loading-spinner {
animation: gallery-spin 1s linear infinite;
}
.gallery-loading-text {
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
}
@keyframes gallery-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Gallery - Empty state */
.gallery-page--empty {
display: grid;
place-items: center;
}
.gallery-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px;
color: #a9a196;
text-align: center;
}
.gallery-empty-state h1 {
margin: 0;
color: #f4efe7;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
}
.gallery-empty-state p {
margin: 0;
max-width: 320px;
font-size: 14px;
line-height: 1.5;
}
/* Gallery - Error state */
.gallery-viewer-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
min-height: 360px;
padding: 24px;
color: #fecaca;
text-align: center;
}
.gallery-viewer-error span {
font-size: 14px;
font-weight: 500;
}
/* Gallery - Navigation buttons */
.gallery-nav-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,
transform 100ms ease;
}
.gallery-nav-button:hover,
.gallery-nav-button:focus-visible {
background: #f4efe7;
color: #050505;
outline: none;
}
.gallery-nav-button:active {
transform: scale(0.95);
}
/* Gallery - Model info */
.gallery-model-name {
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-counter {
margin-top: 2px;
color: #a9a196;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 11px;
font-weight: 600;
}
/* Gallery - Texture status spinner */
.gallery-texture-status-spinner {
animation: gallery-spin 1s linear infinite;
}
/* Gallery - Keyboard hints */
.gallery-keyboard-hints {
position: absolute;
top: clamp(18px, 3vw, 34px);
left: clamp(18px, 3vw, 38px);
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
color: #a9a196;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 11px;
font-weight: 600;
}
.gallery-keyboard-hints kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border: 1px solid #a9a196;
border-radius: 4px;
background: transparent;
color: #f4efe7;
font-family: inherit;
font-size: 10px;
font-weight: 700;
}
.gallery-keyboard-hints-separator {
margin: 0 4px;
opacity: 0.5;
}
@media (max-width: 720px) {
.gallery-header {
right: 50%;
transform: translateX(50%);
text-align: center;
}
.gallery-subtitle {
display: none;
}
.gallery-nav-button {
width: 48px;
height: 50px;
}
}
/* Docs layout */
+204 -29
View File
@@ -1,14 +1,17 @@
import {
Bounds,
Center,
Html,
OrbitControls,
useAnimations,
useGLTF,
useProgress,
} from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import {
Component,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
@@ -19,6 +22,7 @@ import {
ArrowLeft,
ArrowRight,
CheckCircle2,
Loader2,
SlidersHorizontal,
TriangleAlert,
} from "lucide-react";
@@ -26,9 +30,24 @@ import * as THREE from "three";
import { SkyModel } from "@/components/three/world/SkyModel";
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
import {
AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN,
AMBIENT_INTENSITY_STEP,
AMBIENT_LIGHT_COLOR,
LIGHTING_DEFAULTS,
SUN_INTENSITY_MAX,
SUN_INTENSITY_MIN,
SUN_INTENSITY_STEP,
SUN_LIGHT_COLOR,
SUN_X_MAX,
SUN_X_MIN,
SUN_X_STEP,
SUN_Y_MAX,
SUN_Y_MIN,
SUN_Y_STEP,
SUN_Z_MAX,
SUN_Z_MIN,
SUN_Z_STEP,
} from "@/data/world/lightingConfig";
import {
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
@@ -104,13 +123,62 @@ const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
};
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 },
{
key: "ambientIntensity",
label: "Ambiance",
min: AMBIENT_INTENSITY_MIN,
max: AMBIENT_INTENSITY_MAX,
step: AMBIENT_INTENSITY_STEP,
},
{
key: "sunIntensity",
label: "Soleil",
min: SUN_INTENSITY_MIN,
max: SUN_INTENSITY_MAX,
step: SUN_INTENSITY_STEP,
},
{
key: "sunX",
label: "Soleil X",
min: SUN_X_MIN,
max: SUN_X_MAX,
step: SUN_X_STEP,
},
{
key: "sunY",
label: "Soleil Y",
min: SUN_Y_MIN,
max: SUN_Y_MAX,
step: SUN_Y_STEP,
},
{
key: "sunZ",
label: "Soleil Z",
min: SUN_Z_MIN,
max: SUN_Z_MAX,
step: SUN_Z_STEP,
},
];
function GalleryLoadingIndicator(): React.JSX.Element {
const { progress } = useProgress();
return (
<Html center>
<div className="gallery-loading">
<Loader2
className="gallery-loading-spinner"
size={32}
strokeWidth={2}
/>
<span className="gallery-loading-text">
{progress < 100 ? `${Math.round(progress)}%` : "Préparation..."}
</span>
</div>
</Html>
);
}
class GalleryViewerErrorBoundary extends Component<
GalleryViewerErrorBoundaryProps,
GalleryViewerErrorBoundaryState
@@ -134,7 +202,8 @@ class GalleryViewerErrorBoundary extends Component<
if (this.state.hasError) {
return (
<div className="gallery-viewer-error" role="status">
Ce modèle ne peut pas être affiché pour le moment.
<TriangleAlert size={24} strokeWidth={1.8} />
<span>Ce modèle ne peut pas être affiché pour le moment.</span>
</div>
);
}
@@ -321,7 +390,23 @@ function TextureStatusBadge({
}: {
diagnostic: TextureDiagnostic;
}): React.JSX.Element {
const isLoading = diagnostic.status === "loading";
const hasWarning = diagnostic.status === "warning";
if (isLoading) {
return (
<div className="gallery-texture-status gallery-texture-status--loading">
<Loader2
className="gallery-texture-status-spinner"
aria-hidden="true"
size={14}
strokeWidth={2.2}
/>
<span>{diagnostic.summary}</span>
</div>
);
}
const Icon = hasWarning ? TriangleAlert : CheckCircle2;
return (
@@ -390,6 +475,18 @@ function GalleryLightingPanel({
);
}
function GalleryEmptyState(): React.JSX.Element {
return (
<main className="gallery-page gallery-page--empty">
<div className="gallery-empty-state">
<TriangleAlert size={48} strokeWidth={1.5} />
<h1>Aucun modèle disponible</h1>
<p>La galerie ne contient aucun modèle à afficher pour le moment.</p>
</div>
</main>
);
}
function getTextureDiagnostic(
modelId: string,
modelScene: THREE.Object3D,
@@ -462,38 +559,114 @@ export function GalleryPage(): React.JSX.Element {
const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>(
LOADING_TEXTURE_DIAGNOSTIC,
);
const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]!;
const modelCount = galleryModels.length;
const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0];
const activeTextureDiagnostic =
textureDiagnostic.modelId === activeModel.id
activeModel && textureDiagnostic.modelId === activeModel.id
? textureDiagnostic
: LOADING_TEXTURE_DIAGNOSTIC;
const goToPreviousModel = (): void => {
// Preload adjacent models for smoother navigation
useEffect(() => {
if (modelCount <= 1) return;
const prevIndex =
activeModelIndex === 0 ? modelCount - 1 : activeModelIndex - 1;
const nextIndex =
activeModelIndex === modelCount - 1 ? 0 : activeModelIndex + 1;
const prevModel = galleryModels[prevIndex];
const nextModel = galleryModels[nextIndex];
if (prevModel) {
useGLTF.preload(prevModel.path);
}
if (nextModel) {
useGLTF.preload(nextModel.path);
}
}, [activeModelIndex, modelCount]);
// Memoized callbacks to prevent unnecessary re-renders
const goToPreviousModel = useCallback((): void => {
setActiveModelIndex((currentIndex) =>
currentIndex === 0 ? modelCount - 1 : currentIndex - 1,
);
};
}, [modelCount]);
const goToNextModel = (): void => {
const goToNextModel = useCallback((): void => {
setActiveModelIndex((currentIndex) =>
currentIndex === modelCount - 1 ? 0 : currentIndex + 1,
);
};
}, [modelCount]);
const handleLightChange = (
key: keyof GalleryLightingConfig,
value: number,
): void => {
setLighting((currentLighting) => ({
...currentLighting,
[key]: value,
}));
};
const handleLightChange = useCallback(
(key: keyof GalleryLightingConfig, value: number): void => {
setLighting((currentLighting) => ({
...currentLighting,
[key]: value,
}));
},
[],
);
const resetLighting = (): void => {
const resetLighting = useCallback((): void => {
setLighting({ ...LIGHTING_DEFAULTS });
};
}, []);
const toggleLightPanel = useCallback((): void => {
setLightPanelOpen((open) => !open);
}, []);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
// Ignore if user is typing in an input
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
switch (event.key) {
case "ArrowLeft":
event.preventDefault();
goToPreviousModel();
break;
case "ArrowRight":
event.preventDefault();
goToNextModel();
break;
case "l":
case "L":
event.preventDefault();
toggleLightPanel();
break;
case "r":
case "R":
if (!lightPanelOpen) return;
event.preventDefault();
resetLighting();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
goToPreviousModel,
goToNextModel,
toggleLightPanel,
resetLighting,
lightPanelOpen,
]);
// Guard against empty gallery (after all hooks)
if (modelCount === 0 || !activeModel) {
return <GalleryEmptyState />;
}
return (
<main className="gallery-page">
@@ -502,7 +675,7 @@ export function GalleryPage(): React.JSX.Element {
<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}>
<Suspense fallback={<GalleryLoadingIndicator />}>
<GalleryScene
lighting={lighting}
model={activeModel}
@@ -516,21 +689,23 @@ export function GalleryPage(): React.JSX.Element {
<nav className="gallery-bottom-bar" aria-label="Navigation des modèles">
<button
type="button"
className="gallery-nav-button"
onClick={goToPreviousModel}
aria-label="Modèle précédent"
aria-label="Modèle précédent (flèche gauche)"
>
<ArrowLeft aria-hidden="true" size={22} strokeWidth={1.8} />
</button>
<div className="gallery-model-info">
<span>{activeModel.name}</span>
<small>
<span className="gallery-model-name">{activeModel.name}</span>
<small className="gallery-model-counter">
{activeModelIndex + 1} / {modelCount}
</small>
</div>
<button
type="button"
className="gallery-nav-button"
onClick={goToNextModel}
aria-label="Modèle suivant"
aria-label="Modèle suivant (flèche droite)"
>
<ArrowRight aria-hidden="true" size={22} strokeWidth={1.8} />
</button>
@@ -541,7 +716,7 @@ export function GalleryPage(): React.JSX.Element {
lighting={lighting}
onChange={handleLightChange}
onReset={resetLighting}
onToggle={() => setLightPanelOpen((open) => !open)}
onToggle={toggleLightPanel}
open={lightPanelOpen}
/>
</main>