"""Deterministic late-medieval atom kit. Blender 5.2, Y-up authored / export_yup=False.
Run: blender -b --factory-startup --python-exit-code 1 --python build_kit.py
Sources: sources/ CC0 registered scans. See materials.json. -- --geometry-only skips renders.
"""
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));START=time.time()
import geometry as G
from geometry import Mesh,box,prism,cyl,tube
import 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)
G.MATS,SHELF=shelf.build();G.TILES=shelf.TILES
OAK=2;GREY=3;STONE=9;IRON=10;GLASS=11;CLAY=7
LIMITS={'brace_storey':400,'brace_straight':200,'bressummer_1m':600,'joist_end':80,'jetty_bracket':400,'peg_cluster':40,'window_leaded':2500,'window_leaded_stone':2500,'window_small':1200,'door_plank':3000,'door_arched_double':3500,'window_shop':2500,**{f'tile_{s}':30 for s in 'abcd'},'tile_strip_a':240,'tile_strip_b':240,'roof_underlay':12,'ridge_tile_round':150,'verge_board_1m':80,'rafter_end':60,'dormer':6000,'chimney_stone':2500,'tavern_sign':2000,'lantern_iron':600,'bench_wood':400,'barrel':600,'corner_stone':80}
META={};OBJECTS=[]
def finish(g,origin,opening=None,extra=None,parent=None,loc=(0,0,0)):
 ob=g.finish();ob.parent=parent;ob.location=loc;OBJECTS.append(ob)
 if parent:return 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':opening[2],'bottom_center':[0,0,0]}
 if extra:META[g.name].update(extra)
 return ob

def beam(g,a,b,w,d,m=OAK,bevel=.008):
 a=Vector(a);b=Vector(b);v=b-a;g.grain=v.normalized()
 box(g,(a+b)/2,(w,d,v.length),m,bevel,v.to_track_quat('Z','Y').to_matrix());g.grain=Vector((1,0,0))

def ribbon(g,points,width,depth,m=OAK,n=8):
 # Rectangular chamfered sweep in facade XY, closed end caps, shared rings.
 pts=[Vector(p) for p in points];vs=[]
 for j,p in enumerate(pts):
  t=(pts[min(j+1,len(pts)-1)]-pts[max(j-1,0)]).normalized();side=Vector((-t.y,t.x,0));z=Vector((0,0,1));w=width/2;d=depth/2;b=min(.008,w*.25)
  for u,v in [(-w+b,-d),(w-b,-d),(w,-d+b),(w,d-b),(w-b,d),(-w+b,d),(-w,d-b),(-w,-d+b)]:vs.append(p+side*u+z*v)
 fs=[tuple(range(n-1,-1,-1)),tuple(range((len(pts)-1)*n,len(pts)*n))]
 for j in range(len(pts)-1):
  for k in range(n):fs.append((j*n+k,j*n+(k+1)%n,(j+1)*n+(k+1)%n,(j+1)*n+k))
 g.grain=(pts[-1]-pts[0]).normalized();g.add(vs,fs,m);g.grain=Vector((1,0,0))

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

def peg(g,x,y,z=.007,r=.018,n=6):cyl(g,(x,y,z-.015),(x,y,z+.013),r,OAK,n)
def ring(g,c,r,small=.009,m=IRON,n=12,axis='z'):
 pts=[(c[0]+r*math.cos(i*math.tau/n),c[1]+r*math.sin(i*math.tau/n),c[2]) for i in range(n+1)]
 if axis=='x':pts=[(c[0],c[1]+r*math.sin(i*math.tau/n),c[2]+r*math.cos(i*math.tau/n)) for i in range(n+1)]
 sweep(g,pts,small,m,4)
# Brace endpoints occupy the stated panel, face flush Z=0; plaster face Z=-.025.
for name,w,curved in [('brace_storey',1.2,True),('brace_straight',1.0,False)]:
 g=Mesh(name);pts=[]
 for j in range(13 if curved else 2):
  t=j/(12 if curved else 1);pts.append((-.5*w+.07+(w-.14)*t+(.16*math.sin(math.pi*t) if curved else 0),.07+2.26*t,-.082))
 ribbon(g,pts,.14,.164)
 for p in [pts[0],pts[-1]]:peg(g,p[0],p[1],.002,.016)
 finish(g,'Panel bottom centre. Timber face Z=0; mirror X for opposite hand.',extra={'panel_metres':[w,2.4],'attachment_centres':[list(pts[0]),list(pts[-1])],'infill_front_z':-.025})
