diff --git a/package-lock.json b/package-lock.json index 7c3bcc7..e0079d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "remark-parse": "^11.0.0", "sharp": "^0.35.4", "tailwind-merge": "^2.6.0", + "three": "0.160.0", "unified": "^11.0.5", "xlsx": "^0.18.5", "zod": "^3.25.76", @@ -60,6 +61,7 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/three": "0.160.0", "eslint": "^9.17.0", "eslint-config-next": "^16", "postcss": "^8.4.49", @@ -5622,6 +5624,26 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.160.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.160.0.tgz", + "integrity": "sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.6.10", + "meshoptimizer": "~0.18.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5634,6 +5656,13 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.66.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", @@ -9154,6 +9183,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "dev": true, + "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", @@ -11591,6 +11627,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -14814,6 +14857,12 @@ "node": ">=10" } }, + "node_modules/three": { + "version": "0.160.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz", + "integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", diff --git a/package.json b/package.json index d5cdb97..f45c71a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "remark-parse": "^11.0.0", "sharp": "^0.35.4", "tailwind-merge": "^2.6.0", + "three": "0.160.0", "unified": "^11.0.5", "xlsx": "^0.18.5", "zod": "^3.25.76", @@ -67,6 +68,7 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/three": "0.160.0", "eslint": "^9.17.0", "eslint-config-next": "^16", "postcss": "^8.4.49", diff --git a/src/components/site-map/scene/engine.ts b/src/components/site-map/scene/engine.ts new file mode 100644 index 0000000..0bd5c84 --- /dev/null +++ b/src/components/site-map/scene/engine.ts @@ -0,0 +1,157 @@ +// three.js scene for the Site map: renderer, camera, controls, layer groups, picking, hover and the render loop. +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import type { SiteModelData } from "@/lib/site-map/model-types"; +import type { ProgressState } from "@/lib/site-map/progress"; +import { decodeSurface, designZ as dz, surfZ as sz } from "@/lib/site-map/surface"; +import { attachNavigation } from "./navigation"; +import { buildContours, buildLines, buildSurface } from "./layers-base"; +import { buildPegs, buildRoad } from "./layers-road"; +import { LAYER_IDS, type CreateOptions, type LayerId, type SceneContext, type Selection, type SiteMapHandle } from "./types"; + +const NON_PICKABLE: LayerId[] = ["surface", "contours", "ctxMark", "ctxEdge", "ctxToe", "ctxReserve"]; + +export function createSiteMap(container: HTMLElement, model: SiteModelData, state: ProgressState, opts: CreateOptions): SiteMapHandle { + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.shadowMap.enabled = true; + renderer.domElement.style.display = "block"; + container.prepend(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xf3f2ee); + const camera = new THREE.PerspectiveCamera(45, 1, 0.5, 20000); + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.maxPolarAngle = Math.PI / 2.05; + controls.screenSpacePanning = false; // right-drag pans along the ground like a map + scene.add(new THREE.HemisphereLight(0xffffff, 0x8a8f99, 1.4)); + const sun = new THREE.DirectionalLight(0xffffff, 1.8); + sun.castShadow = true; sun.shadow.mapSize.set(4096, 4096); + sun.shadow.bias = -0.0005; sun.shadow.normalBias = 0.4; // no striped "shadow acne" when zoomed in close + Object.assign(sun.shadow.camera, { left: -350, right: 350, top: 350, bottom: -350, near: 1, far: 2000 }); + sun.position.set(-200, 400, 150); + scene.add(sun, sun.target); + + const groups = new Map(); + for (const id of LAYER_IDS) { const g = new THREE.Group(); g.name = id; scene.add(g); groups.set(id, g); } + const disposeTree = (root: THREE.Object3D) => root.traverse((o) => { + if (o === root) return; + const m = o as THREE.Mesh; + m.geometry?.dispose(); + const mats = Array.isArray(m.material) ? m.material : m.material ? [m.material] : []; + for (const mat of mats) { (mat as THREE.MeshBasicMaterial).map?.dispose(); mat.dispose(); } + }); + + const sf = model.surface, Z = decodeSurface(sf); + const zRef = sf.zmin - 4; + const ctx: SceneContext = { + THREE, scene, camera, model, Z, state, selection: null, levEx: 1.5, layEx: 4, + Y: (z) => (z - zRef) * ctx.levEx, + W: (e, n, z) => new THREE.Vector3(e, ctx.Y(z), -n), + designZ: (e, n) => dz(sf, Z, model.road.pegs, e, n), + surfZ: (e, n) => sz(sf, Z, e, n), + group: (id) => groups.get(id)!, + clear: (g) => { disposeTree(g); g.clear(); }, + ghost: (sel) => new THREE.MeshStandardMaterial({ color: sel ? 0x9cc0ff : 0xffffff, transparent: true, opacity: sel ? 0.35 : 0.16, depthWrite: false, side: THREE.DoubleSide }), + frame: [], + }; + + const builders = { + base: () => { buildSurface(ctx); buildContours(ctx); }, + lines: () => buildLines(ctx), + road: () => buildRoad(ctx), + pegs: () => buildPegs(ctx), + }; + const rebuildAll = () => Object.values(builders).forEach((b) => b()); + rebuildAll(); + for (const id of LAYER_IDS) groups.get(id)!.visible = false; + + // ---------- camera ---------- + const pegMean = model.road.pegs.reduce((s, p) => s + p.z, 0) / Math.max(1, model.road.pegs.length); + function view(kind: "top" | "3d") { + controls.target.set(0, ctx.Y(pegMean), 0); + if (kind === "top") camera.position.set(0.01, controls.target.y + 520, 0); + else camera.position.set(-70, controls.target.y + 250, 190); // high oblique, map-like + controls.update(); + } + function focus(e: number, n: number, z: number, dist: number) { + const t = ctx.W(e, n, z), dir = camera.position.clone().sub(controls.target).normalize(); + controls.target.copy(t); camera.position.copy(t).addScaledVector(dir, dist); controls.update(); + } + view("3d"); + + // ---------- picking & hover ---------- + const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(); + ray.params.Line = { threshold: 0.6 }; + const pickable = () => LAYER_IDS.filter((id) => !NON_PICKABLE.includes(id)).map((id) => groups.get(id)!).filter((g) => g.visible); + function hitAt(ev: PointerEvent) { + const r = renderer.domElement.getBoundingClientRect(); + ndc.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1); + ray.setFromCamera(ndc, camera); + return ray.intersectObjects(pickable(), true).find((h) => h.object.userData.pick || h.object.userData.tip); + } + let down: [number, number] | null = null, hoverT = 0; + const onDown = (e: PointerEvent) => { down = [e.clientX, e.clientY]; }; + const onUp = (e: PointerEvent) => { + if (!down || e.button !== 0 || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) { down = null; return; } + down = null; + opts.onPick((hitAt(e)?.object.userData.pick as Selection | undefined) ?? null); + }; + const onMove = (e: PointerEvent) => { + if (performance.now() - hoverT < 60) return; + hoverT = performance.now(); + const tip = hitAt(e)?.object.userData.tip as string | (() => string) | undefined; + const r = container.getBoundingClientRect(); + opts.onHover(tip ? (typeof tip === "function" ? tip() : tip) : null, e.clientX - r.left, e.clientY - r.top); + }; + renderer.domElement.addEventListener("pointerdown", onDown); + renderer.domElement.addEventListener("pointerup", onUp); + renderer.domElement.addEventListener("pointermove", onMove); + + const nav = attachNavigation({ + camera, controls, dom: renderer.domElement, minDistance: 8, maxDistance: 1500, + pick: (r) => r.intersectObjects([groups.get("surface")!, groups.get("road")!, groups.get("natground")!].filter((g) => g.visible), true)[0]?.point, + }); + + // ---------- loop ---------- + const resize = () => { + const r = container.getBoundingClientRect(); + renderer.setSize(r.width, r.height); camera.aspect = r.width / Math.max(1, r.height); camera.updateProjectionMatrix(); + }; + const ro = new ResizeObserver(resize); + ro.observe(container); + resize(); + let last = performance.now(); + renderer.setAnimationLoop((t: number) => { + const dt = Math.min(0.1, (t - last) / 1000); last = t; + ctx.frame.forEach((fn) => fn(dt)); + nav.update(dt); + controls.update(); + // keep the camera above the ground so zooming in can't go through the surface + const floor = ctx.Y(ctx.designZ(camera.position.x, -camera.position.z)) + 1.5; + if (camera.position.y < floor) { camera.position.y = floor; if (controls.target.y > floor - 0.5) controls.target.y = floor - 0.5; } + renderer.render(scene, camera); + }); + + return { + setState(s) { ctx.state = s; builders.road(); builders.lines(); builders.pegs(); }, + setSelection(sel) { ctx.selection = sel; builders.road(); builders.pegs(); }, + setLayerVisible(id, visible) { groups.get(id)!.visible = visible; }, + setStretch(levEx, layEx) { ctx.levEx = levEx; ctx.layEx = layEx; rebuildAll(); }, + focus, view, + nav: { hold: nav.hold, zoom: nav.zoom, home: () => view("3d") }, + dispose() { + renderer.setAnimationLoop(null); + ro.disconnect(); + nav.dispose(); + renderer.domElement.removeEventListener("pointerdown", onDown); + renderer.domElement.removeEventListener("pointerup", onUp); + renderer.domElement.removeEventListener("pointermove", onMove); + controls.dispose(); + disposeTree(scene); + renderer.dispose(); + renderer.domElement.remove(); + }, + }; +} diff --git a/src/components/site-map/scene/layers-base.ts b/src/components/site-map/scene/layers-base.ts new file mode 100644 index 0000000..dc9bae3 --- /dev/null +++ b/src/components/site-map/scene/layers-base.ts @@ -0,0 +1,84 @@ +// Design surface, design contours and drawing linework (markings, edges, embankment toe, road reserve). +import type { SceneContext } from "./types"; +import { roadKey } from "@/lib/site-map/keys"; +import { valueOf } from "@/lib/site-map/progress"; +import type { Pt } from "@/lib/site-map/model-types"; + +export function buildSurface(c: SceneContext) { + const { THREE } = c, g = c.group("surface"), sf = c.model.surface, Z = c.Z; + c.clear(g); + const st = 2, nx = Math.floor((sf.nx - 1) / st) + 1, ny = Math.floor((sf.ny - 1) / st) + 1; // 1 m mesh from the 0.5 m grid + const pos = new Float32Array(nx * ny * 3), col = new Float32Array(nx * ny * 3), index: number[] = []; + const c1 = new THREE.Color(0xd3d2cc), c2 = new THREE.Color(0xeeece6), tmp = new THREE.Color(); // light neutral grey, lighter = higher + const zmax = sf.zmin + 18; + for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) { + const z = Z[j * st * sf.nx + i * st], k = (j * nx + i) * 3; + pos[k] = sf.x0 + i * st * sf.cell; pos[k + 1] = Number.isNaN(z) ? 0 : c.Y(z) - 0.02; pos[k + 2] = -(sf.y0 + j * st * sf.cell); + tmp.copy(c1).lerp(c2, Number.isNaN(z) ? 0 : (z - sf.zmin) / (zmax - sf.zmin)); + col[k] = tmp.r; col[k + 1] = tmp.g; col[k + 2] = tmp.b; + } + const ok = (i: number, j: number) => !Number.isNaN(Z[j * st * sf.nx + i * st]); + for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) { + if (!(ok(i, j) && ok(i + 1, j) && ok(i, j + 1) && ok(i + 1, j + 1))) continue; + const a = j * nx + i, b = a + 1, cc = a + nx, d = cc + 1; + index.push(a, cc, b, b, cc, d); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + geo.setAttribute("color", new THREE.BufferAttribute(col, 3)); + geo.setIndex(index); geo.computeVertexNormals(); + const m = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide })); + m.receiveShadow = true; + g.add(m); +} + +export function buildContours(c: SceneContext) { + const { THREE } = c, g = c.group("contours"); + c.clear(g); + for (const k of c.model.road.contours) { + const pts = k.pts.map(([e, n]) => c.W(e, n, k.z).setY(c.Y(k.z) + 0.03)); + g.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), + new THREE.LineBasicMaterial({ color: k.major ? 0x6b5a3e : 0x8f8064, transparent: true, opacity: k.major ? 0.9 : 0.5 }))); + } +} + +const inRing = (r: Pt[], e: number, n: number) => { + let inside = false; + for (let i = 0, j = r.length - 1; i < r.length; j = i++) { + if ((r[i][1] > n) !== (r[j][1] > n) && e < ((r[j][0] - r[i][0]) * (n - r[i][1])) / (r[j][1] - r[i][1]) + r[i][0]) inside = !inside; + } + return inside; +}; + +export function buildLines(c: SceneContext) { + const { THREE } = c; + const GROUP = { marking: "ctxMark", tar_edge: "ctxEdge", other_road: "ctxEdge", toe: "ctxToe", reserve: "ctxReserve" } as const; + const COLOR: Record = { tar_edge: 0x3a3a3a, other_road: 0x777777, toe: 0x8a6b3a, reserve: 0xc94a3a }; + (["ctxMark", "ctxEdge", "ctxToe", "ctxReserve"] as const).forEach((k) => c.clear(c.group(k))); + // markings on asphalt whose surfacing (top layer) is done are white; elsewhere slate grey so they show on the light map + const built: Pt[][] = []; + for (const a of c.model.road.areas) { + if (a.kind !== "asphalt") continue; + const top = c.model.road.builds.asphalt?.layers.at(-1); + if (top && valueOf(c.state, roadKey(a.section, a.kind, top.id))) built.push(a.rings[0]); + } + const buckets = new Map[]>(); + for (const line of c.model.road.context) { + const zs = line.pts.map(([e, n]) => c.surfZ(e, n)); + if (zs.some((z) => z === null)) continue; // only linework over the designed surface + for (let i = 1; i < line.pts.length; i++) { + const [e1, n1] = line.pts[i - 1], [e2, n2] = line.pts[i]; + const key = line.kind === "marking" ? (built.some((r) => inRing(r, (e1 + e2) / 2, (n1 + n2) / 2)) ? "marking:white" : "marking:grey") : line.kind; + const lift = 0.02 * c.layEx + 0.04; + const list = buckets.get(key) ?? []; + list.push(c.W(e1, n1, zs[i - 1]!).setY(c.Y(zs[i - 1]!) + lift), c.W(e2, n2, zs[i]!).setY(c.Y(zs[i]!) + lift)); + buckets.set(key, list); + } + } + for (const [key, pts] of buckets) { + const kind = key.split(":")[0] as keyof typeof GROUP; + const color = key === "marking:white" ? 0xffffff : key === "marking:grey" ? 0x5f6873 : COLOR[kind]; + c.group(GROUP[kind]).add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts), + new THREE.LineBasicMaterial({ color, transparent: true, opacity: kind === "marking" ? 0.95 : 0.7 }))); + } +} diff --git a/src/components/site-map/scene/layers-road.ts b/src/components/site-map/scene/layers-road.ts new file mode 100644 index 0000000..e67cc95 --- /dev/null +++ b/src/components/site-map/scene/layers-road.ts @@ -0,0 +1,145 @@ +// Road layer slabs per chainage section, kerbs and setting-out pegs. +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import type * as THREE from "three"; +import type { SceneContext } from "./types"; +import type { AreaPiece, Pt } from "@/lib/site-map/model-types"; +import { kerbKey, pegKey, roadKey } from "@/lib/site-map/keys"; +import { valueOf } from "@/lib/site-map/progress"; + +/** Slab between two offsets (metres; + = above the design surface) that follows the design surface. */ +function slabGeometry(c: SceneContext, rings: Pt[][], off0: number, off1: number) { + const { THREE } = c; + const shape = new THREE.Shape(rings[0].map(([e, n]) => new THREE.Vector2(e, n))); + rings.slice(1).forEach((h) => shape.holes.push(new THREE.Path(h.map(([e, n]) => new THREE.Vector2(e, n))))); + const flat = new THREE.ShapeGeometry(shape); + const p = flat.attributes.position, idx = flat.index!.array; + const pos: number[] = [], zc = new Map(); + const zAt = (e: number, n: number) => { + const k = `${e.toFixed(2)},${n.toFixed(2)}`; + let z = zc.get(k); + if (z === undefined) { z = c.designZ(e, n); zc.set(k, z); } + return z; + }; + const v = (e: number, n: number, off: number) => [e, c.Y(zAt(e, n)) + off * c.layEx, -n]; + for (let t = 0; t < idx.length; t += 3) { + const a = [p.getX(idx[t]), p.getY(idx[t])] as const, b = [p.getX(idx[t + 1]), p.getY(idx[t + 1])] as const, d = [p.getX(idx[t + 2]), p.getY(idx[t + 2])] as const; + pos.push(...v(a[0], a[1], off1), ...v(d[0], d[1], off1), ...v(b[0], b[1], off1)); + pos.push(...v(a[0], a[1], off0), ...v(b[0], b[1], off0), ...v(d[0], d[1], off0)); + } + for (const r of rings) for (let k = 0; k < r.length; k++) { + const a = r[k], b = r[(k + 1) % r.length]; + const a0 = v(a[0], a[1], off0), a1 = v(a[0], a[1], off1), b0 = v(b[0], b[1], off0), b1 = v(b[0], b[1], off1); + pos.push(...a0, ...b0, ...b1, ...a0, ...b1, ...a1); + } + flat.dispose(); + const g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); + g.computeVertexNormals(); + return g; +} + +const merged = (geos: THREE.BufferGeometry[]) => { + if (geos.length === 1) return geos[0]; + const m = mergeGeometries(geos); + geos.forEach((g) => g.dispose()); + return m; +}; + +export function groupAreas(areas: AreaPiece[]) { + const bySec = new Map>(); + for (const a of areas) { + const kinds = bySec.get(a.section) ?? new Map(); + bySec.set(a.section, kinds); + kinds.set(a.kind, [...(kinds.get(a.kind) ?? []), a]); + } + return bySec; +} + +export function buildRoad(c: SceneContext) { + const { THREE } = c, g = c.group("road"), road = c.model.road; + c.clear(g); + for (const [section, kinds] of groupAreas(road.areas)) { + const selSec = c.selection?.type === "section" && c.selection.section === section; + const pick = { type: "section" as const, section }; + for (const [kind, list] of kinds) { + const layers = road.builds[kind]?.layers ?? []; + const done = layers.map((l) => valueOf(c.state, roadKey(section, kind, l.id)) > 0); + let base = -layers.reduce((s, l) => s + l.thk, 0); + layers.forEach((l, i) => { + if (done[i] || selSec) { // unbuilt layers only show (as a faint ghost) in the selected section + const mesh = new THREE.Mesh(merged(list.map((a) => slabGeometry(c, a.rings, base, base + l.thk))), + done[i] ? new THREE.MeshStandardMaterial({ color: l.color, roughness: 0.9, side: THREE.DoubleSide }) : c.ghost(true)); + mesh.castShadow = done[i]; mesh.receiveShadow = true; + mesh.userData.pick = pick; + g.add(mesh); + } + base += l.thk; + }); + if (!done.every(Boolean) && !selSec) { + // still to build: thin outline on the design surface + an invisible surface so it can be clicked + for (const a of list) for (const r of a.rings) { + const pts = r.map(([e, n]) => c.W(e, n, 0).setY(c.Y(c.designZ(e, n)) + 0.04)); + pts.push(pts[0]); + g.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x8c8a84, transparent: true, opacity: 0.8 }))); + } + const hit = new THREE.Mesh(merged(list.map((a) => slabGeometry(c, a.rings, -0.01, 0.01))), new THREE.MeshBasicMaterial({ visible: false })); + hit.userData.pick = pick; + g.add(hit); + } + if (selSec) for (const a of list) { + const pts = a.rings[0].map(([e, n]) => c.W(e, n, 0).setY(c.Y(c.designZ(e, n)) + 0.05 * c.layEx)); + pts.push(pts[0]); + g.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x1f6feb }))); + } + } + } + // kerbs: fill whole kerb lines in list order until the section's % is reached + const kerbsBySec = new Map(); + for (const k of road.kerbs) kerbsBySec.set(k.section, [...(kerbsBySec.get(k.section) ?? []), k]); + for (const [section, list] of kerbsBySec) { + const pct = valueOf(c.state, kerbKey(section)), total = list.reduce((s, k) => s + k.length, 0); + let run = 0; + for (const k of list) { + const built = run + k.length / 2 <= (total * pct) / 100; + run += k.length; + if (!built) { // not placed yet: thin line on the surface + const pts = k.pts.map(([e, n]) => c.W(e, n, 0).setY(c.Y(c.designZ(e, n)) + 0.05)); + g.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0xa8a69f }))); + continue; + } + const boxes: THREE.BufferGeometry[] = []; + const h = 0.3 * Math.max(1, c.layEx / 2), w = 0.12 * Math.max(1, c.layEx / 2); + for (let i = 1; i < k.pts.length; i++) { + const [e1, n1] = k.pts[i - 1], [e2, n2] = k.pts[i], L = Math.hypot(e2 - e1, n2 - n1); + if (L < 0.01) continue; + const b = new THREE.BoxGeometry(L, h, w); + b.rotateY(Math.atan2(n2 - n1, e2 - e1)); // box length runs along x; world z = -n + b.translate((e1 + e2) / 2, c.Y(c.designZ((e1 + e2) / 2, (n1 + n2) / 2)), -(n1 + n2) / 2); // half below, half above the surface + boxes.push(b); + } + if (!boxes.length) continue; + const mesh = new THREE.Mesh(merged(boxes), new THREE.MeshStandardMaterial({ color: 0xd8d3c6, roughness: 0.7 })); + mesh.castShadow = true; mesh.userData.pick = { type: "section", section }; + g.add(mesh); + } + } +} + +const PEG_COL = [0x9a9a9a, 0xff5fa2, 0x1f6feb]; +export function buildPegs(c: SceneContext) { + const { THREE } = c, g = c.group("pegs"); + c.clear(g); + for (const p of c.model.road.pegs) { + const st = valueOf(c.state, pegKey(p.name)), sel = c.selection?.type === "peg" && c.selection.name === p.name; + const peg = new THREE.Group(); + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.2, 6), new THREE.MeshStandardMaterial({ color: 0xffffff })); + pole.position.y = 0.6; + const head = new THREE.Mesh(new THREE.SphereGeometry(sel ? 0.5 : 0.3, 12, 8), new THREE.MeshStandardMaterial({ color: PEG_COL[st] ?? PEG_COL[0], emissive: sel ? 0x0b3d91 : 0 })); + head.position.y = 1.25; + peg.add(pole, head); + peg.position.copy(c.W(p.e, p.n, p.z)); + const tip = `${p.name} · Y ${p.Y.toFixed(3)} · X ${p.X.toFixed(3)} · Z ${p.z.toFixed(3)}`; + peg.traverse((o) => { o.userData.pick = { type: "peg", name: p.name }; o.userData.tip = tip; }); + g.add(peg); + } +} diff --git a/src/components/site-map/scene/navigation.ts b/src/components/site-map/scene/navigation.ts new file mode 100644 index 0000000..5bc883c --- /dev/null +++ b/src/components/site-map/scene/navigation.ts @@ -0,0 +1,104 @@ +// Smooth zoom towards the point under the mouse, plus keyboard / arrow-pad movement. +// OrbitControls applies a full zoom step per wheel event and trackpads fire dozens per gesture, +// so wheel events only add to a pending zoom (log-distance units) that the camera eases towards. +import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import type { NavDir } from "./types"; + +const KEYMAP: Record = { + ArrowUp: "f", KeyW: "f", ArrowDown: "b", KeyS: "b", ArrowLeft: "l", KeyA: "l", ArrowRight: "r", KeyD: "r", KeyQ: "rotL", KeyE: "rotR", +}; +const typing = (el: EventTarget | null) => + el instanceof HTMLElement && (el.matches("input[type=search], input[type=text], input[type=number], textarea, select") || el.isContentEditable); + +export function attachNavigation(o: { + camera: THREE.PerspectiveCamera; controls: OrbitControls; dom: HTMLElement; + pick(ray: THREE.Raycaster): THREE.Vector3 | undefined; minDistance: number; maxDistance: number; +}) { + const { camera, controls, dom } = o; + controls.enableZoom = false; + let pending = 0; + const point = new THREE.Vector3(), tmp = new THREE.Vector3(); + let lastPick = 0, lastXY = [0, 0]; + const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(); + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + let dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1); + if (e.ctrlKey) dy *= 4; // trackpad pinch sends small deltas + pending = Math.max(-2.5, Math.min(2.5, pending + Math.max(-0.5, Math.min(0.5, dy * 0.0012)))); + const now = performance.now(); + if (now - lastPick > 250 || Math.hypot(e.clientX - lastXY[0], e.clientY - lastXY[1]) > 4) { + const r = dom.getBoundingClientRect(); + ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); + ray.setFromCamera(ndc, camera); + const hit = o.pick(ray); + if (hit) point.copy(hit); + else if (!ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), -controls.target.y), point)) point.copy(controls.target); + lastXY = [e.clientX, e.clientY]; + } + lastPick = now; + }; + dom.addEventListener("wheel", onWheel, { passive: false }); + + const held = new Set(); + let shift = false; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") shift = true; + if (typing(e.target) || e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === "+" || e.key === "=") { zoom(-0.35); e.preventDefault(); return; } + if (e.key === "-" || e.key === "_") { zoom(0.35); e.preventDefault(); return; } + const k = KEYMAP[e.code]; + if (k) { held.add(k); e.preventDefault(); } + }; + const onKeyUp = (e: KeyboardEvent) => { if (e.key === "Shift") shift = false; const k = KEYMAP[e.code]; if (k) held.delete(k); }; + const onBlur = () => { held.clear(); shift = false; }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + + function zoom(amount: number) { + point.copy(controls.target); + pending = Math.max(-2.5, Math.min(2.5, pending + amount)); + } + + const fwd = new THREE.Vector3(), right = new THREE.Vector3(), move = new THREE.Vector3(), up = new THREE.Vector3(0, 1, 0); + function update(dt: number) { + if (held.size) { + const dist = camera.position.distanceTo(controls.target); + const speed = Math.max(3, dist * 0.3) * (shift ? 3 : 1) * dt; // scales with zoom + camera.getWorldDirection(fwd); fwd.y = 0; + if (fwd.lengthSq() < 1e-4) fwd.set(0, 1, 0).applyQuaternion(camera.quaternion).setY(0); // straight down: forward = up on screen + fwd.normalize(); right.crossVectors(fwd, camera.up).normalize(); + move.set(0, 0, 0); + if (held.has("f")) move.add(fwd); + if (held.has("b")) move.sub(fwd); + if (held.has("r")) move.add(right); + if (held.has("l")) move.sub(right); + if (move.lengthSq()) { move.normalize().multiplyScalar(speed); camera.position.add(move); controls.target.add(move); } + const rot = (held.has("rotL") ? 1 : 0) - (held.has("rotR") ? 1 : 0); + if (rot) camera.position.copy(controls.target).add(tmp.copy(camera.position).sub(controls.target).applyAxisAngle(up, rot * 1.4 * dt)); + } + if (Math.abs(pending) < 1e-4) { pending = 0; return; } + let step = pending * (1 - Math.exp(-dt * 10)); + const dist = camera.position.distanceTo(controls.target), next = dist * Math.exp(step); + if (next < o.minDistance) step = Math.log(o.minDistance / dist); + if (next > o.maxDistance) step = Math.log(o.maxDistance / dist); + pending = Math.abs(step) < 1e-5 ? 0 : pending - step; + const f = Math.exp(step); + camera.position.copy(tmp.copy(camera.position).sub(point).multiplyScalar(f).add(point)); + controls.target.copy(tmp.copy(controls.target).sub(point).multiplyScalar(f).add(point)); + } + + return { + update, + hold(dir: NavDir, on: boolean) { if (on) held.add(dir); else held.delete(dir); }, + zoom, + dispose() { + dom.removeEventListener("wheel", onWheel); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + }, + }; +} diff --git a/src/components/site-map/scene/types.ts b/src/components/site-map/scene/types.ts new file mode 100644 index 0000000..57f5c97 --- /dev/null +++ b/src/components/site-map/scene/types.ts @@ -0,0 +1,42 @@ +import type * as THREE from "three"; +import type { SiteModelData } from "@/lib/site-map/model-types"; +import type { ProgressState } from "@/lib/site-map/progress"; + +export type LayerId = "surface" | "contours" | "ctxMark" | "ctxEdge" | "ctxToe" | "ctxReserve" | "road" | "pegs" + | "signs" | "studs" | "stormwater" | "sewer" | "holds" | "water" | "natground" | "xsfences" | "rain"; +export const LAYER_IDS: LayerId[] = ["surface", "contours", "ctxMark", "ctxEdge", "ctxToe", "ctxReserve", "road", "pegs", + "signs", "studs", "stormwater", "sewer", "holds", "water", "natground", "xsfences", "rain"]; + +export type Selection = + | { type: "section"; section: string } | { type: "peg"; name: string } + | { type: "swPipe"; key: string } | { type: "sw"; name: string } | { type: "srPipe"; key: string } | { type: "sr"; name: string } + | { type: "xs"; i: number } | { type: "ewseg"; i: number } | null; + +/** What a picked object carries in `userData.pick`; hover text goes in `userData.tip`. */ +export type PickData = Exclude; + +export interface SceneContext { + THREE: typeof THREE; + scene: THREE.Scene; camera: THREE.PerspectiveCamera; + model: SiteModelData; Z: Float32Array; state: ProgressState; selection: Selection; + levEx: number; layEx: number; + Y(z: number): number; W(e: number, n: number, z: number): THREE.Vector3; + designZ(e: number, n: number): number; surfZ(e: number, n: number): number | null; + group(id: LayerId): THREE.Group; clear(g: THREE.Group): void; + ghost(selected: boolean): THREE.Material; + /** per-frame callbacks (seconds since last frame) */ + frame: ((dt: number) => void)[]; +} + +export interface SiteMapHandle { + setState(state: ProgressState): void; + setSelection(sel: Selection): void; + setLayerVisible(id: LayerId, visible: boolean): void; + setStretch(levEx: number, layEx: number): void; + focus(e: number, n: number, z: number, dist: number): void; + view(kind: "top" | "3d"): void; + nav: { hold(dir: NavDir, on: boolean): void; zoom(amount: number): void; home(): void }; + dispose(): void; +} +export type NavDir = "f" | "b" | "l" | "r" | "rotL" | "rotR"; +export interface CreateOptions { onPick(sel: Selection): void; onHover(html: string | null, x: number, y: number): void }