"""Clean Blender 5.2 build. Reads sibling source kits, writes only this directory.
Cycles bakes tangent normals from the delivered high meshes and shelf micro
normals; barycentric projection transfers color/ORM. Uses bundled NumPy only.
"""
import bpy,bmesh,numpy as np,math,json,hashlib,time,struct,zlib,sys,random
from pathlib import Path
from mathutils import Vector,Matrix
ROOT=Path(__file__).resolve().parent
START=time.time();N=1024
SOURCES={'tile':ROOT.parent/'medieval-atom-kit-v1','slate':ROOT.parent/'breton-atom-kit-v2'}
NAMES={'tile':'Clay_tile_strip','slate':'Blue_grey_slate_strip'}
C=Matrix.Rotation(math.pi/2,4,'X')
def out(s):print(s,flush=True)
def png(path,a):
 path.parent.mkdir(parents=True,exist_ok=True)
 a=(np.clip(a,0,1)*255+.5).astype(np.uint8)[::-1];h,w,c=a.shape
 def chunk(t,d):return struct.pack('>I',len(d))+t+d+struct.pack('>I',zlib.crc32(t+d)&0xffffffff)
 path.write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(b''.join(b'\0'+r.tobytes() for r in a),6))+chunk(b'IEND',b''))
def load_pixels(path,color=False):
 im=bpy.data.images.load(str(path),check_existing=False);im.colorspace_settings.name='Non-Color'
 a=np.array(im.pixels[:],np.float32).reshape(im.size[1],im.size[0],4)[:,:,:3].copy();bpy.data.images.remove(im);return a

def shader(name,paths):
 m=bpy.data.materials.new(name);m.use_nodes=True;m.use_backface_culling=True
 nt=m.node_tree;bs=nt.nodes.get('Principled BSDF');bs.inputs['Roughness'].default_value=1;bs.inputs['Metallic'].default_value=1
 tex={}
 for k,p in paths.items():
  t=nt.nodes.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(p),check_existing=True);t.image.colorspace_settings.name='sRGB' if k=='BaseColor' else 'Non-Color';t.extension='REPEAT';tex[k]=t
 nt.links.new(tex['BaseColor'].outputs['Color'],bs.inputs['Base Color'])
 normal=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(tex['Normal'].outputs['Color'],normal.inputs['Color']);nt.links.new(normal.outputs['Normal'],bs.inputs['Normal'])
 sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(tex['ORM'].outputs['Color'],sep.inputs['Color']);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=nt.nodes.new('ShaderNodeGroup');gn.node_tree=group;nt.links.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 return m

bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.samples=32;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.render.image_settings.file_format='PNG';scene.view_settings.view_transform='AgX'
high={};source_docs={};source_hashes={};shelves={}
for kind,root in SOURCES.items():
 source_docs[kind]=json.loads((root/'kit.json').read_text());shelves[kind]=json.loads((root/'materials.json').read_text())['materials']
 source_hashes[str(root/'kit.glb')]=hashlib.sha256((root/'kit.glb').read_bytes()).hexdigest()
 before=set(bpy.data.objects);bpy.ops.import_scene.gltf(filepath=str(root/'kit.glb'))
 for o in set(bpy.data.objects)-before:
  if o.name in [kind+'_strip_a',kind+'_strip_b']:
   o.data.transform(o.matrix_world);o.matrix_world=Matrix.Identity(4);high[o.name]=o
  else:bpy.data.objects.remove(o,do_unlink=True)
 for o in list(high.values()):o.hide_render=True;o.hide_set(True)
 for name in (['Clay_tile','Clay_tile_dark'] if kind=='tile' else ['Blue_grey_slate']):
  m=shader(name+'_reference',{k:root/v for k,v in shelves[kind][name]['maps'].items()})
  for o in high.values():
   for i,old in enumerate(o.data.materials):
    if old and old.name==name:o.data.materials[i]=m
out('INPUTS_READY')