g=Mesh('peg_cluster')
for x,y in [(-.034,-.028),(.034,.028)]:peg(g,x,y,0,.015,5)
finish(g,'Joint centre at timber face. Two tapered-looking octagonal end pegs; axis Z.')
# Carved low-relief vine on continuous beam with moulded top and bottom beads.
g=Mesh('bressummer_1m');box(g,(0,-.13,-.09),(1,.26,.18),OAK,.008)
for y in [-.023,-.237]:box(g,(0,y,.004),(1,.022,.024),OAK,.004)
pts=[(-.5+k/36,-.13+.050*math.sin(k*math.tau/12),.007) for k in range(37)];sweep(g,pts,.010,OAK,4)
for off in [-1/3,0,1/3]:
 for sign in [-1,1]:
  x=off+sign*.06;poly=[(x,-.13),(x+sign*.020,-.083),(x+sign*.065,-.063),(x+sign*.055,-.102),(x+sign*.035,-.12),(x+sign*.012,-.135)]
  prism(g,poly,[.007,.020],OAK)
finish(g,'Top reference: X endpoints ±0.5, Y=0 top, Z=0 timber face.',extra={'length_metres':1,'butt_join':'Repeat every 1.0 X; vine endpoints at Y=-0.13, Z=0.007 with matching tangents.'})
g=Mesh('joist_end');g.grain=Vector((0,0,1));poly=[(-.085,0),(.085,0),(.085,-.16),(.052,-.19),(-.052,-.19),(-.085,-.16)];prism(g,poly,[0,.45],OAK)
# Three concentric cut-end growth lines, geometry-carried and budgeted.
for rad in [.022,.043,.064]:
 vs=[]
 for r in [rad,rad+.0015]:vs += [(r*(1+.13*math.sin(k*2.3))*math.cos(k*math.tau/8),-.088+r*(1+.10*math.cos(k*1.7))*math.sin(k*math.tau/8),.4503) for k in range(8)]
 g.add(vs,[(k,(k+1)%8,(k+1)%8+8,k+8) for k in range(8)],GREY)
g.add([(-.010,-.085,.4505),(-.006,-.079,.4505),(-.061,-.041,.4505),(.012,-.103,.4505),(.016,-.100,.4505),(.036,-.173,.4505)],[(0,1,2),(3,4,5)],IRON)
finish(g,'Back top centre at wall; projects Z=0..0.45.',extra={'projection_metres':.45,'end_grain':'Irregular growth rings and two fine drying checks at projecting cut face.'})
g=Mesh('jetty_bracket')
# Shaped knee lies in YZ, flush bearing beneath jetty.
poly=[(0,0),(.47,0),(.46,-.13),(.37,-.15),(.27,-.24),(.18,-.40),(.12,-.65),(0,-.68)]
vs=[(x,y,z) for x in [-.085,.085] for z,y in poly];n=len(poly);g.add(vs,[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)],OAK)
# Curved underside bead adds a hand-cut profile.
for x in [-.079,.079]:sweep(g,[(x,-.61,.105),(x,-.41,.165),(x,-.25,.255),(x,-.15,.375)],.016,OAK,5)
for y,z in [(-.10,.35),(-.53,.05)]:cyl(g,(-.091,y,z),(.091,y,z),.019,OAK,6)
finish(g,'Top at jetty underside Y=0; back plane Z=0; projects +Z 0.47; drops 0.68.')

# Casements: surround outside a true clear opening; moving leaves have hinge pivots.
def casement(g,w,h,offset=(0,0,0)):
 start=len(g.v);st=.025
 for x in [st/2,w-st/2]:beam(g,(x,0,0),(x,h,0),st,.047)
 for y in [st/2,h-st/2]:box(g,(w/2,y,0),(w-2*st,st,.047),OAK,.003)
 box(g,(w/2,h/2,-.015),(w-2*st,h-2*st,.007),GLASS,0)
 # Diamond lead came: clipped lines on both diagonals, no overrun through frame.
 lo=st;hi=w-st;bottom=st;top=h-st
 for slope in [-1.65,1.65]:
  intercept=bottom-abs(slope)*w
  while intercept<top+abs(slope)*w:
   candidates=[]
   for x in [lo,hi]:
    y=slope*x+intercept
    if bottom-1e-8<=y<=top+1e-8:candidates.append((x,y,.001))
   for y in [bottom,top]:
    x=(y-intercept)/slope
    if lo+1e-8<x<hi-1e-8:candidates.append((x,y,.001))
   if len(candidates)==2 and (Vector(candidates[1])-Vector(candidates[0])).length>.001:cyl(g,candidates[0],candidates[1],.0038,IRON,4)
   intercept+=.22 if h>.6 else .18
 for yy in [.10,h-.10]:cyl(g,(0,yy-.021,.008),(0,yy+.021,.008),.011,IRON,6)
 box(g,(w-.040,h*.46,.033),(.014,.07,.013),IRON,.002)
 if offset!=(0,0,0):g.v[start:]=[v+Vector(offset) for v in g.v[start:]]

