"""Breton fisherman's house. Blender 5.2, metres; deterministic seed 731.
Run: blender -b --python-exit-code 1 --python build_breton-stone-house.py
Downloads CC0 Poly Haven maps with curl, bakes authored material swatches in Cycles,
builds from an empty scene, exports GLB, reimports it, and renders delivery views.
Optional -- --preflight performs a tiny bake/export/import before the full run.
"""
import bpy, bmesh, math, random, json, os, sys, subprocess, time
from pathlib import Path
from mathutils import Vector, Matrix
from mathutils.noise import noise_vector
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; BAKES=ROOT/'baked'; TEX.mkdir(exist_ok=True); BAKES.mkdir(exist_ok=True)
random.seed(731)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for data in list(bpy.data.materials): bpy.data.materials.remove(data)
scene=bpy.context.scene; scene.render.engine='CYCLES'; scene.cycles.samples=24
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.unit_settings.scale_length=1
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX'
IDS=['rock_wall_09','worn_rock_natural_01','wooden_planks','metal_plate_02','slate_floor_03']
for ident in IDS:
 for kind in (['diff','nor_gl','arm','disp'] if ident=='rock_wall_09' else ['diff','nor_gl','arm']):
  p=TEX/f'{ident}_{kind}_1k.jpg'
  if not p.exists(): subprocess.run(['curl','--retry','3','-fLsS','-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,typ): return nt.nodes.new(typ)
def mathnode(nt,op,a,b=None):
 n=node(nt,'ShaderNodeMath'); n.operation=op
 if hasattr(a,'node'): nt.links.new(a,n.inputs[0])
 else:n.inputs[0].default_value=a
 if b is not None:
  if hasattr(b,'node'):nt.links.new(b,n.inputs[1])
  else:n.inputs[1].default_value=b
 return n.outputs[0]
def mix(nt,fac,a,b,mode='MIX'):
 n=node(nt,'ShaderNodeMixRGB');n.blend_type=mode
 for val,inp in zip([fac,a,b],n.inputs):
  if hasattr(val,'node'):nt.links.new(val,inp)
  else:inp.default_value=val
 return n.outputs[0]
def image_node(nt,path,uv,noncolor=False):
 n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(str(path),check_existing=True)
 if noncolor:n.image.colorspace_settings.name='Non-Color'
 nt.links.new(uv,n.inputs['Vector']);return n

def material(name,ident,tint,size=1024,metal=0,rough=.8,normal=.6,zone=False,wood=False):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();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 wood:
  mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';nt.links.new(uv,mapping.inputs[0]);mapping.inputs[1].default_value=(.7,.034,1)
  source_uv=mapping.outputs[0]
 diff=image_node(nt,TEX/f'{ident}_diff_1k.jpg',source_uv)
 bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(diff.outputs['Color'],bw.inputs[0])
 value=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',bw.outputs[0],1.65),.24)
 color=mix(nt,1,value,(*tint,1),'MULTIPLY')
 noise=node(nt,'ShaderNodeTexNoise');nt.links.new(uv,noise.inputs['Vector']);noise.inputs['Scale'].default_value=19;noise.inputs['Detail'].default_value=4
 large=node(nt,'ShaderNodeTexNoise');nt.links.new(uv,large.inputs['Vector']);large.inputs['Scale'].default_value=4.5;large.inputs['Detail'].default_value=3
 color=mix(nt,1,color,mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',large.outputs['Fac'],.8),.55),'MULTIPLY')
 patina=mathnode(nt,'MULTIPLY',noise.outputs['Fac'],.10)
 color=mix(nt,patina,color,(.14,.12,.075,1))
 if zone and zone!='wall':
  sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv,sep.inputs[0])
  falloff=mathnode(nt,'MAXIMUM',mathnode(nt,'SUBTRACT',.47,sep.outputs['Y']),0)
  patch=mathnode(nt,'MAXIMUM',mathnode(nt,'SUBTRACT',noise.outputs['Fac'],.40),0)
  moss=mathnode(nt,'MINIMUM',mathnode(nt,'MULTIPLY',mathnode(nt,'MULTIPLY',falloff,patch),10),.86)
  mosscolor=mix(nt,noise.outputs['Fac'],(.038,.053,.016,1),(.16,.18,.071,1))
  color=mix(nt,moss,color,mosscolor)
  mortar=mathnode(nt,'GREATER_THAN',sep.outputs['Y'],1.1 if zone=='wall' else .92)
  mortarcolor=mix(nt,noise.outputs['Fac'],(.23,.222,.195,1),(.39,.375,.32,1))
  color=mix(nt,mortar,color,mortarcolor)
 if zone=='wall':
  hue=node(nt,'ShaderNodeHueSaturation');nt.links.new(diff.outputs['Color'],hue.inputs['Color']);hue.inputs['Saturation'].default_value=.28
  contrast=node(nt,'ShaderNodeBrightContrast');nt.links.new(hue.outputs[0],contrast.inputs['Color']);contrast.inputs['Contrast'].default_value=.12
  color=mix(nt,1,contrast.outputs[0],(1.3,1.32,1.29,1),'MULTIPLY')
  color=mix(nt,1,color,(.105,.103,.094,1),'ADD')
  heighttex=image_node(nt,TEX/f'{ident}_disp_1k.jpg',source_uv,True)
  limemask=mathnode(nt,'MINIMUM',mathnode(nt,'MAXIMUM',mathnode(nt,'MULTIPLY',mathnode(nt,'SUBTRACT',.60,heighttex.outputs['Color']),8),0),.92)
  lime=mix(nt,noise.outputs['Fac'],(.20,.197,.176,1),(.31,.30,.27,1))
  color=mix(nt,limemask,color,lime)
 nt.links.new(color,bs.inputs['Base Color'])
 arm=image_node(nt,TEX/f'{ident}_arm_1k.jpg',source_uv,True); sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(arm.outputs['Color'],sep.inputs[0])
 roughsock=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',sep.outputs['Green'],.22),rough-.15)
 nt.links.new(roughsock,bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 nor=image_node(nt,TEX/f'{ident}_nor_gl_1k.jpg',source_uv,True);norm=node(nt,'ShaderNodeNormalMap');nt.links.new(nor.outputs['Color'],norm.inputs['Color']);norm.inputs['Strength'].default_value=normal;nt.links.new(norm.outputs[0],bs.inputs['Normal'])
 # Each material result is baked on a shared tile, preserving original map registration.
 bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.name='BAKE_SWATCH';plane.data.materials.append(m)
 scene.cycles.samples=4;scene.render.bake.margin=4;scene.render.bake.use_clear=True
 bake_node=node(nt,'ShaderNodeTexImage')
 maps={}
 for kind in ['BaseColor','Normal','ORM']:
  img=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
  if kind!='BaseColor':img.colorspace_settings.name='Non-Color'
  bake_node.image=img;nt.nodes.active=bake_node;bake_node.select=True
  if kind=='Normal':
   nt.links.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   emission=node(nt,'ShaderNodeEmission')
   if kind=='BaseColor':nt.links.new(color,emission.inputs[0])
   else:
    rgb=node(nt,'ShaderNodeCombineColor');nt.links.new(mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',sep.outputs['Red'],.3),.7),rgb.inputs['Red']);nt.links.new(roughsock,rgb.inputs['Green']);rgb.inputs['Blue'].default_value=metal;nt.links.new(rgb.outputs[0],emission.inputs[0])
   nt.links.new(emission.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  img.filepath_raw=str(BAKES/f'{name}_{kind}.jpg');img.file_format='JPEG';scene.render.image_settings.quality=94;img.save();maps[kind]=img
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
 for kind,img in maps.items():
  n=node(nt,'ShaderNodeTexImage');n.image=img
  if kind=='BaseColor':nt.links.new(n.outputs['Color'],bs.inputs['Base Color'])
  elif kind=='Normal':
   nn=node(nt,'ShaderNodeNormalMap');nt.links.new(n.outputs['Color'],nn.inputs['Color']);nt.links.new(nn.outputs[0],bs.inputs['Normal'])
  else:
   sp=node(nt,'ShaderNodeSeparateColor');nt.links.new(n.outputs['Color'],sp.inputs[0]);nt.links.new(sp.outputs['Green'],bs.inputs['Roughness']);nt.links.new(sp.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(sp.outputs['Red'],gn.inputs['Occlusion'])
 print('BAKED',name,size,flush=True)
 return m

preflight='--preflight' in sys.argv
mats=[]
configs=[
 ('Granite_lime_moss','rock_wall_09',(.43,.44,.395),2048,0,.87,1.0,'wall',False),
 ('Dressed_granite','worn_rock_natural_01',(.53,.52,.448),1024,0,.8,.5,True,False),
 ('Blue_grey_slate','slate_floor_03',(.115,.175,.235),1024,0,.73,.6,False,False),
 ('Breton_blue_timber','wooden_planks',(.070,.178,.285),1024,0,.68,.32,False,True),
 ('Aged_ivory_frames','wooden_planks',(.57,.595,.53),512,0,.67,.22,False,True),
 ('Weathered_zinc_iron','metal_plate_02',(.18,.215,.22),512,.78,.51,.17,False,False),
 ('Old_window_glass','metal_plate_02',(.065,.105,.135),256,.55,.12,.008,False,False),
 ('Terracotta','worn_rock_natural_01',(.34,.15,.072),512,0,.78,.3,False,False)]
for cfg in configs[:1] if preflight else configs:
 c=list(cfg)
 if preflight:c[3]=64
 mats.append(material(*c))
if preflight:
 bpy.ops.mesh.primitive_cube_add();bpy.context.object.data.materials.append(mats[0]);bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB');bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False);bpy.data.node_groups['glTF Material Output'].__setattr__('name','Baked AO Settings');bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'));print('PREFLIGHT_OK',flush=True);sys.exit(0)

# Aggregate directly into the required rigid mesh nodes; no thousands of objects.
class Mesh:
 def __init__(self,name):self.name=name;self.v=[];self.f=[];self.mi=[];self.uv=[];self.sm=[]
 def add(self,verts,faces,mat,uvfun=None,smooth=False):
  off=len(self.v);self.v.extend([tuple(v) for v in verts])
  for face in faces:
   self.f.append(tuple(i+off for i in face));self.mi.append(mat);self.sm.append(smooth)
   # Per-face metre projection, mapped to deliberate dry/moss/mortar zones.
   n=(Vector(verts[face[1]])-Vector(verts[face[0]])).cross(Vector(verts[face[2]])-Vector(verts[face[0]]))
   axis=max(range(3),key=lambda k:abs(n[k]));ij=[k for k in range(3) if k!=axis]
   self.uv.append([uvfun(Vector(verts[i]),axis) if uvfun else (verts[i][ij[0]],verts[i][ij[1]]) for i 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.sm):
   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.remove_doubles(bm,verts=list(bm.verts),dist=.00001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free();me.update()
  return ob
G={n:Mesh(n) for n in ['Walls','Roof','Chimney_L','Chimney_R','Door','Shutters','Windows','Rainwater','Ironwork','Entry_fittings']}

def uvzone(mat,center,mode='dry',span=.15):
 a=random.uniform(.1,.90);b=random.uniform(.56,.76)
 if mode=='moss':b=random.uniform(.11,.29);span=min(span,.42)
 if mode=='mortar':b=.955;span=.012
 def uv(p,axis):
  ij=[k for k in range(3) if k!=axis]
  return (a+(p[ij[0]]-center[ij[0]])*span,b+(p[ij[1]]-center[ij[1]])*span)
 return uv

def box(group,center,size,mat,bevel=.015,rot=None,mode='dry'):
 bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
 for v in bm.verts:v.co=Vector((v.co.x*size[0],v.co.y*size[1],v.co.z*size[2]))
 if bevel:
  bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(bevel,min(size)*.25),segments=1,affect='EDGES')
 bm.verts.ensure_lookup_table();bm.verts.index_update()
 verts=[v.co.copy() for v in bm.verts];faces=[tuple(v.index for v in f.verts) for f in bm.faces];bm.free()
 if rot:verts=[rot@v for v in verts]
 verts=[v+Vector(center) for v in verts]
 G[group].add(verts,faces,mat,uvzone(mat,center,mode,1 if mat in [3,4] else .3))

def cyl(group,a,b,r,mat,n=10,r2=None):
 a=Vector(a);b=Vector(b);q=(b-a).to_track_quat('Z','Y');r2=r if r2 is None else r2
 verts=[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)]
 faces=[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[group].add(verts,faces,mat,uvzone(mat,(a+b)*.5,span=.8),smooth=True)

def tube(group,points,r,mat,n=10):
 for a,b in zip(points,points[1:]):cyl(group,a,b,r,mat,n)

def profile(group,poly,depth,mapf,mat,mode='dry'):
 verts=[mapf(u,d,z) for d in depth for u,z in poly];n=len(poly);faces=[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)]
 cen=tuple(sum(v[k] for v in verts)/len(verts) for k in range(3));G[group].add(verts,faces,mat,uvzone(mat,cen,mode,.22))

front=lambda u,d,z:(u,-3.5-d,z)
back=lambda u,d,z:(-u,3.5+d,z)
right=lambda u,d,z:(4.5+d,u,z)
left=lambda u,d,z:(-4.5-d,-u,z)
# Actual wall openings, expressed in surface coordinates.
front_holes=[(-.7,.7,.12,3.12),(-3.05,-1.65,1.12,3.22),(1.65,3.05,1.12,3.22)]
side_holes=[(.9,1.72,1.35,2.54),(.9,1.72,5.03,6.13)]
back_holes=[(-2.7,-1.5,1.2,3.1),(1.5,2.7,1.2,3.1)]

def subtract_intervals(a,b,z0,z1,holes):
 parts=[(a,b)]
 for x0,x1,h0,h1 in holes:
  if z0<h1 and z1>h0:
   nextp=[]
   for s,e in parts:
    if e<=x0 or s>=x1:nextp.append((s,e))
    else:
     if s<x0:nextp.append((s,x0))
     if e>x1:nextp.append((x1,e))
   parts=nextp
 return parts

def wall(mapf,width,holes,gable=False):
 # Mortar and solid wall thickness, partition at every aperture height.
 levels=sorted(set([0,5.6]+[v for h in holes for v in h[2:] if v<5.6]))
 for z0,z1 in zip(levels,levels[1:]):
  for a,b in subtract_intervals(-width/2,width/2,z0+.0001,z1-.0001,holes):profile('Walls',[(a,z0),(b,z0),(b,z1),(a,z1)],(-.38,.002),mapf,0,'mortar')
 if gable:
  # Triangle above eaves; aperture excluded via a rectangular notch plus flanking trapezoids.
  zs=sorted(set([5.6,9.0]+[h[3] for h in holes if h[3]>5.6]))
  for z0,z1 in zip(zs,zs[1:]):
   w0=3.5*(9-z0)/3.4;w1=3.5*(9-z1)/3.4
   for a,b in subtract_intervals(-w0,w0,z0+.0001,z1-.0001,holes):
    at=max(a,-w1);bt=min(b,w1)
    if bt>at:poly=[(a,z0),(b,z0),(bt,z1),(at,z1)]
    elif w1<1e-5:poly=[(a,z0),(b,z0),(0,9)]
    else:continue
    profile('Walls',poly,(-.38,.002),mapf,0,'mortar')
 # The registered CC0 wall scan supplies actual rubble bedding; relief is carried
 # by a dense, welded surface, with the same image coordinates as the normal map.
 # This avoids inventing uniformly bevelled blocks or arbitrary Voronoi paving.
 import numpy as np
 dispimage=bpy.data.images.load(str(TEX/'rock_wall_09_disp_1k.jpg'),check_existing=True)
 dispimage.colorspace_settings.name='Non-Color'
 W,H=dispimage.size
 pixels=np.asarray(dispimage.pixels[:],dtype=np.float32).reshape(H,W,4)[:,:,0]
 repeat=2.25
 def relief(u,z):
  x=(u/repeat%1)*(W-1);y=(z/repeat%1)*(H-1);ix=int(x);iy=int(y);fx=x-ix;fy=y-iy
  val=(pixels[iy,ix]*(1-fx)+pixels[iy,min(ix+1,W-1)]*fx)*(1-fy)+(pixels[min(iy+1,H-1),ix]*(1-fx)+pixels[min(iy+1,H-1),min(ix+1,W-1)]*fx)*fy
  return .006+float(val)*.105
 ex=Vector(mapf(1,0,0))-Vector(mapf(0,0,0));base=Vector(mapf(0,0,0))
 def surfaceuv(p,axis):return ((p-base).dot(ex)/repeat,p.z/repeat)
 height=9 if gable else 5.6
 levels=sorted(set([0,height,5.6]+[v for hole in holes for v in hole[2:] if 0<v<height]))
 for lo,hi in zip(levels,levels[1:]):
  steps=max(1,math.ceil((hi-lo)/.118))
  for j in range(steps):
   z0=lo+(hi-lo)*j/steps;z1=lo+(hi-lo)*(j+1)/steps
   w0=width/2 if z0<=5.6 else max(0,3.5*(9-z0)/3.4)
   w1=width/2 if z1<=5.6 else max(0,3.5*(9-z1)/3.4)
   for a,b in subtract_intervals(-w0,w0,z0+.00001,z1-.00001,holes):
    at=max(a,-w1);bt=min(b,w1)
    if bt<at:continue
    # Fixed columns keep adjacent relief rows welded, including sloping gables.
    grid=[-width/2+k*.118 for k in range(math.ceil(width/.118)+1)]+[v for hole in holes for v in hole[:2]]
    cuts=sorted(set([a,b]+[u for u in grid if a<u<b]))
    for u0,u1 in zip(cuts,cuts[1:]):
     t0=max(at,min(bt,u0));t1=max(at,min(bt,u1))
     coords=[(u0,z0),(u1,z0),(t1,z1),(t0,z1)]
     if abs(t1-t0)<1e-6:coords=coords[:3]
     verts=[mapf(u,relief(u,z),z) for u,z in coords]
     G['Walls'].add(verts,[tuple(range(len(verts)))],0,surfaceuv,smooth=True)

wall(front,9,front_holes);wall(back,9,back_holes);wall(right,7,side_holes,True);wall(left,7,side_holes,True)
# Alternating dressed granite quoins, toothed into rubble.
for x in [-1,1]:
 for y in [-1,1]:
  for i in range(14):
   h=.398;z=(i+.5)*.4
   a,b=(.60,.35) if i%2 else (.35,.65)
   box('Walls',(x*(4.5-a/2+.11),y*(3.5-b/2+.11),z),(a,b,h-.012),1,.026,mode='moss' if i<2 else 'dry')

# Window construction in a local facade basis (u along facade, d outward).
def localbox(group,mapf,u,d,z,w,t,h,mat,bev=.015):
 c=Vector(mapf(u,d,z));ex=Vector(mapf(u+1,d,z))-c;ey=Vector(mapf(u,d+1,z))-c;ez=Vector((0,0,1));rot=Matrix((ex,ey,ez)).transposed()
 box(group,c,(w,t,h),mat,bev,rot)
def window(mapf,u,z,w,h,shutters=False):
 # Recess is behind stone face, stone jambs and drip sill are proud.
 for s in [-1,1]:
  for k in range(3):localbox('Walls',mapf,u+s*(w/2+.13),.01,z+(k+.5)*h/3,.25,.30,h/3-.014,1,.020)
 localbox('Walls',mapf,u,.035,z+h+.13,w+.55,.34,.25,1,.025)
 localbox('Walls',mapf,u,.09,z-.105,w+.62,.47,.21,1,.022)
 localbox('Windows',mapf,u,-.16,z+h/2,w,.045,h,6,.005)
 for s in [-1,1]:localbox('Windows',mapf,u+s*(w/2-.055),-.105,z+h/2,.09,.13,h,4,.008)
 for zz in [z+.045,z+h-.045]:localbox('Windows',mapf,u,-.105,zz,w,.13,.09,4,.008)
 localbox('Windows',mapf,u,-.08,z+h/2,.065,.15,h-.14,4,.007)
 for zz in [z+h/3,z+2*h/3]:localbox('Windows',mapf,u,-.075,zz,w-.16,.08,.04,4,.006)
 # Interior depth cue behind glazing: slender curtain edges, no fake painted mullions.
 if shutters:
  for s in [-1,1]:
   sw=.49;su=u+s*(w/2+.40)
   for k in range(3):localbox('Shutters',mapf,su+(k-1)*sw/3,.13,z+h/2,sw/3-.007,.085,h+.08,3,.009)
   for zz in [z+.25,z+h-.25]:
    localbox('Shutters',mapf,su,.19,zz,sw+.025,.047,.095,3,.007)
    localbox('Ironwork',mapf,su,.225,zz,sw*.65,.022,.035,5,.004)
    for off in [-.14,.14]:
     a=mapf(su+off,.243,zz);b=mapf(su+off,.25,zz);cyl('Ironwork',a,b,.018,5,8)
   localbox('Ironwork',mapf,su-s*.2,.22,z+.13,.035,.04,.16,5,.006)
for u in [-2.35,2.35]:window(front,u,1.12,1.4,2.1,True)
for mp in [right,left]:
 for z,h in [(1.35,1.19),(5.03,1.1)]:window(mp,1.31,z,.82,h)
for u in [-2.1,2.1]:window(back,u,1.2,1.2,1.9,True)
# Entrance portal, coursed jambs and a stepped cornice.
for s in [-1,1]:
 for i in range(6):localbox('Walls',front,s*.86,.09,.26+i*.49,.32 if i%2 else .42,.40,.476,1,.026)
localbox('Walls',front,0,.1,3.20,2.10,.44,.29,1,.028)
localbox('Walls',front,0,.16,3.40,2.27,.53,.13,1,.02)
localbox('Walls',front,0,.18,3.51,2.35,.59,.10,1,.016)
localbox('Walls',front,0,.36,.105,1.8,.90,.21,1,.035)
for s in [-1,1]:localbox('Walls',front,s*.68,-.07,1.62,.10,.16,2.91,4,.012)
localbox('Walls',front,0,-.07,3.035,1.4,.16,.13,4,.012)
for i in range(8):localbox('Door',front,-.595+i*.17,-.12,1.59,.165,.115,2.83,3,.008)
for zz in [.27,2.88]:localbox('Door',front,0,-.035,zz,1.36,.045,.12,3,.009)
# Small upper door light, framed at four sides.
localbox('Door',front,0,-.02,2.24,.28,.025,.48,6,.005)
for s in [-1,1]:localbox('Door',front,s*.18,.015,2.24,.06,.08,.60,5,.009)
for zz in [1.97,2.51]:localbox('Door',front,0,.015,zz,.40,.08,.06,5,.008)
localbox('Door',front,.47,.035,1.44,.07,.055,.24,5,.012)
cyl('Door',front(.47,.08,1.45),front(.47,.16,1.45),.026,5,12)
cyl('Door',front(.47,.16,1.45),front(.34,.16,1.45),.028,5,12)
for zz in [.53,2.61]:
 cyl('Door',front(-.68,-.01,zz-.10),front(-.68,-.01,zz+.10),.033,5,12)
 localbox('Door',front,-.48,-.005,zz,.4,.035,.048,5,.006)

# Main roof: 3.4 m rise, slate plates in staggered overlapping courses.
slope=3.4/3.65
# Underlay planes have thickness, dark visible fascia, no floating roof.
for sign in [-1,1]:
 for j in range(19):
  ya=3.77-j*.2;yb=max(0,ya-.2);y=sign*(ya+yb)/2;z=9-abs(y)*slope-.072
  half=min(.81,max(0,(abs(y)-1.005)/1.106));holes=[(x-half,x+half,0,1) for x in ([-2.35,2.35] if sign==-1 else [1.85])] if half>.005 else []
  for a,b in subtract_intervals(-4.65,4.65,.1,.9,holes):
   box('Roof',((a+b)/2,y,z),(b-a,(ya-yb)*math.sqrt(1+slope*slope)+.005,.085),2,.008,Matrix.Rotation(-sign*math.atan(slope),3,'X'))

def slate_plane(group,origin,U,V,N,width,length,mat=2,tw=.345,step=.21,skip=None):
 U=Vector(U);V=Vector(V);N=Vector(N);origin=Vector(origin)
 rows=math.ceil(length/step)
 for j in range(rows):
  v=j*step;lo=-width/2;offset=(j%2)*tw*.5
  cuts=[lo]+[lo+offset+i*tw for i in range(1,math.ceil(width/tw)+1) if lo+offset+i*tw<width/2]+[width/2]
  for a,b in zip(cuts,cuts[1:]):
   if b-a<.04:continue
   center=origin+U*((a+b)*.5)+V*(v+.12)
   if skip and skip(center):continue
   w=b-a-.008;l=min(step+.085,length-v+.025);zoff=.040+random.uniform(-.002,.002)
   # clipped front corners and subtle irregular thickness, 20 triangles per tile
   c=.014
   poly=[(a+.004+c,v),(b-.004-c,v),(b-.004,v+c),(b-.004,v+l),(a+.004,v+l),(a+.004,v+c)]
   verts=[origin+U*x+V*y+N*(zoff+d-.12*(y-v)) for d in [-.008,.008] for x,y in poly]
   n=6;faces=[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[group].add(verts,faces,mat,uvzone(mat,center,'dry',.28))
# Dormer roof/wall region is excluded, not layered over uncut slates.
for sign in [-1,1]:
 V=Vector((0,-sign, slope)).normalized();N=Vector((0,sign*slope,1)).normalized()
 def skip(p,sign=sign):
  xs=[-2.35,2.35] if sign==-1 else [1.85]
  return any(abs(p.x-x)<.80 and abs(p.y)>1.005+1.106*abs(p.x-x) for x in xs)
 slate_plane('Roof',(0,sign*3.77,5.49),(1,0,0),V,N,9.3,3.77*math.sqrt(1+slope*slope),skip=skip)
# Ridge semicircular terracotta units, hollow cross-section.
def ridgecap(x0,x1,y,z,group='Roof',r=.12):
 n=8;verts=[]
 for x in [x0,x1]:
  for rad in [r,r-.027]:
   verts.extend([(x,y+math.cos(i*math.pi/n)*rad,z+math.sin(i*math.pi/n)*rad) for i in range(n+1)])
 q=n+1;faces=[]
 for i in range(n):
  faces.extend([(i,i+1,2*q+i+1,2*q+i),(q+i,3*q+i,3*q+i+1,q+i+1),(i,q+i,q+i+1,i+1),(2*q+i,2*q+i+1,3*q+i+1,3*q+i)])
 faces.extend([(0,2*q,3*q,q),(n,q+n,3*q+n,2*q+n)])
 G[group].add(verts,faces,7,uvzone(7,(x0,y,z),span=.8))
for i in range(31):ridgecap(-4.63+i*.298,-4.63+i*.298+.290,0,9.045)
# Verge flashings on both gables.
for x in [-4.63,4.63]:
 for sign in [-1,1]:
  tube('Roof',[(x,sign*3.81,5.47),(x,0,9.085)],.047,5,8)

# Lucarnes. Their windows extend below the roof eave, as in the reference.
def dormer(x,sign):
 # Local coordinate d outward from facade plane; front dormers project a little beyond eave.
 mp=lambda u,d,z:(x+u,sign*(3.52+d),z)
 # Side cheek prisms meet the main roof at the back; solid, slate-clad.
 for sx in [-1,1]:
  xx=x+sx*.73
  verts=[(xx-.06,sign*3.49,5.35),(xx+.06,sign*3.49,5.35),(xx-.06,sign*3.49,7.315),(xx+.06,sign*3.49,7.315),(xx-.06,sign*1.81,7.315),(xx+.06,sign*1.81,7.315)]
  G['Roof'].add(verts,[(0,1,3,2),(2,3,5,4),(0,4,5,1),(0,2,4),(1,5,3)],2,uvzone(2,(xx,sign*3,6),span=.6))
 # stone face around recessed opening, and pediment
 for s in [-1,1]:localbox('Walls',mp,s*.68,0,6.23,.25,.35,1.95,1,.021)
 localbox('Walls',mp,0,.06,5.24,1.65,.48,.2,1,.02)
 localbox('Walls',mp,0,.025,7.12,1.58,.37,.24,1,.022)
 profile('Walls',[(-.8,7.15),(.8,7.15),(0,8.04)],(-.18,.08),mp,0)
 # gable infill stones, coursed without detached rubble
 for zz,ww in [(7.31,1.17),(7.57,.72),(7.8,.30)]:
  for i in range(max(1,round(ww/.27))):
   count=max(1,round(ww/.27));localbox('Walls',mp,-ww/2+(i+.5)*ww/count,.10,zz,ww/count-.018,.06,.20,0,.019)
 window(mp,0,5.35,1.08,1.62,False)
 # Roof runs back into main slope; transverse gable pitch.
 for s in [-1,1]:
  U=Vector((0,-sign,0));V=Vector((-s,0,1.03)).normalized();N=Vector((s*1.03,0,1)).normalized()
  origin=Vector((x+s*.90,sign*2.30,7.14))
  slate_plane('Roof',origin,U,V,N,3.05,1.27,tw=.27,step=.20,skip=lambda p: p.z < 9-abs(p.y)*slope-.055)
 # Lead-lined valleys are continuous closed strips tucked under both slate fields.
 for ss in [-1,1]:
  A=Vector((x,sign*1.005,8.08));B=Vector((x+ss*.94,sign*2.045,7.11))
  tangent=(B-A).normalized();normal=Vector((0,sign*slope,1)).normalized();cross=tangent.cross(normal).normalized()
  verts=[q+cross*w+normal*d for d in [-.025,0] for q in [A,B] for w in [-.13,.13]]
  G['Roof'].add(verts,[(0,1,3,2),(4,6,7,5),(0,4,5,1),(2,3,7,6),(0,2,6,4),(1,5,7,3)],5,uvzone(5,A,span=.5))
 # Dormer roof decking beneath slates, clipped exactly against main roof plane.
 for ss in [-1,1]:
  A=(x,sign*3.82,8.035);B=(x+ss*.91,sign*3.82,7.10);C=(x+ss*.91,sign*2.04,7.10);D=(x,sign*1.01,8.035)
  verts=[(xx,yy,zz+dz) for dz in [-.06,0] for xx,yy,zz in [A,B,C,D]]
  G['Roof'].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,uvzone(2,A,span=.5))
 # Ridge of dormer, laid along depth.
 for j in range(10):
  cyl('Roof',(x,sign*(3.79-j*.29),8.08),(x,sign*(3.79-j*.29-.28),8.08),.075,7,10)
 for s in [-1,1]:tube('Roof',[(x+s*.91,sign*3.82,7.13),(x,sign*3.82,8.11)],.040,5,8)
 # Iron Juliet rail attached to stone, with sockets and lower curled feet.
 for z in [5.47,6.03]:
  a=mp(-.62,.31,z);b=mp(.62,.31,z);cyl('Ironwork',a,b,.024,5,10)
 for i in range(9):
  u=-.59+i*.1475;cyl('Ironwork',mp(u,.31,5.46),mp(u,.31,6.04),.014,5,8)
  if i%2==0:
   points=[mp(u+.043*math.sin(t*math.tau/12),.31,5.75+.08*math.cos(t*math.tau/12)) for t in range(13)];tube('Ironwork',points,.008,5,6)
 for u in [-.63,.63]:
  for z in [5.46,6.03]:cyl('Ironwork',mp(u,.04,z),mp(u,.34,z),.024,5,10)
for x in [-2.35,2.35]:dormer(x,-1)
dormer(1.85,1)

# Chimneys sit on gables and bridge the ridge, with lead aprons, dressed courses, double hollow pots.
for x,name in [(-4.12,'Chimney_L'),(4.12,'Chimney_R')]:
 box(name,(x,0,9.23),(.78,.98,1.62),1,.008,mode='mortar')
 box(name,(x,0,9.08),(.87,1.17,.15),5,.028)
 for row in range(5):
  z=8.48+row*.31
  for j in range(2):
   box(name,(x+(j-.5)*.4,0,z),(.394,1.02,.297),1,.024,mode='moss' if row==4 else 'dry')
 # caps step out and shed water
 box(name,(x,0,10.00),(.91,1.16,.14),1,.025,mode='moss')
 box(name,(x,0,10.10),(1.02,1.26,.10),1,.018,mode='moss')
 for y in [-.32,.32]:
  n=16;verts=[]
  for z,r in [(10.15,.16),(10.47,.133),(10.50,.14),(10.50,.102),(10.19,.105)]:
   verts.extend([(x+r*math.cos(i*math.tau/n),y+r*math.sin(i*math.tau/n),z) for i in range(n)])
  faces=[]
  for ring in range(4):faces.extend([(ring*n+i,ring*n+(i+1)%n,(ring+1)*n+(i+1)%n,(ring+1)*n+i) for i in range(n)])
  faces.append(tuple(range(4*n,5*n)))
  G[name].add(verts,faces,7,uvzone(7,(x,y,10.2),span=.8))
# Semicircular zinc gutters, rolled front lip; downpipes and fastening collars.
for sign in [-1,1]:
 y=sign*3.84;z=5.47;n=12
 centers=[-2.35,2.35] if sign==-1 else [1.85]
 intervals=subtract_intervals(-4.77,4.77,0,1,[(c-.85,c+.85,-1,2) for c in centers])
 for start,endx in intervals:
  verts=[]
  for x in [start,endx]:
   for r in [.105,.089]:
    for i in range(n+1):
     a=math.pi+i*math.pi/n;verts.append((x,y+math.cos(a)*r,z+math.sin(a)*r))
  q=n+1;faces=[]
  for i in range(n):faces.extend([(i,i+1,2*q+i+1,2*q+i),(q+i,3*q+i,3*q+i+1,q+i+1)])
  faces.extend([(0,q,3*q,2*q),(n,2*q+n,3*q+n,q+n)])
  for end in [0,2*q]:
   for i in range(n):faces.append((end+i,end+q+i,end+q+i+1,end+i+1))
  G['Rainwater'].add(verts,faces,5,uvzone(5,(0,y,z),span=.2))
  for yy in [y-.102,y+.102]:cyl('Rainwater',(start,yy,z),(endx,yy,z),.018,5,8)
 for x in [-4.25,4.25]:
  pts=[(x,y,5.4),(x,y,5.15),(x,sign*3.64,4.98),(x,sign*3.64,.25),(x,sign*3.80,.10)]
  tube('Rainwater',pts,.054,5,12)
  for zz in [.65,2.45,4.60]:
   cyl('Rainwater',(x,sign*3.64,zz-.035),(x,sign*3.64,zz+.035),.069,5,12)
   box('Rainwater',(x,sign*3.54,zz),(.12,.14,.045),5,.007)
 for x in [-4,-3,-2,-1,0,1,2,3,4]:
  if any(abs(x-c)<.91 for c in centers):continue
  tube('Rainwater',[(x,sign*3.54,5.44),(x,sign*3.82,5.32),(x,sign*3.92,5.43)],.017,5,8)
# Small porch lantern, wall bracket and mounting screws.
localbox('Entry_fittings',front,1.16,.11,2.75,.15,.08,.34,5,.02)
tube('Entry_fittings',[front(1.16,.15,2.87),front(1.16,.49,2.87),front(1.16,.49,2.76)],.025,5,10)
localbox('Entry_fittings',front,1.16,.49,2.51,.23,.22,.35,6,.016)
for u in [1.035,1.285]:
 for d in [.37,.61]:cyl('Entry_fittings',front(u,d,2.31),front(u,d,2.72),.016,5,8)
localbox('Entry_fittings',front,1.16,.49,2.30,.30,.29,.06,5,.012)
localbox('Entry_fittings',front,1.16,.49,2.73,.34,.33,.065,5,.014)
for zz in [2.62,2.86]:cyl('Entry_fittings',front(1.16,.155,zz),front(1.16,.17,zz),.024,5,8)

objects=[g.finish() for g in G.values() if g.f]

wallob=bpy.data.objects['Walls']
bpy.ops.object.select_all(action='DESELECT');wallob.select_set(True);bpy.context.view_layer.objects.active=wallob
wallob.data.uv_layers.new(name='UVAtlas');wallob.data.uv_layers.active_index=1
bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.uv.smart_project(angle_limit=1.45,island_margin=.006);bpy.ops.object.mode_set(mode='OBJECT')
# Make all existing material maps explicitly use UV0 before selecting UV1 for baking.
for m in mats:
 nt=m.node_tree;uv0=node(nt,'ShaderNodeUVMap');uv0.uv_map='UVMap'
 for n in list(nt.nodes):
  if n.type=='TEX_IMAGE':nt.links.new(uv0.outputs['UV'],n.inputs['Vector'])
  if n.type=='NORMAL_MAP':n.uv_map='UVMap'
m=mats[0];nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');out=next(n for n in nt.nodes if n.type=='OUTPUT_MATERIAL');source=bs.inputs['Base Color'].links[0].from_socket
geom=node(nt,'ShaderNodeNewGeometry');sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(geom.outputs['Position'],sep.inputs[0])
noise=node(nt,'ShaderNodeTexNoise');nt.links.new(geom.outputs['Position'],noise.inputs['Vector']);noise.inputs['Scale'].default_value=2.8;noise.inputs['Detail'].default_value=4
height=mathnode(nt,'ADD',1.0,mathnode(nt,'MULTIPLY',noise.outputs['Fac'],1.15))
fall=mathnode(nt,'MAXIMUM',mathnode(nt,'SUBTRACT',height,sep.outputs['Z']),0)
patch=mathnode(nt,'MAXIMUM',mathnode(nt,'SUBTRACT',noise.outputs['Fac'],.33),0)
mask=mathnode(nt,'MINIMUM',mathnode(nt,'MULTIPLY',mathnode(nt,'MULTIPLY',fall,patch),2.9),.83)
mossc=mix(nt,noise.outputs['Fac'],(.026,.037,.009,1),(.135,.159,.045,1))
color=mix(nt,mask,source,mossc)
emit=node(nt,'ShaderNodeEmission');nt.links.new(color,emit.inputs[0]);nt.links.new(emit.outputs[0],out.inputs[0])
atlas=bpy.data.images.new('Granite_weathered_BaseColor',width=2048,height=2048,alpha=False)
# Every used material gets an active target; only granite uses the resulting atlas.
for mat in mats:
 n=node(mat.node_tree,'ShaderNodeTexImage');n.image=atlas;mat.node_tree.nodes.active=n
scene.cycles.samples=4;scene.render.bake.margin=8;bpy.ops.object.bake(type='EMIT')
atlas.filepath_raw=str(BAKES/'Granite_weathered_BaseColor.jpg');atlas.file_format='JPEG';atlas.save()
nt.links.new(bs.outputs[0],out.inputs[0]);tex=node(nt,'ShaderNodeTexImage');tex.image=atlas;uv1=node(nt,'ShaderNodeUVMap');uv1.uv_map='UVAtlas';nt.links.new(uv1.outputs[0],tex.inputs['Vector']);nt.links.new(tex.outputs['Color'],bs.inputs['Base Color'])
wallob.data.uv_layers.active_index=0;wallob.data.uv_layers['UVMap'].active_render=True
wallob.select_set(False)
print('BAKED_UNIQUE_WALL_WEATHERING',flush=True)
# Hinge origin on the door's left edge; other meshes have identity transforms.
door=bpy.data.objects['Door'];pivot=Vector((-.68,-3.49,.175));door.data.transform(Matrix.Translation(-pivot));door.location=pivot
for name,pos in [('Entrance',(0,-4.45,0)),('Lantern',(1.16,-3.99,2.51))]:
 ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.location=pos;ob.empty_display_type='PLAIN_AXES';objects.append(ob)
# Apply normals and final triangulation to exactly the geometry delivered.
for ob in objects:
 if ob.type!='MESH':continue
 bpy.context.view_layer.objects.active=ob;ob.select_set(True)
 mod=ob.modifiers.new('Final triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name);ob.select_set(False)
triangles=sum(len(o.data.polygons) for o in objects if o.type=='MESH')
print('GEOMETRY_TRIANGLES',triangles,flush=True)
assert 50000<=triangles<=120000,triangles
bpy.context.view_layer.update()
points=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
def yup(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
ypts=[yup(p) for p in points]
meta={'units':'metres','up':'+Y','front':'+Z','footprint':[9,7],'triangles':triangles,'materials':len(mats),'nodes':[{'name':o.name,'type':o.type,'world_position':yup(o.matrix_world.translation)} for o in objects], 'bounding_box':{'min':[min(p[i] for p in ypts) for i in range(3)],'max':[max(p[i] for p in ypts) for i in range(3)]}}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
# Export only contract objects; export JPEG images to keep the transfer budget bounded.
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'breton-stone-house.glb'),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_materials='EXPORT',export_cameras=False,export_lights=False)
size=(ROOT/'breton-stone-house.glb').stat().st_size
print('EXPORT_BYTES',size,flush=True)
assert size<=12000000,size
notes=["Breton granite fisherman's house, 9 × 7 m; eaves 5.6 m, ridge 9 m, pots 10.5 m; no quay/platform.",f'{triangles:,} triangles; 10 mesh nodes and Entrance/Lantern anchors; left-hinged Door.', '8 materials; scan-registered geometric wall relief; Cycles-baked base colour, tangent normal and packed occlusion/roughness/metallic; no procedural export dependencies.', 'Baked JPEG textures: granite 2048²; dressed stone, slate and blue timber 1024²; frames, zinc/iron and terracotta 512²; glass 256².', 'Poly Haven CC0 (https://polyhaven.com/license): rock_wall_09, worn_rock_natural_01, wooden_planks, metal_plate_02, slate_floor_03; https://polyhaven.com/a/<id>.', 'Reference differences: rear facade inferred (sheet labels a gable as rear); glazing opaque for a closed exterior asset; no quay, mooring props or barrel.']
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
# Render the actual GLB after a round trip, so all views show the delivered materials.
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
bpy.data.node_groups['glTF Material Output'].name='Baked AO Settings'
bpy.ops.import_scene.gltf(filepath=str(ROOT/'breton-stone-house.glb'))
scene.world.use_nodes=True;scene.world.node_tree.nodes.get('Background').inputs[0].default_value=(.69,.76,.84,1);scene.world.node_tree.nodes.get('Background').inputs[1].default_value=.45
bpy.ops.object.light_add(type='AREA',location=(-7,-10,16));key=bpy.context.object;key.name='STUDIO_key';key.data.energy=2200;key.data.shape='DISK';key.data.size=8;key.rotation_euler=(Vector((0,0,4))-key.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.light_add(type='SUN',location=(0,0,15));sun=bpy.context.object;sun.rotation_euler=(math.radians(25),math.radians(-30),math.radians(-25));sun.data.energy=1.6;sun.data.angle=math.radians(18)
# Neutral receiving floor is render-only; never part of the asset.
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.028));floor=bpy.context.object;floor.name='STUDIO_floor';fm=bpy.data.materials.new('STUDIO_ground');fm.diffuse_color=(.29,.315,.32,1);floor.data.materials.append(fm)
bpy.ops.object.camera_add();cam=bpy.context.object;scene.camera=cam
scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.resolution_percentage=100;scene.cycles.samples=48
scene.render.film_transparent=False

def camera(pos,target,ortho):
 cam.location=pos;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.type='ORTHO';cam.data.ortho_scale=ortho;cam.data.lens=48

def render(name):
 scene.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
camera((15,-22,13.5),(0,0,4.9),24.5);render('final.png')
camera((22,0,5.25),(0,0,5.25),21.6);render('side.png')
camera((-14,21,12),(0,0,4.9),24.0);render('rear-check.png')
camera((8,-14,7),(1,-2,3.5),9);render('close-check.png')
clay=bpy.data.materials.new('STUDIO_clay');clay.diffuse_color=(.45,.45,.45,1);clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.82
scene.view_layers[0].material_override=clay
camera((15,-22,13.5),(0,0,4.9),24.5);render('clay.png')
scene.view_layers[0].material_override=None
print('BUILD_COMPLETE',triangles,size,flush=True)
