"""Breton reusable atoms. Blender 5.2, deterministic, no external Python packages.
Run on gex44: blender -b --python-exit-code 1 --python /root/fal-author/breton-atom-kit-v2/build_kit.py
Reads the copied, external materials shelf without modifying source assets. Delivery outputs
are rebuilt in this script's directory. -- --preflight exercises a tiny render/export/import.
Authored coordinates are already glTF Y-up; exporter axis conversion is intentionally OFF.
"""
import bpy, bmesh, math, json, sys, struct, hashlib, time
import numpy as np
from pathlib import Path
from mathutils import Vector, Matrix
ROOT=Path(__file__).resolve().parent
START=time.time()
material_doc=json.loads((ROOT/'materials.json').read_text())
shelf=material_doc['materials'];NAMES=list(shelf);TILES=[shelf[n]['tile_size_metres'] for n in NAMES]
FRAME=NAMES.index('Window_frame_paint');DOOR=NAMES.index('Door_paint');SHUTTER=NAMES.index('Shutter_paint');SIGN=NAMES.index('Sign_paint')
LIMITS={'window_std':3000,'shutters_std':1200,'window_small':1500,'window_shop':3500,'door_main':4000,'door_back':2500,'door_double':3500,**{'slate_'+v:40 for v in 'abcd'},'slate_strip_a':160,'slate_strip_b':160,'roof_underlay':12,'ridge_tile':150,**{n:250 for n in ['verge_board_1m','fascia_1m','gutter_1m','gutter_bracket','gutter_end_cap','gutter_outlet','downpipe_1m','downpipe_shoe']},'quoin_long':150,'quoin_short':150,'dormer':8000,'chimney_stack':2500,'lantern':600,'tavern_sign':1500,'bench_stone':400}
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=48;scene.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
try:
 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.render.image_settings.file_format='PNG';scene.view_settings.view_transform='AgX'
PROVENANCE={};seams={}
def read_image(path,size,color=False):
 path=Path(path);PROVENANCE[str(path)]=hashlib.sha256(path.read_bytes()).hexdigest()
 im=bpy.data.images.load(str(path),check_existing=False)
 im.colorspace_settings.name='sRGB' if color else 'Non-Color'
 im.scale(size,size);a=np.array(im.pixels[:],dtype=np.float32).reshape(size,size,4)[:,:,:3].copy();bpy.data.images.remove(im);return a

def periodic(a):
 """Moisan periodic-plus-smooth decomposition: remove only nonperiodic low frequencies.
 Then match the outer texels exactly with a narrow cubic correction (no reflected tiles).
 """
 h,w,c=a.shape;b=np.zeros_like(a)
 b[0,:,:]=a[-1,:,:]-a[0,:,:];b[-1,:,:]=-b[0,:,:]
 b[:,0,:]+=a[:,-1,:]-a[:,0,:];b[:,-1,:]-=a[:,-1,:]-a[:,0,:]
 yy,xx=np.meshgrid(np.arange(h),np.arange(w),indexing='ij')
 den=2*np.cos(2*np.pi*xx/w)+2*np.cos(2*np.pi*yy/h)-4;den[0,0]=1
 f=np.fft.fft2(b,axes=(0,1))/den[:,:,None];f[0,0,:]=0
 out=a-np.fft.ifft2(f,axes=(0,1)).real
 for axis in (0,1):
  t=np.swapaxes(out,0,axis);delta=(t[-1]-t[0])*.5
  for i in range(4):
   weight=(1-i/4)**2*(1+2*i/4);t[i]+=delta*weight;t[-1-i]-=delta*weight
 return np.clip(out,0,1).astype(np.float32)

def save_image(path,a,color=False,grey=False):
 path.parent.mkdir(parents=True,exist_ok=True);h,w=a.shape[:2]
 if a.ndim==2:a=np.repeat(a[:,:,None],3,axis=2)
 im=bpy.data.images.new(path.stem,width=w,height=h,alpha=False,float_buffer=False)
 im.colorspace_settings.name='sRGB' if color else 'Non-Color'
 rgba=np.concatenate((a,np.ones((h,w,1),np.float32)),axis=2).astype(np.float32)
 im.pixels.foreach_set(rgba.ravel());im.filepath_raw=str(path);im.file_format='PNG';im.save();bpy.data.images.remove(im)
 # PNG grayscale encoding for the height contract, using standard library only.
 if grey:
  import zlib
  rows=(np.clip(a[:,:,0],0,1)*65535+.5).astype('>u2')[::-1]
  def chunk(tag,data):return struct.pack('>I',len(data))+tag+data+struct.pack('>I',zlib.crc32(tag+data)&0xffffffff)
  raw=b''.join(b'\0'+row.tobytes() for row in rows)
  path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,16,0,0,0,0))+chunk(b'IDAT',zlib.compress(raw,9))+chunk(b'IEND',b''))
 seams[str(path.relative_to(ROOT))]={'edge_max_abs':float(max(abs(a[0]-a[-1]).max(),abs(a[:,0]-a[:,-1]).max())),'size':[w,h]}

if '--preflight' in sys.argv:
 bpy.ops.mesh.primitive_cube_add();probe=bpy.context.object
 pm=bpy.data.materials.new('PREFLIGHT');pm.use_nodes=True;probe.data.materials.append(pm)
 bake=bpy.data.images.new('probe',width=16,height=16);tex=pm.node_tree.nodes.new('ShaderNodeTexImage');tex.image=bake;pm.node_tree.nodes.active=tex
 scene.cycles.samples=1;bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',use_selection=True,export_yup=False)
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False);bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'))
 bpy.ops.object.camera_add(location=(4,-5,3));scene.camera=bpy.context.object;scene.camera.rotation_euler=(-scene.camera.location).to_track_quat('-Z','Y').to_euler()
 scene.world.color=(.5,.5,.5);scene.render.resolution_x=64;scene.render.resolution_y=64;scene.render.resolution_percentage=100;scene.render.filepath=str(ROOT/'preflight.png');bpy.ops.render.render(write_still=True)
 print('PREFLIGHT_OK',flush=True);sys.exit(0)

