diff --git a/package-lock.json b/package-lock.json index eff01c0..fd95c3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "0.1.0", "dependencies": { "@stellar/stellar-sdk": "^15.1.0", + "@types/three": "^0.180.0", "bignumber.js": "^11.1.3", "idb": "^8.0.3", "intl-messageformat": "^11.2.8", "next": "16.2.9", "qrcode": "^1.5.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "three": "^0.180.0" }, "devDependencies": { "@playwright/test": "^1.60.0", @@ -514,6 +516,12 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2744,6 +2752,12 @@ } } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2859,6 +2873,33 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", + "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", @@ -3617,6 +3658,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -5352,6 +5399,12 @@ "is-retry-allowed": "^3.0.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5471,7 +5524,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6948,6 +7000,12 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8563,6 +8621,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 8820deb..bb5c1b4 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,15 @@ }, "dependencies": { "@stellar/stellar-sdk": "^15.1.0", + "@types/three": "^0.180.0", "bignumber.js": "^11.1.3", "idb": "^8.0.3", "intl-messageformat": "^11.2.8", "next": "16.2.9", "qrcode": "^1.5.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "three": "^0.180.0" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/src/components/map/ThreeOverlay.tsx b/src/components/map/ThreeOverlay.tsx new file mode 100644 index 0000000..687a18c --- /dev/null +++ b/src/components/map/ThreeOverlay.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { + GPU_MEMORY_BUDGET_BYTES, + LOD_DISTANCES, + type AssetInstance, + type LODLevel, +} from "@/types/spatial"; +import { buildAssetLOD, createCircleSpriteTexture } from "@/components/map/lod/AssetMesh"; +import type { LODDistances } from "@/components/map/lod/QuadTree"; +import { forwardVector } from "@/utils/cameraMatrix"; +import { + useMapCameraSync, + type MapLike, +} from "@/hooks/useMapCameraSync"; +import type { + FrustumCullRequest, + FrustumCullResponse, +} from "@/workers/frustumCull.worker"; + +/** Map local world (x east, y north, z up) → Three.js (x, y up, z). */ +function worldToThree(p: { x: number; y: number; z: number }): THREE.Vector3 { + return new THREE.Vector3(p.x, p.z, -p.y); +} + +export interface ThreeOverlayProps { + /** The Mapbox map (camera source). */ + map: MapLike | null; + /** The Mapbox container the Three canvas is overlaid onto. */ + container: HTMLElement | null; + assets: AssetInstance[]; + enabled?: boolean; +} + +/** + * Three.js overlay composited over the Mapbox WebGL canvas. A transparent, + * pointer-events-none canvas is absolutely positioned over the map container so + * Mapbox keeps handling interaction. Each frame the camera is synced from + * `map.transform`, a worker culls the QuadTree, and the resulting visible assets + * are rendered at their LOD. A memory watchdog degrades LOD distances when the + * estimated GPU footprint exceeds the budget. + */ +export function ThreeOverlay({ + map, + container, + assets, + enabled = true, +}: ThreeOverlayProps) { + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const workerRef = useRef(null); + const lodByIdRef = useRef>(new Map()); + const visibleIdsRef = useRef>(new Set()); + const lodDistancesRef = useRef({ ...LOD_DISTANCES }); + const requestIdRef = useRef(0); + + // --- Renderer / scene lifecycle ---------------------------------------- + useEffect(() => { + if (!enabled || !container) return; + + const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + const canvas = renderer.domElement; + canvas.style.position = "absolute"; + canvas.style.inset = "0"; + canvas.style.pointerEvents = "none"; // let Mapbox handle interaction + container.appendChild(canvas); + + const scene = new THREE.Scene(); + scene.add(new THREE.AmbientLight(0xffffff, 0.8)); + const sun = new THREE.DirectionalLight(0xffffff, 0.6); + sun.position.set(100, 200, 100); + scene.add(sun); + + const camera = new THREE.PerspectiveCamera( + 60, + container.clientWidth / Math.max(1, container.clientHeight), + 1, + 5000 + ); + + rendererRef.current = renderer; + sceneRef.current = scene; + cameraRef.current = camera; + + const onResize = () => { + renderer.setSize(container.clientWidth, container.clientHeight); + camera.aspect = container.clientWidth / Math.max(1, container.clientHeight); + camera.updateProjectionMatrix(); + }; + window.addEventListener("resize", onResize); + + return () => { + window.removeEventListener("resize", onResize); + canvas.remove(); + renderer.dispose(); + rendererRef.current = null; + sceneRef.current = null; + cameraRef.current = null; + }; + }, [container, enabled]); + + // --- Spawn the frustum-cull worker ------------------------------------- + useEffect(() => { + if (!enabled) return; + let worker: Worker; + try { + worker = new Worker( + new URL("../../workers/frustumCull.worker.ts", import.meta.url), + { type: "module" } + ); + } catch { + return; // worker unsupported — overlay simply renders nothing + } + workerRef.current = worker; + + worker.onmessage = (e: MessageEvent) => { + const msg = e.data; + if (msg.type === "result") { + const ids = new Set(); + for (const v of msg.visible) { + ids.add(v.id); + applyLod(v.id, v.lod); + } + // Hide assets that left the visible set. + for (const id of visibleIdsRef.current) { + if (!ids.has(id)) { + const lod = lodByIdRef.current.get(id); + if (lod) lod.visible = false; + } + } + visibleIdsRef.current = ids; + } + }; + + return () => { + worker.terminate(); + workerRef.current = null; + }; + }, [enabled]); + + // --- Load assets into the scene and the worker ------------------------- + useEffect(() => { + const scene = sceneRef.current; + if (!scene) return; + + const spriteTexture = createCircleSpriteTexture(); + const map_ = lodByIdRef.current; + + for (const asset of assets) { + const lod = buildAssetLOD(asset.type, { spriteTexture }); + lod.position.copy(worldToThree(asset.position)); + lod.visible = false; + scene.add(lod); + map_.set(asset.id, lod); + } + + const loadMsg: FrustumCullRequest = { type: "load", assets }; + workerRef.current?.postMessage(loadMsg); + + return () => { + for (const lod of map_.values()) scene.remove(lod); + map_.clear(); + visibleIdsRef.current.clear(); + }; + }, [assets]); + + // --- Camera sync + render loop ----------------------------------------- + useMapCameraSync({ + map, + enabled, + onSync: (cam, viewProjection) => { + const renderer = rendererRef.current; + const scene = sceneRef.current; + const camera = cameraRef.current; + if (!renderer || !scene || !camera) return; + + // Position/orient the Three camera from the synced Mapbox camera. + camera.position.copy(worldToThree(cam.position)); + const fwd = forwardVector(cam.heading, cam.pitch); + camera.lookAt( + camera.position.x + fwd.x, + camera.position.y + fwd.z, + camera.position.z - fwd.y + ); + camera.updateMatrixWorld(); + + // Update each visible LOD against the camera, then render. + for (const id of visibleIdsRef.current) { + lodByIdRef.current.get(id)?.update(camera); + } + renderer.render(scene, camera); + + maybeDegradeForMemory(renderer); + + // Kick an off-thread cull for the next frame. + const worker = workerRef.current; + if (worker) { + const queryMsg: FrustumCullRequest = { + type: "query", + requestId: ++requestIdRef.current, + viewProjection, + cameraPosition: cam.position, + lodDistances: lodDistancesRef.current, + }; + worker.postMessage(queryMsg); + } + }, + }); + + function applyLod(id: string, lod: LODLevel): void { + const obj = lodByIdRef.current.get(id); + if (!obj) return; + obj.visible = true; + void lod; // exact level is chosen by THREE.LOD.update against the camera + } + + /** + * Rough GPU-memory watchdog. `renderer.info` exposes counts, not bytes, so we + * estimate from geometry/texture counts and degrade the LOD0 distance when the + * estimate crosses the budget (and restore it when it recovers). + */ + function maybeDegradeForMemory(renderer: THREE.WebGLRenderer): void { + const { geometries, textures } = renderer.info.memory; + // Coarse estimate: ~64 KB per geometry, ~256 KB per texture. + const estimate = geometries * 64 * 1024 + textures * 256 * 1024; + if (estimate > GPU_MEMORY_BUDGET_BYTES) { + lodDistancesRef.current = { full: 50, simplified: 200, impostor: 500 }; + } else { + lodDistancesRef.current = { ...LOD_DISTANCES }; + } + } + + return null; +} + +export default ThreeOverlay; diff --git a/src/components/map/lod/AssetMesh.ts b/src/components/map/lod/AssetMesh.ts new file mode 100644 index 0000000..4d07793 --- /dev/null +++ b/src/components/map/lod/AssetMesh.ts @@ -0,0 +1,144 @@ +/** + * Three.js mesh factory for utility assets with built-in Level-of-Detail. + * + * Each asset type maps to a {@link THREE.LOD} carrying three representations: + * - LOD0: a detailed mesh (an injected GLTF scene, or a beveled box fallback) + * - LOD1: a simplified box + * - LOD2: a billboarded impostor sprite + * Beyond the impostor distance an empty level hides the asset. + * + * Identical asset types are also offered as a single {@link THREE.InstancedMesh} + * to collapse thousands of draw calls into one. + */ + +import * as THREE from "three"; +import { + LOD_DISTANCES, + type AssetInstance, + type AssetType, +} from "@/types/spatial"; + +interface AssetTemplate { + color: number; + /** Box half-extents (meters): width, height, depth. */ + size: [number, number, number]; +} + +const ASSET_TEMPLATES: Record = { + meter: { color: 0x22c55e, size: [1, 2, 1] }, + valve: { color: 0xf59e0b, size: [1.5, 1, 1.5] }, + substation: { color: 0xef4444, size: [6, 4, 6] }, +}; + +/** Generate a soft circular texture for impostor sprites. */ +export function createCircleSpriteTexture(size = 64): THREE.Texture | null { + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const r = size / 2; + const gradient = ctx.createRadialGradient(r, r, 0, r, r, r); + gradient.addColorStop(0, "rgba(255,255,255,1)"); + gradient.addColorStop(0.7, "rgba(255,255,255,0.9)"); + gradient.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(r, r, r, 0, Math.PI * 2); + ctx.fill(); + return new THREE.CanvasTexture(canvas); +} + +function detailedBox(template: AssetTemplate): THREE.Mesh { + const [w, h, d] = template.size; + const geometry = new THREE.BoxGeometry(w, h, d, 2, 2, 2); + const material = new THREE.MeshStandardMaterial({ + color: template.color, + roughness: 0.6, + metalness: 0.1, + }); + return new THREE.Mesh(geometry, material); +} + +function simplifiedBox(template: AssetTemplate): THREE.Mesh { + const [w, h, d] = template.size; + const geometry = new THREE.BoxGeometry(w, h, d); + const material = new THREE.MeshBasicMaterial({ color: template.color }); + return new THREE.Mesh(geometry, material); +} + +function impostorSprite( + template: AssetTemplate, + texture: THREE.Texture | null +): THREE.Sprite { + const material = new THREE.SpriteMaterial({ + color: template.color, + map: texture ?? undefined, + sizeAttenuation: true, + }); + const sprite = new THREE.Sprite(material); + const scale = Math.max(...template.size); + sprite.scale.set(scale, scale, 1); + return sprite; +} + +export interface BuildLODOptions { + /** Detailed LOD0 object (e.g. a loaded GLTF scene). Falls back to a box. */ + lod0?: THREE.Object3D; + /** Shared sprite texture for impostors. */ + spriteTexture?: THREE.Texture | null; +} + +/** + * Build a {@link THREE.LOD} for an asset type. Level distances match the + * {@link LOD_DISTANCES} invariants; an empty level past the impostor distance + * hides the asset entirely. + */ +export function buildAssetLOD( + type: AssetType, + options: BuildLODOptions = {} +): THREE.LOD { + const template = ASSET_TEMPLATES[type]; + const lod = new THREE.LOD(); + + lod.addLevel(options.lod0 ?? detailedBox(template), 0); + lod.addLevel(simplifiedBox(template), LOD_DISTANCES.full); + lod.addLevel(impostorSprite(template, options.spriteTexture ?? null), LOD_DISTANCES.simplified); + // Empty object beyond the impostor distance → culled. + lod.addLevel(new THREE.Object3D(), LOD_DISTANCES.impostor); + + return lod; +} + +/** + * Create one {@link THREE.InstancedMesh} for many identical assets of a type, + * baking each instance's position/rotation/scale into the instance matrix. + */ +export function createInstancedMeshes( + type: AssetType, + instances: AssetInstance[] +): THREE.InstancedMesh { + const template = ASSET_TEMPLATES[type]; + const [w, h, d] = template.size; + const geometry = new THREE.BoxGeometry(w, h, d); + const material = new THREE.MeshStandardMaterial({ color: template.color }); + const mesh = new THREE.InstancedMesh(geometry, material, instances.length); + + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + const up = new THREE.Vector3(0, 1, 0); + + instances.forEach((inst, i) => { + position.set(inst.position.x, inst.position.z, -inst.position.y); + quaternion.setFromAxisAngle(up, inst.rotation ?? 0); + const s = inst.scale ?? 1; + scale.set(s, s, s); + matrix.compose(position, quaternion, scale); + mesh.setMatrixAt(i, matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + return mesh; +} diff --git a/src/components/map/lod/QuadTree.ts b/src/components/map/lod/QuadTree.ts new file mode 100644 index 0000000..e0524f4 --- /dev/null +++ b/src/components/map/lod/QuadTree.ts @@ -0,0 +1,230 @@ +/** + * QuadTree spatial index for the 3D asset overlay. + * + * The 100 km × 100 km region is recursively subdivided into four quadrants (up + * to {@link QUADTREE_MAX_DEPTH} levels). Leaf nodes hold asset references; + * every node tracks the aggregated 3D extent of its subtree so a frustum query + * can reject whole branches with a single bounding-sphere test. Visible assets + * are returned tagged with a Level-of-Detail derived from camera distance. + * + * Pure data structure — no Three.js — so it can run inside a Web Worker. + */ + +import { + BATCH_CULL_TARGET, + LODLevel, + LOD_DISTANCES, + MAX_DRAWABLE_ASSETS, + QUADTREE_MAX_DEPTH, + regionBounds, + type AABB, + type AssetInstance, + type BoundingSphere, + type Coordinate3D, + type FrustumPlane, + type VisibleAsset, +} from "@/types/spatial"; +import { distance3D, sphereInFrustum } from "@/utils/frustum"; + +/** Leaf capacity before a node subdivides (while under the depth cap). */ +const NODE_CAPACITY = 16; + +export interface LODDistances { + full: number; + simplified: number; + impostor: number; +} + +/** Select the LOD bucket for a camera distance (meters). */ +export function getLODLevel( + distance: number, + distances: LODDistances = LOD_DISTANCES +): LODLevel { + if (distance < distances.full) return LODLevel.Full; + if (distance < distances.simplified) return LODLevel.Simplified; + if (distance < distances.impostor) return LODLevel.Impostor; + return LODLevel.Culled; +} + +export interface QueryOptions { + cameraPosition: Coordinate3D; + /** Override LOD switch distances (e.g. the memory watchdog degrading them). */ + lodDistances?: LODDistances; + /** Cap before batch culling. @default MAX_DRAWABLE_ASSETS */ + maxAssets?: number; + /** Batch-cull target once the cap is exceeded. @default BATCH_CULL_TARGET */ + cullTarget?: number; +} + +class QuadNode { + children: QuadNode[] | null = null; + assets: AssetInstance[] = []; + count = 0; + // Aggregated 3D extent of every asset in this subtree. + private minX = Infinity; + private minY = Infinity; + private minZ = Infinity; + private maxX = -Infinity; + private maxY = -Infinity; + private maxZ = -Infinity; + + constructor(readonly bounds: AABB, readonly depth: number) {} + + expand(p: Coordinate3D): void { + this.count += 1; + if (p.x < this.minX) this.minX = p.x; + if (p.y < this.minY) this.minY = p.y; + if (p.z < this.minZ) this.minZ = p.z; + if (p.x > this.maxX) this.maxX = p.x; + if (p.y > this.maxY) this.maxY = p.y; + if (p.z > this.maxZ) this.maxZ = p.z; + } + + /** Bounding sphere of the aggregated extent (empty → radius 0 at origin). */ + boundingSphere(): BoundingSphere { + if (this.count === 0) { + return { center: { x: 0, y: 0, z: 0 }, radius: 0 }; + } + const center = { + x: (this.minX + this.maxX) / 2, + y: (this.minY + this.maxY) / 2, + z: (this.minZ + this.maxZ) / 2, + }; + const radius = + 0.5 * + Math.hypot( + this.maxX - this.minX, + this.maxY - this.minY, + this.maxZ - this.minZ + ); + return { center, radius }; + } +} + +export class QuadTree { + private root: QuadNode; + private readonly bounds: AABB; + private _size = 0; + + constructor( + bounds: AABB = regionBounds(), + private readonly maxDepth: number = QUADTREE_MAX_DEPTH + ) { + this.bounds = bounds; + this.root = new QuadNode(bounds, 0); + } + + /** Total inserted assets. */ + get size(): number { + return this._size; + } + + /** Insert an asset, subdividing leaves that exceed capacity. */ + insert(asset: AssetInstance): void { + this.insertInto(this.root, asset); + this._size += 1; + } + + /** Insert many assets. */ + insertAll(assets: Iterable): void { + for (const a of assets) this.insert(a); + } + + private insertInto(node: QuadNode, asset: AssetInstance): void { + node.expand(asset.position); + + if (node.children) { + this.insertInto(this.childFor(node, asset.position), asset); + return; + } + + node.assets.push(asset); + if (node.assets.length > NODE_CAPACITY && node.depth < this.maxDepth) { + this.subdivide(node); + } + } + + private subdivide(node: QuadNode): void { + const { minX, minY, maxX, maxY } = node.bounds; + const midX = (minX + maxX) / 2; + const midY = (minY + maxY) / 2; + const d = node.depth + 1; + // Order: SW, SE, NW, NE (matches childFor's index math). + node.children = [ + new QuadNode({ minX, minY, maxX: midX, maxY: midY }, d), + new QuadNode({ minX: midX, minY, maxX, maxY: midY }, d), + new QuadNode({ minX, minY: midY, maxX: midX, maxY }, d), + new QuadNode({ minX: midX, minY: midY, maxX, maxY }, d), + ]; + const pending = node.assets; + node.assets = []; + for (const a of pending) { + this.insertInto(this.childFor(node, a.position), a); + } + } + + private childFor(node: QuadNode, p: Coordinate3D): QuadNode { + const { minX, minY, maxX, maxY } = node.bounds; + const midX = (minX + maxX) / 2; + const midY = (minY + maxY) / 2; + const east = p.x >= midX ? 1 : 0; + const north = p.y >= midY ? 1 : 0; + return node.children![north * 2 + east]; + } + + /** + * Return the assets visible within `planes`, each tagged with a LOD level. + * Branches whose bounding sphere lies wholly outside the frustum are skipped. + * When the result exceeds `maxAssets`, the nearest `cullTarget` are kept. + */ + queryFrustum(planes: FrustumPlane[], options: QueryOptions): VisibleAsset[] { + const { + cameraPosition, + lodDistances = LOD_DISTANCES, + maxAssets = MAX_DRAWABLE_ASSETS, + cullTarget = BATCH_CULL_TARGET, + } = options; + + const results: VisibleAsset[] = []; + this.queryNode(this.root, planes, cameraPosition, lodDistances, results); + + if (results.length > maxAssets) { + // Density too high: keep the nearest assets (off-screen already removed). + results.sort((a, b) => a.distance - b.distance); + results.length = cullTarget; + } + return results; + } + + private queryNode( + node: QuadNode, + planes: FrustumPlane[], + camera: Coordinate3D, + distances: LODDistances, + out: VisibleAsset[] + ): void { + if (node.count === 0) return; + if (!sphereInFrustum(planes, node.boundingSphere())) return; + + if (node.children) { + for (const child of node.children) { + this.queryNode(child, planes, camera, distances, out); + } + return; + } + + for (const asset of node.assets) { + const distance = distance3D(camera, asset.position); + const lod = getLODLevel(distance, distances); + if (lod !== LODLevel.Culled) { + out.push({ asset, lod, distance }); + } + } + } + + /** Remove all assets. */ + clear(): void { + this.root = new QuadNode(this.bounds, 0); + this._size = 0; + } +} diff --git a/src/components/map/shaders/assetInstancing.vert b/src/components/map/shaders/assetInstancing.vert new file mode 100644 index 0000000..0075662 --- /dev/null +++ b/src/components/map/shaders/assetInstancing.vert @@ -0,0 +1,40 @@ +// Instanced vertex shader for rendering many identical utility-asset meshes +// with per-instance position, rotation (about the world-up axis) and scale. +// +// `instanceMatrix` is provided automatically by THREE.InstancedMesh; the +// per-instance attributes below are an alternative, more compact path when the +// transform is a simple position + yaw + uniform scale. + +precision highp float; + +// Standard Three.js uniforms. +uniform mat4 modelViewMatrix; +uniform mat4 projectionMatrix; + +// Per-vertex attributes. +attribute vec3 position; +attribute vec3 normal; + +// Per-instance attributes (set via InstancedBufferAttribute). +attribute vec3 instancePosition; // world offset (meters) +attribute float instanceRotation; // yaw about +Y, radians +attribute float instanceScale; // uniform scale + +varying vec3 vNormal; + +mat3 rotationY(float a) { + float c = cos(a); + float s = sin(a); + return mat3( + c, 0.0, -s, + 0.0, 1.0, 0.0, + s, 0.0, c + ); +} + +void main() { + mat3 rot = rotationY(instanceRotation); + vec3 local = rot * (position * instanceScale) + instancePosition; + vNormal = normalize(rot * normal); + gl_Position = projectionMatrix * modelViewMatrix * vec4(local, 1.0); +} diff --git a/src/hooks/useMapCameraSync.ts b/src/hooks/useMapCameraSync.ts new file mode 100644 index 0000000..fb6f4b6 --- /dev/null +++ b/src/hooks/useMapCameraSync.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { buildViewProjection, mercatorMeters, type Mat4 } from "@/utils/cameraMatrix"; +import type { CameraState } from "@/types/spatial"; + +/** + * Synchronizes a Three.js overlay camera with the Mapbox camera every animation + * frame. It reads `map.transform` (center, elevation, bearing, pitch), maps the + * mercator center into local world meters, and builds a view-projection matrix + * that the frustum-culling layer consumes. + * + * The Mapbox dependency is expressed via a minimal structural interface so the + * hook can be driven with a fake map and a fake rAF in tests. + */ + +export interface MapTransformLike { + _center?: { lng: number; lat: number }; + center?: { lng: number; lat: number }; + _elevation?: number; + elevation?: number; + /** Bearing / heading, degrees. */ + bearing: number; + /** Pitch, degrees. */ + pitch: number; + width: number; + height: number; +} + +export interface MapLike { + transform: MapTransformLike; +} + +export interface UseMapCameraSyncOptions { + map: MapLike | null; + /** Receives the camera state and column-major view-projection each frame. */ + onSync: (camera: CameraState, viewProjection: Mat4) => void; + enabled?: boolean; + /** Injectable rAF (tests). */ + raf?: (cb: FrameRequestCallback) => number; + /** Injectable cancel (tests). */ + cancelRaf?: (handle: number) => void; +} + +function centerOf(t: MapTransformLike): { lng: number; lat: number } { + return t._center ?? t.center ?? { lng: 0, lat: 0 }; +} + +function elevationOf(t: MapTransformLike): number { + return t._elevation ?? t.elevation ?? 0; +} + +/** Pure: derive the camera state from a transform, relative to a world origin. */ +export function readCameraState( + t: MapTransformLike, + origin: { lng: number; lat: number } +): CameraState { + const center = centerOf(t); + const altitude = elevationOf(t); + const { x, y } = mercatorMeters(center.lng, center.lat, origin); + return { + position: { x, y, z: altitude }, + longitude: center.lng, + latitude: center.lat, + altitude, + heading: t.bearing, + pitch: t.pitch, + }; +} + +/** Pure: build the view-projection for a transform relative to an origin. */ +export function viewProjectionForTransform( + t: MapTransformLike, + origin: { lng: number; lat: number } +): { camera: CameraState; viewProjection: Mat4 } { + const camera = readCameraState(t, origin); + const aspect = t.height > 0 ? t.width / t.height : 1; + const viewProjection = buildViewProjection({ + position: camera.position, + heading: camera.heading, + pitch: camera.pitch, + aspect, + }); + return { camera, viewProjection }; +} + +export function useMapCameraSync(options: UseMapCameraSyncOptions): void { + const { map, onSync, enabled = true } = options; + + const onSyncRef = useRef(onSync); + onSyncRef.current = onSync; + /** World origin (lng/lat of the first observed center), fixed for the session. */ + const originRef = useRef<{ lng: number; lat: number } | null>(null); + + useEffect(() => { + if (!enabled || !map) return; + const raf = + options.raf ?? + (typeof requestAnimationFrame !== "undefined" + ? requestAnimationFrame + : null); + const cancel = + options.cancelRaf ?? + (typeof cancelAnimationFrame !== "undefined" ? cancelAnimationFrame : null); + if (!raf) return; + + let handle = 0; + let stopped = false; + + const frame = () => { + if (stopped) return; + const t = map.transform; + if (t) { + if (originRef.current === null) { + originRef.current = centerOf(t); + } + const { camera, viewProjection } = viewProjectionForTransform( + t, + originRef.current + ); + onSyncRef.current(camera, viewProjection); + } + handle = raf(frame); + }; + + handle = raf(frame); + return () => { + stopped = true; + if (cancel) cancel(handle); + }; + // raf/cancelRaf are read once at effect start; map/enabled drive re-runs. + }, [map, enabled, options.raf, options.cancelRaf]); +} diff --git a/src/types/spatial.ts b/src/types/spatial.ts new file mode 100644 index 0000000..05b1c7e --- /dev/null +++ b/src/types/spatial.ts @@ -0,0 +1,125 @@ +/** + * Spatial types and invariants for the Three.js 3D asset overlay. + * + * Assets (meters, valves, substations) are indexed in a QuadTree covering a + * 100 km × 100 km region. Each frame the Mapbox camera is read, a view-frustum + * is derived, and the tree returns the visible assets tagged with a + * Level-of-Detail so detailed meshes can be swapped for impostor sprites at + * distance. + * + * Coordinates here are in a local metric ("world") space — meters relative to + * the region origin — not lat/lng. The camera-sync layer maps Mapbox mercator + * coordinates into this space. + */ + +/** A point in local world space (meters). */ +export interface Coordinate3D { + x: number; + y: number; + z: number; +} + +/** Axis-aligned bounding box in world space. */ +export interface AABB { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** Bounding sphere used for aggregated frustum tests on internal nodes. */ +export interface BoundingSphere { + center: Coordinate3D; + radius: number; +} + +/** + * A frustum plane in the form `ax + by + cz + d = 0` with the normal `(a,b,c)` + * pointing toward the interior. A point is inside the half-space when + * `a·x + b·y + c·z + d >= 0`. + */ +export interface FrustumPlane { + a: number; + b: number; + c: number; + d: number; +} + +/** Discrete level-of-detail buckets. */ +export enum LODLevel { + /** Full detail mesh (GLTF). */ + Full = 0, + /** Simplified mesh (box). */ + Simplified = 1, + /** Impostor sprite. */ + Impostor = 2, + /** Beyond the draw distance — not rendered. */ + Culled = 3, +} + +export type AssetType = "meter" | "valve" | "substation"; + +/** A single placeable asset instance. */ +export interface AssetInstance { + id: string; + type: AssetType; + /** Position in world space (meters). */ + position: Coordinate3D; + /** Y-axis rotation in radians. @default 0 */ + rotation?: number; + /** Uniform scale. @default 1 */ + scale?: number; +} + +/** An asset paired with the LOD it should render at this frame. */ +export interface VisibleAsset { + asset: AssetInstance; + lod: LODLevel; + /** Distance (m) from the camera to the asset. */ + distance: number; +} + +/** Mapbox camera state read from `map.transform` each frame. */ +export interface CameraState { + /** Camera position in world space (meters). */ + position: Coordinate3D; + longitude: number; + latitude: number; + /** Camera altitude (m). */ + altitude: number; + /** Bearing / heading in degrees. */ + heading: number; + /** Pitch in degrees. */ + pitch: number; +} + +// --- Invariants ------------------------------------------------------------- + +/** LOD switch distances in meters (inclusive lower bound, exclusive upper). */ +export const LOD_DISTANCES = { + /** 0–100 m → full mesh. */ + full: 100, + /** 100–300 m → simplified mesh. */ + simplified: 300, + /** 300–500 m → impostor sprite. */ + impostor: 500, +} as const; + +/** QuadTree covers a 100 km × 100 km region. */ +export const REGION_SIZE_M = 100_000; +/** Maximum QuadTree subdivision depth. */ +export const QUADTREE_MAX_DEPTH = 8; + +/** Hard cap on drawable assets per viewport before batch culling. */ +export const MAX_DRAWABLE_ASSETS = 20_000; +/** Batch-cull target once the cap is exceeded. */ +export const BATCH_CULL_TARGET = 10_000; + +/** GPU memory ceiling (bytes) before the overlay degrades its LOD distances. */ +export const GPU_MEMORY_BUDGET_BYTES = 512 * 1024 * 1024; + +/** The region's AABB centered on the origin. */ +export function regionBounds(size: number = REGION_SIZE_M): AABB { + const half = size / 2; + return { minX: -half, minY: -half, maxX: half, maxY: half }; +} diff --git a/src/utils/cameraMatrix.ts b/src/utils/cameraMatrix.ts new file mode 100644 index 0000000..95eeb9a --- /dev/null +++ b/src/utils/cameraMatrix.ts @@ -0,0 +1,135 @@ +/** + * Minimal column-major 4×4 camera matrix math (perspective, look-at, multiply) + * plus a web-mercator meter projection. Kept Three.js-free so the view-frustum + * derived here can be built and tested without a WebGL context — the QuadTree + * culling layer consumes the resulting view-projection matrix directly. + * + * All matrices are 16-element, column-major (element (row r, col c) = m[c*4+r]). + */ + +import type { Coordinate3D } from "@/types/spatial"; + +/** Earth radius (m) for the spherical web-mercator approximation. */ +const EARTH_RADIUS_M = 6_378_137; + +export type Mat4 = number[]; + +function normalize(v: Coordinate3D): Coordinate3D { + const len = Math.hypot(v.x, v.y, v.z) || 1; + return { x: v.x / len, y: v.y / len, z: v.z / len }; +} +function cross(a: Coordinate3D, b: Coordinate3D): Coordinate3D { + return { + x: a.y * b.z - a.z * b.y, + y: a.z * b.x - a.x * b.z, + z: a.x * b.y - a.y * b.x, + }; +} +function dot(a: Coordinate3D, b: Coordinate3D): number { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +/** Perspective projection (column-major). `fovY` in radians. */ +export function perspective( + fovY: number, + aspect: number, + near: number, + far: number +): Mat4 { + const f = 1 / Math.tan(fovY / 2); + const nf = 1 / (near - far); + const m = new Array(16).fill(0); + m[0] = f / aspect; + m[5] = f; + m[10] = (far + near) * nf; + m[11] = -1; + m[14] = 2 * far * near * nf; + return m; +} + +/** Right-handed view matrix (gluLookAt), column-major. */ +export function lookAt( + eye: Coordinate3D, + center: Coordinate3D, + up: Coordinate3D +): Mat4 { + const z = normalize({ x: eye.x - center.x, y: eye.y - center.y, z: eye.z - center.z }); + const x = normalize(cross(up, z)); + const y = cross(z, x); + return [ + x.x, y.x, z.x, 0, + x.y, y.y, z.y, 0, + x.z, y.z, z.z, 0, + -dot(x, eye), -dot(y, eye), -dot(z, eye), 1, + ]; +} + +/** Column-major 4×4 multiply: returns `a · b`. */ +export function multiply(a: Mat4, b: Mat4): Mat4 { + const out = new Array(16).fill(0); + for (let c = 0; c < 4; c++) { + for (let r = 0; r < 4; r++) { + let s = 0; + for (let k = 0; k < 4; k++) { + s += a[k * 4 + r] * b[c * 4 + k]; + } + out[c * 4 + r] = s; + } + } + return out; +} + +export interface CameraMatrixInput { + /** Camera position in world space (meters). */ + position: Coordinate3D; + /** Heading in degrees, clockwise from north. */ + heading: number; + /** Pitch in degrees below the horizon. */ + pitch: number; + /** Vertical field of view in degrees. @default 60 */ + fov?: number; + aspect: number; + near?: number; + far?: number; +} + +/** World-space forward vector for a heading/pitch (x east, y north, z up). */ +export function forwardVector(headingDeg: number, pitchDeg: number): Coordinate3D { + const h = (headingDeg * Math.PI) / 180; + const p = (pitchDeg * Math.PI) / 180; + return { + x: Math.sin(h) * Math.cos(p), + y: Math.cos(h) * Math.cos(p), + z: -Math.sin(p), + }; +} + +/** Build the column-major view-projection matrix for a camera. */ +export function buildViewProjection(input: CameraMatrixInput): Mat4 { + const { position, heading, pitch, fov = 60, aspect, near = 1, far = 2000 } = input; + const forward = forwardVector(heading, pitch); + const center = { + x: position.x + forward.x, + y: position.y + forward.y, + z: position.z + forward.z, + }; + const proj = perspective((fov * Math.PI) / 180, aspect, near, far); + const view = lookAt(position, center, { x: 0, y: 0, z: 1 }); + return multiply(proj, view); +} + +/** + * Project lng/lat to local world meters relative to a reference point using a + * spherical web-mercator approximation. Accurate to well under a meter across a + * 100 km region, which is sufficient for LOD bucketing. + */ +export function mercatorMeters( + lng: number, + lat: number, + ref: { lng: number; lat: number } = { lng: 0, lat: 0 } +): { x: number; y: number } { + const toRad = Math.PI / 180; + const x = EARTH_RADIUS_M * (lng - ref.lng) * toRad * Math.cos(ref.lat * toRad); + const y = EARTH_RADIUS_M * (lat - ref.lat) * toRad; + return { x, y }; +} diff --git a/src/utils/frustum.ts b/src/utils/frustum.ts new file mode 100644 index 0000000..defb4ba --- /dev/null +++ b/src/utils/frustum.ts @@ -0,0 +1,90 @@ +/** + * View-frustum math used by the QuadTree culling layer. Kept free of Three.js + * so it can run in a Web Worker and be unit-tested without a WebGL context. + * + * Matrices are 16-element, column-major (matching `THREE.Matrix4.elements` and + * WebGL), i.e. element (row r, col c) is `m[c * 4 + r]`. + */ + +import type { BoundingSphere, Coordinate3D, FrustumPlane } from "@/types/spatial"; + +/** Normalize a plane so its normal `(a,b,c)` is unit length. */ +export function normalizePlane(p: FrustumPlane): FrustumPlane { + const len = Math.hypot(p.a, p.b, p.c) || 1; + return { a: p.a / len, b: p.b / len, c: p.c / len, d: p.d / len }; +} + +/** + * Extract the six frustum planes from a column-major view-projection matrix + * (Gribb–Hartmann). Returned planes have inward-pointing normals: a point is + * inside the frustum when it lies in the positive half-space of every plane. + */ +export function extractFrustumPlanes(m: ArrayLike): FrustumPlane[] { + // row(i) = [ m(i,0), m(i,1), m(i,2), m(i,3) ] + const row = (i: number): [number, number, number, number] => [ + m[0 * 4 + i], + m[1 * 4 + i], + m[2 * 4 + i], + m[3 * 4 + i], + ]; + const [r0, r1, r2, r3] = [row(0), row(1), row(2), row(3)]; + + const combine = ( + s: [number, number, number, number], + t: [number, number, number, number], + sign: 1 | -1 + ): FrustumPlane => + normalizePlane({ + a: s[0] + sign * t[0], + b: s[1] + sign * t[1], + c: s[2] + sign * t[2], + d: s[3] + sign * t[3], + }); + + return [ + combine(r3, r0, 1), // left: w + x + combine(r3, r0, -1), // right: w - x + combine(r3, r1, 1), // bottom: w + y + combine(r3, r1, -1), // top: w - y + combine(r3, r2, 1), // near: w + z + combine(r3, r2, -1), // far: w - z + ]; +} + +/** Signed distance from a point to a plane (positive = interior side). */ +export function planeDistance(p: FrustumPlane, pt: Coordinate3D): number { + return p.a * pt.x + p.b * pt.y + p.c * pt.z + p.d; +} + +/** + * Conservative sphere/frustum intersection: returns false only when the sphere + * is fully outside at least one plane. May report a near-miss as inside (the + * standard accept-or-maybe test for broad-phase culling). + */ +export function sphereInFrustum( + planes: FrustumPlane[], + sphere: BoundingSphere +): boolean { + for (const plane of planes) { + if (planeDistance(plane, sphere.center) < -sphere.radius) { + return false; + } + } + return true; +} + +/** True when a point lies inside every frustum plane. */ +export function pointInFrustum( + planes: FrustumPlane[], + point: Coordinate3D +): boolean { + for (const plane of planes) { + if (planeDistance(plane, point) < 0) return false; + } + return true; +} + +/** Euclidean distance between two 3D points. */ +export function distance3D(a: Coordinate3D, b: Coordinate3D): number { + return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); +} diff --git a/src/workers/frustumCull.worker.ts b/src/workers/frustumCull.worker.ts new file mode 100644 index 0000000..1ae3c29 --- /dev/null +++ b/src/workers/frustumCull.worker.ts @@ -0,0 +1,67 @@ +/** + * Off-thread frustum culling. The worker owns the QuadTree so the per-frame + * visibility query never blocks the render thread. It accepts a flat camera + * view-projection matrix, extracts the frustum, queries the tree, and returns + * the visible asset ids with their LOD levels. + */ + +import { QuadTree, type LODDistances } from "@/components/map/lod/QuadTree"; +import { extractFrustumPlanes } from "@/utils/frustum"; +import type { AssetInstance, Coordinate3D, LODLevel } from "@/types/spatial"; + +export type FrustumCullRequest = + | { type: "load"; assets: AssetInstance[] } + | { + type: "query"; + requestId: number; + /** Column-major view-projection matrix (16 numbers). */ + viewProjection: number[]; + cameraPosition: Coordinate3D; + lodDistances?: LODDistances; + maxAssets?: number; + cullTarget?: number; + }; + +export interface VisibleAssetRef { + id: string; + lod: LODLevel; + distance: number; +} + +export type FrustumCullResponse = + | { type: "loaded"; count: number } + | { type: "result"; requestId: number; visible: VisibleAssetRef[] }; + +const worker = self as unknown as Worker; +const tree = new QuadTree(); + +worker.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + + if (msg.type === "load") { + tree.clear(); + tree.insertAll(msg.assets); + const response: FrustumCullResponse = { type: "loaded", count: tree.size }; + worker.postMessage(response); + return; + } + + if (msg.type === "query") { + const planes = extractFrustumPlanes(msg.viewProjection); + const visible = tree + .queryFrustum(planes, { + cameraPosition: msg.cameraPosition, + lodDistances: msg.lodDistances, + maxAssets: msg.maxAssets, + cullTarget: msg.cullTarget, + }) + .map((v) => ({ id: v.asset.id, lod: v.lod, distance: v.distance })); + + const response: FrustumCullResponse = { + type: "result", + requestId: msg.requestId, + visible, + }; + worker.postMessage(response); + } +}); diff --git a/tests/hooks/useMapCameraSync.test.ts b/tests/hooks/useMapCameraSync.test.ts new file mode 100644 index 0000000..0e6a6ed --- /dev/null +++ b/tests/hooks/useMapCameraSync.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { + useMapCameraSync, + readCameraState, + viewProjectionForTransform, + type MapTransformLike, + type MapLike, +} from "@/hooks/useMapCameraSync"; + +function transform(over: Partial = {}): MapTransformLike { + return { + _center: { lng: 0, lat: 0 }, + _elevation: 100, + bearing: 0, + pitch: 0, + width: 800, + height: 600, + ...over, + }; +} + +describe("readCameraState", () => { + it("maps the transform to a world-space camera state", () => { + const state = readCameraState(transform(), { lng: 0, lat: 0 }); + expect(state.position).toEqual({ x: 0, y: 0, z: 100 }); + expect(state.altitude).toBe(100); + expect(state.heading).toBe(0); + }); + + it("offsets position relative to the origin", () => { + const state = readCameraState( + transform({ _center: { lng: 0.01, lat: 0 } }), + { lng: 0, lat: 0 } + ); + expect(state.position.x).toBeGreaterThan(0); + }); + + it("falls back to non-underscore fields", () => { + const t: MapTransformLike = { + center: { lng: 1, lat: 1 }, + elevation: 50, + bearing: 30, + pitch: 45, + width: 100, + height: 100, + }; + const state = readCameraState(t, { lng: 1, lat: 1 }); + expect(state.altitude).toBe(50); + expect(state.heading).toBe(30); + expect(state.pitch).toBe(45); + }); +}); + +describe("viewProjectionForTransform", () => { + it("returns a camera state and a 16-element matrix", () => { + const { camera, viewProjection } = viewProjectionForTransform(transform(), { + lng: 0, + lat: 0, + }); + expect(camera.position.z).toBe(100); + expect(viewProjection).toHaveLength(16); + }); +}); + +describe("useMapCameraSync", () => { + it("invokes onSync each scheduled frame", () => { + const onSync = vi.fn(); + let frameCb: FrameRequestCallback | null = null; + const raf = vi.fn((cb: FrameRequestCallback) => { + frameCb = cb; + return 1; + }); + const cancelRaf = vi.fn(); + const map: MapLike = { transform: transform() }; + + renderHook(() => + useMapCameraSync({ map, onSync, raf, cancelRaf }) + ); + + expect(raf).toHaveBeenCalledTimes(1); + // Drive one frame. + frameCb!(0); + expect(onSync).toHaveBeenCalledTimes(1); + const [camera, vp] = onSync.mock.calls[0]; + expect(camera.altitude).toBe(100); + expect(vp).toHaveLength(16); + }); + + it("cancels the loop on unmount", () => { + const cancelRaf = vi.fn(); + const raf = vi.fn(() => 7); + const map: MapLike = { transform: transform() }; + const { unmount } = renderHook(() => + useMapCameraSync({ map, onSync: vi.fn(), raf, cancelRaf }) + ); + unmount(); + expect(cancelRaf).toHaveBeenCalledWith(7); + }); + + it("does nothing without a map", () => { + const raf = vi.fn(); + renderHook(() => + useMapCameraSync({ map: null, onSync: vi.fn(), raf }) + ); + expect(raf).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/cameraMatrix.test.ts b/tests/unit/cameraMatrix.test.ts new file mode 100644 index 0000000..f1808b6 --- /dev/null +++ b/tests/unit/cameraMatrix.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { + buildViewProjection, + forwardVector, + mercatorMeters, + perspective, + multiply, +} from "@/utils/cameraMatrix"; +import { extractFrustumPlanes, pointInFrustum } from "@/utils/frustum"; + +describe("perspective / multiply", () => { + it("produces a 16-element column-major matrix", () => { + const m = perspective(Math.PI / 3, 1, 1, 1000); + expect(m).toHaveLength(16); + expect(m[11]).toBe(-1); + }); + + it("multiply by identity is a no-op", () => { + const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const m = perspective(Math.PI / 3, 1.5, 1, 100); + expect(multiply(m, id)).toEqual(m); + }); +}); + +describe("forwardVector", () => { + it("points north for heading 0, pitch 0", () => { + const f = forwardVector(0, 0); + expect(f.x).toBeCloseTo(0, 10); + expect(f.y).toBeCloseTo(1, 10); + expect(f.z).toBeCloseTo(0, 10); + }); + + it("points east for heading 90", () => { + const f = forwardVector(90, 0); + expect(f.x).toBeCloseTo(1, 10); + expect(f.y).toBeCloseTo(0, 10); + }); + + it("tilts downward as pitch increases", () => { + expect(forwardVector(0, 45).z).toBeLessThan(0); + }); +}); + +describe("buildViewProjection + frustum round-trip", () => { + const vp = buildViewProjection({ + position: { x: 0, y: 0, z: 0 }, + heading: 0, // looking +y (north) + pitch: 0, + aspect: 1, + fov: 60, + near: 1, + far: 1000, + }); + const planes = extractFrustumPlanes(vp); + + it("sees a point straight ahead", () => { + expect(pointInFrustum(planes, { x: 0, y: 10, z: 0 })).toBe(true); + }); + + it("does not see a point behind the camera", () => { + expect(pointInFrustum(planes, { x: 0, y: -10, z: 0 })).toBe(false); + }); + + it("does not see a point beyond the far plane", () => { + expect(pointInFrustum(planes, { x: 0, y: 2000, z: 0 })).toBe(false); + }); + + it("does not see a point far off-axis (outside the fov)", () => { + expect(pointInFrustum(planes, { x: 500, y: 10, z: 0 })).toBe(false); + }); +}); + +describe("mercatorMeters", () => { + it("maps the reference point to the origin", () => { + const p = mercatorMeters(12.5, 41.9, { lng: 12.5, lat: 41.9 }); + expect(p.x).toBeCloseTo(0, 6); + expect(p.y).toBeCloseTo(0, 6); + }); + + it("is monotonic in lng and lat", () => { + const ref = { lng: 0, lat: 0 }; + expect(mercatorMeters(0.001, 0, ref).x).toBeGreaterThan(0); + expect(mercatorMeters(0, 0.001, ref).y).toBeGreaterThan(0); + }); + + it("east/north offsets are ~111 m per 0.001° near the equator", () => { + const { x } = mercatorMeters(0.001, 0, { lng: 0, lat: 0 }); + expect(x).toBeGreaterThan(100); + expect(x).toBeLessThan(120); + }); +}); diff --git a/tests/unit/frustum.test.ts b/tests/unit/frustum.test.ts new file mode 100644 index 0000000..a17467a --- /dev/null +++ b/tests/unit/frustum.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + extractFrustumPlanes, + sphereInFrustum, + pointInFrustum, + planeDistance, + distance3D, + normalizePlane, +} from "@/utils/frustum"; + +const IDENTITY = [ + 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, +]; + +describe("extractFrustumPlanes", () => { + it("derives the NDC cube from an identity matrix", () => { + const planes = extractFrustumPlanes(IDENTITY); + expect(planes).toHaveLength(6); + // left: x >= -1 → a=1, d=1 + expect(planes[0]).toMatchObject({ a: 1, b: 0, c: 0, d: 1 }); + // right: x <= 1 → a=-1, d=1 + expect(planes[1]).toMatchObject({ a: -1, b: 0, c: 0, d: 1 }); + // near: z >= -1 + expect(planes[4]).toMatchObject({ a: 0, b: 0, c: 1, d: 1 }); + }); +}); + +describe("sphereInFrustum / pointInFrustum (NDC cube)", () => { + const planes = extractFrustumPlanes(IDENTITY); + + it("accepts a point at the origin", () => { + expect(pointInFrustum(planes, { x: 0, y: 0, z: 0 })).toBe(true); + }); + + it("rejects a point outside the cube", () => { + expect(pointInFrustum(planes, { x: 2, y: 0, z: 0 })).toBe(false); + }); + + it("keeps a sphere that straddles a face (conservative)", () => { + expect( + sphereInFrustum(planes, { center: { x: 1.4, y: 0, z: 0 }, radius: 0.5 }) + ).toBe(true); + }); + + it("rejects a sphere fully outside a face", () => { + expect( + sphereInFrustum(planes, { center: { x: 2, y: 0, z: 0 }, radius: 0.4 }) + ).toBe(false); + }); +}); + +describe("helpers", () => { + it("normalizePlane yields a unit normal", () => { + const p = normalizePlane({ a: 0, b: 3, c: 4, d: 10 }); + expect(Math.hypot(p.a, p.b, p.c)).toBeCloseTo(1, 10); + expect(p.d).toBeCloseTo(2, 10); + }); + + it("planeDistance is positive on the interior side", () => { + expect(planeDistance({ a: 1, b: 0, c: 0, d: 0 }, { x: 5, y: 0, z: 0 })).toBe(5); + }); + + it("distance3D is Euclidean", () => { + expect(distance3D({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBe(5); + }); +}); diff --git a/tests/unit/quadTree.test.ts b/tests/unit/quadTree.test.ts new file mode 100644 index 0000000..6af4f07 --- /dev/null +++ b/tests/unit/quadTree.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { QuadTree, getLODLevel } from "@/components/map/lod/QuadTree"; +import { LODLevel, type AssetInstance, type FrustumPlane } from "@/types/spatial"; + +let counter = 0; +function asset(x: number, y: number, z = 0): AssetInstance { + return { id: `a${counter++}`, type: "meter", position: { x, y, z } }; +} + +/** Empty plane set → every node passes the frustum test (isolates LOD logic). */ +const ACCEPT_ALL: FrustumPlane[] = []; + +describe("getLODLevel", () => { + it("buckets by distance", () => { + expect(getLODLevel(0)).toBe(LODLevel.Full); + expect(getLODLevel(99)).toBe(LODLevel.Full); + expect(getLODLevel(100)).toBe(LODLevel.Simplified); + expect(getLODLevel(299)).toBe(LODLevel.Simplified); + expect(getLODLevel(300)).toBe(LODLevel.Impostor); + expect(getLODLevel(499)).toBe(LODLevel.Impostor); + expect(getLODLevel(500)).toBe(LODLevel.Culled); + }); + + it("honours degraded distances", () => { + const degraded = { full: 50, simplified: 200, impostor: 500 }; + expect(getLODLevel(60, degraded)).toBe(LODLevel.Simplified); + }); +}); + +describe("QuadTree insert", () => { + it("tracks size", () => { + const tree = new QuadTree(); + tree.insert(asset(0, 0)); + tree.insertAll([asset(10, 10), asset(-20, -20)]); + expect(tree.size).toBe(3); + }); + + it("subdivides past leaf capacity without losing assets", () => { + const tree = new QuadTree(); + for (let i = 0; i < 100; i++) tree.insert(asset(i, i)); + const visible = tree.queryFrustum(ACCEPT_ALL, { + cameraPosition: { x: 50, y: 50, z: 0 }, + lodDistances: { full: 1e9, simplified: 1e9, impostor: 1e9 }, + }); + expect(visible).toHaveLength(100); + }); +}); + +describe("QuadTree.queryFrustum LOD", () => { + it("tags assets by camera distance and culls beyond 500 m", () => { + const tree = new QuadTree(); + tree.insert(asset(50, 0)); // 50 m → Full + tree.insert(asset(200, 0)); // 200 m → Simplified + tree.insert(asset(400, 0)); // 400 m → Impostor + tree.insert(asset(600, 0)); // 600 m → culled (excluded) + + const visible = tree.queryFrustum(ACCEPT_ALL, { + cameraPosition: { x: 0, y: 0, z: 0 }, + }); + const byDist = visible.sort((a, b) => a.distance - b.distance); + expect(byDist.map((v) => v.lod)).toEqual([ + LODLevel.Full, + LODLevel.Simplified, + LODLevel.Impostor, + ]); + }); + + it("excludes assets outside the frustum", () => { + const tree = new QuadTree(); + tree.insert(asset(100, 100)); + // Plane: x - 1e9 >= 0 → everything is outside. + const reject: FrustumPlane[] = [{ a: 1, b: 0, c: 0, d: -1e9 }]; + expect( + tree.queryFrustum(reject, { cameraPosition: { x: 0, y: 0, z: 0 } }) + ).toHaveLength(0); + }); +}); + +describe("QuadTree batch culling", () => { + it("keeps the nearest cullTarget when over the cap", () => { + const tree = new QuadTree(); + for (let i = 1; i <= 10; i++) tree.insert(asset(i, 0)); // 1..10 m away + const visible = tree.queryFrustum(ACCEPT_ALL, { + cameraPosition: { x: 0, y: 0, z: 0 }, + maxAssets: 5, + cullTarget: 3, + }); + expect(visible).toHaveLength(3); + // The three nearest (1, 2, 3 m) survive. + expect(visible.every((v) => v.distance <= 3)).toBe(true); + }); +}); + +describe("QuadTree clear", () => { + it("resets and re-inserts correctly (no stale extent)", () => { + const tree = new QuadTree(); + tree.insert(asset(40000, 40000)); + tree.clear(); + expect(tree.size).toBe(0); + tree.insert(asset(10, 10)); + const visible = tree.queryFrustum(ACCEPT_ALL, { + cameraPosition: { x: 10, y: 10, z: 0 }, + }); + expect(visible).toHaveLength(1); + expect(visible[0].lod).toBe(LODLevel.Full); + }); +});