263 lines
12 KiB
Python
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])
|