feat(site-map): chainage, surface heights and BoQ quantities

This commit is contained in:
DEls 2026-10-14 07:06:50 +02:00
parent 300b1d2a07
commit dae2aeb044
6 changed files with 177 additions and 0 deletions

View file

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { chainageAt } from "./chainage";
const al: [number, number, number][] = [[0, 0, 0], [100, 100, 0]]; // straight, east
describe("chainageAt", () => {
it("projects onto the alignment", () => {
const c = chainageAt(al, 40, 3);
expect(c.ch).toBeCloseTo(40); expect(c.dist).toBeCloseTo(3); expect(c.left).toBeCloseTo(3);
});
it("reports right-hand offsets as negative", () => expect(chainageAt(al, 40, -2).left).toBeCloseTo(-2));
});

View file

@ -0,0 +1,11 @@
export function chainageAt(al: [number, number, number][], e: number, n: number) {
let best = { dist: Infinity, ch: 0, left: 0 };
for (let i = 0; i < al.length - 1; i++) {
const [c1, e1, n1] = al[i], [, e2, n2] = al[i + 1];
const dx = e2 - e1, dy = n2 - n1, L2 = dx * dx + dy * dy, L = Math.sqrt(L2) || 1;
const t = L2 ? Math.max(0, Math.min(1, ((e - e1) * dx + (n - n1) * dy) / L2)) : 0;
const d = Math.hypot(e1 + t * dx - e, n1 + t * dy - n);
if (d < best.dist) best = { dist: d, ch: c1 + t * L, left: (dx * (n - n1) - dy * (e - e1)) / L };
}
return best;
}

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { boqRows, sectionProgress, trenchVolume } from "./quantities";
import { fixture } from "./__fixtures__/mini-model";
import { latestState } from "./progress";
const at = (key: string, value: number) => ({ key, value, created: "2026-10-14 08:00:00.000Z" });
describe("boqRows", () => {
it("measures layers, kerbs (half the linework), pipes, earthworks and counts", () => {
const rows = boqRows(fixture, latestState([at("road:ch240-260:asphalt:roadbed", 1), at("kerb:ch240-260", 50), at("swpipe:KI1-1>MH1-2", 1), at("earth:240-250", 100), at("sign:R2.1", 1)]));
const row = (name: string) => rows.find((r) => r.name === name)!;
expect(row("Roadbed")).toMatchObject({ qty: 150, done: 100 }); // m²: 100 + 50, section 1 done
expect(row("G1 base")).toMatchObject({ qty: 22.5, done: 0 }); // m³: 150 m² × 0.15
expect(row("Kerbs (mountable / Fig. 7)")).toMatchObject({ qty: 10, done: 5 });
expect(row("450 mm concrete pipe (Class 100D)")).toMatchObject({ qty: 10, done: 10 });
expect(row("Bulk excavation — cut to formation")).toMatchObject({ qty: 30, done: 30 });
expect(row("Road signs (all types)")).toMatchObject({ qty: 2, done: 1 });
});
});
describe("sectionProgress", () => {
it("is the area-weighted share of road layers done", () => {
const s = latestState([at("road:ch260-280:asphalt:roadbed", 1), at("road:ch260-280:landscape:topsoil", 1)]);
// asphalt 50 m² × 1/3 layers + landscape 20 m² × 1/1 → (16.667 + 20) / 70
expect(sectionProgress(fixture, s, "ch260-280")).toBeCloseTo(36.667 / 70, 3);
});
});
describe("trenchVolume", () => {
it("uses OD + 0.6 m width and average depth + 0.15 m bedding, at least 0.6 m deep", () => {
expect(trenchVolume(10, 450, 1.5, 1.5)).toBeCloseTo(10 * 1.05 * 1.65);
expect(trenchVolume(10, 450, 0.1, 0.1)).toBeCloseTo(10 * 1.05 * 0.6);
});
});

View file

