"""Medieval-town frozen atom kit. Blender 5.2; Y-up authoring, export_yup=False.
Run Blender -b --factory-startup --python-exit-code 1 --python build_kit.py.
Shared shelf inputs preserved byte-for-byte. Original reed maps: prepare_materials.py.
"""
import bpy,bmesh,math,json,sys,hashlib,time
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
from details import beam,sweep,ring,ribbon
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)
SHELF=json.loads((ROOT/'materials.json').read_text())['materials']
NAMES=['Rubble_stone','Rubble_stone_dark','Oak_frame','Oak_frame_grey','Plaster_ochre','Plaster_white','Plaster_pink','Clay_tile','Clay_tile_dark','Dressed_limestone','Wrought_iron','Leaded_glass','Door_paint','Sign_paint','Thatch_reed']
G.TILES=[SHELF[n].get('tile_size_metres',1) for n in NAMES]
for name in NAMES:
 m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
 im=bpy.data.images.load(str(ROOT/SHELF[name]['maps']['BaseColor']));im.scale(8,8);px=list(im.pixels);col=tuple(sum(px[i::4])/64 for i in range(3))+(1,);bpy.data.images.remove(im)
 m.diffuse_color=col;bs.inputs['Base Color'].default_value=col;bs.inputs['Roughness'].default_value=.85
 if name=='Wrought_iron':bs.inputs['Metallic'].default_value=.82
 G.MATS.append(m)
OAK=2;STONE=9;IRON=10;GLASS=11;CLAY=7;THATCH=14
OBJECTS=[];META={}
def finish(g,size,origin,limit=2000,**extra):
 ob=g.finish();OBJECTS.append(ob);META[g.name]={'node_name':g.name,'nominal_size_whd_metres':size,'nominal_size_wdh_metres':[size[0],size[2],size[1]],'origin_rule':origin,'triangle_budget':limit,**extra};return ob
FACADE='Bottom centre of clear opening; exterior wall plane Z=0; +Z out.'
DORMER='Front sill centre [0,0,0]; +Z downslope; body -Z; keep Y vertical on 55 degree host roof y=-tan(55deg)*z.'
def move(g,start,c,scale=1):g.v[start:]=[Vector(c)+v*scale for v in g.v[start:]]
def lathe(g,profile,c,mat,n=12,closed=True):
 vs=[];rings=[]
 for r,y in profile:
  ids=[]
  for j in range(n if r>1e-8 else 1):ids.append(len(vs));vs.append((c[0]+r*math.cos(j*math.tau/n),c[1]+y,c[2]+r*math.sin(j*math.tau/n)))
  rings.append(ids)
 fs=[]
 for A,B in zip(rings,rings[1:]):
  if len(A)==1:fs.extend((A[0],B[(i+1)%n],B[i]) for i in range(n))
  elif len(B)==1:fs.extend((A[i],A[(i+1)%n],B[0]) for i in range(n))
  else:fs.extend((A[i],A[(i+1)%n],B[(i+1)%n],B[i]) for i in range(n))
 if closed:
  if len(rings[0])>1:fs.append(tuple(reversed(rings[0])))
  if len(rings[-1])>1:fs.append(tuple(rings[-1]))
 g.add(vs,fs,mat)
def glazing(g,w,h,c,pointed=False,trim_bevel=.005):
 # Thin glass behind clipped diamond leadwork, true surround separate from glazing.
 x0,y0,z=c
 if not pointed:box(g,(x0,y0+h/2,z-.025),(w,h,.014),GLASS,0)
 else:prism(g,[(x0-w/2,y0),(x0+w/2,y0),(x0+w/2,y0+h*.70),(x0,y0+h),(x0-w/2,y0+h*.70)],[z-.035,z-.02],GLASS)
 for x in [-w/2,w/2]:beam(g,(x0+x,y0,z),(x0+x,y0+(h*.70 if pointed else h),z),.055,.065,OAK,trim_bevel)
 box(g,(x0,y0-.026,z),(w+.12,.065,.10),OAK,trim_bevel)
 top=[(-w/2,h*.7),(0,h),(w/2,h*.7)] if pointed else [(-w/2,h),(w/2,h)]
 for a,b in zip(top,top[1:]):beam(g,(x0+a[0],y0+a[1],z),(x0+b[0],y0+b[1],z),.06,.065,OAK,trim_bevel)
 # Clip using convex pane inequalities.
 for slope in [-1.7,1.7]:
  for k in range(-8,14):
   intercept=k*.18;pts=[]
   poly=[(-w/2,0),(w/2,0),(w/2,h*.70),(0,h),(-w/2,h*.70)] if pointed else [(-w/2,0),(w/2,0),(w/2,h),(-w/2,h)]
   for a,b in zip(poly,poly[1:]+poly[:1]):
    dx=b[0]-a[0];dy=b[1]-a[1];den=dy-slope*dx
    if abs(den)<1e-8:continue
    t=(slope*a[0]+intercept-a[1])/den
    if 0<=t<1:pts.append((x0+a[0]+t*dx,y0+a[1]+t*dy,z+.012))
   if len(pts)==2 and (Vector(pts[1])-Vector(pts[0])).length>.008:cyl(g,pts[0],pts[1],.004,IRON,4)
