assemble-district.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. """Blender assembly of evidence-bound modules; no random fill, no height-grid interpolation.
  2. Uses linked mesh data from house-library.blend. Exports an editable .blend and self-contained GLB.
  3. """
  4. import argparse, json, math, sys
  5. from pathlib import Path
  6. import bpy
  7. from mathutils import Vector
  8. ROOT = Path(__file__).resolve().parents[2]
  9. parser = argparse.ArgumentParser()
  10. parser.add_argument('--output', default=str(ROOT / 'tools/city-massing/district/generated'))
  11. args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
  12. out = Path(args.output); out.mkdir(parents=True, exist_ok=True)
  13. plan = json.loads((ROOT / 'tools/city-massing/district/seated-plan.json').read_text(encoding='utf-8'))
  14. lib_dir = ROOT / 'tools/city-massing/house-library'
  15. lib = json.loads((lib_dir / 'library.json').read_text(encoding='utf-8'))
  16. entries = lib['archetypes']
  17. bpy.ops.wm.read_factory_settings(use_empty=True)
  18. bpy.context.scene.unit_settings.system = 'METRIC'
  19. bpy.context.scene.unit_settings.scale_length = 1
  20. col = bpy.data.collections.new('Yuanling_footprint_verified_sample')
  21. bpy.context.scene.collection.children.link(col)
  22. foundation_mat = bpy.data.materials.new('Buried_foundation_stone'); foundation_mat.diffuse_color=(0.32,0.33,0.32,1); foundation_mat.use_nodes=True
  23. foundation_mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(0.32,0.33,0.32,1)
  24. foundation_mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=0.92
  25. loaded={}
  26. report={'scope':'individual-source footprint sample, NOT full-city reconstruction','origin':plan['origin'],'disclaimer':plan['notice'],'buildings':[],'excluded':list(plan['excluded'])}
  27. def select_asset(b):
  28. prefix = {'house':'house','apartment':'slab','midrise':'midrise'}[b['archetype']]
  29. candidates=[]
  30. for e in entries:
  31. if not e['id'].startswith(prefix): continue
  32. w,d=e['nominal_footprint_m']; sx=b['width']/w; sy=b['depth']/d
  33. # A scaled facade must retain sensible windows and balconies. Reject giant stretching.
  34. if not (0.64 <= sx <= 1.40 and 0.72 <= sy <= 1.40): continue
  35. score=abs(e['floors']-b['floors'])*0.8+abs(math.log(sx))+abs(math.log(sy))
  36. candidates.append((score,e,sx,sy))
  37. return min(candidates,key=lambda x:x[0]) if candidates else None
  38. def load_template(e):
  39. name=e['joined_object']
  40. if name not in loaded:
  41. with bpy.data.libraries.load(str(lib_dir/'house-library.blend'),link=False) as (src,dst):
  42. dst.objects=[name]
  43. obj=dst.objects[0]
  44. if obj is None: raise RuntimeError('Missing Blender object '+name)
  45. loaded[name]=obj
  46. return loaded[name]
  47. for b in plan['buildings']:
  48. selected=select_asset(b)
  49. if not selected:
  50. report['excluded'].append({'id':b['id'],'reason':'no library asset fits footprint without excessive XY stretching','width':b['width'],'depth':b['depth']}); continue
  51. _,e,sx,sy=selected
  52. obj=load_template(e).copy(); obj.data=loaded[e['joined_object']].data
  53. obj.name=b['id']; col.objects.link(obj); obj.hide_render=False; obj.hide_viewport=False; obj.hide_set(False)
  54. obj.location=b['positionENU']; obj.rotation_euler=(0,0,b['heading']); obj.scale=(sx,sy,1)
  55. obj['source_footprint']=b['sourceId']; obj['floor_count']=e['floors']; obj['estimated_facade']=True
  56. # These river-side rows have red/brown roofs in the checked satellite mosaic.
  57. red_roof = b['sourceId'] in {'osm-1285336668','osm-1285336669','osm-1285336671','osm-1285336672','osm-1285336673','osm-1285336674','osm-1285336675','trace-east-01','trace-east-02','trace-east-04','trace-east-07','trace-east-09','trace-east-10'}
  58. if red_roof and e['id'].startswith('slab'):
  59. for slot in obj.material_slots:
  60. if slot.material and slot.material.name in ('Roof_Slate','Roof_Slate_Light'):
  61. replacement = 'Roof_Terracotta' if slot.material.name == 'Roof_Slate' else 'Roof_Terra_Light'
  62. if bpy.data.materials.get(replacement):
  63. slot.link = 'OBJECT'; slot.material = bpy.data.materials[replacement]
  64. # Independent buried foundation: body floor elevation is unchanged when skirt depth changes.
  65. h=b['positionENU'][2]-b['foundationBottom']
  66. bpy.ops.mesh.primitive_cube_add(size=1,location=(b['positionENU'][0],b['positionENU'][1],b['foundationBottom']+h/2))
  67. foundation=bpy.context.object; foundation.name=b['id']+'_buried_foundation'; foundation.dimensions=(b['width'],b['depth'],h); foundation.rotation_euler.z=b['heading']
  68. bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);foundation.data.materials.append(foundation_mat)
  69. for old in list(foundation.users_collection):old.objects.unlink(foundation)
  70. col.objects.link(foundation)
  71. report['buildings'].append({**b,'asset':e['id'],'actualFloors':e['floors'],'floorHeightEstimated':True,'scaleXY':[sx,sy],'object':obj.name,'foundationObject':foundation.name,'libraryObject':e['joined_object']})
  72. objects=list(col.objects);bpy.context.view_layer.update()
  73. coords=[o.matrix_world@Vector(c) for o in objects for c in o.bound_box]
  74. mins=[min(v[i] for v in coords) for i in range(3)];maxs=[max(v[i] for v in coords) for i in range(3)]
  75. box=[*( (a+b)/2 for a,b in zip(mins,maxs)), (maxs[0]-mins[0])/2,0,0,0,(maxs[1]-mins[1])/2,0,0,0,(maxs[2]-mins[2])/2]
  76. bpy.ops.object.select_all(action='DESELECT')
  77. for o in objects:o.select_set(True)
  78. bpy.context.view_layer.objects.active=objects[0]
  79. opts=dict(filepath=str(out/'district.glb'),export_format='GLB',use_selection=True,export_yup=True,export_materials='EXPORT',export_extras=True)
  80. # Preserve named nodes for independent per-building verification; shared mesh data reused in GLB.
  81. bpy.ops.export_scene.gltf(**opts)
  82. scene=bpy.context.scene
  83. scene['georeference']=json.dumps({'origin':plan['origin'],'transform':plan['transform']})
  84. bpy.ops.wm.save_as_mainfile(filepath=str(out/'district.blend'))
  85. tile={'asset':{'version':'1.1','generator':'Blender detailed footprint district'},'geometricError':800,'root':{'transform':plan['transform'],'boundingVolume':{'box':box},'geometricError':0,'refine':'ADD','content':{'uri':'district.glb'}}}
  86. (out/'tileset.json').write_text(json.dumps(tile,indent=2),encoding='utf-8')
  87. report['count']=len(report['buildings']);report['boundsENU']={'min':mins,'max':maxs};report['assetReuse']=len(set(b['asset'] for b in report['buildings']))
  88. report['glbBytes']=(out/'district.glb').stat().st_size
  89. full_plan = json.loads((ROOT / 'tools/city-massing/district/plan.json').read_text(encoding='utf-8'))
  90. coverage = []
  91. for source in full_plan['buildings']:
  92. expected = sum(m['sourceId'] == source['id'] for m in full_plan['modules'])
  93. placed = sum(m['sourceId'] == source['id'] for m in report['buildings'])
  94. coverage.append({'id': source['id'], 'plannedModules': expected, 'placedModules': placed,
  95. 'state': 'complete' if placed == expected else 'partial' if placed else 'omitted'})
  96. (out / 'coverage.json').write_text(json.dumps(coverage, indent=2), encoding='utf-8')
  97. report['sourceCoverage'] = {state: sum(c['state'] == state for c in coverage) for state in ['complete', 'partial', 'omitted']}
  98. (out/'placement-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
  99. print('T35_DISTRICT',json.dumps({'count':report['count'],'excluded':len(report['excluded']),'sharedAssets':report['assetReuse'],'MB':round(report['glbBytes']/1048576,2),'output':str(out)}))