def window(name,w,h,stone=False):
 g=Mesh(name);thick=.50 if stone else .20;sur=.09
 for s in [-1,1]:beam(g,(s*(w/2+sur/2),0,-thick/2),(s*(w/2+sur/2),h,-thick/2),sur,thick,OAK)
 for y in [-.045,h+.045]:box(g,(0,y,-thick/2),(w+.18,.09,thick),OAK,.006)
 if stone:
  for s in [-1,1]:
   for j in range(3):box(g,(s*(w/2+.14),(j+.5)*h/3,-.23),(.10,h/3-.006,.54),STONE,.009)
  box(g,(0,h+.13,-.18),(w+.42,.17,.64),STONE,.012)
  sec=[(-.5,-.115),(.18,-.115),(.20,-.035),(.13,.012),(-.5,-.008)]
  vs=[(x,y,z) for x in [-w/2-.20,w/2+.20] for z,y in sec];n=len(sec);g.add(vs,[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)],STONE)
 else:box(g,(0,-.057,.027),(w+.24,.075,.17),OAK,.008)
 parent=finish(g,'Bottom centre of clear opening, exterior wall plane Z=0.',(w,h,thick),{'hinge_axis':'+Y','clear_opening_note':'Outer surround extends outside opening. Generated studs align with outer jamb edges; do not add a second surround.'})
 # Two equal leaves for normal window, single attic leaf.
 count=2 if w>.5 else 1
 for j in range(count):
  cw=w/count-.004;leaf=Mesh(name+('_casement_left' if j==0 else '_casement_right'));casement(leaf,cw,h-.008)
  ob=finish(leaf,'',parent=parent,loc=(-w/2+.002+j*w/count,.004,-.047));ob['hinge_axis']='Y';ob['open_degrees']=-55 if j==0 else 55
  # Right leaf pivot at right jamb; mirror source positions around hinge.
  if j==1:
   for v in ob.data.vertices:v.co.x=-v.co.x
   ob.location.x=w/2-.002
   bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(ob.data);bm.free()
 return parent
window('window_leaded',.7,1)
window('window_leaded_stone',.7,1,True)
window('window_small',.45,.55)

# Door hardware and closed slab leaves with deliberate plank seams.
def straps(g,width,height,side=1):
 for y in [.28,height-.30]:
  x=side*width*.42;poly=[(side*.015,y-.024),(side*(width*.78),y-.019),(side*(width*.85),y),(side*(width*.78),y+.019),(side*.015,y+.024)]
  prism(g,poly,[.045,.058],IRON)
  cyl(g,(0,y-.055,0),(0,y+.055,0),.024,IRON,8)
  for xx in [.12,width*.38,width*.65]:cyl(g,(side*xx,y,.058),(side*xx,y,.065),.007,IRON,6)
 x=side*(width-.16);box(g,(x,.96,.050),(.055,.13,.018),IRON,.005);ring(g,(x,.94,.084),.043,.008,IRON,12)

g=Mesh('door_plank')
for s in [-1,1]:beam(g,(s*.555,0,-.10),(s*.555,2.0,-.10),.11,.20)
box(g,(0,2.075,-.10),(1.30,.15,.25),OAK,.012);box(g,(0,-.055,-.12),(1.26,.11,.72),STONE,.016)
parent=finish(g,'Bottom centre of 1.0 × 2.0 clear opening, exterior Z=0.',(1,2,.5))
g=Mesh('door_plank_leaf');g.grain=Vector((0,1,0))
for j in range(8):box(g,((j+.5)*.1235,.994,0),(.121,1.988,.074),12,.003)
for y in [.20,1.0,1.80]:box(g,(.494,y,-.051),(.97,.10,.032),OAK,.004)
straps(g,.988,1.988);finish(g,'',parent=parent,loc=(-.494,.006,-.055))
META['door_plank']['hinge_axis']='+Y'

g=Mesh('door_arched_double');spring=1.70;r=.90
for s in [-1,1]:
 for j in range(6):box(g,(s*1.025,(j+.5)*spring/6,-.20),(.25,spring/6-.009,.61),STONE,.016)