@ -0,0 +1,77 @@
import type { SiteModelData } from "./model-types";
import type { ProgressState } from "./progress";
import { valueOf } from "./progress";
import { earthKey, fitKey, kerbKey, PAINT_KEY, roadKey, signKey, srKey, srPipeKey, studKey, swKey, swPipeKey } from "./keys";
export interface BoqRow { group: string; name: string; unit: string; color: number; qty: number; done: number }
export const KERB_ITEM = "Kerbs (mountable / Fig. 7)";
const KIND: Record<string, string> = { KI: "Kerb inlet", MH: "Manhole", JB: "Junction box", OUT: "Outlet / headwall", DL: "Outfall point" };
export const structureKind = (name: string) => KIND[name.match(/^[A-Z]+/)?.[0] ?? ""] ?? "Structure";
export function trenchVolume(L: number, dia: number, depthA: number, depthB: number): number {
return L * (dia / 1000 + 0.6) * Math.max(0.6, (depthA + depthB) / 2 + 0.15);
}
export function boqRows(m: SiteModelData, s: ProgressState): BoqRow[] {
const rows = new Map<string, BoqRow>();
const add = (group: string, name: string, unit: string, color: number, qty: number, done: number) => {
const k = `${group}|${name}`, r = rows.get(k) ?? { group, name, unit, color, qty: 0, done: 0 };
r.qty += qty; r.done += done; rows.set(k, r);
};
// Roadworks: per area piece, per layer
for (const a of m.road.areas) for (const l of m.road.builds[a.kind]?.layers ?? []) {
const q = l.unit === "m³" ? a.area * l.thk : a.area;
add("Roadworks", l.name, l.unit, l.color, q, valueOf(s, roadKey(a.section, a.kind, l.id)) ? q : 0);
}
const kerbLen = new Map<string, number>();
for (const k of m.road.kerbs) kerbLen.set(k.section, (kerbLen.get(k.section) ?? 0) + k.length / 2); // drawn as two lines
for (const [sec, L] of kerbLen) add("Roadworks", KERB_ITEM, "m", 0xd8d3c6, L, (L * valueOf(s, kerbKey(sec))) / 100);
// Stormwater
const sw = m.services.stormwater, byName = new Map(sw.structures.map((x) => [x.name, x]));
const top = (name: string) => { const x = byName.get(name)!; return x.gl ?? x.il + 1.5; };
let tq = 0, td = 0;
for (const p of sw.pipes) {
const laid = valueOf(s, swPipeKey(p.from, p.to)) > 0;
add("Stormwater", `${p.dia} mm concrete pipe${p.dia === 450 ? " (Class 100D)" : ""}`, "m", 0x8d99a6, p.length, laid ? p.length : 0);
const t = trenchVolume(p.length, p.dia, top(p.from) - p.ilFrom, top(p.to) - p.ilTo); tq += t; td += laid ? t : 0;
}
for (const x of sw.structures) {
const k = structureKind(x.name);
add("Stormwater", k.endsWith("box") ? `${k}es` : `${k}s`, "No.", 0x6b7a88, 1, valueOf(s, swKey(x.name)) === 2 ? 1 : 0);
}
add("Stormwater", "Trench excavation (estimate)", "m³", 0x9b7f57, tq, td);
// Sewer
const sr = m.services.sewer, srName = new Map(sr.manholes.map((x) => [x.id, x]));
let sq = 0, sd = 0;
for (const p of sr.pipes) {
const a = srName.get(p.from)!, b = srName.get(p.to)!, laid = valueOf(s, srPipeKey(a.name, b.name)) > 0;
add("Sewer", `${p.dia} mm uPVC sewer pipe`, "m", 0xb8703a, p.length, laid ? p.length : 0);
const t = trenchVolume(p.length, p.dia, 1.5, 1.5); sq += t; sd += laid ? t : 0;
}
for (const x of sr.manholes) add("Sewer", "Sewer manholes", "No.", 0x7a5a48, 1, valueOf(s, srKey(x.name)) === 2 ? 1 : 0);
add("Sewer", "Trench excavation (estimate)", "m³", 0x9b7f57, sq, sd);
// Earthworks
for (const v of m.earthworks.volumes) {
const f = valueOf(s, earthKey(v.from, v.to)) / 100;
add("Earthworks", "Bulk excavation — cut to formation", "m³", 0xd64545, v.cut, v.cut * f);
add("Earthworks", "Embankment fill to formation", "m³", 0x3f9a5a, v.fill, v.fill * f);
}
// Signs, studs, markings, water
const sc = m.schedules, sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);
add("Signs, studs & markings", "Road signs (all types)", "No.", 0xd1242f, sum(sc.signCounts), Object.keys(sc.signCounts).reduce((t, c) => t + valueOf(s, signKey(c)), 0));
add("Signs, studs & markings", "Road studs", "No.", 0xf2c200, sum(sc.studCounts), Object.keys(sc.studCounts).reduce((t, c) => t + valueOf(s, studKey(c)), 0));
const paint = sum(sc.paintAreas);
add("Signs, studs & markings", "Painted markings (from hatches)", "m²", 0xf4f4f4, paint, valueOf(s, PAINT_KEY) ? paint : 0);
add("Water", "Water fittings (all)", "No.", 0x18a0c9, sum(sc.waterFittings), Object.keys(sc.waterFittings).reduce((t, c) => t + valueOf(s, fitKey(c)), 0));
return [...rows.values()];
}
export function sectionProgress(m: SiteModelData, s: ProgressState, section: string): number {
let tot = 0, done = 0;
for (const a of m.road.areas.filter((x) => x.section === section)) {
const layers = m.road.builds[a.kind]?.layers ?? [];
tot += a.area;
done += (a.area * layers.filter((l) => valueOf(s, roadKey(section, a.kind, l.id))).length) / (layers.length || 1);
}
return tot ? done / tot : 0;
}

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import surface from "./__fixtures__/mini-model/surface.json";
import { decodeSurface, surfZ, designZ } from "./surface";
import type { SurfaceModel } from "./model-types";
const s = surface as SurfaceModel;
describe("surface", () => {
it("decodes mm above zmin", () => expect([...decodeSurface(s)]).toEqual([1450.5, 1450.5, 1450.5, 1450.5]));
it("interpolates inside and returns null outside", () => {
const Z = decodeSurface(s);
expect(surfZ(s, Z, 0.5, 0.5)).toBeCloseTo(1450.5);
expect(surfZ(s, Z, 5, 5)).toBeNull();
});
it("falls back to pegs outside the grid", () => {
const Z = decodeSurface(s);
expect(designZ(s, Z, [{ name: "P", e: 10, n: 10, z: 1460, Y: 0, X: 0 }], 10, 10)).toBeCloseTo(1460);
});
});

