"""Run in Blender through MCP. Reuses the snapshot of src/constants/structures.ts. Builds a separate scene, then exposes export_assets() and setup_studio(). No source scene objects or external textures are changed. """ import bpy import bmesh import json import math from pathlib import Path from mathutils import Vector ROOT = Path('D:/project/sunrise') SOURCE = ROOT / 'assets/summit' OUTPUT = ROOT / 'public/models/summit' OUTPUT.mkdir(parents=True, exist_ok=True) SPECS = json.loads((SOURCE / 'structure-specs.json').read_text(encoding='utf-8')) SCENE = bpy.data.scenes.get('Summit Assets - refined') or bpy.data.scenes.new('Summit Assets - refined') bpy.context.window.scene = SCENE SCENE.unit_settings.system = 'METRIC' SCENE.unit_settings.scale_length = 1 ASSETS = [] def enum_value(owner, prop, identifier): return next(i.identifier for i in owner.bl_rna.properties[prop].enum_items if i.identifier == identifier) def material(name, hex_color, roughness=0.65, metal=0): existing = bpy.data.materials.get('Summit / ' + name) if existing: return existing rgb = [int(hex_color[i:i+2], 16)/255 for i in (0, 2, 4)] rgba = [v/12.92 if v <= .04045 else ((v+.055)/1.055)**2.4 for v in rgb] + [1] mat = bpy.data.materials.new('Summit / ' + name) mat.diffuse_color = rgba mat.use_nodes = True node = next(n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED') node.inputs['Base Color'].default_value = rgba node.inputs['Roughness'].default_value = roughness node.inputs['Metallic'].default_value = metal return mat MATS = { 'galvanized': material('Galvanized steel', 'a5b0b5', .4, .7), 'red': material('Faded aviation red', 'a94f3d', .64, .25), 'dark': material('Graphite metal', '37413e', .55, .45), 'concrete': material('Warm concrete', 'b7b4a8', .92), 'green': material('Muted green cladding', '6e8b7f', .7, .15), 'seam': material('Panel joints', '8f9c98', .85), 'white': material('Antenna ivory', 'e2e4dc', .52, .08), 'panel0': material('Radome warm white', 'e8e9df', .65), 'panel1': material('Radome pearl', 'dce2da', .65), 'panel2': material('Radome light gray', 'd2dcd5', .68), 'wood0': material('Deck weathered oak', '9b896c', .88), 'wood1': material('Deck warm oak', 'aa9475', .88), 'wood2': material('Deck gray oak', '918774', .9), 'wood3': material('Deck dark oak', '84785f', .9), 'groove': material('Deck grooves', '6e6654', .95), 'rail': material('Dark timber railing', '454b42', .86), } class Meshes: """Accumulate disconnected primitives by material instead of thousands of objects.""" def __init__(self): self.parts = {} def add(self, mat, verts, faces): vs, fs = self.parts.setdefault(mat, ([], [])) offset = len(vs) vs.extend(tuple(v) for v in verts) fs.extend(tuple(offset+i for i in face) for face in faces) def box(self, mat, center, size): x, y, z = center a, b, c = (v/2 for v in size) self.add(mat, [(x+dx*a, y+dy*b, z+dz*c) for dx, dy, dz in [(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1), (-1,-1,1),(1,-1,1),(1,1,1),(-1,1,1)]], [(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)]) def beam(self, mat, a, b, radius, sides=8, end_radius=None): a, b = Vector(a), Vector(b) direction = (b-a).normalized() helper = Vector((0,0,1)) if abs(direction.z) < .95 else Vector((1,0,0)) u = direction.cross(helper).normalized() v = direction.cross(u) verts = [] for point, r in [(a, radius), (b, radius if end_radius is None else end_radius)]: verts.extend(point + r*(math.cos(i*math.tau/sides)*u + math.sin(i*math.tau/sides)*v) for i in range(sides)) faces = [tuple(reversed(range(sides))), tuple(range(sides, 2*sides))] faces.extend((i,(i+1)%sides,(i+1)%sides+sides,i+sides) for i in range(sides)) self.add(mat, verts, faces) def finish(self, root): for key, (verts, faces) in self.parts.items(): mesh = bpy.data.meshes.new(root.name + '/' + key) mesh.from_pydata(verts, [], faces) mesh.update() bm = bmesh.new() bm.from_mesh(mesh) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) bm.to_mesh(mesh) bm.free() obj = bpy.data.objects.new(mesh.name, mesh) root.users_collection[0].objects.link(obj) obj.parent = root obj.data.materials.append(MATS[key]) if key.startswith('wood') or key == 'rail': modifier = obj.modifiers.new('Subtle edge wear', enum_value(bpy.types.Modifier, 'type', 'BEVEL')) modifier.width = .006 modifier.segments = 1 obj['materialBatch'] = key def root_for(spec, kind, gallery): coll = bpy.data.collections.new(spec['id']) SCENE.collection.children.link(coll) root = bpy.data.objects.new(spec['id'], None) coll.objects.link(root) root.location = gallery root['assetId'] = spec['id'] root['dimensionsEstimated'] = True root['source'] = spec['source'] root['kind'] = kind root['units'] = 'metres' root['generationSpecJson'] = json.dumps(spec) root.asset_mark() root.asset_data.description = 'Gwanaksan ' + kind + '; estimated dimensions; photo-informed approximation.' ASSETS.append({'root': root, 'spec': spec, 'kind': kind, 'supports': []}) return root def tower(spec, index): root = root_for(spec, 'tower', ((index-2)*12, 17, 0)) m = Meshes() h, w = spec['height'], spec['width'] levels = 12 def half(t): return w/2 * (1-2.5*t if t < .28 else .3 - .1*(t-.28)/.72) def corner(t, j): r = half(t) return Vector(([-r,r,r,-r][j],[-r,-r,r,r][j],t*h)) for i in range(levels): t, t1 = i/levels, (i+1)/levels color = 'galvanized' if index == 0 or index == 2 else ('red' if (i//2)%2 == 0 else 'galvanized') for j in range(4): k = (j+1)%4 a,b,c,d = corner(t,j),corner(t,k),corner(t1,j),corner(t1,k) m.beam(color,a,c,.075 if index == 0 else .095) m.beam(color,a,b,.055) m.beam(color,a,d,.034) m.beam(color,b,c,.034) if i == levels-1: m.beam(color,c,d,.055) # Base plates and anchors; terrain foundation itself stays in the application. for j in range(4): x,y,_ = corner(0,j) m.box('dark',(x,y,.035),(.38,.38,.07)) for dx in [-.135,.135]: for dy in [-.135,.135]: m.beam('galvanized',(x+dx,y+dy,.07),(x+dx,y+dy,.13),.026,6) # Maintenance platforms, open in the centre, with railings. for fraction in [.32,.64,.93]: z = h*fraction r = half(fraction)+.43 for side in [-1,1]: m.box('galvanized',(0,side*(r-.13),z),(2*r,.26,.085)) m.box('galvanized',(side*(r-.13),0,z),(.26,2*r-.52,.085)) for j in range(4): a = Vector(([-r,r,r,-r][j],[-r,-r,r,r][j],z)) b = Vector(([-r,r,r,-r][(j+1)%4],[-r,-r,r,r][(j+1)%4],z)) for v in [a,b,(a+b)/2]: m.beam('galvanized',v,v+Vector((0,0,.95)),.022) for dz in [.48,.95]: m.beam('galvanized',a+Vector((0,0,dz)),b+Vector((0,0,dz)),.019) # Continuous service ladder and cable trunk. for x in [-.22,.22]: m.beam('dark',(x,0,.15),(x,0,h-.1),.025) for n in range(int(h/.32)): z = .25+n*.32 m.beam('galvanized',(-.22,0,z),(.22,0,z),.017,6) m.beam('dark',(.32,.08,.1),(.32,.08,h),.045) # Dish covers and radial brackets, distributed by height and azimuth. dish_count = 3 if index == 0 else 8 for n in range(dish_count): fraction = (.40+n*.16) if index == 0 else (.17+n*.065) z = h*fraction theta = (n*137.5 + index*31)*math.pi/180 direction = Vector((math.cos(theta),math.sin(theta),0)) base = direction*half(fraction)+Vector((0,0,z)) center = base+direction*.65 radius = .27 if index == 0 else .42+(n%3)*.13 m.beam('galvanized',base,center,.05) m.beam('dark',center-direction*.08,center+direction*.03,radius,20) m.beam('white',center+direction*.03,center+direction*.16,radius*.98,24,radius*.9) for t in [.56,.75,.87]: x = half(t)+.18 m.box('white',(x,0,t*h),(.22,.28,1.45 if index == 0 else 1.9)) m.beam('galvanized',(half(t),0,t*h),(x,0,t*h),.04) # Keep the original four-metre antenna extension. for n in range(4): m.beam('red' if index and n%2 == 0 else 'white',(0,0,h+n),(0,0,h+n+1),.11 if index == 0 else .17,12) for z in [h+.4,h+1.4,h+2.4]: m.beam('dark',(-.25,0,z),(.25,0,z),.024) m.finish(root) def clipped(poly, floor): out = [] for a,b in zip(poly, poly[1:]+poly[:1]): if a.z >= floor: out.append(a) if (a.z >= floor) != (b.z >= floor): out.append(a+(b-a)*((floor-a.z)/(b.z-a.z))) return out def radar(): spec = {'id':'weather-radar', **SPECS['radar']} root = root_for(spec,'radar',(-17,-8,0)) m = Meshes() h,r = spec['buildingHeight'],spec['radius'] m.beam('concrete',(0,0,0),(0,0,h-.2),4,32,3.7) # Ribbed green cladding accents and concrete belt courses. for z in [.18,2.8,5.5]: m.beam('concrete',(0,0,z),(0,0,z+.18),4-.3*z/h+.055,32) for n in range(32): a = n*math.tau/32 p = Vector((math.cos(a),math.sin(a),0)) m.beam('green',p*3.97+Vector((0,0,.35)),p*3.73+Vector((0,0,h-.25)),.07,6) m.beam('green',(0,0,h-.22),(0,0,h+.02),4.4,48) m.beam('white',(0,0,h+.02),(0,0,h+.15),4.43,48) # Balcony ring rails. for n in range(48): a,b = n*math.tau/48,(n+1)*math.tau/48 p,q = Vector((4.36*math.cos(a),4.36*math.sin(a),h+.15)),Vector((4.36*math.cos(b),4.36*math.sin(b),h+.15)) m.beam('green',p,p+Vector((0,0,.85)),.022) for dz in [.43,.85]: m.beam('green',p+Vector((0,0,dz)),q+Vector((0,0,dz)),.019) m.box('dark',(0,-3.96,1.2),(1.25,.12,2.4)) m.box('green',(0,-4.04,1.2),(1.08,.07,2.25)) m.beam('galvanized',(.38,-4.09,1.03),(.38,-4.09,1.35),.024) m.box('concrete',(0,-4.18,.09),(1.5,.5,.18)) for n in range(9): m.box('dark',(.95,-3.85,2.9+n*.075),(.65,.10,.035)) # Dual of an icosphere creates hexagonal/pentagonal panels as in photo-09. bm = bmesh.new() bmesh.ops.create_icosphere(bm,subdivisions=3,radius=1) bm.verts.ensure_lookup_table() center = Vector((0,0,h+r*.8)) for index,v in enumerate(bm.verts): axis = v.co.normalized() u = axis.cross(Vector((0,0,1)) if abs(axis.z)<.95 else Vector((1,0,0))).normalized() w = axis.cross(u) points = [f.calc_center_median().normalized() for f in v.link_faces] points.sort(key=lambda p: math.atan2(p.dot(w),p.dot(u))) polygon = clipped([center+p*r for p in points], h+.17) if len(polygon)<3: continue centroid = sum(polygon,Vector())/len(polygon) # Slight inset leaves readable dark joints with no photographic texture. outer = [centroid+(p-centroid)*.985+axis*.018 for p in polygon] inner = [p-axis*.04 for p in polygon] n = len(polygon) m.add('seam',polygon+inner,[tuple(range(n)),tuple(reversed(range(n,2*n)))] + [(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]) m.add('panel'+str(index%3),outer,[tuple(range(n))]) bm.free() m.finish(root) def deck(spec,index): root = root_for(spec,'deck', (index*15,-9,4)) m = Meshes() w,L,run,drop = (spec[k] for k in ['width','length','stairLength','stairDrop']) thickness = SPECS['deck']['thickness'] rail_h = SPECS['railing']['height'] supports = ASSETS[-1]['supports'] # Blender +Y corresponds to Three.js -Z (the stair direction). def plank(y,z,depth,index): m.box('wood'+str(index%4),(0,y,z-thickness/2),(w,depth,thickness)) # Shallow grooves and visible fasteners, all batched by material. for offset in [-.28,0,.28]: m.box('groove',(0,y+offset*depth,z+.0005),(w-.035,.004,.001)) for x in [-w/2+.12,w/2-.12]: m.beam('galvanized',(x,y,z),(x,y,z+.006),.012,6) count = math.ceil(L/SPECS['deck']['plankWidth']) for i in range(count): plank(-L/2+(i+.5)*L/count,0,L/count-.009,i) steps = math.ceil(drop/.18) for i in range(1,steps+1): y,z = L/2+(i-.5)*run/steps,-i*drop/steps plank(y,z,run/steps+.015,i) m.box('dark',(0,y-run/(2*steps),z-.08),(w-.06,.025,.12)) # Joists and stair stringers remain fixed geometry. for x in [-w*.37,0,w*.37]: m.box('dark',(x,0,-thickness-.085),(.11,L,.17)) m.beam('dark',(x,L/2,-.27),(x,L/2+run,-drop-.27),.09,4) for i in range(math.ceil(L/.9)+1): y = -L/2+i*L/math.ceil(L/.9) m.box('dark',(0,y,-.27),(w,.08,.13)) seen = set() def railing(a,b): a,b = Vector(a),Vector(b) count = math.ceil((b-a).length/SPECS['railing']['maxSpan']) for i in range(count+1): p = a.lerp(b,i/count) key = tuple(round(v,5) for v in p) if key in seen: continue seen.add(key) m.beam('rail',p+Vector((0,0,-.32)),p+Vector((0,0,rail_h)),.072,8) m.beam('rail',p+Vector((0,0,rail_h)),p+Vector((0,0,rail_h+.025)),.078,8) m.box('dark',p+Vector((0,0,-.2)),(.19,.19,.05)) supports.append({'position':[p.x,p.z-.32,-p.y],'postWidth':SPECS['railing']['postWidth']}) for dz in [.5,rail_h-.035]: m.beam('rail',a+Vector((0,0,dz)),b+Vector((0,0,dz)),.045,4) for x in [-w/2,w/2]: railing((x,-L/2,0),(x,L/2,0)) railing((x,L/2,0),(x,L/2+run,-drop)) m.finish(root) root['terrainSupportAnchorsJson'] = json.dumps(supports) if not any(obj.get('assetId') for obj in SCENE.objects): for index,spec in enumerate(SPECS['towers']): tower(spec,index) radar() for index,spec in enumerate(SPECS['decks']): deck(spec,index) else: specs = {s['id']:s for s in SPECS['towers'] + SPECS['decks']} specs['weather-radar'] = {'id':'weather-radar', **SPECS['radar']} for obj in SCENE.objects: if obj.get('assetId'): spec = specs[obj['assetId']] if json.loads(obj['generationSpecJson']) != spec: raise ValueError('Dimension snapshot changed: regenerate in a new Blender file; existing geometry was preserved.') anchors = json.loads(obj.get('terrainSupportAnchorsJson', '[]')) ASSETS.append({'root':obj,'spec':spec,'kind':obj['kind'],'supports':anchors}) bpy.context.view_layer.update() print('Created',len(ASSETS),'assets;',sum(len(a['root'].children) for a in ASSETS),'material batches') def export_assets(): manifest = {'version':1,'units':'metres','upAxis':'Y','northAxis':'-Z', 'dimensionsEstimated':True,'photographsEmbedded':False, 'sourceConstants':'src/constants/structures.ts','assets':[]} # Blender 5.2 exposes these dynamic items through the exporter callback, not RNA. import io_scene_gltf2 formats = io_scene_gltf2.get_format_items(None, bpy.context) fmt = next(item[0] for item in formats if item[0] == 'GLB') for asset in ASSETS: root,spec = asset['root'],asset['spec'] original = root.location.copy() root.location = (0,0,0) bpy.context.view_layer.update() for obj in SCENE.objects: obj.select_set(False) root.select_set(True) for obj in root.children: obj.select_set(True) bpy.context.view_layer.objects.active = root try: bpy.ops.export_scene.gltf(filepath=str(OUTPUT/(root.name+'.glb')),export_format=fmt, use_selection=True,use_active_scene=True,export_apply=True,export_yup=True,export_extras=True, export_animations=False,export_cameras=False,export_lights=False) finally: root.location = original manifest['assets'].append({'id':root.name,'file':root.name+'.glb','kind':asset['kind'], 'latitude':spec['latitude'],'longitude':spec['longitude'],'heading':spec.get('heading',0), 'dimensionsEstimated':True,'positionEstimated':spec.get('estimatedPosition',True), 'source':spec['source'],'dimensions':{k:spec[k] for k in ['height','width','length','radius','buildingHeight','stairLength','stairDrop'] if k in spec}, 'origin':'upper deck surface' if asset['kind']=='deck' else 'base contact plane', 'terrainSupportAnchors':asset['supports']}) bpy.context.view_layer.update() (OUTPUT/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding='utf-8') print('Exported',len(manifest['assets']),'GLBs and manifest') def setup_studio(): if SCENE.camera: return SCENE.camera studio = bpy.data.collections.new('Presentation only - excluded from exports') SCENE.collection.children.link(studio) groundmat = material('Studio slate','23333d',.9) mesh = bpy.data.meshes.new('Studio ground') mesh.from_pydata([(-2000,-2000,-.45),(2000,-2000,-.45),(2000,2000,-.45),(-2000,2000,-.45)],[],[(0,1,2,3)]) ground = bpy.data.objects.new('Studio ground',mesh) studio.objects.link(ground) ground.data.materials.append(groundmat) world = bpy.data.worlds.new('Summit studio world') SCENE.world = world world.use_nodes = True bg = next(n for n in world.node_tree.nodes if n.type=='BACKGROUND') bg.inputs['Color'].default_value = (.38,.46,.55,1) bg.inputs['Strength'].default_value = .55 for name,loc,power,size in [('Key',(-25,-35,65),85000,35),('Fill',(40,-10,35),45000,25),('Rim',(0,45,55),110000,30)]: data = bpy.data.lights.new('Studio '+name,enum_value(bpy.types.Light,'type','AREA')) obj = bpy.data.objects.new(data.name,data) studio.objects.link(obj) obj.location = loc obj.rotation_euler = (Vector((0,5,15))-obj.location).to_track_quat('-Z','Y').to_euler() data.energy = power data.shape = enum_value(bpy.types.AreaLight,'shape','DISK') data.size = size data = bpy.data.cameras.new('Asset preview camera') camera = bpy.data.objects.new(data.name,data) studio.objects.link(camera) data.type = enum_value(bpy.types.Camera,'type','ORTHO') data.lens = 50 SCENE.camera = camera SCENE.render.resolution_percentage = 100 SCENE.render.image_settings.file_format = enum_value(bpy.types.ImageFormatSettings,'file_format','PNG') try: SCENE.render.engine = 'CYCLES' SCENE.cycles.samples = 24 SCENE.cycles.use_denoising = True except TypeError: pass SCENE.render.film_transparent = False preview_camera((64,-92,64),(0,8,23),96,1600,1200) return camera def preview_camera(location,target,scale,width=1400,height=1100): camera = SCENE.camera camera.location = location camera.rotation_euler = (Vector(target)-camera.location).to_track_quat('-Z','Y').to_euler() camera.data.ortho_scale = scale SCENE.render.resolution_x = width SCENE.render.resolution_y = height def save_source(): bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE/'summit-structures.blend')) print('Saved editable Blender source') # MCP executes each request in a fresh namespace. Retain this task's helpers explicitly. bpy.app.driver_namespace['summit_assets'] = globals()