"""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']))=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]