"""Late medieval French townhouse kit — Blender 5.2, metres, seed 8106.
blender -b --python-exit-code 1 --python build_kit.py [-- --count 6]
Self-contained builder: downloads CC0 scans, caches Cycles swatch bakes, builds
and atomically exports after each house, imports the GLB and renders evidence.
Blender Z-up / front -Y converts to glTF Y-up / front +Z.
"""
import bpy,bmesh,math,random,json,sys,subprocess,time,os
from pathlib import Path
from mathutils import Vector,Matrix
from mathutils.noise import noise
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; BAKES=ROOT/'baked'
TEX.mkdir(exist_ok=True);BAKES.mkdir(exist_ok=True)
COUNT=int(sys.argv[sys.argv.index('--count')+1]) if '--count' in sys.argv else 6
PREFLIGHT='--preflight' in sys.argv
NORENDER='--no-render' in sys.argv
random.seed(8106)
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)
sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.samples=24;sc.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'
 sc.cycles.device='GPU'
except Exception:sc.cycles.device='CPU'
sc.unit_settings.system='METRIC';sc.unit_settings.scale_length=1
sc.view_settings.view_transform='AgX';sc.render.image_settings.file_format='PNG'
# Shared shelf. No house-specific images; vertex colors supply all local weather.
CONFIG=[('Granite','worn_rock_natural_01',(.48,.475,.43),.86,.6,0),
 ('Oak','wooden_planks',(.15,.103,.066),.85,.45,0),
 ('Plaster','worn_plaster_wall',(.72,.665,.53),.9,.7,0),
 ('Tile','roof_tiles_14',(.36,.17,.09),.88,.55,0),
 ('Slate','slate_floor_03',(.14,.19,.235),.78,.5,0),
 ('Rubble','rock_wall_09',(.47,.46,.405),.9,1.0,0),
 ('Iron','metal_plate_02',(.055,.06,.058),.6,.22,.75),
 ('Glass','metal_plate_02',(.048,.073,.079),.24,.015,.25),
 ('Brick','medieval_red_brick',(.37,.19,.115),.9,.55,0)]
for ident in sorted(set(c[1] for c in CONFIG)):
 for kind in ['diff','nor_gl','arm']+(['disp'] if ident=='rock_wall_09' else []):
  p=TEX/f'{ident}_{kind}_1k.jpg'
  if not p.exists():subprocess.run(['curl','-fLsS','--retry','2','-A','Mozilla/5.0',f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{p.name}','-o',str(p)],check=True)
def node(nt,t):return nt.nodes.new(t)
def mathn(nt,op,a,b=0):
 n=node(nt,'ShaderNodeMath');n.operation=op
 for v,i in [(a,0),(b,1)]:
  if hasattr(v,'node'):nt.links.new(v,n.inputs[i])
  else:n.inputs[i].default_value=v
 return n.outputs[0]
def mix(nt,f,a,b,mode='MIX'):
 n=node(nt,'ShaderNodeMixRGB');n.blend_type=mode
 for v,i in [(f,0),(a,1),(b,2)]:
  if hasattr(v,'node'):nt.links.new(v,n.inputs[i])
  else:n.inputs[i].default_value=v
 return n.outputs[0]
def inode(nt,p,uv=None,nc=False):
 n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(str(p),check_existing=True)
 if nc:n.image.colorspace_settings.name='Non-Color'
 if uv is not None:nt.links.new(uv,n.inputs['Vector'])
 return n

def material(cfg):
 name,ident,tint,rough,strength,metal=cfg
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 files={k:BAKES/f'{name}_{k}.jpg' for k in ['BaseColor','Normal','ORM']}
 if PREFLIGHT:files={k:BAKES/f'preflight_{k}.jpg' for k in files}
 if not all(p.exists() for p in files.values()):
  out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
  uv=node(nt,'ShaderNodeTexCoord').outputs['UV'];source_uv=uv
  if name=='Oak':
   xyw=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv,xyw.inputs[0]);cuvw=node(nt,'ShaderNodeCombineXYZ');nt.links.new(xyw.outputs['X'],cuvw.inputs['X']);nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',mathn(nt,'SINE',mathn(nt,'MULTIPLY',xyw.outputs['Y'],math.tau)),.022),.108),cuvw.inputs['Y']);source_uv=cuvw.outputs[0]
  diff=inode(nt,TEX/f'{ident}_diff_1k.jpg',source_uv)
  bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(diff.outputs[0],bw.inputs[0])
  col=mix(nt,1,mathn(nt,'ADD',mathn(nt,'MULTIPLY',bw.outputs[0],1.4),.32),(*tint,1),'MULTIPLY')
  # Periodic 4D weather noise: opposite swatch edges have identical coordinates.
  xy=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv,xy.inputs[0])
  u=mathn(nt,'MULTIPLY',xy.outputs['X'],math.tau);v=mathn(nt,'MULTIPLY',xy.outputs['Y'],math.tau)
  xyz=node(nt,'ShaderNodeCombineXYZ')
  for sock,val in zip(xyz.inputs,[mathn(nt,'COSINE',u),mathn(nt,'SINE',u),mathn(nt,'COSINE',v)]):nt.links.new(val,sock)
  nn=node(nt,'ShaderNodeTexNoise');nn.noise_dimensions='4D';nn.inputs['Scale'].default_value=3.8;nn.inputs['Detail'].default_value=4
  nt.links.new(xyz.outputs[0],nn.inputs['Vector']);nt.links.new(mathn(nt,'SINE',v),nn.inputs['W'])
  col=mix(nt,1,col,mathn(nt,'ADD',mathn(nt,'MULTIPLY',nn.outputs['Fac'],.46),.72),'MULTIPLY')
  if name in ['Slate','Tile']:
   moss=mathn(nt,'MINIMUM',mathn(nt,'MAXIMUM',mathn(nt,'MULTIPLY',mathn(nt,'SUBTRACT',nn.outputs['Fac'],.59),1.9),0),.22)
   col=mix(nt,moss,col,(.13,.16,.052,1))
  if name=='Plaster':
   col=mix(nt,1,mathn(nt,'ADD',mathn(nt,'MULTIPLY',bw.outputs[0],.43),.76),(*tint,1),'MULTIPLY')
  if name=='Rubble':
   hue=node(nt,'ShaderNodeHueSaturation');nt.links.new(diff.outputs[0],hue.inputs['Color']);hue.inputs['Saturation'].default_value=.28
   col=mix(nt,1,mix(nt,1,hue.outputs[0],(1.3,1.32,1.29,1),'MULTIPLY'),(.105,.103,.094,1),'ADD')
   heighttex=inode(nt,TEX/f'{ident}_disp_1k.jpg',source_uv,True)
   limemask=mathn(nt,'MINIMUM',mathn(nt,'MAXIMUM',mathn(nt,'MULTIPLY',mathn(nt,'SUBTRACT',.60,heighttex.outputs[0]),8),0),.92)
   col=mix(nt,limemask,col,mix(nt,nn.outputs['Fac'],(.20,.197,.176,1),(.31,.30,.27,1)))
  arm=inode(nt,TEX/f'{ident}_arm_1k.jpg',source_uv,True);sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],sep.inputs[0])
  roughsock=mathn(nt,'ADD',mathn(nt,'MULTIPLY',sep.outputs['Green'],.20),rough-.14)
  nt.links.new(col,bs.inputs['Base Color']);nt.links.new(roughsock,bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
  normal=inode(nt,TEX/f'{ident}_nor_gl_1k.jpg',source_uv,True);nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=strength;nt.links.new(normal.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
  bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.data.materials.append(m)
  target=node(nt,'ShaderNodeTexImage');sc.cycles.samples=4;sc.render.bake.margin=0
  for kind,path in files.items():
   size=64 if PREFLIGHT else (512 if kind=='ORM' or name in ['Iron','Glass'] else 1024)
   im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
   if kind!='BaseColor':im.colorspace_settings.name='Non-Color'
   target.image=im;nt.nodes.active=target
   if kind=='Normal':nt.links.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
   else:
    em=node(nt,'ShaderNodeEmission')
    if kind=='BaseColor':nt.links.new(col,em.inputs[0])
    else:
     combine=node(nt,'ShaderNodeCombineColor');nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',sep.outputs['Red'],.25),.75),combine.inputs['Red']);nt.links.new(roughsock,combine.inputs['Green']);combine.inputs['Blue'].default_value=metal;nt.links.new(combine.outputs[0],em.inputs[0])
    nt.links.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
   im.filepath_raw=str(path);im.file_format='JPEG';sc.render.image_settings.quality=94;im.save()
  bpy.data.objects.remove(plane,do_unlink=True)
  print('BAKED',name,flush=True)
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
 base=inode(nt,files['BaseColor']);vc=node(nt,'ShaderNodeVertexColor');vc.layer_name='COLOR_0';nt.links.new(mix(nt,1,base.outputs[0],vc.outputs['Color'],'MULTIPLY'),bs.inputs['Base Color'])
 normal=inode(nt,files['Normal'],nc=True);nm=node(nt,'ShaderNodeNormalMap');nt.links.new(normal.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
 orm=inode(nt,files['ORM'],nc=True);sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(orm.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs['Green'],bs.inputs['Roughness']);nt.links.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(nt,'ShaderNodeGroup');gn.node_tree=group;nt.links.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 return m
MATS=[material(c) for c in (CONFIG[:1] if PREFLIGHT else CONFIG)]
if PREFLIGHT:
 bpy.ops.mesh.primitive_cube_add();ob=bpy.context.object;ob.data.materials.append(MATS[0]);ob.data.color_attributes.new(name='COLOR_0',type='BYTE_COLOR',domain='CORNER')
 for a in ob.data.color_attributes['COLOR_0'].data:a.color=(1,1,1,1)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',export_vertex_color='ACTIVE')
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
 bpy.data.node_groups['glTF Material Output'].name='Bake AO settings';bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'));print('PREFLIGHT_OK',flush=True);sys.exit(0)
# Geometry directly aggregated by material, following the Breton builder.
G=None;HOUSE=1;W=8;D=10;EAVE=10.8
TINTS={1:(1,.94,.79),2:(1,.79,.48),3:(.91,.94,.86),4:(.93,.98,1),5:(1,1,.94),6:(1,.91,.70)}
def weather(p,mat,tint):
 x,y,z=p; n=noise(Vector((x*.58+HOUSE*6,y*.63,z*.42)))
 f=.94+.10*n
 if mat in [0,2,5,8]:
  foot=math.exp(-max(0,z)*2.3)*(.30+.2*noise(Vector((x*1.4,y*1.4,1))))
  streak=(.5+.5*math.sin(x*7.9+noise(Vector((x*.8,y*.8,z*.2)))*4))**5
  f*=1-foot-.06*streak
  if abs(x)>W/2-.16:
   rain=math.exp(-((y-.35)/.26)**2)+.65*math.exp(-((y-D+.45)/.32)**2)
   f*=1-.25*min(1,rain)*(max(0,min(1,(EAVE-z)/2)))*math.exp(-max(0,EAVE-z)*.11)
 if mat==2:
  # Local tint variation across panels, contact dirt at beam and eave height.
  tint=tuple(a*b for a,b in zip(tint,TINTS[HOUSE]))
  f*=1-.14*math.exp(-abs((z-3)%2.6-.1)*6)
  if x>0:tint=(tint[0]*.94,tint[1]*.97,tint[2])
 if mat in [3,4]:
  moss=max(0,n-.04)*.85
  tint=(tint[0]*(1-moss*.45),tint[1],tint[2]*(1-moss*.7))
  if HOUSE in [2,5]:tint=(tint[0],tint[1]*.90,tint[2]*.82)
 return tuple(max(.035,min(1,a*f)) for a in tint)+(1,)
class Mesh:
 def __init__(self,name,mat):self.name=name;self.mat=mat;self.v=[];self.f=[];self.uv=[];self.cols=[];self.sm=[]
 def add(self,vs,fs,tint=(1,1,1),uvfun=None,smooth=False):
  off=len(self.v);self.v.extend([tuple(v) for v in vs]);self.cols.extend([weather(v,self.mat,tint) for v in vs])
  for face in fs:
   if len(face)<3:continue
   vv=[Vector(vs[i]) for i in face];n=(vv[1]-vv[0]).cross(vv[2]-vv[0]);axis=max(range(3),key=lambda k:abs(n[k]));ij=[k for k in range(3) if k!=axis]
   self.f.append(tuple(off+i for i in face));self.sm.append(smooth)
   if isinstance(uvfun,tuple):
    inv,origin,longaxis,offset=uvfun;local=[inv@(v-origin) for v in vv];ln=(local[1]-local[0]).cross(local[2]-local[0]);normalaxis=max(range(3),key=lambda k:abs(ln[k]));across=next((k for k in range(3) if k not in [normalaxis,longaxis]),(longaxis+1)%3)
    self.uv.append([(v[longaxis]/2+offset[0],v[across]/2+offset[1]) for v in local])
   else:self.uv.append([uvfun(v) if uvfun else (v[ij[0]]/2,v[ij[1]]/2) for v in vv])
 def finish(self,root):
  me=bpy.data.meshes.new(self.name);me.from_pydata(self.v,[],self.f);me.materials.append(MATS[self.mat]);me.update()
  uv=me.uv_layers.new(name='UVMap');vc=me.color_attributes.new(name='COLOR_0',type='BYTE_COLOR',domain='CORNER')
  for poly,coords,sm in zip(me.polygons,self.uv,self.sm):
   poly.use_smooth=sm
   for li,co in zip(poly.loop_indices,coords):uv.data[li].uv=co;vc.data[li].color=self.cols[me.loops[li].vertex_index]
  ob=bpy.data.objects.new(self.name,me);bpy.context.collection.objects.link(ob);ob.parent=root
  bm=bmesh.new();bm.from_mesh(me);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.calc_area()<1e-11],context='FACES');bm.to_mesh(me);bm.free();me.update()
  return ob

def add(v,f,m,tint=(1,1,1),uv=None,smooth=False):G[m].add(v,f,tint,uv,smooth)
BOX_CACHE={}
def box(c,size,m=1,bev=.015,rot=None,tint=(1,1,1)):
 key=(tuple(round(v,5) for v in size),round(bev,5))
 if key not in BOX_CACHE:
  bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
  for v in bm.verts:v.co=Vector(tuple(v.co[i]*size[i] for i in range(3)))
  if bev:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(bev,min(size)*.22),segments=1,affect='EDGES')
  bm.verts.ensure_lookup_table();bm.verts.index_update();BOX_CACHE[key]=([v.co.copy() for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces]);bm.free()
 vs,fs=BOX_CACHE[key];c=Vector(c);vs=[(rot@v if rot else v)+c for v in vs]
 uv=None
 if m==1:
  factor=.88+.18*(.5+.5*math.sin(c.x*14+c.y*7+c.z*11));tint=tuple(a*factor for a in tint)
  uv=((rot.inverted() if rot else Matrix.Identity(3)),c,max(range(3),key=lambda k:size[k]),(math.sin(c.x*13+c.y*8)*4,math.cos(c.z*6)*4))
 add(vs,fs,m,tint,uv)
