feat(site-map): Munyaka 2 stormwater and village network, Progress section highlight, layer switches

Munyaka model: 27 stormwater structures and 26 pipes from the issued structure table
(MH/KI/outlet by CAD block), water node points, and the information-stage village network
(services, ponds, boundary, lots, hydrants) aligned from the SDP Rev C PDF.

Map 3: selected Progress section is outlined and its row highlighted; Show-on-map gains
ground surface, contours, kerbs, markings, toes, reserve, signs, studs and HOLD switches;
the ground fades when underground services are ticked; pipes and manholes are chunkier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-28 12:13:38 +02:00
parent d5d226e022
commit 8e1cbc8b75
25 changed files with 2241 additions and 18 deletions

View file

@ -0,0 +1,50 @@
# Munyaka 2 — first site map
Added to the existing site `1d4hqov69qzajcp`, using the normal five model files and existing Map / Map 2 / Map 3 viewers. No app interface changes are needed.
Active model: `wprfbgsqi9f121j`. The new Site Maps card and main map were verified in the authenticated dashboard. All five protected server files were downloaded and checked byte for byte against the local outputs; see `upload-verification.json`.
The construction model covers the **access road and its services**, from the C3175 Rev 0 issue dated 12 October 2026. The separate village development plan **C3175-VIL-SDP-001 Rev C, 22 October 2026**, is information-stage reference for the internal village network; its wider scope is not represented as construction-ready quantities.
## Included
- 21 provisional planning sections, grouped into West connection, Access road and Village entrance. Distances are 20 m spatial strips within each work area, not consultant chainages or approved construction phases.
- 3,887.935 m² of new asphalt, with 150 mm G7 selected layer, 150 mm C4 stabilised subbase, 150 mm G1 base and 30 mm asphalt, from the typical road section.
- New walkways (1,536.736 m²) and raised intersection (325.282 m²) as separate surface items; unconfirmed build-ups are omitted. Existing and future hatches are excluded.
- 87 finished-design contours. Independent comparison with 194 setting-out levels within contour coverage gives 31 mm median absolute difference; 90% are within 76 mm. This is an interpolated drawing model, not a setting-out replacement.
- 206 setting-out points with issued levels. Thirty T.B.C. levels are excluded. R155 is also excluded because its table coordinate and CAD point differ by 118 mm; the exact discrepancy is recorded in `validation.json`.
- Twelve sewer structures and eleven 160 mm Class 34 pipes, totalling 377.677 m. Inverts and lengths are checked against the issued PDFs, with manhole drops retained. Source IDs SR1–SR12 display as SR-1–SR-12 for compatibility with the existing viewer.
- Stormwater: 27 structures (SW1–SW28, no SW22 in the issue) and 26 pipes (Ø450–Ø1050, 524.204 m) from the C3175-920-002 structure table, with coordinates, IN/OUT inverts and rim-to-sump depths. Every printed invert is used exactly once and checked against the issued PDF. No stormwater long sections were issued, so rim/ground level is blank. At SW6 the two 750 mm arrivals are paired high-to-high (gives the 1 % road-crossing fall): confirm on site.
- Water plan route (416.469 m): drawn route length only, not confirmed trench quantities or procurement lengths.
- Road-marking and kerb linework. Kerb quantities are omitted because the mixed layer has duplicates, paired faces and channels.
**SR1 and SR2→SR1 remain on HOLD:** the issued sewer sheet requires the existing connection invert, drawn at IL 1475.950, to be confirmed on site.
The existing viewer can calculate indicative trench estimates using generic assumptions. Those are not source-measured excavation quantities.
## Village network (information stage)
Drawn from the standalone SDP Rev C PDF (`extract_munyaka_village.py`): internal stormwater (2,397 m), sewer (2,527 m) and water (3,584 m) lines, five attenuation ponds, lot and kerb linework, township boundary and 28 fire hydrants. The PDF has no coordinates, so it is aligned to the CAD model by matching its manhole symbols to the issued SR and SW structures (19 matched points, median 0.30 m, max 1.7 m, rotation -30.1 degrees). Plan position only: no levels, sizes or quantities, no village ground surface (drawn at the height of the nearest road level), no construction phases. Subsoil drains, floodline and buffer zone are not extracted. It appears under Stormwater, Sewer, Water, Reserve & village boundary and Road edges, kerbs & village lots.
## Material available for the next step
The outer OneDrive archive and all ten nested CAD archives were unpacked and inventoried. They contain eleven issued engineering PDFs, ten electronic drawing packages with external references, a drawing register, two JRA manuals (79 road pages / 47 stormwater pages), and fourteen Johannesburg Water standard details. All original file hashes and the exact converted CAD inputs are in `sources.json`.
The standalone village SDP adds internal roads, water, sewer, stormwater, subsoil drains, attenuation ponds and the schematic service arrangement. It can guide a later village model. The construction-ready access-road package should remain clearly distinguished from this newer, information-stage village layout.
The setting-out sheet, marking/sign layout, water nodal layout and water schematic have further reference details available. Sign, stud and fitting quantities are not claimed in this first model. Municipal standard details are references, not additional site geometry.
## Missing for cut/fill and phases
The package contains a **typical** road section and sewer longitudinal profiles, but no measured road cross-sections, road longitudinal section or surveyed natural-ground surface. The supplied contours are finished-design levels. Therefore no bulk cut/fill quantities or earthworks strips are generated; the empty earthworks file means unavailable data, not zero earthworks.
For separate cut/fill sections, obtain the road long/cross-sections and a dated natural-ground survey on the same datum, plus adopted formation levels and earthworks limits. For the internal village network, obtain its CAD layouts and design/ground surfaces. No approved construction phases, sequence or programme dates were supplied; the three current planning areas are provisional.
## Provenance and checks
- Original deliveries: `/Volumes/KIOXIA/Downloads/OneDrive_2026-10-28.zip` and `/Volumes/KIOXIA/Downloads/C3175-VIL-SDP-001 REV C- 2026-10-22.pdf`.
- Session extraction: `/private/tmp/munyaka-source-2026-10-28` (temporary; originals remain in Downloads).
- `sources.json`: complete source inventory and hashes, plus the exact CAD conversion input matches.
- `validation.json`: quantities, surface checks, unresolved points and output hashes.
- `map-preview.png` / `.svg`: plan preview for visual comparison with the road layout PDF.
- Generator: `scripts/site-model/extract_munyaka.py`. Geometry allocation, T.B.C. exclusion, model data and the existing-connection hold are covered by `test_munyaka.py`; the normal site-model tests and viewer compatibility checks were also run.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"source":"Cut and fill is unavailable: the package has a typical road section and sewer profiles, but no measured road cross-sections, road longitudinal section or surveyed natural-ground surface. Design contours must not be treated as natural ground.","method":"Not quantified: no natural ground / measured road cross-sections","chainageOffset":0,"chainageEnd":0,"sections":[],"volumes":[],"alignment":[],"totals":{"cut":0,"fill":0},"validation":{"samples":0,"meanAbs":0,"median":0,"p90Abs":0,"note":"Cut and fill is unavailable: the package has a typical road section and sewer profiles, but no measured road cross-sections, road longitudinal section or surveyed natural-ground surface. Design contours must not be treated as natural ground."}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 95 KiB

View file

@ -0,0 +1 @@
{"signs":[],"signCounts":{},"markingLabels":[],"markingCounts":{},"studs":[],"studCounts":{},"markings":{},"paintAreas":{},"waterFittings":{},"notes":["Road marking linework retained; sign/stud/fitting schedules are not quantified in this first model."]}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,27 @@
{
"site_id": "1d4hqov69qzajcp",
"model_id": "wprfbgsqi9f121j",
"status": "active",
"files_verified": {
"bushveld": {
"bytes": 162021,
"sha256": "892c506e9c4d3218eb1a13aa84dace5c053a7678d77c6e3a87adb2456fdf06cf"
},
"services": {
"bytes": 7305,
"sha256": "bb9daddce461f4b5e454bd3cea91ee3b2584aac482aab6bd2354ffb09559d567"
},
"earthworks": {
"bytes": 747,
"sha256": "d0871603802a00750990c66ad111b21586f94326a602b9b1e643fabef940eeb7"
},
"surface": {
"bytes": 92245,
"sha256": "c796560c4d23746a55bb3845751fe8208fa8ba6c14cb9206f8feffac888b45d1"
},
"schedules": {
"bytes": 254,
"sha256": "a3a0a86ece28962a5b2310bfc2f22e8cf16ffff252ded6cb79b0e553fa486c09"
}
}
}

View file

@ -0,0 +1,168 @@
{
"project": "Munyaka Village \u2014 access road and services (C3175)",
"sections": 21,
"areas_m2": {
"asphalt": 3887.935,
"walkway": 1536.736,
"raised": 325.282
},
"setting_out": {
"known_levels": 206,
"pending_levels": [
"R275",
"R276",
"R277",
"R278",
"R285",
"R286",
"R287",
"R288",
"R289",
"R290",
"R291",
"R292",
"R293",
"R294",
"R295",
"R296",
"R297",
"R298",
"R299",
"R300",
"R301",
"R302",
"R303",
"R304",
"R305",
"R306",
"R307",
"R308",
"R309",
"R310"
],
"conflicts": [
{
"point": {
"name": "R155",
"Y": 88520.169,
"X": 2882079.641,
"z": 1485.817,
"e": 129.831,
"n": -29.641
},
"table_cad_distance_m": 0.1175273091530567
}
],
"max_table_cad_error_m": 0.1175273091530567
},
"contours": 87,
"surface_check": {
"pegs": 194,
"medianAbs": 0.0312,
"p90Abs": 0.0756
},
"surface_valid_cells": 14369,
"sewer_structures": 12,
"sewer_pipes": 11,
"sewer_length_m": 377.677,
"sewer_length_checks": [
{
"from": "SR12",
"to": "SR11",
"length": 10.966000000000008,
"printed": 10.97
},
{
"from": "SR11",
"to": "SR5",
"length": 24.75999999999999,
"printed": 24.76
},
{
"from": "SR5",
"to": "SR4",
"length": 56.48199999999997,
"printed": 56.48
},
{
"from": "SR4",
"to": "SR3",
"length": 58.379999999999995,
"printed": 58.38
},
{
"from": "SR3",
"to": "SR2",
"length": 34.10500000000002,
"printed": 34.1
},
{
"from": "SR2",
"to": "SR1",
"length": 22.004999999999995,
"printed": 22.0
},
{
"from": "SR10",
"to": "SR9",
"length": 24.934000000000026,
"printed": 24.93
},
{
"from": "SR9",
"to": "SR8",
"length": 28.081000000000017,
"printed": 28.08
},
{
"from": "SR8",
"to": "SR7",
"length": 39.976,
"printed": 39.98
},
{
"from": "SR7",
"to": "SR6",
"length": 35.813999999999965,
"printed": 35.81
},
{
"from": "SR6",
"to": "SR5",
"length": 42.174000000000035,
"printed": 42.17
}
],
"stormwater_structures": 27,
"stormwater_pipes": 26,
"stormwater_pipe_length_m": 524.204,
"stormwater_equal_size_arrivals_paired_by_level": [
"SW6"
],
"water_points": 18,
"village": {
"alignment": {
"matchedPoints": 19,
"medianResidualM": 0.301,
"maxResidualM": 1.689,
"rotationDeg": -30.11,
"of": 39
},
"routes": 185,
"ponds": 26,
"hydrants": 28
},
"plan_route_lengths_m": {
"stormwater": 524.206,
"water": 416.469
},
"cut_fill_available": false,
"approved_phases_available": false,
"sha256": {
"bushveld.json": "c6900ac0ef8cca2a122e93e32716d927480ba8c88eaa68774e32cac193ed12fa",
"surface.json": "c796560c4d23746a55bb3845751fe8208fa8ba6c14cb9206f8feffac888b45d1",
"services.json": "347c35672326daa1facc0c9bd72fd1d10ab0564daa36dd81de41b176a51423c3",
"earthworks.json": "d0871603802a00750990c66ad111b21586f94326a602b9b1e643fabef940eeb7",
"schedules.json": "a3a0a86ece28962a5b2310bfc2f22e8cf16ffff252ded6cb79b0e553fa486c09"
}
}

