"""Clean, reproducible cruiser build. Run with Blender 5.2 --background --python.
Authored X left, -Y forward, Z up; glTF exporter converts to X left, Y up, Z forward.
CC0 photographic inputs: Poly Haven brown_leather and metal_plate.
"""
import bpy, math, os, json, urllib.request, random, sys
import numpy as np
from mathutils import Vector
from math import sin,cos,pi
ROOT=os.path.dirname(os.path.abspath(__file__))
random.seed(71)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.samples=24
pref=bpy.context.preferences.addons['cycles'].preferences
pref.compute_device_type='OPTIX';pref.get_devices()
for d in pref.devices:d.use=d.type!='CPU'
sc.cycles.device='GPU';sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'
sc.world=bpy.data.worlds.new('Soft daylight');sc.world.use_nodes=True
sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.82,1)
sc.world.node_tree.nodes['Background'].inputs[1].default_value=.28
sc.view_settings.view_transform='AgX'
# Sources, cached only as immutable photographic inputs.
os.makedirs(os.path.join(ROOT,'textures'),exist_ok=True)
for aid in ['brown_leather','metal_plate']:
 for typ in ['diff','nor_gl','arm']:
  fn=f'{aid}_{typ}_1k.jpg';path=os.path.join(ROOT,'textures',fn)
  if not os.path.exists(path):
   remote_fn=fn.replace('_diff_', '_albedo_') if aid=='brown_leather' else fn
   urllib.request.urlretrieve(f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{aid}/{remote_fn}',path)

def node(nt,typ):return nt.nodes.new(typ)
def image_node(nt,path,noncolor=False):
 n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(path,check_existing=True)
 if noncolor:n.image.colorspace_settings.name='Non-Color'
 return n
# All material results baked by Cycles on a reusable tile; no procedural shader exported.
M={}
def bake_material(name,color,metal,rough,source=None,detail=.05):
 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['Surface'])
 bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 uv=node(nt,'ShaderNodeTexCoord');noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=95;noise.inputs['Detail'].default_value=2
 nt.links.new(uv.outputs['UV'],noise.inputs[0])
 ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=(*[c*(1-detail) for c in color],1);ramp.color_ramp.elements[1].color=(*color,1)
 nt.links.new(noise.outputs['Fac'],ramp.inputs[0]);base=ramp.outputs[0]
 if source:
  diff=image_node(nt,os.path.join(ROOT,'textures',source+'_diff_1k.jpg'))
  mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.82 if name=='Leather' else .1
  nt.links.new(base,mix.inputs[1]);src=diff.outputs[0]
  if name=='Paint':
   gray=node(nt,'ShaderNodeRGBToBW');nt.links.new(src,gray.inputs[0]);src=gray.outputs[0]
  nt.links.new(src,mix.inputs[2]);base=mix.outputs[0]
  normal=image_node(nt,os.path.join(ROOT,'textures',source+'_nor_gl_1k.jpg'),True);nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.38 if name=='Leather' else .012;nt.links.new(normal.outputs[0],nm.inputs[1]);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
  arm=image_node(nt,os.path.join(ROOT,'textures',source+'_arm_1k.jpg'),True);sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],sep.inputs[0])
 else:
  bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.15;bump.inputs['Distance'].default_value=.00015
  nt.links.new(noise.outputs['Fac'],bump.inputs['Height']);nt.links.new(bump.outputs[0],bs.inputs['Normal'])
 if name=='Leather':
  distance=node(nt,'ShaderNodeVectorMath');distance.operation='DISTANCE';nt.links.new(uv.outputs['UV'],distance.inputs[0]);distance.inputs[1].default_value=(.5,.5,0)
  wear=node(nt,'ShaderNodeValToRGB');wear.color_ramp.elements[0].position=.12;wear.color_ramp.elements[0].color=(1,1,1,1);wear.color_ramp.elements[1].position=.67;wear.color_ramp.elements[1].color=(.35,.35,.35,1);nt.links.new(distance.outputs['Value'],wear.inputs[0])
  darken=node(nt,'ShaderNodeMixRGB');darken.blend_type='MULTIPLY';darken.inputs[0].default_value=1;nt.links.new(base,darken.inputs[1]);nt.links.new(wear.outputs[0],darken.inputs[2]);base=darken.outputs[0]
 nt.links.new(base,bs.inputs['Base Color'])
 rr=node(nt,'ShaderNodeMapRange');rr.inputs['To Min'].default_value=rough-.035;rr.inputs['To Max'].default_value=rough+.035
 nt.links.new(sep.outputs[1] if source else noise.outputs['Fac'],rr.inputs[0]);nt.links.new(rr.outputs[0],bs.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=1);o=bpy.context.object;o.name='Bake_'+name;o.data.materials.append(m)
 baked={};sc.cycles.samples=8
 for kind,size in [('Base',1024),('Normal',1024),('ORM',512)]:
  im=bpy.data.images.new(name+'_'+kind,size,size,alpha=False);im.colorspace_settings.name='sRGB' if kind=='Base' else 'Non-Color'
  target=node(nt,'ShaderNodeTexImage');target.image=im;nt.nodes.active=target
  if kind=='Normal':
   nt.links.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL',margin=8)
  else:
   em=node(nt,'ShaderNodeEmission')
   if kind=='Base':nt.links.new(base,em.inputs[0])
   else:
    combine=node(nt,'ShaderNodeCombineColor');combine.inputs[0].default_value=1;combine.inputs[2].default_value=metal;nt.links.new(rr.outputs[0],combine.inputs[1])
    if source:nt.links.new(sep.outputs[0],combine.inputs[0])
    nt.links.new(combine.outputs[0],em.inputs[0])
   nt.links.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=8)
  im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+kind+'.png');im.file_format='PNG';im.save();baked[kind]=im
  if kind=='Normal' and name in ['Chrome','Steel','Dark']:baked[kind]=bpy.data.images['Paint_Normal']
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();bs=node(nt,'ShaderNodeBsdfPrincipled');out=node(nt,'ShaderNodeOutputMaterial');nt.links.new(bs.outputs[0],out.inputs[0])
 for k,im in baked.items():
  tx=node(nt,'ShaderNodeTexImage');tx.image=im
  if k=='Base':
   if name=='Paint':
    mul=node(nt,'ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(.01,.01,.012,1);nt.links.new(tx.outputs[0],mul.inputs[1]);nt.links.new(mul.outputs[0],bs.inputs['Base Color']);m['color_node']=mul.name
   else:nt.links.new(tx.outputs[0],bs.inputs['Base Color'])
  elif k=='Normal':
   nm=node(nt,'ShaderNodeNormalMap');nt.links.new(tx.outputs[0],nm.inputs[1]);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sp=node(nt,'ShaderNodeSeparateColor');nt.links.new(tx.outputs[0],sp.inputs[0]);nt.links.new(sp.outputs[1],bs.inputs['Roughness']);nt.links.new(sp.outputs[2],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[0],gn.inputs['Occlusion'])
 if name=='Paint':bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 M[name]=m
 print('BAKED',name,flush=True)

# Seven shared material sets, including a small emissive lens atlas.
for args in [('Paint',(.95,.95,.95),.12,.32,'metal_plate',.04),('Chrome',(.62,.65,.68),1,.24,'metal_plate',.02),('Steel',(.13,.145,.16),.85,.36,'metal_plate',.08),('Rubber',(.009,.010,.012),0,.72,None,.18),('Leather',(.30,.13,.058),0,.49,'brown_leather',.15),('Dark',(.035,.04,.045),.6,.38,'metal_plate',.1)]:bake_material(*args)
m=bpy.data.materials.new('Lamps');m.use_nodes=True;nt=m.node_tree;bs=nt.nodes.get('Principled BSDF');im=bpy.data.images.new('Lamps_Base',64,64,alpha=False)
a=np.ones((64,64,4),dtype=np.float32);a[:,:22,:3]=(.98,.92,.82);a[:,22:43,:3]=(.9,.008,.003);a[:,43:,:3]=(1,.22,.005);im.pixels.foreach_set(a.ravel());im.filepath_raw=os.path.join(ROOT,'textures','Lamps_Base.png');im.save()
tx=node(nt,'ShaderNodeTexImage');tx.image=im;nt.links.new(tx.outputs[0],bs.inputs['Base Color']);nt.links.new(tx.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.65;bs.inputs['Roughness'].default_value=.18;M['Lamps']=m
# Bake the coloured optical atlas through Cycles as well.
bpy.ops.mesh.primitive_plane_add(size=1);lp=bpy.context.object;lp.data.materials.append(m)
lt=node(nt,'ShaderNodeTexImage');li=bpy.data.images.new('Lamps_Baked',64,64,alpha=False);lt.image=li;nt.nodes.active=lt
bs.inputs['Emission Strength'].default_value=1
bpy.ops.object.bake(type='EMIT',margin=0)
li.filepath_raw=os.path.join(ROOT,'textures','Lamps_Baked.png');li.save()
nt.links.new(lt.outputs[0],bs.inputs['Base Color']);nt.links.new(lt.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.65
for kind in ['Normal','ORM']:
 target=node(nt,'ShaderNodeTexImage');im=bpy.data.images.new('Lamps_'+kind,64,64,alpha=False);im.colorspace_settings.name='Non-Color';target.image=im;nt.nodes.active=target
 if kind=='Normal':bpy.ops.object.bake(type='NORMAL',margin=0)
 else:
  em=node(nt,'ShaderNodeEmission');em.inputs[0].default_value=(1,.08,0,1);out=nt.nodes.get('Material Output');nt.links.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=0);nt.links.new(bs.outputs[0],out.inputs[0])
 im.filepath_raw=os.path.join(ROOT,'textures','Lamps_'+kind+'.png');im.save()
 if kind=='Normal':
  nm=node(nt,'ShaderNodeNormalMap');nt.links.new(target.outputs[0],nm.inputs[1]);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
 else:
  sp=node(nt,'ShaderNodeSeparateColor');nt.links.new(target.outputs[0],sp.inputs[0]);nt.links.new(sp.outputs[1],bs.inputs['Roughness']);nt.links.new(sp.outputs[2],bs.inputs['Metallic']);gn=node(nt,'ShaderNodeGroup');gn.node_tree=bpy.data.node_groups['glTF Material Output'];nt.links.new(sp.outputs[0],gn.inputs['Occlusion'])
bs.inputs['Transmission Weight'].default_value=.72;bs.inputs['IOR'].default_value=1.46;bs.inputs['Emission Strength'].default_value=.08
bpy.data.objects.remove(lp,do_unlink=True)
parts={k:[] for k in ['Frame','Fork','Wheel_F','Wheel_R','Kickstand']}
def P(v):return Vector((v[0],-v[1],v[2]))
def finish(o,mat,group='Frame',smooth=True):
 o.data.materials.append(M[mat]);parts[group].append(o)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 return o
def mesh(name,vs,fs,mat,group='Frame',smooth=True):
 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);sc.collection.objects.link(o);finish(o,mat,group,smooth)
 return o
def apply(o):
 bpy.context.view_layer.objects.active=o;o.select_set(True)
 for md in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=md.name)
 o.select_set(False)
