Munyaka model: 27 stormwater structures and 26 pipes from the issued structure table (MH/KI/outlet by CAD block), water node points, and the information-stage village network (services, ponds, boundary, lots, hydrants) aligned from the SDP Rev C PDF. Map 3: selected Progress section is outlined and its row highlighted; Show-on-map gains ground surface, contours, kerbs, markings, toes, reserve, signs, studs and HOLD switches; the ground fades when underground services are ticked; pipes and manholes are chunkier. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
136 lines
5.9 KiB
Python
136 lines
5.9 KiB
Python
"""Village network of Munyaka (C3175-VIL-SDP-001 Rev C, issued for information) as plan linework.
|
|
|
|
The SDP is a vector PDF at 1:1000 with no coordinates, so it is aligned to the CAD model by
|
|
matching its manhole symbols to the issued SR1-SR12 sewer and SW stormwater structures.
|
|
Result is plan position only: no levels, sizes or quantities are read from it.
|
|
"""
|
|
import itertools
|
|
import math
|
|
import warnings
|
|
|
|
import numpy as np
|
|
from shapely.geometry import LineString
|
|
|
|
SCALE = 1000*0.0254/72 # metres per PDF point at 1:1000
|
|
# Legend colours of the SDP (see its LEGEND block)
|
|
ROUTES = {'stormwater': ((0.0, 0.72, 0.0), 1.08), 'sewer': ((1.0, 0.0, 0.0), 1.68), 'water': ((0.0, 0.25, 1.0), 1.08)}
|
|
POND = ((0.0, 1.0, 0.5), 0.72)
|
|
BOUNDARY = ((0.0, 0.0, 0.0), 0.72)
|
|
LAYOUT = ((0.0, 0.0, 0.0), 0.24)
|
|
|
|
|
|
def _open(pdf_path):
|
|
import pymupdf
|
|
warnings.filterwarnings('ignore')
|
|
page = pymupdf.open(str(pdf_path))[0]
|
|
return pymupdf, page, page.rotation_matrix
|
|
|
|
|
|
def _symbols(page, rot, colour, lo, hi):
|
|
out = []
|
|
for it in page.get_drawings():
|
|
r = it['rect']
|
|
if (it['fill'] or it['color']) is None or not lo <= round(r.width, 1) <= hi or r.height >= 8:
|
|
continue
|
|
c = tuple(round(x, 2) for x in (it['fill'] or it['color']))
|
|
if c == colour:
|
|
q = (r.tl + r.br)*0.5*rot
|
|
out.append((q.x*SCALE, -q.y*SCALE))
|
|
return np.array(out)
|
|
|
|
|
|
def _rigid(P, Q):
|
|
pc, qc = P.mean(0), Q.mean(0)
|
|
U, _, Vt = np.linalg.svd((P-pc).T@(Q-qc))
|
|
R = Vt.T@U.T
|
|
if np.linalg.det(R) < 0:
|
|
Vt[-1] *= -1
|
|
R = Vt.T@U.T
|
|
return R, qc-pc@R.T
|
|
|
|
|
|
def align(page, rot, sewer_mh, storm):
|
|
"""Rigid transform (no scale, no mirroring) from PDF metres to local model metres."""
|
|
P = _symbols(page, rot, (1.0, 0.0, 0.0), 2.3, 3.4)
|
|
Pw = _symbols(page, rot, (0.0, 0.72, 0.0), 2.8, 3.4)
|
|
Q = np.array(sewer_mh)
|
|
best = (0,)
|
|
dP = np.linalg.norm(P[:, None]-P[None], axis=2)
|
|
for i, j in itertools.combinations(range(len(Q)), 2):
|
|
d = np.linalg.norm(Q[i]-Q[j])
|
|
for a, b in zip(*np.where(np.abs(dP-d) < .6)):
|
|
vp, vq = P[b]-P[a], Q[j]-Q[i]
|
|
th = math.atan2(vq[1], vq[0])-math.atan2(vp[1], vp[0])
|
|
R = np.array([[math.cos(th), -math.sin(th)], [math.sin(th), math.cos(th)]])
|
|
T = Q[i]-P[a]@R.T
|
|
hits = (np.linalg.norm((P@R.T+T)[:, None]-Q[None], axis=2).min(0) < 1.5).sum()
|
|
if hits > best[0]:
|
|
best = (hits, R, T)
|
|
if best[0] < 8:
|
|
raise ValueError(f'Village PDF alignment failed: only {best[0]} sewer manholes matched')
|
|
_, R, T = best
|
|
both = np.vstack([Q, np.array(storm)])
|
|
for _ in range(6):
|
|
pairs = []
|
|
for cand, targets in ((P, Q), (Pw, np.array(storm))):
|
|
X = cand@R.T+T
|
|
for q in targets:
|
|
d = np.linalg.norm(X-q, axis=1)
|
|
if d.min() < 3:
|
|
pairs.append((cand[d.argmin()], q))
|
|
R, T = _rigid(np.array([a for a, _ in pairs]), np.array([b for _, b in pairs]))
|
|
res = np.linalg.norm(np.array([a for a, _ in pairs])@R.T+T-np.array([b for _, b in pairs]), axis=1)
|
|
if len(pairs) < 15 or np.median(res) > .6:
|
|
raise ValueError(f'Village PDF alignment is weak: {len(pairs)} pairs, median residual {np.median(res):.2f} m')
|
|
return R, T, {'matchedPoints': len(pairs), 'medianResidualM': round(float(np.median(res)), 3), 'maxResidualM': round(float(res.max()), 3),
|
|
'rotationDeg': round(math.degrees(math.atan2(R[1, 0], R[0, 0])), 2), 'of': len(both)}
|
|
|
|
|
|
def village_model(pdf_path, sewer_mh, storm, simplify=.25):
|
|
pymupdf, page, rot = _open(pdf_path)
|
|
R, T, fit = align(page, rot, sewer_mh, storm)
|
|
tf = lambda q: (np.array([(q*rot).x*SCALE, -(q*rot).y*SCALE])@R.T+T)
|
|
|
|
def points(it):
|
|
out = []
|
|
for seg in it['items']:
|
|
if seg[0] == 'l':
|
|
out += [seg[1], seg[2]]
|
|
elif seg[0] == 'c':
|
|
a, b, c, d = (np.array(v) for v in seg[1:5])
|
|
out += [pymupdf.Point(*(((1-t)**3)*a+3*((1-t)**2)*t*b+3*(1-t)*t*t*c+t**3*d)) for t in np.linspace(0, 1, 6)]
|
|
return out
|
|
|
|
buckets = {k: [] for k in ('stormwater', 'sewer', 'water', 'pond', 'boundary', 'layout')}
|
|
styles = {**{k: v for k, v in ROUTES.items()}, 'pond': POND, 'boundary': BOUNDARY, 'layout': LAYOUT}
|
|
for it in page.get_drawings():
|
|
if it['type'] != 's' or not it['color'] or (it['rect']*rot).x0 > 2000: # right-hand strip is the title block/legend
|
|
continue
|
|
key = (tuple(round(c, 2) for c in it['color']), round(it['width'] or 0, 2))
|
|
for name, style in styles.items():
|
|
if key == style:
|
|
pts = points(it)
|
|
if len(pts) >= 2:
|
|
line = LineString([tf(q) for q in pts])
|
|
if name == 'layout' and line.length < 10:
|
|
continue
|
|
if name == 'pond' and line.length < 25:
|
|
continue
|
|
buckets[name].append(line)
|
|
|
|
def rows(lines):
|
|
out = []
|
|
for ln in lines:
|
|
s = ln.simplify(simplify) if simplify else ln
|
|
if s.length > .5:
|
|
out.append({'pts': [[round(x, 2), round(y, 2)] for x, y in s.coords], 'length': round(s.length, 2)})
|
|
return out
|
|
routes = [{**r, 'service': svc} for svc in ('stormwater', 'sewer', 'water') for r in rows(buckets[svc])]
|
|
hydrants = []
|
|
for w in page.get_text('words'):
|
|
if w[4] == 'FH':
|
|
q = tf(pymupdf.Point((w[0]+w[2])/2, (w[1]+w[3])/2))
|
|
hydrants.append([round(float(q[0]), 2), round(float(q[1]), 2)])
|
|
return {'source': 'C3175-VIL-SDP-001 Rev C (22 Oct 2026), issued for information. Plan position only, aligned to the CAD model; no levels, sizes or quantities.',
|
|
'alignment': fit, 'routes': routes, 'ponds': rows(buckets['pond']), 'boundary': rows(buckets['boundary']),
|
|
'layout': rows(buckets['layout']), 'hydrants': hydrants}
|