# Closed slabs: 26 vertices / 48 triangles for clay; 18 / 32 for slate.
def make_low(kind,var):
 w,L=(1.32,.265) if kind=='tile' else (1.38,.424);zmin=0 if kind=='tile' else -.002
 count=8 if kind=='tile' else 4;variant=0 if var=='a' else 1
 # Asymmetric boundary sampling spends vertices on the visible upper lip.
 # The coarser lower rim stays a closed, thick slab: V=26/18 => 48/32 tris.
 if kind=='tile':
  topfront=[(-w/2,L)]
  for j in range(8):
   # Reproduce the source generator's seeded mid-butt hand-cut chip.
   rng=random.Random(j+variant*11+193);chip=rng.uniform(-.025,.025)
   for lo,hi in [(0,.002),(.0005,.003),(0,.002),(.0005,.003)]:rng.uniform(lo,hi)
   recess=.004+rng.uniform(0,.003);center=(j-3.5)*.165
   topfront.extend([(center+chip-.006,L-recess),(center+chip+.009,L)])
   if j in [2,5]:topfront.append((center+.0825,L-.001))
  topfront.append((w/2,L-.001))
  bottomfront=[(-w/2,L-.008),(w/2,L-.008)]
 else:
  topfront=[(-w/2,L+zmin-.001)]
  for j in range(1,4):
   x=-w/2+j*w/4
   toe=L+zmin-.001*((j+variant)%3)
   topfront.extend([(x-.013,toe),(x,L+zmin-(.013+.002*((j+variant)%2))),(x+.010,toe-.001)])
  topfront.append((w/2,L+zmin-.001))
  bottomfront=[(-w/2,L+zmin-.020),(0,L+zmin-.020),(w/2,L+zmin-.020)]
 top=[(-w/2,zmin),(w/2,zmin)]+list(reversed(topfront));bottom=[(-w/2,zmin),(w/2,zmin)]+list(reversed(bottomfront))
 nt,nb=len(top),len(bottom);slope=.16 if kind=='tile' else 0;thickness=.0124 if kind=='tile' else .0115
 verts=[(x,slope*z,z) for x,z in bottom]+[(x,slope*z+thickness,z) for x,z in top]
 faces=[tuple(range(nb-1,-1,-1)),tuple(range(nb,nb+nt)),(0,1,nb+1,nb),(1,2,nb+2,nb+1),(nb-1,0,nb,nb+nt-1)]
 # Stitch the differently sampled front rims explicitly. A nonplanar front
 # ngon can lose boundary vertices during Blender's projected tessellation.
 i,j=2,2
 while i<nb-1 or j<nt-1:
  advance_bottom=i<nb-1 and (j==nt-1 or bottom[i+1][0]>=top[j+1][0])
  if advance_bottom:faces.append((i,i+1,nb+j));i+=1
  else:faces.append((i,nb+j+1,nb+j));j+=1
 me=bpy.data.meshes.new(kind+'_strip_'+var+'_lite');me.from_pydata([C@Vector(v) for v in verts],[],faces);me.update()
 ob=bpy.data.objects.new(kind+'_strip_'+var+'_lite',me);bpy.context.collection.objects.link(ob)
 uv=me.uv_layers.new(name='UVMap')
 for p in me.polygons:
  for li in p.loop_indices:
   vi=me.loops[li].vertex_index;x,y,z=verts[vi];u=(x+w/2)/w
   if p.index==1:v=.14+.84*(z-zmin)/L
   elif p.index==0:v=.012+.025*(z-zmin)/L
   else:
    v=.065+.045*((y-slope*z)/thickness)
    xs=[verts[vj][0] for vj in p.vertices]
    if max(xs)-min(xs)<1e-6:u=(z-zmin)/L
   if var=='b':u=1-u
   uv.data[li].uv=(u,v)
 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()
 assert len(me.polygons)<=(48 if kind=='tile' else 32),(kind,len(me.polygons))
 bm=bmesh.new();bm.from_mesh(me);assert all(e.is_manifold for e in bm.edges);assert bm.calc_volume()>0;bm.free()
 return ob

