assemble-cad-bim.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  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. ROOT = Path(__file__).resolve().parents[2]
  21. parser = argparse.ArgumentParser()
  22. parser.add_argument('--plan', required=True)
  23. parser.add_argument('--output', required=True)
  24. parser.add_argument('--grid', type=int, default=32)
  25. parser.add_argument('--coarse-grid', type=int, default=0)
  26. parser.add_argument('--limit', type=int, default=0, help='export only the first N buildings')
  27. args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
  28. if args.grid < 2:
  29. raise RuntimeError('--grid must be at least 2')
  30. if args.coarse_grid and (args.coarse_grid < 2 or args.coarse_grid >= args.grid):
  31. raise RuntimeError('--coarse-grid must be at least 2 and smaller than --grid')
  32. PLAN = Path(args.plan).resolve()
  33. OUT = Path(args.output).resolve()
  34. LIBRARY_DIR = ROOT / 'tools/city-massing/house-library'
  35. plan = json.loads(PLAN.read_text(encoding='utf-8'))
  36. lib = json.loads((LIBRARY_DIR / 'library.json').read_text(encoding='utf-8'))
  37. entries = lib['archetypes']
  38. # Same asset-fit contract the placement generator used, so the two stages cannot disagree about
  39. # what counts as an acceptable match.
  40. ASSET_FIT = json.loads((ROOT / 'tools/city-massing/asset-fit.json').read_text(encoding='utf-8'))['variants']
  41. # Scene variety without random re-rolls: a stable hash of the building id picks one wall tone and
  42. # one roof tone, so neighbours differ while the whole extrusion stays coherent.
  43. WALL_TONES = ('Plaster_Cream', 'Plaster_Ivory', 'Plaster_Sand', 'Concrete_Warm')
  44. ROOF_TONES = ('Roof_Slate', 'Roof_Slate_Light', 'Roof_Terracotta', 'Roof_Terra_Light')
  45. def select_asset(building):
  46. family = building.get('archetype') or 'house'
  47. prefix = {'house': 'house', 'apartment': 'slab', 'midrise': 'midrise'}.get(family, 'house')
  48. # A building may name the exact approved variants it was planned for; otherwise any variant of
  49. # its family that fits the shared scale window is eligible.
  50. preferred = building.get('preferredAssets') or []
  51. family_key = family if family in ASSET_FIT else prefix
  52. limits = ASSET_FIT.get(family_key) or ASSET_FIT.get(prefix) or ASSET_FIT['house']
  53. min_x, min_y = limits['minScale']
  54. max_x, max_y = limits['maxScale']
  55. candidates = []
  56. for entry in entries:
  57. if preferred:
  58. if entry['id'] not in preferred:
  59. continue
  60. elif not entry['id'].startswith(prefix):
  61. continue
  62. width, depth = entry['nominal_footprint_m']
  63. sx, sy = building['width'] / width, building['depth'] / depth
  64. if not (min_x <= sx <= max_x and min_y <= sy <= max_y):
  65. continue
  66. preference = preferred.index(entry['id']) if entry['id'] in preferred else len(preferred) + 1
  67. score = preference * 0.25 + abs(entry['floors'] - building['floors']) * 0.8 + abs(math.log(sx)) + abs(math.log(sy))
  68. candidates.append((score, entry, sx, sy))
  69. return min(candidates, key=lambda item: item[0]) if candidates else None
  70. # ---------------------------------------------------------------- polygon helpers
  71. def ring_area(points):
  72. """Signed XY area; positive means counter-clockwise."""
  73. return 0.5 * sum(points[i][0] * points[(i + 1) % len(points)][1]
  74. - points[(i + 1) % len(points)][0] * points[i][1] for i in range(len(points)))
  75. def as_ccw(points):
  76. """Winding fix: surveyed rings arrive CW or CCW, prisms need one known orientation."""
  77. return list(points) if ring_area(points) > 0 else list(reversed(points))
  78. def convex_hull(points):
  79. """Monotone chain; used as the cheap LOD1 outline for a massing prism."""
  80. ordered = sorted({(round(x, 6), round(y, 6)) for x, y in points})
  81. if len(ordered) < 3:
  82. return ordered
  83. def half(seq):
  84. out = []
  85. for point in seq:
  86. while len(out) >= 2 and ((out[-1][0] - out[-2][0]) * (point[1] - out[-2][1])
  87. - (out[-1][1] - out[-2][1]) * (point[0] - out[-2][0])) <= 0:
  88. out.pop()
  89. out.append(point)
  90. return out
  91. return half(ordered)[:-1] + half(reversed(ordered))[:-1]
  92. def prism_triangles(n):
  93. """n-gon prism: two n-gon caps (2 * (n - 2)) plus n wall quads (2n)."""
  94. return 4 * n - 4
  95. def poly_prism(footprint, bottom, top, wall_material, roof_material, anchor):
  96. """Build one extruded prism; the ring must already be CCW.
  97. Vertices are stored relative to `anchor` (the plan's positionENU) and the object is moved to it.
  98. That keeps local coordinates small — which the volume self-check needs — and makes the exported
  99. node translation equal the planned position, as the cell verification expects.
  100. """
  101. bpy.ops.object.select_all(action='DESELECT')
  102. bpy.ops.mesh.primitive_cube_add(size=1)
  103. obj = bpy.context.active_object
  104. mesh = obj.data
  105. mesh.clear_geometry()
  106. n = len(footprint)
  107. ax, ay, az = anchor
  108. vertices = [(x - ax, y - ay, bottom - az) for x, y in footprint] + \
  109. [(x - ax, y - ay, top - az) for x, y in footprint]
  110. # Exactly n wall quads plus two caps. Adding the walls a second time (in reverse) would make
  111. # every wall edge belong to four faces, so the mesh is non-manifold and recalc_face_normals
  112. # cannot orient it — which silently produced a negative volume for thin outlines.
  113. faces = [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)]
  114. faces += [tuple(range(n)), tuple(reversed(range(n, 2 * n)))]
  115. mesh.from_pydata(vertices, [], faces)
  116. obj.location = anchor
  117. # Blender settles the normals for this closed manifold surface; prism_volume_sign in make_cell
  118. # is the independent outward-normal check on the result.
  119. edit = bmesh.new()
  120. edit.from_mesh(mesh)
  121. bmesh.ops.recalc_face_normals(edit, faces=edit.faces)
  122. edit.to_mesh(mesh)
  123. edit.free()
  124. mesh.update()
  125. mesh.materials.append(wall_material)
  126. mesh.materials.append(roof_material)
  127. # Face order is n walls followed by the two caps; slot 0 is the wall tone, slot 1 the roof.
  128. mesh.polygons.foreach_set('material_index', [0] * n + [1, 1])
  129. mesh.update()
  130. return obj
  131. def prism_volume_sign(obj):
  132. """Volume of obj's closed surface read from Blender's own face normals.
  133. V = sum(centre . normal * area) / 3 is positive exactly when the normals point outward, which is
  134. the property the GLB export and every downstream renderer depend on. Local space is enough: the
  135. prisms are built unrotated and unscaled, and reading Blender's normals avoids re-deriving the
  136. winding sign by hand.
  137. """
  138. return sum(polygon.center.dot(polygon.normal) * polygon.area
  139. for polygon in obj.data.polygons) / 3
  140. def box(bounds):
  141. low, high = bounds
  142. centre = [(a + b) / 2 for a, b in zip(low, high)]
  143. half = [(b - a) / 2 for a, b in zip(low, high)]
  144. return [centre[0], centre[1], centre[2], half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
  145. # ---------------------------------------------------------------- plan intake
  146. def build_geometry(building):
  147. """Return (level -> (outline, triangles)) for one building, validating its outline."""
  148. outline = [(float(x), float(y)) for x, y in building['footprintENU']]
  149. if len(outline) < 3:
  150. raise RuntimeError(building['id'] + ' has fewer than 3 outline points')
  151. outline = as_ccw(outline)
  152. if building['kind'] == 'asset':
  153. # The library mesh is already authored in this ring; only the transform differs per level.
  154. return {level: (None, None) for level in ('lod0', 'lod1')}
  155. if ring_area(outline) <= 0:
  156. raise RuntimeError(building['id'] + ' outline is degenerate')
  157. if building['positionENU'][2] + building['heightM'] <= building['foundationBottom']:
  158. raise RuntimeError(building['id'] + ' massing prism has zero volume')
  159. hull = convex_hull(outline)
  160. if len(hull) < 3:
  161. raise RuntimeError(building['id'] + ' LOD1 hull collapsed')
  162. return {'lod0': (outline, prism_triangles(len(outline))),
  163. 'lod1': (hull, prism_triangles(len(hull)))}
  164. placements, rejected, expected_triangles = [], [], {}
  165. library_ids = {entry['id'] for entry in entries}
  166. for building in plan['buildings'][:args.limit or None]:
  167. if building['kind'] == 'asset':
  168. # A named variant that is not in the library is a broken plan, not a building to skip.
  169. unknown = set(building.get('preferredAssets') or []).difference(library_ids)
  170. if unknown:
  171. raise RuntimeError(f"{building['id']} names missing library objects: " + ', '.join(sorted(unknown)))
  172. choice = select_asset(building)
  173. if not choice:
  174. rejected.append(building['id'])
  175. continue
  176. _, entry, sx, sy = choice
  177. expected_triangles[building['id']] = None # filled from the library once templates load
  178. placements.append((building, entry, sx, sy))
  179. elif building['kind'] == 'massing':
  180. geometry = build_geometry(building)
  181. expected_triangles[building['id']] = {level: geometry[level][1] for level in geometry}
  182. placements.append((building, None, None, None))
  183. else:
  184. raise RuntimeError(building['id'] + ' has unknown kind ' + str(building['kind']))
  185. if not placements or len({b['id'] for b, _, _, _ in placements}) != len(placements):
  186. raise RuntimeError('The seated plan does not yield unique, nonempty placements')
  187. # ---------------------------------------------------------------- scene setup
  188. bpy.ops.wm.read_factory_settings(use_empty=True)
  189. bpy.context.scene.unit_settings.system = 'METRIC'
  190. bpy.context.scene.unit_settings.scale_length = 1
  191. OUT.mkdir(parents=True, exist_ok=True)
  192. for pattern in ('*-lod0.glb', '*-lod1.glb'):
  193. for stale in OUT.glob(pattern):
  194. stale.unlink()
  195. for stale in (OUT / 'root-anchor.glb', OUT / 'tileset.json', OUT / 'manifest.json'):
  196. if stale.exists():
  197. stale.unlink()
  198. stone = bpy.data.materials.new('Buried_foundation_stone')
  199. stone.diffuse_color = (0.32, 0.33, 0.32, 1)
  200. stone.use_nodes = True
  201. stone.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = stone.diffuse_color
  202. stone.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value = 0.92
  203. # The dimensions/height vary per foundation; scale a shared unit cube mesh per instance.
  204. foundation_mesh = bpy.data.meshes.new('Seated_foundation_unit_cube')
  205. foundation_mesh.from_pydata(
  206. [(-.5, -.5, -.5), (.5, -.5, -.5), (.5, .5, -.5), (-.5, .5, -.5),
  207. (-.5, -.5, .5), (.5, -.5, .5), (.5, .5, .5), (-.5, .5, .5)],
  208. [],
  209. [(3, 2, 1, 0), (4, 5, 6, 7), (0, 1, 5, 4),
  210. (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)],
  211. )
  212. foundation_mesh.materials.append(stone)
  213. # Load the placed library templates together so common materials retain their names. Massing
  214. # buildings reuse those same material datablocks, so no extra palette has to be authored here.
  215. required = {entry['joined_object'] for _, entry, _, _ in placements if entry}
  216. required.update(entry['lod1']['joined_object'] for _, entry, _, _ in placements if entry)
  217. with bpy.data.libraries.load(str(LIBRARY_DIR / 'house-library.blend'), link=False) as (src, dst):
  218. missing = required.difference(src.objects)
  219. if missing:
  220. raise RuntimeError('Missing Blender library templates: ' + ', '.join(sorted(missing)))
  221. dst.objects = sorted(required)
  222. templates = {obj.name: obj for obj in dst.objects if obj is not None}
  223. if len(templates) != len(required) or any(obj.type != 'MESH' or not obj.data.polygons
  224. for obj in templates.values()):
  225. raise RuntimeError('A required Blender template is empty')
  226. for building, entry, _, _ in placements:
  227. if entry:
  228. expected_triangles[building['id']] = {
  229. level: (entry['validation'] if level == 'lod0' else entry['lod1']['validation'])['triangles']
  230. for level in ('lod0', 'lod1')}
  231. # Append the whole library palette on top of the templates: templates that happen to share no
  232. # material would otherwise leave a massing tone name unavailable.
  233. with bpy.data.libraries.load(str(LIBRARY_DIR / 'house-library.blend'), link=False) as (src, dst):
  234. dst.materials = list(src.materials)
  235. missing_tones = set(WALL_TONES + ROOF_TONES).difference(bpy.data.materials.keys())
  236. if missing_tones:
  237. raise RuntimeError('Library is missing material tones: ' + ', '.join(sorted(missing_tones)))
  238. def id_seed(building_id):
  239. value = 2166136261
  240. for character in str(building_id):
  241. value = ((value ^ ord(character)) * 16777619) % 4294967296
  242. return value
  243. def massing_tone(building_id):
  244. seed = id_seed(building_id)
  245. return (bpy.data.materials[WALL_TONES[seed % len(WALL_TONES)]],
  246. bpy.data.materials[ROOF_TONES[(seed // len(WALL_TONES)) % len(ROOF_TONES)]])
  247. def material_remap(building_id):
  248. """Asset palette rotation, keyed by the same stable hash as the massing tones."""
  249. seed = id_seed(building_id)
  250. roof_shift = (seed // 3) % len(ROOF_TONES)
  251. wall_shift = seed % len(WALL_TONES)
  252. remap = {name: ROOF_TONES[(index + roof_shift) % len(ROOF_TONES)]
  253. for index, name in enumerate(ROOF_TONES)}
  254. remap.update({name: WALL_TONES[(index + wall_shift) % len(WALL_TONES)]
  255. for index, name in enumerate(WALL_TONES)})
  256. remap['Roof_Membrane'] = ROOF_TONES[roof_shift]
  257. return remap
  258. def template(entry, level):
  259. return templates[entry['joined_object'] if level == 'lod0' else entry['lod1']['joined_object']]
  260. def make_cell(group, level):
  261. collection = bpy.data.collections.new('Cad_' + level)
  262. bpy.context.scene.collection.children.link(collection)
  263. for building, entry, sx, sy in group:
  264. x, y, top = building['positionENU']
  265. bottom = building['foundationBottom']
  266. if entry:
  267. obj = template(entry, level).copy() # Object copy shares mesh data with siblings.
  268. obj.name = building['id']
  269. collection.objects.link(obj)
  270. obj.location = building['positionENU']
  271. obj.rotation_euler = (0, 0, building['heading'])
  272. obj.scale = (sx, sy, 1)
  273. obj['source_footprint'] = building.get('sourceId', building['id'])
  274. remap = material_remap(building['id'])
  275. for slot in obj.material_slots:
  276. if slot.material and slot.material.name in remap:
  277. material = bpy.data.materials.get(remap[slot.material.name])
  278. if material:
  279. slot.link = 'OBJECT'
  280. slot.material = material
  281. else:
  282. geometry = build_geometry(building)[level]
  283. outline = geometry[0]
  284. wall, roof = massing_tone(building['id'])
  285. top_z = top + building['heightM']
  286. obj = poly_prism(outline, bottom, top_z, wall, roof, building['positionENU'])
  287. # Self-check the prism is a solid with outward normals. If the ring somehow came out
  288. # clockwise, rebuild once reversed instead of silently emitting an inside-out building.
  289. if prism_volume_sign(obj) <= 0:
  290. bpy.data.meshes.remove(obj.data, do_unlink=True)
  291. obj = poly_prism(list(reversed(outline)), bottom, top_z, wall, roof, building['positionENU'])
  292. if prism_volume_sign(obj) <= 0:
  293. raise RuntimeError(building['id'] + ' massing prism is inside-out or flat')
  294. obj.name = building['id']
  295. for collection_link in list(obj.users_collection):
  296. collection_link.objects.unlink(obj)
  297. collection.objects.link(obj)
  298. # Buried foundation. An asset instance is a library mesh seated at floor level, so it needs
  299. # its own box down to foundationBottom. A massing prism already spans foundationBottom to the
  300. # roof, i.e. its buried foundation IS the prism; a rectangular box under an L-shaped or
  301. # rotated outline would poke outside the building and z-fight its walls. So one cell holds
  302. # two mesh nodes per asset placement and one per massing building.
  303. if entry:
  304. foundation = bpy.data.objects.new(building['id'] + '_buried_foundation', foundation_mesh)
  305. collection.objects.link(foundation)
  306. foundation.location = (x, y, (bottom + top) / 2)
  307. foundation.rotation_euler.z = building['heading']
  308. foundation.scale = (building['width'], building['depth'], top - bottom)
  309. bpy.context.view_layer.update()
  310. objects = list(collection.objects)
  311. corners = [obj.matrix_world @ Vector(corner) for obj in objects for corner in obj.bound_box]
  312. bounds = [[min(p[axis] for p in corners) for axis in range(3)],
  313. [max(p[axis] for p in corners) for axis in range(3)]]
  314. return collection, objects, bounds
  315. def export_cell(path, objects, group, level):
  316. bpy.ops.object.select_all(action='DESELECT')
  317. for obj in objects:
  318. obj.select_set(True)
  319. bpy.context.view_layer.objects.active = objects[0]
  320. bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True,
  321. export_yup=True, export_materials='EXPORT', export_extras=True)
  322. with path.open('rb') as stream:
  323. header = stream.read(20)
  324. if len(header) != 20 or header[:4] != b'glTF':
  325. raise RuntimeError('Invalid GLB export ' + str(path))
  326. _, version, length, chunk_length, chunk_type = struct.unpack('<4s4I', header)
  327. if version != 2 or length != path.stat().st_size or chunk_type != 0x4E4F534A:
  328. raise RuntimeError('Invalid GLB header ' + str(path))
  329. doc = json.loads(stream.read(chunk_length))
  330. # Asset instances are one node plus their buried foundation; a massing prism is a single node.
  331. expected_names = []
  332. for building, entry, _, _ in group:
  333. expected_names.append(building['id'])
  334. if entry:
  335. expected_names.append(building['id'] + '_buried_foundation')
  336. names = [n.get('name') for n in doc['nodes'] if 'mesh' in n]
  337. # One node per massing prism, two per asset placement (instance + buried foundation).
  338. if len(names) != len(expected_names):
  339. raise RuntimeError(f'{path.name}: {len(names)} mesh nodes for {len(expected_names)} '
  340. 'expected building/foundation nodes')
  341. if sorted(names) != sorted(expected_names):
  342. raise RuntimeError('GLB mesh nodes do not match placed buildings and foundations: ' + str(path))
  343. if set(doc['scenes'][doc.get('scene', 0)]['nodes']) != set(range(len(doc['nodes']))):
  344. raise RuntimeError('Unexpected parent transform in ' + str(path))
  345. nodes = {node['name']: node for node in doc['nodes']}
  346. for building, entry, sx, sy in group:
  347. x, y, top = building['positionENU']
  348. bottom = building['foundationBottom']
  349. checks = [(building['id'], top, (sx, 1, sy) if entry else None)]
  350. if entry:
  351. checks.append((building['id'] + '_buried_foundation', (bottom + top) / 2,
  352. (building['width'], top - bottom, building['depth'])))
  353. for name, centre, scale in checks:
  354. node = nodes[name]
  355. expected_pos = (x, centre, -y) # Blender Z-up to glTF Y-up, only once.
  356. if 'matrix' in node or any(abs(a - b) > .002 for a, b in zip(
  357. node.get('translation', (0, 0, 0)), expected_pos)):
  358. raise RuntimeError('Incorrect seated transform for ' + name + ' in ' + str(path))
  359. if scale and any(abs(a - b) > .002 for a, b in zip(node.get('scale', (1, 1, 1)), scale)):
  360. raise RuntimeError('Incorrect seated scale for ' + name + ' in ' + str(path))
  361. def triangles(mesh):
  362. return sum(doc['accessors'][part['indices']]['count'] // 3 if 'indices' in part
  363. else doc['accessors'][part['attributes']['POSITION']]['count'] // 3
  364. for part in mesh['primitives'])
  365. count = sum(triangles(doc['meshes'][node['mesh']]) for node in doc['nodes'] if 'mesh' in node)
  366. # Asset placements also carry a 12-triangle buried foundation box; massing prisms do not.
  367. reference = sum(expected_triangles[b['id']][level] + (12 if entry else 0) for b, entry, _, _ in group)
  368. if count != reference:
  369. raise RuntimeError(f'{path.name}: {count} triangles, expected {reference}')
  370. meshes = {node['mesh'] for node in doc['nodes'] if 'mesh' in node}
  371. # Per-building material remapping is an object-level material link, so a cell whose instances
  372. # were recoloured exports one mesh variant per building; shared mesh data is not asserted here.
  373. # `uniqueMeshes` is reported so a regression towards N meshes for N nodes stays visible.
  374. return {'uri': path.name, 'bytes': path.stat().st_size, 'triangles': count,
  375. 'meshNodes': len(names), 'uniqueMeshes': len(meshes)}
  376. # ---------------------------------------------------------------- spatial cells
  377. # Partition by positionENU into a --grid x --grid grid, then group cells into --coarse-grid parents,
  378. # exactly like the rectangular exporter so the same verifier accepts the tree.
  379. xs = [b['positionENU'][0] for b, _, _, _ in placements]
  380. ys = [b['positionENU'][1] for b, _, _, _ in placements]
  381. min_x, max_x = min(xs), max(xs)
  382. min_y, max_y = min(ys), max(ys)
  383. span_x = max(max_x - min_x, 1e-6)
  384. span_y = max(max_y - min_y, 1e-6)
  385. cells = {}
  386. for placement in placements:
  387. x, y, _ = placement[0]['positionENU']
  388. ix = min(args.grid - 1, int((x - min_x) / span_x * args.grid))
  389. iy = min(args.grid - 1, int((y - min_y) / span_y * args.grid))
  390. cells.setdefault(f'cell-{iy:02d}-{ix:02d}', []).append(placement)
  391. cells = {name: group for name, group in sorted(cells.items()) if group}
  392. if len(cells) < 2:
  393. raise RuntimeError('Not enough geographically separate building groups to demonstrate spatial LOD')
  394. hierarchical = bool(args.coarse_grid)
  395. coarse_groups, fine_coarse = {}, {}
  396. if hierarchical:
  397. for cell_id, group in cells.items():
  398. x, y, _ = group[0][0]['positionENU']
  399. ix = min(args.coarse_grid - 1, int((x - min_x) / span_x * args.coarse_grid))
  400. iy = min(args.coarse_grid - 1, int((y - min_y) / span_y * args.coarse_grid))
  401. coarse_id = f'coarse-{iy:02d}-{ix:02d}'
  402. fine_coarse[cell_id] = coarse_id
  403. coarse_groups.setdefault(coarse_id, []).extend(group)
  404. fine_tiles, manifest_cells, all_bounds = {}, [], []
  405. for cell_id, group in cells.items():
  406. exports, bounds_per_level = {}, []
  407. for level in ('lod1', 'lod0'):
  408. collection, objects, bounds = make_cell(group, level)
  409. path = OUT / f'{cell_id}-{level}.glb'
  410. exports[level] = export_cell(path, objects, group, level)
  411. bounds_per_level.append(bounds)
  412. bpy.data.collections.remove(collection, do_unlink=True)
  413. for obj in objects:
  414. bpy.data.objects.remove(obj, do_unlink=True)
  415. bounds = [[min(b[0][axis] for b in bounds_per_level) for axis in range(3)],
  416. [max(b[1][axis] for b in bounds_per_level) for axis in range(3)]]
  417. all_bounds.append(bounds)
  418. volume = {'box': box(bounds)}
  419. if hierarchical:
  420. fine_tiles[cell_id] = {'boundingVolume': volume, 'geometricError': 0,
  421. 'content': {'uri': exports['lod0']['uri']}}
  422. else:
  423. fine_tiles[cell_id] = {'boundingVolume': volume, 'geometricError': 8,
  424. 'refine': 'REPLACE', 'content': {'uri': exports['lod1']['uri']},
  425. 'children': [{'boundingVolume': volume, 'geometricError': 0,
  426. 'content': {'uri': exports['lod0']['uri']}}]}
  427. manifest_cells.append({'id': cell_id, 'count': len(group),
  428. 'buildingIds': [b['id'] for b, _, _, _ in group],
  429. 'boundsENU': {'min': bounds[0], 'max': bounds[1]}, **exports})
  430. if hierarchical:
  431. cell_tiles, manifest_coarse = [], []
  432. for coarse_id, group in coarse_groups.items():
  433. collection, objects, bounds = make_cell(group, 'lod1')
  434. path = OUT / f'{coarse_id}-lod1.glb'
  435. coarse_export = export_cell(path, objects, group, 'lod1')
  436. bpy.data.collections.remove(collection, do_unlink=True)
  437. for obj in objects:
  438. bpy.data.objects.remove(obj, do_unlink=True)
  439. children = [fine_tiles[cell_id] for cell_id, parent in fine_coarse.items() if parent == coarse_id]
  440. cell_tiles.append({'boundingVolume': {'box': box(bounds)}, 'geometricError': 16,
  441. 'refine': 'REPLACE', 'content': {'uri': coarse_export['uri']},
  442. 'children': children})
  443. manifest_coarse.append({'id': coarse_id, 'count': len(group), **coarse_export})
  444. else:
  445. cell_tiles = list(fine_tiles.values())
  446. manifest_coarse = []
  447. root_bounds = [[min(b[0][axis] for b in all_bounds) for axis in range(3)],
  448. [max(b[1][axis] for b in all_bounds) for axis in range(3)]]
  449. # The explicit root must be renderable to keep Cesium traversing its spatial children.
  450. anchor_json = json.dumps({'asset': {'version': '2.0'}, 'scene': 0, 'scenes': [{'nodes': []}]},
  451. separators=(',', ':')).encode('utf-8')
  452. anchor_json += b' ' * ((-len(anchor_json)) % 4)
  453. anchor = b'glTF' + struct.pack('<II', 2, 20 + len(anchor_json))
  454. anchor += struct.pack('<I', len(anchor_json)) + b'JSON' + anchor_json
  455. (OUT / 'root-anchor.glb').write_bytes(anchor)
  456. tileset = {'asset': {'version': '1.1', 'generator': 'Blender CAD/BIM massing LOD'},
  457. 'geometricError': 800,
  458. 'root': {'transform': plan['transform'], 'boundingVolume': {'box': box(root_bounds)},
  459. 'geometricError': 200, 'refine': 'REPLACE',
  460. 'content': {'uri': 'root-anchor.glb'}, 'children': cell_tiles}}
  461. lod1_records = manifest_coarse if hierarchical else [cell['lod1'] for cell in manifest_cells]
  462. lod0_records = [cell['lod0'] for cell in manifest_cells]
  463. manifest = {'scope': plan.get('scope', 'surveyed CAD/BIM massing sample; not full-city reconstruction'),
  464. 'origin': plan['origin'], 'notice': plan.get('notice'),
  465. 'count': len(placements), 'cellCount': len(cells), 'cellIds': list(cells),
  466. 'hierarchy': {'enabled': hierarchical, 'coarseGrid': args.coarse_grid if hierarchical else None,
  467. 'coarseCellCount': len(manifest_coarse)},
  468. 'excludedUnfittedIds': rejected,
  469. 'boundsENU': {'min': root_bounds[0], 'max': root_bounds[1]},
  470. 'totals': {level: {'bytes': sum(item['bytes'] for item in (lod1_records if level == 'lod1' else lod0_records)),
  471. 'triangles': sum(item['triangles'] for item in (lod1_records if level == 'lod1' else lod0_records))}
  472. for level in ('lod1', 'lod0')},
  473. 'coarseCells': manifest_coarse,
  474. 'cells': manifest_cells}
  475. (OUT / 'tileset.json').write_text(json.dumps(tileset, indent=2), encoding='utf-8')
  476. (OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
  477. print('CAD_BIM_LOD', json.dumps({'count': manifest['count'], 'cells': manifest['cellCount'],
  478. 'coarse': len(manifest_coarse), 'totals': manifest['totals'],
  479. 'output': str(OUT)}))