MEGAINFRA-WEBAPP/scripts/site-model/extract_munyaka.py

413 lines
29 KiB
Python
Raw Normal View History

"""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)