MATS=[]
for name in NAMES:
 m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
 avg=read_image(ROOT/shelf[name]['maps']['BaseColor'],32,True).mean(axis=(0,1))
 orm=read_image(ROOT/shelf[name]['maps']['ORM'],32).mean(axis=(0,1))
 m.diffuse_color=(*map(float,avg),1);bs.inputs['Base Color'].default_value=m.diffuse_color
 bs.inputs['Roughness'].default_value=float(orm[1]);bs.inputs['Metallic'].default_value=float(orm[2]);MATS.append(m)
print('SHELF_READY',flush=True)

class Mesh:
 def __init__(self,name):self.name=name;self.v=[];self.f=[];self.mi=[];self.uv=[];self.smooth=[]
 def add(self,verts,faces,mat,smooth=False,uv=None):
  vs=[Vector(v) for v in verts];off=len(self.v);self.v.extend(vs)
  for face in faces:
   self.f.append(tuple(off+j for j in face));self.mi.append(mat);self.smooth.append(smooth)
   if uv:self.uv.append([uv[j] for j in face]);continue
   normal=(vs[face[1]]-vs[face[0]]).cross(vs[face[2]]-vs[face[0]]).normalized()
   # Orthonormal surface basis preserves metres even on bevels and pitched roofs.
   if abs(normal.y)<.95:
    up=Vector((0,1,0));u=up.cross(normal).normalized();v=normal.cross(u).normalized()
   else:
    u=(Vector((1,0,0))-normal*normal.x).normalized();v=normal.cross(u)
    if v.z<0:v=-v
   self.uv.append([(p.dot(u)/TILES[mat],p.dot(v)/TILES[mat]) for p in [vs[j] for j in face]])
 def finish(self):
  me=bpy.data.meshes.new(self.name);me.from_pydata(self.v,[],self.f);me.update();ob=bpy.data.objects.new(self.name,me);bpy.context.collection.objects.link(ob)
  for m in MATS:me.materials.append(m)
  uv=me.uv_layers.new(name='UVMap')
  for p,mi,coords,sm in zip(me.polygons,self.mi,self.uv,self.smooth):
   p.material_index=mi;p.use_smooth=sm
   for li,co in zip(p.loop_indices,coords):uv.data[li].uv=co
  bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free();me.update()
  # Flat faces with bevel normals preserve the dressed silhouette without a modifier.
  return ob

def box(g,c,s,m,b=.01,rot=None):
 bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
 for v in bm.verts:v.co=Vector((v.co.x*s[0],v.co.y*s[1],v.co.z*s[2]))
 if b:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(b,min(s)*.24),segments=1,affect='EDGES')
 bm.verts.ensure_lookup_table();bm.verts.index_update();vs=[v.co.copy() for v in bm.verts];fs=[tuple(v.index for v in f.verts) for f in bm.faces];bm.free()
 if rot:vs=[rot@v for v in vs]
 g.add([v+Vector(c) for v in vs],fs,m)

def prism(g,poly,depth,m,axis='z'):
 # Polygon in XY, extrusion along Z; optional rigid axis mapping by caller.
 vs=[(x,y,z) for z in depth for x,y in poly];n=len(poly)
 fs=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 if axis=='y':vs=[(x,z,y) for x,y,z in vs]
 g.add(vs,fs,m)

def cyl(g,a,b,r,m,n=8,r2=None):
 a=Vector(a);b=Vector(b);L=(b-a).length;q=(b-a).to_track_quat('Z','Y');r2=r if r2 is None else r2
 vs=[p+q@Vector((rr*math.cos(i*math.tau/n),rr*math.sin(i*math.tau/n),0)) for p,rr in [(a,r),(b,r2)] for i in range(n)]
 fs=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 g.add(vs,fs,m,True)
 g.smooth[-len(fs)]=False;g.smooth[-len(fs)+1]=False

def tube(g,points,r,m,n=8):
 for a,b in zip(points,points[1:]):cyl(g,a,b,r,m,n)

def slate(g,variant=0,center=(0,0,0),rot=None,w=.345,length=.42):
 # Ten-sided chamfered silhouette; different local edge loss per variant.
 c=[.007,.011,.009,.014][variant%4];l=length
 poly=[(-w/2+c,0),(w/2-.009,0),(w/2,.010),(w/2,l*.57),
       (w/2-.002*(variant%3),l-.014),(w/2-c,l),(-w/2+.012,l),
       (-w/2,l-.010-variant*.002),(-w/2,l*.61),(-w/2,.009)]
 def bow(x,z):return .0025*math.sin(math.pi*z/l)*(1-(x/(w*.65))**2)
 vs=[Vector((x,bow(x,z)+dy,z)) for dy in [0,.010+variant*.001] for x,z in poly]
 vs.append(Vector((0,bow(0,l/2)+.010+variant*.001,l/2)));n=len(poly)
 fs=[tuple(range(n-1,-1,-1))]+[(n+i,n+(i+1)%n,2*n) for i in range(n)]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 if rot:vs=[rot@v for v in vs]
 g.add([v+Vector(center) for v in vs],fs,2)

def ridge(g,center=(0,0,0),rot=None,length=.42):
 # Folded/rounded angular cap, hollow underside bears on roof planes at 44 degrees.
 pitch=math.radians(44);z=.185;y=-z*math.tan(pitch)+.03
 profile=[(-z,y),(-.10,-.041),(-.04,.033),(0,.053),(.04,.033),(.10,-.041),(z,y),(z,y-.026),(.092,-.066),(.03,.007),(0,.022),(-.03,.007),(-.092,-.066),(-z,y-.026)]
 vs=[Vector((x,yy,zz)) for x in [-length/2,length/2] for zz,yy in profile];n=len(profile)
 if rot:vs=[rot@v for v in vs]
 vs=[v+Vector(center) for v in vs]
 fs=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 g.add(vs,fs,7)

