"""Modern architectural atoms. Blender 5.2 headless, deterministic Y-up source.
blender -b --factory-startup --python-exit-code 1 --python build_kit.py
External materials are prepared with prepare_materials.py. -- --preflight is tiny.
"""
import bpy,bmesh,math,json,sys,time,hashlib,struct,random
from pathlib import Path
from mathutils import Vector,Matrix
ROOT=Path(__file__).resolve().parent
sys.path.insert(0,str(ROOT))
import geometry as geo
from geometry import Mesh,box,cyl,prism
START=time.time();random.seed(2710)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.samples=32;scene.cycles.use_denoising=True
try:
 prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='OPTIX';prefs.get_devices()
 for d in prefs.devices:d.use=d.type!='CPU'
 scene.cycles.device='GPU'
except Exception:scene.cycles.device='CPU'
scene.unit_settings.system='METRIC';scene.view_settings.view_transform='AgX';scene.render.image_settings.file_format='PNG'
shelf=json.loads((ROOT/'materials.json').read_text())['materials'];NAMES=list(shelf)
for name,spec in shelf.items():
 m=bpy.data.materials.new(name);m.use_nodes=True;m.diffuse_color=(*spec['base_color_mean'],1)
 bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=m.diffuse_color;bs.inputs['Roughness'].default_value=spec['roughness_mean'];bs.inputs['Metallic'].default_value=spec['metalness_mean']
 geo.MATS.append(m);geo.TILES.append(spec['tile_size_metres'])
R,RG,C,L,LC,B,G,S,Z,GR,P,D=range(12)
LIMITS=dict(window_tall=2000,window_std=2000,window_ribbon_1m=600,ribbon_end_mullion=80,glazing_slider=2500,door_pivot=2500,shopfront_bay=2500,balcony_steel=3000,canopy_concrete=300,parapet_coping_1m=80,parapet_corner=100,roof_rail_1m=200,downpipe_square_1m=60,rainwater_head=200,downpipe_shoe=60,roof_skylight=400,roof_vent=200,planter_concrete=1500,bike_rack=400)
OBJECTS=[];META={}
def finish(g,origin='Bottom centre on facade outer plane; X horizontal, Y up, +Z outward.',opening=None,**extra):
 ob=g.finish();OBJECTS.append(ob);META[g.name]={'node_name':g.name,'origin_rule':origin,**extra}
 if opening:META[g.name]['opening']={'width':opening[0],'height':opening[1],'wall_thickness':.35,'bottom_center':[0,0,0],'meaning':'Assembler clear wall cutout. Frame/lining occupy its perimeter; visible glass is smaller.'}
 return ob

