"""Dutch city bicycle. Clean-scene build, CC0 source download, Cycles GPU bake, GLB and review renders.
Blender 5.2: blender -b --python-exit-code 1 --python build_city-bike.py
Author space: X rider left, forward F; converted to Blender (X,-F,Z), then glTF Y-up.
Baked reusable material tiles preserve grain density; six materials, no external runtime images.
"""
import bpy, math, os, json, urllib.request, struct, time
import numpy as np
from mathutils import Vector
from pathlib import Path
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
START=time.time()
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.materials): bpy.data.materials.remove(d)
scene=bpy.context.scene
scene.render.engine='CYCLES'; scene.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=d.type=='OPTIX'
scene.cycles.device='GPU'
scene.cycles.use_denoising=True
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX'
scene.world.color=(.3,.3,.3)
scene.unit_settings.system='METRIC'
PAINTS=[('Carbon black',[.01,.01,.01]),('Bottle green',[.022,.095,.057]),('Bordeaux',[.19,.025,.034]),('Delft blue',[.027,.10,.20]),('Warm ivory',[.64,.55,.37]),('Storm teal',[.065,.23,.23])]
(ROOT/'build-scope.json').write_text(json.dumps({'started_utc':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),'scope':'Reference Dutch step-through bicycle; reusable tiled bakes and articulated nodes','cameras':['front three-quarter','orthographic right side','clay front three-quarter','six paint variants'],'budget':{'triangles':[20000,50000],'materials':6,'texture_max':1024,'orm_max':512,'glb_bytes':12000000},'revision_cap':3},indent=2))
# Required CC0 photographic inputs; never fetch non-Poly-Haven imagery.
texdir=ROOT/'textures'; texdir.mkdir(exist_ok=True)
PH=['brown_leather','metal_plate','rubber_tiles']
source={}
for ident in PH:
 for kind in ['diff','nor_gl','arm']:
  p=texdir/f'{ident}_{kind}_1k.jpg'
  if not p.exists():
   remote_kind='albedo' if ident=='brown_leather' and kind=='diff' else kind
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{remote_kind}_1k.jpg'
   urllib.request.urlretrieve(url,p)
  im=bpy.data.images.load(str(p),check_existing=True)
  if kind!='diff': im.colorspace_settings.name='Non-Color'
  source[ident,kind]=im
print('PH INPUTS READY',flush=True)
# Source shader factories, then baked to portable tile maps before rendering/export.
MATS={}; sources={}
def node(nt,typ): return nt.nodes.new(typ)
def scalar(nt,op,a,b):
 n=node(nt,'ShaderNodeMath'); n.operation=op
 for i,v in enumerate([a,b]):
  if hasattr(v,'node'): nt.links.new(v,n.inputs[i])
  else: n.inputs[i].default_value=v
 return n.outputs[0]
