From dae2aeb044d03cb1b1099155f1a881967a7e8f4b Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 14 Oct 2026 07:06:50 +0200 Subject: [PATCH] feat(site-map): chainage, surface heights and BoQ quantities --- src/lib/site-map/chainage.test.ts | 11 +++++ src/lib/site-map/chainage.ts | 11 +++++ src/lib/site-map/quantities.test.ts | 31 ++++++++++++ src/lib/site-map/quantities.ts | 77 +++++++++++++++++++++++++++++ src/lib/site-map/surface.test.ts | 18 +++++++ src/lib/site-map/surface.ts | 29 +++++++++++ 6 files changed, 177 insertions(+) create mode 100644 src/lib/site-map/chainage.test.ts create mode 100644 src/lib/site-map/chainage.ts create mode 100644 src/lib/site-map/quantities.test.ts create mode 100644 src/lib/site-map/quantities.ts create mode 100644 src/lib/site-map/surface.test.ts create mode 100644 src/lib/site-map/surface.ts diff --git a/src/lib/site-map/chainage.test.ts b/src/lib/site-map/chainage.test.ts new file mode 100644 index 0000000..abbd906 --- /dev/null +++ b/src/lib/site-map/chainage.test.ts @@ -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)); +}); diff --git a/src/lib/site-map/chainage.ts b/src/lib/site-map/chainage.ts new file mode 100644 index 0000000..928271a --- /dev/null +++ b/src/lib/site-map/chainage.ts @@ -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; +} diff --git a/src/lib/site-map/quantities.test.ts b/src/lib/site-map/quantities.test.ts new file mode 100644 index 0000000..def97d1 --- /dev/null +++ b/src/lib/site-map/quantities.test.ts @@ -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); + }); +}); diff --git a/src/lib/site-map/quantities.ts b/src/lib/site-map/quantities.ts new file mode 100644 index 0000000..bc27e25 --- /dev/null +++ b/src/lib/site-map/quantities.ts @@ -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 = { 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(); + 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(); + 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) => 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; +} diff --git a/src/lib/site-map/surface.test.ts b/src/lib/site-map/surface.test.ts new file mode 100644 index 0000000..0807de0 --- /dev/null +++ b/src/lib/site-map/surface.test.ts @@ -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); + }); +}); diff --git a/src/lib/site-map/surface.ts b/src/lib/site-map/surface.ts new file mode 100644 index 0000000..eeeb002 --- /dev/null +++ b/src/lib/site-map/surface.ts @@ -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);