feat(site-map): chainage, surface heights and BoQ quantities
This commit is contained in:
parent
300b1d2a07
commit
dae2aeb044
6 changed files with 177 additions and 0 deletions
11
src/lib/site-map/chainage.test.ts
Normal file
11
src/lib/site-map/chainage.test.ts
Normal 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));
|
||||
});
|
||||
11
src/lib/site-map/chainage.ts
Normal file
11
src/lib/site-map/chainage.ts
Normal 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;
|
||||
}
|
||||
31
src/lib/site-map/quantities.test.ts
Normal file
31
src/lib/site-map/quantities.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
77
src/lib/site-map/quantities.ts
Normal file
77
src/lib/site-map/quantities.ts
Normal 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;
|
||||
}
|
||||
18
src/lib/site-map/surface.test.ts
Normal file
18
src/lib/site-map/surface.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
src/lib/site-map/surface.ts
Normal file
29
src/lib/site-map/surface.ts
Normal 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);
|
||||
Loading…
Reference in a new issue