"""Breton reusable atoms. Blender 5.2, deterministic, no external Python packages.
Run on gex44: blender -b --python-exit-code 1 --python /root/fal-author/breton-atom-kit-v1/build_kit.py
Reads the earlier house textures/ and baked/ without modifying them. All delivery outputs
are rebuilt in this script's directory. -- --preflight exercises a tiny render/export/import.
Authored coordinates are already glTF Y-up; exporter axis conversion is intentionally OFF.
"""
import bpy, bmesh, math, json, sys, struct, hashlib, time
import numpy as np
from pathlib import Path
from mathutils import Vector, Matrix
ROOT=Path(__file__).resolve().parent
SOURCE=Path('/root/fal-author/breton-stone-house-v1')
START=time.time()
NAMES=['Granite_lime_moss','Dressed_granite','Blue_grey_slate','Breton_blue_timber','Aged_ivory_frames','Weathered_zinc_iron','Old_window_glass','Terracotta','Granite_lime_moss_base']
TILES=[2.25,1.6,3.57,1.0,1.0,1.0,1.0,1.0,2.25]
LIMITS={'slate_a':40,'slate_b':40,'slate_c':40,'ridge_tile':120,'quoin_long':150,'quoin_short':150,'window_ground':3000,'window_ground_shutters':3000,'window_upper':3000,'window_upper_shutters':3000,'window_small':3000,'door':4000,'dormer':8000,'chimney_stack':2500,'gutter_1m':250,'downpipe_1m':150,'eaves_board_1m':60,'verge_board_1m':60,'lantern':600}
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.samples=48;scene.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
try:
 prefs.compute_device_type='OPTIX';prefs.get_devices()
 for d in prefs.devices:d.use=d.type!='CPU'
 scene.cycles.device='GPU'
except Exception:scene.cycles.device='CPU'
scene.unit_settings.system='METRIC';scene.render.image_settings.file_format='PNG';scene.view_settings.view_transform='AgX'
PROVENANCE={};seams={}
def read_image(path,size,color=False):
 path=Path(path);PROVENANCE[str(path)]=hashlib.sha256(path.read_bytes()).hexdigest()
 im=bpy.data.images.load(str(path),check_existing=False)
 im.colorspace_settings.name='sRGB' if color else 'Non-Color'
 im.scale(size,size);a=np.array(im.pixels[:],dtype=np.float32).reshape(size,size,4)[:,:,:3].copy();bpy.data.images.remove(im);return a

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

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

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