# Orthographic ray/triangle projection onto strip coordinates. This transfers
# actual delivered relief and per-tile material UVs, not noise shaped like tiles.
def bake(kind):
 ob=high[kind+'_strip_a'];me=ob.data;me.calc_loop_triangles()
 w,L=(1.32,.265) if kind=='tile' else (1.38,.424);zmin=0 if kind=='tile' else -.002
 X=(np.arange(N)+.5)/N*w-w/2;Z=((np.arange(N)+.5)/N-.14)/.84*L+zmin
 height=np.full((N,N),-1.,np.float32);base=np.zeros((N,N,3),np.float32);orm=np.zeros_like(base);micro=np.zeros_like(base);hit=np.zeros((N,N),bool)
 pos=np.array([C.inverted()@v.co for v in me.vertices]);uv=me.uv_layers.active
 textures={}
 for i,m in enumerate(me.materials):
  if not m:continue
  original=m.name.replace('_reference','')
  if original in shelves[kind]:textures[i]={k:load_pixels(SOURCES[kind]/p) for k,p in shelves[kind][original]['maps'].items()}
 for tri in me.loop_triangles:
  vs=pos[list(tri.vertices)];a,b,c=vs[:,[0,2]];den=(b[1]-c[1])*(a[0]-c[0])+(c[0]-b[0])*(a[1]-c[1])
  if abs(den)<1e-10:continue
  ix=np.where((X>=vs[:,0].min())&(X<=vs[:,0].max()))[0];iz=np.where((Z>=vs[:,2].min())&(Z<=vs[:,2].max()))[0]
  if not len(ix) or not len(iz):continue
  xx,zz=np.meshgrid(X[ix],Z[iz]);p=((b[1]-c[1])*(xx-c[0])+(c[0]-b[0])*(zz-c[1]))/den;q=((c[1]-a[1])*(xx-c[0])+(a[0]-c[0])*(zz-c[1]))/den;r=1-p-q
  hh=p*vs[0,1]+q*vs[1,1]+r*vs[2,1];region=np.ix_(iz,ix);mask=(p>=-1e-6)&(q>=-1e-6)&(r>=-1e-6)&(hh>height[region])
  if not mask.any():continue
  height[region]=np.where(mask,hh,height[region]);hit[region]|=mask
  ts=np.array([uv.data[l].uv[:] for l in tri.loops]);uu=p*ts[0,0]+q*ts[1,0]+r*ts[2,0];vv=p*ts[0,1]+q*ts[1,1]+r*ts[2,1]
  for key,target in [('BaseColor',base),('ORM',orm),('Normal',micro)]:
   tex=textures[tri.material_index][key];tx=(uu%1*tex.shape[1]).astype(int)%tex.shape[1];ty=(vv%1*tex.shape[0]).astype(int)%tex.shape[0];s=tex[ty,tx];reg=target[region];reg[mask]=s[mask];target[region]=reg
 # Dilation preserves fine gaps with nearest registered texture; cavity depth is
 # limited before differentiation to avoid nearly horizontal normals at seams.
 original_hit=hit.copy();filled=hit.copy()
 for _ in range(32):
  old=filled.copy()
  for axis,shift in [(0,1),(0,-1),(1,1),(1,-1)]:
   take=(~filled)&np.roll(old,shift,axis)
   for target in [height,base,orm,micro]:target[take]=np.roll(target,shift,axis)[take]
   filled[take]=True
 gap=(~original_hit)&filled
 # Stop silhouette skirts from becoming deep black lines; actual joints retained.
 height[gap]-=.0012
 dz,dx=np.gradient(height,L/(.84*N),w/N);slope=.16 if kind=='tile' else 0
 geom=np.stack([-dx,-(dz-slope),np.ones_like(dx)],axis=-1)
 m=micro*2-1;geom[:,:,:2]+=m[:,:,:2]/np.maximum(m[:,:,2:3],.3);geom/=np.maximum(np.linalg.norm(geom,axis=-1,keepdims=True),1e-8)
 normal=geom*.5+.5
 # Bake a narrow contact cavity at original internal tile boundaries.
 spacing=w/(8 if kind=='tile' else 4)
 seam_distance=np.minimum((X+w/2)%spacing,spacing-(X+w/2)%spacing)
 seam=np.exp(-(seam_distance/(.0013 if kind=='tile' else .0010))**2)[None,:,None]
 base*=1-(.60 if kind=='tile' else .16*np.clip((Z-zmin)/L,0,1)[:,None,None]**3)*seam
 base[gap]*=.68;orm[:,:,0]=np.maximum(.62,orm[:,:,0]);orm[gap,0]*=.86
 # Narrow edge and underside charts use a longitudinal source strip sample,
 # neutral relief: these are real thick slab edges, not top-surface repeats.
 row=int(.90*N)
 for lo,hi in [(0,.125)]:
  end=int(hi*N);base[:end]=base[row][None,:,:];orm[:end]=orm[row][None,:,:];normal[:end]=(.5,.5,1)
 base[~filled]=np.median(base[original_hit],axis=0);orm[~filled]=(.9,.8,0);normal[~filled]=(.5,.5,1)
 paths={k:ROOT/'materials'/NAMES[kind]/(k+'.png') for k in ['BaseColor','Normal','ORM']}
 for k,a in [('BaseColor',base),('Normal',normal),('ORM',orm)]:png(paths[k],a)
 out('BAKED '+kind+' coverage '+str(float(original_hit[int(.15*N):int(.97*N)].mean())))
 return shader(NAMES[kind],paths)

