| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788 |
- """Conservative adjacency candidates for narrow surveyed outlines; no polygon union."""
- import math
- def signed_area(ring):
- ox, oy = ring[0]
- return sum((a[0]-ox)*(b[1]-oy)-(b[0]-ox)*(a[1]-oy)
- for a,b in zip(ring,ring[1:]+ring[:1])) / 2
- def shared_boundary_length(first, second, tolerance=.06):
- """Measure overlapping opposite-facing edges, merging duplicate overlap intervals."""
- a = first if signed_area(first)>0 else list(reversed(first))
- b = second if signed_area(second)>0 else list(reversed(second))
- total = 0
- for p,q in zip(a,a[1:]+a[:1]):
- dx,dy = q[0]-p[0],q[1]-p[1]
- length = math.hypot(dx,dy)
- if length <= tolerance:
- continue
- ux,uy = dx/length,dy/length
- intervals = []
- for r,s in zip(b,b[1:]+b[:1]):
- if (s[0]-r[0])*ux+(s[1]-r[1])*uy >= 0:
- continue # CCW neighbors run opposite ways along a shared boundary
- distances = [abs((v[0]-p[0])*uy-(v[1]-p[1])*ux) for v in (r,s)]
- if max(distances)>tolerance:
- continue
- t = [(v[0]-p[0])*ux+(v[1]-p[1])*uy for v in (r,s)]
- low,high = max(0,min(t)),min(length,max(t))
- if high>low:
- intervals.append((low,high))
- end = -math.inf
- for low,high in sorted(intervals):
- total += max(0,high-max(end,low))
- end = max(end,high)
- return total
- def attachment_candidates(strip, buildings):
- """Geometry evidence only; ambiguity must be resolved before assigning an export role."""
- short,long = sorted((strip['width'],strip['depth']))
- if strip['kind']!='massing' or short>2 or short<=0 or long<4 or long/short<4:
- return []
- area = abs(signed_area(strip['footprintENU']))
- candidates = []
- for host in buildings:
- if host['id']==strip['id'] or min(host['width'],host['depth'])<4:
- continue
- if abs(signed_area(host['footprintENU']))<area*4:
- continue
- shared = shared_boundary_length(strip['footprintENU'],host['footprintENU'])
- if shared>=long*.7:
- candidates.append({'primaryId':host['id'],'sharedBoundaryM':round(shared,4),
- 'sharedToLongSideRatio':round(shared/long,4)})
- return sorted(candidates,key=lambda row:(-row['sharedBoundaryM'],row['primaryId']))
- def validate_composition_scopes(buildings, profile):
- pairs = {}
- for building in buildings:
- scope = building.get('massingFacadeScope', 'building')
- if scope not in ('building', 'shell-only'):
- raise ValueError('unsupported facade scope')
- if scope == 'shell-only':
- composition = building.get('composition', {})
- if profile != 'compact' or composition.get('role') != 'attached-narrow-outline':
- raise ValueError('shell-only requires an explicit compact attachment role')
- pairs[building['id']] = composition.get('primaryId')
- if pairs:
- annotate_reviewed_attachments(buildings, pairs)
- def annotate_reviewed_attachments(buildings, reviewed_pairs):
- """Annotate an isolated plan; reject missing hosts, ambiguity or incorrect pairs."""
- by_id = {b['id']:b for b in buildings}
- if len(by_id)!=len(buildings):
- raise ValueError('duplicate building IDs')
- for strip_id,primary_id in reviewed_pairs.items():
- if strip_id not in by_id or primary_id not in by_id or primary_id in reviewed_pairs:
- raise ValueError('missing or invalid attachment primary')
- candidates = attachment_candidates(by_id[strip_id],buildings)
- if len(candidates)!=1 or candidates[0]['primaryId']!=primary_id:
- raise ValueError('ambiguous or unsupported attachment: '+strip_id)
- return [{**building, 'massingFacadeScope':'shell-only',
- 'composition':{'role':'attached-narrow-outline', 'primaryId':reviewed_pairs[building['id']],
- 'geometryUnchanged':True}}
- if building['id'] in reviewed_pairs else dict(building) for building in buildings]
|