def section(g,profile,a,b,mat,axis='x'):
 # Closed profile sweep, shared corners and no duplicated coincident faces.
 if axis=='x':vs=[(t,y,z) for t in [a,b] for y,z in profile]
 elif axis=='y':vs=[(x,t,z) for t in [a,b] for x,z in profile]
 n=len(profile);fs=[tuple(range(n-1,-1,-1)),tuple(range(n,n*2))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 g.add(vs,fs,mat)

def sweep(g,points,r,mat,n=6):
 vs=[]
 for i,p in enumerate(points):
  t=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
  q=t.to_track_quat('Z','Y')
  vs.extend(Vector(p)+q@Vector((r*math.cos(j*math.tau/n),r*math.sin(j*math.tau/n),0)) for j in range(n))
 fs=[tuple(range(n-1,-1,-1)),tuple(range(len(vs)-n,len(vs)))]
 for k in range(len(points)-1):
  for j in range(n):fs.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
 g.add(vs,fs,mat,True)

def frame(g,w,h,z,th=.055,depth=.09,b=.002):
 for x in [-w/2+th/2,w/2-th/2]:box(g,(x,h/2,z),(th,h,depth),B,b)
 for y in [th/2,h-th/2]:box(g,(0,y,z),(w-2*th,th,depth),B,b)

def reveal(g,w,h,sill=True):
 # Lining on all three reveals; front face is at z=0, back at -0.35.
 for x in [-w/2+.006,w/2-.006]:box(g,(x,h/2,-.175),(.012,h,.35),B,.001)
 box(g,(0,h-.006,-.175),(w-.024,.012,.35),B,.001)
 if sill:
  section(g,[(-.006,-.35),(-.006,.085),(-.026,.095),(-.044,.095),(-.044,.083),(-.025,.077),(-.018,-.35)],-w/2-.025,w/2+.025,B)

def window(name,h,sill):
 g=Mesh(name);reveal(g,1.2,h);frame(g,1.2,h,-.245)
 # Recessed gasket, sash rebate, insulating glass and tilt-turn hardware.
 for x in [-.531,.531]:box(g,(x,h/2,-.202),(.018,h-.12,.018),S,.001)
 for y in [.07,h-.07]:box(g,(0,y,-.202),(1.044,.016,.018),S,.001)
 box(g,(0,h/2,-.228),(1.052,h-.14,.022),G,.001)
 for y in [.22,h-.22]:box(g,(-.55,y,-.184),(.02,.09,.027),B,.003)
 box(g,(.51,min(h*.52,1.1),-.158),(.018,.15,.025),B,.003)
 finish(g,opening=(1.2,h),sill_height_metres=sill,storey_rule='Same master on all storeys; never scale height.')
window('window_tall',2.4,.10);window('window_std',1.5,.90)
g=Mesh('window_ribbon_1m')
for y in [.035,1.265]:box(g,(.5,y,-.235),(1,.07,.11),B,.002)
box(g,(.025,.65,-.225),(.05,1.16,.13),B,.002)
box(g,(.525,.65,-.245),(.95,1.16,.022),G,0)
# Continuous folded aluminium sill and head lining, no end cap mullion.
section(g,[(-.006,-.35),(-.006,.07),(-.023,.08),(-.035,.08),(-.035,.068),(-.018,-.35)],0,1,B)
box(g,(.5,1.294,-.175),(1,.012,.35),B,0)
finish(g,'Left edge of run, bottom on facade; X=0..1. Repeat at exactly 1 m.',opening=(1,1.3),joint_rule='Left mullion only, X=0..0.05. Close right run edge with ribbon_end_mullion at X=N. Cut continuous N x 1.30 m opening.')
g=Mesh('ribbon_end_mullion');box(g,(-.025,.65,-.225),(.05,1.16,.13),B,.002);box(g,(-.006,.65,-.175),(.012,1.3,.35),B,0)
finish(g,'Right edge of ribbon opening at X=N, Y=bottom. Mullion extends toward -X.',mate='window_ribbon_1m')
g=Mesh('glazing_slider');reveal(g,2.8,2.4);frame(g,2.8,2.4,-.23,.055,.14)
for x,z in [(-.655,-.19),(.655,-.27)]:
 for a in [-.655,.655]:box(g,(x+a,1.2,z),(.035,2.26,.048),B,.002)
 for y in [.09,2.31]:box(g,(x,y,z),(1.275,.038,.048),B,.002)
 box(g,(x,1.2,z-.013),(1.275,2.18,.016),G,0)
for z in [-.17,-.25]:box(g,(0,.046,z),(2.67,.012,.016),S,.001)
for x,z in [(-.04,-.145),(.04,-.22)]:box(g,(x,1.08,z),(.018,.24,.035),B,.003)
finish(g,opening=(2.8,2.4),threshold_height_metres=.055,leaves='Two overlapping tracks with central interlock, fixed exported pose.')
g=Mesh('door_pivot');reveal(g,1.2,2.4,False);frame(g,1.2,2.4,-.25,.045,.10)
box(g,(0,.012,-.17),(1.11,.024,.36),B,.002)
root=finish(g,opening=(1.2,2.4),pivot_axis='child door_leaf rotates local +Y; pivot inset 0.16 m from left leaf edge')
g=Mesh('door_leaf');pivot=-.395
box(g,(-pivot,1.2,0),(1.105,2.29,.068),D,.004)
# Flush perimeter edging and vertical bar bolted at two standoffs.
for x in [-.544,.544]:box(g,(x-pivot,1.2,.035),(.012,2.28,.008),B,.001)
for y in [.28,1.38]:cyl(g,(.39-pivot,y,.034),(.39-pivot,y,.091),.012,B,8)
cyl(g,(.39-pivot,.22,.091),(.39-pivot,1.44,.091),.013,B,10)
box(g,(.39-pivot,1.62,.04),(.04,.055,.012),B,.004)
for y in [.047,2.353]:cyl(g,(0,y-.008,0),(0,y+.008,0),.025,S,8)
leaf=g.finish();leaf.parent=root;leaf.location=(pivot,0,-.235);OBJECTS.append(leaf)
g=Mesh('shopfront_bay')
# Frameless panels: floor/head channels only, silicone vertical joints.
for y in [.024,2.976]:box(g,(0,y,-.21),(3,.048,.085),B,.003)
for x,w in [(-1.008,.946),(.491,1.988)]:box(g,(x,1.5,-.21),(w,2.884,.024),G,.001)
box(g,(-.521,1.5,-.21),(.012,2.90,.029),S,0)
for y in [.18,2.82]:box(g,(-1.446,y,-.178),(.085,.095,.044),B,.004)
for x in [-.635,-.61]:cyl(g,(x,.95,-.147),(x,1.65,-.147),.011,B,8)
for y in [1.01,1.59]:cyl(g,(-.623,y,-.21),(-.623,y,-.147),.010,B,6)
box(g,(-1.0,.045,-.21),(.80,.032,.12),S,.002)
finish(g,opening=(3,3),door_clear_width_metres=.946,door_position='Left third',note='Frameless glass with narrow head/floor channels and patch hinges; opaque reflective material.')
g=Mesh('balcony_steel');box(g,(0,-.105,.70),(2.4,.21,1.4),C,.018)
# Continuous fascia and anchor plates; slab top = storey floor.
box(g,(0,-.047,1.406),(2.40,.09,.018),Z,.003)
for x in [-1.14,1.14]:
 for z in [.10,1.32]:
  box(g,(x,.008,z),(.115,.016,.115),S,.003);box(g,(x,.555,z),(.04,1.1,.04),S,.002)
  for dx in [-.034,.034]:cyl(g,(x+dx,.016,z), (x+dx,.027,z),.007,Z,6)
for y in [.14,1.105]:
 box(g,(0,y,1.32),(2.32,.035,.035),S,.002)
 for x in [-1.14,1.14]:box(g,(x,y,.71),(.035,.035,1.18),S,.002)
for x in [(-1.04+i*.104) for i in range(21)]:box(g,(x,.62,1.32),(.016,.945,.016),S,0)
for x in [-1.14,1.14]:
 for j in range(1,11):box(g,(x,.62,.10+j*.111),(.016,.945,.016),S,0)
finish(g,'Wall centre at finished balcony floor; slab top Y=0; cantilever Z=0..1.4.',width_metres=2.4,depth_metres=1.4,railing_height_metres=1.1225,slab_thickness_metres=.21,railing_clear_spacing_max_metres=.095)
g=Mesh('canopy_concrete')
# Thin slab has a formed drip recess under its front edge.
section(g,[(0,0),(0,1.4),(-.14,1.4),(-.14,1.32),(-.125,1.32),(-.125,1.30),(-.14,1.30),(-.14,0)],-1,1,C)
finish(g,'Wall centre at canopy upper bearing plane; top Y=0; projects +Z.',width_metres=2,depth_metres=1.4,thickness_metres=.14)
# Folded zinc section: 350 mm wall under a 410 mm cap, downturned drip hems.
cap=[(.018,-.205),(.034,0),(.018,.205),(-.048,.205),(-.048,.192),(.006,.192),(.022,0),(.006,-.192),(-.048,-.192),(-.048,-.205)]
g=Mesh('parapet_coping_1m');section(g,cap,0,1,Z)
finish(g,'Start on parapet wall centreline at top; length X=0..1; wall Z=-.175..+.175.',length_metres=1,wall_thickness_metres=.35,joint_rule='Butt 1m runs; continuous folded cross-section. Corner atom has 0.5m legs.')
g=Mesh('parapet_corner')
# One continuous 90-degree mitered sweep; section offsets grow at diagonal miter.
vs=[]
for ring in range(3):
 for y,z in cap:
  vs.append(((-.5,y,z) if ring==0 else ((z,y,z) if ring==1 else (z,y,-.5))))
n=len(cap);fs=[tuple(range(n-1,-1,-1)),tuple(range(2*n,3*n))]
for k in range(2):
 for j in range(n):fs.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
g.add(vs,fs,Z)
finish(g,'Outer 90-degree corner on intersecting wall centrelines; incoming -X, outgoing -Z.',leg_length_metres=.5,wall_thickness_metres=.35,joint_rule='Mate straight-run end at X=-.5; rotate outgoing run 90 degrees about Y, start at Z=-.5. Miter is continuous, no overlapping coplanar plates.')
g=Mesh('roof_rail_1m')
# Half-open repeating module: terminal post supplied by next module; final post is noted.
for y in [.15,1.105]:box(g,(.5,y,.03),(1,.026,.032),S,0)
box(g,(.018,.55,.03),(.036,1.1,.036),S,0)
for j in range(1,10):box(g,(j*.1,.623,.03),(.012,.92,.012),S,0)
box(g,(.035,.01,.03),(.07,.02,.10),S,0)
finish(g,'Start of railing, finished deck Y=0; X=0..1.',length_metres=1,joint_rule='Left post belongs to this module. Close final end with an assembler-generated 36x1100x36 mm Steel_dark post at X=N-.018; top/bottom rails butt at exact metre.')
g=Mesh('downpipe_square_1m')
# Open square tube, capped annular rings at ends, 32 tris.
outer=[(-.05,.025),(.05,.025),(.05,.125),(-.05,.125)];inner=[(-.045,.030),(.045,.030),(.045,.120),(-.045,.120)]
vs=[(x,y,z) for y in [0,1] for ring in [outer,inner] for x,z in ring];fs=[]
for j in range(4):
 k=(j+1)%4;fs.extend([(j,k,k+8,j+8),(4+k,4+j,12+j,12+k),(j,4+j,4+k,k),(8+k,12+k,12+j,8+j)])
g.add(vs,fs,Z);box(g,(0,.5,.075),(.112,.026,.112),Z,0);box(g,(0,.5,.013),(.035,.04,.026),S,0)
finish(g,'Bottom on facade Z=0; tube Y=0..1, centre Z=.075.',length_metres=1,section_metres=.1,joint_rule='Stack at integer Y with matching open square ends; shoe inlet/head outlet share section and Z=.075.')
g=Mesh('rainwater_head')
# Open box collector with folded rim, outlet at Y=0 and hidden gutter inlet at back.
for x in [-.15,.15]:box(g,(x,.19,.095),(.012,.38,.20),Z,0)
box(g,(0,.19,.195),(.288,.38,.012),Z,.002)
box(g,(0,.10,-.005),(.288,.20,.012),Z,0)
for x in [-.101,.101]:box(g,(x,.006,.075),(.086,.012,.20),Z,0)
for z in [.01,.15]:box(g,(0,.006,z),(.116,.012,.03),Z,0)
for x in [-.15,.15]:box(g,(x,.381,.095),(.020,.015,.218),Z,0)
box(g,(0,.381,.199),(.288,.015,.020),Z,0)
finish(g,'Outlet bottom at Y=0, facade Z=0. Top Y=.389; pipe joins directly below.',joint_rule='Place at parapet; back upper opening accepts hidden gutter. Pipe top at head origin; 100mm outlet.')
g=Mesh('downpipe_shoe')
# Hollow bent square shoe: three annular rings, open outlet faces outward.
vs=[]
for y,z,ang in [(.24,.075,0),(.09,.075,0),(.012,.205,math.pi/3)]:
 for r in [.05,.045]:
  for x,t in [(-r,-r),(r,-r),(r,r),(-r,r)]:vs.append((x,y-t*math.sin(ang),z+t*math.cos(ang)))
fs=[]
for k in range(2):
 for j in range(4):
  jj=(j+1)%4;fs.extend([(k*8+j,k*8+jj,(k+1)*8+jj,(k+1)*8+j),(k*8+4+jj,k*8+4+j,(k+1)*8+4+j,(k+1)*8+4+jj)])
for j in range(4):jj=(j+1)%4;fs.extend([(j,jj,4+jj,4+j),(16+jj,16+j,20+j,20+jj)])
g.add(vs,fs,Z)
finish(g,'Wall datum at shoe low outlet centre; inlet at Y=.24, Z=.075.',joint_rule='Pipe bottom joins inlet at Y=.24. Place shoe origin at least .06m above paving.')
g=Mesh('roof_skylight')
for x in [-.555,.555]:box(g,(x,.09,0),(.09,.18,1.2),Z,.006)
for z in [-.555,.555]:box(g,(0,.09,z),(1.02,.18,.09),Z,.006)
for x in [-.527,.527]:box(g,(x,.187,0),(.034,.024,1.09),S,0)
for z in [-.527,.527]:box(g,(0,.187,z),(1.02,.024,.034),S,0)
box(g,(0,.181,0),(1.02,.018,1.02),G,.002)
finish(g,'Centre on finished roof; upstand starts Y=0.',size_metres=[1.2,.199,1.2],roof_cutout_metres=[1.02,1.02])
g=Mesh('roof_vent');box(g,(0,.015,0),(.36,.03,.36),Z,.005)
cyl(g,(0,.03,0),(0,.34,0),.095,Z,10)
for x in [-.09,.09]:box(g,(x,.36,0),(.018,.12,.018),S,0)
cyl(g,(0,.385,0),(0,.455,0),.17,Z,10,r2=.065)
finish(g,'Centre of flashing on roof Y=0.',height_metres=.455)
g=Mesh('planter_concrete')
box(g,(0,.055,0),(1.6,.11,.55),C,.012)
for x in [-.76,.76]:box(g,(x,.32,0),(.08,.53,.55),C,.008)
for z in [-.235,.235]:box(g,(0,.32,z),(1.44,.53,.08),C,.008)
g.color=(.24,.19,.11,1);box(g,(0,.50,0),(1.44,.045,.39),GR,0)
# Tapered closed triangular blades, bent under their own weight, varied seed.
for j in range(155):
 x=random.uniform(-.67,.67);z=random.uniform(-.17,.17);h=random.uniform(.27,.75);lean=random.uniform(-.18,.18);leanZ=random.uniform(-.16,.16);w=random.uniform(.007,.018)
 g.color=random.choice([(.20,.27,.065,1),(.30,.35,.10,1),(.42,.40,.16,1),(.48,.45,.22,1)])
 a=(x-w,.51,z);b=(x+w,.51,z);c=(x,.51,z+.004);tip=(x+lean,.51+h,z+leanZ)
 g.add([a,b,c,tip],[(0,1,2),(0,3,1),(1,3,2),(2,3,0)],D)
finish(g,'Bottom centre on paving; long dimension X.',length_metres=1.6,planter_height_metres=.585,vegetation='155 deterministic closed tapered blades; COLOR_0 tints neutral Door_paint. No extra shelf names.')
g=Mesh('bike_rack')
for x in [-.55,0,.55]:
 points=[(x,0,-.34),(x,.62,-.34),(x,.74,-.29),(x,.79,-.18),(x,.79,.18),(x,.74,.29),(x,.62,.34),(x,0,.34)]
 sweep(g,points,.022,S,6)
 for z in [-.34,.34]:box(g,(x,.008,z),(.11,.016,.10),Z,0)
finish(g,'Bottom centre on paving; three inverted-U stands across X.',size_metres=[1.21,.812,.78])

# Exact contracts, triangulated geometry and top-level identity transforms.
bpy.context.view_layer.update();entries=[]
for ob in OBJECTS:
 if ob.parent:continue
 children=[ob]+list(ob.children);pts=[o.matrix_local@v.co for o in children for v in o.data.vertices]
 tris=sum(len(o.data.polygons) for o in children)
 assert tris<=LIMITS[ob.name],(ob.name,tris,LIMITS[ob.name])
 for o in children:
  assert all(math.isfinite(c) for v in o.data.vertices for c in v.co)
  assert all(p.area>1e-12 for p in o.data.polygons),(o.name,'zero-area')
  bm=bmesh.new();bm.from_mesh(o.data);assert all(e.is_manifold for e in bm.edges),(o.name,'open solid');bm.free()
 entry=META[ob.name];entry.update(triangles=tris,triangle_budget=LIMITS[ob.name],bounding_box={'min':[round(min(v[k] for v in pts),6) for k in range(3)],'max':[round(max(v[k] for v in pts),6) for k in range(3)]},materials=sorted(set(o.data.materials[p.material_index].name for o in children for p in o.data.polygons)))
 if ob.children:entry['children']=[{'node_name':leaf.name,'translation':list(leaf.location),'origin_rule':'Inset vertical pivot axis; rotate local Y.'}]
 entries.append(entry);print('ATOM',ob.name,tris,flush=True)
assert len(entries)==19
bpy.ops.object.select_all(action='DESELECT')
for o in OBJECTS:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'kit.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=False,export_materials='EXPORT',export_texcoords=True,export_normals=True,export_tangents=True,export_cameras=False,export_lights=False)
data=(ROOT/'kit.glb').read_bytes();ln=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+ln]);assert not doc.get('images')
roots=[doc['nodes'][i] for i in doc['scenes'][doc.get('scene',0)]['nodes']];assert set(n['name'] for n in roots)==set(LIMITS)
assert all(not any(k in n for k in ['translation','rotation','scale','matrix']) for n in roots)
kit={'schema':'forge.atom-kit.v1','units':'metres','up':'+Y','facade_outward':'+Z','facade_horizontal':'+X','wall_thickness_metres':.35,'storey_height_metres':3,'uv_rule':'UV0 = orthonormal surface metres / shelf tile_size_metres. Do not repeat/divide again. Global facade U,V for generated walls. All masters have identity transforms.','atoms':entries,'total_triangles':sum(e['triangles'] for e in entries),'glb_bytes':len(data),'material_binding':'materials.json','images_embedded':0,'status':'review-only'}
(ROOT/'kit.json').write_text(json.dumps(kit,indent=2))
(ROOT/'build_evidence.json').write_text(json.dumps({'builder_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'glb_sha256':hashlib.sha256(data).hexdigest(),'source_geometry':'geometry.py adapted from read-only Breton example','geometry_checks':{'finite':True,'nondegenerate':True,'closed_components':True,'all_budgets':True,'identity_roots':True}},indent=2))
# Source saved with actual Y-up geometry and external material slots.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'kit_source.blend'))
# Round-trip and render exported geometry with external shelf.
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'kit.glb'))
masters={o.name:o for o in bpy.context.scene.objects if o.parent is None};
for m in bpy.data.materials:
 if m.name not in shelf:continue
 nt=m.node_tree;bs=nt.nodes.get('Principled BSDF');spec=shelf[m.name]
 for kind in ['BaseColor','Normal','ORM']:
  tex=nt.nodes.new('ShaderNodeTexImage');tex.image=bpy.data.images.load(str(ROOT/spec['maps'][kind]),check_existing=True);tex.image.colorspace_settings.name='sRGB' if kind=='BaseColor' else 'Non-Color';tex.extension='REPEAT'
  if kind=='BaseColor':
   attr=nt.nodes.new('ShaderNodeVertexColor');attr.layer_name='Color';mul=nt.nodes.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;nt.links.new(tex.outputs['Color'],mul.inputs[1]);nt.links.new(attr.outputs['Color'],mul.inputs[2]);nt.links.new(mul.outputs[0],bs.inputs['Base Color'])
  elif kind=='Normal':
   nn=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(tex.outputs[0],nn.inputs['Color']);nt.links.new(nn.outputs[0],bs.inputs['Normal'])
  else:
   sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(tex.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs['Green'],bs.inputs['Roughness']);nt.links.new(sep.outputs['Blue'],bs.inputs['Metallic'])
scene.world.use_nodes=True;bg=scene.world.node_tree.nodes.get('Background');bg.inputs[0].default_value=(.67,.75,.86,1);bg.inputs[1].default_value=.55
bpy.ops.object.light_add(type='AREA',location=(-3,-4,7));key=bpy.context.object;key.data.energy=1600;key.data.shape='DISK';key.data.size=6;key.rotation_euler=(Vector((0,0,1))-key.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.light_add(type='AREA',location=(4,1,5));rim=bpy.context.object;rim.data.energy=1000;rim.data.size=5;rim.rotation_euler=(-rim.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.018));floor=bpy.context.object
mat=bpy.data.materials.new('STUDIO');mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.60,.61,.60,1);mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.85;floor.data.materials.append(mat)
bpy.ops.object.camera_add();cam=bpy.context.object;scene.camera=cam;cam.data.type='ORTHO';scene.render.resolution_percentage=100