def cycles_normal(kind,receiver,mat):
 source=high[kind+'_strip_a'];source.hide_set(False);source.hide_render=False
 bpy.ops.object.select_all(action='DESELECT');source.select_set(True);receiver.select_set(True);bpy.context.view_layer.objects.active=receiver
 im=bpy.data.images.new('Projected '+kind,width=N,height=N,alpha=False,float_buffer=True);im.colorspace_settings.name='Non-Color'
 tex=mat.node_tree.nodes.new('ShaderNodeTexImage');tex.image=im;mat.node_tree.nodes.active=tex
 scene.render.bake.use_selected_to_active=True;scene.render.bake.cage_extrusion=.035;scene.render.bake.max_ray_distance=.08;scene.render.bake.margin=4
 scene.cycles.samples=8;bpy.ops.object.bake(type='NORMAL')
 a=np.array(im.pixels[:],np.float32).reshape(N,N,4)[:,:,:3]
 path=ROOT/'materials'/NAMES[kind]/'Normal.png';old=load_pixels(path)
 # Misses are black; preserve the height-derived, filtered seam transfer there.
 good=(a[:,:,2]>.2)&(np.arange(N)[:,None]>.14*N)
 old[good]=a[good];png(path,old)
 for node in mat.node_tree.nodes:
  if node.type=='TEX_IMAGE' and node.image and Path(node.image.filepath).name=='Normal.png':node.image.reload()
 mat.node_tree.nodes.remove(tex);bpy.data.images.remove(im);source.hide_render=True;source.hide_set(True)
 scene.render.bake.use_selected_to_active=False;scene.cycles.samples=32
 out('CYCLES_NORMAL_OK '+kind)

low={};entries=[];shelf_out={}
for kind in SOURCES:
 mat=bake(kind)
 for var in 'ab':
  o=make_low(kind,var);o.data.materials.append(mat);low[o.name]=o
  if var=='a':cycles_normal(kind,o,mat)
  o.hide_render=True;o.hide_set(True)
  old=next(a for a in source_docs[kind]['atoms'] if a['node_name']==kind+'_strip_'+var);entry=dict(old);entry.update(node_name=o.name,replaces=old['node_name'],source_kit=SOURCES[kind].name,triangles=len(o.data.polygons),triangle_budget=48 if kind=='tile' else 32,materials=[NAMES[kind]])
  pts=np.array([C.inverted()@v.co for v in o.data.vertices]);entry['bounding_box']={'min':pts.min(0).round(7).tolist(),'max':pts.max(0).round(7).tolist()}
  entry['size_metres']=(pts.max(0)-pts.min(0)).round(7).tolist()
  entry['roof_placement']=('Rotate X by +roof pitch; heads every 0.10 m downslope on common roof plane; built-in 0.16 m/m lap tilt. X span 1.32 m.' if kind=='tile' else 'Course origins follow roof plane at 0.21 m gauge. Rotate X by roof pitch minus atan(0.07); offset all origins 0.018 m along roof normal; stagger X by 0.1725 m. No accumulating course height.')
  entry['uv_rule']='Exception to world-scale UVs: one strip per UV0 0..1; top chart U=0..1,V=.14..98; padded edge/underside charts V<.125. B mirrors U for variation. Do not apply metre repeat.'
  entries.append(entry)
 shelf_out[NAMES[kind]]={'tile_size_metres':1.32 if kind=='tile' else 1.38,'tile_size_metres_xy':[1.32,.265] if kind=='tile' else [1.38,.424],'tile_unit':'one complete strip; atlas includes edge charts','maps':{k:f'materials/{NAMES[kind]}/{k}.png' for k in ['BaseColor','Normal','ORM']},'resolution':1024,'color_space':{'BaseColor':'sRGB','Normal':'linear','ORM':'linear'},'wrap':'REPEAT','normal_convention':'OpenGL tangent +Y','ORM_channels':{'R':'occlusion','G':'roughness','B':'metallic'},'roughness_factor':1,'metalness_factor':1,'derived_from':'Clay_tile (+ Clay_tile_dark tile variation)' if kind=='tile' else 'Blue_grey_slate','uv_rule':entry['uv_rule']}
