diff --git a/public/map_background.png b/public/map_background.png new file mode 100644 index 0000000..1c439a9 --- /dev/null +++ b/public/map_background.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:330ec029e4398006c02c40d4fb1b57d7d100b857220dd83d092b5f38e33be683 +size 731900 diff --git a/src/components/ebike/EbikeGPSMap.tsx b/src/components/ebike/EbikeGPSMap.tsx index 86d6d8c..140ed3b 100644 --- a/src/components/ebike/EbikeGPSMap.tsx +++ b/src/components/ebike/EbikeGPSMap.tsx @@ -9,7 +9,7 @@ export interface EbikeGPSMapProps { * If omitted, snaps to [0,0,0] */ startPos?: { x: number; y: number; z: number }; - + /** * 3D world position of the destination/target (GPS end point) */ @@ -59,7 +59,7 @@ export const EbikeGPSMap: React.FC = ({ }) => { const [waypoints, setWaypoints] = useState([]); const [mapImage, setMapImage] = useState(null); - + // Offscreen high-res canvas for crystal clear rendering const [offscreenCanvas] = useState(() => { const canvas = document.createElement('canvas'); @@ -110,7 +110,6 @@ export const EbikeGPSMap: React.FC = ({ } const img = new Image(); - img.crossOrigin = 'anonymous'; img.onload = () => setMapImage(img); img.onerror = () => { console.warn(`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`); @@ -175,6 +174,7 @@ export const EbikeGPSMap: React.FC = ({ const draw = () => { const canvas = offscreenCanvas; const ctx = canvas.getContext('2d'); + ctx.globalAlpha = 0.5; if (!ctx) return; const size = canvas.width; @@ -336,7 +336,7 @@ export const EbikeGPSMap: React.FC = ({ if (startPosSnapped) { const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size); - + // Start Marker (Player Arrow/Dot) ctx.beginPath(); ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI); diff --git a/src/pages/backgroundmap/page.tsx b/src/pages/backgroundmap/page.tsx new file mode 100644 index 0000000..ee67366 --- /dev/null +++ b/src/pages/backgroundmap/page.tsx @@ -0,0 +1,251 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { MapControls, OrthographicCamera, useGLTF } from '@react-three/drei'; +import * as THREE from 'three'; + +// ---------------------------------------------------------------------------- +// 1. Terrain Scene +// ---------------------------------------------------------------------------- +function TerrainScene() { + const { scene } = useGLTF('/models/terrain/terrain.glb'); + return ( + + + + + + ); +} + +// ---------------------------------------------------------------------------- +// 2. Waypoint Overlay (Debug visualization) +// ---------------------------------------------------------------------------- +function WaypointOverlay({ waypoints, visible }: { waypoints: any[], visible: boolean }) { + if (!visible) return null; + return ( + + {waypoints.map((w) => ( + + + + + ))} + + ); +} + +// ---------------------------------------------------------------------------- +// 3. Camera Manager (Handles Orthographic Math & Downloads) +// ---------------------------------------------------------------------------- +function CameraManager({ + autoBounds, + boundsTextRef +}: { + autoBounds: any, + boundsTextRef: React.RefObject +}) { + const { camera, gl, scene } = useThree(); + const controlsRef = useRef(null); + + // 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; + + 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(); + + if (controlsRef.current) { + controlsRef.current.target.set(centerX, 0, centerZ); + controlsRef.current.update(); + } + } + }; + + (window as any).applyAutoBounds = applyAutoBounds; + // Initial apply + applyAutoBounds(); + + return () => { delete (window as any).applyAutoBounds; }; + }, [camera, autoBounds]); + + // 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); + + // Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!) + boundsTextRef.current.innerText = JSON.stringify({ minX, maxX, minZ, maxZ }, null, 2); + } + }); + + // Attach screenshot capture logic + useEffect(() => { + (window as any).downloadMapScreenshot = () => { + // Force an immediate render frame to ensure no UI overlays are missing + gl.render(scene, camera); + const dataUrl = gl.domElement.toDataURL("image/png"); + const a = document.createElement("a"); + a.href = dataUrl; + a.download = "map_background.png"; + a.click(); + }; + return () => { delete (window as any).downloadMapScreenshot; }; + }, [gl, camera, scene]); + + return ; +} + +// ---------------------------------------------------------------------------- +// 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(() => { + const saved = localStorage.getItem('la-fabrik-waypoints'); + if (saved) { + setWaypoints(JSON.parse(saved)); + } else { + fetch('/roadNetwork.json') + .then(res => res.json()) + .then(data => setWaypoints(data)) + .catch(() => { }); + } + }, []); + + // Compute exact bounds that the EbikeGPSMap will use by default + const autoBounds = useMemo(() => { + if (waypoints.length === 0) return null; + const xs = waypoints.map(w => w.x); + const zs = waypoints.map(w => w.z); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minZ = Math.min(...zs); + const maxZ = Math.max(...zs); + + // CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE. + // If the camera is rectangular, the exported PNG will be distorted when drawn + // on the EbikeGPSMap's 1024x1024 canvas! + const width = maxX - minX; + const height = maxZ - minZ; + const maxDim = Math.max(width, height); + + const centerX = (minX + maxX) / 2; + const centerZ = (minZ + maxZ) / 2; + + const paddedDim = maxDim * 1.15 || 100; + + return { + minX: centerX - paddedDim / 2, + maxX: centerX + paddedDim / 2, + minZ: centerZ - paddedDim / 2, + maxZ: centerZ + paddedDim / 2, + }; + }, [waypoints]); + + return ( +
+ {/* + CRITICAL: The DOM element MUST be a perfect square so the resulting PNG + is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture! + */} +
+ + + + + + +
+ + {/* Premium Glassmorphic UI Dashboard */} +
+

GPS Map Generator

+ +

+ 1. Cadrez votre carte (ou utilisez le Cadrage Automatique).
+ 2. Masquez les waypoints (fond visuel seul).
+ 3. Cliquez sur Capturer la carte. +

+ + + + + + + +
+
Limites Actuelles (worldBounds):
+
+            Calcul...
+          
+
+ *Si vous décadrez à la souris, vous devrez copier ces valeurs exactes dans la prop worldBounds de votre composant EbikeGPSMap ! +

+ Astuce : Utilisez le Cadrage Automatique pour ne rien avoir à configurer. +
+
+
+
+ ); +} diff --git a/src/router.tsx b/src/router.tsx index 91a9b1d..0bfa7e0 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -7,6 +7,7 @@ import { import { HomePage } from "@/pages/page"; import { EditorPage } from "@/pages/editor/page"; import { WaypointEditorPage } from "@/pages/waypoint/page"; +import { BackgroundMapPage } from "@/pages/backgroundmap/page"; import { DocsAnimationRoute, DocsAudioRoute, @@ -50,6 +51,12 @@ const waypointRoute = createRoute({ component: WaypointEditorPage, }); +const backgroundMapRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/backgroundmap", + component: BackgroundMapPage, +}); + const docsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/docs", @@ -86,6 +93,7 @@ const routeTree = rootRoute.addChildren([ indexRoute, editorRoute, waypointRoute, + backgroundMapRoute, docsRoute.addChildren(docsChildRoutes), ]); diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index ee7772e..ea2868a 100644 --- a/src/world/debug/TestMap.tsx +++ b/src/world/debug/TestMap.tsx @@ -147,7 +147,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { {/* Render Pathfinder Maps Waypoints & Routes visually */} {/* Render Connection Lines */} - {waypoints.flatMap((wp) => + {waypoints.flatMap((wp) => wp.connections.map((connId) => { const other = waypoints.find((w) => w.id === connId); // Draw each line only once by enforcing wp.id < other.id @@ -174,7 +174,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { {waypoints.map((wp) => ( - {/* GPS Map screen plane */} -