MATS=[];shelf={};arrays={}
for i,name in enumerate(NAMES):
 n=1024 if i in (0,1,2,3,8) else 512
 if '--preflight' in sys.argv:n=32
 maps={}
 for kind in ['BaseColor','Normal','ORM']:
  if i==8:a=arrays[(0,kind)].copy()
  elif i==1:
   # Original dressed swatch has an atlas-like mortar strip. Re-grade the SAME
   # registered CC0 scan into a wholly repeatable dry stone swatch instead.
   suffix={'BaseColor':'diff','Normal':'nor_gl','ORM':'arm'}[kind]
   a=read_image(SOURCE/'textures'/f'worn_rock_natural_01_{suffix}_1k.jpg',n,kind=='BaseColor')
   if kind=='BaseColor':
    lum=a@np.array([.2126,.7152,.0722]);a=(lum[:,:,None]*1.65+.24)*np.array([.53,.52,.448])[None,None,:]*.94
   elif kind=='Normal':
    vec=a*2-1;vec[:,:,:2]*=.5;a=(vec/np.maximum(np.linalg.norm(vec,axis=2,keepdims=True),1e-8)+1)/2
   else:a[:,:,0]=.7+.3*a[:,:,0];a[:,:,1]=.65+.22*a[:,:,1];a[:,:,2]=0
  else:a=read_image(SOURCE/'baked'/f'{name}_{kind}.jpg',n,kind=='BaseColor')
  if i==8:
   height=arrays[(0,'Height')]
   yy,xx=np.meshgrid(np.arange(n)/n,np.arange(n)/n,indexing='ij')
   patch=(np.sin(xx*math.tau*3+np.sin(yy*math.tau*2))+.6*np.cos(yy*math.tau*5-xx*math.tau*2)+1.6)/3.2
   mask=np.clip((patch-.23)*1.6,0,.80)*(1-.3*height)
   if kind=='BaseColor':a=a*(1-mask[:,:,None])+np.array([.052,.067,.023])[None,None,:]*mask[:,:,None]
   elif kind=='ORM':a[:,:,1]=a[:,:,1]*(1-mask)+.70*mask
  a=periodic(a)
  if kind=='Normal':
   v=a*2-1;a=(v/np.maximum(np.linalg.norm(v,axis=2,keepdims=True),1e-8)+1)*.5
  maps[kind]=a;arrays[(i,kind)]=a
  save_image(ROOT/'materials'/name/f'{kind}.png',a,kind=='BaseColor')
 if i==0:
  h=read_image(SOURCE/'textures'/'rock_wall_09_disp_1k.jpg',n)[:,:,0]
  h=periodic(np.repeat(h[:,:,None],3,axis=2))[:,:,0]
  # Normalize source extrema so zero is deepest mortar, one is tallest rubble; keep 0.105 m relief and 0.006 m bias.
  h=(h-h.min())/max(float(h.max()-h.min()),1e-8)
  arrays[(0,'Height')]=h;save_image(ROOT/'materials'/name/'Height.png',h,grey=True)
 m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
 avg=np.mean(maps['BaseColor'],axis=(0,1));m.diffuse_color=(*map(float,avg),1)
 bs.inputs['Base Color'].default_value=m.diffuse_color;bs.inputs['Roughness'].default_value=float(maps['ORM'][:,:,1].mean());bs.inputs['Metallic'].default_value=float(maps['ORM'][:,:,2].mean())
 MATS.append(m)
 shelf[name]={'tile_size_metres':TILES[i],'maps':{k:f'materials/{name}/{k}.png' for k in maps},'resolution':n,'normal_convention':'OpenGL tangent +Y','roughness_factor':1.0,'metalness_factor':1.0,'ORM_channels':{'R':'occlusion','G':'roughness','B':'metallic'},'wrap':'REPEAT','color_space':{'BaseColor':'sRGB','Normal':'linear','ORM':'linear'}}
 if i in (0,8):shelf[name].update({'displacement_scale_metres':.105,'displacement_bias_metres':.006,'height_map':'materials/Granite_lime_moss/Height.png','height_formula':'outward_metres = 0.006 + sampled_Height * 0.105','height_repeat_metres':2.25,'height_encoding':'16-bit grayscale; original scan extrema normalized 0..1 after periodic seam correction'})
print('SHELF_READY',flush=True)

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

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

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

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

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

def slate(g,variant=0,center=(0,0,0),rot=None,w=.345,length=.42):
 c=[.011,.022,.015][variant%3];l=length
 poly=[(-w/2,0),(w/2,0),(w/2,l-.024),(w/2-c,l),(-w/2+.018+variant*.005,l),(-w/2,l-.01-variant*.009)]
 vs=[Vector((x,y,z)) for y in [0,.012] for x,z in poly]
 if rot:vs=[rot@v for v in vs]
 vs=[v+Vector(center) for v in vs];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)]
 g.add(vs,fs,2)

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

META={};OBJECTS=[]
def finish(g,origin,opening=None,extra=None):
 ob=g.finish();OBJECTS.append(ob);META[g.name]={'node_name':g.name,'origin_rule':origin}
 if opening:META[g.name]['opening']={'width':opening[0],'height':opening[1],'wall_thickness':.38,'bottom_center':[0,0,0]}
 if extra:META[g.name].update(extra)
 return ob