META={};OBJECTS=[]
def finish(g,origin,opening=None,extra=None):
 ob=g.finish();OBJECTS.append(ob);META[g.name]={'node_name':g.name,'origin_rule':origin}
 if opening:META[g.name]['opening']={'width':opening[0],'height':opening[1],'wall_thickness':.38,'bottom_center':[0,0,0]}
 if extra:META[g.name].update(extra)
 return ob
for i,s in enumerate('abcd'):
 g=Mesh('slate_'+s);slate(g,i);finish(g,'Middle of top edge; top edge Z=0, down-slope +Z, underside Y=0.',extra={'size_metres':[.345,.010+i*.001,.42],'thickness_metres':.010+i*.001,'bow_metres':.0025,'course_gauge_metres':.21,'roof_placement':'Course origins follow the 42..47 degree roof plane at 0.21m gauge. Rotate slate X by roof pitch minus atan(0.07) (4.0 degrees cant); offset all origins 0.018m along roof normal. Lay from eave upward; stagger X by 0.1725m. No accumulating course height.'})
for name,seed in [('slate_strip_a',0),('slate_strip_b',2)]:
 g=Mesh(name)
 for j in range(4):
  slate(g,(j+seed)%4,((j-1.5)*.345,0,(-.002,.002,.001,-.001)[(j+seed)%4]))
 finish(g,'Top course edge midpoint; four slates span X=-0.69..0.69.',extra={'size_metres':[1.38,.016,.424],'course_gauge_metres':.21,'slate_count':4,'jitter_metres':.002})
g=Mesh('roof_underlay');box(g,(0,-.01,.5),(1,.02,1),2,0);finish(g,'Top edge middle; 1m X by 1m down-slope Z, top Y=0. Scalable backing only.',extra={'size_metres':[1,.02,1]})
g=Mesh('ridge_tile');ridge(g)
for sign in [-1,1]:box(g,(0,-.129,sign*.14),(.39,.020,.062),1,.004,Matrix.Rotation(sign*math.radians(44),3,'X'))
finish(g,'Centre of ridge line; length along X.',extra={'pitch_degrees':44,'compatible_pitch_degrees':[42,47],'mortar_bedding':True,'length_metres':.42,'lap_metres':.025})
for name,size in [('quoin_long',(.60,.386,.35)),('quoin_short',(.35,.386,.65))]:
 g=Mesh(name);box(g,(0,0,0),size,1,.025);finish(g,'Block centre; X along facade, Y vertical; alternate with 0.014 m bed joints.',extra={'nominal_size_metres':list(size)})

def window(g,w,h,shutters=False,offset=(0,0,0),stone=True):
 start=len(g.v)
 # Lining returns reach exactly wall interior at -0.38; frame seals the opening.
 for sign in [-1,1]:
  box(g,(sign*(w/2+.032),h/2,-.235),(.064,h,.29),1,.008)
  if stone:
   rows=3 if h>1 else 2
   for j in range(rows):box(g,(sign*(w/2+.095),(j+.5)*h/rows,.015),(.19,h/rows-.008,.23),1,.013)
 box(g,(0,h+.075,-.115),(w+.43,.15,.53),1,.014)
 # Drip sill shaped as a stepped polygon section, projecting forward with sloped top.
 sec=[(-.38,-.085),(.23,-.085),(.245,-.04),(.23,.005),(.07,.025),(-.38,.025)]
 # extrude sill section along X
 vs=[(x,y,z) for x in [-w/2-.24,w/2+.24] for z,y in sec];n=len(sec)
 fs=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 g.add(vs,fs,1)
 box(g,(0,-.09,.145),(w+.38,.025,.05),1,.004)
 for s in [-1,1]:box(g,(s*(w/2-.034),h/2,-.12),(.068,h,.12),FRAME,.006)
 for y in [.035,h-.035]:box(g,(0,y,-.12),(w-.12,.07,.12),FRAME,.006)
 box(g,(0,h/2,-.098),(.046,h-.13,.12),FRAME,.004)
 # Separate panes leave physical space for bars; two casements, each with edge moulding.
 rows=3 if h>=1 else 2
 innerw=(w-.15)/2
 for side in [-1,1]:
  cx=side*(w/4-.009)
  for edge in [-1,1]:box(g,(cx+edge*(innerw/2-.014),h/2,-.123),(.028,h-.14,.065),FRAME,.003)
  for j in range(rows+1):
   yy=.075+(h-.15)*j/rows;box(g,(cx,yy,-.11),(innerw,.027,.075),FRAME,.003)
  for j in range(rows):
   yy=.075+(h-.15)*(j+.5)/rows;box(g,(cx,yy,-.157),(innerw-.028,(h-.15)/rows-.016,.012),6,0)
  # Casement catch and hinge knuckles.
  box(g,(side*.044,h*.46,-.037),(.014,.075,.014),5,.002)
  for yy in [.20,h-.20]:cyl(g,(side*(w/2-.085),yy-.022,-.061),(side*(w/2-.085),yy+.022,-.061),.012,5,6)
 shutter_start=len(g.v)
 if shutters:
  sw=w/2+.025
  for side in [-1,1]:
   cx=side*(w/2+.13+sw/2)
   for j in range(4):box(g,(cx+(j-1.5)*sw/4,h/2,.13),(sw/4-.004,h+.055,.055),SHUTTER,.004)
   for yy in [.20,h-.20]:
    box(g,(cx,yy,.169),(sw+.02,.064,.035),SHUTTER,.006)
    box(g,(cx-side*.02,yy,.195),(sw*.80,.024,.014),5,.002)
    for dx in [-sw*.28,sw*.28]:cyl(g,(cx+dx,yy,.20),(cx+dx,yy,.208),.009,5,6)
   box(g,(cx-side*(sw/2-.035),.08,.19),(.025,.095,.024),5,.003)
 if offset!=(0,0,0):g.v[start:]=[v+Vector(offset) for v in g.v[start:]]
 return shutter_start