View file

@ -5,6 +5,34 @@ Turns a consultant's drawings into the five JSON files the Site map page loads
Specific to Civil Concepts' layer names on Bushveld Joy Road (C3092); other drawing sets
need the layer maps at the top of each script adjusted.
## Munyaka Village / Munyaka 2 (C3175, added 2026-10-28)
The first model covers the access-road construction issue (12 October 2026).
The separate village SDP Rev C (22 October) remains information-stage reference.
See `reports/site-models/munyaka/2026-10-28/README.md` for coverage, source gaps and holds.
Unpack the OneDrive archive and all ten nested ZIPs to a source folder. Convert
the unique DWG filenames with LibreDWG, keeping a copy of each input beside its
DXF. The DXF folder's `files.json` maps original DWG names to converted DXF
filenames (e.g. `"ACCESS NEW.dwg": "00.dxf"`). Keep numeric filenames short for
LibreDWG; run conversion from the DXF folder because it writes to the current
directory. Extract all PDF text with `pdftotext -layout` into `SOURCE/text/`.
Include the standalone village PDF in SOURCE for its source hash.
With `ezdxf numpy scipy shapely>=2.1` installed:
```sh
python3 scripts/site-model/extract_munyaka.py DXF_FOLDER SOURCE_FOLDER OUTPUT_FOLDER
python3 -m unittest discover -s scripts/site-model -p 'test_munyaka.py'
python3 scripts/site-model/upload.py --site 1d4hqov69qzajcp \
--label 'Munyaka Village — access road and services · Rev 0 (2026-10-12)' OUTPUT_FOLDER
```
The extractor checks source coordinates, builds the stormwater network from the C3175-920-002 structure table (every invert used once), preserves sewer invert drops and the
SR1 existing-connection hold, excludes T.B.C./conflicting setting-out points,
and conserves new-work areas in disjoint provisional planning strips. It never
substitutes finished-design contours for natural ground.
## Tools
- LibreDWG (`brew install libredwg`) → `dwg2dxf`
- poppler (`brew install poppler`) → `pdftotext`, `pdftocairo`

View file