for i,s in enumerate('abc'):
 g=Mesh('slate_'+s);slate(g,i);finish(g,'Middle of top edge; top edge Z=0, down-slope +Z, underside Y=0.',extra={'size_metres':[.345,.012,.42],'course_gauge_metres':.21,'roof_placement':'Rotate X by +44 degrees on street slope; +Z runs down slope. Use .014 m normal offset per overlapping course.'})
g=Mesh('ridge_tile');ridge(g);finish(g,'Centre of ridge line; length along X.',extra={'pitch_degrees':44,'length_metres':.42,'lap_metres':.025})
for name,size in [('quoin_long',(.60,.386,.35)),('quoin_short',(.35,.386,.65))]:
 g=Mesh(name);box(g,(0,0,0),size,1,.025);finish(g,'Block centre; X along facade, Y vertical; alternate with 0.014 m bed joints.',extra={'nominal_size_metres':list(size)})

def window(g,w,h,shutters=False,offset=(0,0,0),stone=True):
 start=len(g.v)
 # Lining returns reach exactly wall interior at -0.38; frame seals the opening.
 for sign in [-1,1]:
  box(g,(sign*(w/2+.032),h/2,-.235),(.064,h,.29),1,.008)
  if stone:
   rows=3 if h>1 else 2
   for j in range(rows):box(g,(sign*(w/2+.095),(j+.5)*h/rows,.015),(.19,h/rows-.008,.23),1,.013)
 box(g,(0,h+.075,-.115),(w+.43,.15,.53),1,.014)
 # Drip sill shaped as a stepped polygon section, projecting forward with sloped top.
 sec=[(-.38,-.085),(.23,-.085),(.245,-.04),(.23,.005),(.07,.025),(-.38,.025)]
 # extrude sill section along X
 vs=[(x,y,z) for x in [-w/2-.24,w/2+.24] for z,y in sec];n=len(sec)
 fs=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 g.add(vs,fs,1)
 box(g,(0,-.09,.145),(w+.38,.025,.05),1,.004)
 for s in [-1,1]:box(g,(s*(w/2-.034),h/2,-.12),(.068,h,.12),4,.006)
 for y in [.035,h-.035]:box(g,(0,y,-.12),(w-.12,.07,.12),4,.006)
 box(g,(0,h/2,-.098),(.046,h-.13,.12),4,.004)
 # Separate panes leave physical space for bars; two casements, each with edge moulding.
 rows=3 if h>=1 else 2
 innerw=(w-.15)/2
 for side in [-1,1]:
  cx=side*(w/4-.009)
  for edge in [-1,1]:box(g,(cx+edge*(innerw/2-.014),h/2,-.123),(.028,h-.14,.065),4,.003)
  for j in range(rows+1):
   yy=.075+(h-.15)*j/rows;box(g,(cx,yy,-.11),(innerw,.027,.075),4,.003)
  for j in range(rows):
   yy=.075+(h-.15)*(j+.5)/rows;box(g,(cx,yy,-.157),(innerw-.028,(h-.15)/rows-.016,.012),6,0)
  # Casement catch and hinge knuckles.
  box(g,(side*.044,h*.46,-.037),(.014,.075,.014),5,.002)
  for yy in [.20,h-.20]:cyl(g,(side*(w/2-.085),yy-.022,-.061),(side*(w/2-.085),yy+.022,-.061),.012,5,6)
 if shutters:
  sw=w/2+.025
  for side in [-1,1]:
   cx=side*(w/2+.13+sw/2)
   for j in range(4):box(g,(cx+(j-1.5)*sw/4,h/2,.13),(sw/4-.004,h+.055,.055),3,.004)
   for yy in [.20,h-.20]:
    box(g,(cx,yy,.169),(sw+.02,.064,.035),3,.006)
    box(g,(cx-side*.02,yy,.195),(sw*.80,.024,.014),5,.002)
    for dx in [-sw*.28,sw*.28]:cyl(g,(cx+dx,yy,.20),(cx+dx,yy,.208),.009,5,6)
   box(g,(cx-side*(sw/2-.035),.08,.19),(.025,.095,.024),5,.003)
 if offset!=(0,0,0):g.v[start:]=[v+Vector(offset) for v in g.v[start:]]