# Wedge voussoirs with radial joint planes; exact crown closure without duplicate keystone.
for j in range(13):
 a=j*math.pi/13+.005;b=(j+1)*math.pi/13-.005
 poly=[(rr*math.cos(t),spring+rr*math.sin(t)) for rr,t in [(r,a),(r,b),(1.16,b),(1.16,a)]];prism(g,poly,[-.5,.12],STONE)
box(g,(0,-.052,-.13),(2.30,.104,.77),STONE,.016)
parent=finish(g,'Bottom centre of 1.8 wide arch; spring Y=1.70, crown Y=2.60.',(1.8,2.6,.5),{'spring_height':1.7,'arch_radius':.9,'hinge_axis':'+Y'})
for side,label in [(-1,'left'),(1,'right')]:
 g=Mesh('door_arched_double_'+label);g.grain=Vector((0,1,0));hinge=side*.895
 for j in range(7):
  x1=-.89+j*.127+(0 if side<0 else .89);x2=x1+.124
  # Leaf left world interval [-.89,0], right [0,.89].
  ys=[spring+math.sqrt(max(0,.892**2-x*x))-.009 for x in [x1,x2]]
  prism(g,[(x1-hinge,.008),(x2-hinge,.008),(x2-hinge,ys[1]),(x1-hinge,ys[0])],[-.037,.037],12)
 straps(g,.89,2.15,-side)
 for y in [.22,1.05,1.63]:box(g,(-side*.445,y,-.052),(.85,.105,.03),OAK,.005)
 finish(g,'',parent=parent,loc=(hinge,0,-.061))

# Shop: deep lintel/side return, fixed transom lattice, broad fold-down counter leaf.
g=Mesh('window_shop');w=1.8;h=1.4
for s in [-1,1]:beam(g,(s*.955,0,-.10),(s*.955,h,-.10),.11,.20)
for y in [-.06,h+.065]:box(g,(0,y,-.09),(2.04,.12,.27),OAK,.008)
box(g,(0,1.08,-.08),(1.8,.07,.12),OAK,.005)
for x in [-.72,-.48,-.24,0,.24,.48,.72]:beam(g,(x,1.10,-.08),(x,1.38,-.08),.018,.025,OAK,.002)
parent=finish(g,'Bottom centre of 1.8 × 1.4 opening; counter pivot at sill.',(1.8,1.4,.2),{'shutter_open_rotation_x_degrees':90,'hinge_axis':'+X','shutter_note':'Rotate child +90° X from closed vertical to horizontal outward counter.'})
g=Mesh('window_shop_counter');g.grain=Vector((0,1,0))
for j in range(12):box(g,(-.825+j*.15,.51,0),(.146,1.02,.055),OAK,.003)
for y in [.15,.85]:box(g,(0,y,.039),(1.77,.07,.022),OAK,.004)
for x in [-.69,.69]:box(g,(x,.10,-.039),(.04,.20,.026),IRON,.004);cyl(g,(x-.035,0,0),(x+.035,0,0),.023,IRON,6)
finish(g,'',parent=parent,loc=(0,.008,.038))

# Thin plain tiles: 28 triangles, irregular corners within exact nominal envelope.
def tile(g,variant=0,c=(0,0,0),rot=None):
 rng=random.Random(variant+193);w=.165;length=.265
 # Eight-point perimeter: bowed sides, irregular toe and one small hand-cut chip.
 chip=rng.uniform(-.025,.025)
 outline=[(-w/2,rng.uniform(0,.002)),(w/2-rng.uniform(.0005,.003),0),(w/2-rng.uniform(0,.002),.1325),(w/2,.265-rng.uniform(.0005,.003)),(chip+.009,.265),(chip-.006,.261-rng.uniform(0,.003)),(-w/2+rng.uniform(.0005,.003),.265),(-w/2+rng.uniform(0,.002),.1325)]
 vs=[Vector((x,layer+.0035*math.sin(math.pi*z/length)+.16*z,z)) for layer in [0,.012] for x,z in outline]
 fs=[tuple(range(7,-1,-1)),tuple(range(8,16))]+[(j,(j+1)%8,(j+1)%8+8,j+8) for j in range(8)]
 if rot:vs=[rot@p for p in vs]
 g.add([p+Vector(c) for p in vs],fs,CLAY if variant%4!=3 else 8)
for j,s in enumerate('abcd'):
 g=Mesh('tile_'+s);tile(g,j);finish(g,'Centre of head/top edge, underside at Y=0; X width, +Z downslope.',extra={'size_metres':[.165,.012,.265],'course_gauge_metres':.10,'installation':'Rotate X by +roof pitch. Nominal thickness 12 mm; camber 3.5 mm; built-in lap tilt 0.16 m/m keeps courses separated. Lay heads every .10 m downslope on common plane.'})