def gable_roof(g,w,depth,eave,ridge,zfront=.08,rows=4,cols=6):
 # ridge runs Z; closed lapped clay courses, folded ridge cap.
 for side in [-1,1]:
  def pt(u,v,y=0):return (side*v,ridge+(eave-ridge)*v/(w/2)+y,u)
  for row in range(rows):
   v0=row*w/2/rows;v1=min(w/2,v0+w/2/rows*1.14)
   for col in range(cols):
    u0=zfront-depth+col*depth/cols+.0015;u1=u0+depth/cols-.003
    vs=[pt(u,v,dy+.012*(v-v0)/(v1-v0)) for dy in [0,-.028] for u,v in [(u0,v0),(u1,v0),(u1,v1),(u0,v1)]]
    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)],CLAY)
  beam(g,(0,ridge-.045,zfront-.05),(side*w/2,eave-.045,zfront-.05),.065,.09,OAK,.004)
 prism(g,[(-.085,ridge-.052),(0,ridge+.034),(.085,ridge-.052),(.075,ridge-.085),(0,ridge-.003),(-.075,ridge-.085)],[zfront-depth,zfront+.002],CLAY)
def dormer(name,hip=False):
 g=Mesh(name)
 # Closed cheeks extend to back of declared footprint and 55-degree roof buries them.
 for x in [-.49,.49]:box(g,(x,.45,-.875),(.10,1.0,1.75),4,.006)
 box(g,(0,.045,-.035),(1.1,.13,.17),OAK,.006)
 for x in [-.48,.48]:beam(g,(x,0,.005),(x,1.02,.005),.075,.095,OAK,.004)
 glazing(g,.72,.78,(0,.13,.005))
 if not hip:
  prism(g,[(-.50,.95),(0,1.68),(.50,.95)],[-1.75,-.005],5)
  gable_roof(g,1.3,1.9,1.0,1.76,zfront=.10,rows=4,cols=6)
  # Pointed bargeboard, central carved pendant.
  beam(g,(0,1.02,.025),(0,1.63,.025),.045,.055,OAK,.003)
 else:
  # Steep bellcast octagonal hip around a window box; long tail buried in host roof.
  # Octagonal outline is deliberately stretched backwards to meet roof.
  outline=[(-.65,.08),(.65,.08),(.65,-.35),(.50,-1.80),(.27,-1.80),(-.27,-1.80),(-.50,-1.80),(-.65,-.35)]
  center=Vector((0,-.38));levels=[(1.00,1.0),(1.13,.83),(1.42,.62),(1.73,.40),(2.04,.18),(2.18,.075)]
  for (y0,s0),(y1,s1) in zip(levels,levels[1:]):
   for j in range(8):
    p0=center+(Vector(outline[j])-center)*s0;p1=center+(Vector(outline[(j+1)%8])-center)*s0
    q0=center+(Vector(outline[j])-center)*s1;q1=center+(Vector(outline[(j+1)%8])-center)*s1
    vs=[(p0.x,y0,p0.y),(p1.x,y0,p1.y),(q1.x,y1,q1.y),(q0.x,y1,q0.y)]
    g.add(vs+[(x,y-.035,z) for x,y,z in vs],[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],CLAY)
    # Course lip is geometric; fine tile grain remains the shelf map.
  lathe(g,[(.07,0),(.09,.06),(.045,.12),(.055,.19),(.012,.37),(0,.42)],(0,2.18,-.38),IRON,8)
 finish(g,[1.3,2.6 if hip else 1.8,1.9],DORMER,2500,host_roof_pitch_degrees=55,host_roof_plane='y + tan(55deg)*z = 0',burial_depth_back_metres=1.8,roof_cutout_required=False)
