"""Cargo bicycle: clean, deterministic Blender 5.2 build, Cycles tile bakes and GLB.
Coordinates in construction helpers: (rider-left, forward, up), metres.
Run: blender -b --python-exit-code 1 --python build_cargo-bike.py
"""
import bpy, math, os, json, urllib.request, struct, random, time, hashlib
import numpy as np
from pathlib import Path
from mathutils import Vector, Matrix
ROOT=Path(__file__).resolve().parent
print('CARGO_BUILD_FINAL_5',flush=True)
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
BAKE=ROOT/'baked'; BAKE.mkdir(exist_ok=True)
random.seed(19)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
sc=bpy.context.scene
sc.render.engine='CYCLES'; sc.cycles.samples=24; sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
try: prefs.compute_device_type='OPTIX'; prefs.get_devices()
except: prefs.compute_device_type='CUDA'; prefs.get_devices()
for d in prefs.devices: d.use=d.type!='CPU'
sc.cycles.device='GPU'; sc.render.bake.margin=8
print('DEVICES',[(d.name,d.type,d.use) for d in prefs.devices],flush=True)
sc.unit_settings.system='METRIC'
sc.view_settings.view_transform='AgX'
IDS=['plywood','brown_leather','rusty_metal_03']
for ident in IDS:
 for mp in ['diff','nor_gl','arm']:
  p=TEX/f'{ident}_{mp}_1k.jpg'
  if not p.exists():
   filename=p.name.replace('_diff_', '_albedo_') if ident=='brown_leather' and mp=='diff' else p.name
   urllib.request.urlretrieve(f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{filename}',p)

def node(nt,kind): return nt.nodes.new(kind)
def source(name,ident,tint,rough,metal):
 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['BSDF'],out.inputs['Surface'])
 uv=node(nt,'ShaderNodeTexCoord')
 ims={}
 for mp in ['diff','nor_gl','arm']:
  t=node(nt,'ShaderNodeTexImage'); t.image=bpy.data.images.load(str(TEX/f'{ident}_{mp}_1k.jpg'),check_existing=True)
  if mp!='diff':t.image.colorspace_settings.name='Non-Color'
  nt.links.new(uv.outputs['UV'],t.inputs['Vector']); ims[mp]=t
 noise=node(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=95; noise.inputs['Detail'].default_value=3
 nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
 mix=node(nt,'ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=1; mix.inputs[2].default_value=(*tint,1)
 nt.links.new(ims['diff'].outputs['Color'],mix.inputs[1]); color=mix.outputs[0]
 if name in ['Paint','Rubber','Metal']:
  bw=node(nt,'ShaderNodeRGBToBW'); nt.links.new(ims['diff'].outputs[0],bw.inputs[0])
  ramp=node(nt,'ShaderNodeValToRGB'); nt.links.new(bw.outputs[0],ramp.inputs[0])
  c1,c2={'Paint':((.68,.68,.68,1),(.96,.96,.96,1)), 'Rubber':((.011,.014,.014,1),(.035,.040,.038,1)), 'Metal':((.26,.28,.28,1),(.65,.68,.67,1))}[name]
  ramp.color_ramp.elements[0].color=c1; ramp.color_ramp.elements[1].color=c2; color=ramp.outputs[0]
 if name=='Lamps':
  sep=node(nt,'ShaderNodeSeparateXYZ'); nt.links.new(uv.outputs['UV'],sep.inputs[0])
  r=node(nt,'ShaderNodeValToRGB'); r.color_ramp.interpolation='CONSTANT'; r.color_ramp.elements[0].position=.0; r.color_ramp.elements[0].color=(.004,.003,.002,1)
  r.color_ramp.elements[1].position=.50; r.color_ramp.elements[1].color=(.7,.012,.008,1)
  r.color_ramp.elements.new(.25).color=(.73,.80,.82,1)
  r.color_ramp.elements.new(.75).color=(.85,.29,.015,1)
  nt.links.new(sep.outputs['X'],r.inputs[0]); color=r.outputs[0]
 # Scattered varnish abrasion and accumulated dirt, flattened into the baked colour.
 if name in ['Wood','Leather']:
  weather=node(nt,'ShaderNodeTexNoise'); weather.inputs['Scale'].default_value=7;weather.inputs['Detail'].default_value=4;weather.inputs['Roughness'].default_value=.72
  nt.links.new(uv.outputs['UV'],weather.inputs['Vector'])
  wr=node(nt,'ShaderNodeValToRGB');nt.links.new(weather.outputs['Fac'],wr.inputs[0]);wr.color_ramp.elements[0].position=.28;wr.color_ramp.elements[0].color=(.36,.30,.24,1);wr.color_ramp.elements[1].position=.7;wr.color_ramp.elements[1].color=(1,1,1,1)
  aged=node(nt,'ShaderNodeMixRGB');aged.blend_type='MULTIPLY';aged.inputs[0].default_value=.52;nt.links.new(color,aged.inputs[1]);nt.links.new(wr.outputs[0],aged.inputs[2]);color=aged.outputs[0]
  stretch=node(nt,'ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(7,220,1);nt.links.new(uv.outputs['UV'],stretch.inputs[0])
  scratch=node(nt,'ShaderNodeTexNoise');scratch.inputs['Scale'].default_value=1;scratch.inputs['Detail'].default_value=2;nt.links.new(stretch.outputs[0],scratch.inputs['Vector'])
  sr=node(nt,'ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.72;sr.color_ramp.elements[0].color=(0,0,0,1);sr.color_ramp.elements[1].position=.80;sr.color_ramp.elements[1].color=(.45,.45,.45,1);nt.links.new(scratch.outputs['Fac'],sr.inputs[0])
  worn=node(nt,'ShaderNodeMixRGB');nt.links.new(sr.outputs[0],worn.inputs[0]);nt.links.new(color,worn.inputs[1]);worn.inputs[2].default_value=(.42,.24,.105,1) if name=='Wood' else (.17,.08,.037,1);color=worn.outputs[0]
 nt.links.new(color,bs.inputs['Base Color'])
 arm=node(nt,'ShaderNodeSeparateColor'); nt.links.new(ims['arm'].outputs['Color'],arm.inputs[0])
 rm=node(nt,'ShaderNodeMath'); rm.operation='MULTIPLY_ADD'; rm.inputs[1].default_value=.2; rm.inputs[2].default_value=rough-.1; nt.links.new(arm.outputs['Green'],rm.inputs[0]); nt.links.new(rm.outputs[0],bs.inputs['Roughness'])
 bs.inputs['Metallic'].default_value=metal
 nm=node(nt,'ShaderNodeNormalMap'); nm.inputs['Strength'].default_value={'Paint':.025,'Metal':.045,'Rubber':.06,'Wood':.35,'Leather':.16,'Lamps':.015}[name]; nt.links.new(ims['nor_gl'].outputs['Color'],nm.inputs['Color'])
 bump=node(nt,'ShaderNodeBump'); bump.inputs['Strength'].default_value=.16; bump.inputs['Distance'].default_value=.00015
 nt.links.new(noise.outputs['Fac'],bump.inputs['Height']); nt.links.new(nm.outputs['Normal'],bump.inputs['Normal']); nt.links.new(bump.outputs['Normal'],bs.inputs['Normal'])
 return m,bs,color,rm.outputs[0],metal,arm.outputs['Red']

# One physically-sized reusable baked surface set per material: 1k colour/normal, 512 ORM.
materials=[]; painttex=None
for name,ident,tint,rough,metal in [('Paint','rusty_metal_03',(1,1,1),.38,0),('Wood','plywood',(.72,.40,.19),.43,0),('Rubber','rusty_metal_03',(1,1,1),.78,0),('Metal','rusty_metal_03',(1,1,1),.29,.88),('Leather','brown_leather',(.72,.52,.37),.5,0),('Lamps','rusty_metal_03',(1,1,1),.22,.12)]:
 m,bs,col,roughsocket,met,aosocket=source(name,ident,tint,rough,metal); nt=m.node_tree
 bpy.ops.mesh.primitive_plane_add(size=1); plane=bpy.context.object; plane.data.materials.append(m)
 out=next(n for n in nt.nodes if n.type=='OUTPUT_MATERIAL'); target=node(nt,'ShaderNodeTexImage'); emit=node(nt,'ShaderNodeEmission')
 images={}
 for kind,res in [('base',1024 if name in ['Wood','Paint','Leather'] else 512),('normal',1024 if name=='Wood' else 512),('orm',512)]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
  im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color'
  target.image=im; nt.nodes.active=target
  if kind=='base': nt.links.new(col,emit.inputs['Color']); nt.links.new(emit.outputs[0],out.inputs[0]); typ='EMIT'
  elif kind=='normal': nt.links.new(bs.outputs[0],out.inputs[0]); typ='NORMAL'
  else:
   comb=node(nt,'ShaderNodeCombineColor'); comb.inputs['Red'].default_value=1; comb.inputs['Blue'].default_value=met
   nt.links.new(aosocket,comb.inputs['Red']);nt.links.new(roughsocket,comb.inputs['Green']); nt.links.new(comb.outputs[0],emit.inputs[0]); nt.links.new(emit.outputs[0],out.inputs[0]); typ='EMIT'
  sc.cycles.samples=8
  bpy.ops.object.bake(type=typ)
  im.filepath_raw=str(BAKE/f'{name}_{kind}.png'); im.file_format='PNG'; im.save(); images[kind]=im
  print('BAKED',name,kind,flush=True)
 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])
 ts={}
 for k,im in images.items():
  t=node(nt,'ShaderNodeTexImage'); t.image=im; ts[k]=t
 nt.links.new(ts['base'].outputs['Color'],bs.inputs['Base Color'])
 nm=node(nt,'ShaderNodeNormalMap'); nt.links.new(ts['normal'].outputs['Color'],nm.inputs['Color']); nt.links.new(nm.outputs[0],bs.inputs['Normal'])
 sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(ts['orm'].outputs['Color'],sep.inputs[0]); nt.links.new(sep.outputs['Green'],bs.inputs['Roughness']); nt.links.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 # Named glTF occlusion socket so the exported ORM also supplies occlusion.
 ng=bpy.data.node_groups.get('glTF Material Output')
 if not ng:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 occ=node(nt,'ShaderNodeGroup'); occ.node_tree=ng; nt.links.new(sep.outputs['Red'],occ.inputs['Occlusion'])
 if name=='Paint':
  bs.inputs['Coat Weight'].default_value=1.; bs.inputs['Coat Roughness'].default_value=.05; painttex=ts['base']
 materials.append(m)

# Geometry accumulators preserve rigid assemblies and use shared tiled surface UVs.
groups={}
def V(p):return Vector((p[0],-p[1],p[2]))
def emit(group,verts,faces,mat,uvs=None,smooth=True):
 g=groups.setdefault(group,{'v':[],'f':[],'m':[],'uv':[],'sm':[]})
 off=len(g['v']); g['v'].extend([tuple(V(v)) for v in verts])
 for i,f in enumerate(faces):
  g['f'].append(tuple(off+j for j in f));g['m'].append(mat);g['sm'].append(smooth)
  g['uv'].append(uvs[i] if uvs else [(verts[j][1]*1.3,verts[j][2]*1.3) for j in f])
def tube(group,points,r,mat,n=12,cap=True):
 pts=[Vector(p) for p in points]
 if 2<len(pts)<14 and not isinstance(r,list):
  rounded=[pts[0]]
  for i in range(1,len(pts)-1):
   a,b,c=pts[i-1:i+2];l=min((b-a).length,(c-b).length)*.23
   q=b-(b-a).normalized()*l;s=b+(c-b).normalized()*l
   rounded.extend([q, q*.25+b*.5+s*.25, s])
  rounded.append(pts[-1]);pts=rounded
 vs=[];fs=[];uv=[];dist=0
 ds=[0]
 for a,b in zip(pts,pts[1:]):ds.append(ds[-1]+(b-a).length)
 for i,p in enumerate(pts):
  t=(pts[min(i+1,len(pts)-1)]-pts[max(0,i-1)]).normalized(); ref=Vector((1,0,0))
  if abs(t.dot(ref))>.9:ref=Vector((0,0,1))
  a=t.cross(ref).normalized();b=t.cross(a).normalized()
  rad=r[i] if isinstance(r,list) else r
  for j in range(n):vs.append(tuple(p+rad*(a*math.cos(j*math.tau/n)+b*math.sin(j*math.tau/n))))
 for i in range(len(pts)-1):
  for j in range(n):
   fs.append((i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j)); uv.append([(j/n,ds[i]*2),((j+1)/n,ds[i]*2),((j+1)/n,ds[i+1]*2),(j/n,ds[i+1]*2)])
 if cap:
  fs.extend([tuple(range(n-1,-1,-1)),tuple((len(pts)-1)*n+j for j in range(n))]); uv.extend([[(.5+.45*math.cos(j*math.tau/n),.5+.45*math.sin(j*math.tau/n)) for j in f] for f in [list(range(n-1,-1,-1)),list(range(n))]])
 emit(group,vs,fs,mat,uv)
 if cap:groups[group]['sm'][-2:]=[False,False]
def bar(g,a,b,r,m,n=12):tube(g,[a,b],r,m,n)
def ring(g,c,R,r,m,axis='X',N=96,n=10,start=0,end=math.tau):
 vs=[];fs=[];uv=[]; full=abs(end-start-math.tau)<1e-5; count=N if full else N+1
 for i in range(count):
  t=start+(end-start)*i/N
  for j in range(n):
   a=j*math.tau/n; rr=R+r*math.cos(a)
   q=(r*math.sin(a),rr*math.sin(t),rr*math.cos(t))
   if axis=='Z':q=(q[1],q[2],q[0])
   vs.append(tuple(c[k]+q[k] for k in range(3)))
 for i in range(N):
  for j in range(n):fs.append((i*n+j,i*n+(j+1)%n,((i+1)%count)*n+(j+1)%n,((i+1)%count)*n+j));uv.append([(i/N,j/n),(i/N,(j+1)/n),((i+1)/N,(j+1)/n),((i+1)/N,j/n)])
 emit(g,vs,fs,m,uv)
def ball(g,c,scale,m,N=20,n=10,urange=None):
 vs=[(c[0],c[1],c[2]+scale[2])];fs=[]
 for i in range(1,n):
  a=math.pi*i/n
  for j in range(N):
   b=math.tau*j/N;vs.append((c[0]+scale[0]*math.sin(a)*math.cos(b),c[1]+scale[1]*math.sin(a)*math.sin(b),c[2]+scale[2]*math.cos(a)))
 vs.append((c[0],c[1],c[2]-scale[2]));bottom=len(vs)-1
 for j in range(N):fs.append((0,1+j,1+(j+1)%N))
 for i in range(n-2):
  for j in range(N):a=1+i*N+j;b=1+i*N+(j+1)%N;fs.append((a,a+N,b+N,b))
 for j in range(N):fs.append((bottom,1+(n-2)*N+(j+1)%N,1+(n-2)*N+j))
 uv=None
 if urange:uv=[[(sum(urange)/2,.5) for _ in f] for f in fs]
 emit(g,vs,fs,m,uv)
def panel(g,vs,thick,m,axis=0,bevel=.003):
 # Applied bevel on actual closed plywood/metal panels; UVs use physical dimensions.
 v=[Vector(p) for p in vs]; norm=(v[1]-v[0]).cross(v[2]-v[0]).normalized();N=len(v)
 vv=[tuple(p+norm*thick/2) for p in v]+[tuple(p-norm*thick/2) for p in v]
 ff=[tuple(range(N)),tuple(range(2*N-1,N-1,-1))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
 mesh=bpy.data.meshes.new('panel'); mesh.from_pydata([V(p) for p in vv],[],ff);mesh.update()
 o=bpy.data.objects.new('panel',mesh);bpy.context.collection.objects.link(o);bpy.context.view_layer.objects.active=o;o.select_set(True)
 mod=o.modifiers.new('machined rounded edge','BEVEL');mod.width=bevel;mod.segments=2
 bpy.ops.object.modifier_apply(modifier=mod.name)
 # Normals are recalculated below for all assembled meshes.
 vv=[(p.co.x,-p.co.y,p.co.z) for p in o.data.vertices];ff=[tuple(p.vertices) for p in o.data.polygons]
 uvs=[]
 for f in ff:
  uvs.append([((vv[j][1]+.15)*.95,(vv[j][2]-.25)*1.7) if axis==0 else ((vv[j][0]+.35)*1.5,(vv[j][1]+.2) if axis==2 else vv[j][2]*1.7) for j in f])
 emit(g,vv,ff,m,uvs,False);bpy.data.objects.remove(o,do_unlink=True)
def box(g,c,d,m,bev=.002):
 x,y,z=c;dx,dy,dz=[t/2 for t in d];panel(g,[(x-dx,y-dy,z),(x+dx,y-dy,z),(x+dx,y+dy,z),(x-dx,y+dy,z)],d[2],m,2,bev)
def bolt(g,p,axis=(1,0,0),r=.005):
 a=Vector(p);d=Vector(axis).normalized();bar(g,a-d*.002,a+d*.003,r,3,6)
 # inset dark screw slot is geometry, also shared Rubber material.
 if abs(d.x)>.8:bar(g,a+d*.0035+Vector((0,-r*.52,0)),a+d*.0035+Vector((0,r*.52,0)),.00065,2,5)

F=.966; R=-.966; rf=.254;rr=.3302
# Each wheel: 36 laced, crossed spokes, stepped rims, axle hardware and subtle tyre ribs.
for name,s,rad in [('Wheel_F',F,rf),('Wheel_R',R,rr)]:
 tire=.022 if name=='Wheel_F' else .025
 ring(name,(0,s,rad),rad-tire,tire,2,N=88,n=12)
 for x in [-.014,.014]:
  ring(name,(x,s,rad),rad-tire*1.75,.0044,2,N=80,n=5)
  ring(name,(x,s,rad),rad-tire*2.05,.0042,3,N=80,n=5)
 ring(name,(0,s,rad),rad-tire*2.10,.009,3,N=80,n=6)
 bar(name,(-.035,s,rad),(.035,s,rad),.019,3,20)
 for side in [-1,1]:
  bar(name,(side*.025,s,rad),(side*.030,s,rad),.031,3,24)
  for j in range(18):
   a=j*math.tau/18+(0 if side==1 else math.pi/18);b=a+(.26 if j%2 else -.26)
   p=(side*.028,s+.026*math.sin(a),rad+.026*math.cos(a));q=(side*.010,s+(rad-.046)*math.sin(b),rad+(rad-.046)*math.cos(b))
   bar(name,p,q,.00105,3,6)
   v=Vector(q);bar(name,v-(v-Vector(p)).normalized()*.012,q,.0019,3,6)
 # broken herringbone tread blocks around the crown
 for j in range(64):
  a=j*math.tau/64
  for side in [-1,1]:
   a2=a+.014*side
   p=(side*.006,s+(rad-.0012)*math.sin(a),rad+(rad-.0012)*math.cos(a))
   q=(side*.016,s+(rad-.0045)*math.sin(a2),rad+(rad-.0045)*math.cos(a2))
   bar(name,p,q,.0009,2,5)
 bar(name,(.005,s,rad+(rad-.05)),(.005,s,rad+(rad-.07)),.0024,3,8)

# Welded low-slung main chassis, front cradle, steering column and rear triangle.
tube('Frame',[(0,.90,.49),(0,.74,.36),(0,.64,.285),(0,.52,.278),(0,-.23,.278),(0,-.51,.29),(0,-.96,.33)],.024,0,16)
tube('Frame',[(0,-.20,.285),(0,-.28,.61),(0,-.33,.85)],.024,0,16)
bar('Frame',(0,-.51,.29),(0,-.75,.79),.021,0,16)
for side in [-1,1]:
 x=side*.055
 tube('Frame',[(0,-.51,.29),(x,-.70,.305),(x,R,rr)],.012,0,12)
 tube('Frame',[(0,-.75,.77),(x,-.82,.64),(x,R,rr)],.011,0,12)
 bar('Frame',(x,R,rr),(side*.047,R,rr),.023,0,12)
 # cargo cradle, under-floor longitudinal rail
 tube('Frame',[(side*.21,.68,.335),(side*.22,.62,.315),(side*.22,.55,.30),(side*.22,-.14,.30),(side*.15,-.22,.33)],.012,0,12)
 for y in [-.10,.25,.61]:bar('Frame',(-.24,y,.325),(.24,y,.325),.010,0,12)
# steering head at small wheel
bar('Frame',(0,.89,.49),(0,.836,.65),.025,0,20)
for s,z in [(.89,.49),(.836,.65)]:bar('Fork',(0,s+.002,z-.006),(0,s-.002,z+.006),.029,3,20)
for side in [-1,1]:
 tube('Fork',[(side*.043,F,rf),(side*.043,.925,.34),(side*.037,.91,.49),(side*.022,.874,.54)], [.009,.012,.014,.015],0,14)
 bolt('Fork',(side*.055,F,rf),r=.009)
bar('Fork',(-.042,.875,.54),(.042,.875,.54),.016,0,14)
# Long steering drag link on left side, with bellcranks and ball joints.
bar('Handlebar',(0,-.215,.306),(.17,-.215,.306),.008,3,12)
bar('Fork',(0,.89,.48),(.17,.89,.44),.007,3,10)
tube('Frame',[(.17,-.215,.306),(.185,-.11,.265),(.185,.56,.266),(.17,.89,.44)],.0065,3,12)
for p in [(.17,-.215,.306),(.17,.89,.44)]:ball('Frame',p,(.013,.013,.011),3,12,6);bolt('Frame',(p[0],p[1],p[2]+.008),(0,0,1),.006)

# Mudguards: curved sheet sections across tyre, rolled rims and thin stays.
def fender(g,s,rad,a0,a1):
 g='Fender_'+g
 N=48;n=6;vs=[];fs=[]
 for i in range(N+1):
  a=a0+(a1-a0)*i/N
  for j in range(n+1):
   t=(j/n-.5)*math.pi*.90;x=.035*math.sin(t);r=rad+.014+.009*math.cos(t)
   vs.append((x,s+r*math.sin(a),rad+r*math.cos(a)))
 for i in range(N):
  for j in range(n):fs.append((i*(n+1)+j,i*(n+1)+j+1,(i+1)*(n+1)+j+1,(i+1)*(n+1)+j))
 # Thin inner skin and closed end/side hems, rather than a one-sided sheet.
 count=len(vs)
 vs += [(x, s+(y-s)*.996, rad+(z-rad)*.996) for x,y,z in vs[:]]
 fs += [tuple(count+j for j in reversed(f)) for f in fs[:]]
 for i in range(N):
  for j in [0,n]:
   a=i*(n+1)+j;b=(i+1)*(n+1)+j;fs.append((a,b,b+count,a+count))
 for i in [0,N]:
  for j in range(n):
   a=i*(n+1)+j;fs.append((a,a+1,a+1+count,a+count))
 emit(g,vs,fs,0)
 for side in [-1,1]:
  tube(g,[(side*.0345,s+(rad+.0155)*math.sin(a0+(a1-a0)*i/N),rad+(rad+.0155)*math.cos(a0+(a1-a0)*i/N)) for i in range(N+1)],.0022,3,6)
  for a in [a0+.14,a1-.14]:bar(g,(side*.05,s,rad),(side*.034,s+(rad+.02)*math.sin(a),rad+(rad+.02)*math.cos(a)),.002,3,6)
fender('Fork',F,rf,-1.25,1.06);fender('Frame',R,rr,-1.72,1.55)
# Rear mudflap and rear light housing.
box('Frame',(0,R-.343,.27),(.065,.009,.09),2,.004)
box('Frame',(0,R-.341,.47),(.041,.024,.064),2,.006)
ball('Frame',(0,R-.356,.475),(.017,.008,.025),5,20,10,(.55,.70))

# Four 12 mm plywood sides, upward-flared cargo bin. Floor and child bench.
for side in [-1,1]:
 x=side
 panel('Frame',[(x*.248,-.15,.355),(x*.242,.65,.355),(x*.305,.845,.738),(x*.324,-.19,.838)],.012,1,0,.004)
 # thin exposed ply laminations along the top, varnished hardwood lip
 tube('Frame',[(x*.324,-.19,.838),(x*.305,.845,.738)],.0065,1,10)
 # base angle, fasteners and corner braces
 bar('Frame',(x*.254,-.145,.358),(x*.248,.65,.358),.007,3,8)
 for y,z,xx in [(-.155,.79,.322),(.77,.72,.309),(-.12,.392,.257),(.625,.392,.252),(.20,.76,.322)]:bolt('Frame',(x*xx,y,z),(x,0,0),.0055)
panel('Frame',[(-.242,.65,.355),(.242,.65,.355),(.305,.845,.738),(-.305,.845,.738)],.012,1,1,.004)
panel('Frame',[(-.248,-.15,.355),(-.324,-.19,.838),(.324,-.19,.838),(.248,-.15,.355)],.012,1,1,.004)
panel('Frame',[(-.248,-.15,.35),(.248,-.15,.35),(.242,.65,.35),(-.242,.65,.35)],.014,1,2,.004)
# floor runner seams and drainage holes represented by small recessed dark discs
for y in [.03,.20,.37,.54]:bar('Frame',(-.237,y,.358),(.237,y,.358),.0007,2,5)
box('Frame',(0,-.035,.503),(.510,.245,.024),1,.008)
for x in [-.235,.235]:box('Frame',(x,-.03,.431),(.025,.16,.13),1,.004)
# backrest with rounded top sits inside rear panel
panel('Frame',[(-.26,-.153,.518),(.26,-.153,.518),(.288,-.165,.78),(-.288,-.165,.78)],.016,1,1,.008)
# Two child restraint straps and metal buckles.
for x in [-.165,.165]:
 panel('Frame',[(x-.011,-.143,.775),(x+.011,-.143,.775),(x+.011,-.126,.545),(x-.011,-.126,.545)],.003,4,1,.001)
 panel('Frame',[(x-.011,-.126,.545),(x+.011,-.126,.545),(x+.011,-.035,.522),(x-.011,-.035,.522)],.003,4,1,.001)
 box('Frame',(x,-.128,.692),(.030,.007,.040),3,.002)
 box('Frame',(x,-.123,.692),(.020,.004,.026),4,.001)
 bolt('Frame',(x,-.140,.77),(0,1,0),.006)

# Separate handlebar around the rear head-tube steering axis.
bar('Handlebar',(0,-.328,.83),(0,-.36,1.01),.012,3,16)
bar('Handlebar',(0,-.36,1.005),(0,-.35,1.049),.017,3,16)
tube('Handlebar',[(-.29,-.425,1.065),(-.20,-.37,1.063),(-.12,-.315,1.031),(0,-.32,1.031),(.12,-.315,1.031),(.20,-.37,1.063),(.29,-.425,1.065)],.010,3,16)
for side in [-1,1]:
 bar('Handlebar',(side*.215,-.382,1.064),(side*.303,-.433,1.064),.017,4,20)
 for d in [0,.082]:
  p=Vector((side*.215,-.382,1.064))+Vector((side*.088,-.051,0))*(d/.088)
  bar('Handlebar',p-Vector((side*.002,-.0012,0)),p+Vector((side*.002,-.0012,0)),.018,3,16)
 tube('Handlebar',[(side*.184,-.354,1.049),(side*.207,-.344,1.027),(side*.271,-.386,1.015)],.0045,3,10)
 ball('Handlebar',(side*.18,-.354,1.048),(.016,.013,.012),2,12,6)
# silver bell on the left, highest point exactly 1.10m.
bar('Handlebar',(.16,-.335,1.048),(.16,-.335,1.076),.006,3,10)
ball('Handlebar',(.16,-.335,1.083),(.021,.021,.017),3,24,12)
bar('Handlebar',(.171,-.331,1.075),(.194,-.318,1.072),.003,2,8)
# Bowed real brake cables, clips and calipers.
tube('Handlebar',[(-.181,-.349,1.043),(-.14,-.245,.989),(-.10,-.227,.92),(-.064,-.265,.82),(-.044,-.30,.74)],.0025,2,8)
tube('Handlebar',[(.181,-.349,1.043),(.17,-.24,.95),(.10,-.20,.85),(.037,-.267,.73)],.0025,2,8)
tube('Frame',[(.037,-.267,.73),(.03,-.24,.53),(.035,-.20,.32),(.045,.64,.32),(.049,.89,.48)],.0024,2,8)
tube('Frame',[(-.044,-.30,.74),(-.039,-.245,.33),(-.043,-.52,.315),(-.043,-.74,.777),(-.031,-.92,.647)],.0024,2,8)
for g,s,h in [('Fork',F,rf),('Frame',R,rr)]:
 # Centre-pull arch attached at the fork crown / seat-stay brake bridge.
 top=2*h-.018;pad=2*h-.054
 bar(g,(-.043,s-.016,top),(.043,s-.016,top),.006,3,12)
 bar(g,(0,s-.016,top),(0,s-.07,2*h+.016),.006,3,12)
 for side in [-1,1]:
  tube(g,[(side*.008,s-.016,top),(side*.044,s-.006,top-.012),(side*.039,s,pad)],.0045,3,12)
  box(g,(side*.022,s,pad),(.012,.040,.015),2,.003)
  bolt(g,(side*.039,s-.004,top-.012),(0,1,0),.005)
 tube(g,[(.037,s-.075,2*h+.027),(.028,s-.034,top+.024),(0,s-.016,top)],.002,2,8)

# Seatpost, clamp, suspended leather saddle with profiled nose and raised rear.
bar('Frame',(0,-.746,.77),(0,-.79,.906),.012,3,16)
bar('Frame',(0,-.738,.757),(0,-.754,.800),.023,0,16)
bolt('Frame',(.025,-.748,.785),r=.007)
# saddle cross-sections along forward axis, elliptical section but shaped wings
sections=[(-.925,.055,.925,.017),(-.905,.101,.932,.021),(-.865,.109,.925,.022),(-.815,.096,.914,.023),(-.765,.058,.907,.020),(-.700,.030,.914,.016),(-.655,.024,.923,.013),(-.640,.006,.923,.006)]
vs=[];fs=[];uv=[];N=20
for y,w,z,h in sections:
 for j in range(N):
  a=math.tau*j/N;vs.append((w*math.cos(a),y,z+h*math.sin(a)))
for i in range(len(sections)-1):
 for j in range(N):fs.append((i*N+j,i*N+(j+1)%N,(i+1)*N+(j+1)%N,(i+1)*N+j));uv.append([(j/N,i/7),((j+1)/N,i/7),((j+1)/N,(i+1)/7),(j/N,(i+1)/7)])
fs += [tuple(range(N-1,-1,-1)),tuple((len(sections)-1)*N+j for j in range(N))];uv += [[(.5,.5)]*N]*2
emit('Frame',vs,fs,4,uv)
for side in [-1,1]:
 tube('Frame',[(side*w,y,z) for y,w,z,h in sections],.002,4,8)
 tube('Frame',[(side*.031,-.68,.889),(side*.039,-.76,.870),(side*.059,-.87,.868)],.004,3,10)
 pts=[]
 for j in range(65):
  a=j/64*math.tau*4;pts.append((side*.063+.019*math.cos(a),-.875+.019*math.sin(a),.865+j/64*.042))
 tube('Frame',pts,.0028,3,7)
 for y,w,z in [(-.895,.085,.94),(-.66,.015,.932)]:bolt('Frame',(side*w,y,z),(0,0,1),.0035)

# Drivetrain: bottom bracket, crank arms, tooth ring, roller chain, pedals.
BB=(0,-.51,.29)
bar('Frame',(-.066,BB[1],BB[2]),(.066,BB[1],BB[2]),.025,0,20)
ring('Crank',(-.060,BB[1],BB[2]),.089,.007,3,N=96,n=8)
for j in range(44):
 a=j*math.tau/44;bar('Crank',(-.060,BB[1]+.087*math.sin(a),BB[2]+.087*math.cos(a)),(-.060,BB[1]+.096*math.sin(a),BB[2]+.096*math.cos(a)),.0033,3,6)
for j in range(5):
 a=j*math.tau/5;bar('Crank',(-.060,BB[1]+.018*math.sin(a),BB[2]+.018*math.cos(a)),(-.060,BB[1]+.081*math.sin(a),BB[2]+.081*math.cos(a)),.009,3,10)
# Pressed black chaincase: rounded sprocket ends, a recessed service opening,
# rolled outer lip and screw fixings. The full roller chain remains inside.
outline=[]
for j in range(25):
 a=-math.pi/2+j*math.pi/24
 outline.append((-.077,BB[1]+.111*math.cos(a),BB[2]+.111*math.sin(a)))
for j in range(19):
 a=math.pi/2+j*math.pi/18
 outline.append((-.077,R+.059*math.cos(a),rr+.059*math.sin(a)))
panel('Frame',outline,.012,2,0,.005)
tube('Frame',outline+[outline[0]],.0028,3,7)
# Removable spindle cover and two practical service screws.
ring('Frame',(-.085,BB[1],BB[2]),.033,.003,3,N=36,n=8)
for y,z in [(BB[1]+.068,BB[2]+.063),(BB[1]-.08,BB[2]-.06),(R-.029,rr)]:bolt('Frame',(-.085,y,z),(-1,0,0),.0035)
ring('Wheel_R',(-.051,R,rr),.040,.005,3,N=48,n=8)
# An actual closed two-sprocket chain, capsules and rollers at each pitch.
path=[]
for j in range(42):
 t=j/42;path.append(Vector((-.060,BB[1]+(R-BB[1])*t,BB[2]+.094+(rr+.041-BB[2]-.094)*t)))
for j in range(18):
 a=j/18*math.pi;path.append(Vector((-.060,R-.041*math.sin(a),rr+.041*math.cos(a))))
for j in range(42):
 t=j/42;path.append(Vector((-.060,R+(BB[1]-R)*t,rr-.041+(BB[2]-.094-rr+.041)*t)))
for j in range(28):
 a=math.pi+j/28*math.pi;path.append(Vector((-.060,BB[1]-.094*math.sin(a),BB[2]+.094*math.cos(a))))
for i,p in enumerate(path):
 q=path[(i+1)%len(path)]
 for dx in [-.004,.004]:bar('Frame',p+Vector((dx,0,0)),q+Vector((dx,0,0)),.0028,3,4)
 bar('Frame',p+Vector((-.005,0,0)),p+Vector((.005,0,0)),.0035,3,5)
# left pedal down/forward; right opposite, no ground collision.
PED={}
for side,suffix in [(1,'L'),(-1,'R')]:
 p=(side*.085,BB[1]+side*.050,BB[2]-side*.156);PED[suffix]=p
 tube('Crank',[(side*.064,BB[1],BB[2]),(side*.078,BB[1]+side*.020,BB[2]-side*.08),p],.010,3,12)
 bolt('Crank',(side*.078,BB[1],BB[2]),r=.012)
 bar('Pedal_'+suffix,p,(side*.16,p[1],p[2]),.006,3,12)
 box('Pedal_'+suffix,(side*.136,p[1],p[2]),(.090,.068,.024),2,.004)
 for y in [-.025,.025]:box('Pedal_'+suffix,(side*.135,p[1]+y,p[2]),(.086,.009,.027),3,.002)
 for j in range(5):box('Pedal_'+suffix,(side*(.101+j*.017),p[1],p[2]+.014),(.005,.057,.003),2,.001)
 # amber side reflector patch
 ball('Pedal_'+suffix,(side*.136,p[1]+.036,p[2]),(.027,.002,.006),5,12,6,(.8,.95))

# Retracted centre stand folded rearwards underneath the box.
for side in [-1,1]:
 tube('Kickstand',[(side*.15,.05,.29),(side*.15,-.12,.24),(side*.19,-.30,.23)],.009,3,12)
 bar('Kickstand',(side*.14,-.30,.23),(side*.21,-.30,.23),.012,2,12)
bar('Kickstand',(-.15,.05,.29),(.15,.05,.29),.010,3,12)
# sprung stand return wire
pts=[(.155+.004*math.cos(j*.7),.03-j*.002,.284+.004*math.sin(j*.7)) for j in range(50)];tube('Frame',pts,.0012,3,6)
# Headlight: simple round independent housing with ribbed lens and bracket.
bar('Fork',(0,.866,.59),(0,.960,.605),.009,3,12)
ball('Fork',(0,.963,.605),(.033,.033,.035),2,24,12)
ball('Fork',(0,.989,.606),(.029,.010,.030),3,24,12)
ball('Fork',(0,.996,.606),(.025,.006,.025),5,24,12,(.30,.45))
for x in [-.016,-.008,0,.008,.016]:
 dz=math.sqrt(.022**2-x*x);bar('Fork',(x,1.001,.606-dz),(x,1.001,.606+dz),.0008,3,6)

# Assemble, fix winding, triangulate, and create pivot frames.
import bmesh
objects={}
origins={'Frame':(0,0,0),'Fork':(0,.864,.57),'Handlebar':(0,-.33,.85),'Wheel_F':(0,F,rf),'Wheel_R':(0,R,rr),'Crank':BB,'Pedal_L':PED['L'],'Pedal_R':PED['R'],'Kickstand':(0,.05,.29)}
origins['Fender_Frame']=origins['Frame'];origins['Fender_Fork']=origins['Fork']
for name,g in groups.items():
 me=bpy.data.meshes.new(name);me.from_pydata(g['v'],[],g['f']);me.update()
 for m in materials:me.materials.append(m)
 uv=me.uv_layers.new(name='UVMap')
 for p,mi,sm,coords in zip(me.polygons,g['m'],g['sm'],g['uv']):
  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=list(bm.faces));bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
 o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o)
 if name in ['Frame','Pedal_L','Pedal_R','Crank','Handlebar','Fork']:
  bpy.context.view_layer.objects.active=o
  mod=o.modifiers.new('Game mesh reduction','DECIMATE');mod.ratio=.84;mod.use_collapse_triangulate=True
  bpy.ops.object.modifier_apply(modifier=mod.name)
 # Remove bevel sliver degenerates after reduction; audit the exported buffers too.
 me=o.data
 bm=bmesh.new();bm.from_mesh(me)
 bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges))
 bmesh.ops.triangulate(bm,faces=list(bm.faces))
 bad=[f for f in bm.faces if f.calc_area()<1e-11]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
 origin=V(origins[name]);me.transform(Matrix.Translation(-origin));o.location=origin
 objects[name]=o
