assemble-instanced-city.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. """Generate bounded, building-owned mixed cells using the existing surveyed exporter.
  2. Every fine/coarse pair owns exactly the same IDs; fine assets retain original palette/decimate.
  3. blender ... --python assemble-instanced-city.py -- --plan <active-plan> --output <new-dir> ...
  4. """
  5. from pathlib import Path
  6. import sys
  7. import json
  8. import bpy
  9. source = Path(__file__).with_name('assemble-cad-bim.py')
  10. output_arg = sys.argv[sys.argv.index('--output') + 1]
  11. out = Path(output_arg).resolve()
  12. if out.exists():
  13. raise RuntimeError('Use a new immutable output directory: ' + str(out))
  14. # Reuse authoritative selection, palette, mesh construction, evaluation and GLB checks.
  15. text = source.read_text(encoding='utf-8')
  16. namespace = {'__file__': str(source), '__name__': '__instanced_export__'}
  17. exec(compile(text.split('# ---------------------------------------------------------------- spatial cells')[0], str(source), 'exec'), namespace)
  18. args = namespace['args']
  19. if not args.lod1_footprint_only:
  20. raise RuntimeError('Instanced city requires --lod1-footprint-only')
  21. placements = namespace['placements']
  22. expected = namespace['expected_triangles']
  23. make_cell, export_cell = namespace['make_cell'], namespace['export_cell']
  24. OUT, plan = namespace['OUT'], namespace['plan']
  25. assert len(placements) == len(plan['buildings']), 'active source must not lose buildings'
  26. by_id = {b['id']: (b, entry, sx, sy) for b, entry, sx, sy in placements}
  27. manifest = {'origin': plan['origin'], 'source': 'existing surveyed exporter; building-owned bounded pairs',
  28. 'count': len(placements), 'cells': [], 'fallbackCells': [], 'budget': {'maxRenderedTriangles': 20000},
  29. 'provenance': {'gisFootprints': True, 'closedRoofCaps': True, 'lod1FootprintOnly': True,
  30. 'facadesEstimated': True, 'decimateLod0': args.decimate_lod0}}
  31. root_children, all_bounds = [], []
  32. def triangle_cost(p):
  33. b, entry, _, _ = p
  34. return expected[b['id']]['lod0'] + (12 if entry else 0)
  35. def partition(group):
  36. if sum(triangle_cost(p) for p in group) <= 20000:
  37. return [group]
  38. if len(group) == 1:
  39. raise RuntimeError('One building exceeds rendered triangle budget: ' + group[0][0]['id'])
  40. spans = [max(p[0]['positionENU'][a] for p in group) - min(p[0]['positionENU'][a] for p in group) for a in (0, 1)]
  41. axis = spans.index(max(spans))
  42. ordered = sorted(group, key=lambda p: (p[0]['positionENU'][axis], p[0]['id']))
  43. mid = len(ordered) // 2
  44. return partition(ordered[:mid]) + partition(ordered[mid:])
  45. for cell in plan['activeCells']:
  46. source_group = [by_id[i] for i in cell['buildingIds']]
  47. if any(triangle_cost(p) > 20000 for p in source_group):
  48. manifest['fallbackCells'].append(cell)
  49. print('INSTANCED_SOURCE_FALLBACK ' + cell['id'], flush=True)
  50. continue
  51. for part, group in enumerate(partition(source_group)):
  52. name = cell['id'] + '-p%03d' % part
  53. records, bounds_per_level = {}, []
  54. for level in ('lod1', 'lod0'):
  55. # Footprint-only coarse, mixed unmerged fine: asset transforms survive for instancing.
  56. args.merge_buildings = level == 'lod1'
  57. collection, objects, bounds = make_cell(group, level)
  58. path = OUT / (name + '-' + level + '.glb')
  59. if level == 'lod1':
  60. record = export_cell(path, objects, group, level)
  61. else:
  62. # Only reusable templates/foundations retain individual TRS. Bake unique
  63. # surveyed prisms into one material-batched mesh, avoiding N facade draws.
  64. asset_names = {p[0]['id'] for p in group if p[1]}
  65. unique = [obj for obj in objects if obj.name not in asset_names
  66. and not obj.name.endswith('_buried_foundation')]
  67. if unique:
  68. retained = [obj for obj in objects if obj not in unique]
  69. objects = retained + namespace['merge_cell_objects'](unique, collection)
  70. # Preserve reusable template TRS; conversion checks per-primitive normal
  71. # invariance under nonuniform scale and keeps sloped surfaces conventional.
  72. bpy.ops.object.select_all(action='DESELECT')
  73. for obj in objects:
  74. obj.select_set(True)
  75. bpy.context.view_layer.objects.active = objects[0]
  76. bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True,
  77. export_yup=True, export_materials='EXPORT', export_extras=True, export_apply=True,
  78. export_draco_mesh_compression_enable=True, export_draco_position_quantization=24)
  79. with path.open('rb') as stream:
  80. header = stream.read(20)
  81. doc = json.loads(stream.read(int.from_bytes(header[12:16], 'little')))
  82. def mesh_triangles(mesh):
  83. return sum(doc['accessors'][p['indices']]['count'] // 3 for p in mesh['primitives'])
  84. count = sum(mesh_triangles(doc['meshes'][node['mesh']]) for node in doc['nodes'] if 'mesh' in node)
  85. if count > 20000:
  86. raise RuntimeError('Rendered budget exceeded: ' + name)
  87. record = {'uri': path.name, 'bytes': path.stat().st_size, 'triangles': count,
  88. 'meshNodes': sum('mesh' in n for n in doc['nodes'])}
  89. if record['triangles'] > 20000:
  90. raise RuntimeError('Rendered coarse/fine budget exceeded: ' + name)
  91. records[level] = record
  92. bounds_per_level.append(bounds)
  93. bpy.data.collections.remove(collection, do_unlink=True)
  94. for obj in objects:
  95. if obj.name in bpy.data.objects:
  96. bpy.data.objects.remove(obj, do_unlink=True)
  97. low = [min(b[0][a] for b in bounds_per_level) for a in range(3)]
  98. high = [max(b[1][a] for b in bounds_per_level) for a in range(3)]
  99. all_bounds.append([low, high])
  100. box = namespace['box']([low, high])
  101. center = box[:3]
  102. radius = sum(v*v for v in (box[3], box[7], box[11])) ** 0.5
  103. # Sphere parent avoids childrenWithinParent culling by narrow fine request volumes.
  104. root_children.append({'boundingVolume': {'sphere': center + [radius]}, 'geometricError': 1000000,
  105. 'refine': 'REPLACE', 'viewerRequestVolume': {'sphere': center + [radius + 1500]},
  106. 'content': {'uri': records['lod1']['uri']}, 'children': [
  107. {'boundingVolume': {'box': box}, 'geometricError': 0,
  108. 'viewerRequestVolume': {'sphere': center + [radius + 350]},
  109. 'content': {'uri': records['lod0']['uri']}}]})
  110. manifest['cells'].append({'id': name, 'sourceCell': cell['id'], 'buildingIds': [p[0]['id'] for p in group],
  111. 'assetIds': [p[0]['id'] for p in group if p[1]], 'boundsENU': {'min': low, 'max': high}, **records})
  112. print('INSTANCED_SOURCE_CELL ' + cell['id'] + ' ' + str(len(source_group)), flush=True)
  113. low = [min(b[0][a] for b in all_bounds) for a in range(3)]
  114. high = [max(b[1][a] for b in all_bounds) for a in range(3)]
  115. manifest['boundsENU'] = {'min': low, 'max': high}
  116. (OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
  117. (OUT / 'tileset.json').write_text(json.dumps({'asset': {'version': '1.1', 'generator': 'bounded paired surveyed city'},
  118. 'geometricError': 1000000, 'root': {'transform': plan['transform'], 'boundingVolume': {'box': namespace['box']([low, high])},
  119. 'geometricError': 1000000, 'refine': 'REPLACE', 'children': root_children}}, indent=2), encoding='utf-8')
  120. print('INSTANCED_SOURCE_DONE ' + json.dumps({'buildings': len(placements), 'pairs': len(manifest['cells'])}), flush=True)