for name,w,h,sh in [('window_ground',.95,1.30,False),('window_ground_shutters',.95,1.30,True),('window_upper',.85,1.10,False),('window_upper_shutters',.85,1.10,True),('window_small',.50,.70,False)]:
 g=Mesh(name);window(g,w,h,sh);finish(g,'Bottom centre of clear opening at outer wall plane Z=0.',(w,h))

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

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

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

# Gutter cross section and brackets, 12-step half-round, length exactly one metre.
g=Mesh('gutter_1m');n=10;profile=[]
for r,indices in [(.082,range(n+1)),(.069,range(n,-1,-1))]:
 for i in indices:
  a=math.pi+i*math.pi/n;profile.append((r*math.sin(a),.082+r*math.cos(a)))
vs=[(x,y,z) for x in [-.5,.5] for y,z in profile];q=len(profile)
fs=[tuple(range(q-1,-1,-1)),tuple(range(q,2*q))]+[(i,(i+1)%q,(i+1)%q+q,i+q) for i in range(q)];g.add(vs,fs,5)
for x in [-.32,.32]:
 tube(g,[(x,-.014,0),(x,-.090,.079),(x,-.01,.166)],.009,5,5)
finish(g,'Middle of back top edge: X=0,Y=0,Z=0; trough projects +Z.',extra={'length_metres':1,'open_ends':False,'joint_rule':'Butt sections at X endpoints; thin end webs remain internal. Brackets are included.'})
g=Mesh('downpipe_1m');cyl(g,(0,0,.082),(0,1,.082),.045,5,10)
cyl(g,(0,.47,.082),(0,.52,.082),.056,5,10);box(g,(0,.495,.022),(.085,.035,.044),5,0)
finish(g,'Bottom on wall face; pipe extends Y=0..1 and projects +Z.',extra={'length_metres':1})
for name,s in [('eaves_board_1m',(1,.14,.045)),('verge_board_1m',(1,.12,.035))]:
 g=Mesh(name);box(g,(0,-s[1]/2,s[2]/2),s,3,.005);finish(g,'Middle of top back edge; length along X. Rotate in facade plane for verge.',extra={'length_metres':1})
g=Mesh('lantern');box(g,(0,0,.025),(.10,.24,.05),5,.009)
tube(g,[(0,.065,.05),(0,.065,.29),(0,-.03,.29)],.015,5,8)
box(g,(0,-.19,.29),(.17,.27,.16),6,0)
for x in [-.095,.095]:
 for z in [.20,.38]:cyl(g,(x,-.345,z),(x,-.035,z),.010,5,6)
box(g,(0,-.355,.29),(.235,.035,.23),5,.007)
# Pyramidal rain hood, real profiled metal instead of a plain cuboid.
vs=[(-.14,-.025,.15),(.14,-.025,.15),(.14,-.025,.43),(-.14,-.025,.43),(-.045,.045,.245),(.045,.045,.245),(.045,.045,.335),(-.045,.045,.335)]
g.add(vs,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],5)
for y in [-.07,.07]:cyl(g,(0,y,.05),(0,y,.057),.012,5,6)
finish(g,'Centre of wall mounting plate on facade face Z=0.')