@ -0,0 +1,412 @@
"""Build Munyaka's initial site model from the C3175 October 2026 issue.
Uses the existing five-file model contract; no app changes or invented ground levels.
Requires ezdxf, numpy, scipy and shapely>=2.1. See README.md for preparation.
"""
import argparse
from collections import defaultdict
import hashlib
import json
import math
from pathlib import Path
import re
import numpy as np
from scipy.interpolate import LinearNDInterpolator
from shapely.geometry import LineString, Point, Polygon, box
from shapely.ops import unary_union
from extract_munyaka_village import village_model
from extract_cedar_gate import load_dxf, hatch_geometry, polygons, surface, layer
def point_table(texts, origin):
"""Read the issued R-point table. TBC is missing data, never a zero level."""
pegs, pending = [], []
for x, y, name in texts:
if not re.fullmatch(r"R\d+", name):
continue
columns = sorted((xx, text) for xx, yy, text in texts if 0 < xx-x < 75 and abs(yy-y) < .05)
if len(columns) != 3:
raise ValueError(f"{name}: expected Y, X and level columns, got {columns}")
Y, X = (float(t.replace(" ", "")) for _, t in columns[:2])
level = columns[2][1].strip()
if level == "T.B.C.":
pending.append(name)
continue
z = float(level)
if not 1400 < z < 1550:
raise ValueError(f"{name}: invalid level {z}")
pegs.append({"name": name, "Y": Y, "X": X, "z": z,
"e": round(-Y-origin[0], 3), "n": round(-X-origin[1], 3)})
if len({p['name'] for p in pegs} | set(pending)) != len(pegs)+len(pending):
raise ValueError("Duplicate setting-out point names")
return pegs, pending
def planning_bands(geometry, direction, prefix, name, step=20):
"""Disjoint 20 m spatial planning strips; distances are not design chainages."""
u = np.asarray(direction, dtype=float)
u /= np.linalg.norm(u)
v = np.array([-u[1], u[0]])
vertices = np.array([p for g in polygons(geometry) for p in g.exterior.coords])
us, vs = vertices @ u, vertices @ v
lo, hi = float(us.min()), float(us.max())
bands = []
for start in np.arange(0, hi-lo, step):
end = min(float(start+step), hi-lo)
corners = [list(u*a+v*b) for a, b in [(lo+start, vs.min()-1), (lo+end, vs.min()-1),
(lo+end, vs.max()+1), (lo+start, vs.max()+1)]]
clipped = geometry.intersection(Polygon(corners))
if clipped.area < 1e-8:
continue
section = {"key": f"{prefix}-plan{int(start)}-{math.ceil(end)}", "name": f"{name} · planning distance",
"from": round(float(start), 3), "to": round(end, 3)}
bands.append((section, clipped))
if abs(sum(g.area for _, g in bands)-geometry.area) > .001:
raise ValueError("Planning strips do not conserve area")
return bands
def geometry_rows(doc, wanted):
from ezdxf.path import make_path, from_hatch
rows = defaultdict(list)
for e in doc.modelspace():
if e.dxf.layer not in wanted:
continue
kind = e.dxftype()
if kind in ("LINE", "ARC", "LWPOLYLINE", "POLYLINE", "CIRCLE"):
rows[e.dxf.layer].append({"type": kind, "handle": e.dxf.handle,
"pts": [list(p) for p in make_path(e).flattening(.025)]})
elif kind == "HATCH":
rows[e.dxf.layer].append({"type": kind, "rings": [[list(p) for p in path.flattening(.025)] for path in from_hatch(e)]})
return rows
def texts(doc, wanted):
return [(float(e.dxf.insert.x), float(e.dxf.insert.y), e.plain_text() if e.dxftype() == "MTEXT" else e.dxf.text)
for e in doc.modelspace() if e.dxftype() in ("TEXT", "MTEXT") and e.dxf.layer in wanted]
def sewer_profiles(doc, origin, issued):
"""Issued coordinate tables and the two sewer bands, including manhole drops."""
text = texts(doc, {"CC_LS_TEXT"})
coords = {}
for x, y, value in text:
names = re.findall(r"SR\d+", value)
if not names or re.sub(r"SR\d+|\s+", "", value):
continue
columns = sorted((xx, val) for xx, yy, val in text if x < xx < x+50 and abs(yy-y) < .05)
if len(columns) != 2:
raise ValueError("Sewer coordinate columns are ambiguous")
ys = re.findall(r"\d{2} \d{3}\.\d+", columns[0][1])
xs = re.findall(r"\d \d{3} \d{3}\.\d+", columns[1][1])
if len(names) != len(ys) or len(names) != len(xs):
raise ValueError("Sewer coordinate count mismatch")
for name, Y, X in zip(names, ys, xs):
xy = (float(Y.replace(" ", "")), float(X.replace(" ", "")))
if name in coords and coords[name] != xy:
raise ValueError(f"Conflicting coordinates for {name}")
coords[name] = xy
band = texts(doc, {"Profile View-Band-C3D"})
anchors = sorted((x, y) for x, y, t in band if t == "MANHOLE / JUNCTION BOX")
manholes, pipes, checks, junction_inverts = {}, [], [], {}
for run, (ax, ay) in enumerate(anchors, 1):
end = anchors[run][0]-5 if run < len(anchors) else ax+350
row = lambda offset: sorted((x, t) for x, y, t in band if ax+65 < x < end and abs(y-(ay-offset)) < .05)
names = [(x, n) for x, n in row(0) if re.fullmatch(r"SR\d+", n)]
points = []
for x, name in names:
values = lambda offset: [(xx, float(t)) for xx, t in row(offset) if abs(xx-x) <= 2.1]
gl, inv, ch = values(8), values(16), values(56)
if not gl or not inv or not ch:
raise ValueError(f"Incomplete sewer profile: {name}")
for level in [gl[0][1], *[v for _, v in inv]]:
if f"{level:.3f}" not in issued:
raise ValueError(f"Sewer level {name} {level:.3f} absent from issued PDFs")
Y, X = coords[name]
# Existing viewers expect a hyphen before the manhole number. Keep
# the issued label as the ID, with a compatible display label.
item = {"name": name.replace('SR', 'SR-'), "id": name, "run": run, "il": min(v for _, v in inv), "located": True,
"Y": Y, "X": X, "e": round(-Y-origin[0], 3), "n": round(-X-origin[1], 3)}
levels = {v for _, v in inv}
if name in manholes:
# A branch terminates at the incoming invert of a drop manhole;
# the through profile also shows its lower outgoing invert.
if not levels.intersection(junction_inverts[name]):
raise ValueError(f"Sewer junction level mismatch: {name}")
item['il'] = min(item['il'], manholes[name]['il'])
junction_inverts.setdefault(name, set()).update(levels)
manholes[name] = item
points.append({"x": x, "name": name, "ch": ch[0][1], "left": inv[0][1], "right": inv[-1][1]})
descriptions = row(50)
for a, b in zip(points, points[1:]):
desc = [t for x, t in descriptions if a['x'] < x < b['x']]
if len(desc) != 1 or not re.search(r"160\s*/\s*CLASS 34", desc[0]):
raise ValueError("Unverified sewer diameter/class")
length = b['ch']-a['ch']
drop = a['right']-b['left']
if length <= 0 or drop <= 0:
raise ValueError("Sewer pipe has non-positive length/fall")
grade = 100*drop/length
# Independent printed length and grade checks (CAD band is 1:1 horizontal).
mid = (a['x']+b['x'])/2
lengths = [(abs(x-mid), float(t[:-1])) for x, y, t in band
if abs(y-(ay-32.375)) < .1 and re.fullmatch(r"\d+\.\d+m", t)]
printed = min(lengths)[1] if lengths else None
if printed is None or abs(printed-length) > .011:
raise ValueError(f"Sewer length check failed: {a['name']}–{b['name']}")
pipes.append({"from": a['name'], "to": b['name'], "run": run, "dia": 160, "length": round(length, 3),
"ilFrom": a['right'], "ilTo": b['left'], "grade": round(grade, 3), "hold": None})
checks.append({"from": a['name'], "to": b['name'], "length": length, "printed": printed})
if len(manholes) != 12 or len(pipes) != 11:
raise ValueError(f"Expected 12 sewer structures / 11 pipes, got {len(manholes)} / {len(pipes)}")
if not re.search(r'LEVEL TO BE CONFORMED ON SITE\s+IL:\s*1475\.95', issued):
raise ValueError('Existing sewer connection level-check note is absent from this issue')
hold = 'Confirm existing sewer connection invert on site (drawn IL 1475.950); C3175-M931-001 Rev 0.'
manholes['SR1']['hold'] = hold
for pipe in pipes:
if pipe['to'] == 'SR1':
pipe['hold'] = hold
return list(manholes.values()), pipes, checks
def stormwater_network(layout, runs, origin, issued):
"""Structure table of C3175-920-002 plus the drawn SW PIPE runs.
Pipe size/invert pairs come only from the issued table: a pipe leaves a structure at an OUT
invert and arrives at the next structure at an IN invert of the same size. Every printed
invert must be used exactly once, and every level must appear in the issued PDF.
"""
cells = defaultdict(dict)
for e in layout.modelspace():
if e.dxftype() == "MTEXT" and e.dxf.layer == "Pipe and Structure-Table-C3D":
cells[round(e.dxf.insert.y, 1)][round(e.dxf.insert.x, 1)] = e.plain_text()
columns = sorted({x for r in cells.values() for x in r})
name_x, y_x, x_x, inv_x, depth_x = columns[0], columns[1], columns[2], columns[3], columns[-1]
structures = {}
for row in cells.values():
name = row.get(name_x, "")
if not re.fullmatch(r"SW\d+", name):
continue
Y, X = -float(row[y_x].replace(" ", "")), -float(row[x_x].replace(" ", ""))
inverts = [(int(m.group(1).replace(" ", "")), m.group(2), float(m.group(3)))
for m in re.finditer(r"([\d ]+)mm INV (IN|OUT)=(\d+\.\d+)", row[inv_x])]
if not inverts or (name in structures):
raise ValueError(f"Bad stormwater table row {name}")
for _, _, level in inverts:
if not 1400 < level < 1550 or f"{level:.3f}" not in issued:
raise ValueError(f"Stormwater level {name} {level} absent from issued PDF")
structures[name] = {"cad": (Y, X), "inverts": inverts, "depth": float(row[depth_x]) if row.get(depth_x) else None}
if len(structures) != 27:
raise ValueError(f"Expected 27 stormwater structures, got {len(structures)}")
hops = [] # (up, down, diameter, run, length) for each drawn hop between adjacent structures
for run, line in enumerate(runs, 1):
ls = LineString(line)
on = sorted((ls.project(Point(s["cad"])), n) for n, s in structures.items() if ls.distance(Point(s["cad"])) < 2.5)
if len(on) < 2 or on[0][0] > 3 or ls.length-on[-1][0] > 3:
raise ValueError(f"Stormwater run {run} does not start and end on structures")
for (pa, a), (pb, b) in zip(on, on[1:]):
options = {(up, down, o[0]) for up, down in ((a, b), (b, a)) for o in structures[up]["inverts"] if o[1] == "OUT"
for i in structures[down]["inverts"] if i[1] == "IN" and i[0] == o[0] and o[2] > i[2]}
if len(options) != 1:
raise ValueError(f"Cannot identify the pipe between {a} and {b}: {options}")
hops.append((*options.pop(), run, round(pb-pa, 3)))
pipes, ambiguous, used = [], [], set()
for down in sorted({h[1] for h in hops}):
for dia in sorted({h[2] for h in hops if h[1] == down}):
group = sorted((h for h in hops if h[1] == down and h[2] == dia),
key=lambda h: -next(o[2] for o in structures[h[0]]["inverts"] if o[1] == "OUT" and o[0] == dia))
levels = sorted((i[2] for i in structures[down]["inverts"] if i[1] == "IN" and i[0] == dia), reverse=True)
if len(group) != len(levels):
raise ValueError(f"{down}: {len(group)} pipes but {len(levels)} printed {dia} mm inverts")
if len(group) > 1:
ambiguous.append(down) # equal-size arrivals are paired high-to-high; see the note in services
for (up, _, _, run, length), il_to in zip(group, levels):
il_from = next(o[2] for o in structures[up]["inverts"] if o[1] == "OUT" and o[0] == dia)
used.update({(up, "OUT", dia, il_from), (down, "IN", dia, il_to)})
pipes.append({"from": up, "to": down, "run": run, "length": length, "dia": dia, "ilFrom": il_from, "ilTo": il_to,
"grade": round(100*(il_from-il_to)/length, 3), "diaSource": "structure table C3175-920-002"})
leftover = sorted(f"{n} {i[1]} {i[0]}" for n, st in structures.items() for i in st["inverts"] if (n, i[1], i[0], i[2]) not in used)
return structures, pipes, leftover, sorted(set(ambiguous))
def build(dxf_folder, source_root, output):
mapping = json.loads((dxf_folder/'files.json').read_text())
find = lambda pattern: next(dxf_folder/f for name, f in mapping.items() if re.search(pattern, name))
docs = {}
for key, pattern in [("access", r"^ACCESS NEW"), ("contours", r"^ACCESS_CONTOURS"),
("points", r"^C3175-914"), ("sewerls", r"^C3175-M931"),
("swlayout", r"^C3175-920"), ("stormwater", r"^SER-SW"), ("wnodes", r"^C3175-M941-001"), ("water", r"^SER-WR")]:
docs[key] = load_dxf(find(pattern))
print(f"Read {key}", flush=True)
origin = (-88650., -2882050.)
local = lambda pts: [[round(p[0]-origin[0], 4), round(p[1]-origin[1], 4)] for p in pts]
pegs, pending = point_table(texts(docs['points'], {"Point Table-C3D"}), origin)
# Independently reconcile the rounded issued table with the actual CAD point blocks.
inserts = [e.dxf.insert for e in docs['points'].modelspace() if e.dxftype() == "INSERT" and e.dxf.layer == "POINT BLOCK"]
point_errors = [(p, min(math.hypot(-p['Y']-v.x, -p['X']-v.y) for v in inserts)) for p in pegs]
conflicts = [{'point': p, 'table_cad_distance_m': d} for p, d in point_errors if d > .001]
pegs = [p for p, d in point_errors if d <= .001]
point_error = max(d for _, d in point_errors)
if len(conflicts) > 1:
raise ValueError(f"Unexpected point-table conflicts: {len(conflicts)}")
wanted = {'_RD_NEW ROAD HATCH', '_RD_NEW _PAVING', 'CC_RAISED INTERSECTION', 'CC_SOFT LANDSCAPING',
'_RD_KERBING', '_RD_NEW ROAD MARKINGS', 'CC_RD MARKINGS', 'CC_RDS_MARKINGS', 'CC_RDS_NEW_MARKINGS', 'CC_RD 1.5 X 100 LINE'}
raw = geometry_rows(docs['access'], wanted)
raised = hatch_geometry(raw['CC_RAISED INTERSECTION'])
asphalt = hatch_geometry(raw['_RD_NEW ROAD HATCH']).difference(raised)
paths = hatch_geometry(raw['_RD_NEW _PAVING']).difference(asphalt.union(raised))
shapes = {'asphalt': asphalt, 'walkway': paths, 'raised': raised}
extent = unary_union(list(shapes.values()))
# Three practical work fronts; they are spatial planning areas, not consultant phases.
zones = [('west', 'West connection', box(-88900, -2882200, -88765, -2881900), (0, -1)),
('access', 'Access road', box(-88765, -2882200, -88550, -2881900), (1, -.28)),
('entrance', 'Village entrance', box(-88550, -2882200, -88400, -2881900), (0, 1))]
bands = [entry for prefix, name, zone, direction in zones
for entry in planning_bands(extent.intersection(zone), direction, prefix, name)]
if abs(sum(g.area for _, g in bands)-extent.area) > .001:
raise ValueError("Planning zones omit or overlap works")
areas = []
for kind, shape in shapes.items():
for section, band in bands:
for p in polygons(shape.intersection(band)):
if p.area > 1e-7:
areas.append({'kind': kind, 'section': section['key'], 'area': round(p.area, 6),
'rings': [local(r.coords) for r in [p.exterior, *p.interiors]]})
if abs(sum(a['area'] for a in areas if a['kind'] == kind)-shape.area) > .001:
raise ValueError(f"Area allocation mismatch: {kind}")
# _RD_KERBING includes paired faces/channels and duplicates; retain as linework only.
# A single Fig.7 front-face quantity cannot be identified reliably in that mixed layer.
context = []
for name, rows in raw.items():
for r in rows:
if 'pts' in r:
context.append({'kind': 'tar_edge' if name == '_RD_KERBING' else 'marking', 'pts': local(r['pts'])})
contours = []
for r in geometry_rows(docs['contours'], {'Surface-C3D'})['Surface-C3D']:
z = r['pts'][0][2]
if not 1400 < z < 1550:
raise ValueError("Invalid design contour elevation")
contours.append({'z': z, 'major': abs(z-round(z)) < .001, 'pts': r['pts']})
sf, grid = surface(contours, origin, 'ACCESS_CONTOURS.dwg: 0.25 m design contours referenced by C3175-910-003 Rev 0. Finished design, not surveyed natural ground.')
samples = np.array([[p[0], p[1], c['z']] for c in contours for p in c['pts']])
interp = LinearNDInterpolator(samples[:, :2], samples[:, 2])
residuals = [float(interp(-p['Y'], -p['X']))-p['z'] for p in pegs]
residuals = np.array([r for r in residuals if np.isfinite(r)])
if not len(residuals) or np.median(np.abs(residuals)) > .06:
raise ValueError("Design contours disagree with issued setting-out points")
sf['check'] = {'pegs': len(residuals), 'medianAbs': round(float(np.median(np.abs(residuals))), 4),
'p90Abs': round(float(np.percentile(np.abs(residuals), 90)), 4)}
issued = ' '.join(p.read_text() for p in (source_root/'text').glob('*M931*.txt'))
manholes, sewer, sewer_checks = sewer_profiles(docs['sewerls'], origin, issued)
routes = []
for service, docname, lname in [('stormwater', 'stormwater', 'SW PIPE'), ('water', 'water', 'WR_PIPE')]:
for r in geometry_rows(docs[docname], {lname})[lname]:
if 'pts' in r and len(r['pts']) > 1:
routes.append({'id': f'munyaka-{service}-{r["handle"].lower()}', 'service': service, 'pts': local(r['pts']),
'length': round(LineString(r['pts']).length, 3), 'source': f'C3175 Rev 0 {docname} layout / {lname}; plan route only'})
sw_runs = [[p[:2] for p in r['pts']] for r in geometry_rows(docs['stormwater'], {'SW PIPE'})['SW PIPE'] if 'pts' in r and len(r['pts']) > 1]
sw_issued = ' '.join(p.read_text() for p in (source_root/'text').glob('*920-002*.txt'))
sw_structs, sw_pipes, sw_leftover, sw_ambiguous = stormwater_network(docs['swlayout'], sw_runs, origin, sw_issued)
if sw_leftover or len(sw_pipes) != 26:
raise ValueError(f"Stormwater network does not reconcile: {len(sw_pipes)} pipes, unpaired inverts {sw_leftover}")
blocks = [(e.dxf.insert.x, e.dxf.insert.y, e.dxf.name) for e in docs['stormwater'].modelspace()
if e.dxftype() == 'INSERT' and e.dxf.layer in ('SW MH & KI', '_SW MH & KI')]
def kind_of(name, cad):
if name == 'SW1':
return 'OUT' # only an arriving 1050 pipe: the outlet ("TO DAYLIGHT")
d, block = min((math.hypot(cad[0]-x, cad[1]-y), b) for x, y, b in blocks)
if d > 3:
raise ValueError(f'{name}: no structure block within 3 m')
return 'KI' if block.startswith('Structure-Storm-Kerb Inlet') else 'MH'
sw_out = [{'name': n, 'kind': kind_of(n, st['cad']), 'e': round(st['cad'][0]-origin[0], 3), 'n': round(st['cad'][1]-origin[1], 3), 'Y': -st['cad'][0], 'X': -st['cad'][1],
'il': min(i[2] for i in st['inverts']), 'gl': None, 'rimToSump': st['depth']} for n, st in sw_structs.items()]
water_points, seen = [], defaultdict(int)
for e in docs['wnodes'].modelspace():
if e.dxftype() == 'MULTILEADER' and e.dxf.layer == '_WR_NODES':
label = re.sub(r'\\[A-Za-z][^;]*;|[{}]', '', e.context.mtext.default_content).strip()
v = e.context.leaders[0].lines[0].vertices[0]
x, y = v.x, v.y
seen[label] += 1
water_points.append({'name': f'Node {label}' + ('' if seen[label] == 1 else f' ({chr(96+seen[label])})'), 'e': round(x-origin[0], 3), 'n': round(y-origin[1], 3),
'Y': round(-x, 3), 'X': round(-y, 3)})
for e in docs['water'].modelspace():
if e.dxftype() == 'INSERT' and e.dxf.layer == 'WR_FH':
x, y = e.dxf.insert.x, e.dxf.insert.y
water_points.append({'name': 'Fire hydrant', 'e': round(x-origin[0], 3), 'n': round(y-origin[1], 3), 'Y': round(-x, 3), 'X': round(-y, 3)})
if len(water_points) != 18 or any(not -400 < p['e'] < 400 or not -400 < p['n'] < 400 for p in water_points):
raise ValueError(f'Unexpected water node set: {len(water_points)}')
village_pdf = next(source_root.glob('C3175-VIL-SDP-001*.pdf'))
village = village_model(village_pdf, [(m['e'], m['n']) for m in manholes], [(m['e'], m['n']) for m in sw_out])
notes = [
'Munyaka 2 / Munyaka Village (C3175). This initial construction model covers the access road; the village-wide SDP Rev C dated 22 Oct 2026 is a separate information-stage layout.',
'West connection, Access road and Village entrance are provisional planning areas. Their 20 m distances are spatial planning strips, not official design chainages or approved construction phases. No programme dates were supplied.',
'Access road asphalt layers follow C3175-913-001 Rev 0: 30 mm asphalt, 150 mm G1 base, 150 mm C4 subbase and 150 mm G7 selected layer. Roadbed/general fill thicknesses are not quantified.',
'Walkways and raised intersection are separate surface areas. Their unconfirmed pavement build-ups are excluded. Existing-access, existing-paving and future-road hatches are excluded from new-work quantities.',
'Kerb faces and channels are shown as drawing linework. The mixed kerb layer contains duplicate and parallel lines, so no kerb length is claimed.',
f'{len(pegs)} setting-out points with issued levels are included; {len(pending)} T.B.C. levels and R155 (table / CAD position differs by 118 mm) are excluded. Design contours are checked independently against points within contour coverage.',
'Sewer: 12 structures and 11 × 160 mm Class 34 pipe runs from the issued longitudinal sections and coordinate tables. Issued IDs SR1–SR12 display as SR-1–SR-12 for viewer compatibility. SR1 and SR2→SR1 are on HOLD: the existing connection invert (drawn IL 1475.950) must be confirmed on site. Use the issued coordinate table where the sewer XREF differs.',
'Stormwater: 27 structures (SW1–SW28; the issued table has no SW22) and 26 pipes from the C3175-920-002 structure table, which gives coordinates, pipe sizes, IN/OUT inverts and rim-to-sump depths. No stormwater long sections were issued, so natural/finished ground at each structure is not known (rim level blank). Pipe lengths are centre-to-centre along the drawn runs. SW1 is the outlet; eight structures start a run. At SW6 the two 750 mm arrivals are paired high-to-high (SW7 to 1478.853, SW19 to 1478.766), which gives the 1 % road-crossing fall the notes require; confirm on site. Water is a plan route only; existing water pipes are excluded.',
'Cut and fill is unavailable: the package has a typical road section and sewer profiles, but no measured road cross-sections, road longitudinal section or surveyed natural-ground surface. Design contours must not be treated as natural ground.',
'Village (SDP Rev C, issued for information): internal stormwater, sewer and water lines, five attenuation ponds, lot/kerb linework, township boundary and 28 fire hydrants are drawn from the plan PDF, aligned to the CAD model on matched manholes (median 0.3 m). Plan position only: no levels, pipe sizes, quantities or construction phases, and no village ground surface, so it is drawn at the height of the nearest road level. Subsoil drains, the 1:100 floodline and buffer zone are not extracted.',
]
builds = {
'asphalt': {'name': 'Access road asphalt', 'color': 0x34373b, 'layers': [
layer('Selected G7 150mm', .15, 'm³', 0xae9466), layer('C4 stabilised subbase 150mm', .15, 'm³', 0xa6a49a),
layer('G1 crushed stone base 150mm', .15, 'm³', 0x96938a), layer('Asphalt 30mm', .03, 'm²', 0x34373b)]},
'walkway': {'name': 'New walkways', 'color': 0xc4bba7, 'layers': [layer('Walkway surface', 0, 'm²', 0xc4bba7)]},
'raised': {'name': 'Raised intersection', 'color': 0xd6b085, 'layers': [layer('Raised intersection surface', 0, 'm²', 0xd6b085)]},
}
road = {'project': 'Munyaka Village — access road and services (C3175)', 'origin': {'cadX': origin[0], 'cadY': origin[1]},
'builds': builds, 'sections': [s for s, _ in bands], 'areas': areas, 'kerbs': [], 'kerbQuantityFactor': 1,
'context': context, 'contours': [{**c, 'pts': local(c['pts'])} for c in contours], 'pegs': pegs, 'water': [], 'buffer': [], 'notes': notes}
services = {'planRoutes': routes, 'stormwater': {'structures': sw_out, 'pipes': sw_pipes, 'notes': [notes[7]]},
'sewer': {'manholes': manholes, 'pipes': sewer, 'notes': [notes[6]]}, 'water': {'points': water_points, 'pipeLabels': {}},
'village': village,
'holds': [{'id': 'SR1', 'name': 'SR-1 existing sewer connection', 'service': 'sewer',
'note': 'M931-001 Rev 0: LEVEL TO BE CONFORMED ON SITE. IL: 1475.95',
'english': 'Confirm the existing sewer connection invert on site before connecting SR2 to SR1.',
'scope': 'SR1 and SR2→SR1'}]}
earth = {'source': notes[8], 'method': 'Not quantified: no natural ground / measured road cross-sections',
'chainageOffset': 0, 'chainageEnd': 0, 'sections': [], 'volumes': [], 'alignment': [],
'totals': {'cut': 0, 'fill': 0}, 'validation': {'samples': 0, 'meanAbs': 0, 'median': 0, 'p90Abs': 0, 'note': notes[8]}}
schedules = {'signs': [], 'signCounts': {}, 'markingLabels': [], 'markingCounts': {}, 'studs': [], 'studCounts': {},
'markings': {}, 'paintAreas': {}, 'waterFittings': {}, 'notes': ['Road marking linework retained; sign/stud/fitting schedules are not quantified in this first model.']}
output.mkdir(parents=True, exist_ok=True)
for name, data in [('bushveld', road), ('services', services), ('earthworks', earth), ('schedules', schedules), ('surface', sf)]:
(output/f'{name}.json').write_text(json.dumps(data, separators=(',', ':'), allow_nan=False))
report = {'project': road['project'], 'sections': len(bands), 'areas_m2': {k: round(v.area, 3) for k, v in shapes.items()},
'setting_out': {'known_levels': len(pegs), 'pending_levels': pending, 'conflicts': conflicts, 'max_table_cad_error_m': point_error},
'contours': len(contours), 'surface_check': sf['check'], 'surface_valid_cells': int(np.isfinite(grid).sum()),
'sewer_structures': len(manholes), 'sewer_pipes': len(sewer), 'sewer_length_m': round(sum(p['length'] for p in sewer), 3),
'sewer_length_checks': sewer_checks, 'stormwater_structures': len(sw_out), 'stormwater_pipes': len(sw_pipes), 'stormwater_pipe_length_m': round(sum(p['length'] for p in sw_pipes), 3),
'stormwater_equal_size_arrivals_paired_by_level': sw_ambiguous, 'water_points': len(water_points), 'village': {'alignment': village['alignment'], 'routes': len(village['routes']), 'ponds': len(village['ponds']), 'hydrants': len(village['hydrants'])}, 'plan_route_lengths_m': {s: round(sum(r['length'] for r in routes if r['service'] == s), 3) for s in ('stormwater', 'water')},
'cut_fill_available': False, 'approved_phases_available': False,
'sha256': {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in output.glob('*.json') if f.stem in ('bushveld', 'services', 'earthworks', 'schedules', 'surface')}}
(output/'validation.json').write_text(json.dumps(report, indent=2))
source_files = [p for p in sorted(source_root.rglob('*')) if p.is_file()
and not any(x in p.parts for x in ('dxf', 'text'))
and (p.parent != source_root or p.suffix.lower() == '.pdf')]
hashes = {p: hashlib.sha256(p.read_bytes()).hexdigest() for p in source_files}
conversions = []
for name, converted in mapping.items():
dxf = dxf_folder/converted
original_hash = hashlib.sha256(dxf.with_suffix('.dwg').read_bytes()).hexdigest()
conversions.append({'drawing': name, 'input_sha256': original_hash,
'matching_source_paths': [str(p.relative_to(source_root)) for p, h in hashes.items() if h == original_hash],
'dxf_sha256': hashlib.sha256(dxf.read_bytes()).hexdigest()})
(output/'sources.json').write_text(json.dumps({'notes': notes, 'cad_conversions': conversions, 'files': [
{'file': str(p.relative_to(source_root)), 'bytes': p.stat().st_size, 'sha256': hashes[p]}
for p in source_files]}, indent=2))
print(json.dumps({k: v for k, v in report.items() if k not in ('sha256', 'sewer_length_checks', 'setting_out')}, indent=2))
if __name__ == '__main__':
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('dxf_folder', type=Path)
ap.add_argument('source_root', type=Path)
ap.add_argument('output', type=Path)
args = ap.parse_args()
build(args.dxf_folder, args.source_root, args.output)