def beam(a,b,w=.16,d=None,m=1,tint=(1,1,1)):
 a=Vector(a);b=Vector(b);box((a+b)/2,(w,d or w,(b-a).length),m,(0 if w<.03 else min(.018,w*.11)),(b-a).to_track_quat('Z','Y').to_matrix(),tint)
def cyl(a,b,r,m=6,n=8,r2=None,tint=(1,1,1)):
 a=Vector(a);b=Vector(b);q=(b-a).to_track_quat('Z','Y');r2=r if r2 is None else r2
 vs=[a+q@Vector((r*math.cos(i*math.tau/n),r*math.sin(i*math.tau/n),0)) for i in range(n)]+[b+q@Vector((r2*math.cos(i*math.tau/n),r2*math.sin(i*math.tau/n),0)) 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)];add(vs,fs,m,tint,smooth=True)
def tube(ps,r,m=6,n=6,tint=(1,1,1)):
 for a,b in zip(ps,ps[1:]):cyl(a,b,r,m,n,tint=tint)
def profile(poly,depth,mp,m,tint=(1,1,1)):
 vs=[mp(u,d,z) for d in depth for u,z 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)];add(vs,fs,m,tint)
def lbox(mp,u,d,z,w,t,h,m=1,bev=.015,tint=(1,1,1)):
 c=Vector(mp(u,d,z));ex=Vector(mp(u+1,d,z))-c;ey=Vector(mp(u,d+1,z))-c;rot=Matrix((ex,ey,Vector((0,0,1)))).transposed();box(c,(w,t,h),m,bev,rot,tint)
def lbeam(mp,u0,z0,u1,z1,d=.055,w=.16,depth=.17,m=1,tint=(1,1,1)):
 beam(mp(u0,d,z0),mp(u1,d,z1),w,depth,m,tint)
# A facade maps u across, d outwards, z up. Every side outer plane remains exact.
def front_at(j=0):return lambda u,d,z:(u,-j-d,z)
def back_at():return lambda u,d,z:(-u,D+d,z)
def side_at(s):return lambda u,d,z:(s*(W/2+d),D/2+s*u,z)
# Registered masonry relief (same scan and same two-metre UV as the material).
disp=bpy.data.images.load(str(TEX/'rock_wall_09_disp_1k.jpg'));disp.colorspace_settings.name='Non-Color';DW,DH=disp.size
DISP=np.asarray(disp.pixels[:],dtype=np.float32).reshape(DH,DW,4)[:,:,0]
def relief(u,z):return float(DISP[int(z/2%1*(DH-1)),int(u/2%1*(DW-1))])
def intervals(a,b,z,holes):
 parts=[(a,b)]
 for x0,x1,h0,h1 in holes:
  if h0<z<h1:
   out=[]
   for s,e in parts:
    if e<=x0 or s>=x1:out.append((s,e))
    else:
     if s<x0:out.append((s,x0))
     if e>x1:out.append((x1,e))
   parts=out
 return parts