# Contract validation in author space; all top-level transforms identity.
bpy.context.view_layer.update();entries=[]
for ob in OBJECTS:
 if ob==leaf:continue
 children=[ob]+list(ob.children);pts=[o.matrix_local@v.co for o in children for v in o.data.vertices]
 tris=sum(len(o.data.polygons) for o in children)
 assert tris<=LIMITS[ob.name],(ob.name,tris,LIMITS[ob.name])
 for o in children:
  for p in o.data.polygons:assert p.area>1e-12,(o.name,'degenerate',p.index)
  assert all(math.isfinite(x) for v in o.data.vertices for x in v.co)
 entry=META[ob.name];entry.update({'bounding_box':{'min':[round(min(v[k] for v in pts),6) for k in range(3)],'max':[round(max(v[k] for v in pts),6) for k in range(3)]},'triangles':tris,'triangle_budget':LIMITS[ob.name],'materials':sorted(set(o.data.materials[p.material_index].name for o in children for p in o.data.polygons))})
 if ob==door:entry['children']=[{'node_name':'door_leaf','translation':list(leaf.location),'origin_rule':'Left hinge line; rotate about local Y.','triangles':len(leaf.data.polygons)}]
 entries.append(entry)
 print('ATOM',ob.name,tris,flush=True)
