"""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',(.32,.295,.25),.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),
 ('WindowGlass','metal_plate_02',(.18,.26,.32),.19,.008,.36)]
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 name in ['Oak','WindowGlass']:files={k:BAKES/f'r1_{name}_{k}.jpg' for k in files}
 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*12)),.022),.108),cuvw.inputs['Y']);source_uv=cuvw.outputs[0]
  if name=='Tile':
   # Extract clay grain inside a real scanned tile; geometry owns the joints.
   # Periodic lookup avoids baking an unrelated second tile grid onto the roof.
   st=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv,st.inputs[0]);cv=node(nt,'ShaderNodeCombineXYZ')
   for axis,centre,amplitude in [('X',.560,.014),('Y',.778,.018)]:
    wave=mathn(nt,'SINE',mathn(nt,'MULTIPLY',st.outputs[axis],math.tau))
    nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',wave,amplitude),centre),cv.inputs[axis])
   source_uv=cv.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=='Oak':
   col=mix(nt,1,mathn(nt,'ADD',mathn(nt,'MULTIPLY',bw.outputs[0],3.2),.05),(*tint,1),'MULTIPLY')
  if name=='Plaster':
   col=mix(nt,1,mathn(nt,'ADD',mathn(nt,'MULTIPLY',bw.outputs[0],.66),.63),(*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=.075 if name=='Tile' else 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','WindowGlass'] 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'])
 if name=='WindowGlass':
  bs.inputs['Emission Color'].default_value=(.13,.063,.019,1);bs.inputs['Emission Strength'].default_value=.16
 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)}
# Causal weather sources are collected while constructing each house. Applied
# after geometry is complete so a wall can receive drips from later-built sills.
RAIN=[];SOOT=[]
def weather(p,mat,tint):
 x,y,z=p;n=noise(Vector((x*.65+HOUSE*6,y*.63,z*.45)))
 f=.96+.10*n;tint=list(tint)
 if mat in [0,2,5,8]:
  if mat==2:tint=[a*b for a,b in zip(tint,TINTS[HOUSE])]
  # Capillary damp: broken top edge, confined to the first .8 metre.
  edge=.70+.15*noise(Vector((x*2,y*2,HOUSE)))
  damp=max(0,min(1,(edge-z)/.66)) if z>=0 else .65
  shade=.85 if x>0 else .58
  tint=[tint[0]*(1-damp*.32),tint[1]*(1-damp*.35),tint[2]*(1-damp*.36)]
  f*=1-damp*.32
  # Long narrow runoff columns beneath actual projections, tapering with fall.
  rain=0
  for sx,sy,sz,spread,power in RAIN:
   drop=sz-z
   if -.04<drop<2.8 and abs(y-sy)<.32:
    drift=.045*math.sin(z*2.8+sx*5)
    rain=max(rain,power*math.exp(-((x-sx-drift)/max(.34,spread))**2)*math.exp(-max(0,drop)*.42))
  f*=1-min(.76,rain*1.18)
  if abs(x)>W/2-.16:
   streak=(.5+.5*math.sin(y*5.1+.18*math.sin(z*.7)+HOUSE))**4
   edgeheight=EAVE+({2:3.55,3:3.10,5:3.10}.get(HOUSE,0))*(1-abs(y-D/2)/(D/2))
   f*=1-.40*streak*math.exp(-abs(edgeheight-z)*.23)
   tint[0]*=1-damp*.065*shade;tint[2]*=1-damp*.08*shade
  if mat==2:
   band=min(abs(z-(3+i*2.6)) for i in range(5))
   f*=1-.32*math.exp(-band*3.8)
   f*=.87+.39*noise(Vector((x*1.4,y*1.4,z*1.15)))
 if mat==1:
  # Rain silvering on exposed faces; rich brown survives beneath floor overhangs.
  under=min(abs(z-(3+i*2.6-.14)) for i in range(4))
  sheltered=.42*math.exp(-under*7)
  f*=.94-sheltered
  tint=[tint[0]*(.92-sheltered*.19),tint[1]*(.96-sheltered*.23),tint[2]*(1-sheltered*.27)]
 if mat in [3,4]:
  moss=max(0,n+.07)*.24
  tint=[tint[0]*(1-moss*.40),tint[1],tint[2]*(1-moss*.68)]
  if HOUSE in [2,5]:tint=[tint[0],tint[1]*.91,tint[2]*.86]
 if mat in [0,2,3,5,8]:
  for sx,sy,sz,rad in SOOT:
   distance=((x-sx)/rad)**2+((y-sy)/rad)**2
   plume=math.exp(-distance)*math.exp(-abs(z-sz)*1.25)
   f*=1-.91*plume
 return tuple(max(.025,min(1,a*f)) for a in tint)+(1,)

def settled(p):
 x,y,z=p
 if z>2.65:
  fac=min(1,(z-2.65)/.45)*math.exp(-max(0,y)*1.3)
  z-=.095*math.cos(math.pi*x/W)**2*fac
 return (x,y,z)
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([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([settled(v) for v in 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=weather(self.v[me.loops[li].vertex_index],self.mat,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)
 if w<.015:
  along=(b-a).normalized();out=Vector((0,-1,0)) if abs(a.y-b.y)<.05 else Vector((1,0,0))
  side=along.cross(out).normalized()*w/2
  vs=[a-side,a+out*w*.4,a+side,b-side,b+out*w*.4,b+side]
  add(vs,[(0,3,4,1),(1,4,5,2)],m,tint);return
 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)):
 # Continuous sweep: no internal caps or visible joints between curve segments.
 ps=[Vector(p) for p in ps];closed=(ps[0]-ps[-1]).length<1e-6
 if closed:ps=ps[:-1]
 vs=[];count=len(ps)
 for i,p in enumerate(ps):
  tangent=ps[(i+1)%count]-ps[(i-1)%count] if closed or 0<i<count-1 else (ps[1]-ps[0] if i==0 else ps[-1]-ps[-2])
  q=tangent.to_track_quat('Z','Y')
  vs.extend([p+q@Vector((r*math.cos(k*math.tau/n),r*math.sin(k*math.tau/n),0)) for k in range(n)])
 fs=[(i*n+k,i*n+(k+1)%n,((i+1)%count)*n+(k+1)%n,((i+1)%count)*n+k) for i in range(count if closed else count-1) for k in range(n)]
 if not closed:fs.extend([tuple(range(n-1,-1,-1)),tuple(range((count-1)*n,count*n))])
 add(vs,fs,m,tint,smooth=True)
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

# Two street losses and one on each party wall per plastered house. These
# cut the plaster mesh; a separate recessed masonry surface and broken returns
# close each edge. No coplanar damage decals.
SCARS={1:[(-.99,4.45,.20,.51),(3.53,9.42,.22,.49)],
 2:[(-2.32,4.79,.30,.31),(2.30,6.14,.31,.30)],
 3:[(-2.13,4.54,.22,.52),(3.02,7.01,.22,.42)],
 5:[(.65,4.47,.40,.64),(-2.33,3.48,.36,.21)],
 6:[(.62,4.21,.30,.55),(-.75,3.56,.33,.28)]}
def scar_field(u,z):
 val=9
 for x,h,rx,rz in SCARS.get(HOUSE,[]):
  q=math.hypot((u-x)/rx,(z-h)/rz)-1
  ang=math.atan2((z-h)/rz,(u-x)/rx)
  q+=.18*math.sin(ang*3+HOUSE)+.11*math.sin(ang*7)+.16*noise(Vector((u*13,z*16,HOUSE)))
  val=min(val,q)
 return val

def scar_face(mp,coords,tint):
 def clipped(poly,positive):
  out=[]
  for a,b in zip(poly,poly[1:]+poly[:1]):
   va=scar_field(*a);vb=scar_field(*b);ia=va>=0 if positive else va<=0;ib=vb>=0 if positive else vb<=0
   if ia:out.append(a)
   if ia!=ib:
    t=va/(va-vb);out.append((a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t))
  return out
 def depth(u,z):return .021*noise(Vector((u*1.7,z*1.3,HOUSE)))+.009*noise(Vector((u*4,z*4,HOUSE)))
 a,b,c,d=coords
 for i in range(3):
  for j in range(3):
   def uv(t,r):return (a[0]+(b[0]-a[0])*t,a[1]+(d[1]-a[1])*r)
   aa=uv(i/3,j/3);bb=uv((i+1)/3,j/3);cc=uv((i+1)/3,(j+1)/3);dd=uv(i/3,(j+1)/3)
   for tri in [[aa,bb,cc],[aa,cc,dd]]:
    for positive,mat in [(True,2),(False,8 if HOUSE==2 else 5)]:
     poly=clipped(tri,positive)
     if len(poly)<3:continue
     vs=[mp(u,depth(u,z) if positive else -.055+.018*relief(u,z),z) for u,z in poly]
     add(vs,[tuple(range(len(vs)))],mat,tint if positive else (.86,.87,.80),lambda p:(p.x/2,p.z/2),True)
     if positive:
      for aa,bb in zip(poly,poly[1:]+poly[:1]):
       if abs(scar_field(*aa))<.11 and abs(scar_field(*bb))<.11:
        u,z=aa;v,h=bb
        add([mp(u,depth(u,z),z),mp(v,depth(v,h),h),mp(v,-.053,h),mp(u,-.053,z)],[(0,1,2,3)],2,(.73,.70,.60))

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.
 frontscar=m==2 and abs(mp(1,0,0)[0]-mp(0,0,0)[0]-1)<.01 and mp(0,0,0)[1]<=0 and not gable
 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]
     if frontscar and len(coords)==4 and any(ua<x+rx+.15 and ub>x-rx-.15 and za<h+rz+.12 and zb>h-rz-.12 for x,h,rx,rz in SCARS.get(HOUSE,[])):
      scar_face(mp,coords,tint);continue
     vs=[]
     for u,z in coords:
      r=(relief(u,z)*.060 if m==5 else .021*noise(Vector((u*1.7,z*1.3,HOUSE)))+.009*noise(Vector((u*4,z*4,HOUSE))))
      d=(-.062+r if m==5 else -.008+r*(.18 if gable else .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
 for sx in [-1,1]:
  q=mp(u+sx*(w*.5+.13),0,z-.18);RAIN.append((*q,.22,.76))
 # Small panes hold alternating cool sky and faint amber room colour.
 for ix in range(2):
  for iz in range(3):
   warm=(math.sin(u*17+z*13+ix*7+HOUSE)>-.25)
   pale=(.88,.94,1) if iz>0 or not warm else (1,.88,.69)
   lbox(mp,u+(ix-.5)*w/2,-.29,z+(iz+.5)*h/3,w/2-.004,.03,h/3-.004,9,0,tint=pale)
 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*(.37 if mp(0,0,0)[1]>D-.1 else .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]:
   hinge=u+ss*(w/2+.15)
   ang=math.radians(28+((int(abs(u*31)+z*17)+HOUSE*7+(ss+1)*19)%48))
   sh=lambda a,d,zz,ss=ss,hinge=hinge,ang=ang:mp(hinge+ss*(a*math.cos(ang)-d*math.sin(ang)),.09+a*math.sin(ang)+d*math.cos(ang),zz)
   for k in range(3):lbox(sh,(k+.5)*w/6,0,z+h/2,w/6-.012,.075,h,1,.007,tint=(.88,.92,.91))
   for zz in [z+.22,z+h-.22]:
    lbox(sh,w/4,.065,zz,w/2,.04,.065,1,.006);lbox(sh,w/4,.09,zz,w*.37,.02,.025,6,.002)
   for zz in [z+.20,z+h-.20]:cyl(mp(hinge,.09,zz-.08),mp(hinge,.09,zz+.08),.026,6,6)
 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,oven=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=10 if oven else 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 oven:return spring+rise
 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 i in range(1,round(width/.48)):
  RAIN.append((*mp(-width/2+i*.48,0,z1-.20),.16,.48))
 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 -.29*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-.24 and ss*v<df-.06 and ss*v>0:
    dz=de+dr*(1-side/(dw/2+.10))
    if zz(u,v)<dz-.20:return True
  return False
 step=.255 if mat==3 else .31;tw=.37 if mat==3 else .44
 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=min(.52,max(0,noise(Vector((u*.73,vc*.6,HOUSE+3)))+.23)*(2.15 if j<rows*.24 else .48))
    for du,ss in dormers:
     if s==ss:moss=max(moss,.52*math.exp(-((abs(u-du)-dw/2)/.32)**2)*math.exp(-abs(abs(vc)-df)*.8))
    tint=(tint[0]*(1-.28*moss),min(1.0,tint[1]*(1+.06*moss)),tint[2]*(1-.44*moss))
    missing=(worn or HOUSE in [2,4,6]) and s==(-1 if not gable else 1) and ((j==7 and 2<a<2.6) or (j==12 and -2.5<a<-1.9))
    if missing:
     under=[Vector(mp(uu,vv,zz(uu,vv)+.005)) for uu,vv in [(a,va),(b,va),(b,vb),(a,vb)]]
     add(under,[(0,1,2,3)],1,(1.0,.90,.78))
     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(-.008,.012)
    slipped=(j>1 and j<rows-2 and random.random()<.013)
    if slipped:
     under=[Vector(mp(uu,vv,zz(uu,vv)+.005)) for uu,vv in [(a,va),(b,va),(b,vb),(a,vb)]]
     add(under,[(0,1,2,3)],1,(1.0,.90,.78))
     va+=s*.11;vb+=s*.11;lift+=.012
    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)
    # Pale crustose lichen occupies individual slate faces, never a second atlas.
    if mat==4 and random.random()<.11:
     uu=u;vv=(va+vb)/2;rr=random.uniform(.025,.068)
     pts=[Vector(mp(uu+rr*math.cos(k*math.tau/7),vv+rr*.70*math.sin(k*math.tau/7),zz(uu+rr*math.cos(k*math.tau/7),vv+rr*.70*math.sin(k*math.tau/7))+.048+lift)) for k in range(7)]
     add(pts,[tuple(range(7))],0,(.63,.72,.42))
  # 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)**2.05,.24,eave+.25+(rise-.42)*t) for t in [i/16 for i in range(17)]]
     # Broad bowed oak ribs, with a carved moulding along the exposed edge.
     pairs=[]
     for p in points:pairs.append((p[0],p[2]))
     profile([(x-.095,z) for x,z in pairs]+[(x+.095,z) for x,z in reversed(pairs)],(.15,.31),gm,1)
     tube([gm(x,.33,z) for x,z in pairs],.025,1,6,(1,.92,.78))
     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)
    # Pierced quatrefoil and small cusped spandrels at the apex.
    for sx in [-1,1]:
     for sz in [-1,1]:
      cx=sx*.145;cz=eave+rise-1.12+sz*.145
      tube([gm(cx+.155*math.cos(k*math.tau/18),.28,cz+.155*math.sin(k*math.tau/18)) for k in range(19)],.036,1,6,(1,.95,.81))
    lbeam(gm,-.38,eave+rise-1.58,.38,eave+rise-1.58,.24,.065,.095)
   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
 SOOT.append((x,y,z+tall,.66))
 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,(.24,.23,.20))

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=1.02+.64*noise(Vector((u*.71,HOUSE,.3)))+.20*noise(Vector((u*2.7,HOUSE,1)))
  # One isolated loss per party wall, in addition to the two street-face losses.
  right=mp(0,0,0)[0]>0;cx=width*(-.16 if right else .19);cz=height*(.42 if right else .65)
  rx=.63 if height<6 else .78;rz=.79 if height<6 else 1.08
  ang=math.atan2((z-cz)/rz,(u-cx)/rx)
  patch=(math.hypot((u-cx)/rx,(z-cz)/rz)-1+.17*math.sin(ang*3+HOUSE)+.12*n)*.7
  return min(z-base,edge-(.48+.24*math.sin(z*1.7)+.32*n+.55*math.exp(-z*.33)),patch)
 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=-.15+relief(u,z)*.045 if mat==5 else -.023+.020*noise(Vector((u*1.4,z*1.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)
     if mat==2:
      for aa,bb in zip(poly,poly[1:]+poly[:1]):
       if abs(field(*aa))<.035 and abs(field(*bb))<.035:
        u,z=aa;v,h=bb
        da=-.023+.020*noise(Vector((u*1.4,z*1.2,HOUSE)));db=-.023+.020*noise(Vector((v*1.4,h*1.2,HOUSE)))
        add([mp(u,da,z),mp(v,db,h),mp(v,-.15+relief(v,h)*.045,h),mp(u,-.15+relief(u,z)*.045,z)],[(0,1,2,3)],2,(.95,.92,.82))

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
 RAIN.clear();SOOT.clear();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,shutters=floor==0)
  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,2)
 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,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.35,True)
 for x in [-1.22,1.22]:lbox(mp,x,.47,7.49,.16,.95,1.72,0,.018)
 lbox(mp,0,.49,8.36,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.48+j*.065
  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+.081)),Vector(mp(x,d-.25,z+.081))],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(32):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
 SOOT.append((u,-1.42,1.78,.65))
 # 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) if not (za>=.96 and zb<=1.88 and math.sin((i+.5)*math.tau/n)>.85 and abs(r*math.cos((i+.5)*math.tau/n))<.43)]
  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,oven=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)],(-.41,-.40),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)
 for h in [z+.12,z+2.53]:lbox(fm,0,.04,h,W,.20,.21,1,.014)
 for x in [-2.84,0,2.84]:lbeam(fm,x,z+.15,x,z+2.51,.05,.18,.20)
 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.
 # Rounded scored loaves are added by lived_in().
 return .125

def ellipsoid(center,radii,mat,tint=(1,1,1),n=12,rings=6):
 c=Vector(center);vs=[]
 for j in range(rings+1):
  th=math.pi*j/rings
  for i in range(n):
   a=i*math.tau/n;vs.append(c+Vector((radii[0]*math.sin(th)*math.cos(a),radii[1]*math.sin(th)*math.sin(a),radii[2]*math.cos(th))))
 fs=[(j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i) for j in range(rings) for i in range(n)]
 add(vs,fs,mat,tint,smooth=True)

def planter(mp,u,sill,width):
 z=sill-.44;d=.41
 # Hollow wooden trough with a dark soil bed and two load-bearing brackets.
 lbox(mp,u,d,z,width,.42,.09,1,.01)
 for dd in [d-.22,d+.22]:lbox(mp,u,dd,z+.14,width+.06,.065,.29,1,.01,tint=(.84,.9,.85))
 for ss in [-1,1]:lbox(mp,u+ss*width/2,d,z+.14,.065,.48,.29,1,.009)
 lbox(mp,u,d,z+.20,width-.06,.38,.025,5,0,(.17,.16,.11))
 for ss in [-1,1]:
  x=u+ss*width*.34
  beam(mp(x,0,z-.23),mp(x,d+.23,z-.06),.038,.04,6)
  beam(mp(x,0,z-.23),mp(x,0,z+.11),.045,.05,6)
 for i in range(13):
  x=u-width*.44+i*width*.88/12;dd=d+random.uniform(-.12,.12);height=random.uniform(.27,.54)
  stem=[mp(x,dd,z+.22),mp(x+.06,dd+.02,z+.22+height*.6),mp(x+.02,dd-.01,z+.22+height)]
  tube(stem,.009,1,4,(.35,.53,.15))
  for k in range(5):
   angle=i*2.399+k*1.7;zz=z+.26+k*height/6;length=random.uniform(.12,.22)
   base=Vector(mp(x+.03,dd,zz));tip=base+Vector((math.cos(angle)*length,math.sin(angle)*length,length*.48))
   side=Vector((-math.sin(angle),math.cos(angle),0))*length*.27;mid=(base+tip)/2+Vector((0,0,.035))
   add([base,mid+side,tip,mid-side,mid+Vector((0,0,.018))],[(0,1,4),(1,2,4),(2,3,4),(3,0,4)],2,(.17+random.random()*.08,.44+random.random()*.21,.085),smooth=True)
  if i%4==1:
   cp=Vector(stem[-1]);ellipsoid(cp,(.065,.065,.045),2,(.75,.35,.20),8,3)

def lantern(mp,u):
 lbox(mp,u,.045,2.62,.10,.06,.32,6,.008)
 tube([mp(u,.07,2.72),mp(u,.39,2.72),mp(u,.47,2.66),mp(u,.45,2.60)],.023,6,7)
 lbox(mp,u,.45,2.31,.23,.23,.39,9,.005,(1,.66,.25))
 for ss in [-1,1]:
  for dd in [.32,.58]:beam(mp(u+ss*.13,dd,2.09),mp(u+ss*.13,dd,2.53),.024,.024,6)
 for zz in [2.09,2.53]:lbox(mp,u,.45,zz,.30,.30,.05,6,.008)
 cyl(mp(u,.45,2.55),mp(u,.45,2.65),.21,6,4,r2=.045)

def trade_sign(mp,u,z,bakery=False):
 # A forged arm and two links carry a board turned slightly toward the street.
 beam(mp(u,.02,z+.78),mp(u,.02,z+1.48),.058,.07,6)
 beam(mp(u,.03,z+1.36),mp(u,1.28,z+1.36),.055,.065,6)
 beam(mp(u,.04,z+.89),mp(u,.85,z+1.36),.043,.05,6)
 angle=math.radians(28)
 sm=lambda a,d,h:mp(u+a*math.cos(angle)-d*math.sin(angle),.96+a*math.sin(angle)+d*math.cos(angle),h)
 for x in [-.32,.32]:
  q=sm(x,0,z+.77);cyl(q,(q[0],q[1],z+1.36),.018,6,6)
 poly=[(-.47,z+.77),(.47,z+.77),(.50,z+.12),(.31,z-.04),(-.31,z-.04),(-.50,z+.12)]
 profile(poly,(-.057,.057),sm,1)
 profile([(-.41,z+.71),(.41,z+.71),(.43,z+.15),(.27,z+.02),(-.27,z+.02),(-.43,z+.15)],(.06,.069),sm,2,(.16,.24,.23) if not bakery else (.27,.16,.085))
 if bakery:
  # Painted loaf and three pale cuts, deliberately no lettering.
  profile([(.32*math.cos(i*math.tau/16),z+.34+.18*math.sin(i*math.tau/16)) for i in range(16)],(.073,.079),sm,2,(1,.78,.35))
  for x in [-.15,0,.15]:lbeam(sm,x-.03,z+.30,x+.04,z+.43,.083,.025,.012,2,(.95,.93,.73))
 else:
  # Tailor's shears pictogram.
  for ss in [-1,1]:
   tube([sm(ss*.16+.084*math.cos(i*math.tau/12),.079,z+.20+.10*math.sin(i*math.tau/12)) for i in range(13)],.026,0,5,(.94,.90,.69))
   lbeam(sm,ss*.12,z+.28,-ss*.20,z+.61,.082,.035,.016,0,(.94,.90,.69))


def basket(mp,u,d,z,r=.30):
 n=14;vs=[]
 for zz,rr in [(z,r*.68),(z+.34,r),(z+.35,r*.9),(z+.07,r*.62)]:
  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=[(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,1,(1,.85,.58))
 for j in range(6):
  zz=z+.05+j*.055;rr=r*(.68+.32*(zz-z)/.34)
  tube([mp(u+rr*math.cos(i*math.tau/n),d+rr*math.sin(i*math.tau/n),zz) for i in range(n+1)],.011,1,4,(1,.82,.5))
 for i in range(n):
  a=i*math.tau/n
  beam(mp(u+r*.71*math.cos(a),d+r*.71*math.sin(a),z+.025),mp(u+r*math.cos(a),d+r*math.sin(a),z+.35),.018,.018,1,(.9,.72,.44))
 tube([mp(u+r*math.cos(i*math.pi/12),d,z+.35+.32*math.sin(i*math.pi/12)) for i in range(13)],.022,1,5,(1,.86,.62))

def loaf(mp,u,d,z,long=False):
 rx=.25 if long else .18;ry=.12 if long else .17
 ellipsoid(mp(u,d,z+.10),(rx,ry,.11),2,(1,.69,.31),12,6)
 for k in [-1,0,1]:
  xx=u+k*rx*.48
  tube([mp(xx-.022,d-.055,z+.188),mp(xx+.014,d,z+.213),mp(xx+.045,d+.053,z+.185)],.008,2,4,(.59,.35,.14))

def gutters(mp,width,eave):
 # Open semicircular gutter, closed wall thickness, falling toward a downpipe.
 count=math.ceil(width/.7);r=.095
 for j in range(count):
  x0=-width/2+.10+j*(width-.2)/count;x1=-width/2+.10+(j+1)*(width-.2)/count
  vs=[]
  for x in [x0,x1]:
   zz=eave-.12-.025*(x+width/2)/width
   for rr in [r,r-.018]:
    for k in range(7):
     t=math.pi+math.pi*k/6;vs.append(mp(x,.22+rr*math.cos(t),zz+rr*math.sin(t)))
  fs=[(k,k+1,15+k,14+k) for k in range(6)]+[(7+k,21+k,22+k,8+k) for k in range(6)]+[(0,14,21,7),(6,13,27,20)]
  add(vs,fs,6,(.61,.67,.63))
  # Steel cradle fastened into the fascia.
  tube([mp((x0+x1)/2,.13,eave-.09),mp((x0+x1)/2,.15,eave-.23),mp((x0+x1)/2,.28,eave-.23)],.014,6,5)
 x=width/2-.19
 tube([mp(x,.23,eave-.20),mp(x,.23,eave-.49),mp(x,.09,eave-.68),mp(x,.09,3.15),mp(x,.09+mp(0,0,0)[1],2.87),mp(x,.09+mp(0,0,0)[1],.28),mp(x,.27+mp(0,0,0)[1],.13)],.060,6,9)
 for zz in [.57,2.8,4.9,7.2]:
  if zz<eave-.7:
   dep=.09+(mp(0,0,0)[1] if zz<3 else 0)
   lbox(mp,x,dep-.07,zz,.21,.075,.05,6,.006)
   tube([mp(x+.065*math.cos(i*math.tau/9),dep+.065*math.sin(i*math.tau/9),zz) for i in range(10)],.014,6,5)


def worn_step(mp,u,width):
 # Closed chipped threshold with a concave tread polished by many feet.
 nx=12;ny=4;vs=[]
 for j in range(ny+1):
  for i in range(nx+1):
   xx=-width/2+width*i/nx;dd=.05+.46*j/ny
   wear=.047*math.exp(-(xx/(width*.28))**2)*(math.sin(math.pi*j/ny)**.6)
   vs.append(mp(u+xx,dd,.301-wear+.004*math.sin(i*3+j)))
 fs=[(j*(nx+1)+i,j*(nx+1)+i+1,(j+1)*(nx+1)+i+1,(j+1)*(nx+1)+i) for j in range(ny) for i in range(nx)]
 ring=list(range(nx+1))+[j*(nx+1)+nx for j in range(1,ny+1)]+[ny*(nx+1)+i for i in range(nx-1,-1,-1)]+[j*(nx+1) for j in range(ny-1,0,-1)]
 off=len(vs)
 for i in ring:
  pt=Vector(vs[i]);pt.z=.15;vs.append(pt)
 for j,a in enumerate(ring):fs.append((a,ring[(j+1)%len(ring)],off+(j+1)%len(ring),off+j))
 fs.append(tuple(range(off,len(vs))))
 add(vs,fs,0,(.89,.91,.82),smooth=True)


def lived_in(num,doorx):
 mp=front_at()
 if num==1:planter(front_at(.20),0,3.85,1.52)
 if num==5:planter(front_at(.21),-1.78,3.85,1.24)
 if num in [2,6]:
  trade_sign(mp,-1.36 if num==2 else 2.54,2.18 if num==2 else 2.31,bakery=num==6)
  lantern(mp,-2.80 if num==2 else .75)
 if num==2:
  for i,col in enumerate([(.12,.29,.58),(.48,.10,.085),(.22,.40,.15)]):
   x=-.67+i*.61
   cyl(mp(x,.38,1.09),mp(x,.99,1.09),.13,2,12,tint=col)
   # Visible rolled-cloth end, open spiral of lighter weave.
   tube([mp(x+.11*(1-k/43)*math.cos(k*math.pi/5),1.001,1.09+.11*(1-k/43)*math.sin(k*math.pi/5)) for k in range(36)],.011,2,4,tuple(min(1,c*1.5) for c in col))
  basket(mp,1.76,.68,.945,.31)
 if num==6:
  for i in range(5):loaf(mp,.95+i*.31,.42+(i%2)*.23,1.00,i%2==0)
 if num in [2,5]:gutters(front_at(.38 if num==2 else .21),W,EAVE)
 if num in [2,3,4,6]:worn_step(mp,doorx,1.23 if num!=6 else 1.08)

def finish_house(num,doorx,slot):
 lived_in(num,doorx)
 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))
 doorpos=(doorx,0,0) if num==5 else (doorx,.16,0) if num==1 else (doorx,.20,0)
 entrancepos=(doorx,doorpos[1]-.8,0)
 for name,pos in [('Entrance',entrancepos),('Door',doorpos)]:
  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':list(yup(Vector(entrancepos))),'Door':list(yup(Vector(doorpos)))}
 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
 # Signed normalized 16-bit normals retain shading precision and save four bytes/vertex.
 for mesh in doc['meshes']:
  for primitive in mesh['primitives']:
   ai=primitive['attributes'].get('NORMAL')
   if ai is None:continue
   acc=doc['accessors'][ai];vi=acc['bufferView'];view=doc['bufferViews'][vi]
   off=view.get('byteOffset',0)+acc.get('byteOffset',0)
   ar=np.frombuffer(oldbin,dtype='<f4',count=acc['count']*3,offset=off).reshape(-1,3)
   packed=np.zeros((acc['count'],4),dtype='<i2');packed[:,:3]=np.rint(np.clip(ar,-1,1)*32767).astype('<i2')
   replacements[vi]=packed.tobytes();acc['componentType']=5122;acc['byteOffset']=0;acc['normalized']=True
   view['byteStride']=8;view['_normal_stride']=True
 for key in ['extensionsUsed','extensionsRequired']:
  doc.setdefault(key,[])
  if 'KHR_mesh_quantization' not in doc[key]:doc[key].append('KHR_mesh_quantization')
 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 view.pop('_normal_stride',False):view['byteStride']=8
  elif 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
 assert all(30000<=d['triangles']<=70000 for d in META.values()),'Per-house triangle budget'
 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; signed 16-bit normals use KHR_mesh_quantization.', 'Poly Haven CC0 texture ids: '+', '.join(sorted(set(c[1] for c in CONFIG)))+'; https://polyhaven.com/license; sources dl.polyhaven.org, jpg/1k.', 'Revision 1 wear: four recessed plaster losses per plastered house, with masonry and broken-edge returns, bulged lime panels, sill/eave runoff, green capillary damp, silvered oak, settled jetties/ridges, slipped/missing tiles, lower-course/valley moss, slate lichen, chimney and oven soot; geometry + COLOR_0.', '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.', 'Revision 1 life: two planted window boxes, angled shutters, cloth bolts/woven basket, scored loaves, wordless shop/bakery signs, two added bracket lanterns, two gutters/downpipes, worn treads, cool reflective panes with faint warm interiors. Reimported GLB supplies Cycles renders; QA separately records runtime/structural checks.']
 (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=1000,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:
   if dat['house']==1:
    camera((cx+3,-10,9.1),(cx,0,8.7),6.1);render('detail_merchant.png')
   if dat['house']==2:
    camera((cx+9,-13,13),(cx,2,10),10);render('detail_shop_roof.png')
   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)