def family(ob):return [ob]+list(ob.children_recursive)
def hide(ob,value):
 for o in family(ob):o.hide_render=value

def bounds(obs):
 bpy.context.view_layer.update();pts=[o.matrix_world@Vector(c) for ob in obs for o in family(ob) if o.type=='MESH' for c in o.bound_box]
 return Vector([min(p[k] for p in pts) for k in range(3)]),Vector([max(p[k] for p in pts) for k in range(3)])
def camera(obs,width=1000,height=700,direction=(.30,-1,.35)):
 lo,hi=bounds(obs);centre=(lo+hi)/2;d=Vector(direction).normalized();cam.location=centre+d*30;cam.rotation_euler=(centre-cam.location).to_track_quat('-Z','Y').to_euler()
 q=cam.rotation_euler.to_quaternion().inverted();points=[q@(o.matrix_world@Vector(c)-centre) for ob in obs for o in family(ob) if o.type=='MESH' for c in o.bound_box]
 dx=max(p.x for p in points)-min(p.x for p in points);dy=max(p.y for p in points)-min(p.y for p in points)
 cam.data.ortho_scale=max(dx,dy*width/height)*1.22;scene.render.resolution_x=width;scene.render.resolution_y=height

def render(path):scene.render.filepath=str(ROOT/path);bpy.ops.render.render(write_still=True)
for ob in masters.values():hide(ob,True)
(ROOT/'cards').mkdir(exist_ok=True)
for name,ob in masters.items():
 hide(ob,False);lo,hi=bounds([ob]);ob.location.z-=lo.z
 camera([ob],560,460,(.32,-1,.45) if name not in ['parapet_corner','roof_skylight','bike_rack','roof_vent','canopy_concrete'] else (.5,-1,.85))
 render('cards/'+name+'.png');hide(ob,True);ob.location=(0,0,0)

