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

263 lines
12 KiB
Python

"""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 <road-layout.dxf> <SO DATA folder> <out.json>
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])