def wall(mp,width,z0,z1,holes,m=2,step=.40,exact=False,gable=0,tint=(1,1,1)):
 # Partition at aperture heights, then grid for relief and spatial COLOR_0.
 levels=sorted(set([z0,z1]+[h for a,b,c,d in holes for h in [c,d] if z0<h<z1]))
 for lo,hi in zip(levels,levels[1:]):
  rows=max(1,math.ceil((hi-lo)/step))
  for j in range(rows):
   za=lo+(hi-lo)*j/rows;zb=lo+(hi-lo)*(j+1)/rows
   wa=width/2*(1-(za-z0)/(z1-z0)) if gable else width/2;wb=width/2*(1-(zb-z0)/(z1-z0)) if gable else width/2
   for a,b in intervals(-wa,wa,(za+zb)/2,holes):
    count=max(1,math.ceil((b-a)/step))
    for k in range(count):
     ua=a+(b-a)*k/count;ub=a+(b-a)*(k+1)/count;ta=max(-wb,min(wb,ua));tb=max(-wb,min(wb,ub))
     coords=[(ua,za),(ub,za),(tb,zb),(ta,zb)]
     if abs(ta-tb)<1e-7:coords=coords[:3]
     vs=[]
     for u,z in coords:
      r=(relief(u,z)*.060 if m==5 else .006*noise(Vector((u*3,z*3,HOUSE))))
      d=(-.062+r if m==5 else -.008+r*.45) if exact else r
      vs.append(mp(u,d,z))
     if len(vs)>=3 and (Vector(vs[1])-Vector(vs[0])).cross(Vector(vs[2])-Vector(vs[0])).length>1e-8:
      base=Vector(mp(0,0,0));ex=Vector(mp(1,0,0))-base
      add(vs,[tuple(range(len(vs)))],m,tint,lambda p:((p-base).dot(ex)/2,p.z/2),True)
 # Aperture returns and dark plane at exactly .4 m behind the facade.
 for a,b,c,d in holes:
  for u in [a,b]:profile([(u,c),(u+.022,c),(u+.022,d),(u,d)],(-.4,0),mp,m)
  for z in [c,d]:lbox(mp,(a+b)/2,-.20,z,b-a,.40,.025,m,.004)
  lbox(mp,(a+b)/2,-.415,(c+d)/2,b-a+.04,.025,d-c+.04,7,0,(.09,.095,.10))

def window(mp,u,z,w=1.12,h=1.46,stone=False,shutters=False,rich=False):
 mat=0 if stone else 1
 lbox(mp,u,-.29,z+h/2,w,.035,h,7,.005,tint=(.65,.72,.73))
 for ss in [-1,1]:lbox(mp,u+ss*(w/2+.065),.025,z+h/2,.13,.20,h+.24,mat,.017)
 for zz in [z-.085,z+h+.075]:lbox(mp,u,.04,zz,w+.32,.27,.14,mat,.017)
 lbox(mp,u,.13,z-.17,w+.45,.44,.12,mat,.014)
 lbox(mp,u,-.06,z+h*.51,.065,.10,h,mat,.006)
 lbox(mp,u,-.065,z+h*.60,w,.105,.060,mat,.006)
 # Diamond lead cames, clipped to each rectangular aperture.
 for direction in [-1,1]:
  for k in range(-12,13):
   b=k*.235
   pts=[]
   for xx in [-w/2+.04,w/2-.04]:
    zz=direction*xx+b
    if .03<=zz<=h-.03:pts.append((xx,zz))
   for zz in [.03,h-.03]:
    xx=(zz-b)/direction
    if -w/2+.04<xx<w/2-.04:pts.append((xx,zz))
   if len(pts)>=2:beam(mp(u+pts[0][0],-.20,z+pts[0][1]),mp(u+pts[1][0],-.20,z+pts[1][1]),.012,.012,6)
 if shutters:
  for ss in [-1,1]:
   su=u+ss*(w*.75+.21)
   for k in range(3):lbox(mp,su+(k-1)*w/6,.09,z+h/2,w/6-.012,.09,h,1,.007,tint=(1,.90,.74))
   for zz in [z+.22,z+h-.22]:lbox(mp,su,.15,zz,w/2,.055,.08,1,.008);lbox(mp,su,.185,zz,w*.38,.025,.025,6,.003)
 if rich:
  lbox(mp,u,.10,z-.39,w+.23,.17,.33,1,.016)
  for k in [-1,0,1]:
   cu=u+k*w*.28;r=.13
   tube([mp(cu+r*math.sin(t*math.tau/12),.21,z-.39+r*math.cos(t*math.tau/12)) for t in range(13)],.018,1,5,tint=(1,.9,.76))
   lbeam(mp,cu-r,z-.52,cu+r,z-.26,.205,.025,.035)

def door(mp,u,w=1.20,h=2.35,base=.12,studded=False):
 for k in range(max(6,round(w/.17))):
  n=max(6,round(w/.17));lbox(mp,u-w/2+(k+.5)*w/n,-.20,base+h/2,w/n-.008,.10,h,1,.008,tint=(1,.91,.78))
 for ss in [-1,1]:lbox(mp,u+ss*(w/2+.09),.01,base+h/2,.18,.25,h+.16,0 if HOUSE in [1,3,4] else 1,.02)
 lbox(mp,u,.015,base+h+.10,w+.42,.28,.19,0 if HOUSE in [1,3,4] else 1,.022)
 for zz in [base+.35,base+h-.38]:
  lbox(mp,u,-.125,zz,w*.88,.04,.055,6,.007)
  if studded:
   for x in range(7):cyl(mp(u-w*.39+x*w*.13,-.10,zz),mp(u-w*.39+x*w*.13,-.075,zz),.022,6,6)
 if studded:
  for x in [-.36,0,.36]:
   for zz in [.65,1.03,1.42,1.83]:cyl(mp(u+x*w,-.125,base+zz),mp(u+x*w,-.095,base+zz),.021,6,6)
 tube([mp(u+w*.28+.065*math.sin(t*math.tau/12),-.06,base+1.05+.085*math.cos(t*math.tau/12)) for t in range(13)],.019,6,6)
 for k in range(2):lbox(mp,u,.24+k*.22,.055+(1-k)*.065,w+.42+k*.18,.50+k*.30,.11+(1-k)*.13,0,.025)

def arch(mp,u,w,spring,base=.14,pointed=False,shop=False):
 r=w/2;rise=r*(1.30 if pointed else 1)
 # Contour of a rounded or Gothic arch. Independent voussoirs share exact endpoints.
 def top(x):
  t=abs(x)/r
  return spring+rise*((1-t)**.68 if pointed else math.sqrt(max(0,1-t*t)))
 samples=24
 for ss in [-1,1]:
  for j in range(max(3,round((spring-base)/.38))):
   n=max(3,round((spring-base)/.38));lbox(mp,u+ss*(r+.15),.025,base+(j+.5)*(spring-base)/n,.30,.34,(spring-base)/n-.013,0,.02)
 for j in range(samples):
  a=-r+2*r*j/samples+.007;b=-r+2*r*(j+1)/samples-.007;za=top(a);zb=top(b)
  # ring rises vertically at crown; near the spring it broadens horizontally.
  oa=a*(1+.27/r);ob=b*(1+.27/r)
  profile([(u+a,za),(u+b,zb),(u+ob,zb+.27),(u+oa,za+.27)],(-.15,.19),mp,0,(random.uniform(.79,1),)*3)
 # Fill the spandrels between rectangular wall cut and arch in matching masonry.
 for j in range(samples):
  a=-r+2*r*j/samples;b=-r+2*r*(j+1)/samples
  profile([(u+a,top(a)),(u+b,top(b)),(u+b,spring+rise+.02),(u+a,spring+rise+.02)],(-.18,-.005),mp,0)
 if not shop or HOUSE==1:
  n=round(w/.17)
  for j in range(n):
   a=-r+w*j/n+.005;b=-r+w*(j+1)/n-.005
   profile([(u+a,base),(u+b,base),(u+b,top(b)-.03),(u+a,top(a)-.03)],(-.26,-.16),mp,1,(1,.9,.77))
  for zz in [base+.38,spring-.25]:
   lbox(mp,u,-.105,zz,w*.93,.045,.065,6,.008)
   for j in range(9):cyl(mp(u-w*.43+j*w*.1075,-.078,zz),mp(u-w*.43+j*w*.1075,-.052,zz),.021,6,6)
  for ss in [-1,1]:tube([mp(u+ss*.18+.064*math.sin(t*math.tau/12),-.085,1.05+.09*math.cos(t*math.tau/12)) for t in range(13)],.018,6,6)
 else:
  for j in range(round(w/.20)):lbox(mp,u-w/2+(j+.5)*w/round(w/.20),-.24,.66,w/round(w/.20)-.008,.10,.92,1,.01)
  lbox(mp,u,.14,.98,w+.20,.55,.16,0,.02)
 return spring+rise

def corbel(mp,u,z,projection=.30,stone=False):
 # Carved concave console: top bears the jetty, lower heel keys into the wall.
 poly=[(0,z-.61),(.12,z-.61),(.15,z-.44),(.22,z-.32),(projection+.10,z-.20),(projection+.13,z-.055),(0,z-.055)]
 # Extrude sideways, using local depth as profile horizontal coordinate.
 profile(poly,(-.115,.115),lambda d,side,zz:mp(u+side,d,zz),0 if stone else 1)
 for zz,depth in [(z-.14,projection+.06),(z-.25,.21)]:lbox(mp,u,depth/2,zz,.27,depth,.065,0 if stone else 1,.01)

