"""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 --output ... """ 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)