"""Blender assembly of evidence-bound modules; no random fill, no height-grid interpolation. Uses linked mesh data from house-library.blend. Exports an editable .blend and self-contained GLB. """ import argparse, json, math, sys from pathlib import Path import bpy from mathutils import Vector ROOT = Path(__file__).resolve().parents[2] parser = argparse.ArgumentParser() parser.add_argument('--output', default=str(ROOT / 'tools/city-massing/district/generated')) args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []) out = Path(args.output); out.mkdir(parents=True, exist_ok=True) plan = json.loads((ROOT / 'tools/city-massing/district/seated-plan.json').read_text(encoding='utf-8')) lib_dir = ROOT / 'tools/city-massing/house-library' lib = json.loads((lib_dir / 'library.json').read_text(encoding='utf-8')) entries = lib['archetypes'] bpy.ops.wm.read_factory_settings(use_empty=True) bpy.context.scene.unit_settings.system = 'METRIC' bpy.context.scene.unit_settings.scale_length = 1 col = bpy.data.collections.new('Yuanling_footprint_verified_sample') bpy.context.scene.collection.children.link(col) 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 foundation_mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(0.32,0.33,0.32,1) foundation_mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=0.92 loaded={} report={'scope':'individual-source footprint sample, NOT full-city reconstruction','origin':plan['origin'],'disclaimer':plan['notice'],'buildings':[],'excluded':list(plan['excluded'])} def select_asset(b): prefix = {'house':'house','apartment':'slab','midrise':'midrise'}[b['archetype']] candidates=[] for e in entries: if not e['id'].startswith(prefix): continue w,d=e['nominal_footprint_m']; sx=b['width']/w; sy=b['depth']/d # A scaled facade must retain sensible windows and balconies. Reject giant stretching. if not (0.64 <= sx <= 1.40 and 0.72 <= sy <= 1.40): continue score=abs(e['floors']-b['floors'])*0.8+abs(math.log(sx))+abs(math.log(sy)) candidates.append((score,e,sx,sy)) return min(candidates,key=lambda x:x[0]) if candidates else None def load_template(e): name=e['joined_object'] if name not in loaded: with bpy.data.libraries.load(str(lib_dir/'house-library.blend'),link=False) as (src,dst): dst.objects=[name] obj=dst.objects[0] if obj is None: raise RuntimeError('Missing Blender object '+name) loaded[name]=obj return loaded[name] for b in plan['buildings']: selected=select_asset(b) if not selected: report['excluded'].append({'id':b['id'],'reason':'no library asset fits footprint without excessive XY stretching','width':b['width'],'depth':b['depth']}); continue _,e,sx,sy=selected obj=load_template(e).copy(); obj.data=loaded[e['joined_object']].data obj.name=b['id']; col.objects.link(obj); obj.hide_render=False; obj.hide_viewport=False; obj.hide_set(False) obj.location=b['positionENU']; obj.rotation_euler=(0,0,b['heading']); obj.scale=(sx,sy,1) obj['source_footprint']=b['sourceId']; obj['floor_count']=e['floors']; obj['estimated_facade']=True # These river-side rows have red/brown roofs in the checked satellite mosaic. 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'} if red_roof and e['id'].startswith('slab'): for slot in obj.material_slots: if slot.material and slot.material.name in ('Roof_Slate','Roof_Slate_Light'): replacement = 'Roof_Terracotta' if slot.material.name == 'Roof_Slate' else 'Roof_Terra_Light' if bpy.data.materials.get(replacement): slot.link = 'OBJECT'; slot.material = bpy.data.materials[replacement] # Independent buried foundation: body floor elevation is unchanged when skirt depth changes. h=b['positionENU'][2]-b['foundationBottom'] bpy.ops.mesh.primitive_cube_add(size=1,location=(b['positionENU'][0],b['positionENU'][1],b['foundationBottom']+h/2)) foundation=bpy.context.object; foundation.name=b['id']+'_buried_foundation'; foundation.dimensions=(b['width'],b['depth'],h); foundation.rotation_euler.z=b['heading'] bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);foundation.data.materials.append(foundation_mat) for old in list(foundation.users_collection):old.objects.unlink(foundation) col.objects.link(foundation) 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']}) objects=list(col.objects);bpy.context.view_layer.update() coords=[o.matrix_world@Vector(c) for o in objects for c in o.bound_box] 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)] 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] bpy.ops.object.select_all(action='DESELECT') for o in objects:o.select_set(True) bpy.context.view_layer.objects.active=objects[0] opts=dict(filepath=str(out/'district.glb'),export_format='GLB',use_selection=True,export_yup=True,export_materials='EXPORT',export_extras=True) # Preserve named nodes for independent per-building verification; shared mesh data reused in GLB. bpy.ops.export_scene.gltf(**opts) scene=bpy.context.scene scene['georeference']=json.dumps({'origin':plan['origin'],'transform':plan['transform']}) bpy.ops.wm.save_as_mainfile(filepath=str(out/'district.blend')) 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'}}} (out/'tileset.json').write_text(json.dumps(tile,indent=2),encoding='utf-8') report['count']=len(report['buildings']);report['boundsENU']={'min':mins,'max':maxs};report['assetReuse']=len(set(b['asset'] for b in report['buildings'])) report['glbBytes']=(out/'district.glb').stat().st_size full_plan = json.loads((ROOT / 'tools/city-massing/district/plan.json').read_text(encoding='utf-8')) coverage = [] for source in full_plan['buildings']: expected = sum(m['sourceId'] == source['id'] for m in full_plan['modules']) placed = sum(m['sourceId'] == source['id'] for m in report['buildings']) coverage.append({'id': source['id'], 'plannedModules': expected, 'placedModules': placed, 'state': 'complete' if placed == expected else 'partial' if placed else 'omitted'}) (out / 'coverage.json').write_text(json.dumps(coverage, indent=2), encoding='utf-8') report['sourceCoverage'] = {state: sum(c['state'] == state for c in coverage) for state in ['complete', 'partial', 'omitted']} (out/'placement-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8') print('T35_DISTRICT',json.dumps({'count':report['count'],'excluded':len(report['excluded']),'sharedAssets':report['assetReuse'],'MB':round(report['glbBytes']/1048576,2),'output':str(out)}))