assemble-lod-district.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. """Export the existing 37 accepted district placements as four spatial REPLACE LOD cells.
  2. Run with Blender: blender --background --factory-startup --python-exit-code 1 \
  3. --python tools/city-massing/assemble-lod-district.py -- \
  4. --district tools/city-massing/district --grid 2
  5. The original single-tile output remains independent and unchanged. Use a larger
  6. `--grid` for broad procedural districts so Cesium can cull distant cells.
  7. """
  8. import json
  9. import math
  10. import struct
  11. import argparse
  12. import sys
  13. from pathlib import Path
  14. import bpy
  15. from mathutils import Vector
  16. ROOT = Path(__file__).resolve().parents[2]
  17. parser = argparse.ArgumentParser()
  18. parser.add_argument('--district', default=str(ROOT / 'tools/city-massing/district'))
  19. parser.add_argument('--output', default=None)
  20. parser.add_argument('--grid', type=int, default=2)
  21. parser.add_argument('--coarse-grid', type=int, default=0)
  22. args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
  23. if args.grid < 2:
  24. raise RuntimeError('--grid must be at least 2')
  25. if args.coarse_grid and (args.coarse_grid < 2 or args.coarse_grid >= args.grid):
  26. raise RuntimeError('--coarse-grid must be at least 2 and smaller than --grid')
  27. DISTRICT = Path(args.district).resolve()
  28. OUT = Path(args.output).resolve() if args.output else DISTRICT / 'lod-preview'
  29. LIBRARY_DIR = ROOT / 'tools/city-massing/house-library'
  30. plan = json.loads((DISTRICT / 'seated-plan.json').read_text(encoding='utf-8'))
  31. lib = json.loads((LIBRARY_DIR / 'library.json').read_text(encoding='utf-8'))
  32. entries = lib['archetypes']
  33. # Same asset-fit contract the placement generator used, so the two stages cannot disagree about
  34. # what counts as an acceptable match.
  35. ASSET_FIT = json.loads((ROOT / 'tools/city-massing/asset-fit.json').read_text(encoding='utf-8'))['variants']
  36. def select_asset(building):
  37. family = building.get('archetype') or 'house'
  38. prefix = {'house': 'house', 'apartment': 'slab', 'midrise': 'midrise'}.get(family, 'house')
  39. # A building may name the exact approved variants it was planned for; otherwise any variant of
  40. # its family that fits the shared scale window is eligible.
  41. preferred = building.get('preferredAssets') or []
  42. family_key = family if family in ASSET_FIT else prefix
  43. limits = ASSET_FIT.get(family_key) or ASSET_FIT.get(prefix) or ASSET_FIT['house']
  44. min_x, min_y = limits['minScale']
  45. max_x, max_y = limits['maxScale']
  46. candidates = []
  47. for entry in entries:
  48. if preferred:
  49. if entry['id'] not in preferred:
  50. continue
  51. elif not entry['id'].startswith(prefix):
  52. continue
  53. width, depth = entry['nominal_footprint_m']
  54. sx, sy = building['width'] / width, building['depth'] / depth
  55. if not (min_x <= sx <= max_x and min_y <= sy <= max_y):
  56. continue
  57. preference = preferred.index(entry['id']) if entry['id'] in preferred else len(preferred) + 1
  58. score = preference * 0.25 + abs(entry['floors'] - building['floors']) * 0.8 + abs(math.log(sx)) + abs(math.log(sy))
  59. candidates.append((score, entry, sx, sy))
  60. return min(candidates, key=lambda item: item[0]) if candidates else None
  61. placements, rejected = [], []
  62. for building in plan['buildings']:
  63. choice = select_asset(building)
  64. if choice:
  65. _, entry, sx, sy = choice
  66. placements.append((building, entry, sx, sy))
  67. else:
  68. rejected.append(building['id'])
  69. if not placements or len({b['id'] for b, _, _, _ in placements}) != len(placements):
  70. raise RuntimeError('The seated plan does not yield unique, nonempty placements')
  71. source_plan = json.loads((DISTRICT / 'plan.json').read_text(encoding='utf-8'))
  72. # Plan v4 lists modules under `buildings` and their roots under `sources`; older plans list only
  73. # source footprints. Accept either so a module always resolves to a recorded source.
  74. sources = {source['id'] for source in source_plan.get('buildings', source_plan.get('roofs', []))}
  75. sources.update(source['id'] for source in source_plan.get('sources', []))
  76. sources.update(building.get('sourceId') for building in source_plan.get('buildings', []) if building.get('sourceId'))
  77. if any(b['sourceId'] not in sources or b['foundationBottom'] >= b['positionENU'][2]
  78. for b, _, _, _ in placements):
  79. raise RuntimeError('A placed building has no original source or invalid foundation seating')
  80. # Partition placements into deterministic spatial cells. The original 2x2 names are kept
  81. # for existing district and estate artifacts; larger grids use indexed cell names.
  82. if args.grid == 2:
  83. xs = sorted(b['positionENU'][0] for b, _, _, _ in placements)
  84. ys = sorted(b['positionENU'][1] for b, _, _, _ in placements)
  85. mid_x, mid_y = xs[len(xs)//2], ys[len(ys)//2]
  86. cells = {name: [] for name in ('south-west', 'south-east', 'north-west', 'north-east')}
  87. for placement in placements:
  88. x, y, _ = placement[0]['positionENU']
  89. cell_id = ('north' if y >= mid_y else 'south') + ('-east' if x >= mid_x else '-west')
  90. cells[cell_id].append(placement)
  91. else:
  92. xs = [b['positionENU'][0] for b, _, _, _ in placements]
  93. ys = [b['positionENU'][1] for b, _, _, _ in placements]
  94. min_x, max_x = min(xs), max(xs)
  95. min_y, max_y = min(ys), max(ys)
  96. span_x = max(max_x - min_x, 1e-6)
  97. span_y = max(max_y - min_y, 1e-6)
  98. cells = {}
  99. for placement in placements:
  100. x, y, _ = placement[0]['positionENU']
  101. ix = min(args.grid - 1, int((x - min_x) / span_x * args.grid))
  102. iy = min(args.grid - 1, int((y - min_y) / span_y * args.grid))
  103. cell_id = f'cell-{iy:02d}-{ix:02d}'
  104. cells.setdefault(cell_id, []).append(placement)
  105. cells = {name: group for name, group in cells.items() if group}
  106. if len(cells) < 2:
  107. raise RuntimeError('Not enough geographically separate building groups to demonstrate spatial LOD')
  108. bpy.ops.wm.read_factory_settings(use_empty=True)
  109. bpy.context.scene.unit_settings.system = 'METRIC'
  110. bpy.context.scene.unit_settings.scale_length = 1
  111. OUT.mkdir(parents=True, exist_ok=True)
  112. for stale in OUT.glob('*-lod0.glb'):
  113. stale.unlink()
  114. for stale in OUT.glob('*-lod1.glb'):
  115. stale.unlink()
  116. for stale in (OUT / 'root-anchor.glb', OUT / 'tileset.json', OUT / 'manifest.json'):
  117. if stale.exists():
  118. stale.unlink()
  119. stone = bpy.data.materials.new('Buried_foundation_stone')
  120. stone.diffuse_color = (0.32, 0.33, 0.32, 1)
  121. stone.use_nodes = True
  122. stone.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = stone.diffuse_color
  123. stone.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value = 0.92
  124. # The dimensions/height vary per foundation; scale a shared unit cube mesh per instance.
  125. foundation_mesh = bpy.data.meshes.new('Seated_foundation_unit_cube')
  126. foundation_mesh.from_pydata(
  127. [(-.5, -.5, -.5), (.5, -.5, -.5), (.5, .5, -.5), (-.5, .5, -.5),
  128. (-.5, -.5, .5), (.5, -.5, .5), (.5, .5, .5), (-.5, .5, .5)],
  129. [],
  130. [(3, 2, 1, 0), (4, 5, 6, 7), (0, 1, 5, 4),
  131. (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)],
  132. )
  133. foundation_mesh.materials.append(stone)
  134. # Load the eight placed library templates together so common materials retain their names.
  135. required = {entry['joined_object'] for _, entry, _, _ in placements}
  136. required.update(entry['lod1']['joined_object'] for _, entry, _, _ in placements)
  137. with bpy.data.libraries.load(str(LIBRARY_DIR / 'house-library.blend'), link=False) as (src, dst):
  138. missing = required.difference(src.objects)
  139. if missing:
  140. raise RuntimeError('Missing Blender library templates: ' + ', '.join(sorted(missing)))
  141. dst.objects = sorted(required)
  142. templates = {obj.name: obj for obj in dst.objects if obj is not None}
  143. if len(templates) != len(required) or any(obj.type != 'MESH' or not obj.data.polygons
  144. for obj in templates.values()):
  145. raise RuntimeError('A required Blender template is empty')
  146. red_roof_sources = {source['id'] for source in source_plan.get('roofs', [])}
  147. red_roof_sources.update({
  148. 'osm-1285336668', 'osm-1285336669', 'osm-1285336671',
  149. 'osm-1285336672', 'osm-1285336673', 'osm-1285336674',
  150. 'osm-1285336675', 'trace-east-01', 'trace-east-02',
  151. 'trace-east-04', 'trace-east-07', 'trace-east-09', 'trace-east-10',
  152. })
  153. # Scene variety without random re-rolls: rotate the shared library palettes per source
  154. # building, so neighbouring buildings differ while every part of one building stays coherent.
  155. ROOF_TONES = ('Roof_Slate', 'Roof_Slate_Light', 'Roof_Terracotta', 'Roof_Terra_Light')
  156. PLASTER_TONES = ('Plaster_Cream', 'Plaster_Ivory', 'Plaster_Sand')
  157. def source_seed(source_id):
  158. value = 7
  159. for character in str(source_id):
  160. value = (value * 131 + ord(character)) % 1000003
  161. return value
  162. def material_remap(source_id):
  163. seed = source_seed(source_id)
  164. roof_shift = (seed // 3) % len(ROOF_TONES)
  165. wall_shift = seed % len(PLASTER_TONES)
  166. remap = {name: ROOF_TONES[(index + roof_shift) % len(ROOF_TONES)]
  167. for index, name in enumerate(ROOF_TONES)}
  168. remap.update({name: PLASTER_TONES[(index + wall_shift) % len(PLASTER_TONES)]
  169. for index, name in enumerate(PLASTER_TONES)})
  170. remap['Roof_Membrane'] = ROOF_TONES[roof_shift]
  171. return remap
  172. def template(entry, level):
  173. name = entry['joined_object'] if level == 'lod0' else entry['lod1']['joined_object']
  174. return templates[name]
  175. def make_cell(group, level):
  176. collection = bpy.data.collections.new('District_' + level)
  177. bpy.context.scene.collection.children.link(collection)
  178. for building, entry, sx, sy in group:
  179. obj = template(entry, level).copy() # Object copy shares mesh data with sibling placements.
  180. obj.name = building['id']
  181. collection.objects.link(obj)
  182. obj.location = building['positionENU']
  183. obj.rotation_euler = (0, 0, building['heading'])
  184. obj.scale = (sx, sy, 1)
  185. obj['source_footprint'] = building['sourceId']
  186. remap = material_remap(building['sourceId'])
  187. if building['sourceId'] in red_roof_sources:
  188. # This river-side row has red/brown roofs in the checked satellite mosaic.
  189. remap.update({'Roof_Slate': 'Roof_Terracotta', 'Roof_Slate_Light': 'Roof_Terra_Light'})
  190. for slot in obj.material_slots:
  191. if slot.material and slot.material.name in remap:
  192. material = bpy.data.materials.get(remap[slot.material.name])
  193. if material:
  194. slot.link = 'OBJECT'
  195. slot.material = material
  196. bottom = building['foundationBottom']
  197. x, y, top = building['positionENU']
  198. foundation = bpy.data.objects.new(building['id'] + '_buried_foundation', foundation_mesh)
  199. collection.objects.link(foundation)
  200. foundation.location = (x, y, (bottom + top) / 2)
  201. foundation.rotation_euler.z = building['heading']
  202. foundation.scale = (building['width'], building['depth'], top - bottom)
  203. bpy.context.view_layer.update()
  204. objects = list(collection.objects)
  205. corners = [obj.matrix_world @ Vector(corner) for obj in objects for corner in obj.bound_box]
  206. bounds = [[min(p[axis] for p in corners) for axis in range(3)],
  207. [max(p[axis] for p in corners) for axis in range(3)]]
  208. return collection, objects, bounds
  209. def box(bounds):
  210. low, high = bounds
  211. centre = [(a + b) / 2 for a, b in zip(low, high)]
  212. half = [(b - a) / 2 for a, b in zip(low, high)]
  213. return [centre[0], centre[1], centre[2], half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
  214. def export_cell(path, objects, group, level):
  215. bpy.ops.object.select_all(action='DESELECT')
  216. for obj in objects:
  217. obj.select_set(True)
  218. bpy.context.view_layer.objects.active = objects[0]
  219. bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True,
  220. export_yup=True, export_materials='EXPORT', export_extras=True)
  221. with path.open('rb') as stream:
  222. header = stream.read(20)
  223. if len(header) != 20 or header[:4] != b'glTF':
  224. raise RuntimeError('Invalid GLB export ' + str(path))
  225. _, version, length, chunk_length, chunk_type = struct.unpack('<4s4I', header)
  226. if version != 2 or length != path.stat().st_size or chunk_type != 0x4E4F534A:
  227. raise RuntimeError('Invalid GLB header ' + str(path))
  228. doc = json.loads(stream.read(chunk_length))
  229. expected = {b['id'] for b, _, _, _ in group}
  230. names = [n.get('name') for n in doc['nodes'] if 'mesh' in n]
  231. if len(names) != 2 * len(expected) or sorted(names) != sorted(
  232. list(expected) + [name + '_buried_foundation' for name in expected]):
  233. raise RuntimeError('GLB mesh nodes do not match placed buildings and foundations: ' + str(path))
  234. if set(doc['scenes'][doc.get('scene', 0)]['nodes']) != set(range(len(doc['nodes']))):
  235. raise RuntimeError('Unexpected parent transform in ' + str(path))
  236. nodes = {node['name']: node for node in doc['nodes']}
  237. for building, _, sx, sy in group:
  238. x, y, top = building['positionENU']
  239. bottom = building['foundationBottom']
  240. for name, centre, scale in (
  241. (building['id'], top, (sx, 1, sy)),
  242. (building['id'] + '_buried_foundation', (bottom + top) / 2,
  243. (building['width'], top - bottom, building['depth'])),
  244. ):
  245. node = nodes[name]
  246. expected_pos = (x, centre, -y) # Blender Z-up to glTF Y-up, only once.
  247. if ('matrix' in node or any(abs(a - b) > .002 for a, b in zip(
  248. node['translation'], expected_pos)) or
  249. any(abs(a - b) > .002 for a, b in zip(node['scale'], scale))):
  250. raise RuntimeError('Incorrect seated transform for ' + name + ' in ' + str(path))
  251. def triangles(mesh):
  252. return sum(doc['accessors'][part['indices']]['count'] // 3 if 'indices' in part
  253. else doc['accessors'][part['attributes']['POSITION']]['count'] // 3
  254. for part in mesh['primitives'])
  255. count = sum(triangles(doc['meshes'][node['mesh']]) for node in doc['nodes'] if 'mesh' in node)
  256. reference = sum((entry['validation'] if level == 'lod0' else entry['lod1']['validation'])['triangles'] + 12
  257. for _, entry, _, _ in group)
  258. if count != reference:
  259. raise RuntimeError(f'{path.name}: {count} triangles, expected {reference}')
  260. meshes = {node['mesh'] for node in doc['nodes'] if 'mesh' in node}
  261. if len(expected) > 1 and len(meshes) >= len(names):
  262. raise RuntimeError('No shared mesh data in ' + str(path))
  263. return {'uri': path.name, 'bytes': path.stat().st_size, 'triangles': count,
  264. 'meshNodes': len(names), 'uniqueMeshes': len(meshes)}
  265. hierarchical = bool(args.coarse_grid)
  266. coarse_groups, fine_coarse = {}, {}
  267. if hierarchical:
  268. for cell_id, group in cells.items():
  269. x, y, _ = group[0][0]['positionENU']
  270. ix = min(args.coarse_grid - 1, int((x - min_x) / span_x * args.coarse_grid))
  271. iy = min(args.coarse_grid - 1, int((y - min_y) / span_y * args.coarse_grid))
  272. coarse_id = f'coarse-{iy:02d}-{ix:02d}'
  273. fine_coarse[cell_id] = coarse_id
  274. coarse_groups.setdefault(coarse_id, []).extend(group)
  275. fine_tiles = {}
  276. manifest_cells = []
  277. all_bounds = []
  278. for cell_id, group in cells.items():
  279. exports, bounds_per_level = {}, []
  280. for level in ('lod1', 'lod0'):
  281. collection, objects, bounds = make_cell(group, level)
  282. path = OUT / f'{cell_id}-{level}.glb'
  283. exports[level] = export_cell(path, objects, group, level)
  284. bounds_per_level.append(bounds)
  285. bpy.data.collections.remove(collection, do_unlink=True)
  286. for obj in objects:
  287. bpy.data.objects.remove(obj, do_unlink=True)
  288. bounds = [[min(b[0][axis] for b in bounds_per_level) for axis in range(3)],
  289. [max(b[1][axis] for b in bounds_per_level) for axis in range(3)]]
  290. all_bounds.append(bounds)
  291. volume = {'box': box(bounds)}
  292. if hierarchical:
  293. fine_tiles[cell_id] = {'boundingVolume': volume, 'geometricError': 0,
  294. 'content': {'uri': exports['lod0']['uri']}}
  295. else:
  296. fine_tiles[cell_id] = {'boundingVolume': volume, 'geometricError': 8,
  297. 'refine': 'REPLACE', 'content': {'uri': exports['lod1']['uri']},
  298. 'children': [{'boundingVolume': volume, 'geometricError': 0,
  299. 'content': {'uri': exports['lod0']['uri']}}]}
  300. manifest_cells.append({'id': cell_id, 'count': len(group),
  301. 'sourceIds': sorted({b['sourceId'] for b, _, _, _ in group}),
  302. 'buildingIds': [b['id'] for b, _, _, _ in group],
  303. 'boundsENU': {'min': bounds[0], 'max': bounds[1]}, **exports})
  304. if hierarchical:
  305. cell_tiles = []
  306. manifest_coarse = []
  307. for coarse_id, group in coarse_groups.items():
  308. collection, objects, bounds = make_cell(group, 'lod1')
  309. path = OUT / f'{coarse_id}-lod1.glb'
  310. coarse_export = export_cell(path, objects, group, 'lod1')
  311. bpy.data.collections.remove(collection, do_unlink=True)
  312. for obj in objects:
  313. bpy.data.objects.remove(obj, do_unlink=True)
  314. children = [fine_tiles[cell_id] for cell_id, parent in fine_coarse.items() if parent == coarse_id]
  315. cell_tiles.append({'boundingVolume': {'box': box(bounds)}, 'geometricError': 16,
  316. 'refine': 'REPLACE', 'content': {'uri': coarse_export['uri']},
  317. 'children': children})
  318. manifest_coarse.append({'id': coarse_id, 'count': len(group), **coarse_export})
  319. else:
  320. cell_tiles = list(fine_tiles.values())
  321. manifest_coarse = []
  322. root_bounds = [[min(b[0][axis] for b in all_bounds) for axis in range(3)],
  323. [max(b[1][axis] for b in all_bounds) for axis in range(3)]]
  324. # The explicit root must be renderable to keep Cesium traversing its spatial children.
  325. anchor_json = json.dumps({'asset': {'version': '2.0'}, 'scene': 0, 'scenes': [{'nodes': []}]},
  326. separators=(',', ':')).encode('utf-8')
  327. anchor_json += b' ' * ((-len(anchor_json)) % 4)
  328. anchor = b'glTF' + struct.pack('<II', 2, 20 + len(anchor_json))
  329. anchor += struct.pack('<I', len(anchor_json)) + b'JSON' + anchor_json
  330. (OUT / 'root-anchor.glb').write_bytes(anchor)
  331. tileset = {'asset': {'version': '1.1', 'generator': 'Blender scoped district LOD'},
  332. 'geometricError': 800,
  333. 'root': {'transform': plan['transform'], 'boundingVolume': {'box': box(root_bounds)},
  334. 'geometricError': 200, 'refine': 'REPLACE',
  335. 'content': {'uri': 'root-anchor.glb'}, 'children': cell_tiles}}
  336. lod1_records = manifest_coarse if hierarchical else [cell['lod1'] for cell in manifest_cells]
  337. lod0_records = [cell['lod0'] for cell in manifest_cells]
  338. manifest = {'scope': plan.get('scope', 'evidence-bound district sample; not full-city reconstruction'),
  339. 'origin': plan['origin'], 'notice': plan['notice'], 'count': len(placements),
  340. 'cellCount': len(cells), 'cellIds': list(cells),
  341. 'hierarchy': {'enabled': hierarchical, 'coarseGrid': args.coarse_grid if hierarchical else None,
  342. 'coarseCellCount': len(manifest_coarse)},
  343. 'excludedUnfittedIds': rejected,
  344. 'boundsENU': {'min': root_bounds[0], 'max': root_bounds[1]},
  345. 'totals': {level: {'bytes': sum(item['bytes'] for item in (lod1_records if level == 'lod1' else lod0_records)),
  346. 'triangles': sum(item['triangles'] for item in (lod1_records if level == 'lod1' else lod0_records))}
  347. for level in ('lod1', 'lod0')},
  348. 'coarseCells': manifest_coarse,
  349. 'cells': manifest_cells}
  350. (OUT / 'tileset.json').write_text(json.dumps(tileset, indent=2), encoding='utf-8')
  351. (OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
  352. print('LOD_DISTRICT', json.dumps({'count': manifest['count'], 'cells': manifest['cellCount'],
  353. 'totals': manifest['totals'], 'output': str(OUT)}))