def framing(mp,width,z0,z1,bays,rich=False):
 for z,h in [(z0+.08,.22),(z0+.29,.075),(z1-.10,.20)]:lbox(mp,0,.055,z,width-.03,.24,h,1,.018)
 xs=[-width/2+.14+i*(width-.28)/bays for i in range(bays+1)]
 for i,x in enumerate(xs):lbeam(mp,x,z0+.18,x+(.025*math.sin(i*7+HOUSE) if i not in [0,bays] else 0),z1-.16,.055,.19,.20)
 for i in range(bays):
  a,b=xs[i]+.09,xs[i+1]-.09
  if i%2==0:lbeam(mp,a,z0+.35,a+(b-a)*.35,z0+.65,.062,.135,.15)
  else:lbeam(mp,b,z0+.35,b-(b-a)*.35,z0+.65,.062,.135,.15)
  if rich:
   for s in [-1,1]:lbeam(mp,(a+b)/2,z0+.32,(a+b)/2+s*(b-a)*.28,z0+.62,.083,.07,.085)
 for x in xs:corbel(mp,x,z0,.24)
 return [(a+b)/2 for a,b in zip(xs,xs[1:])]

def quoins(height):
 for sx in [-1,1]:
  for yy in [0,D]:
   for i in range(math.ceil(height/.43)):
    h=min(.416,height-i*.43)
    if h<=0:continue
    a,b=(.46,.25) if i%2 else (.28,.49)
    box((sx*(W/2-a/2),yy+(b/2 if yy==0 else -b/2),i*.43+h/2),(a,b,h),0,.022)

def foundation():
 box((0,D/2,-.69),(W,D,1.62),0,.018)
 # Low plinth course, exact width; soil-contact aging continues below grade.
 for yy in [0,D]:
  for i in range(math.ceil(W/.67)):
   n=math.ceil(W/.67);box((-W/2+(i+.5)*W/n,yy+(.09 if yy==0 else -.09),.13),(W/n-.008,.18,.26),0,.019)
# Roofs use individual clipped plates, not tiled boxes. Slightly concave courses
# follow a settled ridge; every dormer removes the underlying roof region.
def roof_plate(vs,m,tint):
 # Four sided plate, closed solid, 12 triangles; subtly bent upper face.
 lower=[Vector(p)-Vector((0,0,.033)) for p in vs];add(lower+list(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)],m,tint)
def roof(gable,eave,rise,frontjet=0,mat=3,dormers=None,worn=False):
 # Canonical roof: u along ridge, v across slope; transform for street orientation.
 dormers=dormers or []
 span=(W if gable else D+frontjet)/2+.18;length=(D+frontjet if gable else W)+.28
 center=(D-frontjet)/2
 def mp(u,v,z):return (v,center+u,z) if gable else (u,center+v,z)
 def sag(u):return -.12*math.cos(math.pi*u/length)**2
 def zz(u,v):return eave+rise*(1-abs(v)/span)+sag(u)+.032*math.sin(u*1.9+HOUSE)*(abs(v)/span)**3
 # Dormer definition: ridge position u; slope sign s; one or two small gabled lucarnes.
 dw=1.32;df=span*.70;dh=1.65;de=eave+rise*(1-df/span)+1.14;dr=.82
 def hole(u,v,z):
  for du,ss in dormers:
   side=abs(u-du)
   if side<dw/2+.07 and ss*v<df+.12 and ss*v>0:
    dz=de+dr*(1-side/(dw/2+.10))
    if zz(u,v)<dz-.045:return True
  return False
 step=.255 if mat==3 else .285;tw=.37 if mat==3 else .40
 slen=math.hypot(span,rise);rows=math.ceil(slen/step)
 for s in [-1,1]:
  for j in range(rows):
   t=j/rows;v=s*span*(1-t);vnext=s*span*(1-(j+1)/rows)
   # Roof boarding strips behind plates, clipped around dormer openings.
   n=math.ceil(length/tw)
   cuts=[-length/2]+[-length/2+(i+(j%2)*.5)*tw for i in range(1,n+1) if -length/2+(i+(j%2)*.5)*tw<length/2]+[length/2]
   for a,b in zip(cuts,cuts[1:]):
    u=(a+b)/2;vc=(v+vnext)/2
    if hole(u,vc,zz(u,vc)):continue
    va=v;vb=vnext-s*.052
    tintv=random.uniform(.77,1.0);tint=(tintv,tintv*random.uniform(.95,1),tintv*random.uniform(.91,1))
    # Local moss follows low courses and leeward damp strips.
    moss=max(0,noise(Vector((u*.73,vc*.6,HOUSE+3)))-.04)*(1.8 if j<rows*.35 else .52)
    tint=(tint[0]*(1-.48*moss),min(1.0,tint[1]*(1+.18*moss)),tint[2]*(1-.80*moss))
    missing=worn and s==-1 and ((j==7 and 2<a<2.6) or (j==12 and -2.5<a<-1.9))
    if missing:
     for k in range(2):beam(mp(a,va,zz(a,va)-.10-k*.03),mp(b,va,zz(b,va)-.10-k*.03),.045,.06,1)
     continue
    lift=random.uniform(-.009,.009)
    chip=random.uniform(.0,.026);coords=[(a+.005,va-s*chip),(b-.005,va+s*random.uniform(-.018,.018)),(b-.005,vb),(a+.005,vb)]
    vs=[Vector(mp(uu,vv,zz(uu,vv)+.045+lift-(.012 if k>=2 else 0))) for k,(uu,vv) in enumerate(coords)]
    roof_plate(vs,mat,tint)
  # Eave fascia, slightly irregular segmented run, and each exposed rafter tail.
  for j in range(math.ceil(length/.70)):
   a=-length/2+j*length/math.ceil(length/.70);b=-length/2+(j+1)*length/math.ceil(length/.70)
   beam(mp(a,s*span,zz(a,s*span)-.055),mp(b,s*span,zz(b,s*span)-.055),.13,.15,1)
   beam(mp((a+b)/2,s*(span-.30),zz((a+b)/2,s*(span-.30))-.12),mp((a+b)/2,s*(span+.025),zz((a+b)/2,s*span)-.12),.085,.12,1)
 # Curved ridge caps with crown profile and closed underside, visible joints.
 for j in range(math.ceil(length/.32)):
  a=-length/2+j*length/math.ceil(length/.32);b=-length/2+(j+1)*length/math.ceil(length/.32)-.008
  verts=[]
  for u in [a,b]:
   for i in range(7):
    th=math.pi*i/6;verts.append(mp(u,.16*math.cos(th),eave+rise+sag(u)-.15+.26*math.sin(th)))
  faces=[tuple(range(6,-1,-1)),tuple(range(7,14))]+[(i,i+1,i+8,i+7) for i in range(6)]+[(0,7,13,6)]
  add(verts,faces,mat,(.8,.81,.79))
 for u in [-length/2,length/2]:
  for s in [-1,1]:beam(mp(u,s*span,zz(u,s*span)-.005),mp(u,0,zz(u,0)+.08),.12,.16,1)
 # Gables close roof ends. Merchant uses rich front infill, others plain party walls.
 for end in [-1,1]:
  u=end*(length/2-.14)
  if gable:
   jm=frontjet if end==-1 else 0
   gm=(lambda v,d,z:(v,-jm-d,z)) if end==-1 else (lambda v,d,z:(-v,D+d,z))
   width=W
  else:
   gm=lambda v,d,z,end=end:(end*(W/2+d),center+end*v,z)
   width=D+frontjet
  attic=[(-.35,.35,eave+.48,eave+1.51)] if gable and end==-1 and HOUSE in [1,4,6] else []
  wall(gm,width,eave,eave+rise-.04,attic,5 if HOUSE==4 else 2,.34,exact=not gable,gable=1)
  lbeam(gm,-width/2+.1,eave+.05,0,eave+rise-.10,.025,.16,.19)
  lbeam(gm,0,eave+rise-.10,width/2-.1,eave+.05,.025,.16,.19)
  if attic:
   lbeam(gm,0,eave,0,eave+.36,.02,.18,.20);lbeam(gm,0,eave+1.67,0,eave+rise-.10,.02,.18,.20)
  else:lbeam(gm,0,eave,0,eave+rise-.10,.02,.18,.20)
  if gable and end==-1:
   for x in [-width*.26,width*.26]:lbeam(gm,x,eave,x,eave+rise*(1-abs(x)/(width/2))-.12,.02,.15,.18)
   if HOUSE==1:
    # Gothic ogee-like braces assembled as continuous sweeps.
    for ss in [-1,1]:
     points=[gm(ss*(width*.40)*(1-t)**1.38,.24,eave+.25+(rise-.42)*t) for t in [i/16 for i in range(17)]]
     tube(points,.10,1,8)
     for k in range(3):lbeam(gm,ss*(.6+k*.5),eave+rise-1.15-k*.60,ss*(1.0+k*.54),eave+rise-1.0-k*.60,.16,.043,.055)
   if HOUSE in [1,4,6]:window(gm,0,eave+.48,.70,1.03,stone=HOUSE==4)
 for du,s in dormers:
  for ss in [-1,1]:
   u=du+ss*(dw/2+.06);vback=max(.12,span*(1-(de-eave)/rise))
   vs=[Vector(mp(u-ss*.11,s*(df+.02),zz(u,s*(df+.02))+.027)),Vector(mp(u+ss*.16,s*(df+.02),zz(u,s*(df+.02))+.027)),Vector(mp(u+ss*.16,s*vback,zz(u,s*vback)+.026)),Vector(mp(u-ss*.11,s*vback,zz(u,s*vback)+.026))];roof_plate(vs,6,(.8,.8,.8))
  # Front normal is outward along v. Dormer cheek wedges terminate in main roof.
  dm=lambda x,d,z,du=du,s=s:mp(du+x,s*(df+d),z)
  for side in [-1,1]:
   u=du+side*dw/2;vback=s*max(.15,span*(1-(de-eave)/rise))
   vs=[mp(u-.055,s*df,zz(u,s*df)-.02),mp(u+.055,s*df,zz(u,s*df)-.02),mp(u-.055,s*df,de),mp(u+.055,s*df,de),mp(u-.055,vback,de),mp(u+.055,vback,de)]
   add(vs,[(0,1,3,2),(2,3,5,4),(0,4,5,1),(0,2,4),(1,5,3)],2)
  bottom=de-1.45
  for side in [-1,1]:lbox(dm,side*.60,0,bottom+.71,.18,.20,1.43,1,.016)
  lbox(dm,0,.02,de-.03,1.42,.26,.18,1,.017)
  profile([(-.70,de),(.70,de),(0,de+dr)],(-.12,.05),dm,2)
  wall(dm,1.30,bottom,de,[(-.42,.42,bottom+.12,bottom+1.27)],2,.30)
  window(dm,0,bottom+.12,.84,1.15)
  for side in [-1,1]:lbeam(dm,side*.79,de-.03,0,de+dr+.04,.10,.095,.14)
  # Dormer roof slopes intersect and end at the main roof, including decking.
  for side in [-1,1]:
   for row in range(5):
    xa=side*.79*(1-row/5);xb=side*.79*(1-(row+1)/5)
    for j in range(math.ceil(df/.30)):
     va=df+.16-j*.30;vb=max(0,va-.31)
     z0=de+dr*(1-abs(xa)/.79);z1=de+dr*(1-abs(xb)/.79)
     if (z0+z1)/2<zz(du+(xa+xb)/2,s*(va+vb)/2):continue
     vs=[Vector(mp(du+xa,s*va,z0+.06)),Vector(mp(du+xa,s*vb,z0+.06)),Vector(mp(du+xb,s*vb,z1+.04)),Vector(mp(du+xb,s*va,z1+.04))]
     roof_plate(vs,mat,(.87,.88,.84))
  beam(dm(0,.18,de+dr+.06),mp(du,s*max(0,span*(1-(de+dr-eave)/rise)),de+dr+.06),.10,.12,mat)
 return eave+rise