def source_mat(name,ident,color,rough,metal,strength=.12):
 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'); mapping=node(nt,'ShaderNodeVectorMath'); mapping.operation='SCALE'; mapping.inputs[3].default_value=1
 nt.links.new(uv.outputs['UV'],mapping.inputs[0])
 imgs={}
 for kind in ['diff','nor_gl','arm']:
  q=node(nt,'ShaderNodeTexImage'); q.image=source[ident,kind]; nt.links.new(mapping.outputs[0],q.inputs['Vector']); imgs[kind]=q
 bw=node(nt,'ShaderNodeRGBToBW'); nt.links.new(imgs['diff'].outputs['Color'],bw.inputs[0])
 noise=node(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=45; noise.inputs['Detail'].default_value=3; nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
 detail=scalar(nt,'ADD',scalar(nt,'MULTIPLY',bw.outputs[0],.17),.82)
 detail=scalar(nt,'MULTIPLY',detail,scalar(nt,'ADD',scalar(nt,'MULTIPLY',noise.outputs['Fac'],.10),.93))
 mix=node(nt,'ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=1; mix.inputs[1].default_value=(*color,1); nt.links.new(detail,mix.inputs[2]); base=mix.outputs[0]
 if name=='Leather':
  leather=node(nt,'ShaderNodeMixRGB'); leather.blend_type='MULTIPLY'; leather.inputs[0].default_value=1
  nt.links.new(imgs['diff'].outputs['Color'],leather.inputs[1]); leather.inputs[2].default_value=(1.6,1.35,1.2,1)
  nt.links.new(leather.outputs[0],mix.inputs[1])
 if name=='Lens':
  sep=node(nt,'ShaderNodeSeparateXYZ'); nt.links.new(uv.outputs['UV'],sep.inputs[0])
  ramp=node(nt,'ShaderNodeValToRGB'); ramp.color_ramp.interpolation='CONSTANT'; ramp.color_ramp.elements.remove(ramp.color_ramp.elements[1]); e=ramp.color_ramp.elements[0]; e.position=0; e.color=(.65,.76,.78,1)
  e=ramp.color_ramp.elements.new(1/3); e.color=(.55,.008,.003,1)
  e=ramp.color_ramp.elements.new(2/3); e.color=(.9,.25,.012,1)
  nt.links.new(sep.outputs[0],ramp.inputs[0]); base=ramp.outputs[0]
 nt.links.new(base,bs.inputs['Base Color'])
 sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(imgs['arm'].outputs['Color'],sep.inputs[0])
 roughout=scalar(nt,'ADD',scalar(nt,'MULTIPLY',sep.outputs['Green'],.12),rough-.06)
 nt.links.new(roughout,bs.inputs['Roughness']); bs.inputs['Metallic'].default_value=metal
 nor=node(nt,'ShaderNodeNormalMap'); nor.inputs['Strength'].default_value=strength; nt.links.new(imgs['nor_gl'].outputs['Color'],nor.inputs['Color'])
 bump=node(nt,'ShaderNodeBump'); bump.inputs['Strength'].default_value=.12; bump.inputs['Distance'].default_value=.0003; nt.links.new(noise.outputs['Fac'],bump.inputs['Height']); nt.links.new(nor.outputs[0],bump.inputs['Normal']); nt.links.new(bump.outputs[0],bs.inputs['Normal'])
 ao=scalar(nt,'ADD',scalar(nt,'MULTIPLY',sep.outputs['Red'],.18),.82)
 comb=node(nt,'ShaderNodeCombineColor'); nt.links.new(ao,comb.inputs['Red']); nt.links.new(roughout,comb.inputs['Green']); comb.inputs['Blue'].default_value=metal
 MATS[name]=m; sources[name]=(base,comb.outputs[0],bs,out)
 return m
source_mat('Paint','metal_plate',(.94,.94,.94),.46,0,.012)
source_mat('Chrome','metal_plate',(.66,.70,.73),.28,.94,.022)
source_mat('Leather','brown_leather',(.27,.105,.052),.43,0,.38)
source_mat('Tyre','rubber_tiles',(.66,.535,.335),.66,0,.11)
source_mat('Rubber','rubber_tiles',(.016,.019,.02),.57,0,.16)
source_mat('Lens','metal_plate',(.7,.75,.78),.16,.24,.10)
GROUPS={k:[] for k in ['Frame','Fork','Wheel_F','Wheel_R','Crank','Pedal_L','Pedal_R','Kickstand']}
def P(v): return Vector((v[0],-v[1],v[2] if v[2]<=1 else 1+(v[2]-1)*.945))
def register(o,group,mat):
 o.data.materials.append(MATS[mat]); GROUPS[group].append(o)
 for poly in o.data.polygons: poly.use_smooth=True
 return o
def mesh(name,vs,fs,group,mat,uvs=None):
 me=bpy.data.meshes.new(name); me.from_pydata([P(v) for v in vs],[],fs); me.update(); o=bpy.data.objects.new(name,me); bpy.context.collection.objects.link(o)
 if uvs:
  uv=me.uv_layers.new(name='UVMap')
  for p in me.polygons:
   for li in p.loop_indices: uv.data[li].uv=uvs[me.loops[li].vertex_index]
 return register(o,group,mat)
def tube(name,pts,r,group='Frame',mat='Paint',sides=12,closed=False):
 pts=[Vector(p) for p in pts]; vs=[]; uv=[]; fs=[]; dist=0
 for i,p in enumerate(pts):
  if i: dist+=(p-pts[i-1]).length
  d=(pts[(i+1)%len(pts)]-pts[i-1] if closed else pts[min(i+1,len(pts)-1)]-pts[max(i-1,0)]).normalized()
  helper=Vector((1,0,0)) if abs(d.x)<.9 else Vector((0,1,0))
  a=d.cross(helper).normalized(); b=d.cross(a).normalized()
  for j in range(sides):
   t=j*2*math.pi/sides; vs.append(p+r*(a*math.cos(t)+b*math.sin(t))); uv.append((j/sides,dist*5))
 for i in range(len(pts) if closed else len(pts)-1):
  for j in range(sides): fs.append((i*sides+j,i*sides+(j+1)%sides,((i+1)%len(pts))*sides+(j+1)%sides,((i+1)%len(pts))*sides+j))
 if not closed: fs.extend([tuple(reversed(range(sides))),tuple((len(pts)-1)*sides+j for j in range(sides))])
 return mesh(name,vs,fs,group,mat,uv)
def rod(name,a,b,r,group='Frame',mat='Chrome',sides=12): return tube(name,[a,b],r,group,mat,sides)
def smoothpath(points,n=8):
 pts=[Vector(p) for p in points]; out=[]
 for i in range(len(pts)-1):
  p0=pts[max(i-1,0)]; p1=pts[i]; p2=pts[i+1]; p3=pts[min(i+2,len(pts)-1)]
  for j in range(n):
   t=j/n; out.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t))
 out.append(pts[-1]); return out
def curve(name,pts,r,group='Frame',mat='Paint',sides=12,n=8): return tube(name,smoothpath(pts,n),r,group,mat,sides)
def ball(name,pos,scale,group='Frame',mat='Chrome',seg=20,rings=12):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=P(pos)); o=bpy.context.object; o.name=name; o.scale=scale
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True); return register(o,group,mat)
def box(name,pos,size,group='Frame',mat='Paint',bev=.003):
 bpy.ops.mesh.primitive_cube_add(size=1,location=P(pos)); o=bpy.context.object; o.name=name; o.scale=(size[0],size[1],size[2]); bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if bev:
  md=o.modifiers.new('Machined roundovers','BEVEL'); md.width=bev; md.segments=3; bpy.ops.object.modifier_apply(modifier=md.name)
 return register(o,group,mat)
def ring(name,c,major,minor,group,mat,n=96,m=10,axial=None):
 # Wheel-plane ring: axle along X, revolution in F-Z plane.
 vs=[]; fs=[]; uv=[]
 for i in range(n):
  t=2*math.pi*i/n
  for j in range(m):
   a=2*math.pi*j/m; rr=major+minor*math.cos(a)
   vs.append((c[0]+(axial or minor)*math.sin(a),c[1]+rr*math.sin(t),c[2]+rr*math.cos(t))); uv.append((i/n*4,j/m))
 for i in range(n):
  for j in range(m): fs.append((i*m+j,((i+1)%n)*m+j,((i+1)%n)*m+(j+1)%m,i*m+(j+1)%m))
 return mesh(name,vs,fs,group,mat,uv)
def bolt(pos,group='Frame',axis=(1,0,0),radius=.005):
 p=Vector(pos); d=Vector(axis); rod('Hex fastener',p-d*.002,p+d*.003,radius,group,'Chrome',6)
 # A dark inset socket catches light, no coplanar overlap.
 rod('Fastener socket',p+d*.003,p+d*.0035,radius*.40,group,'Rubber',6)
def lensuv(o,zone):
 if not o.data.uv_layers: o.data.uv_layers.new(name='UVMap')
 for l in o.data.uv_layers.active.data: l.uv=((zone+.5)/3,.5)