View file

@ -0,0 +1,136 @@
"""Village network of Munyaka (C3175-VIL-SDP-001 Rev C, issued for information) as plan linework.
The SDP is a vector PDF at 1:1000 with no coordinates, so it is aligned to the CAD model by
matching its manhole symbols to the issued SR1-SR12 sewer and SW stormwater structures.
Result is plan position only: no levels, sizes or quantities are read from it.
"""
import itertools
import math
import warnings
import numpy as np
from shapely.geometry import LineString
SCALE = 1000*0.0254/72 # metres per PDF point at 1:1000
# Legend colours of the SDP (see its LEGEND block)
ROUTES = {'stormwater': ((0.0, 0.72, 0.0), 1.08), 'sewer': ((1.0, 0.0, 0.0), 1.68), 'water': ((0.0, 0.25, 1.0), 1.08)}
POND = ((0.0, 1.0, 0.5), 0.72)
BOUNDARY = ((0.0, 0.0, 0.0), 0.72)
LAYOUT = ((0.0, 0.0, 0.0), 0.24)
def _open(pdf_path):
import pymupdf
warnings.filterwarnings('ignore')
page = pymupdf.open(str(pdf_path))[0]
return pymupdf, page, page.rotation_matrix
def _symbols(page, rot, colour, lo, hi):
out = []
for it in page.get_drawings():
r = it['rect']
if (it['fill'] or it['color']) is None or not lo <= round(r.width, 1) <= hi or r.height >= 8:
continue
c = tuple(round(x, 2) for x in (it['fill'] or it['color']))
if c == colour:
q = (r.tl + r.br)*0.5*rot
out.append((q.x*SCALE, -q.y*SCALE))
return np.array(out)
def _rigid(P, Q):
pc, qc = P.mean(0), Q.mean(0)
U, _, Vt = np.linalg.svd((P-pc).T@(Q-qc))
R = Vt.T@U.T
if np.linalg.det(R) < 0:
Vt[-1] *= -1
R = Vt.T@U.T
return R, qc-pc@R.T
def align(page, rot, sewer_mh, storm):
"""Rigid transform (no scale, no mirroring) from PDF metres to local model metres."""
P = _symbols(page, rot, (1.0, 0.0, 0.0), 2.3, 3.4)
Pw = _symbols(page, rot, (0.0, 0.72, 0.0), 2.8, 3.4)
Q = np.array(sewer_mh)
best = (0,)
dP = np.linalg.norm(P[:, None]-P[None], axis=2)
for i, j in itertools.combinations(range(len(Q)), 2):
d = np.linalg.norm(Q[i]-Q[j])
for a, b in zip(*np.where(np.abs(dP-d) < .6)):
vp, vq = P[b]-P[a], Q[j]-Q[i]
th = math.atan2(vq[1], vq[0])-math.atan2(vp[1], vp[0])
R = np.array([[math.cos(th), -math.sin(th)], [math.sin(th), math.cos(th)]])
T = Q[i]-P[a]@R.T
hits = (np.linalg.norm((P@R.T+T)[:, None]-Q[None], axis=2).min(0) < 1.5).sum()
if hits > best[0]:
best = (hits, R, T)
if best[0] < 8:
raise ValueError(f'Village PDF alignment failed: only {best[0]} sewer manholes matched')
_, R, T = best
both = np.vstack([Q, np.array(storm)])
for _ in range(6):
pairs = []
for cand, targets in ((P, Q), (Pw, np.array(storm))):
X = cand@R.T+T
for q in targets:
d = np.linalg.norm(X-q, axis=1)
if d.min() < 3:
pairs.append((cand[d.argmin()], q))
R, T = _rigid(np.array([a for a, _ in pairs]), np.array([b for _, b in pairs]))
res = np.linalg.norm(np.array([a for a, _ in pairs])@R.T+T-np.array([b for _, b in pairs]), axis=1)
if len(pairs) < 15 or np.median(res) > .6:
raise ValueError(f'Village PDF alignment is weak: {len(pairs)} pairs, median residual {np.median(res):.2f} m')
return R, T, {'matchedPoints': len(pairs), 'medianResidualM': round(float(np.median(res)), 3), 'maxResidualM': round(float(res.max()), 3),
'rotationDeg': round(math.degrees(math.atan2(R[1, 0], R[0, 0])), 2), 'of': len(both)}
def village_model(pdf_path, sewer_mh, storm, simplify=.25):
pymupdf, page, rot = _open(pdf_path)
R, T, fit = align(page, rot, sewer_mh, storm)
tf = lambda q: (np.array([(q*rot).x*SCALE, -(q*rot).y*SCALE])@R.T+T)
def points(it):
out = []
for seg in it['items']:
if seg[0] == 'l':
out += [seg[1], seg[2]]
elif seg[0] == 'c':
a, b, c, d = (np.array(v) for v in seg[1:5])
out += [pymupdf.Point(*(((1-t)**3)*a+3*((1-t)**2)*t*b+3*(1-t)*t*t*c+t**3*d)) for t in np.linspace(0, 1, 6)]
return out
buckets = {k: [] for k in ('stormwater', 'sewer', 'water', 'pond', 'boundary', 'layout')}
styles = {**{k: v for k, v in ROUTES.items()}, 'pond': POND, 'boundary': BOUNDARY, 'layout': LAYOUT}
for it in page.get_drawings():
if it['type'] != 's' or not it['color'] or (it['rect']*rot).x0 > 2000: # right-hand strip is the title block/legend
continue
key = (tuple(round(c, 2) for c in it['color']), round(it['width'] or 0, 2))
for name, style in styles.items():
if key == style:
pts = points(it)
if len(pts) >= 2:
line = LineString([tf(q) for q in pts])
if name == 'layout' and line.length < 10:
continue
if name == 'pond' and line.length < 25:
continue
buckets[name].append(line)
def rows(lines):
out = []
for ln in lines:
s = ln.simplify(simplify) if simplify else ln
if s.length > .5:
out.append({'pts': [[round(x, 2), round(y, 2)] for x, y in s.coords], 'length': round(s.length, 2)})
return out
routes = [{**r, 'service': svc} for svc in ('stormwater', 'sewer', 'water') for r in rows(buckets[svc])]
hydrants = []
for w in page.get_text('words'):
if w[4] == 'FH':
q = tf(pymupdf.Point((w[0]+w[2])/2, (w[1]+w[3])/2))
hydrants.append([round(float(q[0]), 2), round(float(q[1]), 2)])
return {'source': 'C3175-VIL-SDP-001 Rev C (22 Oct 2026), issued for information. Plan position only, aligned to the CAD model; no levels, sizes or quantities.',
'alignment': fit, 'routes': routes, 'ponds': rows(buckets['pond']), 'boundary': rows(buckets['boundary']),
'layout': rows(buckets['layout']), 'hydrants': hydrants}