def bevel(o,w=.003,seg=2):
 md=o.modifiers.new('Machined edge radii','BEVEL');md.width=w;md.segments=seg;apply(o);return o
def ball(name,c,r,mat,group='Frame',seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=P(c));o=bpy.context.object;o.name=name;o.scale=r;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);finish(o,mat,group);o.select_set(False);return o
def box(name,c,sz,mat,group='Frame',b=.004):
 bpy.ops.mesh.primitive_cube_add(size=1,location=P(c));o=bpy.context.object;o.name=name;o.scale=(sz[0],sz[1],sz[2]);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);finish(o,mat,group,True);bevel(o,b,1 if name in ['Sprocket tooth','Drive chain link'] else 3)
 md=o.modifiers.new('Machined solid face normals','WEIGHTED_NORMAL');md.keep_sharp=True;md.weight=50;apply(o);return o
def cyl(name,a,b,r,mat,group='Frame',verts=24,r2=None,edge=.001):
 av,bv=P(a),P(b);v=bv-av
 bpy.ops.mesh.primitive_cone_add(vertices=verts,radius1=r,radius2=r if r2 is None else r2,depth=v.length,location=(av+bv)/2)
 o=bpy.context.object;o.name=name;o.rotation_euler=v.to_track_quat('Z','Y').to_euler();bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);finish(o,mat,group)
 for face in o.data.polygons:
  if abs(face.normal.z)>.99:face.use_smooth=False
 if edge:bevel(o,edge,2)
 o.select_set(False);return o