for name,w,h in [('window_std',.95,1.30),('window_small',.50,.70)]:
 g=Mesh(name);window(g,w,h,False);finish(g,'Bottom centre of clear opening at outer wall plane Z=0.',(w,h),{'full_storey':name=='window_std','scale_rule':'Always unit scale; use window_std on every full storey.'})
# Separate shutters: only the shutter geometry from the same window routine.
tmp=Mesh('shutters_std');sh_start=window(tmp,.95,1.30,True)
g=Mesh('shutters_std')
for face,mi,uv,sm in zip(tmp.f,tmp.mi,tmp.uv,tmp.smooth):
 if all(i>=sh_start for i in face):g.add([tmp.v[i] for i in face],[tuple(range(len(face)))],mi,sm,uv)
finish(g,'Same bottom centre as window_std; mount at identical transform.',extra={'fits':'window_std','clear_opening_metres':[.95,1.30]})
# Broad display with a continuous glazed timber shopfront and writable fascia.
g=Mesh('window_shop');w=2.;h=1.9
for side in [-1,1]:
 for j in range(5):box(g,(side*1.105,(j+.5)*h/5,-.075),(.21,h/5-.01,.61),1,.015)
 box(g,(side*.953,h/2,-.11),(.094,h,.16),FRAME,.007)
box(g,(0,-.075,-.06),(2.48,.15,.62),1,.015);box(g,(0,2.005,-.075),(2.48,.21,.61),1,.015)
for y in [.055,1.845]:box(g,(0,y,-.11),(1.86,.11,.16),FRAME,.007)
for x in [-.33,.33]:box(g,(x,.95,-.10),(.055,1.79,.14),FRAME,.004)
for x in [-.637,0,.637]:box(g,(x,.95,-.145),(.59,1.79,.014),6,0)
box(g,(0,2.21,.06),(2.42,.28,.13),FRAME,.008)
for y in [2.055,2.365]:box(g,(0,y,.09),(2.51,.045,.19),FRAME,.005)
finish(g,'Bottom centre of 2.0 x 1.9 clear opening at wall face.',(2.,1.9),{'fascia_name_area_metres':[2.3,.23]})

g=Mesh('door_main')
for s in [-1,1]:
 box(g,(s*.535,1.05,-.235),(.07,2.10,.29),1,.008)
 for j in range(6):box(g,(s*(.5+.11),(.5+j)*.35,.022),(.22 if j%2 else .27,.339,.29),1,.017)
 box(g,(s*.471,1.05,-.11),(.058,2.10,.12),FRAME,.006)
box(g,(0,2.20,-.075),(1.50,.20,.45),1,.018)
box(g,(0,2.325,.035),(1.63,.10,.48),1,.013)
box(g,(0,2.395,.06),(1.69,.055,.53),1,.009)
box(g,(0,2.065,-.11),(.94,.07,.12),FRAME,.006)
box(g,(0,-.105,.29),(1.40,.21,.72),1,.024)
door=finish(g,'Bottom centre of 1.00 × 2.10 opening at outer wall plane.',(1,2.10),{'hinge_child':'door_leaf','hinge_axis':'+Y','hinge_position':[-.44,.025,-.12]})
g=Mesh('door_leaf')
# Leaf width 0.88, height 2.01; origin is left hinge line. Small light is a real opening.
for j in range(8):
 x=(j+.5)*.11
 if j in (3,4):
  box(g,(x,.59,0),(.107,1.18,.070),DOOR,.003)
  box(g,(x,1.84,0),(.107,.34,.070),DOOR,.003)
 else:box(g,(x,1.005,0),(.107,2.01,.07),DOOR,.003)
for yy in [.13,1.88]:box(g,(.44,yy,-.046),(.85,.085,.035),DOOR,.005)
box(g,(.44,1.425,.002),(.24,.50,.012),6,0)
for x in [.305,.575]:box(g,(x,1.425,.045),(.045,.535,.035),5,.004)
for yy in [1.175,1.675]:box(g,(.44,yy,.045),(.31,.035,.035),5,.003)
box(g,(.755,.91,.047),(.053,.16,.022),5,.005)
cyl(g,(.755,.93,.05),(.755,.93,.115),.017,5,8);cyl(g,(.755,.93,.115),(.66,.93,.115),.017,5,8)
for yy in [.24,1.76]:
 cyl(g,(0,yy-.055,0),(0,yy+.055,0),.021,5,8);box(g,(.13,yy,.046),(.26,.035,.02),5,.003)
leaf=g.finish();leaf.parent=door;leaf.location=(-.44,.025,-.12);OBJECTS.append(leaf)

def plain_leaf(name,parent,x0,width,height):
 g=Mesh(name)
 for j in range(7):box(g,((j+.5)*width/7,height/2,0),(width/7-.003,height,.072),DOOR,.003)
 for y in [.22,height-.22]:
  box(g,(width/2,y,-.05),(width-.04,.09,.028),DOOR,.005)
  box(g,(width*.32,y,.043),(width*.58,.035,.015),5,.002)
 cyl(g,(width*.84,height*.45,.04),(width*.84,height*.45,.09),.028,5,8)
 ob=g.finish();ob.parent=parent;ob.location=(x0,.025,-.12);OBJECTS.append(ob);return ob

g=Mesh('door_back');w=.9;h=2.1
for sign in [-1,1]:
 for j in range(6):box(g,(sign*(w/2+.09),(j+.5)*.35,-.10),(.18,.34,.56),1,.014)
 box(g,(sign*(w/2-.025),h/2,-.12),(.05,h,.12),FRAME,.004)