View file

@ -0,0 +1,118 @@
"""Guard source fidelity when importing the Munyaka construction drawings."""
import unittest
import json
from pathlib import Path
from shapely.geometry import box
from shapely.ops import unary_union
from extract_munyaka import point_table, planning_bands
class MunyakaTests(unittest.TestCase):
def test_existing_sewer_connection_retains_the_issued_level_check(self):
root = Path(__file__).resolve().parents[2] / 'reports/site-models/munyaka/2026-10-28'
services = json.loads((root/'services.json').read_text())
sr1 = next(m for m in services['sewer']['manholes'] if m['id'] == 'SR1')
pipe = next(p for p in services['sewer']['pipes'] if p['to'] == 'SR1')
self.assertTrue(sr1.get('hold'))
self.assertEqual(pipe['hold'], sr1['hold'])
self.assertIn('confirm', ' '.join(h['english'] for h in services['holds']).lower())
def test_import_holds_unresolved_points_and_has_no_fabricated_ground(self):
root = Path(__file__).resolve().parents[2] / 'reports/site-models/munyaka/2026-10-28'
road = json.loads((root/'bushveld.json').read_text())
earth = json.loads((root/'earthworks.json').read_text())
report = json.loads((root/'validation.json').read_text())
self.assertEqual(len(road['pegs']), 206)
self.assertNotIn('R155', {p['name'] for p in road['pegs']})
self.assertEqual(len(report['setting_out']['pending_levels']), 30)
self.assertFalse(report['cut_fill_available'])
self.assertEqual(earth['sections'], [])
self.assertEqual(earth['volumes'], [])
self.assertNotIn('groundSurface', earth)
def test_sewer_grade_uses_viewer_percent_units_and_junction_drop_is_retained(self):
root = Path(__file__).resolve().parents[2] / 'reports/site-models/munyaka/2026-10-28'
services = json.loads((root/'services.json').read_text())
for pipe in services['sewer']['pipes']:
expected = 100*(pipe['ilFrom']-pipe['ilTo'])/pipe['length']
self.assertAlmostEqual(pipe['grade'], expected, delta=.001)
junction = next(m for m in services['sewer']['manholes'] if m['id'] == 'SR5')
self.assertEqual(junction['il'], 1479.230)
incoming = next(p for p in services['sewer']['pipes'] if p['from'] == 'SR6')
self.assertEqual(incoming['ilTo'], 1479.280)
def test_stormwater_network_is_reconciled_to_the_issued_structure_table(self):
root = Path(__file__).resolve().parents[2] / 'reports/site-models/munyaka/2026-10-28'
sw = json.loads((root/'services.json').read_text())['stormwater']
self.assertEqual(len(sw['structures']), 27)
self.assertEqual(len(sw['pipes']), 26)
names = {s['name'] for s in sw['structures']}
self.assertNotIn('SW22', names)
for pipe in sw['pipes']:
self.assertIn(pipe['from'], names)
self.assertIn(pipe['to'], names)
self.assertGreater(pipe['ilFrom'], pipe['ilTo'])
self.assertAlmostEqual(pipe['grade'], 100*(pipe['ilFrom']-pipe['ilTo'])/pipe['length'], delta=.001)
road = json.loads((root/'bushveld.json').read_text())
es, ns = [p['e'] for p in road['pegs']], [p['n'] for p in road['pegs']]
for st in sw['structures']: # local metres, on the site: guards against a sign flip off the map
self.assertTrue(min(es)-50 < st['e'] < max(es)+50 and min(ns)-50 < st['n'] < max(ns)+50, st['name'])
self.assertAlmostEqual(st['e'], -st['Y']+88650, places=2)
kinds = {k: sum(1 for st in sw['structures'] if st['kind'] == k) for k in ('MH', 'KI', 'OUT')}
self.assertEqual(kinds, {'MH': 18, 'KI': 8, 'OUT': 1})
water = json.loads((root/'services.json').read_text())['water']['points']
self.assertEqual(len(water), 18)
for w in water:
self.assertTrue(min(es)-50 < w['e'] < max(es)+50 and min(ns)-50 < w['n'] < max(ns)+50, w['name'])
outlet = next(p for p in sw['pipes'] if p['to'] == 'SW1')
self.assertEqual((outlet['dia'], outlet['ilTo']), (1050, 1474.795))
# equal-size arrivals at SW6 are paired high-to-high, giving the 1 % road-crossing fall
self.assertEqual(next(p for p in sw['pipes'] if p['from'] == 'SW19')['ilTo'], 1478.766)
def test_village_plan_is_aligned_to_the_cad_model_and_marked_plan_only(self):
root = Path(__file__).resolve().parents[2] / 'reports/site-models/munyaka/2026-10-28'
village = json.loads((root/'services.json').read_text())['village']
self.assertGreaterEqual(village['alignment']['matchedPoints'], 15)
self.assertLess(village['alignment']['medianResidualM'], .6)
by = {s: [r for r in village['routes'] if r['service'] == s] for s in ('stormwater', 'sewer', 'water')}
self.assertTrue(all(by.values()))
self.assertEqual(len(village['hydrants']), 28)
self.assertEqual(len(village['ponds']) > 0, True)
self.assertIn('plan position only', village['source'].lower())
for r in village['routes']: # inside the village footprint, not off in another coordinate system
for e, n in r['pts']:
self.assertTrue(-300 < e < 700 and -500 < n < 400)
self.assertNotIn('quantity', ' '.join(r.get('source', '') for r in village['routes']))
def test_tbc_level_is_not_a_zero_height_design_peg(self):
texts = [(0, 10, 'R1'), (20, 10, '88 804.258'), (40, 10, '2 882 022.158'), (60, 10, '1477.357'),
(0, 0, 'R2'), (20, 0, '88 805.258'), (40, 0, '2 882 023.158'), (60, 0, 'T.B.C.')]
pegs, pending = point_table(texts, (-88650, -2882050))
self.assertEqual([p['name'] for p in pegs], ['R1'])
self.assertEqual(pending, ['R2'])
self.assertAlmostEqual(pegs[0]['e'], -154.258)
self.assertAlmostEqual(pegs[0]['n'], 27.842)
self.assertEqual(pegs[0]['z'], 1477.357)
def test_planning_bands_preserve_holes_and_area_without_overlap(self):
shape = box(0, 0, 63, 10).difference(box(25, 2, 30, 8))
bands = planning_bands(shape, (1, 0), 'access', 'Access road')
self.assertEqual(len(bands), 4)
self.assertAlmostEqual(sum(g.area for _, g in bands), shape.area)
self.assertLess(unary_union([g for _, g in bands]).symmetric_difference(shape).area, 1e-8)
for i, (_, g) in enumerate(bands):
for _, other in bands[i+1:]:
self.assertLess(g.intersection(other).area, 1e-8)
self.assertEqual(bands[-1][0]['to'], 63)
self.assertIn('planning', bands[0][0]['name'].lower())
def test_planning_origin_is_spatially_stable_across_work_types(self):
shape = box(5, 0, 48, 10)
bands = planning_bands(shape, (0, -1), 'west', 'West connection')
self.assertEqual(bands[0][0]['from'], 0)
self.assertEqual(bands[0][0]['to'], 10)
self.assertEqual(len({s['key'] for s, _ in bands}), len(bands))
if __name__ == '__main__':
unittest.main()