def tube(name,pts,r,mat,group='Frame',res=8,steps=6):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=steps;cu.bevel_depth=r;cu.bevel_resolution=4 if res>8 else 2
 sp=cu.splines.new('BEZIER');sp.bezier_points.add(len(pts)-1)
 for p,v in zip(sp.bezier_points,pts):p.co=P(v);p.handle_left_type='AUTO';p.handle_right_type='AUTO'
 o=bpy.data.objects.new(name,cu);sc.collection.objects.link(o);bpy.context.view_layer.objects.active=o;o.select_set(True);bpy.ops.object.convert(target='MESH');finish(o,mat,group);o.select_set(False);return o
def ring(name,c,r,minor,mat,group='Frame',width=None,n=64,k=8):
 # axle along X
 vs=[];fs=[]
 for i in range(n):
  t=2*pi*i/n
  for j in range(k):
   q=2*pi*j/k;vs.append((c[0]+(width or minor)*sin(q),c[1]+(r+minor*cos(q))*sin(t),c[2]+(r+minor*cos(q))*cos(t)))
 for i in range(n):
  for j in range(k):fs.append((i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k))
 return mesh(name,vs,fs,mat,group)
def bolt(c,axis=(1,0,0),group='Frame',r=.008,mat='Chrome'):
 d=Vector(axis);a=Vector(c);return cyl('Hex fastener',a,a+d*.006,r,mat,group,6,edge=.0006)