box(g,(0,2.18,-.1),(1.3,.16,.56),1,.014);box(g,(0,2.065,-.12),(.85,.07,.12),FRAME,.004)
box(g,(0,-.075,.18),(1.15,.15,.50),1,.015)
backdoor=finish(g,'Bottom centre at wall face.',(.9,2.1),{'hinge_axis':'+Y'})
plain_leaf('door_back_leaf',backdoor,-.397,.794,2.01)
# Segmental arch: 1.60 clear span; spring at 2.16, crown at 2.40.
g=Mesh('door_double');r=(.8*.8+.24*.24)/(.48);cy=2.4-r;a0=math.asin(.8/r)
for side in [-1,1]:
 for j in range(6):box(g,(side*.90,(j+.5)*.36,-.08),(.20,.35,.60),1,.014)
 box(g,(side*.78,1.08,-.13),(.04,2.16,.11),FRAME,.004)
for j in range(11):
 a=-a0+2*a0*j/11+.006;b=-a0+2*a0*(j+1)/11-.006
 poly=[(rr*math.sin(t),cy+rr*math.cos(t)) for rr,t in [(r,a),(r,b),(r+.22,b),(r+.22,a)]]
 prism(g,poly,[-.38,.22],1)
 # Continuous recessed timber arch closes the fitted leaf-to-stone reveal.
 aa=-a0+2*a0*j/11;bb=-a0+2*a0*(j+1)/11
 frame_poly=[(rr*math.sin(t),cy+rr*math.cos(t)) for rr,t in [(r-.065,aa),(r-.065,bb),(r+.005,bb),(r+.005,aa)]]
 prism(g,frame_poly,[-.205,-.075],FRAME)
for side in [-1,1]:
 prism(g,[(side*.8,2.155),(side*1.01,2.155),(side*((r+.22)*math.sin(a0)),cy+(r+.22)*math.cos(a0))],[-.38,.22],1)
box(g,(0,-.07,.15),(2.10,.14,.60),1,.018)
double=finish(g,'Bottom centre of segmental opening at wall face.',(1.6,2.4),{'spring_height_metres':2.16,'arch_rise_metres':.24,'hinge_axis':'+Y','hinge_children':['door_double_left','door_double_right']})
for side in [-1,1]:
 g=Mesh('door_double_left' if side<0 else 'door_double_right');hinge=side*.762
 # Each leaf has its own arched plank tops, fitted below the stone intrados.
 for j in range(6):
  xa=-.758+j*.126 if side<0 else .002+j*.126;xb=xa+.123
  ya=cy+math.sqrt(r*r-xa*xa)-.045;yb=cy+math.sqrt(r*r-xb*xb)-.045
  prism(g,[(xa-hinge,0),(xb-hinge,0),(xb-hinge,yb-.025),(xa-hinge,ya-.025)],[-.036,.036],DOOR)
 for y in [.25,1.72]:box(g,(-side*.38,y,.045),(.68,.05,.017),5,.004)
 cyl(g,(-side*.65,1.,.04),(-side*.65,1.,.10),.028,5,8)
 ob=g.finish();ob.parent=double;ob.location=(hinge,.025,-.12);OBJECTS.append(ob)

# Dormer: front opening and roof cheeks land on main roof plane Y=-Z*tan(44°).
g=Mesh('dormer');pitch=math.radians(44);slope=math.tan(pitch);eave=1.40;half=.65;peak=eave+half*slope;back=-peak/slope
window(g,.85,1.10,False,offset=(0,.13,0))
for s in [-1,1]:
 # Triangular cheek: bottom edge lies exactly on main roof, no floating box.
 x=s*.57;poly=[(0,0),(0,eave),(-eave/slope,eave)]
 vs=[(xx,y,z) for xx in [x-.045,x+.045] for z,y in poly]
 g.add(vs,[(0,2,1),(3,4,5),(0,1,4,3),(1,2,5,4),(2,0,3,5)],0)
prism(g,[(-half,eave),(half,eave),(0,peak)],[-.08,.04],0)
# Individual gable infill stones, neatly clipped to triangular envelope.
for row in range(3):
 y=eave+.065+row*.15;width=max(.08,2*(peak-y)/slope-.10);count=max(1,round(width/.23))
 for k in range(count):box(g,(-width/2+(k+.5)*width/count,y,.056),(width/count-.009,.11,.045),1,.009)
# Two roof slabs and staggered individual slates; every back edge trimmed to intersection.
for sign in [-1,1]:
 # Side roof from eave to ridge, depth grows toward ridge as main roof rises.
 vs=[(sign*half,eave,.13),(0,peak,.13),(0,peak,back),(sign*half,eave,-eave/slope)]
 roof=[(x,y+dy,z) for dy in [-.045,0] for x,y,z in vs]
 g.add(roof,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],2)
 length=half/math.cos(pitch)
 for row in range(5):
  d=row*.185;d2=min(d+.26,length+.018)
  if d>=length:continue
  x1=sign*d*math.cos(pitch);x2=sign*d2*math.cos(pitch);y1=peak-d*math.sin(pitch);y2=peak-d2*math.sin(pitch)
  # Course axis is dormer depth; lap projects toward eave.
  for col in range(9):
   z1=.13-col*.27+(row%2)*.135;z2=z1-.262
   if z2>=.14:continue
   z1=min(z1,.14)
   rear1=-y1/slope;rear2=-y2/slope
   if z1<max(rear1,rear2):continue
   za=max(z2,rear1);zb=max(z2,rear2)
   normal=Vector((sign*math.sin(pitch),math.cos(pitch),0))
   # Each slate tilts above the next course: no coplanar overlapping faces.
   verts=[Vector(p)+normal*offset for p,offset in [((x1,y1,z1),.019),((x2,y2,z1),.019+.10*(d2-d)),((x2,y2,zb),.019+.10*(d2-d)),((x1,y1,za),.019)]]
   verts += [v+normal*.012 for v in verts]
   g.add(verts,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],2)
 tube(g,[(sign*(half+.01),eave+.025,.15),(0,peak+.025,.15)],.024,5,8)
 # Valley flashing follows the roof/roof intersection and bears on both decks.
 tube(g,[(sign*half,eave,-eave/slope),(0,peak,back)],.025,5,8)