def sheet(names,filename,clay=False,reverse=False):
 for ob in masters.values():hide(ob,True);ob.location=(0,0,0)
 active=[];x=0
 for name in names:
  ob=masters[name];hide(ob,False);lo,hi=bounds([ob]);w=hi.x-lo.x;ob.location=(x-lo.x,0,-lo.z);x+=w+.4;active.append(ob)
 camera(active,1800,950,(-.32,1,.4) if reverse else (.19,-1,.43))
 if clay:
  cm=bpy.data.materials.new('CLAY');cm.use_nodes=True;cm.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.36,.36,.36,1);scene.view_layers[0].material_override=cm
 render(filename);scene.view_layers[0].material_override=None
sheet(['window_tall','window_std','glazing_slider','door_pivot','shopfront_bay'],'details_openings.png')
sheet(['balcony_steel','canopy_concrete','roof_rail_1m','planter_concrete','bike_rack'],'details_exterior.png')
sheet(['parapet_coping_1m','parapet_corner','rainwater_head','downpipe_square_1m','downpipe_shoe','roof_skylight','roof_vent'],'details_roof.png')
sheet(['window_tall','glazing_slider','door_pivot','balcony_steel'],'clay_check.png',True)
sheet(['window_tall','door_pivot','balcony_steel'],'reverse_check.png',reverse=True)
print('BUILD_COMPLETE',kit['total_triangles'],len(data),'seconds',round(time.time()-START,2),flush=True)