def chimney(x,y,z,stone=False,tall=2.0):
 m=0 if stone else 8;w=.66;d=.80
 box((x,y,z+tall/2),(w,d,tall),m,.016,tint=(.8,.84,.77))
 # Foot and apron sit across roof slope rather than perched on a tile.
 box((x,y,z+.08),(.84,.98,.16),6,.016)
 for k in range(math.ceil(tall/.24)):
  zz=z+k*.24
  for sy in [-1,1]:
   for j in range(2):box((x+(j-.5)*.33,y+sy*.40,zz+.11),(.315,.04,.216),m,.012,tint=(random.uniform(.75,1),.86,.78))
 for dz,wid in [(tall-.12,.84),(tall,.94)]:box((x,y,z+dz),(wid,1.06,.13),m,.019,tint=(.62,.69,.58))
 for xx in [-.18,.18]:
  n=12;vs=[]
  for zz,r in [(z+tall+.065,.12),(z+tall+.43,.108),(z+tall+.43,.078),(z+tall+.07,.08)]:vs.extend([(x+xx+r*math.cos(i*math.tau/n),y+r*math.sin(i*math.tau/n),zz) for i in range(n)])
  fs=[(j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i) for j in range(3) for i in range(n)]+[tuple(range(3*n,4*n))];add(vs,fs,3,(.61,.64,.58))

def finial(mp,z):
 for zz,r0,r1,h in [(z,.13,.095,.27),(z+.27,.095,.17,.14),(z+.41,.17,.05,.33),(z+.74,.07,0,.44)]:cyl(mp(0,0,zz),mp(0,0,zz+h),r0,1,8,r1)

def rear(storeys,stone=False):
 mp=back_at();height=3+(storeys-1)*2.6
 holes=[(-.62,.62,.14,2.40)]
 for floor in range(storeys):
  z=1.0 if floor==0 else 3+(floor-1)*2.6+.75
  for u in [-W*.27,W*.27]:holes.append((u-.45,u+.45,z,z+1.20))
 wall(mp,W,.12,height,holes,5 if stone else 2,.30 if stone else .45)
 door(mp,0,1.24,2.26)
 for a,b,c,d in holes[1:]:window(mp,(a+b)/2,c,b-a,d-c,stone,shutters=not stone)
 if not stone:
  for floor in range(1,storeys):
   z=3+(floor-1)*2.6
   for u in [-W/2+.12,0,W/2-.12]:lbeam(mp,u,z,u,z+2.6,.02,.17,.17)
   lbox(mp,0,.02,z,W,.19,.19,1)
   for s in [-1,1]:lbeam(mp,s*.20,z+.20,s*(W/2-.24),z+.58,.02,.14,.17)

def jetty_returns(storeys,step):
 for floor in range(1,storeys):
  z=3+(floor-1)*2.6;j=floor*step
  for side in [-1,1]:
   mp=lambda u,d,h,side=side,j=j:(side*(W/2+d),-j/2+side*u,h)
   wall(mp,j,z,z+2.6,[],2,.18,exact=True)
   box((side*(W/2-.08),-j/2,z+.08),(.16,j,.19),1,.012)
  box((0,-j/2,z+.035),(W-.08,j,.07),1,.008)

def ashlar(mp,width,z0,z1,holes):
 for row in range(math.ceil((z1-z0)/.38)):
  lo=z0+row*.38;hi=min(z1,lo+.38)
  for a,b in intervals(-width/2,width/2,(lo+hi)/2,[(a-.10,b+.10,c-.04,d+.04) for a,b,c,d in holes]):
   n=max(1,round((b-a)/.58))
   for k in range(n):
    aa=a+(b-a)*k/n;bb=a+(b-a)*(k+1)/n
    lbox(mp,(aa+bb)/2,.018,(lo+hi)/2,bb-aa-.014,.07,hi-lo-.017,0,.012,(random.uniform(.78,1),)*3)

def party_skin(mp,width,height):
 # Continuous plaster-loss boundary, clipped through triangles rather than stair-step cells.
 def field(u,z):
  edge=width/2-abs(u);n=noise(Vector((u*1.45,HOUSE*.71,z*1.16)))
  base=.74+.47*noise(Vector((u*.71,HOUSE,.3)))+.20*noise(Vector((u*2.7,HOUSE,1)))
  return min(z-base,edge-(.42+.19*math.sin(z*1.7)+.23*n))
 def clip(poly,limit,positive):
  out=[]
  for a,b in zip(poly,poly[1:]+poly[:1]):
   va=field(*a)-limit;vb=field(*b)-limit;ina=va>=0 if positive else va<=0;inb=vb>=0 if positive else vb<=0
   if ina:out.append(a)
   if ina!=inb:
    t=va/(va-vb);out.append((a[0]+t*(b[0]-a[0]),a[1]+t*(b[1]-a[1])))
  return out
 nu=math.ceil(width/.31);nz=math.ceil((height-.12)/.31)
 for i in range(nu):
  a=-width/2+width*i/nu;b=-width/2+width*(i+1)/nu
  for j in range(nz):
   c=.12+(height-.12)*j/nz;d=.12+(height-.12)*(j+1)/nz
   for tri in [[(a,c),(b,c),(b,d)],[(a,c),(b,d),(a,d)]]:
    for mat,lim,pos in [(5,.08,False),(2,0,True)]:
     poly=clip(tri,lim,pos)
     if len(poly)<3:continue
     vs=[]
     for u,z in poly:
      dep=-.062+relief(u,z)*.060 if mat==5 else -.006+.003*noise(Vector((u*2,z*2,HOUSE)))
      vs.append(mp(u,dep,z))
     base=Vector(mp(0,0,0));ex=Vector(mp(1,0,0))-base
     add(vs,[tuple(range(len(vs)))],mat,(1,1,1),lambda p:((p-base).dot(ex)/2,p.z/2),True)

def parties(storeys,stone=False):
 height=3+(storeys-1)*2.6
 for side in [-1,1]:
  mp=side_at(side)
  if stone:wall(mp,D,.12,height,[],5,.27,exact=True,tint=(.93,.95,.91))
  else:party_skin(mp,D,height)

ROOTS=[];META={};EXPORT_OBS=[]
NAMES={1:'House_merchant',2:'House_shop',3:'House_tavern',4:'House_tower',5:'House_joiner',6:'House_bakery'}
SIZES={1:(8,10,4),2:(6,9,3),3:(10,10,3),4:(5,8,4),5:(7,9,2),6:(6,9,2)}
def begin_house(num):
 global G,HOUSE,W,D,EAVE
 HOUSE=num;W,D,storeys=SIZES[num];EAVE=3+2.6*(storeys-1);random.seed(8106+num)
 G={i:Mesh(NAMES[num]+'_'+c[0],i) for i,c in enumerate(CONFIG)};foundation()
 return storeys