for j in range(6):
 z=.04-j*.37
 ridge(g,(0,peak+.025,z),Matrix.Rotation(math.pi/2,3,'Y'),.39)
for y in [.19,.64]:cyl(g,(-.52,y,.27),(.52,y,.27),.016,5,8)
for j in range(9):
 x=-.49+j*.1225;cyl(g,(x,.19,.27),(x,.64,.27),.010,5,6)
 if j%2==0:
  pts=[(x+.032*math.sin(k*math.tau/12),.40+.058*math.cos(k*math.tau/12),.27) for k in range(13)];tube(g,pts,.006,5,5)
for x in [-.52,.52]:
 for y in [.19,.64]:cyl(g,(x,y,.04),(x,y,.28),.015,5,8)
finish(g,'Bottom centre of dormer front at main wall eaves: Y=0, facade Z=0.',extra={'window_opening_metres':[.85,1.10],'window_is_scaled_standard':False,'nominal_width_metres':1.3,'main_roof_pitch_degrees':44,'main_roof_plane':'Y = -Z * tan(44 degrees)','roof_cut_polygon_XZ':[[-.65,0],[.65,0],[.65,-eave/slope],[0,back],[-.65,-eave/slope]],'roof_cut_note':'Remove underlying main-roof slates and decking inside this polygon; use included valley strips; front is at eaves.'})

# Chimney body courses, cap and two truly hollow flared terracotta pots, total 2 m.
g=Mesh('chimney_stack')
for row in range(5):
 y=(row+.5)*.292
 if row%2:
  for z in [-.173,.173]:box(g,(0,y,z),(.99,.281,.338),1,.014)
 else:
  for x in [-.247,.247]:box(g,(x,y,0),(.486,.281,.69),1,.014)
box(g,(0,.12,0),(1,.12,.70),5,.012)
box(g,(0,1.495,0),(1.10,.11,.79),1,.015);box(g,(0,1.585,0),(1.17,.07,.85),1,.012)
for x in [-.27,.27]:
 n=14;vs=[]
 for y,r in [(1.62,.134),(1.94,.105),(2.0,.115),(2.0,.078),(1.66,.087)]:
  vs.extend([(x+r*math.cos(i*math.tau/n),y,r*math.sin(i*math.tau/n)) for i in range(n)])
 fs=[]
 for ring in range(4):fs += [(ring*n+i,ring*n+(i+1)%n,(ring+1)*n+(i+1)%n,(ring+1)*n+i) for i in range(n)]
 fs.append(tuple(range(4*n,5*n)));fs.append(tuple(range(n-1,-1,-1)));g.add(vs,fs,7,True)
finish(g,'Bottom centre; nominal stack 1.00 X × 0.70 Z; pots reach Y=2.00.',extra={'nominal_body_size_metres':[1,.70],'total_height_metres':2})

# Open-ended thin half-round zinc gutter, rolled front and rear edges.
def sweep_x(g,profile,length,mat=5):
 n=len(profile);vs=[(x,y,z) for x in [-length/2,length/2] for y,z in profile]
 fs=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 g.add(vs,fs,mat,True)
 g.smooth[-len(fs)]=False;g.smooth[-len(fs)+1]=False
def gutter_profile(ro=.080,ri=.077,n=12):
 return [(r*math.sin(a),.086+r*math.cos(a)) for r,seq in [(ro,range(n+1)),(ri,range(n,-1,-1))] for a in [math.pi+i*math.pi/n for i in seq]]
g=Mesh('gutter_1m');sweep_x(g,gutter_profile(),1)
for z in [.006,.166]:cyl(g,(-.5,0,z),(.5,0,z),.006,5,8)
finish(g,'Centre of back rim; X length, trough below Y=0, projects +Z.',extra={'length_metres':1,'open_ends':True,'inner_radius_metres':.077,'sheet_thickness_metres':.003,'rolled_bead_radius_metres':.006,'assembly':'Rim Y=eave_height-0.06; back rim Z=eave_tip_Z-0.035. Slates discharge 35mm into trough. Never attach at roof-plane height. Brackets every 0.5m, plus terminal caps and one outlet.'})
g=Mesh('gutter_bracket');sweep_x(g,gutter_profile(.091,.084,10),.028)
box(g,(0,-.060,-.009),(.034,.15,.018),5,.002)
for y in [-.035,-.11]:cyl(g,(0,y,.001),(0,y,.007),.007,5,6)
finish(g,'Same datum as gutter_1m; place at 0.5m intervals, 0.10m from ends.',extra={'spacing_metres':.5})
g=Mesh('gutter_end_cap');profile=[(0,.006)]+[(.080*math.sin(math.pi+i*math.pi/12),.086+.080*math.cos(math.pi+i*math.pi/12)) for i in range(1,13)]
sweep_x(g,profile,.006);finish(g,'Gutter terminal X plane. Place at either run end with shared rim datum.')
def hollow_pipe(g,points,radii,n=12,thickness=.003):
 # Smooth concentric rings, sealed annular ends; openings remain truly hollow.
 vs=[]
 for inner in [False,True]:
  for i,(p,r) in enumerate(zip(points,radii)):
   tangent=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
   q=tangent.to_track_quat('Z','Y');rr=r-thickness if inner else r
   vs.extend([Vector(p)+q@Vector((rr*math.cos(j*math.tau/n),rr*math.sin(j*math.tau/n),0)) for j in range(n)])
 k=len(points);fs=[]
 for shell in [0,1]:
  off=shell*k*n
  for i in range(k-1):
   for j in range(n):fs.append((off+i*n+j,off+i*n+(j+1)%n,off+(i+1)*n+(j+1)%n,off+(i+1)*n+j))
 for i in [0,k-1]:
  for j in range(n):fs.append((i*n+j,i*n+(j+1)%n,k*n+i*n+(j+1)%n,k*n+i*n+j))
 g.add(vs,fs,5,True)
 g.smooth[-2*n:]=[False]*(2*n)