dormer('dormer_gabled');dormer('dormer_hipped',True)
# Wall-gable dormer, stepped limestone crest and pointed tracery pane.
g=Mesh('dormer_wall')
outline=[(-.8,0),(.8,0),(.8,1.25),(.66,1.25),(.66,1.55),(.50,1.55),(.50,1.82),(.32,1.82),(.32,2.08),(.16,2.08),(0,2.40),(-.16,2.08),(-.32,2.08),(-.32,1.82),(-.50,1.82),(-.50,1.55),(-.66,1.55),(-.66,1.25),(-.8,1.25)]
# Open centre: side piers + top spandrel, pane hides no full solid wall.
for x in [-.62,.62]:box(g,(x,.60,-.105),(.36,1.20,.21),5,.008)
prism(g,[(x,y) for x,y in outline[2:]]+[(-.8,1.0),(-.40,1.0),(0,1.55),(.40,1.0),(.8,1.0)],[-.21,0],5)
box(g,(0,.09,-.105),(1.6,.18,.21),STONE,.008)
glazing(g,.72,1.25,(0,.20,.025),True)
for a,b in zip(outline[2:],outline[3:]):beam(g,(a[0],a[1],-.015),(b[0],b[1],-.015),.045,.10,STONE,.002)
gable_roof(g,1.30,1.05,1.20,2.04,zfront=-.15,rows=3,cols=4)
finish(g,[1.6,2.4,1.2],'Bottom centre front face at eaves; facade Z=0; body -Z.',2500)
# Louvre: saddle open to either pitched roof, ridge along X.
g=Mesh('louvre_ridge')
for x in [-.48,.48]:
 for z in [-.34,.34]:beam(g,(x,-.12,z),(x,.42,z),.075,.075,OAK,.004)
for z in [-.34,.34]:
 for y in [.08,.22,.36]:box(g,(0,y,z),(1.08,.070,.065),OAK,.003,Matrix.Rotation(math.radians(-18 if z>0 else 18),3,'X'))
# Rotate gable roof so ridge Z becomes X.
st=len(g.v);gable_roof(g,1.0,1.2,.42,.67,zfront=.6,rows=2,cols=4)
g.v[st:]=[Vector((v.z,v.y,-v.x)) for v in g.v[st:]]
finish(g,[1.2,.7,1.0],'Centre on host roof ridge line Y=0; ridge along X.',ridge_axis='+X',saddle_embed_metres=.12)
# Rich grouped hollow chimney flues, stepped mouldings, octagonal decorated shafts.
def chimney(name,rich):
 g=Mesh(name);w=1.6 if rich else 1.2;h=3.4 if rich else 2.8;d=1.0 if rich else .8
 box(g,(0,.39,0),(w*.88,.78,d*.90),0,.02)
 box(g,(0,.82,0),(w,.12,d),STONE,.012)
 xs=[-.48,0,.48] if rich else [-.29,.29];n=8 if rich else 4
 for k,x in enumerate(xs):
  top=h-(.12 if rich and k==1 else 0);r=.23 if rich else .235
  lathe(g,[(r*1.12,.86),(r*1.12,.99),(r,1.10),(r,top-.45),(r*1.22,top-.40),(r*1.25,top-.32),(r*1.06,top-.24),(r*1.25,top-.18),(r*1.25,top),(r*.68,top),(r*.68,top-.52)],(x,0,0),STONE,n)
  # Narrow course bands, very shallow. Shafts remain legible continuous masonry.
  for y in [1.32,1.66,2.00,2.34]+([2.68] if rich else []):
   if y<top-.47:lathe(g,[(r*1.012,y),(r*1.012,y+.025)],(x,0,0),0,n,False)
  if rich:
   for j in range(8):
    a=j*math.tau/8;xx=x+(r+.006)*math.cos(a);zz=(r+.006)*math.sin(a)
    cyl(g,(xx,top-.39,zz),(xx,top-.23,zz),.021,STONE,4,r2=.012)
  # Recessed dark cavity, never a fake lid at the rim.
  cyl(g,(x,top-.53,0),(x,top-.52,0),r*.66,IRON,8)
 finish(g,[w,h,d],'Bottom centre; vertical Y; placement base 0.55 m below ridge.',3000 if rich else 2000,flue_openings=len(xs))
chimney('chimney_cluster',True);chimney('chimney_twin',False)
# Hanging trade signs, wrought bracket + chains; silhouette objects for boot/key/pretzel.
def sign_bracket(g):
 box(g,(0,-.13,.025),(.09,.30,.05),IRON,.004)
 beam(g,(0,-.03,.04),(0,-.03,.56),.025,.028,IRON,.002)
 sweep(g,[(0,-.26,.04),(0,-.24,.16),(0,-.16,.30),(0,-.045,.45)],.012,IRON,5)
 for x in [-.23,.23]:
  beam(g,(0,-.03,.51),(x,-.03,.51),.021,.021,IRON,.001)
  for j in range(2):ring(g,(x,-.09-j*.063,.51),.034,.006,IRON,8)
 for y in [-.04,-.23]:cyl(g,(0,y,.045),(0,y,.058),.013,IRON,6)