def merchant():
 begin_house(1);mp=front_at()
 # Ground-floor stone portal and wide arched trading window.
 holes=[(-3.25,-1.75,.14,2.77),(-.55,3.25,.15,2.77)]
 wall(mp,W,.12,3,holes,0,.34);ashlar(mp,W,.12,3,holes)
 arch(mp,-2.5,1.5,1.78,pointed=True);arch(mp,1.35,3.8,.87,shop=True)
 lbox(mp,1.35,.14,.70,4.0,.48,.17,0,.018)
 for k in range(3):lbox(mp,-2.5,.23+k*.22,.07+(2-k)*.07,1.87+k*.10,.48+k*.29,.14+(2-k)*.14,0,.025)
 for floor in range(3):
  z=3+floor*2.6;j=(floor+1)*.20;fm=front_at(j)
  xs=[-2.51,0,2.51];holes=[(x-.66,x+.66,z+.85,z+2.19) for x in xs]
  wall(fm,W,z,z+2.6,holes,2,.36)
  framing(fm,W,z,z+2.6,3,True)
  for x in xs:window(fm,x,z+.85,1.32,1.34,rich=True)
  for x in [-3.84,-1.31,1.31,3.84]:corbel(front_at(j-.20),x,z,.36)
  # Slender upper frieze and pegged joints.
  for x in [-3.82,-1.30,1.30,3.82]:
   for zz in [z+.36,z+2.32]:cyl(fm(x,.168,zz),fm(x,.185,zz),.030,1,8,tint=(.55,.53,.48))
 jetty_returns(4,.20);parties(4);rear(4);quoins(3)
 ridge=roof(True,EAVE,4.45,.60,4,[(-2.3,1),(2.4,1)])
 finial(front_at(.70),ridge+.06)
 # Rich chamfered stone imposts and decorative capitals at ground piers.
 for x in [-3.80,-1.17,3.80]:
  for z,w,h in [(2.62,.43,.12),(2.80,.55,.10),(2.91,.64,.12)]:lbox(mp,x,.10,z,w,.43,h,0,.018)
 return -2.5

def shop():
 begin_house(2);mp=front_at();holes=[(-2.65,-1.55,.13,2.45),(-1.15,2.62,.73,2.48)]
 wall(mp,W,.13,3,holes,2,.36);door(mp,-2.10,1.10,2.32)
 for x in [-2.87,-1.36,2.87]:lbeam(mp,x,.13,x,2.88,.06,.22,.28)
 lbox(mp,0,.06,2.86,W,.28,.27,1)
 # Open shop: lowered plank shutter, upper shutter lifted under the beam.
 for j in range(14):lbox(mp,-1.12+(j+.5)*3.72/14,.66,.89,3.72/14-.008,1.28,.10,1,.009,tint=(1,.94,.80))
 for x in [-.85,2.30]:beam(mp(x,.05,.36),mp(x,1.22,.84),.085,.09,1)
 for j in range(14):
  box(mp(-1.12+(j+.5)*3.72/14,.43,2.50),(3.72/14-.008,1.0,.09),1,.008,Matrix.Rotation(math.radians(-12),3,'X'))
 for x in [-.9,2.35]:beam(mp(x,.05,1.88),mp(x,.86,2.38),.035,.035,6)
 for zz in [1.08,1.54]:lbox(mp,.73,-.32,zz,3.45,.18,.08,1,.008)
 for j in range(8):cyl(mp(-.7+j*.4,-.22,.99),mp(-.7+j*.4,-.22,1.12),.09,3,10,r2=.055,tint=(.9,.82,.65))
 for floor in range(2):
  z=3+floor*2.6;fm=front_at((floor+1)*.19);xs=[-.72,.78];holes=[(x-.39,x+.39,z+.8,z+2.1) for x in xs]
  wall(fm,W,z,z+2.6,holes,2,.35);framing(fm,W,z,z+2.6,4)
  for x in xs:window(fm,x,z+.8,.78,1.30)
  for s in [-1,1]:
   lbeam(fm,s*2.73,z+.25,s*1.64,z+2.32,.075,.16,.18)
   lbeam(fm,s*1.64,z+.25,s*2.73,z+2.32,.079,.16,.18)
 jetty_returns(3,.19 if HOUSE==2 else .18);parties(3);rear(3);quoins(3)
 roof(False,EAVE,3.55,.38,3,[(0,-1)])
 chimney(1.86,D/2+.25,EAVE+2.91,False,1.75)
 return -2.10

def tavern():
 begin_house(3);mp=front_at();holes=[(-4.45,-1.15,.13,2.78),(-.55,.65,.14,2.48),(2.0,3.78,1.15,2.43)]
 wall(mp,W,.12,3,holes,5,.25);arch(mp,-2.80,3.30,1.10);door(mp,.05,1.20,2.34,studded=True);window(mp,2.89,1.15,1.78,1.28,True)
 for floor in range(2):
  z=3+2.6*floor;fm=front_at(.18*(floor+1));xs=[-3.75,-1.25,1.25,3.75];holes=[(x-.62,x+.62,z+.87,z+2.19) for x in xs]
  wall(fm,W,z,z+2.6,holes,2,.38);framing(fm,W,z,z+2.6,4)
  for x in xs:window(fm,x,z+.87,1.24,1.32)
  for i in range(4):
   a=-4.84+i*2.5;b=a+2.25
   lbeam(fm,a,z+.30,(a+b)/2,z+.69,.069,.13,.17)
   lbeam(fm,b,z+.30,(a+b)/2,z+.69,.069,.13,.17)
 jetty_returns(3,.19 if HOUSE==2 else .18);parties(3);rear(3);quoins(3);roof(False,EAVE,3.10,.36,3)
 chimney(3.62,D/2+.45,EAVE+2.50,False,2.05)
 # Bench carries through to the wall, with paired trestles and worn edges.
 for j in range(3):lbox(mp,2.55,.60+j*.16,.54,3.05,.15,.10,1,.012)
 for x in [1.35,3.75]:
  for d in [.55,.96]:lbeam(mp,x,.07,x,.50,d,.12,.14)
  lbox(mp,x,.73,.21,.14,.55,.12,1)
 for zz in [.85,1.06]:lbox(mp,2.55,.19,zz,3.1,.11,.16,1)
 for x in [1.34,3.76]:lbeam(mp,x,.13,x,1.14,.20,.09,.12)
 sign_and_lantern(mp)
 for x,y in [(4.37,.62),(4.37,1.23)]:barrel(mp,x,y,.10,.32,.82)
 return .05

def barrel(mp,u,d,z,r=.32,h=.80):
 n=14;vs=[]
 for zz,rr in [(z,r*.85),(z+.15*h,r*.95),(z+.5*h,r),(z+.85*h,r*.95),(z+h,r*.85)]:vs.extend([mp(u+rr*math.cos(i*math.tau/n),d+rr*math.sin(i*math.tau/n),zz) for i in range(n)])
 fs=[tuple(range(n-1,-1,-1)),tuple(range(4*n,5*n))]+[(j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i) for j in range(4) for i in range(n)];add(vs,fs,1,(.9,.84,.68))
 for f,rr in [(.15,r*.96),(.80,r*.98)]:tube([mp(u+rr*math.cos(i*math.tau/n),d+rr*math.sin(i*math.tau/n),z+f*h) for i in range(n+1)],.026,6,6)

def sign_and_lantern(mp):
 # Large forged bracket extends normal to street, with scrolls in the d/z plane.
 u=1.65
 for a,b in [((.08,3.28),(.08,5.15)),((.06,4.93),(1.68,4.93)),((.06,3.68),(1.60,4.93))]:beam(mp(u,a[0],a[1]),mp(u,b[0],b[1]),.075,.075,6)
 for dc,zc,rr in [(.60,4.57,.26),(1.12,4.72,.18),(.24,4.04,.19)]:tube([mp(u,dc+rr*(1-i/40)*math.cos(i*math.pi/14),zc+rr*(1-i/40)*math.sin(i*math.pi/14)) for i in range(31)],.022,6,6)
 for dd in [.77,1.48]:cyl(mp(u,dd,4.92),mp(u,dd,4.43),.020,6,6)
 # Shield lies in side plane, with colored crown/thistle pictogram, no words.
 sm=lambda x,d,z:mp(u+d,1.13+x,z)
 profile([(-.52,4.43),(.52,4.43),(.48,3.48),(0,3.22),(-.48,3.48)],(-.055,.055),sm,1)
 profile([(-.45,4.36),(.45,4.36),(.41,3.52),(0,3.30),(-.41,3.52)],(.06,.07),sm,2,(.30,.25,.12))
 # Painted emblems made from shallow colored geometry using shared plaster shelf.
 for side in [-1,1]:
  dd=side*.074
  profile([(-.29,4.12),(-.33,4.30),(-.15,4.22),(0,4.36),(.15,4.22),(.33,4.30),(.29,4.12)],(dd,dd+side*.005),sm,2,(1,.76,.24))
  tube([sm(.22*math.sin(i*math.tau/16),dd,3.73+.25*math.cos(i*math.tau/16)) for i in range(17)],.065,2,6,(.22,.42,.11))
 # Lantern at the main entrance, visible attached hook and cage.
 u=.98;cyl(mp(u,.02,2.62),mp(u,.39,2.62),.025,6,8)
 tube([mp(u,.39+.09*math.sin(i*math.pi/12),2.54+.09*math.cos(i*math.pi/12)) for i in range(13)],.018,6)
 lbox(mp,u,.39,2.23,.23,.22,.38,7,.007,tint=(1,.75,.32))
 for ss in [-1,1]:
  for dd in [.28,.50]:beam(mp(u+ss*.12,dd,2.04),mp(u+ss*.12,dd,2.44),.020,.02,6)
 for zz in [2.03,2.44]:lbox(mp,u,.39,zz,.29,.28,.045,6,.008)
 cyl(mp(u,.39,2.45),mp(u,.39,2.57),.20,6,4,r2=.055)