# Preserve curved thin sheet geometry without reduction, then merge into rigid owners.
for owner in ['Frame','Fork']:
 bpy.ops.object.select_all(action='DESELECT')
 objects[owner].select_set(True);objects['Fender_'+owner].select_set(True)
 bpy.context.view_layer.objects.active=objects[owner];bpy.ops.object.join()
 del objects['Fender_'+owner]
# Set exact maximum width including metal bolt heads, preserving axle-centred symmetry.
bpy.context.view_layer.update()
width=max((o.matrix_world@v.co).x for o in objects.values() for v in o.data.vertices)-min((o.matrix_world@v.co).x for o in objects.values() for v in o.data.vertices)
xfit=.66/width
for o in objects.values():
 o.data.transform(Matrix.Diagonal((xfit,1,1,1)));o.location.x*=xfit
# Parenting preserves world-space transforms and pivots.
def parent(child,par):
 matrix=child.matrix_world.copy();child.parent=par;child.matrix_world=matrix
bpy.context.view_layer.update()
parent(objects['Wheel_F'],objects['Fork'])
for name in ['Pedal_L','Pedal_R']:parent(objects[name],objects['Crank'])
# Explicit local steering axes are included in metadata; local Y in glTF is vertical,
# steer about the supplied inclined axis (no hidden transform corrections).
sockets={'Seat_Rider':(0,-.82,.945),'Hand_L':(.26,-.408,1.064),'Hand_R':(-.26,-.408,1.064),'Foot_L':(.136,PED['L'][1],PED['L'][2]+.015),'Foot_R':(-.136,PED['R'][1],PED['R'][2]+.015),'Light_Head':(0,1.003,.606),'Light_Tail':(0,R-.364,.475)}
for name,pos in sockets.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=V((pos[0]*xfit,pos[1],pos[2]));bpy.context.view_layer.update()
 target='Handlebar' if name.startswith('Hand') else 'Fork' if name=='Light_Head' else 'Pedal_'+name[-1] if name.startswith('Foot') else 'Frame'
 parent(o,objects[target]);objects[name]=o
