party_wall_facades.py 4.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. """Skip facade details only on explicitly reviewed, fully shared massing edges.
  2. No walls, roof caps, terrain coordinates or heights are changed. Partial-edge
  3. contacts stay untouched, and higher exposed storeys are never hidden by a low neighbor.
  4. """
  5. import math
  6. from building_composition import signed_area
  7. def _covers_edge(a, b, ring, tolerance=.011):
  8. dx,dy=b[0]-a[0],b[1]-a[1]
  9. length=math.hypot(dx,dy)
  10. if length<=tolerance:
  11. return False
  12. ux,uy=dx/length,dy/length
  13. intervals=[]
  14. for p,q in zip(ring,ring[1:]+ring[:1]):
  15. if (q[0]-p[0])*ux+(q[1]-p[1])*uy>=0:
  16. continue
  17. if max(abs((v[0]-a[0])*uy-(v[1]-a[1])*ux) for v in (p,q))>tolerance:
  18. continue
  19. along=[(v[0]-a[0])*ux+(v[1]-a[1])*uy for v in (p,q)]
  20. low,high=max(0,min(along)),min(length,max(along))
  21. if high>low:
  22. intervals.append((low,high))
  23. end=0
  24. for low,high in sorted(intervals):
  25. if low>end+tolerance:
  26. return False
  27. end=max(end,high)
  28. return end>=length-tolerance
  29. def party_wall_masks(buildings, normalize, profile):
  30. """Derive per-level masks from real neighbors; never trust serialized edge indices."""
  31. by_id={b['id']:b for b in buildings}
  32. if len(by_id)!=len(buildings):
  33. raise ValueError('duplicate party-wall building IDs')
  34. specified=[b for b in buildings if b.get('partyWallNeighbors')]
  35. if specified and profile!='compact':
  36. raise ValueError('reviewed party walls require compact profile')
  37. outlines={b['id']:normalize(b['footprintENU']) for b in buildings}
  38. masks={}
  39. for building in specified:
  40. ids=building['partyWallNeighbors']
  41. if not isinstance(ids,list) or not ids or len(set(ids))!=len(ids):
  42. raise ValueError('invalid party-wall neighbor IDs')
  43. if building['kind']!='massing' or building.get('massingFacadeScope','building')!='building':
  44. raise ValueError('party-wall details require an ordinary surveyed massing shell')
  45. ring=outlines[building['id']]
  46. hosts=[]
  47. for neighbor_id in ids:
  48. neighbor=by_id.get(neighbor_id)
  49. if neighbor is None or neighbor is building or neighbor['kind']!='massing':
  50. raise ValueError('party-wall neighbor missing or not surveyed massing')
  51. if building['id'] not in neighbor.get('partyWallNeighbors',[]):
  52. raise ValueError('party-wall review must be reciprocal')
  53. other=outlines[neighbor_id]
  54. if len(ring)<3 or len(other)<3 or signed_area(other)<=0:
  55. raise ValueError('invalid party-wall outline')
  56. edges=[i for i,a in enumerate(ring) if _covers_edge(a,ring[(i+1)%len(ring)],other)]
  57. if not edges:
  58. raise ValueError('no fully shared edge for reviewed party wall')
  59. low,high=neighbor['foundationBottom'],neighbor['positionENU'][2]+neighbor['heightM']
  60. if not math.isfinite(low) or not math.isfinite(high) or high<=low:
  61. raise ValueError('invalid party-wall neighbor heights')
  62. hosts.append((edges,low,high))
  63. base=max(building['foundationBottom'],building['positionENU'][2])
  64. top=building['positionENU'][2]+building['heightM']
  65. floors=building['floors']
  66. if floors<1 or top<=base or not math.isfinite(top):
  67. raise ValueError('invalid party-wall building heights')
  68. floor_height=max(2.4,(top-base)/floors)
  69. # Match compact's deterministic longest-edge tie break. Door masks and
  70. # actual reservation must refer to the same surveyed wall after ring rotation.
  71. longest=min(range(len(ring)),key=lambda i:(
  72. -round(math.dist(ring[i],ring[(i+1)%len(ring)]),6),
  73. tuple(sorted((tuple(ring[i]),tuple(ring[(i+1)%len(ring)]))))))
  74. def hidden(edge,low,high):
  75. return high>low and any(edge in indices and low>=z0 and high<=z1
  76. for indices,z0,z1 in hosts)
  77. levels={}
  78. for level in ('lod0','lod1'):
  79. compact=level=='lod0'
  80. windows=set()
  81. for i in range(len(ring)):
  82. for floor in range(floors):
  83. low=base+floor*floor_height+floor_height*(.30 if compact else .34)
  84. high=min(top-.08,low+floor_height*(.45 if compact else .36))
  85. if hidden(i,low,high):
  86. windows.add((i,floor))
  87. bands={(i,floor) for i in range(len(ring)) for floor in range(1,floors)
  88. if hidden(i,base+floor*floor_height-.045,base+floor*floor_height+.045)}
  89. door_low=base+.08
  90. door_high=min(top-.05,base+min(2.2,max(1.8,floor_height*.76)))
  91. levels[level]={'windows':windows,'bands':bands,'door':hidden(longest,door_low,door_high)}
  92. masks[building['id']]=levels
  93. return masks