"""Build a Map 2/3 model for Nellmapius Ext. 25 from the 100_ROADS and 400_KERBING drawings. What the supplied files give: the kerb and road-edge lines, stand lines, road names and widths, plus four survey benchmarks (SVR detail contour plan). They give no design levels, no pavement build-up and no stormwater or sewer data in a form that can be placed, so the model has road areas, kerbs and context only, over an indicative plane through the four benchmarks. Requires numpy shapely ezdxf scipy. Coordinates: local east/north metres = CAD (x=-Lo Y, y=-Lo X) minus origin. python3 -I extract_nellmapius.py 100_ROADS.dxf 400_KERBING.dxf OUT_DIR """ import base64 import hashlib import io import json import math import re import sys from collections import defaultdict from pathlib import Path import numpy as np import shapely from scipy.spatial import cKDTree from shapely.geometry import LineString, MultiPoint, Point, Polygon, box from shapely.ops import polygonize, unary_union # Survey benchmarks printed on the SVR detail contour plan (Lo29 Y, X, level), Sep 2024 BENCHMARKS = [("ST18", 65305.974, 2847299.892, 1298.993, "12mm peg next to kerb"), ("BM3", 65203.736, 2847335.083, 1300.044, "hole in kerb"), ("ST17", 65152.153, 2847435.083, 1302.880, "12mm peg in paving"), ("BM2", 65182.192, 2847332.670, 1300.406, "hole in kerb")] KERB_LAYERS = {"Kerb_300 Mountable": "300 mm mountable kerb", "Kerb_Edge Beam": "150 mm edge beam", "Kerb_Fig7_Semi-mount": "Fig. 7 semi-mountable kerb"} NAMES = {"IDELO CRESCENT": "Idelo Crescent", "UMNGAMO STREET": "Umngamo Street", "IGWALI STREET": "Igwali Street"} # roads built in this phase (cross-section titles) SECTION = 20.0 def read_dxf(path, layers): """TEXT/MTEXT/lines of the wanted layers; repairs LibreDWG's multiline text and unsupported payloads.""" import ezdxf from ezdxf.path import make_path lines = Path(path).read_text(errors="replace").splitlines() pairs, i = [], 0 while i < len(lines): try: code = int(lines[i]) except ValueError: pairs[-1] = (pairs[-1][0], pairs[-1][1] + r"\P" + lines[i]); i += 1; continue pairs.append((code, lines[i + 1])); i += 2 clean, skip = [], False for code, value in pairs: if code == 0: skip = False if code == 101 and value == "Embedded Object": skip = True if not skip: clean.extend((str(code), value)) doc = ezdxf.read(io.StringIO("\n".join(clean) + "\n")) out = defaultdict(list) for e in doc.modelspace(): if e.dxf.layer not in layers: continue k = e.dxftype() if k in ("LINE", "ARC", "LWPOLYLINE", "POLYLINE", "CIRCLE"): out[e.dxf.layer].append({"type": k, "pts": [list(p)[:2] for p in make_path(e).flattening(.05)]}) elif k in ("TEXT", "MTEXT"): out[e.dxf.layer].append({"type": k, "text": e.plain_text() if k == "MTEXT" else e.dxf.text, "pos": list(e.dxf.insert)[:2]}) return out def plane_fit(points): A = np.array([[1, e, n] for e, n, _ in points]); z = np.array([p[2] for p in points]) coef, *_ = np.linalg.lstsq(A, z, rcond=None) return coef, z - A @ coef def build(roads, kerbs, out_dir, source_files): edge = [LineString(r["pts"]) for r in kerbs["_X25 ROAD EDGE"] if len(r["pts"]) > 2] closed = [l for l in edge if math.dist(l.coords[0], l.coords[-1]) < 0.05] cx0, cy0, cx1, cy1 = unary_union(closed).bounds window = box(cx0 - 120, cy0 - 120, cx1 + 120, cy1 + 120) origin = (round((cx0 + cx1) / 2, -1), round((cy0 + cy1) / 2, -1)) local = lambda pts: [[round(p[0] - origin[0], 3), round(p[1] - origin[1], 3)] for p in pts] inside = lambda pts: window.contains(LineString(pts)) if len(pts) > 1 else False # ---- road faces: the ring roads are the space between the closed edge lines (kerbs lie on them); the Igwali spur is closed by a hull kerb_lines = [(k, LineString(r["pts"])) for k in KERB_LAYERS for r in kerbs[k] if len(r["pts"]) > 1 and inside(r["pts"])] faces = list(polygonize(unary_union([g for _, g in kerb_lines] + closed))) ring = [f for f in faces if f.area > 20 and f.area / f.length < 8] blocks = [f for f in faces if f.area > 20 and f.area / f.length >= 8] road = unary_union(ring) # the 932 m outer edge line includes the Igwali Street spur, so the face already has it report = {"roadFaces": [round(f.area, 1) for f in ring], "blocks": [round(b.area) for b in blocks]} # ---- 20 m sections: along each block's boundary (ring roads) and along the spur axis labels = [] # (name, x, y) for r in roads["_NEW ROAD NAMES"] + roads["ROAD NAMES"]: t = re.sub(r"[()]|\d+m", "", r["text"]).strip().upper() if t in NAMES: labels.append((NAMES[t], r["pos"][0], r["pos"][1])) block_rings = [b.exterior for b in blocks] block_minx = min(b.bounds[0] for b in blocks) cell = 1.0 minx, miny, maxx, maxy = road.bounds cells = [(x + .5, y + .5) for x in np.arange(math.floor(minx), math.ceil(maxx), cell) for y in np.arange(math.floor(miny), math.ceil(maxy), cell) if road.contains(Point(x + .5, y + .5))] spur_cells = [(x, y) for x, y in cells if x < block_minx - 2] west = min(spur_cells, key=lambda c: c[0]) near_j = [c[1] for c in spur_cells if block_minx - 3 < c[0] < block_minx - 2] or [west[1]] axis = LineString([(block_minx - 2, float(np.mean(near_j))), west]) n_spur = max(1, int(axis.length // SECTION)) def chunk_of(x, y): if x < block_minx - 2: return ("spur", min(n_spur - 1, int(axis.project(Point(x, y)) // SECTION))) p = Point(x, y) k = min(range(len(block_rings)), key=lambda i: block_rings[i].distance(p)) return (f"loop{k}", int(block_rings[k].project(p) // SECTION)) groups = defaultdict(list) for x, y in cells: groups[chunk_of(x, y)].append((x, y)) exact = road.area / max(1, len(cells)) sections, areas, chunk_geo = [], [], {} for (kind, idx), pts in sorted(groups.items()): cx, cy = np.mean([p[0] for p in pts]), np.mean([p[1] for p in pts]) name = (min(labels, key=lambda l: math.hypot(l[1] - cx, l[2] - cy))[0] if labels else "Road") if kind != "spur" else "Igwali Street" if kind.startswith("loop") and name == "Igwali Street": name = "Idelo Crescent" tag = kind if kind != "spur" else "spur" f, t = (idx * SECTION, (idx + 1) * SECTION) key = f"{re.sub('[^a-z0-9]+', '-', name.lower())}-{tag}-ch{int(f)}-{int(t)}" poly = unary_union([box(x - .5, y - .5, x + .5, y + .5) for x, y in pts]).buffer(0) parts = list(poly.geoms) if poly.geom_type == "MultiPolygon" else [poly] sections.append({"key": key, "name": f"{name} ({'spur' if kind == 'spur' else 'loop ' + 'AB'[int(kind[-1])] if len(blocks) > 1 else 'loop'})", "from": f, "to": t}) chunk_geo[key] = poly for part in parts: areas.append({"kind": "road_surface", "section": key, "area": round(part.area * exact / cell ** 2, 3), "rings": [local(list(part.simplify(.05).exterior.coords))]}) assert abs(sum(a["area"] for a in areas) - road.area) < 1, (sum(a["area"] for a in areas), road.area) # ---- kerbs: each metre of kerb line goes to the section it sits in tree = cKDTree(np.array([(g.centroid.x, g.centroid.y) for g in chunk_geo.values()])); keys = list(chunk_geo) owner = lambda x, y: keys[int(tree.query([x, y])[1])] if False else min(keys, key=lambda k: chunk_geo[k].distance(Point(x, y))) kerb_rows, total_kerb = [], 0.0 for lname, g in kerb_lines: runs, cur, cur_key = [], [], None n = max(1, int(round(g.length))) pts = [g.interpolate(i * g.length / n) for i in range(n + 1)] for a, b in zip(pts, pts[1:]): k = owner((a.x + b.x) / 2, (a.y + b.y) / 2) if k != cur_key and cur: runs.append((cur_key, cur)); cur = [] cur_key = k; cur = cur + [a.coords[0]] if not cur else cur; cur.append(b.coords[0]) if cur: runs.append((cur_key, cur)) for k, line in runs: L = LineString(line).length; total_kerb += L kerb_rows.append({"section": k, "length": round(L, 3), "pts": local(line)}) context = [{"kind": "tar_edge", "pts": local(list(l.coords))} for l in closed if window.contains(l)] context += [{"kind": "other_road", "pts": local(r["pts"])} for r in kerbs["KERB_FIG 10"] if inside(r["pts"])] context += [{"kind": "marking", "pts": local(r["pts"])} for r in roads["DCE-RMRS-LINE-W-SOLID"] if inside(r["pts"])] context += [{"kind": "other_road", "pts": local(r["pts"])} for r in roads["2025_Calc Plan"] if inside(r["pts"])] # stand lines pegs = [] for name, Y, X, z, note in BENCHMARKS: e, n = local([(-Y, -X)])[0] pegs.append({"name": name, "e": e, "n": n, "z": z, "Y": Y, "X": X}) notes = [ "Nellmapius Ext. 25 (SVR / Civil Concepts drawings 100_ROADS and 400_KERBING). Roads read from the closed road-edge lines and kerb lines; widths per the cross-section titles: Idelo Crescent and Umngamo Street 5.0 m in 13 m reserve, Igwali Street 8.0 m in 20 m reserve.", "20 m sections run along the inner edge of each ring road and along the Igwali Street spur; road names come from the nearest street-name label, so check them.", "No pavement build-up was supplied: each section has one 'road surface' item to tick off. No design levels or contours were supplied.", "The ground is an indicative plane through the four survey benchmarks (Sep 2024). It is not a survey surface: do not use it for earthworks, cut and fill or levels.", "No stormwater, sewer or water pipes are placed: the sewer long section supplied is sheet 3 of 3 and the services layout carries no levels.", f"Kerb lines measured once: {total_kerb:.0f} m (300 mm mountable, 150 mm edge beam, Fig. 7 semi-mountable).", ] road_model = {"project": "Nellmapius Ext. 25", "origin": {"cadX": origin[0], "cadY": origin[1]}, "builds": {"road_surface": {"name": "Road surface", "color": 0x6B7280, "layers": [{"id": "road-surface", "name": "Road surface (layers not supplied)", "thk": 0.1, "unit": "m²", "color": 0x6B7280}]}}, "sections": sections, "areas": areas, "kerbs": kerb_rows, "kerbQuantityFactor": 1, "context": context, "contours": [], "pegs": pegs, "water": [], "buffer": [], "notes": notes} # ---- indicative ground: a plane through the benchmarks pts3 = [(p["e"], p["n"], p["z"]) for p in pegs] coef, res = plane_fit(pts3) x0, y0 = math.floor(minx - origin[0] - 40), math.floor(miny - origin[1] - 40) nx, ny = math.ceil(maxx - origin[0] + 40) - x0 + 1, math.ceil(maxy - origin[1] + 40) - y0 + 1 gx, gy = np.meshgrid(x0 + np.arange(nx), y0 + np.arange(ny)) Z = coef[0] + coef[1] * gx + coef[2] * gy zmin = round(float(Z.min()) - 1, 1) q = np.round((Z - zmin) * 1000).astype("