413 lines
29 KiB
Python
413 lines
29 KiB
Python
|
|
"""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)
|