def joiner():
 begin_house(5);mp=front_at();holes=[(-2.98,.35,.14,2.63),(1.04,2.74,.14,2.63)]
 wall(mp,W,.12,3,holes,2,.36)
 # Posts mortised into stone shoes support workshop lintel.
 for x in [-3.32,.64,3.30]:
  lbox(mp,x,.015,.23,.32,.34,.46,0,.018);lbeam(mp,x,.46,x,2.88,.025,.21,.28)
 lbox(mp,0,.035,2.81,W,.32,.32,1,.022)
 # Upward-hinged plank shutter canopy, supported by iron stays.
 for a,b in [(-2.98,.35),(1.04,2.74)]:
  n=math.ceil((b-a)/.24)
  for j in range(n):box(mp(a+(j+.5)*(b-a)/n,.57,2.64),((b-a)/n-.009,1.15,.10),1,.01,Matrix.Rotation(math.radians(-10),3,'X'),(1,.94,.79))
  for x in [a+.18,b-.18]:beam(mp(x,.03,1.91),mp(x,1.08,2.53),.029,.033,6)
 # Dark room planes already close the apertures at .4m. Interior bench is in front of backing.
 lbox(mp,-1.31,-.16,.95,2.73,.40,.13,1,.018,tint=(1,.91,.70))
 for x in [-2.37,-.25]:lbeam(mp,x,.13,x,.89,-.15,.12,.15)
 for z in [1.53,2.04]:lbox(mp,-1.32,-.31,z,2.58,.12,.10,1,.009)
 for x in [-2.3,-1.75,-1.12,-.52]:
  lbeam(mp,x,1.54,x+.06,1.96,-.22,.045,.06)
  lbox(mp,x+.06,-.20,1.90,.23,.075,.10,0,.008)
 # Leaning stock boards stay within the facade's frontage and project <1.8 m.
 for i in range(5):
  x=2.92+(i%2)*.16
  beam(mp(x,.83+i*.09,.03),mp(x-.15,.18,1.48+i*.13),.13,.055,1,(1,.97,.84))
 for u,w in [(-1.30,3.25),(1.90,1.66)]:
  for k in range(2):lbox(mp,u,.35+k*.33,.055+(1-k)*.08,w+.1,.56+k*.38,.11+(1-k)*.16,0,.024)
 z=3;fm=front_at(.21);xs=[-1.78,1.64];holes=[(x-.53,x+.53,z+.85,z+2.13) for x in xs]
 wall(fm,W,z,z+2.6,holes,2,.36);framing(fm,W,z,z+2.6,4)
 for x in xs:window(fm,x,z+.85,1.06,1.28,shutters=True)
 for ss in [-1,1]:lbeam(fm,ss*3.31,z+.25,ss*2.36,z+2.36,.06,.17,.19)
 jetty_returns(2,.21);parties(2);rear(2);quoins(3);roof(False,EAVE,3.10,.21,3,[(.10,-1)],True)
 return 1.90

def tower():
 begin_house(4);mp=front_at();holes=[(-1.76,-.54,.14,2.52),(.45,1.40,1.03,2.33)]
 for z in [3.75,6.43,9.03]:
  for x in [-1.13,1.13]:holes.append((x-.46,x+.46,z,z+1.33))
 wall(mp,W,.12,EAVE,holes,5,.21);parties(4,True);rear(4,True);quoins(EAVE)
 door(mp,-1.15,1.22,2.38,studded=True);window(mp,.925,1.03,.95,1.30,True)
 for a,b,c,d in holes[2:]:window(mp,(a+b)/2,c,b-a,d-c,True,shutters=True)
 # Second-floor oriel: cantilevered stone box with three mullioned lights.
 # Vertical support consoles curve into the main stone wall.
 om=front_at(.89)
 for x in [-.91,0,.91]:corbel(mp,x,5.99,.83,True)
 lbox(mp,0,.45,6.04,2.52,.95,.20,0,.023)
 lbox(mp,0,.47,6.38,2.30,.89,.52,0,.015)
 for x in [-.80,0,.80]:
  lbox(om,x,.025,6.38,.64,.065,.37,0,.014,tint=(.81,.84,.77))
  tube([om(x+.22*math.sin(t*math.tau/12),.075,6.38+.15*math.cos(t*math.tau/12)) for t in range(13)],.022,0,6)
 lbox(mp,0,.47,6.72,2.50,1.01,.14,0,.024)
 for x in [-.81,0,.81]:window(om,x,6.88,.59,1.54,True)
 for x in [-1.22,1.22]:lbox(mp,x,.47,7.60,.16,.95,1.94,0,.018)
 lbox(mp,0,.49,8.58,2.66,1.10,.18,0,.022)
 # Sloped oriel cap in visible slate courses.
 for j in range(5):
  d=.99-j*.20;z=8.70+j*.13
  for i in range(8):
   x=-1.32+i*.33;roof_plate([Vector(mp(x,d,z)),Vector(mp(x+.324,d,z)),Vector(mp(x+.324,d-.25,z+.15)),Vector(mp(x,d-.25,z+.15))],4,(.85,.90,.85))
 ridge=roof(True,EAVE,3.53,0,4)
 # Tall external chimney keyed into party wall, entirely within exact side plane.
 x=1.96;y=3.85
 box((x,y,7.2),(.72,1.06,13.2),5,.018)
 for k in range(30):box((x+.015,y-.005,.61+k*.43),(.77,1.12,.414),0,.018,tint=(.79,.86,.75))
 chimney(x,y,13.21,True,2.0)
 return -1.15