for o in high.values():o.hide_render=True;o.hide_set(True)
bpy.ops.object.select_all(action='DESELECT')
for o in low.values():o.hide_set(False);o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'kit_lite.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_tangents=True,export_cameras=False,export_lights=False)
(ROOT/'kit_lite.json').write_text(json.dumps({'schema':'forge.atom-kit.v2','units':'metres','up':'+Y','facade_outward':'+Z','facade_horizontal':'+X','uv_rule':entries[0]['uv_rule'],'atoms':entries,'source_sha256':source_hashes},indent=2))
(ROOT/'materials_lite.json').write_text(json.dumps({'schema':'forge.material-shelf.v1','materials':shelf_out,'runtime_binding':'Exact name replacement; BaseColor sRGB, Normal/ORM linear, flipY=false. UV0 is already a strip atlas. No world-scale repeat. Roughness and metalness factors = 1. GLB embeds the same maps.','provenance':{'source':'Read-only source kit geometry and registered shelf maps','license':'Inherited Poly Haven CC0 shelf','normal_bake':'Cycles selected-to-active tangent normal transfer from original meshes and shelf normals, cage extrusion 0.035 m, max ray 0.08 m, 4 px margin. Missed gaps use orthographic geometric height-gradient projection with 1.2 mm filtered cavity depth. BaseColor/ORM use highest-surface barycentric source UV transfer.'}},indent=2))
out('EXPORT_OK')

# Validate saved artifact by reimporting it; comparison uses serialized LOW.
for o in low.values():bpy.data.objects.remove(o,do_unlink=True)
bpy.data.node_groups['glTF Material Output'].name='Baked Occlusion Export'
before=set(bpy.data.objects);bpy.ops.import_scene.gltf(filepath=str(ROOT/'kit_lite.glb'));low={o.name:o for o in set(bpy.data.objects)-before if o.type=='MESH'}
for o in low.values():
 o.data.transform(o.matrix_world);o.matrix_world=Matrix.Identity(4);o.hide_render=True;o.hide_set(True)
scene.world.use_nodes=True;scene.world.node_tree.nodes['Background'].inputs[0].default_value=(.21,.25,.32,1);scene.world.node_tree.nodes['Background'].inputs[1].default_value=.55
bpy.ops.object.light_add(type='SUN',location=(2,-4,7));sun=bpy.context.object;sun.rotation_euler=(math.radians(25),math.radians(-30),math.radians(-25));sun.data.energy=2.6;sun.data.angle=.07
bpy.ops.object.light_add(type='AREA',location=(-3,-4,6));fill=bpy.context.object;fill.data.energy=250;fill.data.shape='DISK';fill.data.size=5
bpy.ops.object.camera_add();camera=bpy.context.object;scene.camera=camera;camera.data.lens=32;camera.data.sensor_width=36
scene.render.resolution_x=1400;scene.render.resolution_y=800;scene.render.resolution_percentage=100
renders=[];camera_records=[]
for kind,pitch in [('tile',55),('slate',44)]:
 for distance in [3,10]:
  copies=[];w=1.32 if kind=='tile' else 1.38;gauge=.1 if kind=='tile' else .21;courses=9 if kind=='tile' else 5
  R=Matrix.Rotation(math.radians(pitch),4,'X');normal=R@Vector((0,1,0));cant=0 if kind=='tile' else math.atan(.07)
  for side in [0,1]:
   for row in range(courses):
    name=kind+'_strip_'+('a' if row%2==0 else 'b')+('' if side==0 else '_lite');src=high[name] if side==0 else low[name]
    o=bpy.data.objects.new('patch',src.data);bpy.context.collection.objects.link(o);copies.append(o)
    loc=Vector(((side-.5)*(w+.18)+(.1725*(row%2) if kind=='slate' else 0),0,row*gauge));loc=R@loc+(normal*.018 if kind=='slate' else Vector((0,0,0)))
    o.matrix_world=C@Matrix.Translation(loc)@Matrix.Rotation(math.radians(pitch)-cant,4,'X')@C.inverted()
  pts=[o.matrix_world@Vector(p) for o in copies for p in o.bound_box];target=sum(pts,Vector())/len(pts)
  direction=Vector((.06,-.88,.47)).normalized();camera.location=target+direction*distance;camera.rotation_euler=(target-camera.location).to_track_quat('-Z','Y').to_euler();fill.rotation_euler=(target-fill.location).to_track_quat('-Z','Y').to_euler()
  # Identical fixed lens for true angular size at 3m and 10m.
  path=ROOT/f'compare_{kind}_{distance}m.png';scene.render.filepath=str(path);bpy.ops.render.render(write_still=True);renders.append((kind,pitch,distance,path));camera_records.append({'kind':kind,'pitch_degrees':pitch,'distance_metres':distance,'lens_mm':32,'target':list(target),'camera':list(camera.location)})
  for o in copies:bpy.data.objects.remove(o,do_unlink=True)