def sign(name,kind):
 g=Mesh(name);sign_bracket(g)
 if kind=='boot':
  poly=[(-.17,-.20),(.22,-.20),(.22,-.28),(.17,-.31),(.15,-.70),(.23,-.85),(.21,-.96),(.07,-.98),(.015,-.93),(-.29,-.98),(-.43,-.94),(-.44,-.87),(-.37,-.82),(-.13,-.78),(-.13,-.34),(-.19,-.29)]
  prism(g,poly,[.445,.545],13)
  for z in [.435,.553]:
   sweep(g,[(-.42,-.923,z),(-.27,-.95,z),(.0,-.90,z),(.20,-.935,z)],.012,OAK,4)
   beam(g,(-.17,-.27,z),(.20,-.27,z),.018,.014,OAK,.001)
  beam(g,(-.23,-.18,.51),(-.12,-.20,.51),.014,.014,IRON,.001)
 elif kind=='pig':
  pp=[(-.45,-.23),(-.36,-.19),(.36,-.19),(.45,-.25),(.43,-.92),(.32,-.98),(-.36,-.97),(-.45,-.89)]
  prism(g,pp,[.455,.515],13)
  for z in [.447,.523]:
   sweep(g,[(x*.93,y*.94-.025,z) for x,y in pp+[pp[0]]],.012,OAK,4)
   pig=[(-.30,-.56),(-.23,-.45),(-.06,-.40),(.13,-.43),(.20,-.51),(.29,-.52),(.34,-.57),(.32,-.64),(.23,-.66),(.17,-.80),(.10,-.80),(.10,-.70),(-.11,-.70),(-.18,-.81),(-.24,-.80),(-.23,-.64),(-.30,-.61)]
   prism(g,pig,[z,z+(.02 if z>.5 else -.02)],OAK)
   prism(g,[(.16,-.51),(.16,-.40),(.24,-.49)],[z,z+.019],OAK)
   cyl(g,(.245,-.554,z),(.245,-.554,z+.026),.011,IRON,6)
   sweep(g,[(-.29,-.55,z),(-.35,-.50,z),(-.36,-.45,z),(-.31,-.44,z),(-.31,-.49,z)],.012,OAK,5)
 elif kind=='key':
  ring(g,(0,-.38,.51),.18,.035,IRON,16)
  beam(g,(0,-.55,.51),(0,-.96,.51),.065,.065,IRON,.004)
  for y in [-.81,-.93]:box(g,(.09,y,.51),(.19,.07,.065),IRON,.003)
  beam(g,(-.23,-.18,.51),(-.12,-.25,.51),.015,.015,IRON,.001);beam(g,(.23,-.18,.51),(.12,-.25,.51),.015,.015,IRON,.001)
 else:
  pts=[(-.24,-.81),(-.37,-.69),(-.38,-.47),(-.28,-.33),(-.13,-.36),(.0,-.56),(.14,-.85),(.27,-.85),(.36,-.72),(.38,-.48),(.28,-.33),(.13,-.36),(-.02,-.59),(-.17,-.84),(-.24,-.81)]
  sweep(g,[(x,y,.51+.018*math.sin(i)) for i,(x,y) in enumerate(pts)],.047,CLAY,8)
  sweep(g,[(-.31,-.71,.51),(-.17,-.84,.53),(.13,-.84,.51),(.31,-.71,.51)],.044,CLAY,7)
  for x in [-.23,.23]:beam(g,(x,-.18,.51),(x,-.35,.51),.015,.015,IRON,.001)
 finish(g,[.9,1.0,.6],'Wall bracket fixing point [0,0,0]; object hangs below, body +Z.',sockets={'wall_fix':[0,0,0]},trade={'boot':'cobbler','pig':'butcher','key':'locksmith','pretzel':'baker'}[kind])
for k in ['boot','pig','key','pretzel']:sign('sign_'+k,k)
# Box bay; real framed glazed front and end lights, shaped corbels under floor.
g=Mesh('oriel')
box(g,(0,.10,.29),(1.50,.20,.58),OAK,.012)
for x in [-.72,0,.72]:beam(g,(x,.20,.53),(x,1.73,.53),.075,.09,OAK,.005)
for x in [-.74,.74]:
 beam(g,(x,.18,.045),(x,1.75,.045),.075,.065,OAK,.005)
 box(g,(x,.36,.29),(.07,.28,.50),4,.003)
