MEGAINFRA-WEBAPP/scripts/site-model/extract_cedar_gate_xs.py
DEls 76d450aa1d chore: site certificates, site spend and cedar-gate model updates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:23:34 +02:00

399 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Extract Cedar Gate Road's 40 drawn cross-sections from C3277-912-001 Rev A and check them against
the C3277-911-001 Rev A road long section.
The cross-sections are read from the CAD drawing converted to DXF (preferred: named layers, exact lines)
or, as before, from the plotted PDF. Scales are measured from the grid labels. Connected road design
paths are separated from the independent service symbols. The long section's centreline ground and design
levels must agree with every matching cross-section. Quantities are drawing-derived end-area estimates,
not measured backfill or as-built volumes.
python3 extract_cedar_gate_xs.py "C3277-912-001 ... - bound.dxf" raw.json MODEL_FOLDER --long-section "C3277-911-001 ... - bound.dxf"
"""
import argparse
import base64
from collections import defaultdict
import hashlib
import json
import math
from pathlib import Path
import re
import subprocess
import tempfile
import numpy as np
from shapely.geometry import LineString, Point
from extract_cedar_gate import alignment, load_dxf
from extract_xs import load_svg_paths, load_words
H_SCALE = 1000/250/25.4*72
V_SCALE = -1000/100/25.4*72
STEP = .05
def profile(chainage, ground, design_segments, excluded):
"""Sample one section's natural ground and road design every STEP m and measure cut/fill to formation.
ground and design_segments are ((offset, level), (offset, level)) pairs sorted by offset.
"""
xs = np.arange(-15, 15+STEP/2, STEP)
def sample(segments, aggregate):
out = np.full(len(xs), np.nan)
for (ax, ay), (bx, by) in segments:
mask = (xs >= ax-1e-8) & (xs <= bx+1e-8)
value = ay+(xs[mask]-ax)*(by-ay)/(bx-ax)
out[mask] = np.where(np.isnan(out[mask]), value, aggregate(out[mask], value))
return out
if not ground:
raise ValueError(f'{chainage}: no natural ground line')
ngl = sample(ground, np.maximum)
good = np.isfinite(ngl)
if good.sum() < 20:
raise ValueError(f'{chainage}: insufficient natural ground')
ngl = np.interp(xs, xs[good], ngl[good]) # bridge plotted dashes
top = sample(design_segments, np.maximum)
bottom = sample(design_segments, np.minimum)
cut = np.maximum(ngl-bottom, 0)
fill = np.maximum(bottom-ngl, 0)
# Integrate only contiguous runs of drawn formation; do not bridge blank spans.
def integrate(values):
mask = np.isfinite(values[:-1]) & np.isfinite(values[1:])
return float(np.sum((values[:-1][mask]+values[1:][mask])*.5*STEP))
return {'ch': chainage, 'x': xs, 'ngl': ngl, 'top': top, 'bot': bottom,
'cutArea': integrate(cut), 'fillArea': integrate(fill),
'roadWidth': float(np.ptp(xs[np.isfinite(bottom)])), 'excludedIndependentPaths': excluded}
def check_chainages(result):
result.sort(key=lambda s: s['ch'])
if len(result) != 40 or [s['ch'] for s in result] != [*range(0, 381, 10), 385.411]:
raise ValueError('Expected all 40 drawn sections from 0 to 385.411 m')
return result
def read_sections(svg_path, words_path):
paths = load_svg_paths(Path(svg_path).read_text())
words = load_words(Path(words_path).read_text())
black = lambda p: p['stroke'] == 'rgb(0%, 0%, 0%)'
grey = lambda p: p['stroke'].startswith('rgb(72.')
inside = lambda r, x, y, pad=0: r[0]-pad <= x <= r[2]+pad and r[1]-pad <= y <= r[3]+pad
rects = []
for p in paths:
if black(p) and p['w'] == 17 and len(p['pts']) >= 4:
xx, yy = zip(*p['pts'])
if max(xx)-min(xx) > 300 and max(yy)-min(yy) > 30:
rects.append((min(xx), min(yy), max(xx), max(yy)))
result = []
for i, word in enumerate(words):
if word[0] != 'Peg':
continue
cx, cy = (word[1]+word[3])/2, (word[2]+word[4])/2
strip = next((r for r in rects if inside(r, cx, cy) and r[3]-r[1] < 60), None)
if strip is None:
raise ValueError('Cross-section caption lacks a frame')
plot = next((r for r in rects if abs(r[3]-strip[1]) < 2 and abs(r[0]-strip[0]) < 2 and r[3]-r[1] > 60), None)
if plot is None:
raise ValueError('Cross-section plot lacks a frame')
following = [w for w in words[i+1:i+7] if abs((w[2]+w[4])/2-cy) < 3]
chainage = next((float(w[0]) for w in following if re.fullmatch(r'\d+(\.\d+)?', w[0])), None)
if chainage is None:
raise ValueError('Cross-section chainage unreadable')
gx = sorted({p['pts'][0][0] for p in paths if grey(p) and len(p['pts']) == 2 and abs(p['pts'][0][0]-p['pts'][1][0]) < .01 and inside(plot, *p['pts'][0])})
gy = sorted({p['pts'][0][1] for p in paths if grey(p) and len(p['pts']) == 2 and abs(p['pts'][0][1]-p['pts'][1][1]) < .01 and inside(plot, *p['pts'][0])})
offsets = [(float(w[0]), (w[1]+w[3])/2) for w in words if re.fullmatch(r'-?\d{1,2}', w[0]) and inside(strip, (w[1]+w[3])/2, (w[2]+w[4])/2)]
snapped = [(o, min(gx, key=lambda g: abs(g-x))) for o, x in offsets if o % 5 == 0 and min(abs(g-x) for g in gx) < 2]
if len(snapped) < 5:
raise ValueError(f'{chainage}: insufficient offset labels')
x0 = float(np.median([x-H_SCALE*o for o, x in snapped]))
levels = [(float(w[0]), (w[2]+w[4])/2) for w in words if re.fullmatch(r'13\d{2}', w[0]) and inside(plot, (w[1]+w[3])/2, (w[2]+w[4])/2, 1)]
level_pairs = [(z, min([g for g in gy if 0 < g-y < 10])) for z, y in levels if any(0 < g-y < 10 for g in gy)]
if len(level_pairs) < 2:
raise ValueError(f'{chainage}: insufficient level labels')
y0 = float(np.median([y-V_SCALE*z for z, y in level_pairs]))
to_m = lambda p: ((p[0]-x0)/H_SCALE, (p[1]-y0)/V_SCALE)
local = []
for p in paths:
if black(p) and p['w'] in (6, 9, 12) and all(inside(plot, x, y, -.3) for x, y in p['pts']):
local.append({'w': p['w'], 'pts': [to_m(p) for p in p['pts']]})
# The 0.72 pt finished road profile is the seed. Select road layerwork,
# kerbs, sidewalks and batters connected to it; exclude separate pipe markers.
designs = [p for p in local if p['w'] in (6, 12)]
connected = [p for p in designs if p['w'] == 12]
if len(connected) != 1:
raise ValueError(f'{chainage}: expected one finished road profile')
remaining = [p for p in designs if p['w'] != 12]
for _ in range(len(remaining)+1):
geometry = [LineString(p['pts']) for p in connected]
joined = [p for p in remaining if any(LineString(p['pts']).distance(g) < .012 for g in geometry)]
if not joined:
break
connected.extend(joined)
remaining = [p for p in remaining if p not in joined]
ground = []
for p in local:
if p['w'] != 9:
continue
for a, b in zip(p['pts'], p['pts'][1:]):
if abs(a[0]-b[0]) > .02:
ground.append(tuple(sorted((a, b))))
design_segments = [tuple(sorted((a, b))) for p in connected for a, b in zip(p['pts'], p['pts'][1:]) if abs(a[0]-b[0]) > .001]
result.append(profile(chainage, ground, design_segments, len(remaining)))
return check_chainages(result)
XS_DESIGN_LAYERS = ('XS_ROAD_SURFACE', 'XS_LAYERWORKS', 'XS_KERBS', 'XS_ROAD_SHOULDERS', 'XS_SLOPE_REV')
def read_sections_cad(dxf_path):
"""Read the 40 sections from the C3277-912-001 CAD drawing (converted to DXF).
Each section is a plot frame above a caption strip "Peg Distance (m) N m". Offsets are drawn at
true horizontal scale and levels stretched (2.5x, from H 1:250 / V 1:100); both scales are measured
from the grid labels. Natural ground is the XS_GROUND polyline; the design is the road surface plus
the layerworks, kerbs, shoulders and batters connected to it. Unconnected service markers are excluded.
"""
doc = load_dxf(dxf_path)
msp = doc.modelspace()
layer = lambda e: e.dxf.layer.split('$')[-1]
lines = defaultdict(list)
for e in msp:
if e.dxftype() == 'LINE':
lines[layer(e)].append(((e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)))
grounds = [[tuple(p[:2]) for p in e.get_points()] for e in msp if e.dxftype() == 'LWPOLYLINE' and layer(e) == 'XS_GROUND']
texts = [(layer(e), e.dxf.insert.x, e.dxf.insert.y, e.dxf.text.strip()) for e in msp if e.dxftype() == 'TEXT']
horizontal = [(min(a[0], b[0]), max(a[0], b[0]), a[1]) for a, b in lines['XS_FRAME_A'] if abs(a[1]-b[1]) < 1e-6]
result = []
for kind, cx, cy, text in texts:
m = re.fullmatch(r'Peg Distance \(m\) (\d+(?:\.\d+)?) m', text) if kind == 'XS_TEXT_FRAME' else None
if not m:
continue
chainage = float(m.group(1))
above = sorted({round(y, 6) for x0, x1, y in horizontal if x0 <= cx <= x1 and y > cy})
if len(above) < 2:
raise ValueError(f'{chainage}: plot frame not found')
bottom, top = above[0], above[1]
x0, x1 = next((a, b) for a, b, y in horizontal if round(y, 6) == bottom and a <= cx <= b)
inside = lambda x, y, pad=.01: x0-pad <= x <= x1+pad and bottom-pad <= y <= top+pad
gx = sorted({a[0] for a, b in lines['XS_GRID'] if abs(a[0]-b[0]) < 1e-6 and inside(*a)})
gy = sorted({a[1] for a, b in lines['XS_GRID'] if abs(a[1]-b[1]) < 1e-6 and inside(*a)})
offsets = [(float(t), min(gx, key=lambda g: abs(g-x))) for k, x, y, t in texts
if k == 'XS_TEXT_GRID' and re.fullmatch(r'-?\d{1,2}', t) and x0 <= x <= x1 and bottom-4 <= y < bottom]
levels = [(float(t), max([g for g in gy if g <= y+1e-6], default=None)) for k, x, y, t in texts
if k == 'XS_TEXT_GRID' and re.fullmatch(r'1[34]\d{2}', t) and inside(x, y, .5)]
levels = [(z, g) for z, g in levels if g is not None]
if len(offsets) < 5 or len(levels) < 2:
raise ValueError(f'{chainage}: grid labels unreadable')
h_scale = np.polyfit([o for o, _ in offsets], [x for _, x in offsets], 1)[0]
v_scale = np.polyfit([z for z, _ in levels], [y for _, y in levels], 1)[0]
if abs(h_scale-1) > .01 or abs(v_scale-2.5) > .02:
raise ValueError(f'{chainage}: unexpected drawing scales {h_scale:.3f} / {v_scale:.3f}')
xo = float(np.median([x-h_scale*o for o, x in offsets]))
yo = float(np.median([y-v_scale*z for z, y in levels]))
to_m = lambda p: ((p[0]-xo)/h_scale, (p[1]-yo)/v_scale)
ground_lines = [g for g in grounds if all(inside(*p) for p in g)]
if len(ground_lines) != 1:
raise ValueError(f'{chainage}: expected one natural ground line, found {len(ground_lines)}')
ground = [tuple(sorted((to_m(a), to_m(b)))) for a, b in zip(ground_lines[0], ground_lines[0][1:]) if abs(a[0]-b[0]) > 1e-6]
design = [(name, tuple(sorted((to_m(a), to_m(b))))) for name in XS_DESIGN_LAYERS for a, b in lines[name] if inside(*a) and inside(*b)]
connected = [seg for name, seg in design if name == 'XS_ROAD_SURFACE']
if not connected:
raise ValueError(f'{chainage}: no finished road profile')
remaining = [seg for name, seg in design if name != 'XS_ROAD_SURFACE']
while True:
geometry = [LineString(seg) for seg in connected]
joined = [seg for seg in remaining if any(LineString(seg).distance(g) < .005 for g in geometry)]
if not joined:
break
connected.extend(joined)
remaining = [seg for seg in remaining if seg not in joined]
design_segments = [seg for seg in connected if abs(seg[0][0]-seg[1][0]) > .001]
result.append(profile(chainage, ground, design_segments, len(remaining)))
return check_chainages(result)
def read_long_section(dxf_path):
"""Read the level bands under each panel of the C3277-911-001 road long section.
Returns one panel per road: rows of chainage with centreline ground level and the design levels at the
centreline and 2.5 m left and right, wherever the drawing gives them.
"""
doc = load_dxf(dxf_path)
msp = doc.modelspace()
frame = [((e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)) for e in msp if e.dxftype() == 'LINE' and e.dxf.layer.endswith('CC_LS_FRAME')]
top = max(max(a[1], b[1]) for a, b in frame)
panels = sorted((min(a[0], b[0]), max(a[0], b[0])) for a, b in frame if abs(a[1]-top) < 1e-6 and abs(b[1]-top) < 1e-6)
texts = [(e.dxf.insert.x, e.dxf.insert.y, e.dxf.text.strip(), round(e.dxf.get('rotation', 0))) for e in msp
if e.dxftype() == 'TEXT' and e.dxf.layer.endswith('CC_LS_TEXT')]
bands = {'Peg Distance (m)': 'ch', 'Ground Level (m) (@GLO)': 'ground', 'Left': 'left', 'Center': 'centre', 'Right': 'right'}
result = []
for x0, x1 in panels:
labels = [(y, bands[t]) for x, y, t, r in texts if x0 <= x <= x1 and t in bands and r == 0]
if sorted(b for _, b in labels) != sorted(bands.values()):
raise ValueError(f'Long-section panel at x {x0:.1f}: level bands not found')
columns = defaultdict(dict)
for x, y, t, r in texts:
if not (x0 <= x <= x1 and r == 90 and re.fullmatch(r'\d+(\.\d+)?', t)):
continue
# A value sits in the band whose label is level with it or up to 8 units above it.
band = min(((ly-y, b) for ly, b in labels if -1 <= ly-y <= 8), default=None)
if band:
columns[round(x, 1)][band[1]] = float(t)
rows = sorted((c for c in columns.values() if 'ch' in c), key=lambda c: c['ch'])
result.append({'length': rows[-1]['ch'], 'rows': rows})
return result
def long_section_check(sections, panel):
"""Compare each cross-section's centreline ground and design level with the long section at that chainage."""
rows = {round(r['ch'], 3): r for r in panel['rows']}
centre = int(round(15/STEP))
side = int(round(2.5/STEP))
ground, design = [], []
for s in sections:
r = rows.get(round(s['ch'], 3))
if not r:
continue
if 'ground' in r:
ground.append(float(s['ngl'][centre])-r['ground'])
if 'centre' in r and np.isfinite(s['top'][centre]):
design.append(float(s['top'][centre])-r['centre'])
# Left/right are a matter of drawing direction: compare with whichever pairing agrees.
if 'left' in r and 'right' in r and np.isfinite(s['top'][centre-side]) and np.isfinite(s['top'][centre+side]):
a = (s['top'][centre-side]-r['left'], s['top'][centre+side]-r['right'])
b = (s['top'][centre-side]-r['right'], s['top'][centre+side]-r['left'])
design.extend(min((a, b), key=lambda d: abs(d[0])+abs(d[1])))
if len(ground) < 30 or len(design) < 30:
raise ValueError(f'Long section matches too few cross-sections: {len(ground)} ground, {len(design)} design levels')
stats = lambda d: {'samples': len(d), 'medianAbs': round(float(np.median(np.abs(d))), 4), 'maxAbs': round(float(np.max(np.abs(d))), 4)}
report = {'ground': stats(ground), 'design': stats(design)}
if report['ground']['medianAbs'] > .02 or report['design']['medianAbs'] > .02 or report['ground']['maxAbs'] > .1 or report['design']['maxAbs'] > .1:
raise ValueError(f'Cross-sections disagree with the long section: {report}')
return report
def dem(surface):
q = np.frombuffer(base64.b64decode(surface['z']), dtype='<i2')
grid = np.where(q < 0, np.nan, surface['zmin']+q/1000).reshape(surface['ny'], surface['nx'])
def at(e, n):
x, y = (e-surface['x0'])/surface['cell'], (n-surface['y0'])/surface['cell']
if x < 0 or y < 0 or x > surface['nx']-1 or y > surface['ny']-1:
return None
i, j = min(int(x), surface['nx']-2), min(int(y), surface['ny']-2)
u, v = x-i, y-j
values = grid[j:j+2, i:i+2].ravel(); weights = np.array([(1-u)*(1-v), u*(1-v), (1-u)*v, u*v])
valid = np.isfinite(values)
if not valid.any() or weights[valid].sum() == 0:
return None
return float(np.sum(values[valid]*weights[valid])/np.sum(weights[valid]))
return at
def build_sections(sections, raw, folder, source, long_section=None):
road = json.loads((folder/'bushveld.json').read_text())
surface = json.loads((folder/'surface.json').read_text())
previous = json.loads((folder/'earthworks.json').read_text())
rows = raw['road']['RDS_LO$0$CC_RDS_NEW_INT_CL']
line = alignment(rows, rows[0]['pts'][0], 385.411)
ox, oy = road['origin']['cadX'], road['origin']['cadY']
def at(ch):
distance = min(max(ch, 0), line.length)
point = line.interpolate(distance)
a, b = line.interpolate(max(0, distance-.05)), line.interpolate(min(line.length, distance+.05))
direction = np.array([b.x-a.x, b.y-a.y]); direction /= np.linalg.norm(direction)
return np.array([point.x-ox, point.y-oy]), direction
level = dem(surface)
def differences(side):
errors = []
for s in sections:
point, direction = at(s['ch']); normal = np.array([direction[1], -direction[0]])*side
for offset in np.arange(-2, 2.01, .5):
index = int(round((offset+15)/STEP)); z = s['top'][index]
if not np.isfinite(z):
continue
e, n = point+normal*offset; value = level(e, n)
if value is not None:
errors.append(float(z-value))
return np.array(errors)
errors = {side: differences(side) for side in (1, -1)}
side = min(errors, key=lambda side: np.median(np.abs(errors[side])))
delta = errors[side]
if len(delta) < 200 or np.median(np.abs(delta)) > .15 or np.percentile(np.abs(delta), 90) > .35:
raise ValueError(f'Cross-sections do not agree with finished-level contours: {len(delta)} samples, median {np.median(np.abs(delta))}, p90 {np.percentile(np.abs(delta), 90)}')
out = []
for s in sections:
point, direction = at(s['ch'])
keep = np.arange(0, len(s['x']), 5) # 0.25 m browser samples
out.append({'ch': s['ch'], 'e': round(float(point[0]), 4), 'n': round(float(point[1]), 4), 'dir': direction.round(7).tolist(), 'side': side,
'x': s['x'][keep].round(3).tolist(), 'ngl': s['ngl'][keep].round(4).tolist(),
'top': [round(float(z), 4) if np.isfinite(z) else None for z in s['top'][keep]],
'bot': [round(float(z), 4) if np.isfinite(z) else None for z in s['bot'][keep]],
'cutArea': round(s['cutArea'], 4), 'fillArea': round(s['fillArea'], 4)})
volumes = [{'from': a['ch'], 'to': b['ch'], 'cut': round((a['cutArea']+b['cutArea'])*.5*(b['ch']-a['ch']), 3),
'fill': round((a['fillArea']+b['fillArea'])*.5*(b['ch']-a['ch']), 3)} for a, b in zip(sections, sections[1:])]
section_keys = [s['key'] for s in road['sections'] if s.get('name') == 'Cedar Gate Road']
lengths = {name: max(s['to'] for s in road['sections'] if s.get('name') == name) for name in {s.get('name') for s in road['sections']}}
long_report = None
if long_section:
named = {min(lengths, key=lambda n: abs(lengths[n]-panel['length'])): panel for panel in long_section}
long_report = {'check': long_section_check(sections, named['Cedar Gate Road']), 'roads': {
name: {'length': panel['length'], 'levels': sum('ground' in r and 'centre' in r for r in panel['rows']),
'maxCentreCut': round(max([r['ground']-r['centre'] for r in panel['rows'] if 'ground' in r and 'centre' in r] or [0]), 3),
'maxCentreFill': round(max([r['centre']-r['ground'] for r in panel['rows'] if 'ground' in r and 'centre' in r] or [0]), 3)}
for name, panel in named.items()}}
for s in road['sections']:
if s['key'] == section_keys[-1]:
s['to'] = 385.411
method = 'End-area between 10 m drawn sections (last strip 5.411 m); natural ground versus connected road formation, including the revised side slopes to be shaped. Service trench symbols excluded.'
if long_report:
method += f" Centreline ground and design levels agree with the C3277-911-001 long section to {long_report['check']['ground']['medianAbs']*1000:.0f} mm / {long_report['check']['design']['medianAbs']*1000:.0f} mm median."
result = {'source': source, 'method': method,
'sectionKeys': section_keys, 'chainageOffset': 0, 'chainageEnd': 385.411, 'sections': out, 'volumes': volumes,
'alignment': [[round(float(ch), 3), *at(ch)[0].round(4).tolist()] for ch in np.arange(0, line.length, 2)],
'totals': {key: round(sum(v[key] for v in volumes), 3) for key in ['cut', 'fill']},
'validation': {'samples': len(delta), 'meanAbs': round(float(np.mean(np.abs(delta))), 4), 'median': round(float(np.median(delta)), 4),
'medianAbs': round(float(np.median(np.abs(delta))), 4), 'p90Abs': round(float(np.percentile(np.abs(delta), 90)), 4),
'note': 'Drawn finished road profile versus independently extracted FRL_CONTOURS; tested both offset directions.'}}
if previous.get('groundSurface'):
result['groundSurface'] = previous['groundSurface']
old = 'Bulk cut/fill quantities are unavailable until the road cross-sections and formation levels are reconciled; no zero-volume claim is made.'
replacement = 'Cut/fill covers Cedar Gate Road only, from C3277-912-001 Rev A sections 0–385.411 m. Road formation earthworks exclude service trenches and external roads; fill is design compacted fill, not pipe-trench backfill.'
road['notes'] = [replacement if note == old else note for note in road.get('notes', [])]
(folder/'earthworks.json').write_text(json.dumps(result, separators=(',', ':'), allow_nan=False))
(folder/'bushveld.json').write_text(json.dumps(road, separators=(',', ':'), allow_nan=False))
report = {'sections': len(out), 'chainage': [0, 385.411], 'totals': result['totals'], 'validation': result['validation'],
'offset_side': side, 'widths': [[s['ch'], round(s['roadWidth'], 3)] for s in sections],
'excluded_independent_service_paths': sum(s['excludedIndependentPaths'] for s in sections), 'scope': replacement,
'longSection': long_report,
'earthworks_sha256': hashlib.sha256((folder/'earthworks.json').read_bytes()).hexdigest()}
(folder/'earthworks-validation.json').write_text(json.dumps(report, indent=2))
validation_path = folder/'validation.json'
if validation_path.exists():
validation = json.loads(validation_path.read_text()); validation['earthworks'] = report; validation['notes'] = road['notes']
validation['sha256'] = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in [folder/f'{f}.json' for f in ['bushveld','services','earthworks','schedules','surface']]}
validation_path.write_text(json.dumps(validation, indent=2))
print(json.dumps(report, indent=2))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('cross_sections', type=Path, help='C3277-912-001 as DXF (converted from the DWG) or as the plotted PDF')
parser.add_argument('raw_geometry', type=Path)
parser.add_argument('model_folder', type=Path)
parser.add_argument('--long-section', type=Path, help='C3277-911-001 road long section as DXF; checks every cross-section')
parser.add_argument('--poppler-bin', type=Path, default=Path('/usr/local/bin'))
args = parser.parse_args()
if args.cross_sections.suffix.lower() == '.dxf':
sections = read_sections_cad(args.cross_sections)
else:
with tempfile.TemporaryDirectory() as tmp:
svg, words = Path(tmp)/'sections.svg', Path(tmp)/'words.html'
subprocess.run([str(args.poppler_bin/'pdftocairo'), '-svg', str(args.cross_sections), str(svg)], check=True)
subprocess.run([str(args.poppler_bin/'pdftotext'), '-bbox', str(args.cross_sections), str(words)], check=True)
sections = read_sections(svg, words)
long_section = read_long_section(args.long_section) if args.long_section else None
sources = ' + '.join(p.name.replace('.dxf', '.dwg') if p.suffix.lower() == '.dxf' else p.name for p in [args.cross_sections, args.long_section] if p)
build_sections(sections, json.loads(args.raw_geometry.read_text()), args.model_folder, sources, long_section)
if __name__ == '__main__':
main()