for variant,s in enumerate('ab'):
 g=Mesh('tile_strip_'+s)
 for j in range(8):tile(g,j+variant*11,((j-3.5)*.165,.0002*((j+variant)%3),0))
 finish(g,'Row midpoint at tile heads; nominal X span 1.32 m.',extra={'tiles':8,'row_width_metres':1.32,'gauge_metres':.10,'jitter':'Camber, internal edge offsets and sub-mm rise baked; outer span remains 1.32.'})
g=Mesh('roof_underlay');box(g,(0,-.0125,.5),(1,.025,1),8,0);finish(g,'Head centre, top face Y=0, X ±0.5, Z=0..1.',extra={'size_metres':[1,.025,1],'note':'Hidden dark deck; scale in X/Z to roof envelope, offset beneath tile undersides.'})
def ridge(g,c=(0,0,0),rot=None,length=.30):
 # Rounded crown blends into clay skirts following a steep roof.
 outline=[(-.15,-.185),(-.10,-.105)]
 outline += [(.075*math.cos(math.radians(a)),.075*math.sin(math.radians(a))-.020) for a in range(165,14,-15)]
 outline += [(.10,-.105),(.15,-.185)]
 profile=outline+[(z,y-.014) for z,y in reversed(outline)]
 vs=[Vector((x,y+(x/length)*.032,z)) for x in [-length/2,length/2] for z,y in profile];count=len(profile)
 fs=[tuple(range(count-1,-1,-1)),tuple(range(count,2*count))]+[(j,(j+1)%count,(j+1)%count+count,j+count) for j in range(count)]
 if rot:vs=[rot@v for v in vs]
 g.add([v+Vector(c) for v in vs],fs,CLAY)
 for s in [-1,1]:
  pp=[(s*.118,-.165),(s*.15,-.215),(s*.15,-.201),(s*.118,-.150)]
  vv=[Vector((x,y+(x/length)*.032,z)) for x in [-length/2,length/2] for z,y in pp]
  if rot:vv=[rot@v for v in vv]
  g.add([v+Vector(c) for v in vv],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],STONE)
g=Mesh('ridge_tile_round');ridge(g);finish(g,'Centre along ridge X; bedding shoulders below Y=0.',extra={'length_metres':.30,'pitch_range_degrees':[50,60],'nominal_pitch_degrees':55,'spacing_metres':.255,'lap_metres':.045,'lap_rule':'Repeat in +X. Raised +X lip overlaps lowered -X end of next cap by 45 mm. Crown rises 32 mm along length.'})
g=Mesh('verge_board_1m');box(g,(0,-.065,-.023),(1,.13,.046),OAK,.006);finish(g,'Top face Y=0, exterior Z=0, X endpoints ±0.5.')
g=Mesh('rafter_end');g.grain=Vector((0,-.3,1)).normalized();poly=[(0,0),(.43,-.13),(.46,-.19),(.39,-.235),(-.03,-.12)]
vs=[(x,y,z) for x in [-.052,.052] for z,y in poly];n=len(poly);g.add(vs,[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)],OAK)
finish(g,'Back top at eaves; pre-cut 17-degree tail, extends +Z; repeat 0.5 m along X.')
# Dormer with cheeks and roof tails clipped to the shared 55-degree main-roof plane.
g=Mesh('dormer');pitch=math.radians(55);slope=math.tan(pitch);half=.48;eave=.86;peak=eave+half*slope;back=-peak/slope
# Framed front opening consistent with window_small.
for s in [-1,1]:beam(g,(s*.27,.13,-.045),(s*.27,.68,-.045),.09,.14)
for y in [.095,.715]:box(g,(0,y,-.04),(.62,.07,.15),OAK,.006)
casement(g,.446,.546,(-.223,.134,-.055))
# Apron + gable infill sits back 25mm; construction beams stand proud.
box(g,(0,.030,-.10),(.86,.06,.15),4,.004)
for sign in [-1,1]:box(g,(sign*.365,.435,-.090),(.10,.79,.13),4,.002)
box(g,(0,.805,-.090),(.76,.11,.13),4,.002)
prism(g,[(-half,eave),(half,eave),(0,peak)],[-.13,-.025],5)
for s in [-1,1]:
 beam(g,(s*half,eave,.015),(0,peak,.015),.075,.095)
 # Cheek front lower edge meets plane at origin, rear rises with main roof.
 poly=[(0,0),(0,eave),(-eave/slope,eave)];vs=[(x,y,z) for x in [s*.415-.037,s*.415+.037] 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)],4)
 beam(g,(s*.425,0,0),(s*.425,eave,0),.07,.09)
 beam(g,(s*.425,eave,0),(s*.425,eave,-eave/slope),.07,.09)
 # Closed underlay slab, rear edge on main roof.
 top=[(s*half,eave,.12),(0,peak,.12),(0,peak,back),(s*half,eave,-eave/slope)]
 vv=[(x,y+dy,z) for dy in [-.022,0] for x,y,z in top]
 g.add(vv,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],8)
 length=half/math.cos(pitch)
 # Courses overlap 0.165 m; clay width 0.165 along dormer ridge, gauge .10 downslope.
 for row in range(8):
  d=row*.10;rotation=Matrix(((0,s*math.sin(pitch),s*math.cos(pitch)),(0,math.cos(pitch),-math.sin(pitch)),(-s,0,0)))
  for col in range(8):
   z=.055-col*.165;tmp=Mesh('clip');tile(tmp,row*13+col,(s*d*math.cos(pitch),peak-d*math.sin(pitch)+.006,z),rotation)
   # Bisect against the main roof plane; cap intersections to keep a closed tile.
   if max(v.y+slope*v.z for v in tmp.v)<=.001:continue
   bm=bmesh.new();verts=[bm.verts.new(v) for v in tmp.v]
   for face in tmp.f:bm.faces.new([verts[i] for i in face])
   result=bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=.000001,plane_co=(0,0,0),plane_no=(0,1,slope),clear_inner=True)
   cut=[e for e in result['geom_cut'] if isinstance(e,bmesh.types.BMEdge) and e.is_boundary]
   if cut:bmesh.ops.holes_fill(bm,edges=cut,sides=0)
   bm.verts.ensure_lookup_table();bm.verts.index_update();g.add([v.co.copy() for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces],CLAY if (row+col)%4!=3 else 8);bm.free()
