| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124 |
- """Generate bounded, building-owned mixed cells using the existing surveyed exporter.
- Every fine/coarse pair owns exactly the same IDs; fine assets retain original palette/decimate.
- blender ... --python assemble-instanced-city.py -- --plan <active-plan> --output <new-dir> ...
- """
- from pathlib import Path
- import sys
- import json
- import bpy
- source = Path(__file__).with_name('assemble-cad-bim.py')
- output_arg = sys.argv[sys.argv.index('--output') + 1]
- out = Path(output_arg).resolve()
- if out.exists():
- raise RuntimeError('Use a new immutable output directory: ' + str(out))
- # Reuse authoritative selection, palette, mesh construction, evaluation and GLB checks.
- text = source.read_text(encoding='utf-8')
- namespace = {'__file__': str(source), '__name__': '__instanced_export__'}
- exec(compile(text.split('# ---------------------------------------------------------------- spatial cells')[0], str(source), 'exec'), namespace)
- args = namespace['args']
- if not args.lod1_footprint_only:
- raise RuntimeError('Instanced city requires --lod1-footprint-only')
- placements = namespace['placements']
- expected = namespace['expected_triangles']
- make_cell, export_cell = namespace['make_cell'], namespace['export_cell']
- OUT, plan = namespace['OUT'], namespace['plan']
- assert len(placements) == len(plan['buildings']), 'active source must not lose buildings'
- by_id = {b['id']: (b, entry, sx, sy) for b, entry, sx, sy in placements}
- manifest = {'origin': plan['origin'], 'source': 'existing surveyed exporter; building-owned bounded pairs',
- 'count': len(placements), 'cells': [], 'fallbackCells': [], 'budget': {'maxRenderedTriangles': 20000},
- 'provenance': {'gisFootprints': True, 'closedRoofCaps': True, 'lod1FootprintOnly': True,
- 'facadesEstimated': True, 'decimateLod0': args.decimate_lod0}}
- root_children, all_bounds = [], []
- def triangle_cost(p):
- b, entry, _, _ = p
- return expected[b['id']]['lod0'] + (12 if entry else 0)
- def partition(group):
- if sum(triangle_cost(p) for p in group) <= 20000:
- return [group]
- if len(group) == 1:
- raise RuntimeError('One building exceeds rendered triangle budget: ' + group[0][0]['id'])
- spans = [max(p[0]['positionENU'][a] for p in group) - min(p[0]['positionENU'][a] for p in group) for a in (0, 1)]
- axis = spans.index(max(spans))
- ordered = sorted(group, key=lambda p: (p[0]['positionENU'][axis], p[0]['id']))
- mid = len(ordered) // 2
- return partition(ordered[:mid]) + partition(ordered[mid:])
- for cell in plan['activeCells']:
- source_group = [by_id[i] for i in cell['buildingIds']]
- if any(triangle_cost(p) > 20000 for p in source_group):
- manifest['fallbackCells'].append(cell)
- print('INSTANCED_SOURCE_FALLBACK ' + cell['id'], flush=True)
- continue
- for part, group in enumerate(partition(source_group)):
- name = cell['id'] + '-p%03d' % part
- records, bounds_per_level = {}, []
- for level in ('lod1', 'lod0'):
- # Footprint-only coarse, mixed unmerged fine: asset transforms survive for instancing.
- args.merge_buildings = level == 'lod1'
- collection, objects, bounds = make_cell(group, level)
- path = OUT / (name + '-' + level + '.glb')
- if level == 'lod1':
- record = export_cell(path, objects, group, level)
- else:
- # Only reusable templates/foundations retain individual TRS. Bake unique
- # surveyed prisms into one material-batched mesh, avoiding N facade draws.
- asset_names = {p[0]['id'] for p in group if p[1]}
- unique = [obj for obj in objects if obj.name not in asset_names
- and not obj.name.endswith('_buried_foundation')]
- if unique:
- retained = [obj for obj in objects if obj not in unique]
- objects = retained + namespace['merge_cell_objects'](unique, collection)
- # Preserve reusable template TRS; conversion checks per-primitive normal
- # invariance under nonuniform scale and keeps sloped surfaces conventional.
- bpy.ops.object.select_all(action='DESELECT')
- for obj in objects:
- obj.select_set(True)
- bpy.context.view_layer.objects.active = objects[0]
- bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True,
- export_yup=True, export_materials='EXPORT', export_extras=True, export_apply=True,
- export_draco_mesh_compression_enable=True, export_draco_position_quantization=24)
- with path.open('rb') as stream:
- header = stream.read(20)
- doc = json.loads(stream.read(int.from_bytes(header[12:16], 'little')))
- def mesh_triangles(mesh):
- return sum(doc['accessors'][p['indices']]['count'] // 3 for p in mesh['primitives'])
- count = sum(mesh_triangles(doc['meshes'][node['mesh']]) for node in doc['nodes'] if 'mesh' in node)
- if count > 20000:
- raise RuntimeError('Rendered budget exceeded: ' + name)
- record = {'uri': path.name, 'bytes': path.stat().st_size, 'triangles': count,
- 'meshNodes': sum('mesh' in n for n in doc['nodes'])}
- if record['triangles'] > 20000:
- raise RuntimeError('Rendered coarse/fine budget exceeded: ' + name)
- records[level] = record
- bounds_per_level.append(bounds)
- bpy.data.collections.remove(collection, do_unlink=True)
- for obj in objects:
- if obj.name in bpy.data.objects:
- bpy.data.objects.remove(obj, do_unlink=True)
- low = [min(b[0][a] for b in bounds_per_level) for a in range(3)]
- high = [max(b[1][a] for b in bounds_per_level) for a in range(3)]
- all_bounds.append([low, high])
- box = namespace['box']([low, high])
- center = box[:3]
- radius = sum(v*v for v in (box[3], box[7], box[11])) ** 0.5
- # Sphere parent avoids childrenWithinParent culling by narrow fine request volumes.
- root_children.append({'boundingVolume': {'sphere': center + [radius]}, 'geometricError': 1000000,
- 'refine': 'REPLACE', 'viewerRequestVolume': {'sphere': center + [radius + 1500]},
- 'content': {'uri': records['lod1']['uri']}, 'children': [
- {'boundingVolume': {'box': box}, 'geometricError': 0,
- 'viewerRequestVolume': {'sphere': center + [radius + 350]},
- 'content': {'uri': records['lod0']['uri']}}]})
- manifest['cells'].append({'id': name, 'sourceCell': cell['id'], 'buildingIds': [p[0]['id'] for p in group],
- 'assetIds': [p[0]['id'] for p in group if p[1]], 'boundsENU': {'min': low, 'max': high}, **records})
- print('INSTANCED_SOURCE_CELL ' + cell['id'] + ' ' + str(len(source_group)), flush=True)
- low = [min(b[0][a] for b in all_bounds) for a in range(3)]
- high = [max(b[1][a] for b in all_bounds) for a in range(3)]
- manifest['boundsENU'] = {'min': low, 'max': high}
- (OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8')
- (OUT / 'tileset.json').write_text(json.dumps({'asset': {'version': '1.1', 'generator': 'bounded paired surveyed city'},
- 'geometricError': 1000000, 'root': {'transform': plan['transform'], 'boundingVolume': {'box': namespace['box']([low, high])},
- 'geometricError': 1000000, 'refine': 'REPLACE', 'children': root_children}}, indent=2), encoding='utf-8')
- print('INSTANCED_SOURCE_DONE ' + json.dumps({'buildings': len(placements), 'pairs': len(manifest['cells'])}), flush=True)
|