View file

@ -0,0 +1,29 @@
import type { Peg, SurfaceModel } from "./model-types";
export function decodeSurface(s: SurfaceModel): Float32Array {
const bin = atob(s.z), u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
const q = new Int16Array(u8.buffer), Z = new Float32Array(q.length);
for (let i = 0; i < q.length; i++) Z[i] = q[i] < 0 ? NaN : s.zmin + q[i] / 1000;
return Z;
}
export function surfZ(s: SurfaceModel, Z: Float32Array, e: number, n: number): number | null {
const fx = (e - s.x0) / s.cell, fy = (n - s.y0) / s.cell;
if (fx < 0 || fy < 0 || fx > s.nx - 1 || fy > s.ny - 1) return null;
const i = Math.min(Math.floor(fx), s.nx - 2), j = Math.min(Math.floor(fy), s.ny - 2), u = fx - i, v = fy - j;
const corners: [number, number][] = [[Z[j * s.nx + i], (1 - u) * (1 - v)], [Z[j * s.nx + i + 1], u * (1 - v)], [Z[(j + 1) * s.nx + i], (1 - u) * v], [Z[(j + 1) * s.nx + i + 1], u * v]];
const ok = corners.filter(([z]) => !Number.isNaN(z));
if (!ok.length) return null;
const w = ok.reduce((t, [, k]) => t + k, 0) || 1;
return ok.reduce((t, [z, k]) => t + z * k, 0) / w;
}
export function pegZ(pegs: Peg[], e: number, n: number): number {
const best = pegs.map((p) => [Math.hypot(p.e - e, p.n - n) + 0.5, p.z] as const).sort((a, b) => a[0] - b[0]).slice(0, 6);
let w = 0, t = 0;
for (const [d, z] of best) { const k = 1 / (d * d); w += k; t += k * z; }
return t / w;
}
export const designZ = (s: SurfaceModel, Z: Float32Array, pegs: Peg[], e: number, n: number) => surfZ(s, Z, e, n) ?? pegZ(pegs, e, n);