assemble-cad-bim.py 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162
  1. """Export a CAD/BIM seated plan as a spatial-LOD 3D Tiles tree.
  2. Run with Blender: blender --background --factory-startup --python-exit-code 1 \
  3. --python tools/city-massing/assemble-cad-bim.py -- \
  4. --plan tools/city-massing/cad-bim/seated-plan.json \
  5. --output tools/city-massing/cad-bim/lod-preview --grid 32 --coarse-grid 8
  6. Same output conventions as assemble-lod-district.py (GLB cells, `box()` bounding volumes,
  7. tileset.json/manifest.json, root-anchor.glb), so verify-lod-district.mjs still gates it.
  8. Difference: input footprints are surveyed polygons, so `kind == "massing"` buildings are
  9. built here as extruded prisms instead of being copied from the house library.
  10. """
  11. import json
  12. import math
  13. import struct
  14. import argparse
  15. import sys
  16. from pathlib import Path
  17. import bpy
  18. import bmesh
  19. from mathutils import Vector
  20. from mathutils.geometry import tessellate_polygon
  21. ROOT = Path(__file__).resolve().parents[2]
  22. parser = argparse.ArgumentParser()
  23. parser.add_argument('--plan', required=True)
  24. parser.add_argument('--output', required=True)
  25. parser.add_argument('--grid', type=int, default=32)
  26. parser.add_argument('--coarse-grid', type=int, default=0)
  27. parser.add_argument('--limit', type=int, default=0, help='export only the first N buildings')
  28. parser.add_argument('--decimate-lod1', type=float, default=1.0,
  29. help='Decimate ratio applied to library asset meshes at LOD1 (1.0 = off)')
  30. parser.add_argument('--decimate-lod0', type=float, default=1.0,
  31. help='Decimate ratio applied to library asset meshes at LOD0 (1.0 = off)')
  32. parser.add_argument('--merge-buildings', action='store_true',
  33. help='Bake each cell into ONE merged mesh (per-material primitives) instead of one node per building')
  34. parser.add_argument('--lod1-footprint-only', action='store_true',
  35. help='Use each surveyed GIS footprint for LOD1/coarse geometry, including asset buildings')
  36. parser.add_argument('--near-architecture', action='store_true', help='Add bounded window reveals and storey bands at LOD0')
  37. parser.add_argument('--massing-storey-bands', action='store_true', help='Experimental continuous storey bands on massing LOD0; requires a facade profile or --near-architecture')
  38. parser.add_argument('--massing-facade-profile', choices=('off', 'compact', 'full'), default='off',
  39. help='Opt-in facade quality for surveyed massing: compact windows/bands/eaves without deep reveals, or full near architecture')
  40. parser.add_argument('--compact-window-texture', help='Opt-in estimated window-frame texture manifest; compact profile only')
  41. parser.add_argument('--roof-textures', help='JSON mapping building IDs to sourced roof image paths and ENU UV bounds')
  42. parser.add_argument('--draco', action='store_true', help='Enable Draco mesh compression in GLB export')
  43. args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
  44. for ratio in (args.decimate_lod1, args.decimate_lod0):
  45. if not (0.0 < ratio <= 1.0):
  46. raise RuntimeError('--decimate-lod* ratios must be in (0, 1]')
  47. if args.grid < 2:
  48. raise RuntimeError('--grid must be at least 2')
  49. if args.coarse_grid and (args.coarse_grid < 2 or args.coarse_grid >= args.grid):
  50. raise RuntimeError('--coarse-grid must be at least 2 and smaller than --grid')
  51. PLAN = Path(args.plan).resolve()
  52. OUT = Path(args.output).resolve()
  53. LIBRARY_DIR = ROOT / 'tools/city-massing/house-library'
  54. plan = json.loads(PLAN.read_text(encoding='utf-8'))
  55. lib = json.loads((LIBRARY_DIR / 'library.json').read_text(encoding='utf-8'))
  56. entries = lib['archetypes']
  57. # Same asset-fit contract the placement generator used, so the two stages cannot disagree about
  58. # what counts as an acceptable match.
  59. ASSET_FIT = json.loads((ROOT / 'tools/city-massing/asset-fit.json').read_text(encoding='utf-8'))['variants']
  60. # Scene variety without random re-rolls: a stable hash of the building id picks one wall tone and
  61. # one roof tone, so neighbours differ while the whole extrusion stays coherent.
  62. WALL_TONES = ('Plaster_Cream', 'Plaster_Ivory', 'Plaster_Sand', 'Concrete_Warm')
  63. ROOF_TONES = ('Roof_Slate', 'Roof_Slate_Light', 'Roof_Terracotta', 'Roof_Terra_Light')
  64. ROOF_TEXTURES = json.loads(Path(args.roof_textures).read_text(encoding='utf-8')) if args.roof_textures else {}
  65. def facade_profile_flags(profile, near_architecture, storey_bands):
  66. """Resolve explicit massing quality without silently upgrading the default path."""
  67. if profile == 'full' and not near_architecture:
  68. raise RuntimeError('--massing-facade-profile full requires --near-architecture')
  69. if storey_bands and not (near_architecture or profile != 'off'):
  70. raise RuntimeError('--massing-storey-bands requires --near-architecture or a massing-facade-profile')
  71. return {
  72. 'profile': profile,
  73. 'architecture': near_architecture or profile in ('compact', 'full'),
  74. 'fullReveals': near_architecture or profile == 'full',
  75. # Compact already gets the ordinary per-edge storey rhythm; the extra
  76. # boundary band remains an explicit opt-in for controlled experiments.
  77. 'bands': storey_bands,
  78. }
  79. PROFILE_FLAGS = facade_profile_flags(args.massing_facade_profile, args.near_architecture, args.massing_storey_bands)
  80. FACADE_PROFILE = PROFILE_FLAGS['profile']
  81. FULL_REVEALS = PROFILE_FLAGS['fullReveals']
  82. ARCHITECTURE = PROFILE_FLAGS['architecture']
  83. BANDS = PROFILE_FLAGS['bands']
  84. WINDOW_TEXTURE = None
  85. if args.compact_window_texture:
  86. if FACADE_PROFILE != 'compact' or args.near_architecture:
  87. raise RuntimeError('--compact-window-texture requires compact without --near-architecture')
  88. sys.path.insert(0, str(Path(__file__).resolve().parent))
  89. from window_texture import load_window_texture, window_quad_uv
  90. WINDOW_TEXTURE = load_window_texture(args.compact_window_texture)
  91. def compact_scope_ids(plan, profile, full_reveals=False, bands=False):
  92. ids = plan.get('compactFacadeBuildingIds')
  93. if ids is None:
  94. return None
  95. if profile != 'compact' or full_reveals or bands:
  96. raise ValueError('compactFacadeBuildingIds requires compact without reveals or extra bands')
  97. if not isinstance(ids, list) or not ids or any(not isinstance(i, str) for i in ids) or len(ids) != len(set(ids)):
  98. raise ValueError('compactFacadeBuildingIds must be a nonempty unique ID list')
  99. records = {building['id']: building for building in plan['buildings']}
  100. if any(i not in records or records[i]['kind'] != 'massing' for i in ids):
  101. raise ValueError('compactFacadeBuildingIds must name existing massing records only')
  102. return frozenset(ids)
  103. COMPACT_BUILDING_IDS = compact_scope_ids(plan, FACADE_PROFILE, FULL_REVEALS, BANDS)
  104. def facade_operation(building, operation, *values, **options):
  105. """Use legacy facade rules for nonselected context; restore state even on failure."""
  106. global FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE
  107. if COMPACT_BUILDING_IDS is None or building['id'] in COMPACT_BUILDING_IDS:
  108. return operation(*values, **options)
  109. previous = FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE
  110. try:
  111. FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE = 'off', False, False, False, None
  112. return operation(*values, **options)
  113. finally:
  114. FACADE_PROFILE, ARCHITECTURE, FULL_REVEALS, BANDS, WINDOW_TEXTURE = previous
  115. def select_asset(building):
  116. family = building.get('archetype') or 'house'
  117. prefix = {'house': 'house', 'apartment': 'slab', 'midrise': 'midrise'}.get(family, 'house')
  118. # A building may name the exact approved variants it was planned for; otherwise any variant of
  119. # its family that fits the shared scale window is eligible.
  120. preferred = building.get('preferredAssets') or []
  121. family_key = family if family in ASSET_FIT else prefix
  122. limits = ASSET_FIT.get(family_key) or ASSET_FIT.get(prefix) or ASSET_FIT['house']
  123. min_x, min_y = limits['minScale']
  124. max_x, max_y = limits['maxScale']
  125. candidates = []
  126. for entry in entries:
  127. if preferred:
  128. if entry['id'] not in preferred:
  129. continue
  130. elif not entry['id'].startswith(prefix):
  131. continue
  132. width, depth = entry['nominal_footprint_m']
  133. sx, sy = building['width'] / width, building['depth'] / depth
  134. if not (min_x <= sx <= max_x and min_y <= sy <= max_y):
  135. continue
  136. preference = preferred.index(entry['id']) if entry['id'] in preferred else len(preferred) + 1
  137. score = preference * 0.25 + abs(entry['floors'] - building['floors']) * 0.8 + abs(math.log(sx)) + abs(math.log(sy))
  138. candidates.append((score, entry, sx, sy))
  139. return min(candidates, key=lambda item: item[0]) if candidates else None
  140. # ---------------------------------------------------------------- polygon helpers
  141. def ring_area(points):
  142. """Signed XY area; positive means counter-clockwise."""
  143. return 0.5 * sum(points[i][0] * points[(i + 1) % len(points)][1]
  144. - points[(i + 1) % len(points)][0] * points[i][1] for i in range(len(points)))
  145. def as_ccw(points):
  146. """Keep surveyed coordinates, removing consecutive duplicates at CAD centimetre precision."""
  147. ring = []
  148. for point in points:
  149. if not ring or math.hypot(point[0] - ring[-1][0], point[1] - ring[-1][1]) > 0.011:
  150. ring.append(point)
  151. if len(ring) > 1 and math.hypot(ring[0][0] - ring[-1][0], ring[0][1] - ring[-1][1]) <= 0.011:
  152. ring.pop()
  153. return ring if ring_area(ring) > 0 else list(reversed(ring))
  154. def convex_hull(points):
  155. """Monotone chain; used as the cheap LOD1 outline for a massing prism."""
  156. ordered = sorted({(round(x, 6), round(y, 6)) for x, y in points})
  157. if len(ordered) < 3:
  158. return ordered
  159. def half(seq):
  160. out = []
  161. for point in seq:
  162. while len(out) >= 2 and ((out[-1][0] - out[-2][0]) * (point[1] - out[-2][1])
  163. - (out[-1][1] - out[-2][1]) * (point[0] - out[-2][0])) <= 0:
  164. out.pop()
  165. out.append(point)
  166. return out
  167. return half(ordered)[:-1] + half(reversed(ordered))[:-1]
  168. def prism_triangles(n):
  169. """n-gon prism: two n-gon caps plus n wall quads."""
  170. return 4 * n - 4
  171. def facade_window_count(edge_length, level):
  172. """Small, bounded facade detail count; LOD1 keeps one readable window per edge."""
  173. if level == 'lod1':
  174. return 1
  175. # Compact LOD0 uses physical-scale bays. The cap bounds the work on unusually
  176. # long survey edges; walls beyond 105.6m still require a separate review.
  177. if FACADE_PROFILE == 'compact':
  178. return 0 if edge_length < 2.4 else max(1, min(32, int(edge_length / 3.2)))
  179. # Full near architecture gets a denser, still bounded facade rhythm; coarse LOD stays sparse.
  180. return max(1, min(20 if FULL_REVEALS else 3, int(edge_length / (2.4 if FULL_REVEALS else 3.5))))
  181. def facade_entrance_edge(footprint):
  182. """Compact ties use surveyed endpoint coordinates; legacy profiles keep their order."""
  183. if FACADE_PROFILE != 'compact':
  184. return max(range(len(footprint)), key=lambda i: math.dist(footprint[i], footprint[(i + 1) % len(footprint)]))
  185. return min(range(len(footprint)), key=lambda i: (
  186. -round(math.dist(footprint[i], footprint[(i + 1) % len(footprint)]), 6),
  187. tuple(sorted((tuple(footprint[i]), tuple(footprint[(i + 1) % len(footprint)]))))))
  188. def facade_panel_halfwidth(edge_length, width_ratio=0.42, physical_window=False):
  189. """Shared panel sizing for generated geometry and entrance-clearance checks."""
  190. return (min(0.8, edge_length * 0.5) if physical_window else
  191. min(edge_length * 0.22, max(0.45, edge_length / 3.5 * width_ratio)))
  192. def facade_window_positions(edge_length, level, reserve_entrance=False):
  193. """Keep upper-floor rhythm; reserve 15cm between compact ground windows and door."""
  194. count = facade_window_count(edge_length, level)
  195. positions = [(index + 1) / (count + 1) for index in range(count)]
  196. if not reserve_entrance or FACADE_PROFILE != 'compact' or not count:
  197. return positions
  198. span = edge_length / (count + 1) * 0.34
  199. window_half = facade_panel_halfwidth(2 * span, physical_window=level == 'lod0')
  200. door_half = facade_panel_halfwidth(min(edge_length, 3.5), width_ratio=0.72)
  201. clearance = door_half + window_half + 0.15
  202. adjusted = []
  203. for t in positions:
  204. offset = (t - 0.5) * edge_length
  205. if abs(offset) >= clearance:
  206. adjusted.append(t)
  207. continue
  208. if abs(offset) < 1e-9:
  209. continue # a centered window occupies the entrance; keep the upper one
  210. center = edge_length * 0.5 + math.copysign(clearance, offset)
  211. # Do not solve a door conflict by making a wall-end or neighboring-window conflict.
  212. if center - window_half < 0.15 or center + window_half > edge_length - 0.15:
  213. continue
  214. if any(abs(center - other * edge_length) < 2 * window_half + 0.15
  215. for other in positions if other != t):
  216. continue
  217. adjusted.append(center / edge_length)
  218. return adjusted
  219. def massing_triangles(footprint, floors, level, facade_scope='building', facade_mask=None):
  220. """Reference triangles for a massing prism plus lightweight facade details."""
  221. if facade_scope not in ('building', 'shell-only'):
  222. raise ValueError('unknown massing facade scope: ' + str(facade_scope))
  223. base = prism_triangles(len(footprint))
  224. if facade_scope == 'shell-only':
  225. return base
  226. mask = facade_mask or {}
  227. hidden_windows = mask.get('windows', set())
  228. hidden_bands = mask.get('bands', set())
  229. longest = facade_entrance_edge(footprint)
  230. windows = 0
  231. for i in range(len(footprint)):
  232. a, b = footprint[i], footprint[(i + 1) % len(footprint)]
  233. length = math.hypot(b[0] - a[0], b[1] - a[1])
  234. count = facade_window_count(length, level)
  235. # add_panel/add_window_reveal reject a sub-0.35m window span. Surveyed
  236. # cornices/notches have short edges; they remain in the solid, without windows.
  237. if length / (count + 1) * 0.68 >= 0.35:
  238. windows += 2 * sum(len(facade_window_positions(length, level,
  239. reserve_entrance=i == longest and floor == 0 and not mask.get('door')))
  240. for floor in range(floors) if (i, floor) not in hidden_windows)
  241. roof_edges = sum(math.hypot(footprint[(i + 1) % len(footprint)][0] - a[0], footprint[(i + 1) % len(footprint)][1] - a[1]) >= .35 for i, a in enumerate(footprint)) if FULL_REVEALS else 0
  242. storey_bands = sum((i, floor) not in hidden_bands for i in range(len(footprint))
  243. for floor in range(1, floors))
  244. bands = storey_bands if BANDS and ARCHITECTURE and level == 'lod0' else 0
  245. architectural = ((windows * 4 if FULL_REVEALS else 0)
  246. + 2 * storey_bands
  247. + 2 * roof_edges + bands * 2) if ARCHITECTURE and level == 'lod0' else 0
  248. return base + windows + architectural + (0 if mask.get('door') else 12)
  249. def poly_prism(footprint, bottom, top, wall_material, roof_material, anchor, floors, level,
  250. window_material, door_material, facade_scope='building', facade_mask=None):
  251. """Build a surveyed-outline building with bounded facade details.
  252. The solid uses the exact GIS ring. Windows are shallow, outward-facing quads repeated per
  253. floor and door is one first-floor quad on the longest wall. They add a readable facade without
  254. adding separate Blender objects or changing the building footprint.
  255. """
  256. if facade_scope not in ('building', 'shell-only'):
  257. raise ValueError('unknown massing facade scope: ' + str(facade_scope))
  258. mask = facade_mask or {}
  259. hidden_windows = mask.get('windows', set())
  260. hidden_bands = mask.get('bands', set())
  261. # Create the mesh directly: repeated scene-wide select/cube operators make city exports quadratic.
  262. # This is an exterior-only massing shell: no rooms, floors, ceilings, or interior props.
  263. mesh = bpy.data.meshes.new('Surveyed_building')
  264. obj = bpy.data.objects.new('Surveyed_building', mesh)
  265. n = len(footprint)
  266. ax, ay, az = anchor
  267. vertices = [(x - ax, y - ay, bottom - az) for x, y in footprint] + \
  268. [(x - ax, y - ay, top - az) for x, y in footprint]
  269. faces = [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)]
  270. materials = [0] * n
  271. # Explicit caps survive merging/export and also handle concave surveyed outlines.
  272. # Use the same footprint vertices; no hull, overhang or XY displacement is introduced.
  273. cap_points = [Vector((x - ax, y - ay, 0)) for x, y in footprint]
  274. cap_indices = {tuple(point): i for i, point in enumerate(cap_points)}
  275. roof_area = 0.0
  276. for triangle in tessellate_polygon([cap_points]):
  277. # Blender 5.2 returns point indices; earlier versions returned Vector triples.
  278. indices = [point if isinstance(point, int) else cap_indices[tuple(point)] for point in triangle]
  279. area = ring_area([footprint[index] for index in indices])
  280. if abs(area) < 1e-10:
  281. continue
  282. if area < 0:
  283. indices.reverse()
  284. faces.append(tuple(n + index for index in indices)) # roof points up
  285. materials.append(1)
  286. faces.append(tuple(reversed(indices))) # buried bottom points down
  287. materials.append(0)
  288. roof_area += abs(area)
  289. # GIS vertices are rounded to centimetres. One real ring contains a 1cm backtrack and its
  290. # signed area differs by 0.08m² from the filled roof. Bound that tolerance by source precision,
  291. # instead of accepting missing caps or moving the whole surveyed footprint.
  292. perimeter = sum(math.hypot(footprint[(i + 1) % n][0] - p[0],
  293. footprint[(i + 1) % n][1] - p[1]) for i, p in enumerate(footprint))
  294. tolerance = max(0.001, roof_area * 1e-6, perimeter * 0.01)
  295. if abs(roof_area - ring_area(footprint)) > tolerance:
  296. raise RuntimeError('Triangulated roof does not cover its GIS footprint')
  297. floor_base = max(bottom, az) if ARCHITECTURE else bottom
  298. floor_height = max(2.4, (top - floor_base) / max(1, floors))
  299. def edge_panel_points(a, b, z0, z1, inset, width_ratio=0.42, physical_window=False):
  300. dx, dy = b[0] - a[0], b[1] - a[1]
  301. length = math.hypot(dx, dy)
  302. if length < 0.35 or z1 <= z0:
  303. return None
  304. nx, ny = dy / length, -dx / length
  305. half = facade_panel_halfwidth(length, width_ratio, physical_window)
  306. cx, cy = (a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5
  307. tx, ty = dx / length, dy / length
  308. return [(cx - tx * half + nx * inset, cy - ty * half + ny * inset),
  309. (cx + tx * half + nx * inset, cy + ty * half + ny * inset)]
  310. def add_panel(a, b, z0, z1, material_index, inset=0.035):
  311. points = edge_panel_points(a, b, z0, z1, inset,
  312. physical_window=FACADE_PROFILE == 'compact' and level == 'lod0')
  313. if not points:
  314. return
  315. base = len(vertices)
  316. vertices.extend([(x - ax, y - ay, z - az) for x, y in
  317. [(points[0][0], points[0][1]), (points[1][0], points[1][1])]
  318. for z in (z0, z1)])
  319. faces.append((base, base + 2, base + 3, base + 1))
  320. materials.append(material_index)
  321. def add_window_reveal(a, b, z0, z1):
  322. # Four solid-depth reveal strips around glass; no unsupported balcony/roof invention.
  323. inner = edge_panel_points(a, b, z0, z1, 0.035)
  324. outer = edge_panel_points(a, b, z0, z1, 0.16, 0.52)
  325. if not inner or not outer:
  326. return
  327. base = len(vertices)
  328. vertices.extend([(x - ax, y - ay, z - az) for points, low, high in
  329. ((outer, z0 - 0.10, z1 + 0.10), (inner, z0, z1))
  330. for x, y in points for z in (low, high)])
  331. faces.extend([(base, base + 2, base + 6, base + 4),
  332. (base + 1, base + 5, base + 7, base + 3),
  333. (base, base + 4, base + 5, base + 1),
  334. (base + 2, base + 3, base + 7, base + 6)])
  335. materials.extend([0] * 4)
  336. def add_box_panel(a, b, z0, z1, material_index):
  337. """Closed shallow door box; keeps a door material through merged GLB export."""
  338. if ARCHITECTURE:
  339. # Entrance width is architectural scale, not a fraction of a 70m apartment facade.
  340. dx, dy = b[0] - a[0], b[1] - a[1]
  341. length = math.hypot(dx, dy) or 1
  342. cx, cy = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
  343. half = min(length, 3.5) / 2
  344. a = (cx - dx / length * half, cy - dy / length * half)
  345. b = (cx + dx / length * half, cy + dy / length * half)
  346. points = edge_panel_points(a, b, z0, z1, 0.055, 0.72)
  347. if not points:
  348. return
  349. dx, dy = b[0] - a[0], b[1] - a[1]
  350. length = math.hypot(dx, dy) or 1
  351. nx, ny = dy / length, -dx / length
  352. outer = 0.14
  353. inner = 0.045
  354. p = points
  355. base = len(vertices)
  356. for depth in (inner, outer):
  357. vertices.extend([(p[0][0] - ax + nx * depth, p[0][1] - ay + ny * depth, z - az)
  358. for z in (z0, z1)])
  359. vertices.extend([(p[1][0] - ax + nx * depth, p[1][1] - ay + ny * depth, z - az)
  360. for z in (z0, z1)])
  361. faces.extend([(base, base + 1, base + 3, base + 2),
  362. (base + 4, base + 6, base + 7, base + 5),
  363. (base, base + 4, base + 5, base + 1),
  364. (base + 2, base + 3, base + 7, base + 6),
  365. (base, base + 2, base + 6, base + 4),
  366. (base + 1, base + 5, base + 7, base + 3)])
  367. materials.extend([material_index] * 6)
  368. longest = facade_entrance_edge(footprint)
  369. # Reviewed attachments retain closed GIS geometry without independent house details.
  370. for i in range(n if facade_scope == 'building' else 0):
  371. a, b = footprint[i], footprint[(i + 1) % n]
  372. count = facade_window_count(math.hypot(b[0] - a[0], b[1] - a[1]), level)
  373. dx, dy = b[0] - a[0], b[1] - a[1]
  374. for floor in range(max(1, floors)):
  375. if (i, floor) in hidden_windows:
  376. continue
  377. compact_window = FACADE_PROFILE == 'compact' and level == 'lod0'
  378. z0 = floor_base + floor * floor_height + floor_height * (0.30 if compact_window else 0.34)
  379. z1 = min(top - 0.08, z0 + floor_height * (0.45 if compact_window else 0.36))
  380. for t in facade_window_positions(math.hypot(dx, dy), level,
  381. reserve_entrance=i == longest and floor == 0 and not mask.get('door')):
  382. center = (a[0] + dx * t, a[1] + dy * t)
  383. span = math.hypot(dx, dy) / (count + 1) * 0.34
  384. wa = (center[0] - dx / (math.hypot(dx, dy) or 1) * span,
  385. center[1] - dy / (math.hypot(dx, dy) or 1) * span)
  386. wb = (center[0] + dx / (math.hypot(dx, dy) or 1) * span,
  387. center[1] + dy / (math.hypot(dx, dy) or 1) * span)
  388. add_panel(wa, wb, z0, z1, 2)
  389. if FULL_REVEALS and level == 'lod0':
  390. add_window_reveal(wa, wb, z0, z1)
  391. if ARCHITECTURE and level == 'lod0':
  392. for floor in range(1, max(1, floors)):
  393. if (i, floor) in hidden_bands:
  394. continue
  395. z = floor_base + floor * floor_height
  396. # A thin full-edge storey band uses the darker door tone, not extra objects.
  397. length = math.hypot(dx, dy) or 1
  398. nx, ny = dy / length, -dx / length
  399. base = len(vertices)
  400. vertices.extend([(p[0] - ax + nx * 0.025, p[1] - ay + ny * 0.025, zz - az)
  401. for p in (a, b) for zz in (z - 0.045, z + 0.045)])
  402. faces.append((base, base + 2, base + 3, base + 1))
  403. materials.append(3)
  404. if facade_scope == 'building' and BANDS and ARCHITECTURE and level == 'lod0':
  405. for i in range(n):
  406. a, b = footprint[i], footprint[(i + 1) % n]
  407. dx, dy = b[0] - a[0], b[1] - a[1]
  408. length = math.hypot(dx, dy)
  409. if length < .35: continue
  410. nx, ny = dy / length, -dx / length
  411. for floor in range(1, max(1, floors)):
  412. if (i, floor) in hidden_bands:
  413. continue
  414. z = floor_base + floor * floor_height
  415. inset = .028
  416. base = len(vertices)
  417. vertices.extend([(a[0] - ax + nx * inset, a[1] - ay + ny * inset, z - .035 - az),
  418. (b[0] - ax + nx * inset, b[1] - ay + ny * inset, z - .035 - az),
  419. (b[0] - ax + nx * inset, b[1] - ay + ny * inset, z + .035 - az),
  420. (a[0] - ax + nx * inset, a[1] - ay + ny * inset, z + .035 - az)])
  421. faces.append((base, base + 2, base + 3, base + 1)); materials.append(3)
  422. if facade_scope == 'building' and FULL_REVEALS and level == 'lod0':
  423. for i in range(n):
  424. a, b = footprint[i], footprint[(i + 1) % n]
  425. length = math.hypot(b[0] - a[0], b[1] - a[1])
  426. if length < 0.35: continue
  427. dx, dy = b[0] - a[0], b[1] - a[1]
  428. norm = math.hypot(dx, dy) or 1
  429. nx, ny = dy / norm, -dx / norm
  430. inset = 0.045
  431. low, high = top - 0.18, top + 0.08
  432. base = len(vertices)
  433. vertices.extend([(a[0] - ax + nx * inset, a[1] - ay + ny * inset, low - az),
  434. (b[0] - ax + nx * inset, b[1] - ay + ny * inset, low - az),
  435. (b[0] - ax + nx * inset, b[1] - ay + ny * inset, high - az),
  436. (a[0] - ax + nx * inset, a[1] - ay + ny * inset, high - az)])
  437. faces.append((base, base + 1, base + 2, base + 3)); materials.append(0)
  438. # One door on the original longest wall, visible from at least one approach direction.
  439. edge_a = footprint[longest]
  440. edge_b = footprint[(longest + 1) % n]
  441. door_h = min(2.2, max(1.8, floor_height * 0.76))
  442. if facade_scope == 'building' and not mask.get('door'):
  443. add_box_panel(edge_a, edge_b, floor_base + 0.08, min(top - 0.05, floor_base + door_h), 3)
  444. mesh.from_pydata(vertices, [], faces)
  445. obj.location = anchor
  446. for material in (wall_material, roof_material, window_material, door_material):
  447. mesh.materials.append(material)
  448. mesh.polygons.foreach_set('material_index', materials)
  449. # This slot belongs to our authored window planes, never to library assets.
  450. obj['cad_authored_window_slot'] = 2
  451. edit = bmesh.new()
  452. edit.from_mesh(mesh)
  453. # An isolated plane has no volume from which bmesh can infer outside.
  454. # Keep authored windows in every profile, plus the existing compact planes.
  455. normal_faces = [face for face in edit.faces if not (
  456. (face.material_index == 2 or FACADE_PROFILE == 'compact')
  457. and all(edge.is_boundary for edge in face.edges))]
  458. bmesh.ops.recalc_face_normals(edit, faces=normal_faces)
  459. edit.to_mesh(mesh)
  460. edit.free()
  461. mesh.update()
  462. return obj
  463. def prism_volume_sign(obj):
  464. """Volume of obj's closed surface read from Blender's own face normals.
  465. V = sum(centre . normal * area) / 3 is positive exactly when the normals point outward, which is
  466. the property the GLB export and every downstream renderer depend on. Local space is enough: the
  467. prisms are built unrotated and unscaled, and reading Blender's normals avoids re-deriving the
  468. winding sign by hand.
  469. """
  470. return sum(polygon.center.dot(polygon.normal) * polygon.area
  471. for polygon in obj.data.polygons) / 3
  472. def box(bounds):
  473. low, high = bounds
  474. centre = [(a + b) / 2 for a, b in zip(low, high)]
  475. half = [(b - a) / 2 for a, b in zip(low, high)]
  476. return [centre[0], centre[1], centre[2], half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
  477. # ---------------------------------------------------------------- plan intake
  478. def build_geometry(building):
  479. """Return (level -> (outline, triangles)) for one building, validating its outline."""
  480. outline = [(float(x), float(y)) for x, y in building['footprintENU']]
  481. if len(outline) < 3:
  482. raise RuntimeError(building['id'] + ' has fewer than 3 outline points')
  483. outline = as_ccw(outline)
  484. if building['kind'] == 'asset' and not args.lod1_footprint_only:
  485. # The library mesh is already authored in this ring; only the transform differs per level.
  486. return {level: (None, None) for level in ('lod0', 'lod1')}
  487. if ring_area(outline) <= 0:
  488. raise RuntimeError(building['id'] + ' outline is degenerate')
  489. if building['positionENU'][2] + building['heightM'] <= building['foundationBottom']:
  490. raise RuntimeError(building['id'] + ' massing prism has zero volume')
  491. # Both runtime levels use the surveyed ring. LOD1 reduces facade detail, not the GIS footprint;
  492. # replacing an irregular ring with its hull was the source of visibly misplaced road-covering mass.
  493. scope = building.get('massingFacadeScope', 'building')
  494. masks = PARTY_WALL_MASKS.get(building['id'], {})
  495. return {'lod0': (outline, massing_triangles(outline, building['floors'], 'lod0', scope, masks.get('lod0'))),
  496. 'lod1': (outline, massing_triangles(outline, building['floors'], 'lod1', scope, masks.get('lod1')))}
  497. placements, rejected, expected_triangles = [], [], {}
  498. selected_input = plan['buildings'][:args.limit or None]
  499. PARTY_WALL_MASKS = {}
  500. if any(building.get('partyWallNeighbors') for building in selected_input):
  501. sys.path.insert(0, str(Path(__file__).resolve().parent))
  502. from party_wall_facades import party_wall_masks
  503. if FULL_REVEALS or BANDS:
  504. raise RuntimeError('Reviewed party-wall masks require compact without reveals or extra storey bands')
  505. PARTY_WALL_MASKS = party_wall_masks(selected_input, as_ccw, FACADE_PROFILE)
  506. if any('massingFacadeScope' in building for building in selected_input):
  507. # Blender's script runner does not always add the script directory to sys.path.
  508. sys.path.insert(0, str(Path(__file__).resolve().parent))
  509. from building_composition import validate_composition_scopes
  510. validate_composition_scopes(selected_input, FACADE_PROFILE)
  511. library_ids = {entry['id'] for entry in entries}
  512. for building in selected_input:
  513. if building['kind'] == 'asset':
  514. # A named variant that is not in the library is a broken plan, not a building to skip.
  515. unknown = set(building.get('preferredAssets') or []).difference(library_ids)
  516. if unknown:
  517. raise RuntimeError(f"{building['id']} names missing library objects: " + ', '.join(sorted(unknown)))
  518. choice = select_asset(building)
  519. if not choice:
  520. rejected.append(building['id'])
  521. continue
  522. _, entry, sx, sy = choice
  523. expected_triangles[building['id']] = None # filled from the library once templates load
  524. placements.append((building, entry, sx, sy))
  525. elif building['kind'] == 'massing':
  526. geometry = facade_operation(building, build_geometry, building)
  527. expected_triangles[building['id']] = {level: geometry[level][1] for level in geometry}
  528. placements.append((building, None, None, None))
  529. else:
  530. raise RuntimeError(building['id'] + ' has unknown kind ' + str(building['kind']))
  531. if not placements or len({b['id'] for b, _, _, _ in placements}) != len(placements):
  532. raise RuntimeError('The seated plan does not yield unique, nonempty placements')
  533. placed_ids = {building['id'] for building, _, _, _ in placements}
  534. for building, _, _, _ in placements:
  535. if building.get('massingFacadeScope') == 'shell-only' and building['composition']['primaryId'] not in placed_ids:
  536. raise RuntimeError('Attachment primary did not pass asset placement: ' + building['id'])
  537. # ---------------------------------------------------------------- scene setup
  538. bpy.ops.wm.read_factory_settings(use_empty=True)
  539. bpy.context.scene.unit_settings.system = 'METRIC'
  540. bpy.context.scene.unit_settings.scale_length = 1
  541. OUT.mkdir(parents=True, exist_ok=True)
  542. for pattern in ('*-lod0.glb', '*-lod1.glb'):
  543. for stale in OUT.glob(pattern):
  544. stale.unlink()
  545. for stale in (OUT / 'root-anchor.glb', OUT / 'tileset.json', OUT / 'manifest.json'):
  546. if stale.exists():
  547. stale.unlink()
  548. stone = bpy.data.materials.new('Buried_foundation_stone')
  549. stone.diffuse_color = (0.32, 0.33, 0.32, 1)
  550. stone.use_nodes = True
  551. stone.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = stone.diffuse_color
  552. stone.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value = 0.92
  553. # The dimensions/height vary per foundation; scale a shared unit cube mesh per instance.
  554. foundation_mesh = bpy.data.meshes.new('Seated_foundation_unit_cube')
  555. foundation_mesh.from_pydata(
  556. [(-.5, -.5, -.5), (.5, -.5, -.5), (.5, .5, -.5), (-.5, .5, -.5),
  557. (-.5, -.5, .5), (.5, -.5, .5), (.5, .5, .5), (-.5, .5, .5)],
  558. [],
  559. [(3, 2, 1, 0), (4, 5, 6, 7), (0, 1, 5, 4),
  560. (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)],
  561. )
  562. foundation_mesh.materials.append(stone)
  563. # Load the placed library templates together so common materials retain their names. Massing
  564. # buildings reuse those same material datablocks, so no extra palette has to be authored here.
  565. required = {entry['joined_object'] for _, entry, _, _ in placements if entry}
  566. required.update(entry['lod1']['joined_object'] for _, entry, _, _ in placements if entry)
  567. with bpy.data.libraries.load(str(LIBRARY_DIR / 'house-library.blend'), link=False) as (src, dst):
  568. missing = required.difference(src.objects)
  569. if missing:
  570. raise RuntimeError('Missing Blender library templates: ' + ', '.join(sorted(missing)))
  571. dst.objects = sorted(required)
  572. templates = {obj.name: obj for obj in dst.objects if obj is not None}
  573. if len(templates) != len(required) or any(obj.type != 'MESH' or not obj.data.polygons
  574. for obj in templates.values()):
  575. raise RuntimeError('A required Blender template is empty')
  576. # Asset-level reduction: a Decimate modifier on the TEMPLATE mesh collapses every placed instance
  577. # at once (object copies share mesh data). Ratios come from the CLI; 1.0 (default) leaves meshes
  578. # untouched. Collapsing an inset window frame to one sheet is acceptable at these viewing
  579. # distances; what must survive is the building silhouette, its openings as dark insets, and the
  580. # storey rhythm — the maintainer's acceptance bar for "looks like a building".
  581. DECIMATE_RATIOS = {'lod0': args.decimate_lod0, 'lod1': args.decimate_lod1}
  582. decimated_templates = {}
  583. if any(ratio < 1.0 for ratio in DECIMATE_RATIOS.values()):
  584. for name, obj in templates.items():
  585. level = 'lod0' if name.endswith('_LOD0') else 'lod1' if name.endswith('_LOD1') else None
  586. ratio = DECIMATE_RATIOS.get(level, 1.0)
  587. if ratio < 1.0:
  588. modifier = obj.modifiers.new('AssetDecimate', 'DECIMATE')
  589. modifier.ratio = ratio
  590. modifier.use_collapse_triangulate = True
  591. decimated_templates[name] = modifier
  592. has_asset_placements = any(entry for _, entry, _, _ in placements)
  593. if not decimated_templates and has_asset_placements:
  594. raise RuntimeError('Decimate requested but no template name ends with _LOD0/_LOD1')
  595. # A massing-only batch (no asset placements, e.g. the unfitted-rectangle tail) has no templates
  596. # to decimate — its prisms are already minimal, so Draco-only export is the correct behavior.
  597. if decimated_templates:
  598. # Library.json triangle counts no longer describe the decimated meshes: link the decimated
  599. # templates into the view layer, evaluate them, and measure the real triangulated counts.
  600. scratch = bpy.data.collections.new('Template_eval_scratch')
  601. bpy.context.scene.collection.children.link(scratch)
  602. for name in decimated_templates:
  603. scratch.objects.link(templates[name])
  604. bpy.context.view_layer.update()
  605. depsgraph = bpy.context.evaluated_depsgraph_get()
  606. evaluated_triangles = {}
  607. for name in decimated_templates:
  608. evaluated = templates[name].evaluated_get(depsgraph)
  609. mesh = evaluated.to_mesh()
  610. evaluated_triangles[name] = sum(len(polygon.vertices) - 2 for polygon in mesh.polygons)
  611. evaluated.to_mesh_clear()
  612. for building, entry, _, _ in placements:
  613. if entry:
  614. expected_triangles[building['id']] = {}
  615. for level in ('lod0', 'lod1'):
  616. if args.lod1_footprint_only and level == 'lod1':
  617. expected_triangles[building['id']][level] = facade_operation(building, build_geometry, building)[level][1]
  618. continue
  619. template_name = entry['joined_object'] if level == 'lod0' else entry['lod1']['joined_object']
  620. fallback = (entry['validation'] if level == 'lod0' else entry['lod1']['validation'])['triangles']
  621. expected_triangles[building['id']][level] = evaluated_triangles.get(template_name, fallback)
  622. else:
  623. for building, entry, _, _ in placements:
  624. if entry:
  625. expected_triangles[building['id']] = {
  626. level: (facade_operation(building, build_geometry, building)[level][1] if args.lod1_footprint_only and level == 'lod1'
  627. else (entry['validation'] if level == 'lod0' else entry['lod1']['validation'])['triangles'])
  628. for level in ('lod0', 'lod1')}
  629. # Append the whole library palette on top of the templates: templates that happen to share no
  630. # material would otherwise leave a massing tone name unavailable.
  631. with bpy.data.libraries.load(str(LIBRARY_DIR / 'house-library.blend'), link=False) as (src, dst):
  632. dst.materials = list(src.materials)
  633. missing_tones = set(WALL_TONES + ROOF_TONES + ('Glass_Dark', 'Door_Walnut')).difference(bpy.data.materials.keys())
  634. if missing_tones:
  635. raise RuntimeError('Library is missing material tones: ' + ', '.join(sorted(missing_tones)))
  636. def id_seed(building_id):
  637. value = 2166136261
  638. for character in str(building_id):
  639. value = ((value ^ ord(character)) * 16777619) % 4294967296
  640. return value
  641. def massing_tone(building_id):
  642. seed = id_seed(building_id)
  643. return (bpy.data.materials[WALL_TONES[seed % len(WALL_TONES)]],
  644. bpy.data.materials[ROOF_TONES[(seed // len(WALL_TONES)) % len(ROOF_TONES)]],
  645. bpy.data.materials['Glass_Dark'], bpy.data.materials['Door_Walnut'])
  646. def sourced_roof(building_id, fallback):
  647. record = ROOF_TEXTURES.get(building_id)
  648. if not record:
  649. return fallback
  650. name = 'SatelliteRoof_' + building_id
  651. material = bpy.data.materials.get(name)
  652. if material:
  653. return material
  654. material = bpy.data.materials.new(name)
  655. material.use_nodes = True
  656. shader = material.node_tree.nodes.get('Principled BSDF')
  657. shader.inputs['Roughness'].default_value = 0.85
  658. shader.inputs['Metallic'].default_value = 0.0
  659. texture = material.node_tree.nodes.new('ShaderNodeTexImage')
  660. texture.image = bpy.data.images.load(record['path'], check_existing=True)
  661. texture.extension = 'CLIP'
  662. material.node_tree.links.new(texture.outputs['Color'], shader.inputs['Base Color'])
  663. return material
  664. def compact_window_material():
  665. name = 'Compact_Window_Frame_' + WINDOW_TEXTURE['sha256'][:12]
  666. material = bpy.data.materials.get(name)
  667. if material:
  668. return material
  669. material = bpy.data.materials.new(name)
  670. material.use_nodes = True
  671. shader = material.node_tree.nodes.get('Principled BSDF')
  672. shader.inputs['Base Color'].default_value = (1, 1, 1, 1)
  673. shader.inputs['Roughness'].default_value = 0.65
  674. shader.inputs['Metallic'].default_value = 0.0
  675. texture = material.node_tree.nodes.new('ShaderNodeTexImage')
  676. texture.image = bpy.data.images.load(WINDOW_TEXTURE['path'], check_existing=True)
  677. texture.extension = 'CLIP'
  678. material.node_tree.links.new(texture.outputs['Color'], shader.inputs['Base Color'])
  679. return material
  680. def apply_window_uv(obj):
  681. faces = [face for face in obj.data.polygons if face.material_index == 2]
  682. if not faces:
  683. return
  684. uv = obj.data.uv_layers.active or obj.data.uv_layers.new(name='UVMap')
  685. for face in faces:
  686. points = [tuple(obj.data.vertices[obj.data.loops[index].vertex_index].co)
  687. for index in face.loop_indices]
  688. for index, value in zip(face.loop_indices, window_quad_uv(points)):
  689. uv.data[index].uv = value
  690. def apply_roof_uv(obj, building_id):
  691. record = ROOF_TEXTURES.get(building_id)
  692. if not record:
  693. return
  694. low, high = record['boundsENU']
  695. uv = obj.data.uv_layers.active or obj.data.uv_layers.new(name='UVMap')
  696. roof_loops = (obj.data.loops[index] for face in obj.data.polygons
  697. if face.material_index == 1 for index in face.loop_indices)
  698. for loop in roof_loops:
  699. point = obj.data.vertices[loop.vertex_index].co + obj.location
  700. uv.data[loop.index].uv = ((point.x - low[0]) / (high[0] - low[0]),
  701. (point.y - low[1]) / (high[1] - low[1]))
  702. def material_remap(building_id):
  703. """Asset palette rotation, keyed by the same stable hash as the massing tones."""
  704. seed = id_seed(building_id)
  705. roof_shift = (seed // 3) % len(ROOF_TONES)
  706. wall_shift = seed % len(WALL_TONES)
  707. remap = {name: ROOF_TONES[(index + roof_shift) % len(ROOF_TONES)]
  708. for index, name in enumerate(ROOF_TONES)}
  709. remap.update({name: WALL_TONES[(index + wall_shift) % len(WALL_TONES)]
  710. for index, name in enumerate(WALL_TONES)})
  711. remap['Roof_Membrane'] = ROOF_TONES[roof_shift]
  712. return remap
  713. def template(entry, level):
  714. return templates[entry['joined_object'] if level == 'lod0' else entry['lod1']['joined_object']]
  715. def make_cell(group, level):
  716. collection = bpy.data.collections.new('Cad_' + level)
  717. bpy.context.scene.collection.children.link(collection)
  718. for building, entry, sx, sy in group:
  719. x, y, top = building['positionENU']
  720. bottom = building['foundationBottom']
  721. if entry and not (args.lod1_footprint_only and level == 'lod1'):
  722. obj = template(entry, level).copy() # Object copy shares mesh data with siblings.
  723. obj.name = building['id']
  724. collection.objects.link(obj)
  725. obj.location = building['positionENU']
  726. obj.rotation_euler = (0, 0, building['heading'])
  727. obj.scale = (sx, sy, 1)
  728. obj['source_footprint'] = building.get('sourceId', building['id'])
  729. remap = material_remap(building['id'])
  730. for slot in obj.material_slots:
  731. if slot.material and slot.material.name in remap:
  732. material = bpy.data.materials.get(remap[slot.material.name])
  733. if material:
  734. slot.link = 'OBJECT'
  735. slot.material = material
  736. else:
  737. geometry = facade_operation(building, build_geometry, building)[level]
  738. outline = geometry[0]
  739. wall, roof, window, door = massing_tone(building['id'])
  740. textured_windows = (WINDOW_TEXTURE is not None and level == 'lod0'
  741. and (COMPACT_BUILDING_IDS is None or building['id'] in COMPACT_BUILDING_IDS)
  742. and building.get('massingFacadeScope', 'building') == 'building')
  743. if textured_windows:
  744. window = compact_window_material()
  745. if level == 'lod0':
  746. roof = sourced_roof(building['id'], roof)
  747. top_z = top + building['heightM']
  748. obj = facade_operation(building, poly_prism, outline, bottom, top_z, wall, roof, building['positionENU'],
  749. max(1, int(building.get('floors') or round(building['heightM'] / 3.2))), level,
  750. window, door, facade_scope=building.get('massingFacadeScope', 'building'),
  751. facade_mask=PARTY_WALL_MASKS.get(building['id'], {}).get(level))
  752. # Self-check the prism is a solid with outward normals. If the ring somehow came out
  753. # clockwise, rebuild once reversed instead of silently emitting an inside-out building.
  754. if prism_volume_sign(obj) <= 0:
  755. bpy.data.meshes.remove(obj.data, do_unlink=True)
  756. if building['id'] in PARTY_WALL_MASKS:
  757. raise RuntimeError('Reviewed party-wall prism needs normal correction; do not reuse reversed edge masks: ' + building['id'])
  758. obj = facade_operation(building, poly_prism, list(reversed(outline)), bottom, top_z, wall, roof, building['positionENU'],
  759. max(1, int(building.get('floors') or round(building['heightM'] / 3.2))), level,
  760. window, door, facade_scope=building.get('massingFacadeScope', 'building'),
  761. facade_mask=PARTY_WALL_MASKS.get(building['id'], {}).get(level))
  762. if prism_volume_sign(obj) <= 0:
  763. raise RuntimeError(building['id'] + ' massing prism is inside-out or flat')
  764. if textured_windows:
  765. apply_window_uv(obj)
  766. if level == 'lod0':
  767. apply_roof_uv(obj, building['id'])
  768. obj.name = building['id']
  769. for collection_link in list(obj.users_collection):
  770. collection_link.objects.unlink(obj)
  771. collection.objects.link(obj)
  772. # Buried foundation. An asset instance is a library mesh seated at floor level, so it needs
  773. # its own box down to foundationBottom. A massing prism already spans foundationBottom to the
  774. # roof, i.e. its buried foundation IS the prism; a rectangular box under an L-shaped or
  775. # rotated outline would poke outside the building and z-fight its walls. So one cell holds
  776. # two mesh nodes per asset placement and one per massing building.
  777. if entry and not (args.lod1_footprint_only and level == 'lod1'):
  778. foundation = bpy.data.objects.new(building['id'] + '_buried_foundation', foundation_mesh)
  779. collection.objects.link(foundation)
  780. foundation.location = (x, y, (bottom + top) / 2)
  781. foundation.rotation_euler.z = building['heading']
  782. foundation.scale = (building['width'], building['depth'], top - bottom)
  783. bpy.context.view_layer.update()
  784. objects = list(collection.objects)
  785. if args.merge_buildings and objects:
  786. objects = merge_cell_objects(objects, collection)
  787. corners = [obj.matrix_world @ Vector(corner) for obj in objects for corner in obj.bound_box]
  788. bounds = [[min(p[axis] for p in corners) for axis in range(3)],
  789. [max(p[axis] for p in corners) for axis in range(3)]]
  790. return collection, objects, bounds
  791. def merge_cell_objects(objects, collection):
  792. """Bake every building/foundation in the cell into ONE mesh.
  793. The per-building export produces a separate node and material primitives per placement.
  794. Merging reduces that structural cost to one primitive per used material; actual frame-time
  795. improvement still requires a runtime measurement. The decimate modifier is baked via the
  796. depsgraph. Transforms are baked into ENU vertices; normals are recalculated with flat shading.
  797. Material slots are preserved. Roof/window appearance must still be visually reviewed.
  798. """
  799. bpy.context.view_layer.update()
  800. depsgraph = bpy.context.evaluated_depsgraph_get()
  801. vertices, faces, face_material_names, face_uvs = [], [], [], []
  802. material_names = {}
  803. preserve_planar_faces = []
  804. scope_ids = globals().get('COMPACT_BUILDING_IDS')
  805. has_uv = False
  806. for obj in objects:
  807. evaluated = obj.evaluated_get(depsgraph)
  808. mesh = evaluated.to_mesh()
  809. has_uv = has_uv or mesh.uv_layers.active is not None
  810. matrix = obj.matrix_world.copy()
  811. base = len(vertices)
  812. authored_window_slot = obj.get('cad_authored_window_slot')
  813. for vertex in mesh.vertices:
  814. vertices.append(tuple(matrix @ vertex.co))
  815. for polygon in mesh.polygons:
  816. corner_indices = tuple(base + index for index in polygon.vertices)
  817. # Reject invalid faces with fewer than three distinct indices before rebuilding.
  818. if len(set(corner_indices)) < 3:
  819. continue
  820. faces.append(corner_indices)
  821. compact_plane = FACADE_PROFILE == 'compact' and (scope_ids is None or getattr(obj, 'name', '') in scope_ids)
  822. preserve_planar_faces.append(compact_plane or polygon.material_index == authored_window_slot)
  823. uv_layer = mesh.uv_layers.active
  824. face_uvs.append([tuple(uv_layer.data[i].uv) if uv_layer else (0.0, 0.0)
  825. for i in polygon.loop_indices])
  826. material = obj.material_slots[polygon.material_index].material if obj.material_slots else None
  827. key = material.name if material else ''
  828. if key not in material_names:
  829. material_names[key] = len(material_names)
  830. face_material_names.append(key)
  831. evaluated.to_mesh_clear()
  832. merged_mesh = bpy.data.meshes.new('cell_merged')
  833. merged_mesh.from_pydata(vertices, [], faces)
  834. if ROOF_TEXTURES or (WINDOW_TEXTURE is not None and has_uv):
  835. uv_layer = merged_mesh.uv_layers.new(name='UVMap')
  836. for polygon, values in zip(merged_mesh.polygons, face_uvs):
  837. for loop_index, value in zip(polygon.loop_indices, values):
  838. uv_layer.data[loop_index].uv = value
  839. for name in material_names:
  840. merged_mesh.materials.append(bpy.data.materials[name])
  841. merged_mesh.polygons.foreach_set('material_index', [material_names[n] for n in face_material_names])
  842. edit = bmesh.new()
  843. edit.from_mesh(merged_mesh)
  844. # Preserve authored windows in every profile and the existing selected compact
  845. # planes. Assets/foundations without that provenance keep their normal path.
  846. normal_faces = list(edit.faces)
  847. if len(normal_faces) != len(preserve_planar_faces):
  848. raise RuntimeError('Merged face provenance changed')
  849. normal_faces = [face for face, preserve in zip(normal_faces, preserve_planar_faces)
  850. if not (preserve and all(edge.is_boundary for edge in face.edges))]
  851. bmesh.ops.recalc_face_normals(edit, faces=normal_faces)
  852. edit.to_mesh(merged_mesh)
  853. edit.free()
  854. merged_mesh.update()
  855. merged = bpy.data.objects.new('cell_merged', merged_mesh)
  856. collection.objects.link(merged)
  857. for obj in objects:
  858. bpy.data.objects.remove(obj, do_unlink=True)
  859. return [merged]
  860. def export_cell(path, objects, group, level):
  861. bpy.ops.object.select_all(action='DESELECT')
  862. for obj in objects:
  863. obj.select_set(True)
  864. bpy.context.view_layer.objects.active = objects[0]
  865. export_options = dict(filepath=str(path), export_format='GLB', use_selection=True,
  866. export_yup=True, export_materials='EXPORT', export_extras=True,
  867. # Bake modifiers (the asset Decimate) into the exported mesh; without
  868. # this the GLB would carry the full-detail template meshes again.
  869. export_apply=True)
  870. if args.draco:
  871. export_options['export_draco_mesh_compression_enable'] = True
  872. if ARCHITECTURE:
  873. # Surveyed roofs include near-collinear centimetre edges: default quantization can invert slivers.
  874. export_options['export_draco_position_quantization'] = 24
  875. bpy.ops.export_scene.gltf(**export_options)
  876. with path.open('rb') as stream:
  877. header = stream.read(20)
  878. if len(header) != 20 or header[:4] != b'glTF':
  879. raise RuntimeError('Invalid GLB export ' + str(path))
  880. _, version, length, chunk_length, chunk_type = struct.unpack('<4s4I', header)
  881. if version != 2 or length != path.stat().st_size or chunk_type != 0x4E4F534A:
  882. raise RuntimeError('Invalid GLB header ' + str(path))
  883. doc = json.loads(stream.read(chunk_length))
  884. # Asset instances are one node plus their buried foundation; a massing prism is a single node.
  885. if args.merge_buildings:
  886. # Merged cell: exactly one mesh node carries the whole cell. Per-building node identity
  887. # and seated transforms are baked into world-space vertices, so only the triangle total
  888. # (below) and the scene-root shape remain meaningful here.
  889. names = [n.get('name') for n in doc['nodes'] if 'mesh' in n]
  890. if len(names) != 1:
  891. raise RuntimeError(f'{path.name}: merged cell must contain exactly one mesh node')
  892. else:
  893. expected_names = []
  894. for building, entry, _, _ in group:
  895. expected_names.append(building['id'])
  896. if entry and not (args.lod1_footprint_only and level == 'lod1'):
  897. expected_names.append(building['id'] + '_buried_foundation')
  898. names = [n.get('name') for n in doc['nodes'] if 'mesh' in n]
  899. # One node per massing prism, two per asset placement (instance + buried foundation).
  900. if len(names) != len(expected_names):
  901. raise RuntimeError(f'{path.name}: {len(names)} mesh nodes for {len(expected_names)} '
  902. 'expected building/foundation nodes')
  903. if sorted(names) != sorted(expected_names):
  904. raise RuntimeError('GLB mesh nodes do not match placed buildings and foundations: ' + str(path))
  905. nodes = {node['name']: node for node in doc['nodes']}
  906. for building, entry, sx, sy in group:
  907. x, y, top = building['positionENU']
  908. bottom = building['foundationBottom']
  909. use_asset = entry and not (args.lod1_footprint_only and level == 'lod1')
  910. checks = [(building['id'], top, (sx, 1, sy) if use_asset else None)]
  911. if use_asset:
  912. checks.append((building['id'] + '_buried_foundation', (bottom + top) / 2,
  913. (building['width'], top - bottom, building['depth'])))
  914. for name, centre, scale in checks:
  915. node = nodes[name]
  916. expected_pos = (x, centre, -y) # Blender Z-up to glTF Y-up, only once.
  917. if 'matrix' in node or any(abs(a - b) > .002 for a, b in zip(
  918. node.get('translation', (0, 0, 0)), expected_pos)):
  919. raise RuntimeError('Incorrect seated transform for ' + name + ' in ' + str(path))
  920. if scale and any(abs(a - b) > .002 for a, b in zip(node.get('scale', (1, 1, 1)), scale)):
  921. raise RuntimeError('Incorrect seated scale for ' + name + ' in ' + str(path))
  922. if set(doc['scenes'][doc.get('scene', 0)]['nodes']) != set(range(len(doc['nodes']))):
  923. raise RuntimeError('Unexpected parent transform in ' + str(path))
  924. def triangles(mesh):
  925. return sum(doc['accessors'][part['indices']]['count'] // 3 if 'indices' in part
  926. else doc['accessors'][part['attributes']['POSITION']]['count'] // 3
  927. for part in mesh['primitives'])
  928. count = sum(triangles(doc['meshes'][node['mesh']]) for node in doc['nodes'] if 'mesh' in node)
  929. if args.merge_buildings:
  930. # Merged mode: the reference is the authored merged mesh itself (degenerate faces were
  931. # dropped at collection time, so export must reproduce it one-for-one). Per-building
  932. # seated transforms were verified by the identical make_cell code path in unmerged runs.
  933. reference = sum(len(polygon.vertices) - 2 for polygon in objects[0].data.polygons)
  934. else:
  935. # Asset placements also carry a 12-triangle buried foundation box; massing prisms do not.
  936. reference = sum(expected_triangles[b['id']][level] + (12 if entry and not (args.lod1_footprint_only and level == 'lod1') else 0)
  937. for b, entry, _, _ in group)
  938. if count != reference:
  939. raise RuntimeError(f'{path.name}: {count} triangles, expected {reference}')
  940. meshes = {node['mesh'] for node in doc['nodes'] if 'mesh' in node}
  941. # Per-building material remapping is an object-level material link, so a cell whose instances
  942. # were recoloured exports one mesh variant per building; shared mesh data is not asserted here.
  943. # `uniqueMeshes` is reported so a regression towards N meshes for N nodes stays visible.
  944. return {'uri': path.name, 'bytes': path.stat().st_size, 'triangles': count,
  945. 'meshNodes': len(names), 'uniqueMeshes': len(meshes),
  946. 'merged': bool(args.merge_buildings)}
  947. # ---------------------------------------------------------------- spatial cells
  948. # Partition by positionENU into a --grid x --grid grid, then group cells into --coarse-grid parents,
  949. # exactly like the rectangular exporter so the same verifier accepts the tree.
  950. xs = [b['positionENU'][0] for b, _, _, _ in placements]
  951. ys = [b['positionENU'][1] for b, _, _, _ in placements]
  952. min_x, max_x = min(xs), max(xs)
  953. min_y, max_y = min(ys), max(ys)
  954. span_x = max(max_x - min_x, 1e-6)
  955. span_y = max(max_y - min_y, 1e-6)
  956. cells = {}
  957. for placement in placements:
  958. x, y, _ = placement[0]['positionENU']
  959. ix = min(args.grid - 1, int((x - min_x) / span_x * args.grid))
  960. iy = min(args.grid - 1, int((y - min_y) / span_y * args.grid))
  961. cells.setdefault(f'cell-{iy:02d}-{ix:02d}', []).append(placement)
  962. cells = {name: group for name, group in sorted(cells.items()) if group}
  963. if len(cells) < 2:
  964. raise RuntimeError('Not enough geographically separate building groups to demonstrate spatial LOD')
  965. hierarchical = bool(args.coarse_grid)
  966. coarse_groups, fine_coarse = {}, {}
  967. if hierarchical:
  968. for cell_id, group in cells.items():
  969. x, y, _ = group[0][0]['positionENU']
  970. ix = min(args.coarse_grid - 1, int((x - min_x) / span_x * args.coarse_grid))
  971. iy = min(args.coarse_grid - 1, int((y - min_y) / span_y * args.coarse_grid))
  972. coarse_id = f'coarse-{iy:02d}-{ix:02d}'
  973. fine_coarse[cell_id] = coarse_id
  974. coarse_groups.setdefault(coarse_id, []).extend(group)
  975. fine_tiles, manifest_cells, all_bounds = {}, [], []
  976. for cell_id, group in cells.items():
  977. exports, bounds_per_level = {}, []
  978. for level in ('lod1', 'lod0'):
  979. collection, objects, bounds = make_cell(group, level)
  980. path = OUT / f'{cell_id}-{level}.glb'
  981. exports[level] = export_cell(path, objects, group, level)
  982. bounds_per_level.append(bounds)
  983. bpy.data.collections.remove(collection, do_unlink=True)
  984. for obj in objects:
  985. bpy.data.objects.remove(obj, do_unlink=True)
  986. bounds = [[min(b[0][axis] for b in bounds_per_level) for axis in range(3)],
  987. [max(b[1][axis] for b in bounds_per_level) for axis in range(3)]]
  988. all_bounds.append(bounds)
  989. volume = {'box': box(bounds)}
  990. if hierarchical:
  991. fine_tiles[cell_id] = {'boundingVolume': volume, 'geometricError': 0,
  992. 'content': {'uri': exports['lod0']['uri']}}
  993. else:
  994. fine_tiles[cell_id] = {'boundingVolume': volume, 'geometricError': 8,
  995. 'refine': 'REPLACE', 'content': {'uri': exports['lod1']['uri']},
  996. 'children': [{'boundingVolume': volume, 'geometricError': 0,
  997. 'content': {'uri': exports['lod0']['uri']}}]}
  998. manifest_cells.append({'id': cell_id, 'count': len(group),
  999. 'buildingIds': [b['id'] for b, _, _, _ in group],
  1000. 'boundsENU': {'min': bounds[0], 'max': bounds[1]}, **exports})
  1001. if hierarchical:
  1002. cell_tiles, manifest_coarse = [], []
  1003. for coarse_id, group in coarse_groups.items():
  1004. collection, objects, bounds = make_cell(group, 'lod1')
  1005. path = OUT / f'{coarse_id}-lod1.glb'
  1006. coarse_export = export_cell(path, objects, group, 'lod1')
  1007. bpy.data.collections.remove(collection, do_unlink=True)
  1008. for obj in objects:
  1009. bpy.data.objects.remove(obj, do_unlink=True)
  1010. children = [fine_tiles[cell_id] for cell_id, parent in fine_coarse.items() if parent == coarse_id]
  1011. cell_tiles.append({'boundingVolume': {'box': box(bounds)}, 'geometricError': 16,
  1012. 'refine': 'REPLACE', 'content': {'uri': coarse_export['uri']},
  1013. 'children': children})
  1014. manifest_coarse.append({'id': coarse_id, 'count': len(group), **coarse_export})
  1015. else:
  1016. cell_tiles = list(fine_tiles.values())
  1017. manifest_coarse = []
  1018. root_bounds = [[min(b[0][axis] for b in all_bounds) for axis in range(3)],
  1019. [max(b[1][axis] for b in all_bounds) for axis in range(3)]]
  1020. # The explicit root must be renderable to keep Cesium traversing its spatial children.
  1021. anchor_json = json.dumps({'asset': {'version': '2.0'}, 'scene': 0, 'scenes': [{'nodes': []}]},
  1022. separators=(',', ':')).encode('utf-8')
  1023. anchor_json += b' ' * ((-len(anchor_json)) % 4)
  1024. anchor = b'glTF' + struct.pack('<II', 2, 20 + len(anchor_json))
  1025. anchor += struct.pack('<I', len(anchor_json)) + b'JSON' + anchor_json
  1026. (OUT / 'root-anchor.glb').write_bytes(anchor)
  1027. tileset = {'asset': {'version': '1.1', 'generator': 'Blender CAD/BIM massing LOD'},
  1028. 'geometricError': 800,
  1029. 'root': {'transform': plan['transform'], 'boundingVolume': {'box': box(root_bounds)},
  1030. 'geometricError': 200, 'refine': 'REPLACE',
  1031. 'content': {'uri': 'root-anchor.glb'}, 'children': cell_tiles}}
  1032. lod1_records = manifest_coarse if hierarchical else [cell['lod1'] for cell in manifest_cells]
  1033. lod0_records = [cell['lod0'] for cell in manifest_cells]
  1034. manifest = {'scope': plan.get('scope', 'surveyed CAD/BIM massing sample; not full-city reconstruction'),
  1035. 'origin': plan['origin'], 'notice': plan.get('notice'),
  1036. 'provenance': {'source': plan.get('source'), 'plan': PLAN.name, 'planPath': str(PLAN),
  1037. 'nearArchitecture': ARCHITECTURE, 'massingFacadeProfile': FACADE_PROFILE,
  1038. 'compactFacadeBuildingIds': sorted(COMPACT_BUILDING_IDS) if COMPACT_BUILDING_IDS is not None else None,
  1039. 'massingFacadeLayout': 'physical-windows-entrance-v2' if FACADE_PROFILE == 'compact' else 'legacy-panels',
  1040. 'compactWindowTexture': ({key: WINDOW_TEXTURE[key] for key in
  1041. ('sha256', 'source', 'rasterBytes', 'rgba8FullMipEstimate')}
  1042. if WINDOW_TEXTURE else None),
  1043. 'shellOnlyAttachmentIds': [b['id'] for b, _, _, _ in placements
  1044. if b.get('massingFacadeScope') == 'shell-only'],
  1045. 'reviewedPartyWallIds': sorted(PARTY_WALL_MASKS),
  1046. 'partyWallReview': plan.get('partyWallReview'),
  1047. 'compositionReview': plan.get('compositionReview'),
  1048. 'estimatedMassingStoreyBands': BANDS,
  1049. 'satelliteRoofTextures': len(ROOF_TEXTURES),
  1050. 'facadeFidelity': 'procedural estimate; not facade photography or surveyed BIM',
  1051. 'gisFootprints': True, 'lod1FootprintOnly': bool(args.lod1_footprint_only),
  1052. 'facadeDetails': True, 'roadsProcedural': False, 'closedRoofCaps': True},
  1053. 'reduction': {'decimateLod0': args.decimate_lod0, 'decimateLod1': args.decimate_lod1,
  1054. 'draco': bool(args.draco)},
  1055. 'count': len(placements), 'cellCount': len(cells), 'cellIds': list(cells),
  1056. 'hierarchy': {'enabled': hierarchical, 'coarseGrid': args.coarse_grid if hierarchical else None,
  1057. 'coarseCellCount': len(manifest_coarse)},
  1058. 'excludedUnfittedIds': rejected,
  1059. 'boundsENU': {'min': root_bounds[0], 'max': root_bounds[1]},
  1060. 'totals': {level: {'bytes': sum(item['bytes'] for item in (lod1_records if level == 'lod1' else lod0_records)),
  1061. 'triangles': sum(item['triangles'] for item in (lod1_records if level == 'lod1' else lod0_records))}
  1062. for level in ('lod1', 'lod0')},
  1063. 'coarseCells': manifest_coarse,
  1064. 'cells': manifest_cells}
  1065. (OUT / 'tileset.json').write_text(json.dumps(tileset, indent=2), encoding='utf-8')
  1066. (OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
  1067. print('CAD_BIM_LOD', json.dumps({'count': manifest['count'], 'cells': manifest['cellCount'],
  1068. 'coarse': len(manifest_coarse), 'totals': manifest['totals'],
  1069. 'output': str(OUT)}))