# Wheels: rounded sidewalls, genuine inset tread channels sampled into surface.
for group,f,w in [('Wheel_R',-.825,.24),('Wheel_F',.825,.13)]:
 vs=[];fs=[];n=240;k=20;major=.330;minor=.045
 for i in range(n):
  t0=2*pi*((i//6)+[0,.07,.14,.21,.45,.75][i%6])/40
  for j in range(k):
   q=2*pi*j/k;x=w*.5*sin(q);rr=major+minor*cos(q)
   t=t0+.09*abs(sin(q))
   tread=max(0,(cos(q)-.15)/.85)
   cut=.0035*tread*(1 if i%6 in [1,2] else 0)
   rr-=cut
   vs.append((x,f+rr*sin(t),.375+rr*cos(t)))
 for i in range(n):
  for j in range(k):fs.append((i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k))
 mesh('Radial tyre with siped tread',vs,fs,'Rubber',group)
 for x in [-w*.41,w*.41]:
  for rr in [.305]:ring('Moulded sidewall bead',(x,f,.375),rr,.002,'Rubber',group,n=64,k=4)
 rimwidth=w*(.40 if group=='Wheel_R' else .31)
 rimradius=.295 if group=='Wheel_R' else .281
 for x in [-rimwidth,rimwidth]:
  ring('Rolled wheel rim',(x,f,.375),rimradius,.013,'Dark',group,n=80,k=6)
  ring('Polished rim lip',(x,f,.375),rimradius+.009,.003,'Chrome',group,n=80,k=4)
 cyl('Axle hub',(-w*.43,f,.375),(w*.43,f,.375),.057,'Steel',group,32)
 for side in [-1,1]:
  x=side*rimwidth;hubwidth=.075 if group=='Wheel_R' else .043
  ring('Spoke flange',(side*hubwidth,f,.375),.054,.009,'Chrome',group,n=32,k=6)
  for j in range(24):
   t=j*2*pi/24;s=t+side*.26
   cyl('Laced stainless spoke',(side*hubwidth,f+.055*sin(s),.375+.055*cos(s)),(x,f+(rimradius-.002)*sin(t),.375+(rimradius-.002)*cos(t)),.0019,'Chrome',group,6,edge=0)
  bolt((side*w*.46,f,.375),(side,0,0),group,.018)
 # brake disc with real perforated structure: radial annulus segmented, holes as absent cells
 x= w*.43+.012
 vs=[];fs=[];nr=96
 for i in range(nr):
  t=i*2*pi/nr
  for rr in [.078,.115,.121,.145,.151]:vs.append((x,f+rr*sin(t),.375+rr*cos(t)))
 for i in range(nr):
  for j in range(4):
   if j==1 and i%6==1:continue
   fs.append((i*5+j,((i+1)%nr)*5+j,((i+1)%nr)*5+j+1,i*5+j+1))
 o=mesh('Ventilated brake rotor',vs,fs,'Steel',group);md=o.modifiers.new('Rotor thickness','SOLIDIFY');md.thickness=.004;apply(o);bevel(o,.0006,1)
 for j in range(6):
  t=j*pi/3;bolt((x+.003,f+.088*sin(t),.375+.088*cos(t)),group=group,r=.005)
 box('Brake caliper',(x+.008,f-.115,.41),(.045,.055,.10),'Dark','Fork' if group=='Wheel_F' else 'Frame',.012)
# Double cradle frame and rear triangle.
for x in [-.095,.095]:
 tube('Continuous lower cradle',[(x,-.57,.36),(x,-.43,.205),(x,.26,.19),(x,.37,.26),(x,.48,.82)],.018,'Steel')
 tube('Backbone',[(x,-.64,.61),(x,-.38,.64),(x,.04,.81),(x,.43,.86)],.018,'Steel')
 tube('Seat down tube',[(x,-.42,.63),(x,-.32,.46),(x,-.29,.23)],.021,'Steel')
 tube('Rigid rear upper stays',[(x,-.31,.62),(x*1.38,-.58,.49),(x*1.40,-.825,.375)],.019,'Steel')
 tube('Rear lower swingarms',[(x,-.28,.25),(x*1.5,-.59,.30),(x*1.4,-.825,.375)],.021,'Steel')
 for f,z in [(-.825,.375),(-.33,.61),(-.3,.27)]:bolt((x*1.55,f,z),(1 if x>0 else -1,0,0),r=.012)
for f,z in [(-.43,.24),(.25,.2),(-.42,.62)]:cyl('Frame cross member',(-.1,f,z),(.1,f,z),.018,'Steel')
cyl('Steering head',(0,.47,.75),(0,.365,.925),.037,'Steel',verts=32)
# V twin engine, rear leans rearward, front forward. Axis points from crank to head.
ball('Crankcase',(0,-.015,.355),(.135,.20,.15),'Steel',seg=40,rings=20)
for side in [-1,1]:
 cyl('Circular crank cover',(side*.12,-.005,.36),(side*.151,-.005,.36),.095,'Chrome',verts=48)
 cyl('Cover inset',(side*.153,-.005,.36),(side*.158,-.005,.36),.073,'Steel',verts=40)
 for j in range(8):
  t=2*pi*j/8;bolt((side*.16,-.005+.085*sin(t),.36+.085*cos(t)),(side,0,0),r=.0048)
for direction in [-1,1]:
 base=Vector((0,direction*.045,.41));axis=Vector((0,direction*.43,.903));top=base+axis*.31
 cyl('Black iron cylinder barrel',base,top,.095,'Dark',verts=32)
 for i in range(16):
  a=base+axis*(.04+i*.0175);b=a+axis*.0055
  fin=cyl('Cooling fin with polished edge',a,b,.106 if i<10 else .112,'Dark',verts=32,edge=.001)
  fin.data.materials.append(M['Steel'])
  for face in fin.data.polygons:
   if abs(face.normal.z)<.5:face.material_index=1
 # rocker head rounded rectangular castings across X
 head=box('Polished rocker housing',top+axis*.025,(.23,.145,.065),'Chrome',b=.02)
 head.rotation_euler[0]=direction*.445
 gasket=box('Rocker cover gasket',top+axis*.001,(.23,.147,.008),'Dark',b=.003);gasket.rotation_euler[0]=direction*.445
 for side in [-1,1]:
  for zoff in [-.012,0,.012]:
   a=top+Vector((side*.116,-.042,zoff+.025));b=top+Vector((side*.116,.042,zoff+.025))
   cyl('Rocker cover machined rib',a,b,.0022,'Steel',verts=8,edge=0)
 for side in [-1,1]:
  start=base+Vector((side*.075,0,0));end=top+Vector((side*.088,0,0))
  cyl('External pushrod tube',start,end,.010,'Chrome',verts=12)
  bolt((side*.119,top.y,top.z+.015),(side,0,0),r=.008)
  tube('Ignition lead',[(side*.09,top.y,top.z+.04),(side*.105,top.y*.4,.77),(side*.075,-.08,.74)],.0035,'Rubber')
# Air cleaner prominent on visible right side, filtered back plate.
cyl('Air filter backing',(-.14,0,.62),(-.18,0,.62),.091,'Dark',verts=48)
cyl('Air cleaner chrome dish',(-.175,0,.62),(-.207,0,.62),.084,'Chrome',verts=64,r2=.077)
cyl('Air cleaner centre',(-.208,0,.62),(-.217,0,.62),.033,'Steel',verts=32)
for j in range(6):
 t=2*pi*j/6;bolt((-.212,.064*sin(t),.62+.064*cos(t)),(-1,0,0),r=.0045)
# Transmission/oil tank and battery case.
box('Oil reservoir',(0,-.38,.50),(.215,.18,.18),'Steel',b=.014)
cyl('Oil filler',(-.065,-.40,.59),(-.065,-.40,.62),.018,'Chrome')
box('Gearbox',(0,-.26,.31),(.25,.21,.12),'Dark',b=.025)
ball('Primary drive case',(.155,-.19,.36),(.038,.24,.085),'Dark',seg=40,rings=16)
for f in [-.35,-.20,-.04]:bolt((.193,f,.36),r=.007)
# Chain left, two taut spans and sprocket rings.
ring('Rear drive sprocket',(.135,-.825,.375),.122,.01,'Steel','Wheel_R',n=64,k=6)
for j in range(60):
 t=2*pi*j/60
 box('Sprocket tooth',(.135,-.825+.128*sin(t),.375+.128*cos(t)),(.012,.011,.011),'Steel','Wheel_R',b=.001)
for z in [.263,.483]:
 for j in range(36):
  f=-.80+j*.0145;box('Drive chain link',(.145,f,z),(.012,.017,.008),'Steel',b=.0015)
# Sculpted teardrop tank: longitudinal elliptical sections, lower seam tucked around backbone.
sections=[(-.30,.016,.682,.722),(-.25,.055,.697,.772),(-.14,.105,.735,.865),(.015,.146,.765,.952),(.18,.151,.773,.979),(.28,.126,.80,.959),(.34,.066,.83,.918),(.36,.009,.86,.88)]
vs=[];fs=[];n=32
for f,w,lo,hi in sections:
 for j in range(n):
  t=j*2*pi/n;vs.append((w*sin(t),f,(lo+hi)/2+(hi-lo)/2*cos(t)))
for i in range(len(sections)-1):
 for j in range(n):fs.append((i*n+j,(i+1)*n+j,(i+1)*n+(j+1)%n,i*n+(j+1)%n))
fs.extend([tuple(reversed(range(n))),tuple((len(sections)-1)*n+j for j in range(n))])
o=mesh('Teardrop fuel tank',vs,fs,'Paint');md=o.modifiers.new('Tank hand formed curvature','SUBSURF');md.levels=1;apply(o)
cyl('Fuel cap',(0,.185,.976),(0,.185,.988),.025,'Chrome',verts=40)
ring('Fuel cap seal',(0,0,0),.01,.002,'Rubber') if False else None
# Saddle shaped planform, dished centre and rising back; thick leather shell.
vs=[];fs=[];nu=24;nv=20
for i in range(nu+1):
 t=i/nu;f=-.70+.43*t;w=.025+.135*(sin(pi*(.12+.88*t))**.6)
 for j in range(nv+1):
  u=-1+2*j/nv;z=.65+.105*(1-t)**3+.035*t*t+.027*u*u
  vs.append((w*u,f+.025*u*u*(1-t)**4,z))
for i in range(nu):
 for j in range(nv):
  a=i*(nv+1)+j;fs.append((a,a+nv+1,a+nv+2,a+1))
o=mesh('Sculpted leather solo saddle',vs,fs,'Leather');md=o.modifiers.new('Cushion thickness','SOLIDIFY');md.thickness=.035;apply(o);bevel(o,.012,3)
for side in [-1,1]:
 pts=[]
 for i in range(25):
  t=i/24;pts.append((side*(.025+.135*sin(pi*(.12+.88*t))**.6)*.965,-.70+.43*t+.025*.965**2*(1-t)**4,.65+.105*(1-t)**3+.035*t*t+.027*.965**2))
 tube('Rolled leather seat piping',pts,.0026,'Leather',steps=2)
 for i in range(2,23):
  t=i/24;x=side*(.025+.135*sin(pi*(.12+.88*t))**.6)*.925;z=.65+.105*(1-t)**3+.035*t*t+.027*.925**2+.0006
  cyl('Saddle stitch',(x,-.70+.43*t+.025*.925**2*(1-t)**4,z),(x,-.70+.43*t+.025*.925**2*(1-t)**4+.004,z),.00065,'Leather',verts=4,edge=0)
# Close the leather piping around the rounded back and narrow nose.
for t in [0,1]:
 w=.025+.135*sin(pi*(.12+.88*t))**.6
 pts=[]
 for j in range(17):
  u=-.965+1.93*j/16;pts.append((w*u,-.70+.43*t+.025*u*u*(1-t)**4,.65+.105*(1-t)**3+.035*t*t+.027*u*u))
 tube('Saddle end piping',pts,.0026,'Leather',steps=2)
# Fenders: curved steel shells with rolled edge and mounting stays.
def fender(f,width,t0,t1,group):
 vs=[];fs=[];n=36;k=8
 for i in range(n+1):
  t=t0+(t1-t0)*i/n
  for j in range(k+1):
   q=-pi*.43+pi*.86*j/k;x=width*.5*sin(q);r=.384+.015*cos(q)
   vs.append((x,f+r*sin(t),.375+r*cos(t)))
 for i in range(n):
  for j in range(k):a=i*(k+1)+j;fs.append((a,a+k+1,a+k+2,a+1))
 o=mesh('Rolled steel mudguard',vs,fs,'Paint',group);md=o.modifiers.new('Fender real thickness','SOLIDIFY');md.thickness=.003;apply(o);bevel(o,.0012,2)
 for side in [-1,1]:
  pts=[]
  for i in range(25):
   t=t0+(t1-t0)*i/24;pts.append((side*width*.488,f+.3873*sin(t),.375+.3873*cos(t)))
  tube('Fender rolled lip',pts,.0022,'Paint',group,steps=2)
fender(-.825,.265,-1.18,.32,'Frame');fender(.825,.155,-.88,.68,'Fork')
for x in [-.135,.135]:
 tube('Rear fender rail',[(x,-.98,.69),(x,-.83,.737),(x,-.57,.63)],.009,'Steel')
 bolt((x,-.94,.71),(1 if x>0 else -1,0,0),r=.007)
# Fork: raked 32 degrees, telescopic legs and triple trees.
for x in [-.09,.09]:
 cyl('Fork stanchion',(x,.398,.95),(x,.697,.55),.024,'Chrome','Fork',32)
 cyl('Fork lower slider',(x,.68,.57),(x,.825,.375),.032,'Dark','Fork',32)
 cyl('Fork dust seal',(x,.674,.58),(x,.692,.556),.034,'Rubber','Fork',24)
 bolt((x*1.33,.825,.375),(1 if x>0 else -1,0,0),'Fork',.015)
 tube('Fender brace',[(x,.765,.44),(x,.745,.67),(x*.75,.75,.77)],.005,'Steel','Fork')
for f,z in [(.415,.94),(.505,.815)]:
 box('Fork triple clamp',(0,f,z),(.26,.07,.034),'Dark','Fork',b=.012)
 for x in [-.105,.105]:bolt((x,f,z+.019),(0,0,1),'Fork',.009)
# Broad swept handlebar, exactly 0.95 m tip to tip.
for side in [-1,1]:
 tube('Wide swept handlebar',[(0,.405,1.025),(side*.12,.41,1.028),(side*.24,.36,1.113),(side*.35,.275,1.125),(side*.455,.235,1.123)],.012,'Chrome','Fork')
 cyl('Handlebar riser',(side*.055,.41,.956),(side*.055,.405,1.033),.015,'Steel','Fork')
 cyl('Brown rubber grip',(side*.345,.277,1.125),(side*.4686,.23,1.123),.019,'Leather','Fork',32)
 for j in range(9):
  t=j/8;a=Vector((side*.356,.273,1.125));b=Vector((side*.463,.232,1.123));c=a.lerp(b,t);d=(b-a).normalized()
  cyl('Grip raised ring',c-d*.0015,c+d*.0015,.020,'Rubber','Fork',24,edge=.0005)
 box('Switch housing',(side*.321,.285,1.126),(.039,.046,.043),'Dark','Fork',.009)
 box('Reservoir',(side*.25,.326,1.134),(.055,.045,.032),'Dark','Fork',.005)
 tube('Curved hand lever',[(side*.30,.30,1.115),(side*.36,.332,1.108),(side*.465,.29,1.107)],.004,'Steel','Fork')
 ball('Lever end',(side*.465,.29,1.107),(.007,.007,.007),'Steel','Fork',16,8)
 tube('Control cable',[(side*.30,.28,1.10),(side*.21,.45,1.03),(side*.1,.49,.89),(side*.075,.13,.62)],.003,'Rubber','Fork')
# Small analogue instrument, no writing or logos.
cyl('Instrument housing',(0,.38,1.021),(0,.36,1.065),.035,'Dark','Fork',40)
cyl('Instrument glass',(0,.36,1.065),(0,.358,1.068),.031,'Chrome','Fork',40)
# Round independent face, concentric headlight bezel and ribbed lens.
ball('Headlight bucket',(0,.515,.903),(.079,.072,.079),'Dark','Fork',40,20)
# Open chrome bezel and recessed parabolic reflector, visible through the optical cover.
vs=[];fs=[]
for f,r in [(.582,.079),(.618,.079),(.618,.069),(.585,.069)]:
 for j in range(64):
  t=2*pi*j/64;vs.append((r*sin(t),f,.903+r*cos(t)))
for k in range(3):
 for j in range(64):fs.append((k*64+j,k*64+(j+1)%64,(k+1)*64+(j+1)%64,(k+1)*64+j))
o=mesh('Open headlight bezel',vs,fs,'Chrome','Fork');bevel(o,.001,2)
vs=[];fs=[]
for r in [.003,.012,.025,.045,.067]:
 f=.588+.027*(r/.067)**2
 for j in range(64):
  t=2*pi*j/64;vs.append((r*sin(t),f,.903+r*cos(t)))
for k in range(4):
 for j in range(64):fs.append((k*64+j,k*64+(j+1)%64,(k+1)*64+(j+1)%64,(k+1)*64+j))
mesh('Headlight parabolic reflector',vs,fs,'Chrome','Fork')
cyl('Bulb socket',(0,.588,.903),(0,.602,.903),.010,'Dark','Fork',24)
o=ball('Headlight bulb',(0,.607,.903),(.008,.012,.008),'Lamps','Fork',24,12)
for uv in o.data.uv_layers.active.data:uv.uv=(.16,.5)
def lamp(name,a,b,r,kind,group):
 o=cyl(name,a,b,r,'Lamps',group,48,edge=.001)
 # pin UVs to the required emissive atlas band
 for uv in o.data.uv_layers.active.data:uv.uv=(.16 if kind=='head' else .50 if kind=='tail' else .83,.5)
 return o
lamp('Headlamp lens',(0,.619,.903),(0,.624,.903),.069,'head','Fork')
for i in range(-6,7):
 x=i*.009;h=math.sqrt(max(0,.065**2-x*x));cyl('Headlamp optical fluting',(x,.625,.903-h),(x,.625,.903+h),.0008,'Chrome','Fork',6,edge=0)
for side in [-1,1]:
 cyl('Indicator stalk',(side*.08,.48,.86),(side*.158,.48,.86),.007,'Steel','Fork')
 ball('Indicator housing',(side*.163,.48,.86),(.017,.025,.017),'Dark','Fork',20,10)
 lamp('Amber front indicator',(side*.163,.497,.86),(side*.163,.503,.86),.013,'amber','Fork')
 tube('Hydraulic front brake line',[(side*.09,.5,.89),(side*.13,.61,.65),(side*.13,.74,.49),(side*.077,.71,.41)],.0026,'Rubber','Fork')
# Tail lamps fitted below rear mudguard.
box('Rear lamp bracket',(0,-1.167,.535),(.09,.024,.044),'Dark',b=.007)
lamp('Tail lens',(0,-1.177,.54),(0,-1.185,.54),.027,'tail','Frame')
for side in [-1,1]:
 cyl('Rear signal stem',(side*.1,-1.01,.625),(side*.163,-1.01,.625),.006,'Steel')
 lamp('Rear amber indicator',(side*.163,-1.02,.625),(side*.163,-1.035,.625),.012,'amber','Frame')
# Twin swept exhausts on right: open mouths, visible interior and rolled rims.
for pts in [[(-.105,-.155,.61),(-.165,-.18,.54),(-.19,-.08,.39),(-.20,-.18,.285),(-.205,-.48,.285),(-.205,-.80,.285)],[(-.105,.17,.62),(-.17,.24,.54),(-.19,.15,.34),(-.205,.04,.215),(-.22,-.39,.215),(-.22,-.71,.215)]]:
 tube('Swept chrome exhaust header',pts,.025,'Chrome',res=16,steps=12)
 end=pts[-1];start=pts[-2]
 vv=[];ff=[]
 for f,r in [(start[1],.033),(end[1],.033),(end[1],.027),(end[1]+.055,.027)]:
  for j in range(48):
   t=j*2*pi/48;vv.append((end[0]+r*sin(t),f,end[2]+r*cos(t)))
 for k in range(3):
  for j in range(48):ff.append((k*48+j,k*48+(j+1)%48,(k+1)*48+(j+1)%48,(k+1)*48+j))
 mesh('Hollow chrome muffler with return lip',vv,ff,'Chrome')
 # cap disk in black with chrome annulus at outlet
 cyl('Recessed exhaust interior',(end[0],end[1]+.051,end[2]),(end[0],end[1]+.053,end[2]),.027,'Dark',verts=40,edge=0)
 # opening lip torus oriented along forward axis
 o=ring('Rolled exhaust mouth',(0,0,0),.030,.003,'Chrome',n=40,k=8);o.rotation_euler[2]=pi/2;o.location=P((end[0],end[1]-.005,end[2]))
 tube('Muffler hanger',[(end[0]+.005,end[1]+.12,end[2]),(-.14,end[1]+.12,.36)],.009,'Steel')
# Forward controls with ribbed rubber, linkage and heel/toe lever.
for side in [-1,1]:
 tube('Forward peg bracket',[(side*.095,.30,.22),(side*.16,.33,.27),(side*.205,.35,.30)],.013,'Steel')
 cyl('Footpeg axle',(side*.16,.35,.30),(side*.29,.35,.30),.013,'Steel')
 cyl('Footpeg rubber',(side*.205,.35,.30),(side*.29,.35,.30),.019,'Rubber')
 for j in range(7):
  x=side*(.21+j*.011);cyl('Footpeg rib',(x,.35,.30),(x+side*.003,.35,.30),.021,'Rubber',verts=20,edge=.0005)
 tube('Control pedal',[(side*.165,.32,.29),(side*.17,.43,.335),(side*.21,.46,.34)],.007,'Steel')
 cyl('Pedal toe rubber',(side*.205,.46,.34),(side*.248,.46,.34),.013,'Rubber')
 tube('Control linkage',[(side*.135,.33,.27),(side*.14,.12,.265),(side*.145,-.12,.30)],.0035,'Chrome')
# Stowed kickstand on left, with pivot and spring.
bolt((.115,-.28,.24),r=.013)
tube('Retracted side stand',[(.13,-.28,.24),(.16,-.43,.24),(.17,-.58,.27)],.012,'Dark','Kickstand')
box('Stand shoe',(.17,-.58,.268),(.038,.045,.016),'Steel','Kickstand',.004)
for i in range(16):
 t=2*pi*i/4;f=-.30-i*.004
 ball('Return spring coil',(.14+.006*sin(t),f,.255+.006*cos(t)),(.003,.003,.003),'Steel','Kickstand',8,4)
# Aggregate rigid parts, UV unwrap once per group. Apply geometry before export.
objects={}
pivots={'Frame':(0,0,0),'Fork':(0,.410,.85),'Wheel_F':(0,.825,.375),'Wheel_R':(0,-.825,.375),'Kickstand':(.13,-.28,.24)}
for name,obs in parts.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name;o.data.name=name+'_Mesh'
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Preserve lamp atlas addressing while generating portable UVs for the remaining faces.
 if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
 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=.003);bpy.ops.object.mode_set(mode='OBJECT')
 # Continuous leather grain across the seat instead of tiny packed UV islands.
 for p in o.data.polygons:
  if o.material_slots[p.material_index].material.name=='Leather':
   c=o.matrix_world@p.center
   if .60<c.z<.83 and .24<c.y<.76:
    for li in p.loop_indices:
     v=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co
     o.data.uv_layers.active.data[li].uv=(v.x/.35+.5,(.70-v.y)/.43)
 # Restore lamp colours based on world forward coordinate and lens role.
 for p in o.data.polygons:
  if o.material_slots[p.material_index].material.name=='Lamps':
   c=o.matrix_world@p.center;f=-c.y
   u=.16 if name=='Fork' and abs(c.x)<.09 else .50 if name=='Frame' and abs(c.x)<.08 else .83
   for li in p.loop_indices:o.data.uv_layers.active.data[li].uv=(u,.5)
 sc.cursor.location=P(pivots[name]);bpy.ops.object.origin_set(type='ORIGIN_CURSOR');o.select_set(False);objects[name]=o
# Parent with explicit local transform so wheel centre stays world-correct.
w=objects['Wheel_F'];world=w.matrix_world.copy();w.parent=objects['Fork'];w.matrix_world=world
sockets={'Seat_Rider':(0,-.46,.704),'Hand_L':(.415,.253,1.124),'Hand_R':(-.415,.253,1.124),'Foot_L':(.246,.35,.30),'Foot_R':(-.246,.35,.30),'Light_Head':(0,.625,.903),'Light_Tail':(0,-1.185,.54)}
for name,pos in sockets.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=P(pos);o.empty_display_size=.025
 if name.startswith('Hand') or name=='Light_Head':
  world=o.matrix_world.copy();bpy.context.view_layer.update();world=o.matrix_world.copy();o.parent=objects['Fork'];o.matrix_world=world
 objects[name]=o
bpy.context.view_layer.update()
# Mesh triangle sanitation / portable uncompressed glTF.
import bmesh
for o in objects.values():
 if o.type!='MESH':continue
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.0000001);bm.to_mesh(o.data);bm.free()
budgets={'Frame':36500,'Fork':11500,'Wheel_F':15000,'Wheel_R':15000,'Kickstand':650}
for name,budget in budgets.items():
 o=objects[name];count=len(o.data.polygons)
 if count>budget:
  source=o.copy();source.data=o.data.copy();sc.collection.objects.link(source);source.hide_render=True
  md=o.modifiers.new('Game mesh reduction preserving silhouette','DECIMATE');md.ratio=budget/count;md.use_collapse_triangulate=True;apply(o)
  md=o.modifiers.new('Preserve smooth source curvature','DATA_TRANSFER');md.object=source;md.use_loop_data=True;md.data_types_loops={'CUSTOM_NORMAL'};md.loop_mapping='POLYINTERP_NEAREST';apply(o)
  bpy.data.objects.remove(source,do_unlink=True)