# Wheels: 28 inches outer diameter, 1.1688 m wheelbase = 1.88 m overall.
R=.3556; W=.5703
for name,f in [('Wheel_F',W),('Wheel_R',-W)]:
 c=(0,f,R)
 ring('Cream pneumatic tyre',c,.334,.0216,name,'Tyre',128,14,axial=.021)
 ring('Rolled alloy rim',c,.310,.0105,name,'Chrome',128,10,axial=.014)
 for x in [-.016,.016]:
  ring('Moulded tyre bead',(x,f,R),.322,.0015,name,'Tyre',96,6)
  ring('Sidewall mould line',(x,f,R),.344,.0006,name,'Tyre',96,5)
 # Fine raised chevrons across the running surface, 120 paired treads.
 for i in range(100):
  t=2*math.pi*i/100
  pts=[]
  for x,shift,rr in [(-.013,-.012,.3508),(0,0,.35485),(.013,-.012,.3508)]:
   a=t+shift; pts.append((x,f+rr*math.sin(a),R+rr*math.cos(a)))
  tube('Moulded herringbone tread',pts,.00075,name,'Tyre',5)
 rod('Hub barrel',(-.033,f,R),(.033,f,R),.019,name,'Chrome',24)
 for x in [-.027,.027]: ring('Spoke flange',(x,f,R),.025,.003,name,'Chrome',24,6,axial=.003)
 for i in range(36):
  t=2*math.pi*i/36; x=.026 if i%2 else -.026; a=t+(.36 if (i//2)%2 else -.36)
  p=(x,f+.025*math.sin(a),R+.025*math.cos(a)); q=(.009 if x>0 else -.009,f+.304*math.sin(t),R+.304*math.cos(t))
  rod('Cross-laced stainless spoke',p,q,.00085,name,'Chrome',5)
  p2=Vector(q)+(Vector(p)-Vector(q)).normalized()*.010
  rod('Spoke nipple',p2,q,.00165,name,'Chrome',6)
 rod('Axle',(-.06,f,R),(.06,f,R),.006,name,'Chrome',12)
 for x in [-.053,.053]: bolt((x,f,R),name,radius=.0075)
 rod('Presta valve',(0,f-.302,R),(0,f-.278,R),.0025,name,'Chrome',8)
 rod('Valve dust cap',(0,f-.282,R),(0,f-.276,R),.0032,name,'Rubber',8)
# Main steel frame: two elegant swept curves with genuinely low step-through.
BB=(0,-.115,.285); ST=(0,-.315,.79); HB=(0,.375,.735); HT=(0,.29,.947)
rod('Seat tube',BB,ST,.017,'Frame','Paint',16)
curve('Swept lower down tube',[BB,(0,.015,.405),(0,.22,.62),HB],.0195,'Frame','Paint',16,12)
curve('Deep step-through upper tube',[(0,-.19,.435),(0,-.025,.49),(0,.125,.60),(0,.235,.79),HT],.0145,'Frame','Paint',16,12)
rod('Head tube',HB,HT,.024,'Frame','Paint',20)
rod('Bottom bracket sleeve',(-.052,-.115,.285),(.052,-.115,.285),.032,'Frame','Paint',24)
for x in [-.036,.036]:
 curve('Rear chainstay',[(x,-.115,.285),(x*1.6,-.34,.30),(x*1.5,-W,R)],.0105,'Frame','Paint',12)
 rod('Slender seat stay',(x,-.308,.75),(x*1.5,-W,R),.009,'Frame','Paint',12)
 box('Rear dropout',(x*1.5,-W,R),(.009,.047,.036),'Frame','Paint',.007)
 bolt((x*1.67,-W,R),radius=.008)
# Seat post collar and sculpted saddle.
rod('Seat post',(0,-.309,.77),(0,-.358,.899),.0115,'Frame','Chrome',16)
rod('Seat clamp collar',(0,-.31,.774),(0,-.322,.805),.020,'Frame','Chrome',16)
bolt((-.025,-.319,.793),radius=.006)
for x in [-.058,.058]:
 pts=[(x+.014*math.cos(i*2*math.pi/16),-.414+.014*math.sin(i*2*math.pi/16),.826+.037*i/64) for i in range(65)]
 tube('Saddle suspension coil',pts,.0025,'Frame','Chrome',7)
 curve('Saddle underside rail',[(x,-.43,.831),(x*.65,-.34,.838),(x*.35,-.215,.866)],.0035,'Frame','Chrome',8)
# Saddle cross-section loft with domed upper and tucked underside.
sections=[(-.187,.012,.883,.014),(-.21,.03,.883,.021),(-.28,.046,.881,.027),(-.35,.088,.887,.032),(-.42,.094,.895,.030),(-.45,.071,.90,.025),(-.458,.028,.898,.013)]
vs=[]; fs=[]; uv=[]; N=20
for k,(f,w,z,h) in enumerate(sections):
 for j in range(N):
  a=2*math.pi*j/N; vs.append((w*math.cos(a),f,z+h*math.sin(a))); uv.append((j/N,k/(len(sections)-1)))
for k in range(len(sections)-1):
 for j in range(N): fs.append((k*N+j,k*N+(j+1)%N,(k+1)*N+(j+1)%N,(k+1)*N+j))
fs.extend([tuple(reversed(range(N))),tuple((len(sections)-1)*N+j for j in range(N))])
saddle=mesh('Hand-shaped sprung leather saddle',vs,fs,'Frame','Leather',uv)
bpy.context.view_layer.objects.active=saddle; saddle.select_set(True)
md=saddle.modifiers.new('Leather curvature','SUBSURF'); md.levels=2; bpy.ops.object.modifier_apply(modifier=md.name)
for s in [-1,1]:
 curve('Saddle edge piping',[(s*w,f,z-.006) for f,w,z,h in sections],.0015,'Frame','Leather',7,5)
 for f,w,z,h in sections[2:6]: ball('Saddle copper rivet',(s*(w-.003),f,z+.004),(.0027,.0027,.0027),'Frame','Chrome',12,8)
# Steering: raked axis meets front axle via curved fork blades.
for z,f in [(.737,.374),(.946,.290)]:
 rod('Headset bearing',(0,f+.004,z-.011),(0,f-.004,z+.011),.028,'Fork','Chrome',24)
rod('Chrome quill',(0,.287,.95),(0,.242,1.064),.012,'Fork','Chrome',16)
curve('Forward stem',[(0,.242,1.04),(0,.225,1.075),(0,.20,1.082)],.012,'Fork','Chrome',16)
for x in [-.041,.041]:
 curve('Tapered fork blade',[(x,.375,.745),(x,.432,.584),(x,.49,.414),(x,W,R)],.013,'Fork','Paint',16,10)
 box('Fork dropout',(x,W,R),(.011,.034,.026),'Fork','Paint',.006)
 bolt((x*1.42,W,R),'Fork',radius=.008)
rod('Fork crown',(-.044,.373,.746),(.044,.373,.746),.017,'Fork','Paint',18)
# Swept upright bars: overall outside width exactly 0.62 m.
curve('Swept chrome handlebar',[(-.303,.094,1.1),(-.23,.135,1.112),(-.17,.21,1.074),(0,.20,1.077),(.17,.21,1.074),(.23,.135,1.112),(.303,.094,1.1)],.011,'Fork','Chrome',16,7)
GRIPS={}
for s,label in [(1,'L'),(-1,'R')]:
 a=(s*.232,.135,1.11); b=(s*.303,.094,1.10)
 rod('Leather handgrip '+label,a,b,.014,'Fork','Leather',20); GRIPS[label]=tuple((Vector(a)+Vector(b))/2)
 for t in [0,.93]:
  p=Vector(a).lerp(Vector(b),t); q=Vector(a).lerp(Vector(b),min(1,t+.055)); rod('Grip end collar',p,q,.0146,'Fork','Chrome',16)
 rod('Brake lever clamp',(s*.205,.168,1.098),(s*.224,.155,1.103),.014,'Fork','Chrome',16)
 curve('Forged brake lever',[(s*.212,.151,1.084),(s*.234,.100,1.071),(s*.288,.066,1.070)],.0038,'Fork','Chrome',10)
 # Flexible control line loops dip gracefully in front of headstock.
 curve('Brake cable housing',[(s*.211,.15,1.085),(s*.16,.32,1.06),(s*.08,.385,.90),(s*.04,.40,.735)],.0021,'Fork','Rubber',7,10)
# Bell dome and thumb striker.
rod('Bell stalk',(.174,.2,1.082),(.174,.2,1.113),.004,'Fork','Chrome',10)
ball('Polished bicycle bell',(.174,.2,1.112),(.025,.025,.009),'Fork','Chrome',24,10)
rod('Bell thumb lever',(.18,.19,1.104),(.203,.171,1.099),.003,'Fork','Rubber',8)
# Mudguards: real curved sheet cross-section, rolled outer lips, front and rear support stays.
def fender(name,f,start,end,group):
 vs=[]; fs=[]; uv=[]; nr=65; nc=9
 for i in range(nr):
  t=math.radians(start+(end-start)*i/(nr-1))
  for j in range(nc):
   a=-1.18+2.36*j/(nc-1); x=.032*math.sin(a); rr=.366+.013*math.cos(a)
   vs.append((x,f+rr*math.sin(t),R+rr*math.cos(t))); uv.append((j/(nc-1),i/(nr-1)*3))
 for i in range(nr-1):
  for j in range(nc-1): fs.append((i*nc+j,(i+1)*nc+j,(i+1)*nc+j+1,i*nc+j+1))
 ob=mesh(name,vs,fs,group,'Paint',uv); bpy.context.view_layer.objects.active=ob
 md=ob.modifiers.new('Steel sheet thickness','SOLIDIFY'); md.thickness=.0015; bpy.ops.object.modifier_apply(modifier=md.name)
 for s in [-1,1]:
  pts=[(s*.0296,f+.371*math.sin(math.radians(start+(end-start)*i/(nr-1))),R+.371*math.cos(math.radians(start+(end-start)*i/(nr-1)))) for i in range(nr)]
  tube('Rolled mudguard edge',pts,.0015,group,'Paint',6)
 return ob
fender('Front wraparound mudguard',W,-104,72,'Fork')
fender('Rear wraparound mudguard',-W,-104,94,'Frame')
for f,g,t in [(W,'Fork',-88),(-W,'Frame',-77),(-W,'Frame',65)]:
 for s in [-1,1]:
  q=(s*.034,f+.374*math.sin(math.radians(t)),R+.374*math.cos(math.radians(t)))
  rod('Mudguard wire stay',(s*.05,f,R),q,.0028,g,'Chrome',8); bolt(q,g,radius=.003)
# Rear skirt flap.
box('Rear rubber splash flap',(0,-.943,.308),(.058,.006,.064),'Frame','Rubber',.004)
# Front and rear rim brakes with pads and cables.
for f,g in [(W,'Fork'),(-W,'Frame')]:
 for s in [-1,1]:
  curve('Caliper arm',[(s*.004,f-.025,.735),(s*.039,f-.03,.715),(s*.041,f-.045,.651)],.004,g,'Chrome',10)
  box('Rim brake pad',(s*.022,f-.047,.651),(.010,.033,.013),g,'Rubber',.004)
  bolt((s*.038,f-.047,.66),g,radius=.004)
 rod('Brake bridge',(-.025,f-.025,.725),(.025,f-.025,.725),.006,g,'Chrome',10)
curve('Rear brake line',[(.018,.28,.92),(.019,.15,.64),(.02,-.19,.445),(.024,-.31,.77),(.034,-.56,.733)],.0018,'Frame','Rubber',7,12)
# Rear luggage rack with framed deck, six slats and four real mounting stays.
curve('Rear rack perimeter',[(-.074,-.79,.773),(-.079,-.81,.773),(.079,-.81,.773),(.079,-.49,.773),(.065,-.42,.75),(-.065,-.42,.75),(-.079,-.49,.773),(-.074,-.79,.773)],.0055,'Frame','Paint',10,5)
for x in [-.038,0,.038]: rod('Rack longitudinal rail',(x,-.798,.773),(x,-.478,.773),.0035,'Frame','Paint',8)
for f in [-.75,-.62,-.49]: rod('Rack crossmember',(-.075,f,.773),(.075,f,.773),.0035,'Frame','Paint',8)
for s in [-1,1]:
 for f in [-.75,-.49]: rod('Rear rack support',(s*.051,-W,R+.012),(s*.074,f,.765),.0045,'Frame','Paint',10)
 bolt((s*.08,-.72,.773),radius=.004)
curve('Rack spring cargo clip',[(-.043,-.5,.783),(-.043,-.75,.783),(.043,-.75,.783),(.043,-.5,.783)],.0026,'Frame','Chrome',8,4)
# The reference front basket is an open Dutch porter rack with tall back.
curve('Cargo deck perimeter',[(-.155,.39,.792),(-.166,.41,.792),(-.166,.695,.792),(-.153,.719,.792),(.153,.719,.792),(.166,.695,.792),(.166,.41,.792),(.155,.39,.792),(-.155,.39,.792)],.007,'Fork','Paint',12,4)
curve('Cargo upright back',[(-.158,.402,.797),(-.157,.364,1.012),(-.139,.360,1.034),(.139,.36,1.034),(.157,.364,1.012),(.158,.402,.797)],.007,'Fork','Paint',12,5)
for z in [.86,.94]: rod('Cargo back crossbar',(-.157,.395-(z-.8)*.18,z),(.157,.395-(z-.8)*.18,z),.005,'Fork','Paint',10)
for x in [-.11,-.055,0,.055,.11]: rod('Cargo floor slat',(x,.40,.792),(x,.707,.792),.004,'Fork','Paint',8)
for f in [.49,.62]: rod('Cargo underside crossmember',(-.158,f,.782),(.158,f,.782),.004,'Fork','Paint',8)
for s in [-1,1]:
 rod('Cargo rack axle strut',(s*.046,W,R+.012),(s*.146,.665,.787),.006,'Fork','Paint',10)
 rod('Cargo rack headstay',(s*.025,.35,.808),(s*.147,.402,.811),.005,'Fork','Paint',10)
 bolt((s*.148,.403,.83),'Fork',radius=.006)
box('Cargo rear mounting plate',(0,.372,.98),(.088,.006,.077),'Fork','Paint',.005)
for x in [-.03,.03]:
 for z in [.956,1.007]: bolt((x,.368,z),'Fork',axis=(0,-1,0),radius=.004)
# Original dynamo headlamp: compact silver shell, recessed prismatic round lens.
rod('Headlamp bracket',(0,.654,.773),(0,.67,.742),.006,'Fork','Chrome',10)
rod('Headlamp cylindrical shell',(0,.643,.738),(0,.695,.738),.037,'Fork','Chrome',32)
ball('Headlamp domed back',(0,.643,.738),(.036,.028,.035),'Fork','Chrome',24,12)
rod('Headlamp rubber bezel',(0,.693,.738),(0,.699,.738),.034,'Fork','Rubber',32)
l=ball('Prismatic headlamp lens',(0,.700,.738),(.030,.007,.030),'Fork','Lens',28,12); lensuv(l,0)
for x in [-.022,-.014,-.006,.006,.014,.022]:
 h=math.sqrt(.028**2-x*x); o=rod('Headlamp lens fluting',(x,.707,.738-h),(x,.707,.738+h),.0009,'Fork','Lens',6); lensuv(o,0)
# Bottle dynamo contacts rear tyre; wire routes under rack to tail light.
rod('Dynamo bottle',(.05,-.46,.604),(.065,-.478,.657),.013,'Frame','Chrome',16)
rod('Dynamo contact roller',(.065,-.478,.657),(.07,-.484,.677),.007,'Frame','Rubber',12)
rod('Dynamo mount',(.036,-.46,.60),(.06,-.46,.614),.004,'Frame','Chrome',10)
curve('Dynamo electrical wire',[(.05,-.46,.60),(.034,-.52,.71),(.04,-.72,.765),(0,-.817,.755)],.0011,'Frame','Rubber',6)
box('Tail light housing',(0,-.817,.755),(.061,.025,.032),'Frame','Rubber',.008)
l=box('Red rear lamp lens',(0,-.832,.756),(.05,.006,.022),'Frame','Lens',.006); lensuv(l,1)
l=box('Rear fender reflector',(0,-.944,.385),(.025,.005,.041),'Frame','Lens',.006); lensuv(l,1)
# Full chain guard, tapered shell around the chain and rounded crank cover.
# Side profile joins tangent-like rounded ends, shaped as pressed sheet, on rider right.
profile=[]
for i in range(33):
 a=math.pi/2-i*math.pi/32; profile.append((-.115+.096*math.cos(a),.285+.096*math.sin(a)))
for i in range(25):
 a=-math.pi/2-i*math.pi/24; profile.append((-W+.046*math.cos(a),R+.046*math.sin(a)))
vs=[]
for x in [-.064,-.082]: vs.extend([(x,f,z) for f,z in profile])
N=len(profile); fs=[tuple(reversed(range(N))),tuple(N+i for i in range(N))]
for i in range(N): fs.append((i,(i+1)%N,(i+1)%N+N,i+N))
case=mesh('Fully enclosed pressed chain case',vs,fs,'Frame','Paint')
bpy.context.view_layer.objects.active=case
md=case.modifiers.new('Pressed case edge','BEVEL'); md.width=.003; md.segments=3; bpy.ops.object.modifier_apply(modifier=md.name)
tube('Chain case perimeter seam',[(-.083,f,z) for f,z in profile],.0017,'Frame','Chrome',7,True)
ring('Chain case service panel',(-.084,-.115,.285),.082,.0013,'Frame','Paint',64,6)
for f,z in [(-.42,.372),(-.50,.32),(-.25,.244)]: bolt((-.086,f,z),radius=.003)
curve('Pressed chain case rib',[(-.085,-.53,.367),(-.085,-.40,.362),(-.085,-.29,.355),(-.085,-.22,.345)],.0023,'Frame','Paint',8,8)
# Actual chain concealed within its full guard; link pins and paired continuous rollers.
for x in [-.068,-.074]:
 tube('Enclosed chain side plates',[(x,f,z) for f,z in profile],.002,'Frame','Chrome',6,True)
for i in range(42):
 t=i/41; f=-.54+t*.40
 for z in [.375,.305-.035*t]: rod('Enclosed chain roller',(-.074,f,z),(-.068,f,z),.0022,'Frame','Chrome',6)
# Crank axis and arms. Pedals remain level and independently pivoted.
ring('Enclosed chainring',(-.063,-.115,.285),.074,.004,'Crank','Chrome',48,8)
rod('Crank axle',(-.101,-.115,.285),(.10,-.115,.285),.012,'Crank','Chrome',20)
ring('Crank cover polished ring',(-.092,-.115,.285),.025,.004,'Crank','Chrome',32,8)
PEDALS={}
for s,label in [(1,'L'),(-1,'R')]:
 f=-.115+s*-.153; z=.285+s*.038
 curve('Forged crank arm '+label,[(s*.095,-.115,.285),(s*.103,-.115+s*-.074,.285+s*.015),(s*.11,f,z)],.010,'Crank','Chrome',12,6)
 bolt((s*.108,-.115,.285),'Crank',radius=.008)
 rod('Pedal spindle '+label,(s*.108,f,z),(s*.167,f,z),.006,'Pedal_'+label,'Chrome',12)
 pos=(s*.165,f,z); PEDALS[label]=pos
 box('Pedal rubber block '+label,pos,(.087,.061,.018),'Pedal_'+label,'Rubber',.005)
 # Open cage perimeter, grip ribs, amber safety reflectors.
 for a in [-1,1]:
  rod('Pedal polished cage',(s*.126,f+a*.029,z),(s*.205,f+a*.029,z),.003,'Pedal_'+label,'Chrome',8)
  o=box('Pedal amber reflector',(s*.166,f+a*.031,z),(.051,.002,.010),'Pedal_'+label,'Lens',.002); lensuv(o,2)
 for x in [.135,.15,.165,.18,.195]: box('Pedal traction rib',(s*x,f,z+.011),(.003,.052,.004),'Pedal_'+label,'Rubber',.001)
# Retracted double kickstand, attached just behind bottom bracket and folded aft.
box('Kickstand pivot bracket',(0,-.18,.275),(.075,.055,.025),'Frame','Paint',.004)
rod('Stand pivot pin',(-.048,-.18,.269),(.048,-.18,.269),.006,'Kickstand','Chrome',12)
for s in [-1,1]:
 curve('Folded kickstand leg',[(s*.037,-.18,.269),(s*.045,-.25,.238),(s*.075,-.44,.252)],.006,'Kickstand','Chrome',10)
 box('Kickstand foot',(s*.075,-.439,.252),(.022,.031,.014),'Kickstand','Rubber',.004)
# Join rigid assemblies, retain source UVs; generate missing UVs before joining.
objects={}
for name,items in GROUPS.items():
 for o in items:
  if not o.data.uv_layers:
   bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o
   bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT'); bpy.ops.uv.smart_project(angle_limit=1.15,island_margin=.02); bpy.ops.object.mode_set(mode='OBJECT')
 bpy.ops.object.select_all(action='DESELECT')
 for o in items: o.select_set(True)
 bpy.context.view_layer.objects.active=items[0]; bpy.ops.object.join(); ob=bpy.context.object; ob.name=name
 bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
 # Mesh-level normals recalculation; UV-preserving weld only coincident geometry.
 import bmesh
 bm=bmesh.new(); bm.from_mesh(ob.data); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bm.to_mesh(ob.data); bm.free()
 objects[name]=ob
# Object origins on physical axes, without leaving unapplied scale or rotation.
pivots={'Frame':(0,0,0),'Fork':HT,'Wheel_F':(0,W,R),'Wheel_R':(0,-W,R),'Crank':BB,'Kickstand':(0,-.18,.269),'Pedal_L':(.11,PEDALS['L'][1],PEDALS['L'][2]),'Pedal_R':(-.11,PEDALS['R'][1],PEDALS['R'][2])}
for name,ob in objects.items():
 bpy.ops.object.select_all(action='DESELECT'); ob.select_set(True); bpy.context.view_layer.objects.active=ob; scene.cursor.location=P(pivots[name]); bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
def parent_keep(ob,par):
 w=ob.matrix_world.copy(); ob.parent=par; ob.matrix_world=w
parent_keep(objects['Wheel_F'],objects['Fork'])
for side in ['L','R']: parent_keep(objects['Pedal_'+side],objects['Crank'])
sockets={
 'Seat_Rider':((0,-.354,.925),'Frame'),
 'Hand_L':(GRIPS['L'],'Fork'),'Hand_R':(GRIPS['R'],'Fork'),
 'Foot_L':((PEDALS['L'][0],PEDALS['L'][1],PEDALS['L'][2]+.014),'Pedal_L'),
 'Foot_R':((PEDALS['R'][0],PEDALS['R'][1],PEDALS['R'][2]+.014),'Pedal_R'),
 'Light_Head':((0,.708,.738),'Fork'),'Light_Tail':((0,-.837,.756),'Frame')}
for name,(pos,par) in sockets.items():
 ob=bpy.data.objects.new(name,None); bpy.context.collection.objects.link(ob); ob.location=P(pos); parent_keep(ob,objects[par]); objects[name]=ob
# Ensure no zero-area triangles survive bevel/cap tessellation.
for ob in objects.values():
 if ob.type!='MESH': continue
 bm=bmesh.new(); bm.from_mesh(ob.data); bmesh.ops.triangulate(bm,faces=list(bm.faces)); bad=[f for f in bm.faces if f.calc_area()<1e-13]
 if bad: bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(ob.data); bm.free()
raw_triangles=sum(len(ob.data.polygons) for ob in objects.values() if ob.type=='MESH')
# Conservative collapse across finely tessellated pipes; retain ~46k real triangles.
if raw_triangles>48000:
 for ob in objects.values():
  if ob.type!='MESH': continue
  bpy.context.view_layer.objects.active=ob
  md=ob.modifiers.new('Game mesh optimization','DECIMATE'); md.ratio=46500/raw_triangles
  bpy.ops.object.modifier_apply(modifier=md.name)
  bm=bmesh.new(); bm.from_mesh(ob.data); bmesh.ops.triangulate(bm,faces=list(bm.faces))
  bad=[f for f in bm.faces if f.calc_area()<1e-13]
  if bad: bmesh.ops.delete(bm,geom=bad,context='FACES')
  bm.to_mesh(ob.data); bm.free()
# Remove sub-millimetre tread overshoot at the contact patch; wheel origins stay on their true axes.
for name in ['Wheel_F','Wheel_R']:
 ob=objects[name]
 for v in ob.data.vertices:
  worldz=(ob.matrix_world@v.co).z
  if worldz<0: v.co.z-=worldz
triangles=sum(len(ob.data.polygons) for ob in objects.values() if ob.type=='MESH')
print('TRIANGLES',triangles,flush=True)
assert 20000<=triangles<=50000,triangles

# Stable stage lighting/cameras; never included in the selected GLB export.
world=bpy.data.worlds.new('Soft overcast studio'); scene.world=world; world.use_nodes=True; world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.80,1); world.node_tree.nodes['Background'].inputs[1].default_value=.45
floor_mat=bpy.data.materials.new('Stage only'); floor_mat.diffuse_color=(.24,.25,.24,1); floor_mat.use_nodes=True; floor_mat.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.24,.25,.24,1); floor_mat.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.82
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.001)); floor=bpy.context.object; floor.name='STAGE'; floor.data.materials.append(floor_mat)
def area(name,pos,power,size,color):
 bpy.ops.object.light_add(type='AREA',location=P(pos)); o=bpy.context.object; o.name=name; o.data.energy=power; o.data.shape='DISK'; o.data.size=size; o.data.color=color; o.rotation_euler=(P((0,0,.5))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight softbox',(-3,1.7,4),420,4,(1,.94,.86)); area('Cool fill',(2,1.0,2.3),260,3,(.8,.89,1)); area('Edge strip',(1,-3,3),380,3,(1,1,1))
bpy.ops.object.camera_add(); camera=bpy.context.object; scene.camera=camera
# Frame from asset bounds, orthographic hero avoids exaggerated wheel size.
points=[ob.matrix_world@Vector(c) for ob in objects.values() if ob.type=='MESH' for c in ob.bound_box]
lo=Vector([min(v[i] for v in points) for i in range(3)]); hi=Vector([max(v[i] for v in points) for i in range(3)]); target=(lo+hi)/2
camera.data.type='ORTHO'; camera.data.ortho_scale=2.45
hero_dir=Vector((-3.4,-2.4,1.55)).normalized()
def cam(dir,scale):
 camera.location=target+Vector(dir).normalized()*4; camera.rotation_euler=(target-camera.location).to_track_quat('-Z','Y').to_euler(); camera.data.ortho_scale=scale
cam(hero_dir,2.60)
def render(file,w=1920,h=1080,samples=24):
 scene.render.resolution_x=w; scene.render.resolution_y=h; scene.render.resolution_percentage=100; scene.cycles.samples=samples; scene.render.filepath=str(ROOT/file); bpy.ops.render.render(write_still=True)
# Clay evidence is produced before any texture bake is trusted.
clay=bpy.data.materials.new('Clay review only'); clay.use_nodes=True; clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.36,.36,.36,1); clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.65
scene.view_layers[0].material_override=clay
render('clay.png',1920,1080,20)
scene.view_layers[0].material_override=None
print('CLAY COMPLETE',flush=True)
# Bake 6 reusable source shaders on a UV swatch. Modest GPU samples, genuine Cycles normal/emit passes.
# Tile AO is from registered scan ARM; no static shadows are frozen across moving parts.
for ob in list(scene.objects): ob.hide_render=True
bpy.ops.mesh.primitive_plane_add(size=1); swatch=bpy.context.object; swatch.name='BAKE_SWATCH'; swatch.hide_render=False
baked={}
scene.cycles.samples=8; scene.render.bake.margin=8; scene.render.bake.use_clear=True
for name,m in MATS.items():
 swatch.data.materials.clear(); swatch.data.materials.append(m)
 bpy.ops.object.select_all(action='DESELECT'); swatch.select_set(True); bpy.context.view_layer.objects.active=swatch
 nt=m.node_tree; base,orm,bs,out=sources[name]
 targetnode=node(nt,'ShaderNodeTexImage'); emission=node(nt,'ShaderNodeEmission')
 maps={}
 for kind,res in [('base',1024 if name in ['Leather','Tyre'] else 512),('normal',1024 if name=='Leather' else 512),('orm',256)]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
  if kind!='base': im.colorspace_settings.name='Non-Color'
  targetnode.image=im; nt.nodes.active=targetnode
  for q in nt.nodes: q.select=False
  targetnode.select=True
  if kind=='normal':
   nt.links.new(bs.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='NORMAL')
  else:
   nt.links.new(base if kind=='base' else orm,emission.inputs[0]); nt.links.new(emission.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(texdir/(name+'_'+kind+'.png')); im.file_format='PNG'; im.save(); maps[kind]=im
 nt.nodes.clear()
 out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled'); nt.links.new(bs.outputs[0],out.inputs[0])
 imnodes={}
 for kind,im in maps.items():
  q=node(nt,'ShaderNodeTexImage'); q.image=im; imnodes[kind]=q
 nt.links.new(imnodes['base'].outputs['Color'],bs.inputs['Base Color'])
 sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(imnodes['orm'].outputs['Color'],sep.inputs[0]); nt.links.new(sep.outputs['Green'],bs.inputs['Roughness']); nt.links.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 normal=node(nt,'ShaderNodeNormalMap'); nt.links.new(imnodes['normal'].outputs['Color'],normal.inputs['Color']); nt.links.new(normal.outputs[0],bs.inputs['Normal'])
 # glTF occlusion convention node group.
 gn=bpy.data.node_groups.get('glTF Material Output')
 if not gn:
  gn=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); gn.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 go=node(nt,'ShaderNodeGroup'); go.node_tree=gn; nt.links.new(sep.outputs['Red'],go.inputs['Occlusion'])
 if name=='Paint': bs.inputs['Coat Weight'].default_value=0  # satin/matt enamel, not glossy clearcoat
 baked[name]=(bs,imnodes)
 print('BAKED',name,flush=True)