for x in [-.36,.36]:glazing(g,.56,1.05,(x,.60,.535),trim_bevel=0)
box(g,(0,.37,.535),(1.40,.27,.07),4,.003)
# Side glazing oriented toward the street-side corner.
for s in [-1,1]:
 st=len(g.v);glazing(g,.36,1.05,(0,.60,0),trim_bevel=0)
 g.v[st:]=[Vector((s*(.74+v.z),v.y,.29-s*v.x)) for v in g.v[st:]]
for x in [-.53,.53]:
 poly=[(.035,.04),(.53,.04),(.47,-.08),(.33,-.17),(.17,-.36),(.035,-.43)]
 vs=[(xx,y,z) for xx in [x-.07,x+.07] for z,y in poly];n=len(poly)
 g.add(vs,[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)],OAK)
# Small pent tile roof against wall, horizontal span X.
for j in range(6):
 for row in range(3):
  x0=-.80+j*1.6/6+.002;x1=x0+1.6/6-.004;z0=row*.20;z1=min(.60,z0+.23)
  vs=[(x,2.20-z*.65+dy,z) for dy in [0,-.032] for x,z in [(x0,z0),(x1,z0),(x1,z1),(x0,z1)]]
  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)],CLAY)
box(g,(0,1.74,.52),(1.58,.11,.14),OAK,.008)
finish(g,[1.6,2.2,.6],'Bottom centre BACK face Z=0 at upper-storey floor; corbels extend below Y=0.',corbel_drop_metres=.43,wall_trim_front_min_z=.035)
# Arched doors: clear-opening masks + independently fitted plank leaves.
def door(name,w,h,outer_w,outer_h,rubble=False):
 g=Mesh(name);r=w/2;spring=h-r;t=(outer_w-w)/2;mat=0 if rubble else STONE
 for s in [-1,1]:
  for row in range(6):box(g,(s*(r+t/2),(row+.5)*spring/6,.025),(t,spring/6-.006,.30),mat,.004)
 # Elliptical outer crown preserves specified outer height without widening jambs.
 for j in range(11):
  a=j*math.pi/11+.006;b=(j+1)*math.pi/11-.006
  pp=[(r*math.cos(a),spring+r*math.sin(a)),((r+t)*math.cos(a),spring+(outer_h-spring)*math.sin(a)),((r+t)*math.cos(b),spring+(outer_h-spring)*math.sin(b)),(r*math.cos(b),spring+r*math.sin(b))]
  prism(g,pp,[-.125,.175],mat)
 for j in range(9):
  x0=-r+.008+j*(w-.016)/9;x1=-r+.008+(j+1)*(w-.016)/9-.005
  # Every plank has its own sampled arched top; no triangle fan across clear arch.
  top=[(x1-(x1-x0)*i/3,spring+math.sqrt(max(0,r*r-(x1-(x1-x0)*i/3)**2))-.008) for i in range(4)]
  prism(g,[(x0,.008),(x1,.008)]+top,[-.07,-.012],OAK if rubble else 12)
 for y in [.30,spring-.17]:
  poly=[(-r+.05,y-.018),(r-.17,y-.026),(r-.09,y),(r-.17,y+.026),(-r+.05,y+.018)]
  prism(g,poly,[.002,.017],IRON)
  for x in [-r+.13,-.06,r-.21]:cyl(g,(x,y,.017),(x,y,.025),.01,IRON,6)
 ring(g,(r-.19,.88,.038),.045,.009,IRON,12)
 box(g,(r-.19,.92,.007),(.075,.12,.018),IRON,.003)
 finish(g,[outer_w,outer_h,.3],FACADE,opening={'width':w,'height':h,'spring_height':spring,'arch_radius':r,'bottom_center':[0,0,0],'wall_thickness':.3},wall_trim_front_min_z=.175,requires_wall_opening=True)
door('door_arched_stone',1.1,2.3,1.3,2.6);door('door_cellar',.9,1.8,1.0,2.0,True)
# Oval cellar opening with radial dressed stones and through bars.
g=Mesh('window_cellar')
for j in range(16):
 a=j*math.tau/16+.012;b=(j+1)*math.tau/16-.012
 pp=[(rx*math.cos(t),.19+ry*math.sin(t)) for rx,ry,t in [(.235,.19,a),(.30,.25,a),(.30,.25,b),(.235,.19,b)]]
 prism(g,pp,[-.19,.06],STONE)
for x in [-.15,-.05,.05,.15]:
 yy=.19*math.sqrt(1-(x/.235)**2);cyl(g,(x,.19-yy,.007),(x,.19+yy,.007),.013,IRON,6)
