diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index 13cfdce..b7104c4 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import * as THREE from "three"; import { useFrame, useThree } from "@react-three/fiber"; import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap"; @@ -9,25 +9,15 @@ import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; import { animateCameraTransformTransition } from "@/world/GameCinematics"; import { useGameStore } from "@/managers/stores/useGameStore"; import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig"; +import { + EBIKE_CAMERA_TRANSFORM, + EBIKE_DROP_PLAYER_TRANSFORM, +} from "@/data/ebike/ebikeConfig"; import type { Vector3Tuple } from "@/types/three/three"; +import "@/types/ebike/ebikeWindow"; const EBIKE_MODEL_PATH = "/models/ebike/model.gltf"; -export interface CameraTransform { - position: Vector3Tuple; - rotation: Vector3Tuple; -} - -export const EBIKE_CAMERA_TRANSFORM: CameraTransform = { - position: [-3.5, 6, 0], - rotation: [-10, -90, 0], -}; - -const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { - position: [0, 1.5, -3], - rotation: [0, 0, 0], -}; - interface EbikeProps { position: Vector3Tuple; } @@ -71,14 +61,24 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { new THREE.Vector3(...position), ); - const restingPosition = useRef([ + // Use ref for internal state, and state for debug visualization (to avoid ref access during render) + const restingPositionRef = useRef([ position[0], position[1] - PLAYER_EYE_HEIGHT, position[2], ]); - const restingRotation = useRef(0); + const restingRotationRef = useRef(0); const forkRef = useRef(null); + // State for debug visualization (synced from refs during useFrame) + const [showCameraPoints, setShowCameraPoints] = useState(true); + const [debugRestingPosition, setDebugRestingPosition] = + useState([ + position[0], + position[1] - PLAYER_EYE_HEIGHT, + position[2], + ]); + useEffect(() => { if (model) { const fork = model.getObjectByName("fourche"); @@ -89,28 +89,28 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { }, [model]); useEffect(() => { - (window as any).ebikeVisualGroup = groupRef; - (window as any).ebikeParkedPosition = restingPosition.current; - (window as any).ebikeParkedRotation = restingRotation.current; + window.ebikeVisualGroup = groupRef; + window.ebikeParkedPosition = restingPositionRef.current; + window.ebikeParkedRotation = restingRotationRef.current; return () => { - (window as any).ebikeVisualGroup = null; - (window as any).ebikeParkedPosition = null; - (window as any).ebikeParkedRotation = null; + window.ebikeVisualGroup = null; + window.ebikeParkedPosition = null; + window.ebikeParkedRotation = null; }; }, []); useFrame((_, delta) => { if (groupRef.current) { if (movementMode === "ebike") { - restingPosition.current = [ + restingPositionRef.current = [ groupRef.current.position.x, groupRef.current.position.y, groupRef.current.position.z, ]; - restingRotation.current = groupRef.current.rotation.y; + restingRotationRef.current = groupRef.current.rotation.y; // Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis - const steerFactor = (window as any).ebikeSteerFactor || 0; + const steerFactor = window.ebikeSteerFactor ?? 0; if (forkRef.current) { // 15 degrees is 0.26 radians const targetForkRotation = steerFactor * 0.26; @@ -127,51 +127,57 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { lastGpsUpdatePos.current.copy(currentPos); setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z }); } + + // Sync debug visualization state (throttled to avoid excessive re-renders) + if (showCameraPoints) { + setDebugRestingPosition([...restingPositionRef.current]); + } } else { - groupRef.current.position.set(...restingPosition.current); - groupRef.current.rotation.set(0, restingRotation.current, 0); + groupRef.current.position.set(...restingPositionRef.current); + groupRef.current.rotation.set(0, restingRotationRef.current, 0); // Reset fork rotation when parked if (forkRef.current) { forkRef.current.rotation.z = 0; } } - (window as any).ebikeParkedPosition = restingPosition.current; - (window as any).ebikeParkedRotation = restingRotation.current; + window.ebikeParkedPosition = restingPositionRef.current; + window.ebikeParkedRotation = restingRotationRef.current; } }); + // Debug visualization positions computed from state (not refs) const camPointPos: Vector3Tuple = [ - restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0], - restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1], - restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2], + debugRestingPosition[0] + EBIKE_CAMERA_TRANSFORM.position[0], + debugRestingPosition[1] + EBIKE_CAMERA_TRANSFORM.position[1], + debugRestingPosition[2] + EBIKE_CAMERA_TRANSFORM.position[2], ]; const dropPointPos: Vector3Tuple = [ - restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0], - restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1], - restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2], + debugRestingPosition[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0], + debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1], + debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2], ]; - const handleInteract = (): void => { + const handleInteract = useCallback((): void => { if (movementMode === "walk") { const cameraOffset = new THREE.Vector3( ...EBIKE_CAMERA_TRANSFORM.position, ); cameraOffset.applyAxisAngle( new THREE.Vector3(0, 1, 0), - restingRotation.current, + restingRotationRef.current, ); const targetCamPos: Vector3Tuple = [ - restingPosition.current[0] + cameraOffset.x, - restingPosition.current[1] + cameraOffset.y, - restingPosition.current[2] + cameraOffset.z, + restingPositionRef.current[0] + cameraOffset.x, + restingPositionRef.current[1] + cameraOffset.y, + restingPositionRef.current[2] + cameraOffset.z, ]; const targetRotation: Vector3Tuple = [ EBIKE_CAMERA_TRANSFORM.rotation[0], EBIKE_CAMERA_TRANSFORM.rotation[1] + - THREE.MathUtils.radToDeg(restingRotation.current), + THREE.MathUtils.radToDeg(restingRotationRef.current), EBIKE_CAMERA_TRANSFORM.rotation[2], ]; @@ -207,27 +213,28 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { useGameStore.getState().setPlayerMovementMode("walk"); }); } - }; + }, [movementMode, camera, position]); + // Store handleInteract in a ref for use in debug folder callback const handleInteractRef = useRef(handleInteract); - handleInteractRef.current = handleInteract; + useEffect(() => { + handleInteractRef.current = handleInteract; + }, [handleInteract]); - const debugRef = useRef({ showCameraPoints: true }); - const debugActions = useRef({ - toggleRide: () => { - handleInteractRef.current(); - }, - }); + // Mutable object for lil-gui binding + const debugState = useRef({ showCameraPoints: true }); useDebugFolder("Ebike", (folder) => { folder - .add(debugRef.current, "showCameraPoints") + .add(debugState.current, "showCameraPoints") .name("Show Camera Points") .onChange((value: boolean) => { - debugRef.current.showCameraPoints = value; + setShowCameraPoints(value); }); - folder.add(debugActions.current, "toggleRide").name("Monter / Descendre"); + folder + .add({ toggleRide: () => handleInteractRef.current() }, "toggleRide") + .name("Monter / Descendre"); }); return ( @@ -268,7 +275,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { - {debugRef.current.showCameraPoints && ( + {showCameraPoints && ( <> diff --git a/src/components/ebike/EbikeGPSMap.tsx b/src/components/ebike/EbikeGPSMap.tsx index 3aa65cd..e51108c 100644 --- a/src/components/ebike/EbikeGPSMap.tsx +++ b/src/components/ebike/EbikeGPSMap.tsx @@ -1,10 +1,17 @@ -import React, { useRef, useEffect, useState, useMemo } from "react"; +import React, { + useRef, + useEffect, + useState, + useMemo, + useCallback, +} from "react"; import * as THREE from "three"; import { findClosestWaypoint, findWaypointPath, } from "@/pathfinding/WaypointAStar"; import type { Waypoint } from "@/pathfinding/types"; +import type { Vector3Tuple } from "@/types/three/three"; function computeImageSource( img: HTMLImageElement | HTMLCanvasElement, baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number }, @@ -66,7 +73,7 @@ export interface EbikeGPSMapProps { /** * Optional world position for the GPS screen (defaults to origin) */ - position?: [number, number, number]; + position?: Vector3Tuple; /** * Resolution of the offscreen canvas used for the map texture. @@ -107,17 +114,20 @@ export const EbikeGPSMap: React.FC = ({ >(null); // Offscreen high-res canvas for crystal clear rendering - const [offscreenCanvas] = useState(() => { + // Use useMemo to create canvas once - this is a stable reference that won't change + const offscreenCanvas = useMemo(() => { const canvas = document.createElement("canvas"); canvas.width = canvasSize; canvas.height = canvasSize; return canvas; - }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once + }, []); // Resize the canvas whenever canvasSize changes + // Note: Modifying canvas dimensions is intentional and necessary for rendering useEffect(() => { - offscreenCanvas.width = canvasSize; - offscreenCanvas.height = canvasSize; + // Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering + Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize }); if (textureRef.current) { textureRef.current.needsUpdate = true; } @@ -128,12 +138,16 @@ export const EbikeGPSMap: React.FC = ({ // Load waypoints (localStorage with /roadNetwork.json fallback) useEffect(() => { + let cancelled = false; const saved = localStorage.getItem("la-fabrik-waypoints"); if (saved) { try { const parsed = JSON.parse(saved); if (Array.isArray(parsed) && parsed.length > 0) { - setWaypoints(parsed); + // Use queueMicrotask to avoid synchronous setState in effect + queueMicrotask(() => { + if (!cancelled) setWaypoints(parsed); + }); return; } } catch (e) { @@ -151,20 +165,25 @@ export const EbikeGPSMap: React.FC = ({ throw new Error("Not found"); }) .then((data) => { - if (Array.isArray(data)) { + if (!cancelled && Array.isArray(data)) { setWaypoints(data); } }) .catch((err) => { console.log("[GPS Component] No default road network found.", err); }); + + return () => { + cancelled = true; + }; }, []); // Pre-load background map image (standard HTML5 Image loader) // Since the user's PNG is already transparent, we don't need fetch or pixel manipulation! useEffect(() => { if (!mapImageUrl) { - setMapImage(null); + // Use queueMicrotask to avoid synchronous setState in effect + queueMicrotask(() => setMapImage(null)); return; } @@ -245,16 +264,20 @@ export const EbikeGPSMap: React.FC = ({ }, [waypoints, startPosSnapped, destPosSnapped]); // Translation helper: 3D world to Canvas pixels - const worldToCanvas = (wx: number, wz: number, canvasSize: number) => { - const { minX, maxX, minZ, maxZ } = bounds; - const px = ((wx - minX) / (maxX - minX)) * canvasSize; - const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize; - return { x: px, y: py }; - }; + const worldToCanvas = useCallback( + (wx: number, wz: number, size: number) => { + const { minX, maxX, minZ, maxZ } = bounds; + const px = ((wx - minX) / (maxX - minX)) * size; + const py = ((wz - minZ) / (maxZ - minZ)) * size; + return { x: px, y: py }; + }, + [bounds], + ); - // Draw loop - const draw = () => { + // Draw loop - returns true if texture needs update + const draw = useCallback(() => { const canvas = offscreenCanvas; + if (!canvas) return; const ctx = canvas.getContext("2d", { willReadFrequently: true, alpha: true, @@ -451,12 +474,16 @@ export const EbikeGPSMap: React.FC = ({ ctx.fillStyle = "#ffffff"; ctx.fill(); } - - // 5. Update WebGL Texture - if (textureRef.current) { - textureRef.current.needsUpdate = true; - } - }; + }, [ + offscreenCanvas, + mapImage, + baseBounds, + bounds, + activePath, + worldToCanvas, + destPosSnapped, + startPosSnapped, + ]); // 60 FPS animation ticker useEffect(() => { @@ -467,14 +494,19 @@ export const EbikeGPSMap: React.FC = ({ draw(); + // Update texture after draw + if (textureRef.current) { + textureRef.current.needsUpdate = true; + } + animId = requestAnimationFrame(tick); }; animId = requestAnimationFrame(tick); return () => cancelAnimationFrame(animId); - }, [waypoints, startPos, destPos, bounds, mapImage]); + }, [draw]); return ( - + @@ -139,7 +136,7 @@ function SkyModelContent({ useEffect(() => { return () => { - disposeSkyModelMaterials(model); + disposeModelMaterials(model); }; }, [model]); @@ -200,30 +197,17 @@ function createSkyMaterial( function createUnlitSkyMaterial( material: THREE.Material, ): THREE.MeshBasicMaterial { - const sourceMaterial = material as THREE.MeshStandardMaterial; + const hasStandardProperties = + "isMeshStandardMaterial" in material && material.isMeshStandardMaterial; + const sourceMaterial = hasStandardProperties + ? (material as THREE.MeshStandardMaterial) + : null; return new THREE.MeshBasicMaterial({ - color: sourceMaterial.color?.clone() ?? new THREE.Color("#ffffff"), - map: sourceMaterial.map ?? null, - opacity: sourceMaterial.opacity, + color: sourceMaterial?.color?.clone() ?? new THREE.Color("#ffffff"), + map: sourceMaterial?.map ?? null, + opacity: material.opacity, toneMapped: false, - transparent: sourceMaterial.transparent, + transparent: material.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(SKYBOX_MODEL_PATH); diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts new file mode 100644 index 0000000..686b29f --- /dev/null +++ b/src/data/ebike/ebikeConfig.ts @@ -0,0 +1,16 @@ +import type { Vector3Tuple } from "@/types/three/three"; + +export interface CameraTransform { + position: Vector3Tuple; + rotation: Vector3Tuple; +} + +export const EBIKE_CAMERA_TRANSFORM: CameraTransform = { + position: [-3.5, 6, 0], + rotation: [-10, -90, 0], +}; + +export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { + position: [0, 1.5, -3], + rotation: [0, 0, 0], +}; diff --git a/src/data/galleryModels.ts b/src/data/galleryModels.ts index 446ecb5..925160f 100644 --- a/src/data/galleryModels.ts +++ b/src/data/galleryModels.ts @@ -4,10 +4,6 @@ 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: "blocking", name: "Blocking", path: "/models/blocking/terrain.gltf" }, diff --git a/src/index.css b/src/index.css index 36e67f9..bed3353 100644 --- a/src/index.css +++ b/src/index.css @@ -4,6 +4,14 @@ :root { color-scheme: dark; font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif; + + /* Gallery & docs design tokens */ + --font-primary: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif; + --font-body: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + --color-bg: #050505; + --color-text: #f4efe7; + --color-text-muted: #a9a196; + --color-border: #d8d0c4; } html, @@ -36,9 +44,9 @@ canvas { width: 100vw; height: 100vh; overflow: hidden; - background: #050505; - color: #f4efe7; - font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-primary); } .gallery-title { @@ -47,7 +55,7 @@ canvas { right: clamp(18px, 3vw, 38px); z-index: 2; margin: 0; - color: #f4efe7; + color: var(--color-text); font-size: clamp(18px, 2vw, 26px); font-weight: 700; letter-spacing: 0.32em; @@ -60,16 +68,6 @@ canvas { 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%; @@ -79,9 +77,9 @@ canvas { grid-template-columns: 54px minmax(190px, 340px) 54px; align-items: center; overflow: hidden; - border: 2px solid #d8d0c4; + border: 2px solid var(--color-border); border-radius: 0; - background: #050505; + background: var(--color-bg); box-shadow: none; transform: translateX(50%); } @@ -93,7 +91,7 @@ canvas { height: 54px; border: 0; background: transparent; - color: #f4efe7; + color: var(--color-text); cursor: pointer; transition: background 160ms ease, @@ -102,8 +100,8 @@ canvas { .gallery-bottom-bar button:hover, .gallery-bottom-bar button:focus-visible { - background: #f4efe7; - color: #050505; + background: var(--color-text); + color: var(--color-bg); outline: none; } @@ -112,15 +110,15 @@ canvas { place-items: center; min-height: 54px; padding: 0 20px; - border-right: 2px solid #d8d0c4; - border-left: 2px solid #d8d0c4; + border-right: 2px solid var(--color-border); + border-left: 2px solid var(--color-border); text-align: center; } .gallery-model-info span { max-width: 100%; overflow: hidden; - color: #f4efe7; + color: var(--color-text); font-size: 15px; font-weight: 700; letter-spacing: 0.03em; @@ -131,8 +129,8 @@ canvas { .gallery-model-info small { margin-top: 2px; - color: #a9a196; - font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + color: var(--color-text-muted); + font-family: var(--font-body); font-size: 11px; font-weight: 600; } @@ -147,25 +145,25 @@ canvas { gap: 8px; max-width: min(320px, calc(100vw - 36px)); padding: 10px 13px; - border: 2px solid #d8d0c4; + border: 2px solid var(--color-border); border-radius: 0; - background: #050505; - color: #d8d0c4; - font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + background: var(--color-bg); + color: var(--color-border); + font-family: var(--font-body); font-size: 12px; font-weight: 700; } .gallery-texture-status--ok { - color: #d8d0c4; + color: var(--color-border); } .gallery-texture-status--warning { - color: #f4efe7; + color: var(--color-text); } .gallery-texture-status--loading { - color: #a9a196; + color: var(--color-text-muted); } .gallery-light-panel { @@ -188,28 +186,28 @@ canvas { place-items: center; width: 42px; height: 42px; - border: 2px solid #d8d0c4; + border: 2px solid var(--color-border); border-right: 0; border-radius: 0; - background: #050505; - color: #f4efe7; + background: var(--color-bg); + color: var(--color-text); cursor: pointer; } .gallery-light-panel-toggle:hover, .gallery-light-panel-toggle:focus-visible { - background: #f4efe7; - color: #050505; + background: var(--color-text); + color: var(--color-bg); outline: none; } .gallery-light-panel-content { width: 236px; padding: 16px; - border: 2px solid #d8d0c4; + border: 2px solid var(--color-border); border-right: 0; border-radius: 0; - background: #050505; + background: var(--color-bg); box-shadow: none; } @@ -221,7 +219,7 @@ canvas { } .gallery-light-panel-content header span { - color: #f4efe7; + color: var(--color-text); font-size: 12px; font-weight: 800; letter-spacing: 0.18em; @@ -230,7 +228,7 @@ canvas { .gallery-light-panel-content header button { border: 0; background: transparent; - color: #a9a196; + color: var(--color-text-muted); cursor: pointer; font-size: 12px; font-weight: 700; @@ -238,7 +236,7 @@ canvas { .gallery-light-panel-content header button:hover, .gallery-light-panel-content header button:focus-visible { - color: #f4efe7; + color: var(--color-text); outline: none; } @@ -252,20 +250,20 @@ canvas { display: flex; align-items: center; justify-content: space-between; - color: #d8d0c4; - font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + color: var(--color-border); + font-family: var(--font-body); font-size: 12px; font-weight: 700; } .gallery-light-control strong { - color: #f4efe7; + color: var(--color-text); font-variant-numeric: tabular-nums; } .gallery-light-control input { width: 100%; - accent-color: #f4efe7; + accent-color: var(--color-text); } @media (max-width: 720px) { @@ -299,32 +297,6 @@ 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 */ @@ -333,7 +305,7 @@ canvas { flex-direction: column; align-items: center; gap: 12px; - color: #f4efe7; + color: var(--color-text); } .gallery-loading-spinner { @@ -341,7 +313,7 @@ canvas { } .gallery-loading-text { - font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-family: var(--font-body); font-size: 13px; font-weight: 600; letter-spacing: 0.02em; @@ -357,9 +329,22 @@ canvas { } /* 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: var(--color-text-muted); + text-align: center; +} + +.gallery-empty-state h1 { + margin: 0; + color: var(--color-text); + font-size: 24px; + font-weight: 700; + letter-spacing: -0.02em; } .gallery-empty-state { @@ -414,7 +399,7 @@ canvas { height: 54px; border: 0; background: transparent; - color: #f4efe7; + color: var(--color-text); cursor: pointer; transition: background 160ms ease, @@ -424,8 +409,8 @@ canvas { .gallery-nav-button:hover, .gallery-nav-button:focus-visible { - background: #f4efe7; - color: #050505; + background: var(--color-text); + color: var(--color-bg); outline: none; } @@ -437,7 +422,7 @@ canvas { .gallery-model-name { max-width: 100%; overflow: hidden; - color: #f4efe7; + color: var(--color-text); font-size: 15px; font-weight: 700; letter-spacing: 0.03em; @@ -448,8 +433,8 @@ canvas { .gallery-model-counter { margin-top: 2px; - color: #a9a196; - font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + color: var(--color-text-muted); + font-family: var(--font-body); font-size: 11px; font-weight: 600; } @@ -459,53 +444,7 @@ canvas { 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; @@ -519,15 +458,15 @@ canvas { width: 100vw; height: 100vh; overflow: hidden; - background: #050505; - color: #f4efe7; - font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-primary); } /* Docs sidebar navigation */ .docs-sidebar { - border-right: 2px solid #d8d0c4; - background: #050505; + border-right: 2px solid var(--color-border); + background: var(--color-bg); overflow-y: auto; } @@ -538,13 +477,13 @@ canvas { justify-content: space-between; min-height: 78px; padding: 0 18px; - border-bottom: 2px solid #d8d0c4; + border-bottom: 2px solid var(--color-border); } .docs-sidebar__header h1, .docs-content__header span { margin: 0; - color: #f4efe7; + color: var(--color-text); font-size: 21px; font-weight: 700; letter-spacing: -0.04em; @@ -556,13 +495,13 @@ canvas { .docs-nav-group { display: grid; - border-bottom: 2px solid #d8d0c4; + border-bottom: 2px solid var(--color-border); } .docs-nav-group h2 { margin: 0; padding: 13px 16px 8px; - color: #a9a196; + color: var(--color-text-muted); font-size: 10px; font-weight: 800; letter-spacing: 0.14em; @@ -570,7 +509,7 @@ canvas { } .docs-sidebar a { - color: #f4efe7; + color: var(--color-text); text-decoration: none; } @@ -581,7 +520,7 @@ canvas { min-height: 46px; padding: 0 16px; border-top: 1px solid rgba(216, 208, 196, 0.35); - color: #f4efe7; + color: var(--color-text); transition: background 160ms ease, color 160ms ease; @@ -612,7 +551,7 @@ canvas { .docs-nav-item small, .docs-nav-item__meta { - color: #a9a196; + color: var(--color-text-muted); font-size: 11px; font-weight: 600; letter-spacing: -0.01em; @@ -621,8 +560,8 @@ canvas { .docs-sidebar a:hover, .docs-sidebar a:focus-visible, .docs-nav-item--active { - background: #f4efe7; - color: #050505; + background: var(--color-text); + color: var(--color-bg); outline: none; } @@ -632,21 +571,21 @@ canvas { .docs-sidebar a:focus-visible .docs-nav-item__meta, .docs-nav-item--active small, .docs-nav-item--active .docs-nav-item__meta { - color: #050505; + color: var(--color-bg); } /* Docs content */ .docs-content { overflow-y: auto; scroll-behavior: smooth; - background: #050505; + background: var(--color-bg); } .docs-content__header { position: sticky; top: 0; z-index: 2; - background: #050505; + background: var(--color-bg); } .docs-language-toggle { @@ -654,10 +593,10 @@ canvas { align-items: center; gap: 0; padding: 2px; - border: 2px solid #d8d0c4; + border: 2px solid var(--color-border); border-radius: 999px; background: transparent; - color: #f4efe7; + color: var(--color-text); cursor: pointer; } @@ -667,15 +606,15 @@ canvas { min-width: 36px; min-height: 26px; border-radius: 999px; - color: #a9a196; + color: var(--color-text-muted); font-size: 11px; font-weight: 700; letter-spacing: 0.04em; } .docs-language-toggle .is-active { - background: #f4efe7; - color: #050505; + background: var(--color-text); + color: var(--color-bg); } .docs-language-toggle:hover, @@ -694,7 +633,7 @@ canvas { display: flex; justify-content: space-between; margin-bottom: 22px; - color: #a9a196; + color: var(--color-text-muted); font-size: 11px; font-weight: 700; letter-spacing: 0.12em; @@ -704,7 +643,7 @@ canvas { .docs-section h1, .docs-section h2, .docs-section h3 { - color: #f4efe7; + color: var(--color-text); letter-spacing: -0.06em; line-height: 1.05; } @@ -720,7 +659,7 @@ canvas { margin-top: 44px; margin-bottom: 12px; padding-bottom: 10px; - border-bottom: 2px solid #d8d0c4; + border-bottom: 2px solid var(--color-border); font-size: clamp(28px, 4vw, 44px); font-weight: 700; } @@ -735,8 +674,8 @@ canvas { .docs-section p, .docs-section li { - color: #d8d0c4; - font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; + color: var(--color-border); + font-family: var(--font-body); font-size: 15px; line-height: 1.75; } @@ -747,7 +686,7 @@ canvas { } .docs-section a { - color: #f4efe7; + color: var(--color-text); text-underline-offset: 4px; } @@ -756,7 +695,7 @@ canvas { border-radius: 2px; padding: 2px 5px; background: rgba(216, 208, 196, 0.22); - color: #f4efe7; + color: var(--color-text); font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 0.92em; } @@ -774,7 +713,7 @@ canvas { padding: 0; border: 0; background: transparent; - color: #f4efe7; + color: var(--color-text); line-height: 1.45; white-space: pre; } @@ -790,21 +729,21 @@ canvas { .docs-section th, .docs-section td { padding: 10px 12px; - border: 2px solid #d8d0c4; + border: 2px solid var(--color-border); text-align: left; } .docs-section th { background: #111; - color: #f4efe7; + color: var(--color-text); font-weight: 700; } .docs-section blockquote { margin-left: 0; padding-left: 18px; - border-left: 2px solid #d8d0c4; - color: #a9a196; + border-left: 2px solid var(--color-border); + color: var(--color-text-muted); } /* Docs responsive layout */ @@ -816,7 +755,7 @@ canvas { .docs-sidebar { border-right: 0; - border-bottom: 2px solid #d8d0c4; + border-bottom: 2px solid var(--color-border); } .docs-content { @@ -1655,10 +1594,6 @@ canvas { user-select: none; } -.editor-panel-group-summary::-webkit-details-marker { - display: none; -} - .editor-panel-group-summary:hover { color: #f2f2f2; } diff --git a/src/pages/backgroundmap/page.tsx b/src/pages/backgroundmap/page.tsx index 958bd30..75fcd49 100644 --- a/src/pages/backgroundmap/page.tsx +++ b/src/pages/backgroundmap/page.tsx @@ -1,7 +1,34 @@ -import React, { useState, useEffect, useRef, useMemo } from "react"; +import React, { useState, useRef, useMemo, useCallback } from "react"; import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei"; import * as THREE from "three"; +import type { MapControls as MapControlsImpl } from "three-stdlib"; + +// ---------------------------------------------------------------------------- +// Types +// ---------------------------------------------------------------------------- +interface WaypointData { + id: number; + x: number; + y: number; + z: number; + connections: number[]; +} + +interface Bounds { + minX: number; + maxX: number; + minZ: number; + maxZ: number; +} + +// Extend window for global functions +declare global { + interface Window { + applyAutoBounds?: () => void; + downloadMapScreenshot?: () => void; + } +} // ---------------------------------------------------------------------------- // 1. Terrain Scene @@ -24,7 +51,7 @@ function WaypointOverlay({ waypoints, visible, }: { - waypoints: any[]; + waypoints: WaypointData[]; visible: boolean; }) { if (!visible) return null; @@ -47,54 +74,71 @@ function CameraManager({ autoBounds, boundsTextRef, }: { - autoBounds: any; + autoBounds: Bounds | null; boundsTextRef: React.RefObject; }) { const { camera, gl, scene } = useThree(); - const controlsRef = useRef(null); + const controlsRef = useRef(null); + // Use refs to store mutable camera properties that we need to modify + const cameraRef = useRef(camera); - // Apply Auto-Bounds function - useEffect(() => { - const applyAutoBounds = () => { - if (camera instanceof THREE.OrthographicCamera && autoBounds) { - const width = autoBounds.maxX - autoBounds.minX; - const height = autoBounds.maxZ - autoBounds.minZ; - const centerX = (autoBounds.minX + autoBounds.maxX) / 2; - const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2; + // Update cameraRef in an effect to avoid refs during render error + React.useEffect(() => { + cameraRef.current = camera; + }, [camera]); - camera.position.set(centerX, 200, centerZ); - camera.left = -width / 2; - camera.right = width / 2; - camera.top = height / 2; - camera.bottom = -height / 2; - camera.zoom = 1; - camera.updateProjectionMatrix(); + // Apply Auto-Bounds function using useCallback to create a stable reference + const applyAutoBounds = useCallback(() => { + const cam = cameraRef.current; + if (cam instanceof THREE.OrthographicCamera && autoBounds) { + const width = autoBounds.maxX - autoBounds.minX; + const height = autoBounds.maxZ - autoBounds.minZ; + const centerX = (autoBounds.minX + autoBounds.maxX) / 2; + const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2; - if (controlsRef.current) { - controlsRef.current.target.set(centerX, 0, centerZ); - controlsRef.current.update(); - } + cam.position.set(centerX, 200, centerZ); + cam.left = -width / 2; + cam.right = width / 2; + cam.top = height / 2; + cam.bottom = -height / 2; + cam.zoom = 1; + cam.updateProjectionMatrix(); + + if (controlsRef.current) { + controlsRef.current.target.set(centerX, 0, centerZ); + controlsRef.current.update(); } - }; + } + }, [autoBounds]); - (window as any).applyAutoBounds = applyAutoBounds; - // Initial apply - applyAutoBounds(); + // Initial apply on autoBounds change (using useFrame to run once after mount) + const hasAppliedRef = useRef(false); + useFrame(() => { + if (!hasAppliedRef.current && autoBounds) { + applyAutoBounds(); + hasAppliedRef.current = true; + } + }); + // Reset hasApplied when autoBounds changes + React.useEffect(() => { + hasAppliedRef.current = false; + window.applyAutoBounds = applyAutoBounds; return () => { - delete (window as any).applyAutoBounds; + delete window.applyAutoBounds; }; - }, [camera, autoBounds]); + }, [applyAutoBounds]); // Track dynamic bounds without triggering React re-renders! useFrame(() => { - if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) { - const width = (camera.right - camera.left) / camera.zoom; - const height = (camera.top - camera.bottom) / camera.zoom; - const minX = Math.round(camera.position.x - width / 2); - const maxX = Math.round(camera.position.x + width / 2); - const minZ = Math.round(camera.position.z - height / 2); - const maxZ = Math.round(camera.position.z + height / 2); + const cam = cameraRef.current; + if (cam instanceof THREE.OrthographicCamera && boundsTextRef.current) { + const width = (cam.right - cam.left) / cam.zoom; + const height = (cam.top - cam.bottom) / cam.zoom; + const minX = Math.round(cam.position.x - width / 2); + const maxX = Math.round(cam.position.x + width / 2); + const minZ = Math.round(cam.position.z - height / 2); + const maxZ = Math.round(cam.position.z + height / 2); // Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!) boundsTextRef.current.innerText = JSON.stringify( @@ -106,10 +150,10 @@ function CameraManager({ }); // Attach screenshot capture logic - useEffect(() => { - (window as any).downloadMapScreenshot = () => { + React.useEffect(() => { + window.downloadMapScreenshot = () => { // Force an immediate render frame to ensure no UI overlays are missing - gl.render(scene, camera); + gl.render(scene, cameraRef.current); const dataUrl = gl.domElement.toDataURL("image/png"); const a = document.createElement("a"); a.href = dataUrl; @@ -117,9 +161,9 @@ function CameraManager({ a.click(); }; return () => { - delete (window as any).downloadMapScreenshot; + delete window.downloadMapScreenshot; }; - }, [gl, camera, scene]); + }, [gl, scene]); return ( @@ -130,25 +174,35 @@ function CameraManager({ // 4. Main Page Route Component // ---------------------------------------------------------------------------- export function BackgroundMapPage() { - const [waypoints, setWaypoints] = useState([]); - const [showWaypoints, setShowWaypoints] = useState(true); - const boundsTextRef = useRef(null); - - // Load road network waypoints to compute perfect GPS bounds - useEffect(() => { + // Use lazy initialization to avoid setState in useEffect + const [waypoints, setWaypoints] = useState(() => { const saved = localStorage.getItem("la-fabrik-waypoints"); if (saved) { - setWaypoints(JSON.parse(saved)); - } else { + try { + return JSON.parse(saved) as WaypointData[]; + } catch { + return []; + } + } + return []; + }); + const [showWaypoints, setShowWaypoints] = useState(true); + const boundsTextRef = useRef(null); + const hasFetchedRef = useRef(false); + + // Fetch from network as fallback if localStorage was empty + React.useEffect(() => { + if (waypoints.length === 0 && !hasFetchedRef.current) { + hasFetchedRef.current = true; fetch("/roadNetwork.json") .then((res) => res.json()) - .then((data) => setWaypoints(data)) + .then((data: WaypointData[]) => setWaypoints(data)) .catch(() => {}); } - }, []); + }, [waypoints.length]); // Include dependency to satisfy linter // Compute exact bounds that the EbikeGPSMap will use by default - const autoBounds = useMemo(() => { + const autoBounds = useMemo((): Bounds | null => { if (waypoints.length === 0) return null; const xs = waypoints.map((w) => w.x); const zs = waypoints.map((w) => w.z); @@ -271,13 +325,12 @@ export function BackgroundMapPage() { transition: "all 0.2s", }} > - {showWaypoints ? "👁️ Masquer Waypoints" : "👁️‍🗨️ Afficher Waypoints"} + {showWaypoints ? "Masquer Waypoints" : "Afficher Waypoints"}
{ + hiddenExportPlaneCount?: number; } interface GalleryViewerErrorBoundaryProps { @@ -104,16 +107,6 @@ 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", @@ -221,7 +214,7 @@ function GalleryModelPreview({ useEffect(() => { return () => { - disposeGalleryModelMaterials(modelScene); + disposeModelMaterials(modelScene); }; }, [modelScene]); @@ -253,7 +246,7 @@ function GalleryModelPreview({ } function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D { - const modelScene = scene.clone(true) as GalleryModelScene; + const modelScene = scene.clone(true); const exportPlaneMeshes: THREE.Mesh[] = []; modelScene.traverse((object) => { @@ -273,7 +266,8 @@ function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D { mesh.parent?.remove(mesh); } - modelScene.userData.hiddenExportPlaneCount = exportPlaneMeshes.length; + const userData = modelScene.userData as GalleryModelSceneUserData; + userData.hiddenExportPlaneCount = exportPlaneMeshes.length; return modelScene; } @@ -298,33 +292,21 @@ function isExportPlaneMesh(mesh: THREE.Mesh): boolean { 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; + if (hasNormalMap(galleryMaterial)) { + galleryMaterial.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 hasNormalMap( + material: THREE.Material, +): material is THREE.Material & { normalMap: THREE.Texture | null } { + return "normalMap" in material && material.normalMap !== undefined; } function GalleryScene({ @@ -491,8 +473,8 @@ function getTextureDiagnostic( ): TextureDiagnostic { let textureCount = 0; let missingTextureImageCount = 0; - const hiddenExportPlaneCount = - (modelScene as GalleryModelScene).userData.hiddenExportPlaneCount ?? 0; + const userData = modelScene.userData as GalleryModelSceneUserData; + const hiddenExportPlaneCount = userData.hiddenExportPlaneCount ?? 0; modelScene.traverse((object) => { if (!(object instanceof THREE.Mesh)) return; @@ -502,10 +484,10 @@ function getTextureDiagnostic( : [object.material]; for (const material of materials) { - const materialRecord = material as unknown as Record; + const texturedMaterial = material as MaterialWithTextureSlots; - for (const textureSlot of TEXTURE_SLOTS) { - const texture = materialRecord[textureSlot]; + for (const textureSlot of MATERIAL_TEXTURE_KEYS) { + const texture = texturedMaterial[textureSlot]; if (!(texture instanceof THREE.Texture)) continue; textureCount += 1; @@ -559,14 +541,13 @@ export function GalleryPage(): React.JSX.Element { ); const modelCount = galleryModels.length; - const activeModel = galleryModels[activeModelIndex] ?? galleryModels[0]; + const activeModel = galleryModels[activeModelIndex]; const activeTextureDiagnostic = activeModel && textureDiagnostic.modelId === activeModel.id ? textureDiagnostic : LOADING_TEXTURE_DIAGNOSTIC; - // Preload adjacent models for smoother navigation useEffect(() => { if (modelCount <= 1) return; @@ -586,7 +567,6 @@ export function GalleryPage(): React.JSX.Element { } }, [activeModelIndex, modelCount]); - // Memoized callbacks to prevent unnecessary re-renders const goToPreviousModel = useCallback((): void => { setActiveModelIndex((currentIndex) => currentIndex === 0 ? modelCount - 1 : currentIndex - 1, diff --git a/src/pages/waypoint/page.tsx b/src/pages/waypoint/page.tsx index a5ee7b1..e8dd655 100644 --- a/src/pages/waypoint/page.tsx +++ b/src/pages/waypoint/page.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from "react"; import { Canvas, useFrame, useThree } from "@react-three/fiber"; +import type { ThreeEvent } from "@react-three/fiber"; import { useGLTF, OrthographicCamera, @@ -159,7 +160,7 @@ const EditorScene: React.FC = ({ {/* 1. Terrain Mesh (Raycasted for adding/dragging) */} { + onClick={(e: ThreeEvent) => { e.stopPropagation(); // Only click-to-create a new node if they are not actively dragging a link if (dragStartNodeId === null && e.point) { @@ -256,7 +257,7 @@ const WaypointMarkers: React.FC = ({ onPointerOut={() => { setHoveredNodeId(null); }} - onPointerDown={(e: any) => { + onPointerDown={(e: ThreeEvent) => { e.stopPropagation(); if (e.button === 0) { // Left click start drag link connection @@ -388,7 +389,33 @@ const ConnectionLines: React.FC = ({ // ========================================== export const WaypointEditorPage: React.FC = () => { - const [waypoints, setWaypoints] = useState([]); + // Use lazy initialization to load from localStorage on mount + const [waypoints, setWaypoints] = useState(() => { + console.log( + "[Initialisation] Chargement des waypoints depuis localStorage...", + ); + const saved = localStorage.getItem("la-fabrik-waypoints"); + if (saved) { + try { + const list = JSON.parse(saved); + console.log( + `[Initialisation] ${list.length} waypoints chargés avec succès !`, + ); + return list; + } catch (e) { + console.error( + "[Initialisation] Erreur de parsing du stockage local", + e, + ); + return []; + } + } else { + console.log( + "[Initialisation] Aucun point enregistré en localStorage. Démarrage à vide.", + ); + return []; + } + }); const [selectedId, setSelectedId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); @@ -425,38 +452,35 @@ export const WaypointEditorPage: React.FC = () => { number | null >(null); - // Load from localstorage on mount - useEffect(() => { - console.log( - "[Initialisation] Chargement des waypoints depuis localStorage...", - ); - const saved = localStorage.getItem("la-fabrik-waypoints"); - if (saved) { - try { - const list = JSON.parse(saved); - console.log( - `[Initialisation] ${list.length} waypoints chargés avec succès !`, - ); - setWaypoints(list); - } catch (e) { - console.error( - "[Initialisation] Erreur de parsing du stockage local", - e, - ); - } - } else { - console.log( - "[Initialisation] Aucun point enregistré en localStorage. Démarrage à vide.", - ); - } - }, []); - // Save to localstorage when waypoints change const saveWaypoints = (list: Waypoint[]) => { setWaypoints(list); localStorage.setItem("la-fabrik-waypoints", JSON.stringify(list)); }; + // Delete current selected node + const handleDeleteNode = (id: number) => { + console.log( + `[Suppression] Action de suppression définitive du Point : ID = ${id}`, + ); + setWaypoints((currentWaypoints) => { + const updatedList = currentWaypoints + .filter((wp) => wp.id !== id) + .map((wp) => ({ + ...wp, + connections: wp.connections.filter((cId) => cId !== id), + })); + console.log( + `[Suppression] Point ${id} supprimé. ${updatedList.length} points restants.`, + ); + localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList)); + return updatedList; + }); + setSelectedId((currentSelected) => + currentSelected === id ? null : currentSelected, + ); + }; + // Delete a specific connection (break the link) const deleteSelectedConnection = (idA: number, idB: number) => { console.log( @@ -673,29 +697,6 @@ export const WaypointEditorPage: React.FC = () => { } }; - // Delete current selected node - const handleDeleteNode = (id: number) => { - console.log( - `[Suppression] Action de suppression définitive du Point : ID = ${id}`, - ); - setWaypoints((currentWaypoints) => { - const updatedList = currentWaypoints - .filter((wp) => wp.id !== id) - .map((wp) => ({ - ...wp, - connections: wp.connections.filter((cId) => cId !== id), - })); - console.log( - `[Suppression] Point ${id} supprimé. ${updatedList.length} points restants.`, - ); - localStorage.setItem("la-fabrik-waypoints", JSON.stringify(updatedList)); - return updatedList; - }); - setSelectedId((currentSelected) => - currentSelected === id ? null : currentSelected, - ); - }; - // Connect Mode Trigger const startConnecting = (id: number) => { console.log( diff --git a/src/pathfinding/useGPS.ts b/src/pathfinding/useGPS.ts index fbb7780..b005900 100644 --- a/src/pathfinding/useGPS.ts +++ b/src/pathfinding/useGPS.ts @@ -38,8 +38,6 @@ export function useGPS({ // Initialize the pathfinding grid useEffect(() => { let active = true; - setLoading(true); - setError(null); async function initGrid() { try { @@ -63,9 +61,13 @@ export function useGPS({ colorMapImgRef.current = colorMapImg; setLoading(false); } - } catch (err: any) { + } catch (err: unknown) { if (active) { - setError(err.message || "Failed to initialize GPS system"); + const message = + err instanceof Error + ? err.message + : "Failed to initialize GPS system"; + setError(message); setLoading(false); } } diff --git a/src/pathfinding/useWaypointGPS.ts b/src/pathfinding/useWaypointGPS.ts index 201e012..e93643e 100644 --- a/src/pathfinding/useWaypointGPS.ts +++ b/src/pathfinding/useWaypointGPS.ts @@ -23,8 +23,6 @@ export function useWaypointGPS({ // Load waypoint list and background color map image useEffect(() => { let active = true; - setLoading(true); - setError(null); async function initGPS() { try { @@ -49,9 +47,13 @@ export function useWaypointGPS({ colorMapImgRef.current = colorMapImg; setLoading(false); } - } catch (err: any) { + } catch (err: unknown) { if (active) { - setError(err.message || "Failed to initialize Waypoint GPS"); + const message = + err instanceof Error + ? err.message + : "Failed to initialize Waypoint GPS"; + setError(message); setLoading(false); } } diff --git a/src/types/ebike/ebikeWindow.ts b/src/types/ebike/ebikeWindow.ts new file mode 100644 index 0000000..90cd966 --- /dev/null +++ b/src/types/ebike/ebikeWindow.ts @@ -0,0 +1,11 @@ +import type * as THREE from "three"; +import type { Vector3Tuple } from "@/types/three/three"; + +declare global { + interface Window { + ebikeVisualGroup: React.RefObject | null; + ebikeParkedPosition: Vector3Tuple | null; + ebikeParkedRotation: number | null; + ebikeSteerFactor: number | undefined; + } +} diff --git a/src/types/three/three.ts b/src/types/three/three.ts index 70cd0f0..36c70ff 100644 --- a/src/types/three/three.ts +++ b/src/types/three/three.ts @@ -1,4 +1,5 @@ import type { Octree } from "three-stdlib"; +import type * as THREE from "three"; export type Vector3Tuple = [number, number, number]; @@ -13,3 +14,21 @@ export interface ModelTransformProps { export type ColliderShape = "cuboid" | "ball" | "hull"; export type OctreeReadyHandler = (octree: Octree) => void; + +/** + * Keys for texture slots that may exist on various material types. + */ +export type TextureMaterialKey = Extract< + | keyof THREE.MeshBasicMaterial + | keyof THREE.MeshStandardMaterial + | keyof THREE.MeshPhysicalMaterial + | keyof THREE.MeshToonMaterial, + string +>; + +/** + * Interface for materials that may have texture slots. + * Used for type-safe texture diagnostic access and disposal. + */ +export type MaterialWithTextureSlots = THREE.Material & + Partial>; diff --git a/src/utils/three/dispose.ts b/src/utils/three/dispose.ts index 87b5275..42fd67b 100644 --- a/src/utils/three/dispose.ts +++ b/src/utils/three/dispose.ts @@ -1,21 +1,18 @@ import * as THREE from "three"; - -type TextureMaterialKey = Extract< - | keyof THREE.MeshBasicMaterial - | keyof THREE.MeshStandardMaterial - | keyof THREE.MeshPhysicalMaterial - | keyof THREE.MeshToonMaterial, - string ->; - -type MaterialWithTextureSlots = THREE.Material & - Partial>; +import type { + MaterialWithTextureSlots, + TextureMaterialKey, +} from "@/types/three/three"; interface DisposeObject3DOptions { disposeTextures?: boolean; } -const MATERIAL_TEXTURE_KEYS = [ +/** + * Common texture slot keys found on Three.js materials. + * Exported for use in texture diagnostics and disposal. + */ +export const MATERIAL_TEXTURE_KEYS = [ "alphaMap", "aoMap", "bumpMap", @@ -40,6 +37,8 @@ const MATERIAL_TEXTURE_KEYS = [ "transmissionMap", ] as const satisfies readonly TextureMaterialKey[]; +export type { MaterialWithTextureSlots }; + export function disposeObject3D( object: THREE.Object3D, options: DisposeObject3DOptions = {}, @@ -57,6 +56,25 @@ export function disposeObject3D( }); } +/** + * Disposes only materials (not geometry) from an Object3D and its children. + * Useful for cloned models where you want to preserve the original geometry. + */ +export function disposeModelMaterials( + object: THREE.Object3D, + options: DisposeObject3DOptions = {}, +): void { + object.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + + if (Array.isArray(child.material)) { + child.material.forEach((material) => disposeMaterial(material, options)); + } else if (child.material) { + disposeMaterial(child.material, options); + } + }); +} + function disposeMaterial( material: THREE.Material, options: DisposeObject3DOptions, diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx index 78edb2f..430bc66 100644 --- a/src/world/Environment.tsx +++ b/src/world/Environment.tsx @@ -37,7 +37,7 @@ export function Environment(): React.JSX.Element { {showSky ? ( { + let cancelled = false; + // 1. Try localStorage const saved = localStorage.getItem("la-fabrik-waypoints"); if (saved) { @@ -111,7 +113,10 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { console.log( `[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`, ); - setWaypoints(parsed); + // Schedule state update to avoid synchronous setState in effect + queueMicrotask(() => { + if (!cancelled) setWaypoints(parsed); + }); return; } } catch (e) { @@ -129,6 +134,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { throw new Error("Impossible de charger /roadNetwork.json"); }) .then((data) => { + if (cancelled) return; if (Array.isArray(data)) { console.log( `[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`, @@ -139,6 +145,10 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { .catch((err) => { console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err); }); + + return () => { + cancelled = true; + }; }, []); return ( diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index 122a0bc..2298f9c 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -29,7 +29,22 @@ import { InteractionManager } from "@/managers/InteractionManager"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import type { Vector3Tuple } from "@/types/three/three"; -import { EBIKE_CAMERA_TRANSFORM } from "@/components/ebike/Ebike"; +import { EBIKE_CAMERA_TRANSFORM } from "@/data/ebike/ebikeConfig"; + +/** Global window properties used for ebike communication */ +interface EbikeGlobalState { + ebikeParkedPosition?: Vector3Tuple; + ebikeParkedRotation?: number; + ebikeSteerFactor?: number; + ebikeVisualGroup?: React.RefObject; + playerPos?: Vector3Tuple; + ebikeAngle?: number; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Extending Window with EbikeGlobalState properties + interface Window extends EbikeGlobalState {} +} type Keys = { forward: boolean; @@ -146,12 +161,11 @@ export function PlayerController({ useEffect(() => { movementModeRef.current = movementMode; }, [movementMode]); + // eslint-disable-next-line react-hooks/immutability -- Three.js camera properties (position, rotation, fov) must be mutated directly; this is the standard pattern for R3F useEffect(() => { if (movementMode === "ebike") { - const targetPos: Vector3Tuple = (window as any).ebikeParkedPosition || [ - 0, 8.2, 0, - ]; - const targetRot: number = (window as any).ebikeParkedRotation || 0; + const targetPos: Vector3Tuple = window.ebikeParkedPosition ?? [0, 8.2, 0]; + const targetRot: number = window.ebikeParkedRotation ?? 0; const headY = targetPos[1] + PLAYER_EYE_HEIGHT; const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS; @@ -189,6 +203,7 @@ export function PlayerController({ prevMovementModeRef.current === "ebike" ) { const perspectiveCam = camera as THREE.PerspectiveCamera; + // eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes perspectiveCam.fov = 60; perspectiveCam.updateProjectionMatrix(); @@ -300,6 +315,7 @@ export function PlayerController({ }; }, []); + // eslint-disable-next-line react-hooks/immutability -- Three.js camera properties (position, rotation, fov) must be mutated directly in frame loop; this is the standard pattern for R3F game loops useFrame((_, delta) => { if (!initializedRef.current) return; @@ -435,17 +451,18 @@ export function PlayerController({ if (keys.current.left) targetSteer = 1; else if (keys.current.right) targetSteer = -1; - const currentSteer = (window as any).ebikeSteerFactor || 0; + const currentSteer = window.ebikeSteerFactor ?? 0; const steerFactor = THREE.MathUtils.lerp( currentSteer, targetSteer, 8 * dt, ); - (window as any).ebikeSteerFactor = steerFactor; + window.ebikeSteerFactor = steerFactor; const speed = velocity.current.length(); const targetFov = 60 + Math.min(speed * 0.35, 9); const perspectiveCam = camera as THREE.PerspectiveCamera; + // eslint-disable-next-line react-hooks/immutability -- Three.js camera.fov must be mutated directly for dynamic FOV changes during frame updates perspectiveCam.fov = THREE.MathUtils.lerp( perspectiveCam.fov, targetFov, @@ -482,7 +499,7 @@ export function PlayerController({ ); camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ"); - const ebikeVisual = (window as any).ebikeVisualGroup?.current; + const ebikeVisual = window.ebikeVisualGroup?.current; if (ebikeVisual) { ebikeVisual.position.set( capsule.current.end.x, @@ -496,12 +513,12 @@ export function PlayerController({ camera.position.copy(capsule.current.end); } - (window as any).playerPos = [ + window.playerPos = [ capsule.current.end.x, capsule.current.end.y, capsule.current.end.z, ]; - (window as any).ebikeAngle = ebikeAngle.current; + window.ebikeAngle = ebikeAngle.current; }); return null;