tris=sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH');print('TRIANGLES',tris,flush=True)
paints=[{'name':'Obsidian','linear_rgb':[.01,.01,.012]},{'name':'Oxblood','linear_rgb':[.16,.012,.018]},{'name':'Petrol blue','linear_rgb':[.018,.085,.12]},{'name':'Forest green','linear_rgb':[.021,.095,.048]},{'name':'Champagne','linear_rgb':[.48,.34,.17]},{'name':'Ivory','linear_rgb':[.72,.67,.55]}]
def export():
 nt=M['Paint'].node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');mul=nt.nodes[M['Paint']['color_node']];neutral=mul.inputs[1].links[0].from_socket;nt.links.new(neutral,bs.inputs['Base Color'])
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects.values():o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=os.path.join(ROOT,'cruiser-bike.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_extras=True)
 nt.links.new(mul.outputs[0],bs.inputs['Base Color'])
 import struct
 path=os.path.join(ROOT,'cruiser-bike.glb');raw=open(path,'rb').read();jl=struct.unpack_from('<I',raw,12)[0];g=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
 paint=next(m for m in g['materials'] if m['name']=='Paint');paint['pbrMetallicRoughness']['baseColorFactor']=[.01,.01,.012,1]
 jb=json.dumps(g,separators=(',',':')).encode();jb+=b' '*((-len(jb))%4)
 open(path,'wb').write(struct.pack('<III',0x46546c67,2,20+len(jb)+len(binary))+struct.pack('<II',len(jb),0x4e4f534a)+jb+binary)
export()
def gp(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
pts=[o.matrix_world@Vector(c) for o in objects.values() if o.type=='MESH' for c in o.bound_box];lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
data={'units':'metres','forward':'+Z','up':'+Y','left':'+X','wheelbase':1.65,'steering_axis_world':[0, .857493, -.514496], 'nodes':{n:{'world_position':gp(o.matrix_world.translation),'parent':o.parent.name if o.parent else None} for n,o in objects.items()},'bounding_box':{'min':[lo.x,lo.z,-hi.y],'max':[hi.x,hi.z,-lo.y]},'triangles':tris,'paints':paints}
open(os.path.join(ROOT,'nodes.json'),'w').write(json.dumps(data,indent=2))
open(os.path.join(ROOT,'NOTES.md'),'w').write('\n'.join(['Original low V-twin cruiser; metres, Y-up, +Z forward; articulated wheels/fork and rider sockets.',f'{tris:,} triangles; five rigid mesh nodes, seven socket empties.','Seven materials: Paint, Chrome, Steel, Rubber, Leather, Dark, Lamps.','Baked base/normal 1024 px; packed ORM 512 px; lamp atlas 64 px; shared tile sets.','Poly Haven CC0: brown_leather, metal_plate; https://polyhaven.com/license .','Differences: original lamp and engine castings; simplified tread/chain; no logos, mirrors or lettering.'])+'\n')
# Product lighting and shadows, excluded from GLB.
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.002));ground=bpy.context.object;ground.name='Studio floor';gm=bpy.data.materials.new('Studio floor');gm.diffuse_color=(.29,.32,.35,1);ground.data.materials.append(gm)
def area(name,loc,energy,size,color):
 bpy.ops.object.light_add(type='AREA',location=P(loc));o=bpy.context.object;o.name=name;o.data.energy=energy;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('Daylight key',(-3,1,4),450,4,(1,.94,.86));area('Cool broad fill',(3,2,2.5),320,3,(.78,.88,1));area('Chrome strip',(-1,-3,3),500,2.5,(1,1,1))
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.lens=55
hero=(-4.0,2.7,1.8)
def camera(loc,scale=3.25):
 cam.location=P(loc);cam.rotation_euler=(P((0,0,.56))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
camera(hero);sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=48
# Save reproducible baked scene for inspections.
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'cruiser-bike.blend'))
def render(file):sc.render.filepath=os.path.join(ROOT,file);bpy.ops.render.render(write_still=True)
render('final.png');camera((-4,0,.56),2.85);render('side.png')
camera(hero)
clay=bpy.data.materials.new('Clay');clay.diffuse_color=(.36,.36,.36,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.62;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.22,.22,.22,1)
sc.view_layers[0].material_override=clay;render('clay.png');sc.view_layers[0].material_override=None
# Same-view paint proof, assembled using Blender's image API, no external imaging library.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=16
mul=M['Paint'].node_tree.nodes[M['Paint']['color_node']];grid=np.zeros((720,1920,4),dtype=np.float32)
for i,p in enumerate(paints):
 mul.inputs[2].default_value=(*p['linear_rgb'],1);render(f'paint_{i}.png')
 im=bpy.data.images.load(os.path.join(ROOT,f'paint_{i}.png'),check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);row=1-i//3;col=i%3;grid[row*360:(row+1)*360,col*640:(col+1)*640]=a;bpy.data.images.remove(im)
out=bpy.data.images.new('Six paint variants',1920,720,alpha=True);out.pixels.foreach_set(grid.ravel());out.filepath_raw=os.path.join(ROOT,'paints.png');out.file_format='PNG';out.save()
mul.inputs[2].default_value=(.01,.01,.012,1)
print('BUILD_COMPLETE',os.path.getsize(os.path.join(ROOT,'cruiser-bike.glb')),flush=True)
