Files
sunrise/scripts/build-summit-assets.py
T

444 lines
19 KiB
Python

"""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()