for j in range(4):ridge(g,(0,peak+.027,-.035-j*.255),Matrix.Rotation(math.pi/2,3,'Y'))
ridge(g,(0,peak+.027,-1.012),Matrix.Rotation(math.pi/2,3,'Y'),length=.14)
finish(g,'Dormer front bottom centre at intersection with main roof; +Z toward eaves.',extra={'main_roof_pitch_degrees':55,'main_roof_plane':'Y = -Z * tan(55 degrees)','roof_cut_polygon_XZ':[[-half,0],[half,0],[half,-eave/slope],[0,back],[-half,-eave/slope]],'roof_cut_note':'Cut all main-roof decking and tiles within polygon. Cheek bottom and rear roof edges follow main roof plane. Do not place intact main roof through dormer.','casement_clear_metres':[.45,.55]})
g=Mesh('chimney_stone');rng=random.Random(820)
box(g,(0,.972,0),(.866,1.944,.664),1,0)
beds=[0]+[i*.162+rng.uniform(-.023,.023) for i in range(1,12)]+[1.944]
for row in range(12):
 cuts=[-.45,-.16+rng.uniform(-.105,.105),.16+rng.uniform(-.085,.085),.45]
 for side in [-1,1]:
  for k in range(3):
   x0=cuts[k]+(.007 if k else 0);x1=cuts[k+1]-(.007 if k<2 else 0);y0=beds[row]+.005;y1=beds[row+1]-.005
   c1=rng.uniform(.012,.032);c2=rng.uniform(.012,.027)
   poly=[(x0+c1,y0+rng.uniform(0,.009)),(x1-c2,y0+rng.uniform(0,.018)),(x1,y0+c2),(x1,y1-c1),(x1-c1,y1-rng.uniform(0,.011)),(x0+c2,y1-rng.uniform(0,.014)),(x0,y1-c2),(x0,y0+c1)]
   face=.35-rng.uniform(0,.008)
   prism(g,poly,[.003,face] if side>0 else [-face,-.003],STONE)
box(g,(0,1.98,0),(.96,.072,.76),STONE,.012);box(g,(0,2.047,0),(1.04,.062,.84),STONE,.009)
for x in [-.25,.25]:
 n=12;vs=[]
 for y,r in [(2.078,.123),(2.105,.128),(2.425,.105),(2.452,.126),(2.49,.124),(2.50,.111),(2.50,.084),(2.14,.080)]:vs += [(x+r*math.cos(k*math.tau/n),y,r*math.sin(k*math.tau/n)) for k in range(n)]
 fs=[]
 for j in range(7):fs += [(j*n+k,j*n+(k+1)%n,(j+1)*n+(k+1)%n,(j+1)*n+k) for k in range(n)]
 # Closed bottom beneath deep bore; hollow lip remains visibly open.
 fs += [tuple(range(n-1,-1,-1)),tuple(range(7*n,8*n))];g.add(vs,fs,CLAY,True)
