feat(site-map): three.js scene engine, navigation and road layers

This commit is contained in:
DEls 2026-10-14 07:12:32 +02:00
parent 54a631c6ba
commit 220ca7bc45
7 changed files with 583 additions and 0 deletions

49
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<LayerId, THREE.Group>();
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();
},
};
}

View file

@ -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<string, number> = { 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<string, InstanceType<typeof THREE.Vector3>[]>();
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 })));
}
}

View file

@ -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<string, number>();
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<string, Map<string, AreaPiece[]>>();
for (const a of areas) {
const kinds = bySec.get(a.section) ?? new Map<string, AreaPiece[]>();
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<string, typeof road.kerbs>();
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 = `<b>${p.name}</b> · 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);
}
}

View file

@ -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<string, NavDir> = {
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<NavDir>();
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);
},
};
}

View file

@ -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<Selection, null>;
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 }