beam(g,(-.227,.19,.01),(.227,.19,.01),.023,.022,IRON,.001)
finish(g,[.6,.5,.25],FACADE,opening={'width':.47,'height':.38,'shape':'ellipse','center':[0,.19,0],'bottom_center':[0,0,0]},requires_wall_opening=True)
# Open unglazed shutters. Clear opening same 0.7 x 1.0 as medieval window_leaded.
g=Mesh('window_shuttered')
for x in [-.375,.375]:beam(g,(x,0,-.05),(x,1,-.05),.05,.15,OAK,.005)
for y in [-.025,1.025]:box(g,(0,y,-.05),(.80,.05,.15),OAK,.005)
box(g,(0,-.038,.055),(.80,.07,.20),OAK,.005)
for side in [-1,1]:
 # Open 90 degrees about vertical jamb: leaf is edge-on from straight front.
 for j in range(4):box(g,(side*.366,.50,.012+(j+.5)*.342/4),(.035,.98,.342/4-.004),3,.003)
 for y in [.20,.80]:box(g,(side*.342,y,.183),(.018,.065,.33),OAK,.003)
 for y in [.20,.80]:
  cyl(g,(side*.351,y-.04,.012),(side*.351,y+.04,.012),.018,IRON,8)
  box(g,(side*.337,y,.18),(.012,.029,.29),IRON,.001)
finish(g,[.8,1.1,.3],FACADE,opening={'width':.7,'height':1.0,'bottom_center':[0,0,0]},requires_wall_opening=True,shutter_open_angle_degrees=90,open_shutter_projection_metres=.355,nominal_depth_note='Frame depth nominal 0.3; open 0.35 m leaf projects 0.355 m.')
# Gable cross finial with forged scroll and spike.
g=Mesh('finial');lathe(g,[(.08,0),(.08,.045),(.04,.08),(.035,.69),(.047,.73),(.012,.95),(0,1)],(0,0,0),IRON,8)
beam(g,(-.125,.65,0),(.125,.65,0),.027,.027,IRON,.002)
for s in [-1,1]:sweep(g,[(0,.40,0),(s*.09,.46,0),(s*.10,.54,0),(s*.06,.56,0)],.009,IRON,5)
finish(g,[.25,1,.25],'Bottom centre; +Y vertical.')
# Thatch modules. Profile bulge changes within row but every boundary is identical.
g=Mesh('thatch_panel');xs=[-.505,-.375,-.25,-.125,0,.125,.25,.375,.505];zs=[0,.33,.67,1.04]
vs=[]
for z in zs:
 for x in xs:
  # Raised bundle scallops repeat across X; flat top at row interfaces avoids cracks.
  h=.285+.012*math.cos(x*math.tau*8)+.012*math.sin(min(1,z)*math.pi)
  vs.append((x,h,z))
N=len(xs);fs=[]
for j in range(len(zs)-1):
 for i in range(N-1):a=j*N+i;fs.append((a,a+1,a+1+N,a+N))
# Closed underside and cut bundle edges, not individual reed cylinders.
edge=list(range(N))+[j*N+N-1 for j in range(1,len(zs))]+list(range((len(zs)-1)*N+N-2,(len(zs)-1)*N-1,-1))+[j*N for j in range(len(zs)-2,0,-1)]
bottom=[]
for i in edge:bottom.append(len(vs));x,y,z=vs[i];vs.append((x,0,z))
for k,(i,j) in enumerate(zip(edge,edge[1:]+edge[:1])):fs.append((i,bottom[k],bottom[(k+1)%len(edge)],j))
fs.append(tuple(reversed(bottom)))
g.add(vs,fs,THATCH,uv=[(x+.5,z) for x,y,z in vs])
# Side/cut UVs give end grain visible on eaves; separate normals still registered at scale.
for fi,face in enumerate(g.f):
 p=[g.v[i] for i in face];normal=(p[1]-p[0]).cross(p[2]-p[0]).normalized()
 if abs(normal.y)<.5:g.uv[fi]=[(v.x+.5 if abs(normal.z)>.5 else v.z,v.y*3) for v in p]