assert len(entries)==19
bpy.ops.object.select_all(action='DESELECT')
for o in OBJECTS:o.select_set(True)
# At this point materials contain scalar placeholders only, no texture nodes/images.
bpy.ops.export_scene.gltf(filepath=str(ROOT/'kit.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=False,export_materials='EXPORT',export_texcoords=True,export_normals=True,export_tangents=True,export_cameras=False,export_lights=False)
data=(ROOT/'kit.glb').read_bytes();ln=struct.unpack_from('<I',data,12)[0];gltf=json.loads(data[20:20+ln]);assert not gltf.get('images') and not gltf.get('textures')
rootnodes=[gltf['nodes'][i] for i in gltf['scenes'][gltf.get('scene',0)]['nodes']]
assert set(n['name'] for n in rootnodes)==set(LIMITS)
assert all('mesh' in n and not any(k in n for k in ['translation','rotation','scale','matrix']) for n in rootnodes)
# Ninth shelf material is optional for assembler-generated wall base; no master needs it.
material_doc={'schema':'forge.material-shelf.v1','materials':shelf,'moss_lower_zone':{'material':'Granite_lime_moss_base','height_metres':.9,'uv_rule':'Use identical world-surface metre UV coordinates for both granite maps: U=facade_distance/2.25, V=height/2.25. Bind base material on generated wall faces below 0.9 m. For a soft transition, mix the two BaseColor/ORM samples with 1-smoothstep(0.65,0.9,world_height); normal and height are identical. Do not stretch UVs to fit a wall or base band.','normal_and_height_shared':True},'runtime_binding':'Bind maps by exact material name. BaseColor sRGB; Normal and ORM linear; RepeatWrapping; texture.flipY=false for glTF UVs; normalScale=(1,1), roughness=1, metalness=1 (ORM drives both). AO reads UV0. Geometry UVs already divide by tile size: do not divide or repeat again.','provenance':{'license':'CC0','source':'Poly Haven','ids':['rock_wall_09','worn_rock_natural_01','wooden_planks','metal_plate_02','slate_floor_03'],'read_only_source':str(SOURCE)}}
(ROOT/'materials.json').write_text(json.dumps(material_doc,indent=2))
kitdoc={'schema':'forge.atom-kit.v1','units':'metres','up':'+Y','facade_outward':'+Z','facade_horizontal':'+X','uv_rule':'UV0 is orthonormal surface distance in metres / material tile size. Translation preserves scale. Rotate masters as rigid units; do not rescale. For phase-continuous generated walls, use global facade coordinates.','atoms':entries,'total_triangles':sum(e['triangles'] for e in entries),'glb_bytes':len(data),'material_binding':'materials.json','images_embedded':0}
(ROOT/'kit.json').write_text(json.dumps(kitdoc,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(["19 Breton fisherman’s house atoms in kit.glb; Y up, +Z outward, metres; door_leaf is a left-hinged child.",f"{kitdoc['total_triangles']:,} triangles total; every atom within its requested ceiling; counts and bounds in kit.json.","Eight original material names plus optional Granite_lime_moss_base; external tileable PBR shelf, metric UV0; no GLB images.","PNG shelf: primary maps 1024², frames/zinc/glass/terracotta 512²; registered granite Height is 1024² 16-bit grey.","Poly Haven CC0 ids: rock_wall_09, worn_rock_natural_01, wooden_planks, metal_plate_02, slate_floor_03; reused existing downloads and bakes read-only.","House differences: requested smaller openings, modular origins/budgets, repeatable moss base replaces unique wall atlas; opaque old glass retained; no whole-house walls."])+ '\n')
(ROOT/'build_evidence.json').write_text(json.dumps({'source_sha256':PROVENANCE,'seams':seams,'glb_sha256':hashlib.sha256(data).hexdigest(),'builder_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()},indent=2))

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

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

def render(name):scene.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
# Literal single row with names: keep common scale so units are visually comparable.
x=0
for name,ob in masters.items():
 lo,hi=bounds([ob]);w=hi.x-lo.x;slot=max(w,.85)+.24;ob.location.x=x+slot/2-(lo.x+hi.x)/2;ob.location.z=-lo.z;ob.location.y=0
 text(name.replace('_','_\n'),(x+slot/2,-1.15,.02),.155)
 x+=slot
for ob in masters.values():ob.location.x-=x/2
for ob in labels:ob.location.x-=x/2
text('BRETON  /  CONSTRUCTION ATOMS',(0,.8,3.3),.40)
text('19 reusable masters  ·  shared metre-scale PBR shelf  ·  Y up / +Z street',(0,.8,2.7),.18)
camera((0,0,.9),x*1.045,(.03,-1,.52));render('kit_sheet.png')
# Useful closer contact sheets accompany the requested single row.
for ob in labels:ob.hide_render=True
for ob in masters.values():ob.hide_render=True

def close_sheet(names,filename,clay=False):
 for ob in masters.values():
  ob.hide_render=True
  for child in ob.children:child.hide_render=True
 xx=0;active=[]
 for name in names:
  ob=masters[name];ob.hide_render=False
  for child in ob.children:child.hide_render=False
  ob.location=(0,0,0);lo,hi=bounds([ob]);slot=max(hi.x-lo.x,.6)+.30
  ob.location=(xx+slot/2-(lo.x+hi.x)/2,0,-lo.z);xx+=slot;active.append(ob)
 for ob in active:ob.location.x-=xx/2
 lo,hi=bounds(active);camera((lo+hi)/2,max(xx*1.12,(hi.z-lo.z)*2.25),(.18,-1,.4))
 if clay:
  cm=bpy.data.materials.get('STUDIO_clay') or bpy.data.materials.new('STUDIO_clay');cm.diffuse_color=(.40,.40,.40,1);cm.use_nodes=True;cm.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.4,.4,.4,1);scene.view_layers[0].material_override=cm
 render(filename);scene.view_layers[0].material_override=None
close_sheet(['window_ground_shutters','window_upper','window_small','door','lantern'],'details_joinery.png')
close_sheet(['dormer','chimney_stack','ridge_tile','gutter_1m','downpipe_1m'],'details_roof.png')
close_sheet(['window_ground_shutters','door','dormer','chimney_stack'],'clay_check.png',True)
close_sheet(['window_ground','door','dormer'],'reverse_check.png')
# Rear-facing view to verify reveal depth and underside connections.
active=[masters[n] for n in ['window_ground','door','dormer']];lo,hi=bounds(active);camera((lo+hi)/2,7.8,(-.35,1,.35));render('reverse_check.png')
print('BUILD_COMPLETE',kitdoc['total_triangles'],len(data),'seconds',round(time.time()-START,2),flush=True)