g=Mesh('gutter_outlet');hollow_pipe(g,[(0,-.035,.086),(0,-.10,.086),(0,-.19,.086)],[.062,.047,.043],12)
finish(g,'Shared gutter rim datum; throat descends below bowl.',extra={'drain_hole_radius_metres':.043,'assembly':'Assembler must remove a circular opening of radius 0.043m in gutter bottom at outlet. Viewer demo clips that opening in the gutter mesh before instancing the outlet.'})
g=Mesh('downpipe_1m');hollow_pipe(g,[(0,0,.086),(0,1,.086)],[.043,.043],12)
hollow_pipe(g,[(0,.47,.086),(0,.52,.086)],[.050,.050],8)
box(g,(0,.495,.024),(.075,.033,.032),5,0)
finish(g,'Bottom on wall face; Y=0..1; pipe centre Z=0.086.',extra={'length_metres':1,'open_ends':True})
g=Mesh('downpipe_shoe');hollow_pipe(g,[(0,.26,.086),(0,.14,.086),(0,.07,.14),(0,.035,.24)],[.046,.043,.043,.048],8)
finish(g,'Wall/ground datum; inlet Y=.26; shoe discharges outward at Z=.24.',extra={'open_ends':True})
for name,sz in [('fascia_1m',(1,.16,.045)),('verge_board_1m',(1,.12,.035))]:
 g=Mesh(name);box(g,(0,-sz[1]/2,sz[2]/2),sz,FRAME,.004);finish(g,'Middle of top back edge, length along X.',extra={'length_metres':1})
g=Mesh('lantern');box(g,(0,0,.025),(.10,.24,.05),5,.009)
tube(g,[(0,.065,.05),(0,.065,.29),(0,-.03,.29)],.015,5,8)
box(g,(0,-.19,.29),(.17,.27,.16),6,0)
for x in [-.095,.095]:
 for z in [.20,.38]:cyl(g,(x,-.345,z),(x,-.035,z),.010,5,6)
box(g,(0,-.355,.29),(.235,.035,.23),5,.007)
# Pyramidal rain hood, real profiled metal instead of a plain cuboid.
vs=[(-.14,-.025,.15),(.14,-.025,.15),(.14,-.025,.43),(-.14,-.025,.43),(-.045,.045,.245),(.045,.045,.245),(.045,.045,.335),(-.045,.045,.335)]
g.add(vs,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],5)
for y in [-.07,.07]:cyl(g,(0,y,.05),(0,y,.057),.012,5,6)
finish(g,'Centre of wall mounting plate on facade face Z=0.')

g=Mesh('tavern_sign');box(g,(0,0,.025),(.11,.36,.05),5,.007)
tube(g,[(0,.12,.04),(0,.12,.88),(0,.07,.93)],.018,5,8)
tube(g,[(0,-.13,.04),(0,.10,.65)],.013,5,8)
# Board faces perpendicular to facade; two eyes and short hanging rods.
for z in [.29,.77]:
 cyl(g,(0,.11,z),(0,-.095,z),.012,5,8)
 pts=[(.023*math.sin(j*math.tau/10),-.085+.023*math.cos(j*math.tau/10),z) for j in range(11)];tube(g,pts,.005,5,5)
box(g,(0,-.36,.53),(.065,.50,.66),SIGN,.012)
for yy in [-.62,-.10]:box(g,(0,yy,.53),(.080,.035,.71),SIGN,.005)
finish(g,'Centre of wrought iron wall mounting plate, Z=0.',extra={'paint_slot':'Sign_paint','board_name_area_metres':[.60,.43]})
g=Mesh('bench_stone');box(g,(0,.46,.29),(1.60,.12,.48),1,.028)
for x in [-.56,.56]:box(g,(x,.20,.27),(.23,.40,.35),1,.025)
finish(g,'Ground centre at wall plane; 1.6m along X; seat projects +Z.',extra={'length_metres':1.6,'seat_height_metres':.52})