finish(g,'Bottom centre; body 0.90 X × 0.70 Z, pots reach Y=2.50.',extra={'body_size_metres':[.9,.7],'total_height_metres':2.5,'construction':'12 irregular bed courses, dressed cap, two hollow clay pots.'})

# Tavern sign: bracket in YZ, sign plane YZ, readable on both street directions.
g=Mesh('tavern_sign');box(g,(0,.05,.025),(.10,.67,.05),IRON,.01)
sweep(g,[(0,.27,.05),(0,.27,1.0),(0,.23,1.05)],.018,IRON,6)
sweep(g,[(0,-.20,.05),(0,.17,.74)],.014,IRON,6)
for cz,cy,rad in [(.32,.07,.135),(.74,.12,.095)]:
 pts=[]
 for k in range(22):
  t=k/21;ang=-.6+t*math.pi*2;rr=rad*(1-.70*t);pts.append((0,cy+rr*math.cos(ang),cz+rr*math.sin(ang)))
 sweep(g,pts,.010,IRON,5)
for y in [-.21,.29]:cyl(g,(0,y,.045),(0,y,.055),.017,IRON,6)
parent=finish(g,'Wall mounting-plate centre; bracket projects +Z, board hangs in YZ plane.',extra={'board_child':'tavern_sign_board','hinge_axis':'+Z','projection_metres':1.08})
g=Mesh('tavern_sign_board')
# Curved heraldic board silhouette, extruded along X, neutral paint for tinting.
poly=[(-.40,-.11),(-.37,-.055),(-.22,-.012),(0,.025),(.22,-.012),(.37,-.055),(.40,-.11),(.40,-.63),(.24,-.70),(0,-.73),(-.24,-.70),(-.40,-.63)]
# poly coordinates are sign-local horizontal (Z) and Y.
vs=[(x,y,z) for x in [-.033,.033] for z,y in poly];n=len(poly);g.add(vs,[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)],13)
for x in [-.038,.038]:
 # Inset border and carved tankard emblem, real shallow relief on both sides.
 sweep(g,[(x,y*.92-.02,z*.92) for z,y in poly+[poly[0]]],.012,OAK,4)
 pp=[(-.12,-.24),(.10,-.24),(.085,-.48),(-.09,-.48)];vv=[(x+dx,y,z) for dx in [0,math.copysign(.012,x)] for z,y in pp];g.add(vv,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],OAK)
 sweep(g,[(x,-.27,.10),(x,-.28,.19),(x,-.40,.19),(x,-.43,.095)],.018,OAK,5)
 for y in [-.24,-.48]:sweep(g,[(x,y,-.13),(x,y,.11)],.012,OAK,4)
for z in [-.27,.27]:
 for y in [.02,.105]:ring(g,(0,y,z),.05,.007,IRON,10,axis='x')
finish(g,'',parent=parent,loc=(0,.06,.62))

g=Mesh('lantern_iron');box(g,(0,.19,.025),(.08,.25,.05),IRON,.007)
sweep(g,[(0,.27,.05),(0,.30,.16),(0,.28,.30),(0,.19,.32)],.011,IRON,6)
box(g,(0,-.055,.32),(.145,.25,.14),GLASS,0)
for x in [-.085,.085]:
 for z in [.24,.40]:cyl(g,(x,-.20,z),(x,.085,z),.009,IRON,5)
for y in [-.21,.087]:box(g,(0,y,.32),(.22,.025,.22),IRON,.004)
vs=[(-.14,.10,.18),(.14,.10,.18),(.14,.10,.46),(-.14,.10,.46),(-.04,.18,.28),(.04,.18,.28),(.04,.18,.36),(-.04,.18,.36)]
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)],IRON)
ring(g,(0,.18,.32),.03,.007,IRON,10)
finish(g,'Centre of wall plate at Z=0; lantern projects +Z. Unlit historical iron lantern.')
g=Mesh('bench_wood')
for z in [-.105,.105]:box(g,(0,.48,z),(1.45,.055,.201),OAK,.008)
for x in [-.51,.51]:
 beam(g,(x,.06,-.18),(x,.45,-.12),.095,.09);beam(g,(x,.06,.18),(x,.45,.12),.095,.09)
 box(g,(x,.41,0),(.10,.09,.45),OAK,.006)