finish(g,[1,.30,1],'Centre of UPPER edge on rafter plane [0,0,0]; X eaves, Y thickness, +Z downslope.',300,grid_step_metres=[1,1],mesh_overlap_metres={'x':.01,'z':.04},roof_pitch_degrees=[50,55],texture_period_metres=[1,1])
g=Mesh('thatch_ridge')
# Rounded ridge section including deep hanging edges; repeat exactly in X.
poly=[(-.45,-.12),(-.44,.02),(-.36,.15),(-.25,.30),(-.12,.42),(0,.45),(.12,.42),(.25,.30),(.36,.15),(.44,.02),(.45,-.12),(.31,-.17),(.20,.14),(0,.25),(-.20,.14),(-.31,-.17)]
vs=[(x,y,z) for x in [-.505,.505] for z,y in poly];n=len(poly)
g.add(vs,[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)],THATCH,uv=[(x+.5,z+.5) for x,y,z in vs])
finish(g,[1,.45,.9],'Centre on ridge line; ridge along X; lower skirts cover panel tops.',300,grid_step_metres=[1],lower_skirt_drop_metres=.17)
# Gothic stone pillory: stepped base, slender shaft, pierced lantern and crocketed spire.
g=Mesh('pillory')
for y,w,h in [(.055,1,.11),(.165,.85,.11),(.28,.68,.12)]:box(g,(0,y,0),(w,h,w),STONE,.012)
lathe(g,[(.26,.34),(.26,.41),(.18,.49),(.15,.57),(.145,2.88),(.18,2.96),(.18,3.03),(.32,3.13),(.35,3.23),(.35,3.31)],(0,0,0),STONE,8)
# Six columns with lancet arches. Cage is really open; no opaque core.
for j in range(6):
 a=j*math.tau/6;r=.29;x=r*math.cos(a);z=r*math.sin(a)
 cyl(g,(x,3.28,z),(x,4.30,z),.035,STONE,6)
 # Each arch is two curved limbs toward bay centre, radial local basis.
 b=(j+1)*math.tau/6;x2=r*math.cos(b);z2=r*math.sin(b)
 mid=Vector(((x+x2)/2,4.62,(z+z2)/2))
 for p in [Vector((x,4.25,z)),Vector((x2,4.25,z2))]:
  sweep(g,[p,p*.68+mid*.32+Vector((0,.10,0)),mid],.035,STONE,5)
 cyl(g,(x,4.29,z),(x,4.65,z),.025,STONE,5,r2=.008)
lathe(g,[(.35,4.60),(.37,4.66),(.34,4.74),(.30,4.78),(.21,5.10),(.115,5.45),(.03,5.78),(0,5.86)],(0,0,0),STONE,8)
for j in range(8):
 a=j*math.tau/8
 for y,r in [(4.82,.31),(5.07,.23),(5.31,.16)]:
  cyl(g,(r*math.cos(a),y,r*math.sin(a)),((r+.05)*math.cos(a),y+.085,(r+.05)*math.sin(a)),.027,STONE,5,r2=.008)
lathe(g,[(.035,5.78),(.035,5.89),(.045,5.91),(.018,5.96),(0,6)],(0,0,0),IRON,6)
for s in [-1,1]:
 ring(g,(s*.16,1.72,.035),.085,.012,IRON,12)
 for k in range(6):ring(g,(s*(.19+.025*math.sin(k)),1.58-k*.082,.05),.047,.010,IRON,8,axis='z' if k%2==0 else 'x')
 ring(g,(s*.22,1.01,.05),.08,.013,IRON,12)
finish(g,[1,6,1],'Ground centre; Y=0 base bearing.',6000,reference='Sheet G stone gothic civic pillory; pierced upper lantern, iron restraint chains.')
# Covered well with large wheel winch, octagonal masonry curb and timber hip roof.
g=Mesh('well_wheel')
for row in range(3):
 for j in range(12):
  a=(j+.5*(row%2))*math.tau/12+.008;b=a+math.tau/12-.016
  vs=[(r*math.cos(t),y,r*math.sin(t)) for y in [row*.23,row*.23+.223] for r,t in [(.62,a),(.87,a),(.87,b),(.62,b)]]
  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)],0)
lathe(g,[(.89,.69),(.89,.80),(.61,.80),(.61,.69)],(0,0,0),STONE,16)
for x in [-.72,.72]:
 for z in [-.72,.72]:
  beam(g,(x,.72,z),(x,2.55,z),.11,.11,OAK,.006)
  for s in [-1,1]:
   if abs(x+s*.35)<.8:beam(g,(x,2.09,z),(x+s*.35,2.52,z),.065,.065,OAK,.004)
for z in [-.72,.72]:beam(g,(-.78,2.49,z),(.78,2.49,z),.15,.14,OAK,.006)
for x in [-.72,.72]:beam(g,(x,2.49,-.78),(x,2.49,.78),.14,.15,OAK,.006)
# Enclosure waist panels and braces, open front for drawing water.
for x in [-.72,.72]:
 for y in [.88,1.16]:box(g,(x,y,0),(.05,.19,1.33),OAK,.003)
 beam(g,(x,.80,-.63),(x,1.29,.64),.05,.055,OAK,.003)