# PNG contact sheet assembled with Blender pixels, plus vector-free bitmap labels.
W,H=1400,800;sheet=np.full((H*4+56*4,W,3),.045,np.float32)
# simple text via Blender's bundled fonts is rendered in separate overlay scene
for i,(kind,pitch,distance,path) in enumerate(renders):
 a=load_pixels(path);y=(3-i)*(H+56);sheet[y:y+H]=a
png(ROOT/'compare.png',sheet)
# Save camera data and individual frames; annotate sheet with standard-library SVG
# text rasterized in a tiny Blender orthographic text scene, same clean script.
for o in list(bpy.data.objects):bpy.data.objects.remove(o,do_unlink=True)
scene.world.node_tree.nodes['Background'].inputs[0].default_value=(.045,.045,.045,1);scene.world.node_tree.nodes['Background'].inputs[1].default_value=1
scene.render.film_transparent=True
label_mat=bpy.data.materials.new('label');label_mat.use_nodes=True;nt=label_mat.node_tree;nt.nodes.clear();em=nt.nodes.new('ShaderNodeEmission');em.inputs[0].default_value=(.85,.87,.9,1);output=nt.nodes.new('ShaderNodeOutputMaterial');nt.links.new(em.outputs[0],output.inputs[0])
for i,(kind,pitch,distance,path) in enumerate(renders):
 curve=bpy.data.curves.new('label','FONT');curve.body=f'{kind.upper()}  |  {pitch} deg roof  |  {distance} m  |  ORIGINAL (left)     /     LITE (right)';curve.size=22;ob=bpy.data.objects.new('label',curve);bpy.context.collection.objects.link(ob);ob.location=(25,(3-i)*(H+56)+H+15,0);curve.materials.append(label_mat)
bpy.ops.object.camera_add(location=(W/2,(H+56)*2,10));scene.camera=bpy.context.object;scene.camera.data.type='ORTHO';scene.camera.data.ortho_scale=(H+56)*4;scene.render.resolution_x=W;scene.render.resolution_y=(H+56)*4;scene.cycles.samples=1;scene.render.filepath=str(ROOT/'labels.png');bpy.ops.render.render(write_still=True)
im=bpy.data.images.load(str(ROOT/'labels.png'));a=np.array(im.pixels[:],np.float32).reshape((H+56)*4,W,4);sheet=sheet*(1-a[:,:,3:])+a[:,:,:3]*a[:,:,3:];png(ROOT/'compare.png',sheet)
(ROOT/'build_evidence.json').write_text(json.dumps({'elapsed_seconds':time.time()-START,'blender':bpy.app.version_string,'render_engine':'Cycles','device':scene.cycles.device,'cameras':camera_records,'triangles':{e['node_name']:e['triangles'] for e in entries},'source_sha256':source_hashes},indent=2))
out('BUILD_COMPLETE '+str(time.time()-START))

# Recheck read-only inputs and emit the rebuild / integration notes in this run.
for path,digest in source_hashes.items():
 assert hashlib.sha256(Path(path).read_bytes()).hexdigest()==digest, 'Source changed during build'