# Contract validation in author space; all top-level transforms identity.
bpy.context.view_layer.update();entries=[]
for ob in OBJECTS:
 if ob.parent is not None: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:
  for p in o.data.polygons:assert p.area>1e-12,(o.name,'degenerate',p.index)
  assert all(math.isfinite(x) for v in o.data.vertices for x in v.co)
 entry=META[ob.name];entry.update({'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)]},'triangles':tris,'triangle_budget':LIMITS[ob.name],'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':child.name,'translation':list(child.location),'origin_rule':'Hinge line; rotate about local Y.','triangles':len(child.data.polygons)} for child in ob.children]
 entries.append(entry)
 print('ATOM',ob.name,tris,flush=True)
assert len(entries)==len(LIMITS)
bpy.ops.object.select_all(action='DESELECT')
for o in OBJECTS:o.select_set(True)
# At this point materials contain scalar placeholders only, no texture nodes/images.
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];gltf=json.loads(data[20:20+ln]);assert not gltf.get('images') and not gltf.get('textures')
rootnodes=[gltf['nodes'][i] for i in gltf['scenes'][gltf.get('scene',0)]['nodes']]
assert set(n['name'] for n in rootnodes)==set(LIMITS)
assert all('mesh' in n and not any(k in n for k in ['translation','rotation','scale','matrix']) for n in rootnodes)
kitdoc={'schema':'forge.atom-kit.v2','units':'metres','up':'+Y','facade_outward':'+Z','facade_horizontal':'+X','uv_rule':'UV0 is orthonormal surface distance in metres / material tile size. Translation preserves scale. Rotate masters as rigid units; do not rescale. For phase-continuous generated walls, use global facade coordinates.','atoms':entries,'total_triangles':sum(e['triangles'] for e in entries),'glb_bytes':len(data),'material_binding':'materials.json','images_embedded':0}
(ROOT/'kit.json').write_text(json.dumps(kitdoc,indent=2))
(ROOT/'build_evidence.json').write_text(json.dumps({'source_sha256':PROVENANCE,'seams':seams,'glb_sha256':hashlib.sha256(data).hexdigest(),'builder_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()},indent=2))

if '--export-only' in sys.argv: sys.exit(0)

# Render the exported meshes, reimported and rebound to the EXTERNAL shelf by name.
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.type=='MESH' and o.parent is None}
for m in bpy.data.materials:
 if m.name not in shelf:continue
 nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED')
 for kind in ['BaseColor','Normal','ORM']:
  tex=nt.nodes.new('ShaderNodeTexImage');tex.image=bpy.data.images.load(str(ROOT/shelf[m.name]['maps'][kind]),check_existing=True);tex.image.colorspace_settings.name='sRGB' if kind=='BaseColor' else 'Non-Color';tex.extension='REPEAT'
  if kind=='BaseColor':nt.links.new(tex.outputs['Color'],bs.inputs['Base Color'])
  elif kind=='Normal':
   norm=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(tex.outputs['Color'],norm.inputs['Color']);nt.links.new(norm.outputs[0],bs.inputs['Normal'])
  else:
   sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(tex.outputs['Color'],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=(.72,.77,.85,1);bg.inputs[1].default_value=.50
bpy.ops.object.light_add(type='SUN',location=(-4,-6,12));sun=bpy.context.object;sun.rotation_euler=(math.radians(23),math.radians(-28),math.radians(-24));sun.data.energy=2;sun.data.angle=math.radians(12)
bpy.ops.object.light_add(type='AREA',location=(0,-6,10));key=bpy.context.object;key.data.energy=1800;key.data.shape='RECTANGLE';key.data.size=20;key.data.size_y=8;key.rotation_euler=(Vector((0,0,0))-key.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));floor=bpy.context.object;floor.name='STUDIO_floor';mat=bpy.data.materials.new('STUDIO_light_grey');mat.diffuse_color=(.58,.59,.59,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=mat.diffuse_color;mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.82;floor.data.materials.append(mat)
bpy.ops.object.camera_add();cam=bpy.context.object;scene.camera=cam;cam.data.type='ORTHO'
scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.resolution_percentage=100
labels=[]
ink=bpy.data.materials.new('STUDIO_ink');ink.diffuse_color=(.025,.038,.05,1);ink.use_nodes=True;ink.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=ink.diffuse_color
def text(label,loc,size=.12):
 curve=bpy.data.curves.new(label,'FONT');curve.body=label;curve.size=size;curve.align_x='CENTER';curve.align_y='BOTTOM';curve.extrude=0
 ob=bpy.data.objects.new(label,curve);bpy.context.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=(math.pi/2,0,0);ob.data.materials.append(ink);labels.append(ob);return ob

def bounds(objects):
 bpy.context.view_layer.update();pts=[o.matrix_world@Vector(c) for ob in objects for o in [ob]+list(ob.children) if o.type=='MESH' for c in o.bound_box]
 return Vector([min(p[i] for p in pts) for i in range(3)]),Vector([max(p[i] for p in pts) for i in range(3)])
def camera(target,scale,dir=(.07,-1,.48)):
 target=Vector(target);cam.location=target+Vector(dir).normalized()*50;cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale

def render(name):scene.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
# Readable catalog at constant metric scale, four shelves.
for i,(name,ob) in enumerate(masters.items()):
 lo,hi=bounds([ob]);ob.location=(i%8*3.1-(lo.x+hi.x)/2, (i//8)*4.2,-lo.z)
 text(name,(i%8*3.1,(i//8)*4.2-1.2,.02),.16)
text('BRETON / ATOM KIT V2',(10.8,13.8,3.8),.5)
text('Standard openings · shaped slate · zinc rainwater system · 17 coherent materials',(10.8,13.8,3.15),.22)
camera((10.8,6.1,1),29,(.02,-1,1.25));render('kit_sheet.png')
for ob in labels:ob.hide_render=True
for ob in masters.values():ob.hide_render=True

def close_sheet(names,filename,clay=False):
 for ob in masters.values():
  ob.hide_render=True
  for child in ob.children:child.hide_render=True
 xx=0;active=[]
 for name in names:
  ob=masters[name];ob.hide_render=False
  for child in ob.children:child.hide_render=False
  ob.location=(0,0,0);lo,hi=bounds([ob]);slot=max(hi.x-lo.x,.4)+.30
  ob.location=(xx+slot/2-(lo.x+hi.x)/2,0,-lo.z);xx+=slot;active.append(ob)
 for ob in active:ob.location.x-=xx/2
 lo,hi=bounds(active);camera((lo+hi)/2,max(xx*1.12,(hi.z-lo.z)*2.25),(.18,-1,.4))
 if clay:
  cm=bpy.data.materials.get('STUDIO_clay') or bpy.data.materials.new('STUDIO_clay');cm.diffuse_color=(.40,.40,.40,1);cm.use_nodes=True;cm.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.4,.4,.4,1);scene.view_layers[0].material_override=cm
 render(filename);scene.view_layers[0].material_override=None
close_sheet(['window_std','shutters_std','window_small','window_shop'],'details_windows.png')
close_sheet(['door_main','door_back','door_double','tavern_sign','bench_stone'],'details_joinery.png')
close_sheet(['slate_a','slate_b','slate_c','slate_d','slate_strip_a','ridge_tile'],'details_slate.png')
close_sheet(['gutter_1m','gutter_bracket','gutter_end_cap','gutter_outlet','downpipe_1m','downpipe_shoe'],'details_gutters.png')
close_sheet(['window_std','door_double','dormer','chimney_stack'],'clay_check.png',True)
close_sheet(['window_std','door_main','dormer'],'reverse_check.png')
active=[masters[n] for n in ['window_std','door_main','dormer']];lo,hi=bounds(active);camera((lo+hi)/2,7.8,(-.35,1,.35));render('reverse_check.png')
print('BUILD_COMPLETE',kitdoc['total_triangles'],len(data),'seconds',round(time.time()-START,2),flush=True)
