"""Build bushveld.json for the 3D progress app from the converted road-layout DXF and the setting-out data. Usage: python3 -I extract_bushveld.py Coordinates: the DWG is drawn in SA Lo coordinates with CAD x = -Y and CAD y = -X. Output uses a local origin (metres east / north of `origin`) so the browser keeps float precision. """ import csv import json import math import os import re import sys import zipfile import xml.etree.ElementTree as ET import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from dxf_reader import read_sections, get, lwpolyline, line, arc, hatch_rings, ring_area # noqa: E402 from alignment import load_alignment, bands, band_key, clip_ring_to_band # noqa: E402 def slug(text): return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") # Layer build-ups (bottom layer first). Road from drawing M913-001 (assumed SG1, Class 3); others assumed. BUILDS = { "asphalt": {"name": "Asphalt carriageway", "color": 0x34373b, "layers": [ ("Roadbed: rip & recompact 150mm", 0.15, "m²", 0x7d6446), ("Lower selected G7 150mm", 0.15, "m³", 0x9b7f57), ("Upper selected G7 150mm", 0.15, "m³", 0xae9466), ("C4 subbase (G6 + 3% cement) 150mm", 0.15, "m³", 0xa6a49a), ("G1 crushed stone base 150mm", 0.15, "m³", 0x8d8f91), ("Asphalt 30mm (medium graded)", 0.03, "m²", 0x2c2f33)]}, "paving": {"name": "Walkway paving", "color": 0xb9b2a6, "layers": [ ("Roadbed preparation (walkways)", 0.15, "m²", 0x7d6446), ("G7 subbase 150mm (walkways)", 0.15, "m³", 0xae9466), ("Bedding sand 20mm", 0.02, "m²", 0xd8c48f), ("80mm interlocking pavers", 0.08, "m²", 0xb9b2a6)]}, "paving_red": {"name": "Red paving", "color": 0xa4553f, "layers": [ ("Roadbed preparation (walkways)", 0.15, "m²", 0x7d6446), ("G7 subbase 150mm (walkways)", 0.15, "m³", 0xae9466), ("Bedding sand 20mm", 0.02, "m²", 0xd8c48f), ("80mm red pavers", 0.08, "m²", 0xa4553f)]}, "landscape": {"name": "Landscaping", "color": 0x5f9a3f, "layers": [ ("Topsoil 150mm", 0.15, "m³", 0x5a4632), ("Grassing / planting", 0.03, "m²", 0x5f9a3f)]}, "ramp": {"name": "Pedestrian ramps", "color": 0xe0c341, "layers": [ ("Ramp base 100mm", 0.10, "m³", 0xae9466), ("Tactile blocks (SANS 784)", 0.06, "m²", 0xe0c341)]}, } # Which design layers (inside the bound road xref "MKE-RDS") become which work type. AREA_LAYERS = { 'MKE-RDS$0$BJ_RD HCH': 'asphalt', 'MKE-RDS$0$BJ_RD WW HCH': 'paving', 'MKE-RDS$0$BJ_RD WW HCH RED': 'paving_red', 'MKE-RDS$0$BJ_RD WW HCH LANDSCAPING': 'landscape', 'MKE-RDS$0$BJ_RD WW PR HCH': 'ramp', } KERB_LAYERS = {'MKE-RDS$0$BJ_RD KERBS'} CONTEXT_LAYERS = { # drawn as thin lines for orientation only 'MKE-RDS$0$BJ_RD TAR EDGE': 'tar_edge', 'MKE-RDS$0$BJ_Toe Embank': 'toe', 'MKE-RDS$0$BJ-RM W': 'marking', 'MKE-RDS$0$CC_RD RES BND': 'reserve', 'MKE-RDS$0$12-INT ARC': 'other_road', 'MKE-RDS$0$WM8_KERB': 'other_road', 'MKE-RDS$0$WM_ACC_X_ KERB': 'other_road', 'MKE-RDS$0$WM1-RD KERB': 'other_road', } CONTOUR_PREFIX = 'RD CONT BJ$0$' def point_in_ring(x, y, ring): inside = False j = len(ring) - 1 for i in range(len(ring)): xi, yi = ring[i]; xj, yj = ring[j] if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi + 1e-12) + xi: inside = not inside j = i return inside def simplify(pts, tol=0.05): """Drop nearly collinear points to keep the JSON small.""" if len(pts) < 3: return pts out = [pts[0]] for k in range(1, len(pts) - 1): a, b, c = out[-1], pts[k], pts[k + 1] cross = abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) if cross / (math.hypot(c[0] - a[0], c[1] - a[1]) + 1e-9) > tol: out.append(b) out.append(pts[-1]) return out def read_xlsx_rows(path): """Tiny xlsx reader (first sheet, values only) so we need no extra packages.""" with zipfile.ZipFile(path) as z: ns = {'m': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'} shared = [] if 'xl/sharedStrings.xml' in z.namelist(): for si in ET.fromstring(z.read('xl/sharedStrings.xml')).findall('m:si', ns): shared.append(''.join(t.text or '' for t in si.iter('{%s}t' % ns['m']))) sheet = ET.fromstring(z.read('xl/worksheets/sheet1.xml')) for row in sheet.iter('{%s}row' % ns['m']): vals = [] for c in row.findall('m:c', ns): v = c.find('m:v', ns) if v is None: vals.append(None); continue vals.append(shared[int(v.text)] if c.get('t') == 's' else v.text) yield vals def main(dxf_path, so_dir, out_path): sec = read_sections(dxf_path) areas, kerbs, context, contours = [], [], [], [] block = None for typ, t in sec['BLOCKS']: if typ == 'BLOCK': block = get(t, 2); continue if block not in ('MKE-RDS', 'RD CONT BJ'): continue layer = get(t, 8) if typ == 'HATCH' and layer in AREA_LAYERS: rings = [r for r in hatch_rings(t) if abs(ring_area(r)) > 0.5] if not rings: continue rings.sort(key=lambda r: -abs(ring_area(r))) # group: each ring not inside a bigger one is an outer; rings inside an outer are its holes outers = [] for r in rings: host = next((o for o in outers if point_in_ring(r[0][0], r[0][1], o[0])), None) if host: host.append(r) else: outers.append([r]) for poly in outers: areas.append({'kind': AREA_LAYERS[layer], 'rings': poly}) elif layer in KERB_LAYERS or layer in CONTEXT_LAYERS or (block == 'RD CONT BJ' and typ == 'LWPOLYLINE'): if typ == 'LWPOLYLINE': pts, closed, elev = lwpolyline(t) elif typ == 'LINE': pts, closed, elev = line(t), False, 0.0 elif typ == 'ARC': pts, closed, elev = arc(t), False, 0.0 else: continue if not pts: continue if closed: pts = pts + [pts[0]] if block == 'RD CONT BJ': if elev: contours.append({'z': elev, 'pts': pts, 'major': 'M CONT' in layer}) elif layer in KERB_LAYERS: kerbs.append(pts) else: context.append({'kind': CONTEXT_LAYERS[layer], 'pts': pts}) # ---- setting-out data (Y, X in Lo; CAD x = -Y, y = -X) ---- def cad(y, x): return -float(y), -float(x) pegs = [] with open(os.path.join(so_dir, 'Road SO REV1.csv'), newline='') as f: for row in csv.reader(f): if len(row) >= 4 and row[0].strip().startswith('BJ'): cx, cy = cad(row[1], row[2]) pegs.append({'name': row[0].strip(), 'x': cx, 'y': cy, 'z': float(row[3]), 'Y': float(row[1]), 'X': float(row[2])}) def simple_csv(name, prefix): out = [] with open(os.path.join(so_dir, name), newline='') as f: for row in csv.reader(f): if len(row) >= 4 and row[1].strip().startswith(prefix): cx, cy = cad(row[2], row[3]) out.append({'name': row[1].strip(), 'x': cx, 'y': cy, 'Y': float(row[2]), 'X': float(row[3])}) return out water = simple_csv('Water SO.csv', 'WR') buffer = simple_csv('Buffer Zone SO.csv', 'BZ') manholes = [] for vals in read_xlsx_rows(os.path.join(so_dir, 'SW SO REV1.xlsx')): if len(vals) >= 5 and vals[1] and isinstance(vals[1], str) and vals[2] and vals[3]: try: cx, cy = cad(vals[2], vals[3]) manholes.append({'name': vals[1], 'x': cx, 'y': cy, 'inv': float(vals[4]), 'Y': float(vals[2]), 'X': float(vals[3])}) except (TypeError, ValueError): pass # ---- local origin & 20 m chainage sections ---- road_pts = np.array([p for a in areas if a['kind'] == 'asphalt' for p in a['rings'][0]]) ox, oy = road_pts.mean(axis=0) loc = lambda p: (round(p[0] - ox, 3), round(p[1] - oy, 3)) al = load_alignment(dxf_path, ox, oy) loc_rings = lambda a: [[(p[0] - ox, p[1] - oy) for p in r] for r in a['rings']] all_ch = [al.chainage_of(x, y) for a in areas for (x, y) in loc_rings(a)[0][::5]] band_list = bands(min(all_ch), max(all_ch)) pieces = [] for a in areas: rings = loc_rings(a) for i, (lo, hi) in enumerate(band_list): first, last = i == 0, i == len(band_list) - 1 clipped = [clip_ring_to_band(r, al.at, lo, hi, first, last) for r in rings] if len(clipped[0]) < 3 or abs(ring_area(clipped[0])) < 0.05: # keep small slivers (ramps are < 1 m²) continue area = abs(ring_area(clipped[0])) - sum(abs(ring_area(h)) for h in clipped[1:] if len(h) >= 3) if area < 0.05: continue pieces.append({'kind': a['kind'], 'section': band_key(lo, hi), 'area': round(area, 2), 'rings': [[(round(x, 3), round(y, 3)) for x, y in simplify(r)] for r in clipped if len(r) >= 3]}) # kerbs: each kerb line goes to the section holding its mid-point kerb_out = [] for pts in kerbs: lp = [loc(p) for p in pts] L = sum(math.dist(lp[i - 1], lp[i]) for i in range(1, len(lp))) if L < 0.3: continue mid = np.mean(np.array(lp), axis=0) ch = al.chainage_of(*mid) lo, hi = next(((lo, hi) for lo, hi in band_list if lo <= ch < hi), band_list[0] if ch < band_list[0][0] else band_list[-1]) kerb_out.append({'section': band_key(lo, hi), 'length': round(L, 2), 'pts': simplify(lp, 0.02)}) # contours kept within 60 m of the road pad = 60.0 gx0, gx1 = float(road_pts[:, 0].min() - ox - pad), float(road_pts[:, 0].max() - ox + pad) gy0, gy1 = float(road_pts[:, 1].min() - oy - pad), float(road_pts[:, 1].max() - oy + pad) out = { 'project': 'Bushveld Joy Road — The Hills Ext 12 (C3092)', 'source': os.path.basename(dxf_path), 'origin': {'cadX': round(ox, 3), 'cadY': round(oy, 3), 'note': 'local e = cadX - origin.cadX, n = cadY - origin.cadY; Lo Y = -cadX, X = -cadY'}, 'sections': [{'key': band_key(lo, hi), 'from': lo, 'to': hi} for lo, hi in band_list if any(p['section'] == band_key(lo, hi) for p in pieces)], 'builds': {k: {'name': b['name'], 'color': b['color'], 'layers': [{'id': slug(n), 'name': n, 'thk': t, 'unit': u, 'color': c} for n, t, u, c in b['layers']]} for k, b in BUILDS.items()}, 'areas': pieces, 'kerbs': kerb_out, 'context': [{'kind': c['kind'], 'pts': simplify([loc(p) for p in c['pts']], 0.05)} for c in context], 'contours': [{'z': c['z'], 'major': c['major'], 'pts': simplify([loc(p) for p in c['pts']], 0.2)} for c in contours if any(gx0 < p[0] - ox < gx1 and gy0 < p[1] - oy < gy1 for p in c['pts'])], 'pegs': [{'name': p['name'], 'e': round(p['x'] - ox, 3), 'n': round(p['y'] - oy, 3), 'z': p['z'], 'Y': p['Y'], 'X': p['X']} for p in pegs], 'manholes': [{'name': m['name'], 'e': round(m['x'] - ox, 3), 'n': round(m['y'] - oy, 3), 'inv': m['inv'], 'Y': m['Y'], 'X': m['X']} for m in manholes], 'water': [{'name': p['name'], 'e': round(p['x'] - ox, 3), 'n': round(p['y'] - oy, 3), 'Y': p['Y'], 'X': p['X']} for p in water], 'buffer': [{'name': p['name'], 'e': round(p['x'] - ox, 3), 'n': round(p['y'] - oy, 3), 'Y': p['Y'], 'X': p['X']} for p in buffer], } with open(out_path, 'w') as f: json.dump(out, f, separators=(',', ':')) # summary from collections import defaultdict tot = defaultdict(float) for pc in pieces: tot[pc['kind']] += pc['area'] print('sections', len(out['sections']), 'pieces', len(pieces), [x['key'] for x in out['sections']]) for k, v in tot.items(): print(f' {k:12s} {v:10.1f} m²') print('kerbs', len(kerb_out), 'total', round(sum(k['length'] for k in kerb_out), 1), 'm') print('context lines', len(context), 'contours', len(out['contours']), 'pegs', len(pegs), 'manholes', len(manholes), 'water', len(water), 'buffer', len(buffer)) print('json size', os.path.getsize(out_path) // 1024, 'KB') if __name__ == '__main__': main(*sys.argv[1:4])