for j in range(6):box(g,(-.6+j*.24,1.02,-.72),(.23,.49,.05),3,.003)
cyl(g,(-1.15,1.48,0),(.82,1.48,0),.08,OAK,12)
# Wheel in YZ plane; oak felloe with continuous iron tyre and mortised spokes.
ring(g,(-1.04,1.48,0),.77,.065,OAK,24,axis='x');ring(g,(-1.04,1.48,0),.824,.018,IRON,24,axis='x')
for j in range(12):
 a=j*math.tau/12;beam(g,(-1.04,1.48,0),(-1.04,1.48+.72*math.sin(a),.72*math.cos(a)),.042,.045,OAK,.003)
cyl(g,(-1.15,1.48,0),(-.95,1.48,0),.14,OAK,12)
cyl(g,(-1.19,1.48+.59,0),(-1.02,1.48+.59,0),.033,OAK,8)
# Rope wound on spindle and descending to bucket; shelf oak used for low-area cord.
for j in range(7):ring(g,(-.15+j*.04,1.48,0),.093,.013,OAK,12,axis='x')
cyl(g,(.05,.39,0),(.05,1.39,0),.014,OAK,6)
lathe(g,[(.14,.0),(.18,.28),(.15,.28),(.12,.025),(0,.025)],(.05,.34,0),OAK,10)
for y in [.38,.57]:ring(g,(.05,y,0),.15,.014,IRON,10,axis='z') if False else None
# Four hip roof facets with overlapped wooden shingle courses, flared eaves.
for side in range(4):
 rot=Matrix.Rotation(side*math.pi/2,3,'Y')
 for row in range(4):
  t0=row/4;t1=min(1,(row+1.16)/4)
  w0=1.18*(1-t0)+.07*t0;w1=1.18*(1-t1)+.07*t1
  z0=w0;z1=w1;y0=2.50+.61*t0+.06*t0*t0;y1=2.50+.61*t1+.06*t1*t1
  for j in range(5):
   u0=-1+2*j/5+.002;u1=-1+2*(j+1)/5-.002
   vs=[rot@Vector((x,y,z)) for dy in [0,-.027] for x,y,z in [(u0*w0,y0+dy,z0),(u1*w0,y0+dy,z0),(u1*w1,y1+dy,z1),(u0*w1,y1+dy,z1)]]
   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)],OAK)
lathe(g,[(.10,3.13),(.10,3.20),(.04,3.25),(.018,3.38),(0,3.4)],(0,0,0),OAK,8)
finish(g,[2.4,3.4,2.4],'Ground centre; +Y vertical; wheel on -X side.',6000,sockets={'winch':[0,1.48,0],'wheel':[-1.04,1.48,0],'well_opening':[0,.80,0]})
# Structural delivery and budgets on evaluated triangles.
entries=[]
for ob in OBJECTS:
 ob.data.update();name=ob.name;tri=len(ob.data.polygons);entry=META[name]
 print('ATOM',name,tri,'/',entry['triangle_budget'],flush=True)
 assert tri<=entry['triangle_budget'],(name,tri,entry['triangle_budget'])
 assert all(p.area>1e-12 for p in ob.data.polygons),(name,'zero area')
 pts=[v.co for v in ob.data.vertices]
 entry.update(triangles=tri,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({ob.data.materials[p.material_index].name for p in ob.data.polygons}))
 entries.append(entry)
assert len(entries)==20
assert sum(e['triangles'] for e in entries)<=45000
bpy.ops.object.select_all(action='DESELECT')
for ob in OBJECTS:ob.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()
kit={'schema':'forge.atom-kit.v2','style':'medieval-town','units':'metres','up':'+Y','facade_outward':'+Z','facade_horizontal':'+X','atoms':entries,'total_triangles':sum(e['triangles'] for e in entries),'triangle_budget':45000,'glb_bytes':len(data),'material_binding':'materials.json','paint_slots':['Door_paint','Sign_paint'],'images_embedded':0,'roof_units':None,'uv_rule':'Metric UV0 divided by shelf physical repeat, no extra repeat at runtime. Thatch UV V downslope, periodic 1m.','provenance':{'geometry':'Original reconstruction from user sheet.jpg using medieval shelf helpers; not extracted source geometry.','textures':'Shared Poly Haven CC0 shelf unchanged; original periodic Thatch_reed height/albedo/normal.'}}
(ROOT/'kit.json').write_text(json.dumps(kit,indent=2));bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'kit.blend'))
(ROOT/'build_evidence.json').write_text(json.dumps({'seconds':time.time()-START,'glb_sha256':hashlib.sha256(data).hexdigest(),'builder_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()},indent=2))
print('EXPORT_COMPLETE',len(entries),kit['total_triangles'],flush=True)
