feat(site-map): three.js scene engine, navigation and road layers
This commit is contained in:
parent
54a631c6ba
commit
220ca7bc45
7 changed files with 583 additions and 0 deletions
49
package-lock.json
generated
49
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
157
src/components/site-map/scene/engine.ts
Normal file
157
src/components/site-map/scene/engine.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
84
src/components/site-map/scene/layers-base.ts
Normal file
84
src/components/site-map/scene/layers-base.ts
Normal 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 })));
|
||||
}
|
||||
}
|
||||
145
src/components/site-map/scene/layers-road.ts
Normal file
145
src/components/site-map/scene/layers-road.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
104
src/components/site-map/scene/navigation.ts
Normal file
104
src/components/site-map/scene/navigation.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
42
src/components/site-map/scene/types.ts
Normal file
42
src/components/site-map/scene/types.ts
Normal 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 }
|
||||
Loading…
Reference in a new issue