def bakery():
 begin_house(6);mp=front_at();holes=[(.74,2.51,.83,2.23),(-.35,.60,.14,2.36)]
 wall(mp,W,.12,3,holes,2,.34);door(mp,.125,.95,2.22)
 lbox(mp,1.62,.39,.93,2.02,.90,.13,1,.014)
 for x in [.83,2.42]:beam(mp(x,.025,.38),mp(x,.77,.87),.065,.08,1)
 for i in range(8):box(mp(.77+(i+.5)*1.71/8,.32,2.29),(1.71/8-.007,.68,.065),1,.008,Matrix.Rotation(math.radians(-13),3,'X'))
 # Round bread oven built as semi-circular masonry from facade to 1.65m projection.
 u=-1.71;r=1.04;cy=.47;n=24
 # Revolved volume rear portion intersects facade; front does not exceed 1.8m.
 rings=[(.05,r),(.80,r),(.96,r*1.02),(1.08,r*.97),(1.25,r*.94),(1.58,r*.88),(1.88,r*.69),(2.08,r*.40)]
 for (za,ra),(zb,rb) in zip(rings,rings[1:]):
  vs=[mp(u+rr*math.cos(i*math.tau/n),cy+rr*math.sin(i*math.tau/n),zz) for zz,rr in [(za,ra),(zb,rb)] for i in range(n)]
  fs=[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
  add(vs,fs,5 if za<1.08 else 2,(.80,.80,.66),smooth=True)
 # Oven mouth is dark and inset, with actual arched stone lip.
 ovenmp=lambda x,d,z:mp(u+x,cy+r*.91+d,z)
 arch(ovenmp,0,.74,1.27,base=1.01,shop=True)
 # Overlay black mouth within the stone arch, no opening through the house.
 profile([(-.35,1.01),(.35,1.01),(.35,1.27)]+[(.35*math.cos(t*math.pi/12),1.27+.35*math.sin(t*math.pi/12)) for t in range(1,13)],(-.09,-.08),ovenmp,7,(.05,.045,.04))
 lbox(ovenmp,0,.02,1.01,.94,.37,.10,0,.018)
 # Conical little roof, individual annular wedge tiles in overlapping courses.
 for j in range(8):
  ra=1.16*(1-j/9);rb=1.16*(1-(j+1)/9);za=2.04+j*.135;zb=za+.16;nn=max(9,round(ra*math.tau/.23))
  for i in range(nn):
   a=(i+(j%2)*.5)*math.tau/nn;b=(i+1+(j%2)*.5)*math.tau/nn-.009
   vs=[Vector(mp(u+rr*math.cos(t),cy+rr*math.sin(t),zz)) for rr,zz,t in [(ra,za,a),(ra,za,b),(rb,zb,b),(rb,zb,a)]];roof_plate(vs,3,(.81,.82,.67))
 cyl(mp(u,cy,3.07),mp(u,cy,3.27),.15,0,8,r2=.06)
 z=3;fm=front_at(.16);xs=[-1.62,1.46];holes=[(x-.42,x+.42,z+.82,z+2.12) for x in xs]
 wall(fm,W,z,z+2.6,holes,2,.34);framing(fm,W,z,z+2.6,3)
 for x in xs:window(fm,x,z+.82,.84,1.30)
 for ss in [-1,1]:lbeam(fm,ss*2.83,z+.25,ss*1.92,z+2.36,.06,.15,.18)
 jetty_returns(2,.16);parties(2);rear(2);quoins(3);roof(True,EAVE,3.23,.16,3)
 chimney(-1.96,1.94,EAVE+1.53,False,2.95)
 # Bakers' rounds on hatch shelf, baskets represented with shared oak surface.
 for x in [1.13,1.56,1.98]:cyl(mp(x,.47,1.02),mp(x,.47,1.14),.15,2,12,r2=.12,tint=(.85,.59,.28))
 return .125

def finish_house(num,doorx,slot):
 root=bpy.data.objects.new(NAMES[num],None);bpy.context.collection.objects.link(root);root.location=(14*slot,0,0);root.empty_display_type='PLAIN_AXES'
 obs=[]
 for m,geo in G.items():
  if geo.f:obs.append(geo.finish(root))
 for name,pos in [('Entrance',(doorx,-.8,0)),('Door',(doorx,0,0))]:
  ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.parent=root;ob.location=pos;ob['socket_name']=name;obs.append(ob)
 bpy.context.view_layer.update()
 pts=[Vector(v.co) for o in obs if o.type=='MESH' for v in o.data.vertices]
 def yup(p):return (p.x,p.z,-p.y)
 ps=[yup(p) for p in pts];tris=sum(len(o.data.polygons) for o in obs if o.type=='MESH')
 META[root.name]={'house':num,'width':W,'depth':D,'storeys':SIZES[num][2], 'triangles':tris,'bounding_box':{'min':[round(min(p[i] for p in ps),5) for i in range(3)],'max':[round(max(p[i] for p in ps),5) for i in range(3)]},'root_translation':[14*slot,0,0], 'Entrance':[doorx,0,.8],'Door':[doorx,0,0]}
 ROOTS.append(root);EXPORT_OBS.extend([root]+obs)
 print('HOUSE_COMPLETE',num,tris,flush=True)

def export_kit():
 bpy.ops.object.select_all(action='DESELECT')
 for o in EXPORT_OBS:o.select_set(True)
 tmp=ROOT/'medieval-hero-houses.candidate.glb'
 bpy.ops.export_scene.gltf(filepath=str(tmp),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='JPEG',export_jpeg_quality=90,export_texcoords=True,export_normals=True,export_vertex_color='ACTIVE',export_materials='EXPORT',export_cameras=False,export_lights=False)
 # glTF node names need not be globally unique. Blender does: normalize socket names in JSON chunk.
 import struct
 data=tmp.read_bytes();jlen,jtyp=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jlen]);tail=data[20+jlen:]
 for nd in doc['nodes']:
  if nd.get('name','').split('.')[0] in ['Entrance','Door']:nd['name']=nd['name'].split('.')[0]
 # COLOR_0 is authored as bytes; Blender exports it as ushort. Repack to core glTF
 # normalized UBYTE (no compression extension or decoder dependency required).
 oldbin=tail[8:];replacements={}
 for mesh in doc['meshes']:
  for primitive in mesh['primitives']:
   ai=primitive['attributes'].get('COLOR_0')
   if ai is None:continue
   acc=doc['accessors'][ai]
   if acc['componentType']!=5123:continue
   vi=acc['bufferView'];view=doc['bufferViews'][vi];off=view.get('byteOffset',0)+acc.get('byteOffset',0)
   ar=np.frombuffer(oldbin,dtype='<u2',count=acc['count']*4,offset=off)
   replacements[vi]=np.rint(ar.astype(np.float32)/257).astype(np.uint8).tobytes();acc['componentType']=5121;acc['byteOffset']=0;acc['normalized']=True
 newbin=bytearray()
 for vi,view in enumerate(doc['bufferViews']):
  chunk=replacements.get(vi,oldbin[view.get('byteOffset',0):view.get('byteOffset',0)+view['byteLength']])
  newbin.extend(b'\0'*((-len(newbin))%4));view['byteOffset']=len(newbin);view['byteLength']=len(chunk)
  if vi in replacements:view.pop('byteStride',None)
  newbin.extend(chunk)
 newbin.extend(b'\0'*((-len(newbin))%4));doc['buffers'][0]['byteLength']=len(newbin);tail=struct.pack('<II',len(newbin),0x004e4942)+newbin

 jb=json.dumps(doc,separators=(',',':')).encode();jb+=b' '*((-len(jb))%4);out=struct.pack('<4sII',b'glTF',2,20+len(jb)+len(tail))+struct.pack('<II',len(jb),0x4e4f534a)+jb+tail
 assert len(out)<=25000000,('GLB too large',len(out))
 assert sum(d['triangles'] for d in META.values())<=350000
 tmp.write_bytes(out);tmp.replace(ROOT/'medieval-hero-houses.glb')
 (ROOT/'nodes.json').write_text(json.dumps(META,indent=2))
 print('EXPORTED',len(ROOTS),'houses',sum(d['triangles'] for d in META.values()),'triangles',len(out),'bytes',flush=True)
 assert len(out)<=25000000,('GLB too large',len(out))
 assert sum(d['triangles'] for d in META.values())<=350000
 notes=[f'Late-medieval French kit: {len(ROOTS)} houses; exact frontage/depth, metre units, +Y up / +Z front; origins at front-wall centre; 1.5 m foundations.',f'{sum(d["triangles"] for d in META.values()):,} triangles; {len(out):,} GLB bytes; {len(CONFIG)} shared PBR materials; COLOR_0 multiplies baked base colour.', 'Poly Haven CC0 texture ids: '+', '.join(sorted(set(c[1] for c in CONFIG)))+'; https://polyhaven.com/license; sources dl.polyhaven.org, jpg/1k.', 'Cycles shared tileable swatch bakes: base/normal <=1024, packed ORM 512; registered 2 m UVs; periodic patina nodes; spatial vertex grime, moss, beam contact shading and plaster/tile tints.', 'Build: Blender 5.2 -b --python-exit-code 1 --python build_kit.py; -- --count N for incremental order 1,2,3,5,4,6; scans/bakes cached beside script; every completed house exports atomically.', 'Deliverable GLB is reimported for Cycles hero/clay/lineup renders; dark recessed rooms are closed; rear facades authored; structural audit and visual review recorded separately.']
 (ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')

def studio():
 # Reimport delivered glTF: validation images show portable exported materials.
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
 if bpy.data.node_groups.get('glTF Material Output'):bpy.data.node_groups['glTF Material Output'].name='Baked AO source settings'
 bpy.ops.import_scene.gltf(filepath=str(ROOT/'medieval-hero-houses.glb'))
 sc.world.use_nodes=True;bg=sc.world.node_tree.nodes.get('Background');bg.inputs[0].default_value=(.72,.78,.86,1);bg.inputs[1].default_value=.55
 bpy.ops.object.light_add(type='SUN',location=(0,0,20));sun=bpy.context.object;sun.rotation_euler=(math.radians(25),math.radians(-25),math.radians(-30));sun.data.energy=1.8;sun.data.angle=math.radians(18)
 bpy.ops.object.light_add(type='AREA',location=(-15,-20,24));light=bpy.context.object;light.data.energy=3000;light.data.shape='DISK';light.data.size=16;light.rotation_euler=(Vector((20,3,5))-light.location).to_track_quat('-Z','Y').to_euler()
 bpy.ops.mesh.primitive_plane_add(size=240,location=(30,0,-.025));floor=bpy.context.object;floor.name='STUDIO_GROUND';mat=bpy.data.materials.new('STUDIO_GROUND');mat.diffuse_color=(.30,.325,.33,1);floor.data.materials.append(mat)
 bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO'
 sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=40
 def camera(pos,target,scale):cam.location=pos;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
 def render(name):sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
 roots=[bpy.data.objects[name] for name in META]
 # Individual hero frames. Root children retain their house-local frame.
 for r in roots:
  for other in roots:
   for o in other.children_recursive:o.hide_render=other!=r
  dat=META[r.name];cx=r.location.x;h=dat['bounding_box']['max'][1]
  camera((cx+18,-25,h*.72),(cx,D*.32,h*.47),max(h*1.95,23.0));render(f'final_{dat["house"]}.png')
  if '--checks' in sys.argv:
   camera((cx-17,24,h*.64),(cx,4,h*.44),max(h*1.95,23));render(f'rear_{dat["house"]}.png')
 for r in roots:
  for o in r.children_recursive:o.hide_render=False
 # Street render closes the file's placement gaps; root local geometry unchanged.
 x=0
 for r in roots:
  w=META[r.name]['width'];r.location.x=x+w/2;x+=w
 cx=x/2
 camera((cx+15,-57,26),(cx,2,6.9),max(x*1.09,26));render('lineup.png')
 clay=bpy.data.materials.new('STUDIO_CLAY');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.48,.48,.48,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.82
 sc.view_layers[0].material_override=clay;render('clay_lineup.png');sc.view_layers[0].material_override=None
 print('RENDERS_COMPLETE',flush=True)

if __name__=='__main__':
 funcs={1:merchant,2:shop,3:tavern,4:tower,5:joiner,6:bakery}
 for slot,num in enumerate([1,2,3,5,4,6][:COUNT]):
  doorx=funcs[num]();finish_house(num,doorx,slot);export_kit()
 if not NORENDER:studio()
 print('BUILD_COMPLETE',flush=True)