bpy.data.objects.remove(swatch,do_unlink=True)
for ob in scene.objects: ob.hide_render=False
# Export selection only. glTF's standard Y-up conversion maps Blender -Y to front +Z.
bpy.ops.object.select_all(action='DESELECT')
for ob in objects.values(): ob.select_set(True)
bpy.context.view_layer.objects.active=objects['Frame']
path=ROOT/'city-bike.glb'
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_extras=True)
# Set Paint baseColorFactor in serialized glTF explicitly, preserving neutral baked base pixels.
data=path.read_bytes(); ln,typ=struct.unpack_from('<II',data,12); doc=json.loads(data[20:20+ln]); binary=data[20+ln:]
for mat in doc['materials']:
 if mat['name']=='Paint':
  mat['pbrMetallicRoughness']['baseColorFactor']=[.01,.01,.01,1]
payload=json.dumps(doc,separators=(',',':')).encode(); payload+=b' '*((-len(payload))%4)
path.write_bytes(struct.pack('<III',0x46546C67,2,12+8+len(payload)+len(binary))+struct.pack('<II',len(payload),0x4E4F534A)+payload+binary)
assert path.stat().st_size<12000000
# Add render-side multiply only after portable texture-only export.
bs,imnodes=baked['Paint']; nt=MATS['Paint'].node_tree; paintmix=node(nt,'ShaderNodeMixRGB'); paintmix.blend_type='MULTIPLY'; paintmix.inputs[0].default_value=1; nt.links.new(imnodes['base'].outputs['Color'],paintmix.inputs[1]); paintmix.inputs[2].default_value=(.01,.01,.01,1); nt.links.new(paintmix.outputs[0],bs.inputs['Base Color'])
# JSON positions are world-space glTF, not Blender coordinates.
def G(v): return [round(v.x,6),round(v.z,6),round(-v.y,6)]
bpy.context.view_layer.update()
positions={name:{'position':G(ob.matrix_world.translation),'parent':ob.parent.name if ob.parent else None,'type':'mesh' if ob.type=='MESH' else 'socket'} for name,ob in objects.items()}
points=[ob.matrix_world@v.co for ob in objects.values() if ob.type=='MESH' for v in ob.data.vertices]
pts=[G(v) for v in points]; bounds={'min':[min(v[i] for v in pts) for i in range(3)],'max':[max(v[i] for v in pts) for i in range(3)]}; bounds['size']=[round(bounds['max'][i]-bounds['min'][i],6) for i in range(3)]
metadata={'units':'metres','axes':{'up':'+Y','front':'+Z','rider_left':'+X'},'nodes':positions,'bounding_box':bounds,'triangles':triangles,'materials':list(MATS),'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'pivots':{'wheel_axis':[1,0,0],'crank_axis':[1,0,0],'pedal_axis':[1,0,0],'steering_axis':G((P(HT)-P(HB)).normalized()),'kickstand_axis':[1,0,0]},'bake':{'engine':'Cycles','device':'OPTIX','samples':8,'method':'Registered Poly Haven scan maps plus procedural micro-grime; reusable material swatches baked as emission base and ORM and tangent normal','orm':'R occlusion, G roughness, B metallic'},'glb_bytes':path.stat().st_size}
(ROOT/'nodes.json').write_text(json.dumps(metadata,indent=2))
(ROOT/'NOTES.md').write_text('Dutch step-through city bicycle, 28-inch wheels, open front porter carrier, full chain case, functional pivots and retracted stand.\n'+f'{triangles:,} triangles; {path.stat().st_size/1048576:.2f} MiB GLB; metre-scale Y-up, forward +Z.\n'+'6 materials: Paint, Chrome, Leather, Tyre, Rubber, Lens; neutral baked Paint texture with linear [0.01, 0.01, 0.01] factor; satin/matt enamel.\n'+'Cycles OptiX baked reusable base/normal tiles 512–1024 px; packed ORM 256 px; all runtime textures embedded.\n'+'Poly Haven CC0 ids: brown_leather, metal_plate, rubber_tiles; https://polyhaven.com/license ; https://polyhaven.com/a/{id}.\n'+'Differences: original lamp and fasteners, no reference branding, retracted stand; fine wear restrained, enclosed chain simplified, carrier matches open reference basket.\n')
print('FIRST COMPLETE GLB',path.stat().st_size,'elapsed',time.time()-START,flush=True)
cam(hero_dir,2.60); render('final.png',1920,1080,32)
cam((-1,0,.015),2.23); render('side.png',1920,1080,28)
cam(hero_dir,2.60)
# Render six identical cameras and combine pixels into 3x2 image without external dependencies.
thumbs=[]
for i,(name,col) in enumerate(PAINTS):
 paintmix.inputs[2].default_value=(*col,1); render('paint_%d.png'%i,640,360,12)
 im=bpy.data.images.load(str(ROOT/('paint_%d.png'%i)),check_existing=False)
 arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4); thumbs.append(arr); bpy.data.images.remove(im)
grid=np.zeros((720,1920,4),dtype=np.float32)
for i,arr in enumerate(thumbs):
 y=360 if i<3 else 0; x=(i%3)*640; grid[y:y+360,x:x+640]=arr
im=bpy.data.images.new('Paint variants grid',width=1920,height=720,alpha=True); im.pixels.foreach_set(grid.ravel()); im.filepath_raw=str(ROOT/'paints.png'); im.file_format='PNG'; im.save()
for i in range(6): (ROOT/('paint_%d.png'%i)).unlink()
paintmix.inputs[2].default_value=(.01,.01,.01,1)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'city-bike.blend'))
print('COMPLETE',time.time()-START,flush=True)