beam(g,(-.54,.20,0),(.54,.20,0),.085,.085)
finish(g,'Ground centre; seat Y=0.5075.',extra={'nominal_size_metres':[1.45,.5075,.42]})
g=Mesh('barrel');n=16
# Each stave is a solid curved strip, with gaps and end rims. 16 × 28 tris =448.
for k in range(n):
 a=k*math.tau/n+.004;b=(k+1)*math.tau/n-.004;vs=[]
 for inner in [0,.023]:
  for y,r in [(0,.24),(.43,.315),(.86,.24)]:
   for t in [a,b]:vs.append(((r-inner)*math.cos(t),y,(r-inner)*math.sin(t)))
 fs=[]
 for j in range(2):
  fs += [(2*j,2*j+1,2*j+3,2*j+2),(6+2*j,8+2*j,9+2*j,7+2*j),(2*j,2*j+2,8+2*j,6+2*j),(2*j+1,7+2*j,9+2*j,3+2*j)]
 fs += [(0,6,7,1),(4,5,11,10)];g.grain=Vector((0,1,0));g.add(vs,fs,OAK)
# Hoops low-sided thin shells, 3 bands ×64 tris; barrel target needs economical staves.
for y,r in [(.115,.264),(.43,.319),(.745,.264)]:
 vs=[(r*math.cos(k*math.tau/n),yy,r*math.sin(k*math.tau/n)) for yy in [y-.023,y+.023] for k in range(n)]
 g.add(vs,[(k,(k+1)%n,(k+1)%n+n,k+n) for k in range(n)],IRON)
for y in [.025,.831]:cyl(g,(0,y-.008,0),(0,y+.008,0),.231,OAK,16)
finish(g,'Bottom centre, Y=0; height 0.86.',extra={'diameter_metres':.638})
g=Mesh('corner_stone');box(g,(0,0,0),(.5,.3,.5),STONE,.022);
for i,v in enumerate(g.v):
 for ax in range(3):
  limit=[.25,.15,.25][ax]
  if abs(v[ax])<limit-.0001:v[ax]+=random.Random(901+i*3+ax).uniform(-.006,.006)
finish(g,'Block centre, Y vertical; alternate bonding with generated stone courses.',extra={'size_metres':[.5,.3,.5]})

# Manifest + strict source geometry checks before serialization.
bpy.context.view_layer.update();entries=[]
for ob in OBJECTS:
 if ob.parent:continue
 parts=[ob]+list(ob.children);pts=[o.matrix_local@v.co for o in parts for v in o.data.vertices]
 tris=sum(len(o.data.polygons) for o in parts)
 print('ATOM',ob.name,tris,'/',LIMITS[ob.name],flush=True)
 assert tris<=LIMITS[ob.name],(ob.name,tris,LIMITS[ob.name])
 for o in parts:
  assert all(p.area>1e-12 for p in o.data.polygons),(o.name,'degenerate')
  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({o.data.materials[p.material_index].name for o in parts for p in o.data.polygons})})
 if ob.children:entry['children']=[{'node_name':o.name,'translation':list(o.location),'hinge_axis':'+X' if 'counter' in o.name else ('+Z' if 'board' in o.name else '+Y'),'triangles':len(o.data.polygons)} for o in ob.children]
 entries.append(entry)
assert set(META)==set(LIMITS)
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];gl=json.loads(data[20:20+ln]);assert not gl.get('images') and not gl.get('textures')
roots=[gl['nodes'][i] for i in gl['scenes'][gl.get('scene',0)]['nodes']];assert {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)
kitdoc={'schema':'forge.atom-kit.v1','units':'metres','up':'+Y','facade_outward':'+Z','facade_horizontal':'+X','uv_rule':'Metric UV0 divided by shelf physical repeat. Oak grain follows each structural member. No extra texture repeat at runtime.','atoms':entries,'total_triangles':sum(e['triangles'] for e in entries),'glb_bytes':len(data),'material_binding':'materials.json','images_embedded':0,'provenance':{'geometry':'Original procedural authoring from supplied sheet.png; helper structure adapted from adjacent read-only Breton kit.','textures':'Poly Haven CC0; see materials.json'}}
(ROOT/'kit.json').write_text(json.dumps(kitdoc,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(),'seconds_before_render':time.time()-START},indent=2))
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'kit.blend'))
print('EXPORT_COMPLETE',len(entries),kitdoc['total_triangles'],len(data),flush=True)
if '--geometry-only' not in sys.argv:
 exec(compile((ROOT/'render_kit.py').read_text(),str(ROOT/'render_kit.py'),'exec'))