(ROOT/'labels.png').unlink(missing_ok=True)
(ROOT/'NOTES.md').write_text("""# Roof strips lite v1

Four closed, manifold strip slabs replace the matching source nodes without
changing their head-midpoint origins, X/Z footprints, axis convention or course
placement. Metres, +Y up, +X strip width, +Z downslope before roof rotation.

| Node | Original triangles | Lite triangles | Material |
| --- | ---: | ---: | --- |
| tile_strip_a_lite | 224 | 48 | Clay_tile_strip |
| tile_strip_b_lite | 224 | 48 | Clay_tile_strip |
| slate_strip_a_lite | 152 | 32 | Blue_grey_slate_strip |
| slate_strip_b_lite | 152 | 32 | Blue_grey_slate_strip |

Clay geometry falls 78.6%; slate 78.9%. At 390 clay strips, roof strip geometry
falls from 87,360 to 18,720 triangles. Each strip has one material primitive.
These counts exclude the rest of a house; this is not a mobile FPS certification.

## Geometry and placement

The stepped clay slab retains the 0.16 m/m lap tilt and 0.10 m course gauge.
Rotate X by the existing roof pitch. Slate retains the 0.21 m gauge, existing
rotation of pitch minus atan(0.07), 0.018 m common normal offset and alternating
0.1725 m X stagger. Never accumulate the normal offset per course.
Actual bounds and inherited placement fields are recorded in kit_lite.json.
Camber and individual tile height variation move into tangent normals, so the
Y envelope is intentionally simplified. Lower rim vertices are recessed below
the notched upper lip; closed undersides and ends remain real geometry.

## Shelf and UV exception

Both new shelf materials are explicit exceptions to world-scale UV mapping.
UV0 spans one complete strip, U 0..1, with the top in V .14..98 and padded
edge/underside charts below .125. Do not multiply UVs by metres or by the old
shelf repeat. B mirrors U to vary the common atlas. Its texture is a mirrored A
bake, rather than a separate pixel-exact bake of the original B variation.

Six new 1024x1024 PNG maps are in materials/. BaseColor is sRGB; Normal and ORM
are linear. Normal is OpenGL tangent +Y; ORM is R=AO, G=roughness, B=metallic.
Use roughnessFactor=1 and metallicFactor=1 so packed channels control both.
GLB embeds the same maps and tangents. External Three.js replacement textures
use flipY=false. Shelf provenance is inherited Poly Haven CC0 from source kits.

The bake transfers the original top surfaces and registered shelf textures:
Cycles selected-to-active tangent normals (including shelf micro-normal),
0.035 m cage extrusion, 0.08 m ray distance, 4 px margin. Missed narrow seams
use a filtered geometric height projection. Color/ORM use highest-surface
barycentric source UV sampling. Narrow baked cavity shading preserves joints.
The original kit GLBs have preview materials; comparison explicitly binds their
registered shelf maps before rendering.

## Comparison and limits

compare.png contains four Cycles views: clay at 55 degrees and slate at 44
degrees, each at 3 m and 10 m. Original is left, lite right. Cameras use the
same 32 mm lens; 10 m is not zoomed to match 3 m. Distance is measured from
the camera to the paired patches' common centre. build_evidence.json records
exact camera positions and targets. Individual full-size frames are included.
The lite objects in these renders are reimported from the saved GLB.

The broad material, joint rhythm and roof layering are retained at these
distances. At 3 m, close comparison still reveals more regular lip thickness
and fewer tiny chips in lite; grazing views cannot reproduce high-poly
parallax with a normal map. The simplified silhouette is intended for roofs
viewed from 3 m and beyond, not macro inspection. No alpha cutouts are used.

## Rebuild

Place this directory beside medieval-atom-kit-v1 and breton-atom-kit-v2, each
containing kit.glb, kit.json, materials.json and materials/. Then run:

```sh
/root/blender-spike/blender-5.2.0-linux-x64/blender -b --python-exit-code 1 --python /root/fal-author/roof-strips-lite-v1/build_lite.py
```

One script generates all requested outputs using Blender 5.2 and bundled
NumPy, with no pip dependencies. It uses Cycles GPU when available. Source
files are read only, source GLB hashes are recorded and rechecked, and every
output stays in this directory. Build-time assertions check triangle budgets,
manifold edges and positive solid volume. No deployment is performed.
""")