View file

@ -9,14 +9,17 @@ import type { Selection } from "../scene/types";
const css = (n: number) => `#${n.toString(16).padStart(6, "0")}`;
export function BuildProgressPanel({ model, state, selected, setSelected, select, focus, onOpen, canEdit }: {
export function BuildProgressPanel({ model, state, selected, setSelected, select, focus, onOpen, canEdit, selection }: {
model: SiteModelData; state: ProgressState; selected: Set<string>; setSelected: (s: Set<string>) => void;
select(sel: Selection): void; focus(e: number, n: number, z: number, dist: number): void; onOpen(sectionKey: string): void; canEdit: boolean;
select(sel: Selection): void; focus(e: number, n: number, z: number, dist: number): void; onOpen(sectionKey: string): void; canEdit: boolean; selection: Selection;
}) {
const catalog = React.useMemo(() => layerCatalog(model), [model]);
const design = React.useMemo(() => decodeGrid(model.surface), [model.surface]);
const rows = model.road.sections.map(s => ({ s, sum: sectionSummary(model, state, s.key, selected) })).filter(r => r.sum.layersTotal > 0);
const full = rows.filter(r => r.sum.layersDone === r.sum.layersTotal).length, none = rows.filter(r => r.sum.fraction === 0).length;
const chosen = selection?.type === "section" ? selection.section : null;
const rowRef = React.useRef<HTMLLIElement | null>(null);
React.useEffect(() => { rowRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }, [chosen]);
const toggle = (id: string) => { const n = new Set(selected); if (n.has(id)) n.delete(id); else n.add(id); setSelected(n); };
const centre = (key: string) => {
const pts = model.road.areas.filter(a => a.section === key).flatMap(a => a.rings[0]);
@ -39,9 +42,9 @@ export function BuildProgressPanel({ model, state, selected, setSelected, select
<span className="text-muted-foreground">Label = layers done / layers shown</span></section>
{rows.length > 0 && <section aria-label="Sections"><h3 className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Sections · {full} done · {rows.length - full - none} in progress · {none} not started</h3>
<ul className="divide-y rounded-lg border">{rows.map(({ s, sum }) => { const c = centre(s.key); return <li key={s.key} className="flex flex-wrap items-center gap-2 p-2">
<ul className="divide-y rounded-lg border">{rows.map(({ s, sum }) => { const c = centre(s.key); const on = s.key === chosen; return <li key={s.key} ref={on ? rowRef : undefined} aria-current={on ? "true" : undefined} className={`flex flex-wrap items-center gap-2 p-2 ${on ? "bg-blue-50 ring-2 ring-inset ring-blue-600 dark:bg-blue-950/40" : ""}`}>
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => select({ type: "section", section: s.key })}>
<b className="block truncate">{s.name} {s.from}–{Math.round(s.to)} m</b>
<b className="block truncate">{on && <span className="mr-1 text-blue-600">▶ You are here ·</span>}{s.name} {s.from}–{Math.round(s.to)} m</b>
<span className="mt-1 flex items-center gap-1" aria-label={`${sum.layersDone} of ${sum.layersTotal} layers done`}>{sum.layers.map(l => <i key={l.id} title={l.name} className="h-3 w-3 rounded-sm border" style={{ background: l.done >= 0.999 ? "#22c55e" : l.done > 0 ? "#f59e0b" : "transparent" }} />)}<span className="ml-1 text-xs tabular-nums text-muted-foreground">{sum.layersDone}/{sum.layersTotal}</span></span></button>
{c && <button type="button" className="min-h-9 rounded-md border px-2 text-xs hover:bg-muted" onClick={() => { select({ type: "section", section: s.key }); focus(c[0], c[1], sampleGrid(design, c[0], c[1]) ?? model.surface.zmin, 60); }}>Show in 3D</button>}
<button type="button" disabled={!canEdit} className="min-h-9 rounded-md border px-2 text-xs hover:bg-muted disabled:opacity-50" onClick={() => { select({ type: "section", section: s.key }); onOpen(s.key); }}>Tick layers</button>

View file

@ -77,7 +77,7 @@ export function SelectionCard(props: PanelProps & { selection: Selection; extra?
const x = sv.stormwater.structures.find((y) => y.name === s.name);
if (!x) return null;
const top = coverSW(x, sZ), key = swKey(x.name);
return (<div className="text-xs"><b className="text-sm">{structureKind(x.name)} {x.name}</b>
return (<div className="text-xs"><b className="text-sm">{structureKind(x.kind ?? x.name)} {x.name}</b>
<States labels={STRUCT_ST} value={valueOf(state, key)} disabled={!canEdit} onPick={(v) => setProgress(key, v)} />
<Kv rows={[["Y / X (Lo)", `${x.Y.toFixed(3)} / ${x.X.toFixed(3)}`], ["Invert level", `${x.il.toFixed(3)} m`],
["Cover level", `${top.toFixed(2)} m ${x.gl ? "(long-section table)" : "(design surface)"}`], ["Depth", `${(top - x.il).toFixed(2)} m`]]} />

View file

@ -224,7 +224,7 @@ export function createSiteMap(container: HTMLElement, model: SiteModelData, stat
return {
setState(s) { ctx.state = s; rebuildAll(); },
setSelection(sel) { ctx.selection = sel; builders.road(); builders.pegs(); builders.services(); builders.earthworks(); applyStyles(); },
setSelection(sel) { ctx.selection = sel; builders.road(); builders.pegs(); builders.services(); builders.earthworks(); builders.layerview(); applyStyles(); },
setLayerVisible(id, visible) { groups.get(id)!.visible = visible; },
setLayerOpacity(id, value) { opacity[id] = Math.max(0, Math.min(1, value)); applyStyles(); },
setExtraLayer(id, visible, op) { extra.get(id)!.visible = visible; extraOpacity[id] = Math.max(0, Math.min(1, op)); applyExtra(); },

View file

@ -51,7 +51,9 @@ const inRing = (r: Pt[], e: number, n: number) => {
return inside;
};
import { addVillageContext } from "./layers-village";
export function buildLines(c: SceneContext) {
// (village linework is appended at the end)
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 };
@ -82,6 +84,7 @@ export function buildLines(c: SceneContext) {
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 })));
}
addVillageContext(c);
}
/** Supplied ordered buffer line only: no inferred enclosed/approved zone. */

View file

@ -19,9 +19,11 @@ export function buildBuildProgress(c: SceneContext, g: THREE.Group, selected: Re
const { THREE, model, state } = c;
c.clear(g);
if (!selected) return;
const chosen = c.selection?.type === "section" ? c.selection.section : null;
for (const sec of model.road.sections) {
const sum = sectionSummary(model, state, sec.key, selected);
if (!sum.layersTotal) continue;
const isSel = sec.key === chosen, dim = chosen !== null && !isSel;
const colour = progressColour(sum.fraction, sum.layersTotal), pickData = { type: "section", section: sec.key };
const checklist = sum.layers.map(l => `${l.done >= 0.999 ? "✔" : l.done > 0 ? "◐" : "○"} ${l.name}`).join("<br>");
const tip = `<b>${sec.name} ${sec.from}–${Math.round(sec.to)} m</b><br>${sum.layersDone} of ${sum.layersTotal} layers done<br>${checklist}`;
@ -30,13 +32,22 @@ export function buildBuildProgress(c: SceneContext, g: THREE.Group, selected: Re
const ring = a.rings[0], thk = (model.road.builds[a.kind]?.layers ?? []).reduce((t, l) => t + l.thk, 0) * c.layEx;
const geo = new THREE.ShapeGeometry(new THREE.Shape(ring.map(([e, n]) => new THREE.Vector2(e, n)))), pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) { const e = pos.getX(i), n = pos.getY(i); pos.setXYZ(i, e, c.Y(c.designZ(e, n)) + thk + 0.2, -n); }
const m = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: colour, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthWrite: false, depthTest: false }));
const m = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: colour, transparent: true, opacity: isSel ? 1 : dim ? 0.3 : 0.8, side: THREE.DoubleSide, depthWrite: false, depthTest: false }));
m.renderOrder = 25; m.userData.pick = pickData; m.userData.tip = tip; g.add(m);
if (isSel) {
// Selected section: dark outline with a white halo so it stands out from the tint.
const loop = ring.map(([e, n]) => new THREE.Vector3(e, c.Y(c.designZ(e, n)) + thk + 0.3, -n));
for (const [col, lift] of [[0xffffff, 0.12], [0x1d4ed8, 0]] as const) {
const line = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(loop.map(v => v.clone().setY(v.y + lift))),
new THREE.LineBasicMaterial({ color: col, depthTest: false }));
line.renderOrder = 27; line.userData.pick = pickData; g.add(line);
}
}
for (const [e, n] of ring) { ce += e; cn += n; count++; top = Math.max(top, c.Y(c.designZ(e, n)) + thk); }
}
if (!count) continue;
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: labelTexture(c, `${sum.layersDone}/${sum.layersTotal}`), depthTest: false, transparent: true }));
sprite.scale.set(9, 4.5, 1); sprite.position.set(ce / count, top + 2, -(cn / count)); sprite.renderOrder = 30;
sprite.scale.set(isSel ? 14 : 9, isSel ? 7 : 4.5, 1); sprite.position.set(ce / count, top + 2, -(cn / count)); sprite.renderOrder = 30;
sprite.userData.pick = pickData; sprite.userData.tip = tip; g.add(sprite);
}
}

View file

@ -7,6 +7,7 @@ import { valueOf } from "@/lib/site-map-v2/progress";
import { structureKind } from "@/lib/site-map-v2/quantities";
import { coverSR, coverSW, groundKnownSR, sewerGap, srName } from "@/lib/site-map-v2/services";
import { pegZ } from "@/lib/site-map-v2/surface";
import { addVillageServices } from "./layers-village";
export const PIPE_ST = ["Not laid", "Laid", "Backfilled & tested"];
export const STRUCT_ST = ["Not built", "Under way", "Built"];
@ -80,7 +81,7 @@ export function buildServices(c: SceneContext) {
for (const p of sv.stormwater.pipes) {
const a = swBy.get(p.from), b = swBy.get(p.to);
if (!a || !b) continue;
const key = swPipeKey(p.from, p.to), r = p.dia / 2000, st = valueOf(c.state, key), isSel = sel?.type === "swPipe" && sel.key === key;
const key = swPipeKey(p.from, p.to), r = Math.max(p.dia / 2000, 0.3), st = valueOf(c.state, key), isSel = sel?.type === "swPipe" && sel.key === key;
const mat = st ? new THREE.MeshStandardMaterial({ color: st === 2 ? 0x55708a : 0x8d99a6, emissive: isSel ? 0x0b3d91 : 0, roughness: 0.6 })
: new THREE.MeshStandardMaterial({ color: isSel ? 0x6aa0ff : 0x1d9bd1, transparent: true, opacity: 0.92, depthWrite: false });
const m = cylinder(c, c.W(a.e, a.n, p.ilFrom + r), c.W(b.e, b.n, p.ilTo + r), r, mat);
@ -90,15 +91,20 @@ export function buildServices(c: SceneContext) {
}
for (const s of sv.stormwater.structures) {
const st = valueOf(c.state, swKey(s.name)), top = coverSW(s, c.surfZ), isSel = sel?.type === "sw" && sel.name === s.name;
const h = Math.max(0.3, c.Y(top) - c.Y(s.il)), k = s.name.match(/^[A-Z]+/)?.[0];
const h = Math.max(0.3, c.Y(top) - c.Y(s.il)), k = s.kind ?? s.name.match(/^[A-Z]+/)?.[0];
const height = k === "OUT" || k === "DL" ? Math.min(h, 1.2) : h;
const geo = k === "KI" ? new THREE.BoxGeometry(1.4, height, 1.0) : k === "OUT" || k === "DL" ? new THREE.BoxGeometry(2.0, height, 2.0) : new THREE.CylinderGeometry(0.65, 0.65, height, 18);
const geo = k === "KI" ? new THREE.BoxGeometry(1.4, height, 1.0) : k === "OUT" || k === "DL" ? new THREE.BoxGeometry(2.0, height, 2.0) : new THREE.CylinderGeometry(0.9, 0.9, height, 24);
const mat = st === 2 ? new THREE.MeshStandardMaterial({ color: 0x6b7a88, emissive: isSel ? 0x0b3d91 : 0 })
: new THREE.MeshStandardMaterial({ color: isSel ? 0x9cc0ff : st ? 0xe3a21a : 0x2b7bb8, transparent: true, opacity: 0.95, depthWrite: false });
const m = new THREE.Mesh(geo, mat);
m.position.copy(c.W(s.e, s.n, s.il)); m.position.y += height / 2;
if (k !== "KI" && k !== "OUT" && k !== "DL") { // round manhole lid at cover level
const lid = new THREE.Mesh(new THREE.CylinderGeometry(0.95, 0.95, 0.08, 24), new THREE.MeshStandardMaterial({ color: 0x1f2933, roughness: 0.5 }));
lid.position.copy(m.position); lid.position.y += height / 2 + 0.04; lid.userData.pick = { type: "sw", name: s.name };
lid.userData.tip = `<b>${structureKind(s.kind ?? s.name)} ${s.name}</b> · IL ${s.il.toFixed(3)}`; gSW.add(lid);
}
m.userData.pick = { type: "sw", name: s.name };
m.userData.tip = `<b>${structureKind(s.name)} ${s.name}</b> · IL ${s.il.toFixed(3)} · cover ${top.toFixed(2)}${s.gl ? "" : " (from design surface)"} · ${STRUCT_ST[st]}`;
m.userData.tip = `<b>${structureKind(s.kind ?? s.name)} ${s.name}</b> · IL ${s.il.toFixed(3)} · cover ${top.toFixed(2)}${s.gl ? "" : " (from design surface)"} · ${STRUCT_ST[st]}`;
gSW.add(m);
}
@ -106,7 +112,7 @@ export function buildServices(c: SceneContext) {
for (const p of sv.sewer.pipes) {
const a = srBy.get(p.from), b = srBy.get(p.to);
if (!a?.located || !b?.located || a.e === undefined || b.e === undefined) continue;
const key = srPipeKey(srName(sv, p.from), srName(sv, p.to)), r = p.dia / 2000, st = valueOf(c.state, key), isSel = sel?.type === "srPipe" && sel.key === key;
const key = srPipeKey(srName(sv, p.from), srName(sv, p.to)), r = Math.max(p.dia / 2000, 0.2), st = valueOf(c.state, key), isSel = sel?.type === "srPipe" && sel.key === key;
const A = c.W(a.e, a.n!, p.ilFrom + r), B = c.W(b.e, b.n!, p.ilTo + r);
const tip = `<b>${p.dia} mm uPVC sewer</b> · ${a.name} → ${b.name} · ${p.length.toFixed(1)} m @ ${p.grade.toFixed(2)}% · ${PIPE_ST[st]}${p.hold ? ' · <b style="color:#ff8b8b">HOLD</b>' : ""}`;
const gap = sewerGap(sv, p);
@ -145,6 +151,7 @@ export function buildServices(c: SceneContext) {
s.userData.tip = `<b>Water ${w.name}</b> · Y ${w.Y.toFixed(2)} · X ${w.X.toFixed(2)}`;
gWR.add(s);
}
addVillageServices(c);
}
/** Keep HOLD flags facing the camera. */

View file

@ -0,0 +1,81 @@
// Information-stage village (plan PDF): services, ponds, boundary, lots and hydrants drawn flat, since no village ground exists.
import type * as THREE from "three";
import type { SceneContext } from "./types";
import type { VillageLine } from "@/lib/site-map-v2/model-types";
const COLOUR = { stormwater: 0x0891b2, sewer: 0xc27542, water: 0x287bd6 } as const;
/** Height of the nearest known design surface (the road), so the village continues at the road's level. */
function levelAt(c: SceneContext) {
const cache = new Map<string, number>(), fallback = c.model.surface.zmin;
return (e: number, n: number) => {
const key = `${Math.round(e / 15)}:${Math.round(n / 15)}`;
let z = cache.get(key);
if (z === undefined) {
z = c.surfZ(e, n) ?? undefined;
for (let r = 5; z === undefined && r <= 600; r += 5) {
for (let a = 0; a < 24 && z === undefined; a++) z = c.surfZ(e + r * Math.cos(a * Math.PI / 12), n + r * Math.sin(a * Math.PI / 12)) ?? undefined;
}
cache.set(key, (z ?? fallback) + 0.2);
z = cache.get(key)!;
}
return z;
};
}
function lineSegments(c: SceneContext, lines: VillageLine[], z: (e: number, n: number) => number, lift = 0) {
const { THREE } = c, pos: THREE.Vector3[] = [];
for (const l of lines) for (let i = 1; i < l.pts.length; i++) pos.push(c.W(l.pts[i - 1][0], l.pts[i - 1][1], z(l.pts[i - 1][0], l.pts[i - 1][1]) + lift), c.W(l.pts[i][0], l.pts[i][1], z(l.pts[i][0], l.pts[i][1]) + lift));
return new THREE.BufferGeometry().setFromPoints(pos);
}
/** Flat ribbon along a polyline, so the route shows at map scale. */
function ribbon(c: SceneContext, l: VillageLine, z: (e: number, n: number) => number, width: number) {
const { THREE } = c, pos: number[] = [];
for (let i = 1; i < l.pts.length; i++) {
const A = c.W(l.pts[i - 1][0], l.pts[i - 1][1], z(l.pts[i - 1][0], l.pts[i - 1][1])), B = c.W(l.pts[i][0], l.pts[i][1], z(l.pts[i][0], l.pts[i][1]));
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz) || 1, nx = (-dz / len) * width / 2, nz = (dx / len) * width / 2;
pos.push(A.x - nx, A.y, A.z - nz, A.x + nx, A.y, A.z + nz, B.x + nx, B.y, B.z + nz, A.x - nx, A.y, A.z - nz, B.x + nx, B.y, B.z + nz, B.x - nx, B.y, B.z - nz);
}
const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
return geo;
}
/** Services, ponds and hydrants join the stormwater, sewer and water layers. */
export function addVillageServices(c: SceneContext) {
const v = c.model.services.village;
if (!v) return;
const { THREE } = c, z = levelAt(c);
const groups = { stormwater: c.group("stormwater"), sewer: c.group("sewer"), water: c.group("water") };
const merged = { stormwater: [] as THREE.BufferGeometry[], sewer: [] as THREE.BufferGeometry[], water: [] as THREE.BufferGeometry[] };
for (const r of v.routes) merged[r.service].push(ribbon(c, r, z, 1.4));
for (const svc of ["stormwater", "sewer", "water"] as const) {
const total = v.routes.filter(r => r.service === svc).reduce((s, r) => s + r.length, 0);
for (const geo of merged[svc]) {
const m = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: COLOUR[svc], transparent: true, opacity: 0.7, side: THREE.DoubleSide, depthWrite: false }));
m.renderOrder = 4; m.userData.tip = `<b>Village ${svc}</b> · ${total.toFixed(0)} m drawn in total<br>Information-stage plan (SDP Rev C). Plan position only: no sizes, levels or quantities.`;
groups[svc].add(m);
}
}
for (const pond of v.ponds) {
const l = new THREE.Line(lineSegments(c, [pond], z, 0.1), new THREE.LineBasicMaterial({ color: 0x06b6d4 }));
l.userData.tip = "<b>Attenuation pond</b> · information-stage village plan"; groups.stormwater.add(l);
}
for (const [e, n] of v.hydrants) {
const h = new THREE.Mesh(new THREE.ConeGeometry(0.9, 2.2, 10), new THREE.MeshStandardMaterial({ color: 0x1d4ed8 }));
h.position.copy(c.W(e, n, z(e, n))); h.position.y += 1.1;
h.userData.tip = "<b>Fire hydrant</b> · village plan position (SDP Rev C)"; groups.water.add(h);
}
}
/** Township boundary and lot/kerb linework go with the reserve and road-edge layers. */
export function addVillageContext(c: SceneContext) {
const v = c.model.services.village;
if (!v) return;
const { THREE } = c, z = levelAt(c);
const add = (id: "ctxReserve" | "ctxEdge", lines: VillageLine[], color: number, opacity: number) => {
if (lines.length) c.group(id).add(new THREE.LineSegments(lineSegments(c, lines, z), new THREE.LineBasicMaterial({ color, transparent: true, opacity })));
};
add("ctxReserve", v.boundary, 0xc94a3a, 0.8);
add("ctxEdge", v.layout, 0x6b6b6b, 0.75);
}

View file

@ -40,12 +40,21 @@ const SiteMapCanvas = dynamic(() => import("./site-map-canvas").then(m => ({ def
type Mode = "map" | "buildprogress" | "ground" | "services" | "details" | "totals" | "earthworks" | "rain";
const MODES = [{ id: "map", label: "Map", icon: Map }, { id: "buildprogress", label: "Progress", icon: ListChecks }, { id: "ground", label: "Ground", icon: MountainSnow }, { id: "services", label: "Services", icon: Droplets }, { id: "details", label: "Section details", icon: MousePointer2 }, { id: "totals", label: "Totals", icon: Sigma }, { id: "earthworks", label: "Cut & fill", icon: Mountain }, { id: "rain", label: "Rain", icon: CloudRain }] as const;
const QUICK_LAYERS: { id: LayerId; label: string; color: string }[] = [
{ id: "surface", label: "Ground surface", color: "#8fa77a" },
{ id: "road", label: "Road & paths", color: "#548bc2" },
{ id: "stormwater", label: "Stormwater", color: "#0891b2" },
{ id: "sewer", label: "Sewer", color: "#c27542" },
{ id: "water", label: "Water", color: "#287bd6" },
{ id: "pegs", label: "Survey pegs", color: "#d15b92" },
{ id: "buffer", label: "Buffer line", color: "#a65dc2" },
{ id: "contours", label: "Design contours", color: "#8a7a5a" },
{ id: "ctxEdge", label: "Road edges, kerbs & village lots", color: "#b8b2a2" },
{ id: "ctxMark", label: "Road markings", color: "#9a9a9a" },
{ id: "ctxToe", label: "Fill / cut toes", color: "#a0825a" },
{ id: "ctxReserve", label: "Reserve & village boundary", color: "#c94a3a" },
{ id: "signs", label: "Road signs", color: "#d97706" },
{ id: "studs", label: "Road studs", color: "#ca8a04" },
{ id: "holds", label: "HOLD flags", color: "#d1242f" },
];
export function SiteMapView3({ userId, siteId, siteName, canEdit, isAdmin }: { userId: string; siteId: string; siteName: string; canEdit: boolean; isAdmin: boolean }) {
@ -99,6 +108,8 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
const v = workspaceVisibility(saved, mode === "ground" || mode === "services" ? "map" : mode);
return mode === "services" ? { ...v, stormwater: true, sewer: true, water: true, road: false, surface: true } : v;
}, [scenario.view.preset, scenario.view.visible, mode, model.earthworks.groundSurface]);
// Pipes and manholes sit below the ground: when any is ticked on the Map tab, fade the ground so they can be seen.
const underground = visible.stormwater || visible.sewer || visible.water;
React.useEffect(() => { handle?.setCutFill(cutfill); }, [handle, cutfill]);
React.useEffect(() => { handle?.setLayerView(mode === "buildprogress" ? [...layerSel] : null); }, [handle, mode, layerSel]);
React.useEffect(() => { if (handle) for (const id of EXTRA_LAYER_IDS) handle.setExtraLayer(id, mode !== "buildprogress" && extra.visible[id] && (id === "status" || !!ground), extra.opacity[id]); }, [handle, extra, ground, mode]);
@ -114,7 +125,15 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model, state]);
React.useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 6000); return () => clearTimeout(t); }, [toast]);
const quickLayers = QUICK_LAYERS.filter(layer => layer.id !== "pegs" || model.road.pegs.length > 0);
// Only offer the layers this model actually has data for.
const hasContext = (kind: string) => model.road.context.some(x => x.kind === kind);
const available: Partial<Record<LayerId, boolean>> = {
pegs: model.road.pegs.length > 0, buffer: model.road.buffer.length > 0, contours: model.road.contours.length > 0,
ctxEdge: hasContext("tar_edge") || hasContext("other_road") || !!model.services.village?.layout.length, ctxMark: hasContext("marking"), ctxToe: hasContext("toe"), ctxReserve: hasContext("reserve") || !!model.services.village?.boundary.length,
signs: model.schedules.signs.length > 0, studs: model.schedules.studs.length > 0, holds: model.services.holds.length > 0,
stormwater: model.services.stormwater.pipes.length + model.services.stormwater.structures.length + (model.services.planRoutes ?? []).filter(r => r.service === "stormwater").length > 0,
};
const quickLayers = QUICK_LAYERS.filter(layer => available[layer.id] ?? true);
if (model.earthworks.groundSurface) quickLayers.push({ id: "natground", label: "Survey ground", color: "#8a6b3a" });
React.useEffect(() => {
const fs = createFullscreen(document, setFull);
@ -125,7 +144,7 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
// Cut & fill tab: taller relief so the ground reads as 3D, and the camera may go below the ground to look up at the sections
React.useEffect(() => { handle?.setStretch(mode === "earthworks" ? Math.max(scenario.view.levEx, 4) : scenario.view.levEx, scenario.view.layEx); handle?.setUnderground(mode === "earthworks"); }, [handle, mode, scenario.view.levEx, scenario.view.layEx]);
React.useEffect(() => { handle?.setSelection(selection); }, [handle, selection]);
React.useEffect(() => { if (handle) for (const id of LAYER_IDS) handle.setLayerOpacity(id, mode === "rain" && id === "rain" ? 1 : (mode === "services" || mode === "buildprogress") && id === "surface" ? 0.3 : scenario.view.opacity[id] ?? (id === "natground" ? 0.5 : 1)); }, [handle, scenario.view.opacity, mode]);
React.useEffect(() => { if (handle) for (const id of LAYER_IDS) handle.setLayerOpacity(id, mode === "rain" && id === "rain" ? 1 : (mode === "services" || mode === "buildprogress" || (mode === "map" && underground)) && id === "surface" ? 0.3 : scenario.view.opacity[id] ?? (id === "natground" ? 0.5 : 1)); }, [handle, scenario.view.opacity, mode, underground]);
React.useEffect(() => { handle?.setPavementVisibility(scenario.view.pavement); }, [handle, scenario.view.pavement]);
React.useEffect(() => { handle?.view(scenario.view.camera); }, [handle, scenario.view.camera]);
const setLayer = (id: LayerId, on: boolean) => update(s => ({ ...s, view: { ...s.view, visible: { ...presetVisibility(s.view.preset), ...s.view.visible, [id]: on } } }));
@ -163,7 +182,7 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
<p className="text-xs leading-relaxed text-muted-foreground">Drag to rotate. Scroll to zoom. Use Top view for a plan of the road.</p>
</div>}
{mode === "ground" && <GroundPanel model={model} extra={extra} setExtra={setExtra} source={source} setSource={setSource} hasGround={!!ground} depth={depth} setDepth={setDepth} comparison={comparison} cutfill={cutfill} range={groundRange} />}
{mode === "buildprogress" && <BuildProgressPanel model={model} state={state} selected={layerSel} setSelected={setLayerSel} select={select} focus={focus} onOpen={setPopup} canEdit={canEdit} />}
{mode === "buildprogress" && <BuildProgressPanel model={model} state={state} selected={layerSel} setSelected={setLayerSel} select={select} focus={focus} onOpen={setPopup} canEdit={canEdit} selection={selection} />}
{mode === "services" && <RoutesPanel {...panelProps} selection={selection} handle={handle} />}
{mode === "details" && <SectionInspector {...panelProps} selection={selection} handle={handle} />}
{mode === "totals" && <AllSectionsTotals model={model} state={state} />}

View file

@ -17,12 +17,19 @@ export interface RoadModel {
water: { name: string; e: number; n: number; Y: number; X: number }[];
buffer: { name: string; e: number; n: number; Y: number; X: number }[];
}
export interface SwStructure { name: string; e: number; n: number; Y: number; X: number; il: number; gl: number | null }
export interface SwStructure { name: string; e: number; n: number; Y: number; X: number; il: number; gl: number | null; kind?: string }
export interface SwPipe { from: string; to: string; run: number; length: number; dia: number; ilFrom: number; ilTo: number; grade: number; diaSource: string }
export interface SrManhole { name: string; id: string; run: number; il: number; located: boolean; Y?: number; X?: number; e?: number; n?: number; hold?: string }
export interface SrPipe { from: string; to: string; run: number; dia: number; length: number; ilFrom: number; ilTo: number; grade: number; hold: string | null }
export interface Hold { id: string; name: string; service: string; note: string; english: string; scope: string }
/** Information-stage village layout drawn from a plan PDF: plan position only, no levels (see scripts/site-model/extract_munyaka_village.py). */
export interface VillageLine { pts: Pt[]; length: number }
export interface VillageModel {
source: string; alignment: { matchedPoints: number; medianResidualM: number };
routes: (VillageLine & { service: "stormwater" | "sewer" | "water" })[]; ponds: VillageLine[]; boundary: VillageLine[]; layout: VillageLine[]; hydrants: Pt[];
}
export interface ServicesModel {
village?: VillageModel;
planRoutes?: { id: string; service: "stormwater" | "sewer" | "water"; pts: Pt[]; length: number; source: string }[];
stormwater: { structures: SwStructure[]; pipes: SwPipe[]; notes: string[] };
sewer: { manholes: SrManhole[]; pipes: SrPipe[]; notes: string[] };

View file

@ -36,7 +36,7 @@ export function boqRows(m: SiteModelData, s: ProgressState): BoqRow[] {
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);
const k = structureKind(x.kind ?? 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);