bpy.context.view_layer.update()
paint_colours=[('Bottle green',[.08,.30,.15]),('Midnight blue',[.028,.067,.13]),('Oxblood',[.28,.038,.027]),('Warm cream',[.72,.62,.43]),('Graphite',[.055,.062,.065]),('Petrol blue',[.035,.23,.25])]
# Export only requested nodes; no render helpers.
bpy.ops.object.select_all(action='DESELECT')
for o in objects.values():o.select_set(True)
path=ROOT/'cargo-bike.glb'
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT')
# glTF factor belongs outside the neutral image. Keep the exact linear contract.
data=path.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);tail=data[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=paint_colours[0][1]+[1]
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(tail))+struct.pack('<II',len(j),0x4e4f534a)+j+tail)
tri=sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH')
pts=[o.matrix_world@Vector(c) for o in objects.values() if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
def glpos(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
manifest={'units':'metres','front':'+Z','up':'+Y','left':'+X','nodes':{n:{'world_position':glpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'socket'} for n,o in objects.items()},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]],'dimensions':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]},'triangles':tri,'paints':[{'name':n,'linear_rgb':c} for n,c in paint_colours],'steering_axes':{'Fork':[0,.947,-.321],'Handlebar':[0,.976,-.217]},'spin_axis':[1,0,0]}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text(f'Two-wheel Dutch bakfiets with plywood child bench, working-part pivots, linked steering, laced wheels and retracted stand.\n{tri:,} triangles; {path.stat().st_size/1048576:.2f} MiB GLB; metres, Y up, front +Z.\n6 materials: Paint, Wood, Rubber, Metal, Leather, Lamps; neutral Paint texture and recolourable linear factor, clearcoat 1.0.\nCycles-baked reusable sets: base colour 512–1024 px, normals 512–1024 px, packed ORM 512 px; all embedded.\nPoly Haven CC0: plywood, brown_leather, rusty_metal_03 (https://polyhaven.com/license; https://polyhaven.com/a/<id>).\nReference differences: consistent two-wheel layout replaces contradictory small views; stand retracted; original round lamp; restrained wear and a black removable chaincase.\n')
print('EXPORTED',tri,path.stat().st_size,'BOUNDS',manifest['bounding_box'],flush=True)
# Connect recolour for Blender presentation after portable export.
nt=materials[0].node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*paint_colours[0][1],1);nt.links.new(painttex.outputs[0],mix.inputs[1]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
# Soft daylight studio: no HDR image dependencies.
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.72,.79,.86,1);world.node_tree.nodes['Background'].inputs[1].default_value=.42
bpy.ops.mesh.primitive_plane_add(size=200);floor=bpy.context.object;floor.name='Studio floor';floor.location.z=-.001
fm=bpy.data.materials.new('Studio only');fm.diffuse_color=(.52,.55,.54,1);fm.use_nodes=True;fm.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.52,.55,.54,1);fm.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8;floor.data.materials.append(fm)
def area(name,pos,energy,size):
 bpy.ops.object.light_add(type='AREA',location=pos);l=bpy.context.object;l.name=name;l.data.energy=energy;l.data.shape='DISK';l.data.size=size;l.rotation_euler=(Vector((0,0,.4))-l.location).to_track_quat('-Z','Y').to_euler()
area('large daylight',(3,-4,5),430,4);area('rim',(-3,1,4),320,3);area('front fill',(1,4,2.5),180,3)
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.lens=50
# 3/4 view shows long side, front and bench interior.
def camera(pos,scale):
 cam.location=pos;cam.rotation_euler=(Vector((0,0,.50))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
camera((4,-3,2.15),3.15)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
sc.render.image_settings.file_format='PNG';sc.cycles.samples=32
sc.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
camera((4,0,.50),3.03);sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
camera((4,-3,2.15),3.15)
clay=bpy.data.materials.new('Clay inspection');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.38,.38,.38,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.62
sc.view_layers[0].material_override=clay;sc.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
# Additional high-angle detail proof: bench, restraints and box construction.
camera((2.7,-3.3,3.9),2.95);sc.render.filepath=str(ROOT/'interior.png');bpy.ops.render.render(write_still=True)
camera((4,-3,2.15),3.15)
# Six same-angle recolours: image compositing uses Blender pixels, no extra dependencies.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
canvas=np.ones((720,1920,4),dtype=np.float32)
for i,(name,c) in enumerate(paint_colours):
 mix.inputs[2].default_value=(*c,1);sc.render.filepath=str(ROOT/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath,check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
 row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=arr;bpy.data.images.remove(im)
im=bpy.data.images.new('Paint variations',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
mix.inputs[2].default_value=(*paint_colours[0][1],1)
proof={'script_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'glb_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'blender':bpy.app.version_string,'engine':'Cycles','device':'GPU','bake_samples':8,'render_samples':32,'paint_samples':12,'sources':{ident:{'license':'CC0','asset_url':'https://polyhaven.com/a/'+ident} for ident in IDS},'renders':{n:hashlib.sha256((ROOT/n).read_bytes()).hexdigest() for n in ['final.png','side.png','clay.png','interior.png','paints.png']}}
(ROOT/'build-proof.json').write_text(json.dumps(proof,indent=2))
print('COMPLETE',flush=True)
