"""Forge cafe racer, metres, built clean in Blender 5.2. CC0 Poly Haven surfaces.
Run: blender -b --python-exit-code 1 --python build_cafe-racer.py
"""
import bpy, bmesh, math, os, json, urllib.request, time
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
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)
sc=bpy.context.scene; sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
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()
    for d in prefs.devices: d.use=d.type!='CPU'
    sc.cycles.device='GPU'
except Exception:
    prefs.compute_device_type='CUDA'; prefs.get_devices()
    for d in prefs.devices: d.use=d.type!='CPU'
    sc.cycles.device='GPU'
sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
sc.view_settings.view_transform='AgX'
sc.render.resolution_percentage=100
PH_IDS=['blue_metal_plate','brown_leather','rubber_tiles']
def download(id,mp):
    p=TEX/f'{id}_{mp}_1k.jpg'
    if not p.exists():
        remote_map='albedo' if id=='brown_leather' and mp=='diff' else mp
        u=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{remote_map}_1k.jpg'
        req=urllib.request.Request(u,headers={'User-Agent':'Mozilla/5.0'})
        with urllib.request.urlopen(req,timeout=90) as r: p.write_bytes(r.read())
    return p
for ph in PH_IDS:
    for mp in ['diff','nor_gl','arm']: download(ph,mp)

def node(nt,typ): return nt.nodes.new(typ)
def img_node(nt,path,noncolor=False):
    n=node(nt,'ShaderNodeTexImage'); n.image=bpy.data.images.load(str(path),check_existing=True)
    if noncolor: n.image.colorspace_settings.name='Non-Color'
    return n

def material(name,id,col,metal,rough,grain):
    m=bpy.data.materials.new(name); m.use_nodes=True
    nt=m.node_tree; nt.nodes.clear(); out=node(nt,'ShaderNodeOutputMaterial'); p=node(nt,'ShaderNodeBsdfPrincipled'); nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
    uv=node(nt,'ShaderNodeTexCoord'); dif=img_node(nt,download(id,'diff')); nor=img_node(nt,download(id,'nor_gl'),True); arm=img_node(nt,download(id,'arm'),True)
    surface_uv=uv.outputs['UV']
    if id=='blue_metal_plate':
        mapping=node(nt,'ShaderNodeMapping');mapping.inputs['Location'].default_value=(.32,.32,0);mapping.inputs['Scale'].default_value=(.36,.36,1)
        nt.links.new(surface_uv,mapping.inputs['Vector']);surface_uv=mapping.outputs['Vector']
    for t in [dif,nor,arm]: nt.links.new(surface_uv,t.inputs['Vector'])
    gray=node(nt,'ShaderNodeRGBToBW'); nt.links.new(dif.outputs['Color'],gray.inputs['Color'])
    ramp=node(nt,'ShaderNodeValToRGB'); ramp.color_ramp.elements[0].position=.012 if id=='rubber_tiles' else .010; ramp.color_ramp.elements[1].position=.028 if id=='rubber_tiles' else .080
    ramp.color_ramp.elements[0].color=(*[v*(.90 if name in ['Paint','Chrome'] else (.82 if name in ['Alloy','Dark'] else .67)) for v in col],1); ramp.color_ramp.elements[1].color=(*col,1); nt.links.new(gray.outputs[0],ramp.inputs[0])
    noise=node(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=155; noise.inputs['Detail'].default_value=3; nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
    nm=node(nt,'ShaderNodeNormalMap'); nm.inputs['Strength'].default_value=grain; nt.links.new(nor.outputs['Color'],nm.inputs['Color'])
    bump=node(nt,'ShaderNodeBump'); bump.inputs['Strength'].default_value=.13; bump.inputs['Distance'].default_value=.00006; nt.links.new(noise.outputs['Fac'],bump.inputs['Height']); nt.links.new(nm.outputs[0],bump.inputs['Normal']); nt.links.new(bump.outputs[0],p.inputs['Normal'])
    sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(arm.outputs['Color'],sep.inputs['Color'])
    rr=node(nt,'ShaderNodeMapRange'); rr.inputs['To Min'].default_value=max(.07,rough-.055); rr.inputs['To Max'].default_value=rough+.06; nt.links.new(sep.outputs[1],rr.inputs['Value'])
    nt.links.new(ramp.outputs[0],p.inputs['Base Color']); nt.links.new(rr.outputs['Result'],p.inputs['Roughness']); p.inputs['Metallic'].default_value=metal
    # Bake a reusable physical surface tile. The asset uses this tiled result.
    bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,4)); plane=bpy.context.object; plane.data.materials.append(m)
    target=node(nt,'ShaderNodeTexImage'); nt.nodes.active=target
    emission=node(nt,'ShaderNodeEmission')
    def bake_output(suffix,socket,res,normal=False):
        im=bpy.data.images.new(name+'_'+suffix,width=res,height=res,alpha=False)
        im.colorspace_settings.name='Non-Color' if suffix!='Base' else 'sRGB'; target.image=im
        for n in nt.nodes: n.select=False
        target.select=True; nt.nodes.active=target
        if normal: nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
        else:
            nt.links.new(socket,emission.inputs['Color']); nt.links.new(emission.outputs[0],out.inputs['Surface'])
        sc.cycles.samples=8; sc.render.bake.margin=4
        bpy.ops.object.bake(type='NORMAL' if normal else 'EMIT')
        im.filepath_raw=str(TEX/(name+'_'+suffix+('.jpg' if suffix=='Base' else '.png'))); im.file_format='JPEG' if suffix=='Base' else 'PNG'; im.save()
        return im
    base=bake_output('Base',ramp.outputs[0],1024)
    normal=bake_output('Normal',None,512,True)
    orm=node(nt,'ShaderNodeCombineColor'); orm.inputs[0].default_value=.94; orm.inputs[2].default_value=metal; nt.links.new(rr.outputs[0],orm.inputs[1])
    packed=bake_output('ORM',orm.outputs[0],512)
    bpy.data.objects.remove(plane,do_unlink=True)
    # Exportable glTF graph only: no procedural shading remains.
    nt.nodes.clear(); out=node(nt,'ShaderNodeOutputMaterial'); p=node(nt,'ShaderNodeBsdfPrincipled'); nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
    a=node(nt,'ShaderNodeTexImage'); a.image=base
    if name=='Paint':
        mix=node(nt,'ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=1; mix.inputs[2].default_value=(.02,.17,.08,1); nt.links.new(a.outputs['Color'],mix.inputs[1]); nt.links.new(mix.outputs[0],p.inputs['Base Color'])
        p.inputs['Coat Weight'].default_value=1; p.inputs['Coat Roughness'].default_value=.05
    else: nt.links.new(a.outputs['Color'],p.inputs['Base Color'])
    t=node(nt,'ShaderNodeTexImage'); t.image=normal; nm=node(nt,'ShaderNodeNormalMap'); nt.links.new(t.outputs['Color'],nm.inputs['Color']); nt.links.new(nm.outputs[0],p.inputs['Normal'])
    t=node(nt,'ShaderNodeTexImage'); t.image=packed; s=node(nt,'ShaderNodeSeparateColor'); nt.links.new(t.outputs['Color'],s.inputs[0]); nt.links.new(s.outputs[1],p.inputs['Roughness']); nt.links.new(s.outputs[2],p.inputs['Metallic'])
    # glTF exporter recognises the occlusion group convention.
    group=bpy.data.node_groups.get('glTF Material Output')
    if group is None:
        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(s.outputs[0],gn.inputs['Occlusion'])
    m.diffuse_color=(*((.02,.17,.08) if name=='Paint' else col),1)
    print('BAKED',name,flush=True); return m

M={}
M['Paint']=material('Paint','blue_metal_plate',(.98,.98,.98),.35,.24,.002)
M['Chrome']=material('Chrome','blue_metal_plate',(.69,.71,.72),1,.19,.006)
M['Alloy']=material('Alloy','blue_metal_plate',(.32,.34,.35),.92,.34,.025)
M['Dark']=material('Dark','blue_metal_plate',(.024,.028,.030),.68,.34,.035)
M['Rubber']=material('Rubber','rubber_tiles',(.028,.031,.032),0,.66,.12)
M['Leather']=material('Leather','brown_leather',(.047,.037,.03),0,.49,.2)
# Small common lens atlas: warm headlight, ruby tail and amber indicators.
import numpy as np
im=bpy.data.images.new('Lamps_Base',width=128,height=128,alpha=False)
pix=np.ones((128,128,4),dtype=np.float32)
pix[:,:43,:3]=[.95,.90,.78]; pix[:,43:85,:3]=[.52,.006,.002]; pix[:,85:,:3]=[1,.18,.004]
for x in range(128): pix[:,x,:3]*=.8+.2*cos(x*pi/3)**2
im.pixels.foreach_set(pix.ravel()); im.filepath_raw=str(TEX/'Lamps_Base.png'); im.file_format='PNG'; im.save()
lamp=bpy.data.materials.new('Lamps'); lamp.use_nodes=True; nt=lamp.node_tree; p=nt.nodes.get('Principled BSDF'); p.inputs['Metallic'].default_value=.1;p.inputs['Roughness'].default_value=.2;p.inputs['Emission Strength'].default_value=.6
n=node(nt,'ShaderNodeTexImage'); n.image=im; nt.links.new(n.outputs['Color'],p.inputs['Base Color']); nt.links.new(n.outputs['Color'],p.inputs['Emission Color']); M['Lamps']=lamp

# Bake the shared lens material through Cycles as well; its atlas preserves
# the three lamp hues while normal and ORM remain compact 128-pixel tiles.
bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,4))
lens_plane=bpy.context.object;lens_plane.data.materials.append(lamp)
out=nt.nodes.get('Material Output'); target=node(nt,'ShaderNodeTexImage')
emit=node(nt,'ShaderNodeEmission'); comb=node(nt,'ShaderNodeCombineColor')
comb.inputs[0].default_value=1;comb.inputs[1].default_value=.2;comb.inputs[2].default_value=.1
baked_lenses={}
for suffix,socket in [('Base',n.outputs['Color']),('Normal',None),('ORM',comb.outputs[0])]:
    tex=bpy.data.images.new('Lamps_'+suffix+'_baked',width=128,height=128,alpha=False)
    tex.colorspace_settings.name='sRGB' if suffix=='Base' else 'Non-Color'
    target.image=tex
    for nn in nt.nodes:nn.select=False
    target.select=True;nt.nodes.active=target
    if socket is None:nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
    else:
        nt.links.new(socket,emit.inputs['Color']);nt.links.new(emit.outputs[0],out.inputs['Surface'])
    bpy.ops.object.bake(type='NORMAL' if suffix=='Normal' else 'EMIT')
    tex.filepath_raw=str(TEX/('Lamps_'+suffix+'_baked.png'));tex.file_format='PNG';tex.save();baked_lenses[suffix]=tex
bpy.data.objects.remove(lens_plane,do_unlink=True)
nt.links.new(p.outputs['BSDF'],out.inputs['Surface']);n.image=baked_lenses['Base']
tn=node(nt,'ShaderNodeTexImage');tn.image=baked_lenses['Normal'];nm=node(nt,'ShaderNodeNormalMap');nt.links.new(tn.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],p.inputs['Normal'])
to=node(nt,'ShaderNodeTexImage');to.image=baked_lenses['ORM'];sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(to.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],p.inputs['Roughness']);nt.links.new(sep.outputs[2],p.inputs['Metallic'])
gn=node(nt,'ShaderNodeGroup');gn.node_tree=bpy.data.node_groups['glTF Material Output'];nt.links.new(sep.outputs[0],gn.inputs['Occlusion'])
print('BAKED Lamps',flush=True)

parts={n:[] for n in ['Frame','Fork','Wheel_F','Wheel_R','Kickstand']}
def add(o,name,mat,grp='Frame'):
    o.name=name; o.data.materials.append(M[mat]); parts[grp].append(o)
    for f in o.data.polygons: f.use_smooth=True
    return o

def bevel(o,w=.002,seg=2):
    mod=o.modifiers.new('machined radii','BEVEL'); mod.width=w; mod.segments=seg
    bpy.context.view_layer.objects.active=o; bpy.ops.object.modifier_apply(modifier=mod.name)
    return o

def box(name,loc,scale,mat='Dark',grp='Frame',b=.005):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.scale=scale; bpy.ops.object.transform_apply(location=False,rotation=False,scale=True); add(o,name,mat,grp)
    if b: bevel(o,b,3)
    for f in o.data.polygons: f.use_smooth=max(abs(v) for v in f.normal)<.999
    return o

def ell(name,loc,scale,mat='Chrome',grp='Frame',seg=32,rings=16):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=loc); o=bpy.context.object; o.scale=scale; bpy.ops.object.transform_apply(location=False,rotation=False,scale=True); return add(o,name,mat,grp)

def cyl(name,a,b,r,mat='Chrome',grp='Frame',v=20,bev=.001):
    a,b=Vector(a),Vector(b); d=b-a
    bpy.ops.mesh.primitive_cylinder_add(vertices=v,radius=r,depth=d.length,end_fill_type='NGON',location=(a+b)*.5); o=bpy.context.object; o.rotation_euler=d.to_track_quat('Z','Y').to_euler(); add(o,name,mat,grp)
    if bev: bevel(o,bev,2)
    for f in o.data.polygons:
        if len(f.vertices)>4: f.use_smooth=False
    return o

def tube(name,pts,r,mat='Chrome',grp='Frame',res=8,smooth=True):
    cu=bpy.data.curves.new(name,'CURVE'); cu.dimensions='3D'; cu.resolution_u=8; cu.bevel_depth=r; cu.bevel_resolution=1 if res<=6 else 2; cu.resolution_u=10
    sp=cu.splines.new('BEZIER' if smooth else 'POLY')
    if smooth:
        sp.bezier_points.add(len(pts)-1)
        for p,co in zip(sp.bezier_points,pts): p.co=co; p.handle_left_type='AUTO'; p.handle_right_type='AUTO'
    else:
        sp.points.add(len(pts)-1)
        for p,co in zip(sp.points,pts): p.co=(*co,1)
    o=bpy.data.objects.new(name,cu); sc.collection.objects.link(o); bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o; bpy.ops.object.convert(target='MESH'); return add(bpy.context.object,name,mat,grp)

def mesh(name,verts,faces,mat,grp='Frame'):
    me=bpy.data.meshes.new(name); me.from_pydata(verts,[],faces); me.update(); o=bpy.data.objects.new(name,me); sc.collection.objects.link(o); return add(o,name,mat,grp)

def torus(name,center,major,minor,mat='Chrome',grp='Frame',axis='X',seg=64,minorseg=8):
    rot=(0,pi/2,0) if axis=='X' else ((pi/2,0,0) if axis=='Y' else (0,0,0))
    bpy.ops.mesh.primitive_torus_add(major_segments=seg,minor_segments=minorseg,location=center,rotation=rot,major_radius=major,minor_radius=minor); return add(bpy.context.object,name,mat,grp)

def loft(name,sections,mat='Paint',grp='Frame',n=32):
    # Cross sections in X/Z, with a broad flatter lower surface and arched crown.
    vs=[]
    for y,w,zc,h in sections:
        for j in range(n):
            t=2*pi*j/n; xx=sin(t)*w; zz=cos(t)*h
            if zz<0: zz*=.64
            vs.append((xx,y,zc+zz))
    fs=[]
    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))
    fs.append(tuple(reversed(range(n)))); fs.append(tuple((len(sections)-1)*n+j for j in range(n)))
    o=mesh(name,vs,fs,mat,grp); mod=o.modifiers.new('coachwork smooth','SUBSURF'); mod.levels=2; bpy.context.view_layer.objects.active=o; bpy.ops.object.modifier_apply(modifier=mod.name); return o

def bolt(name,x,y,z,grp='Frame',r=.008,axis='X'):
    if axis=='X': a=(x-.003,y,z); b=(x+.003,y,z)
    else: a=(x,y,z-.003); b=(x,y,z+.003)
    return cyl(name,a,b,r,'Alloy',grp,6,.0007)

# WHEELS: crowned tires, laced crossed spokes, dished hubs and raised tread blocks.
for y,grp,width in [(-.71,'Wheel_F',.097),(.71,'Wheel_R',.116)]:
    R=.3394; cr=.0474 if grp=='Wheel_F' else .0494; major=R-cr
    # Tire elliptical cross-section, smooth road carcass under fine raised chevrons.
    vs=[]; fs=[]; N=112; K=20
    for i in range(N):
        a=2*pi*i/N
        for j in range(K):
            t=2*pi*j/K; x=cos(t)*width*.5; rad=major+sin(t)*cr; vs.append((x,y+cos(a)*rad,.34+sin(a)*rad))
    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('road tire',vs,fs,'Rubber',grp)
    # Discrete rubber ribs separated by crisp diagonal drainage channels.
    for i in range(84):
        a=i*2*pi/84
        for side in [-1,1]:
            vv=[]; ff=[]
            for j in range(4):
                x=side*(.001+j*width*.115); rad=major+cr*math.sqrt(max(0,1-(2*x/width)**2))+.0006
                for da in [-.034,.034]:
                    aa=a+da+j*.018*side; vv.append((x,y+cos(aa)*rad,.34+sin(aa)*rad))
            for j in range(3): ff.append((j*2,j*2+1,j*2+3,j*2+2))
            o=mesh('tire chevron',vv,ff,'Rubber',grp); sol=o.modifiers.new('tread depth','SOLIDIFY'); sol.thickness=.0005; bpy.context.view_layer.objects.active=o; bpy.ops.object.modifier_apply(modifier=sol.name)
    for x in [-width*.41,width*.41]:
        torus('bead ridge',(x,y,.34),.268,.002,'Rubber',grp)
        torus('rim rolled lip',(x,y,.34),.258,.007,'Chrome',grp,seg=80)
        torus('rim inner bed',(x*.87,y,.34),.249,.006,'Alloy',grp,seg=80)
    cyl('axle',(-.105,y,.34),(.105,y,.34),.013,'Alloy',grp)
    cyl('hub barrel',(-.05,y,.34),(.05,y,.34),.052,'Alloy',grp)
    for side in [-1,1]:
        cyl('hub flange',(side*.046,y,.34),(side*.055,y,.34),.072,'Chrome',grp)
        for i in range(20):
            a=2*pi*i/20+(0 if side>0 else pi/20)
            for phase in [-.20,.20]:
                p=(side*.048,y+cos(a)*.066,.34+sin(a)*.066); q=(side*width*.36,y+cos(a+phase)*.251,.34+sin(a+phase)*.251)
                cyl('cross-laced stainless spoke',p,q,.0017,'Chrome',grp,6,0)
                pp=Vector(q).lerp(Vector(p),.045); cyl('spoke nipple',pp,q,.003,'Alloy',grp,6,0)
    # True drilled brake rotor assembled as bridges and annular strips, no black discs.
    x=-.067
    for ri,ro in [(.071,.096),(.110,.142)]:
        vs=[];fs=[]
        for i in range(72):
            a=2*pi*i/72
            for xx,r in [(x-.002,ri),(x-.002,ro),(x+.002,ri),(x+.002,ro)]: vs.append((xx,y+cos(a)*r,.34+sin(a)*r))
        for i in range(72):
            j=(i+1)%72
            for aa,bb in [(0,1),(2,3),(0,2),(1,3)]: fs.append((i*4+aa,i*4+bb,j*4+bb,j*4+aa))
        mesh('brake rotor rings',vs,fs,'Alloy',grp)
    for i in range(36):
        a=2*pi*i/36
        for rr in [.099,.122]:
            p=(x,y+cos(a)*rr,.34+sin(a)*rr)
            o=box('ventilated brake disc web',p,(.004,.023,.014),'Alloy',grp,0); bevel(o,.0007,1); o.rotation_euler.x=a
    for i in range(6):
        a=2*pi*i/6; bolt('rotor fastener',x-.004,y+cos(a)*.063,.34+sin(a)*.063,grp,.004)

# Main duplex cradle, head tube, swingarm, triangulated rear subframe.
for side in [-1,1]:
    x=.108*side
    tube('duplex cradle',[(x,-.43,.91),(x,-.37,.62),(x,-.26,.23),(x,-.12,.19),(x,.34,.21),(x,.45,.43),(x,.59,.72)],.017,'Dark')
    tube('backbone',[(x,-.43,.87),(x,-.17,.75),(x,.23,.70),(x,.75,.71),(x,.90,.77)],.014,'Dark')
    tube('rear triangle',[(x,.2,.25),(x,.36,.58),(x,.57,.75)],.016,'Dark')
    tube('swingarm',[(x,.20,.35),(side*.125,.46,.33),(side*.112,.71,.34)],.022,'Dark')
    cyl('swingarm pivot',(x-.019,.20,.35),(x+.019,.20,.35),.032,'Alloy')
    bolt('pivot cap',side*.142,.2,.35,r=.016)
cyl('steering head',(0,-.465,.8659),(0,-.405,1.0041),.030,'Dark')
for y,z in [(-.21,.20),(.22,.71),(.7,.71)]: cyl('frame cross brace',(-.11,y,z),(.11,y,z),.015,'Dark')

# Tank: full teardrop crown and a tucked rear knee scallop, no badge.
loft('formed teardrop fuel tank',[(-.43,.022,.785,.03),(-.414,.09,.797,.092),(-.36,.148,.815,.125),(-.22,.178,.813,.15),(-.07,.17,.798,.15),(.07,.137,.77,.125),(.19,.092,.748,.073),(.23,.032,.738,.025)],n=32)
# Filler cap and rubber gasket.
cyl('filler gasket',(0,-.25,.951),(0,-.25,.955),.034,'Rubber',v=40)
cyl('flush fuel filler',(0,-.25,.955),(0,-.25,.961),.030,'Chrome',v=40)
box('filler latch',(0,-.25,.964),(.027,.007,.004),'Alloy',b=.001)
# Low bench cushion seated directly on its stamped steel undertray.
loft('seat pan',[(.215,.045,.716,.010),(.25,.115,.723,.014),(.5,.13,.731,.014),(.72,.119,.742,.014),(.90,.09,.750,.014),(.96,.02,.754,.008)],'Dark',n=24)
seat=loft('seat cushion',[(.215,.044,.724,.023),(.24,.112,.739,.040),(.36,.135,.748,.043),(.56,.129,.753,.043),(.66,.12,.761,.048),(.715,.11,.791,.052)],'Leather',n=24)
# Every stitch follows the actual curved cushion, sampled against its surface.
for i in range(9):
    y=.27+i*.041; pts=[]
    for j in range(15):
        x=-.101+j*.202/14
        hit,co,no,idx=seat.ray_cast(Vector((x,y,1.2)),Vector((0,0,-1)))
        if hit: pts.append((co.x,co.y,co.z+.00025))
    if len(pts)>2: tube('seat stitch channel',pts,.00075,'Dark',res=6,smooth=False)
for side in [-1,1]: tube('seat welt',[(side*.107,.255,.737),(side*.13,.37,.746),(side*.123,.58,.756),(side*.114,.685,.777)],.0025,'Leather',res=6)
loft('rear painted seat hump',[(.64,.101,.754,.025),(.665,.112,.796,.075),(.71,.125,.801,.084),(.80,.126,.794,.081),(.89,.107,.771,.061),(.953,.056,.748,.022),(.965,.018,.747,.012)],n=28)
for side in [-1,1]:
    tube('cowl lower rolled edge',[(side*.10,.657,.752),(side*.12,.73,.745),(side*.10,.85,.740),(side*.025,.955,.748)],.003,'Dark',res=6)
    bolt('cowl fixing',side*.119,.71,.775,r=.006)
# Rounded, shallow convex electrical covers with continuous manufactured skins.
for side in [-1,1]:
    outline=[(.207,.683),(.445,.677),(.410,.532),(.360,.485),(.270,.481),(.202,.549)]
    cy=sum(v[0] for v in outline)/len(outline); cz=sum(v[1] for v in outline)/len(outline)
    # Round each corner with three interpolated points before doming the skin.
    perimeter=[]
    for i,p in enumerate(outline):
        prev=outline[(i-1)%len(outline)]; nxt=outline[(i+1)%len(outline)]
        aa=(p[0]*.82+prev[0]*.18,p[1]*.82+prev[1]*.18); bb=(p[0]*.82+nxt[0]*.18,p[1]*.82+nxt[1]*.18)
        for j in range(4):
            t=j/3;perimeter.append(((1-t)**2*aa[0]+2*(1-t)*t*p[0]+t*t*bb[0],(1-t)**2*aa[1]+2*(1-t)*t*p[1]+t*t*bb[1]))
    N=len(perimeter);vs=[];fs=[]
    for scale,xx in [(1,.126),(.98,.135),(.85,.148),(.55,.156),(.15,.160)]:
        for yy,zz in perimeter:vs.append((side*xx,cy+(yy-cy)*scale,cz+(zz-cz)*scale))
    for k in range(4):
        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.append(tuple(4*N+j for j in range(N)));fs.append(tuple(reversed(range(N))))
    o=mesh('domed side electrical cover',vs,fs,'Dark');mod=o.modifiers.new('formed side panel','SUBSURF');mod.levels=1;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
    for yy,zz in [(.237,.655),(.374,.521)]: bolt('side cover screw',side*.153,yy,zz,r=.0035)

# Twin cylinder air-cooled engine: cast block, fin edges, barrels, rocker covers.
box('cast crankcase',(0,-.03,.36),(.27,.38,.24),'Alloy',b=.060)
for s in [-1,1]:
    ell('oval polished engine side cover',(s*.157,-.035,.357),(.038,.199,.120),'Chrome',seg=40,rings=20)
    torus('inspection cap',(s*.19,-.13,.37),.049,.003,'Alloy')
    for i in range(11):
        a=2*pi*i/11; bolt('crankcase perimeter bolt',s*.184,-.035+cos(a)*.178,.357+sin(a)*.100,r=.005)
    cyl('oil cap',(s*.185,.06,.42),(s*.204,.06,.42),.021,'Alloy',v=24)
for x in [-.072,.072]:
    box('cylinder core',(x,-.10,.55),(.111,.15,.23),'Dark',b=.017)
    for k in range(13):
        z=.449+k*.015
        box('cooling fin',(x,-.112,z),(.139,.186,.006),'Alloy',b=.005)
    box('cylinder head',(x,-.108,.659),(.145,.165,.035),'Alloy',b=.011)
    ell('polished rocker cover',(x,-.10,.693),(.066,.075,.046),'Chrome',seg=24,rings=12)
    for yy in [-.16,-.045]: cyl('head stud',(x,yy,.639),(x,yy,.702),.005,'Dark',v=8)
    cyl('spark plug',(x,-.04,.704),(x,-.005,.72),.008,'Alloy',v=10)
    tube('ignition HT lead',[(x,-.005,.72),(x,.016,.738),(x,.09,.687),(x,.11,.645)],.004,'Rubber',res=6)
# Carburettors, inlet boots and clamps.
for x in [-.073,.073]:
    cyl('intake cast manifold',(x,-.043,.616),(x,.018,.616),.028,'Alloy','Frame')
    cyl('intake rubber boot',(x,.009,.616),(x,.065,.616),.027,'Rubber','Frame')
    cyl('carb throat',(x,.056,.616),(x,.135,.616),.028,'Alloy')
    ell('carb float bowl',(x,.099,.583),(.032,.035,.025),'Alloy',seg=16,rings=8)
    cyl('airfilter',(x,.136,.616),(x,.212,.616),.038,'Dark')
    for yy in [.06,.13]: torus('intake clamp',(x,yy,.616),.027,.002,'Chrome',axis='Y',seg=24)
    tube('throttle cable',[(x,.085,.649),(x,.095,.71),(.065,-.15,.73),(.04,-.405,.92)],.0022,'Rubber',res=6)

# Rear transmission casing, starter and oil lines fill the mechanical bay.
ell('rear transmission casting',(0,.176,.346),(.136,.115,.083),'Alloy',seg=28,rings=14)
for side in [-1,1]:
    cyl('starter motor',(.02*side,.173,.43),(.139*side,.173,.43),.036,'Dark',v=24)
    cyl('starter end cap',(.130*side,.173,.43),(.148*side,.173,.43),.036,'Alloy',v=24)
    bolt('starter fixing',.153*side,.173,.43,r=.005)
    tube('oil return line',[(.12*side,.15,.38),(.13*side,.18,.48),(.12*side,-.035,.55)],.004,'Rubber',res=6)
# Polished twin exhausts, two staggered cones up the visible right flank.
for i,x in enumerate([-.073,.073]):
    z=.185+i*.030; side=-.19-i*.035
    tube('exhaust header',[(x,-.205,.63),(x,-.30,.608),(x,-.345,.515),(x,-.294,.355),(x,-.195,.206),(side,.03,z),(side,.40,z+.055)],.0205,'Chrome')
    a=Vector((side,.30,z+.04)); b=Vector((side,.88,.43+i*.105)); d=b-a
    # Hand-spun tapered muffler, with rolled end ring and dark open outlet.
    n=32; rings=[(0,.021),(.07,.034),(.15,.046),(.89,.046),(1,.038)]
    q=d.to_track_quat('Z','Y'); vs=[];fs=[]
    for t,r in rings:
        for j in range(n):
            co=a+d*t+q@Vector((r*cos(j*2*pi/n),r*sin(j*2*pi/n),0)); vs.append(tuple(co))
    for k in range(len(rings)-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))
    mesh('upswept megaphone muffler',vs,fs,'Chrome')
    cyl('exhaust outlet shadow',b-d.normalized()*.012,b-d.normalized()*.010,.032,'Dark',v=32,bev=0)
    cyl('muffler outlet lip',b-d.normalized()*.004,b,.038,'Chrome',v=32,bev=.002)
    # Reopen lip visually with smaller dark bore disc.
    cyl('exhaust inner bore',b,b+d.normalized()*.0005,.028,'Dark',v=24,bev=0)
    cyl('silencer mounting strap',(side,.57,.31+i*.085),(side+.035,.58,.46),.008,'Alloy')

# Rear suspension: chrome springs around damper rods, both functional eyes.
for s in [-1,1]:
    a=Vector((s*.135,.69,.36)); b=Vector((s*.139,.49,.72)); axis=(b-a).normalized(); q=(b-a).to_track_quat('Z','Y')
    cyl('rear damper body',a,a+(b-a)*.62,.019,'Alloy'); cyl('damper piston',a+(b-a)*.40,b,.009,'Chrome')
    pts=[]
    for i in range(161):
        t=i/160; co=a+(b-a)*(.15+.72*t)+q@Vector((.030*cos(t*2*pi*12),.030*sin(t*2*pi*12),0)); pts.append(tuple(co))
    tube('helical rear suspension spring',pts,.0042,'Chrome',res=6,smooth=False)
    for p in [a,b]:
        cyl('shock mount',(s*.125,p.y,p.z),(s*.161,p.y,p.z),.022,'Alloy'); bolt('shock bolt',s*.167,p.y,p.z,r=.009)

# Chain drive: shaped loop plus individual link cheeks and sprocket teeth.
yc=.71; front=.19; zc=.34; rc=.118; rf=.053; x=.129
cyl('rear drive sprocket',(x-.005,yc,zc),(x+.005,yc,zc),rc,'Alloy','Wheel_R',v=48)
for i in range(38):
    a=2*pi*i/38; o=box('sprocket tooth',(x,yc+cos(a)*.123,zc+sin(a)*.123),(.013,.015,.011),'Alloy','Wheel_R',b=.001);o.rotation_euler.x=a
pts=[]
for i in range(28):
    a=-pi/2+i*pi/27;pts.append((x,yc+rc*cos(a),zc+rc*sin(a)))
for i in range(25):
    t=i/24;pts.append((x,yc*(1-t)+front*t,zc+rc*(1-t)+rf*t))
for i in range(16):
    a=pi/2+i*pi/15;pts.append((x,front+rf*cos(a),zc+rf*sin(a)))
for i in range(25):
    t=i/24;pts.append((x,front*(1-t)+yc*t,zc-rf*(1-t)-rc*t))
tube('drive chain core',pts,.004,'Dark',res=6,smooth=False)
for p in pts:
    for xx in [x-.006,x+.006]: cyl('chain rivet',(xx-.001,p[1],p[2]),(xx+.001,p[1],p[2]),.004,'Alloy',v=6,bev=0)
# Rider controls, rear brake and retracted side stand.
for s in [-1,1]:
    cyl('footrest hanger',(s*.115,.16,.30),(s*.195,.24,.275),.012,'Alloy')
    cyl('rider footpeg',(s*.175,.24,.275),(s*.255,.24,.275),.013,'Rubber',v=20)
    for i in range(6): torus('footpeg rubber ribs',(s*(.19+i*.01),.24,.275),.013,.002,'Rubber',seg=16,minorseg=6)
    tube('foot lever',[(s*.17,.18,.27),(s*.19,.075,.27),(s*.23,.045,.27)],.007,'Alloy',res=6)
    cyl('toe peg',(s*.218,.045,.27),(s*.251,.045,.27),.011,'Rubber',v=12)
tube('retracted stand',[(.135,.15,.245),(.19,.35,.213),(.205,.46,.237)],.009,'Dark','Kickstand')
box('stand foot',(.205,.46,.237),(.035,.035,.008),'Dark','Kickstand',.003)
cyl('stand pivot',(.12,.15,.245),(.157,.15,.245),.013,'Alloy','Kickstand')

# Fork assembly follows steering rake; layered lower sliders, gaiters, tubes.
for s in [-1,1]:
    x=s*.092; a=Vector((x,-.71,.34)); b=Vector((x,-.43,.986)); d=b-a
    cyl('fork polished stanchion',a,b,.016,'Chrome','Fork',24)
    cyl('fork lower slider',a,a+d*.45,.025,'Dark','Fork',24)
    cyl('axle mount',(s*.075,-.71,.34),(s*.12,-.71,.34),.028,'Alloy','Fork',24)
    bolt('front axle nut',s*.124,-.71,.34,'Fork',.014)
    # A single closed accordion sleeve: dark rubber fills every valley.
    q=d.to_track_quat('Z','Y');vs=[];fs=[];N=24
    for i in range(45):
        t=.447+.285*i/44;rad=.0245+.0040*cos(i*pi/2)
        for j in range(N):
            co=a+d*t+q@Vector((rad*cos(j*2*pi/N),rad*sin(j*2*pi/N),0));vs.append(tuple(co))
    for i in range(44):
        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))
    fs.append(tuple(reversed(range(N))));fs.append(tuple(44*N+j for j in range(N)))
    mesh('rubber accordion fork boot',vs,fs,'Rubber','Fork')
    cyl('fork cap',b-d.normalized()*.015,b+d.normalized()*.010,.022,'Alloy','Fork',24)
for y,z in [(-.477,.872),(-.431,.983)]:
    box('triple clamp',(0,y,z),(.244,.055,.025),'Alloy','Fork',.01)
    for s in [-1,1]: bolt('clamp bolt',s*.096,y,z+.016,'Fork',.007,'Z')
# Front fender arched with rolled outer edge and side stays.
vs=[];fs=[];N=40
for i in range(N):
    a=.50+i*(2.35-.50)/(N-1)
    for x in [-.057,-.044,0,.044,.057]:
        r=.36+.008*(1-(x/.058)**2); vs.append((x,-.71+cos(a)*r,.34+sin(a)*r))
for i in range(N-1):
    for j in range(4):fs.append((i*5+j,i*5+j+1,(i+1)*5+j+1,(i+1)*5+j))
o=mesh('front mudguard',vs,fs,'Paint','Fork');mod=o.modifiers.new('steel sheet thickness','SOLIDIFY');mod.thickness=.002;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
for s in [-1,1]:
    tube('fender stays',[(s*.065,-.875,.66),(s*.083,-.69,.48),(s*.065,-.50,.64)],.005,'Alloy','Fork',res=6)
# Front brake caliper bolts and flexible brake hose.
box('front caliper',(-.085,-.586,.41),(.041,.060,.095),'Dark','Fork',.012)
for z in [.381,.43]: bolt('caliper fastener',-.11,-.587,z,'Fork',.005)
tube('front brake hydraulic line',[(-.109,-.586,.445),(-.123,-.586,.56),(-.118,-.486,.85),(-.173,-.46,1.02),(-.225,-.43,1.024)],.0032,'Rubber','Fork',res=6)
# One original round headlamp, cast shell, rolled chrome rim, fluted lens.
ell('round headlamp bucket',(0,-.581,.903),(.101,.076,.101),'Dark','Fork',seg=40,rings=20)
torus('headlamp bezel',(0,-.647,.903),.094,.007,'Chrome','Fork',axis='Y',seg=64)
ell('headlamp lens',(0,-.653,.903),(.090,.014,.090),'Lamps','Fork',seg=48,rings=24)
for i in range(-12,13):
    x=i*.0064; h=math.sqrt(max(.0001,.088**2-x*x)); tube('headlamp lens flute',[(x,-.658,.903-h),(x,-.669+.006*(abs(x)/.088),.903),(x,-.658,.903+h)],.00075,'Chrome','Fork',res=6)
for s in [-1,1]:
    tube('headlight ear',[(s*.089,-.475,.922),(s*.12,-.551,.913),(s*.101,-.582,.903)],.007,'Alloy','Fork',res=6)
    bolt('lamp pivot screw',s*.105,-.581,.903,'Fork',.009)
# Low clip-on bars, swept grips and metal levers. Exact outer width .75 m.
for s in [-1,1]:
    tube('clip on bar',[(s*.09,-.438,.981),(s*.165,-.444,1.001),(s*.26,-.419,1.003),(s*.355,-.39,.98)],.010,'Chrome','Fork')
    a=Vector((s*.265,-.418,1.002)); b=Vector((s*.365,-.386,.978)); d=b-a
    cyl('rubber handgrip',a,b,.014,'Rubber','Fork',24)
    for i in range(13):
        p=a+d*(i/13); o=torus('grip texture ring',p,.014,.001,'Rubber','Fork',axis='Z',seg=16,minorseg=6);o.rotation_euler=d.to_track_quat('Z','Y').to_euler()
    cyl('bar end weight',b,b+d.normalized()*.006,.013,'Alloy','Fork',20)
    box('switchgear',(s*.248,-.423,1.004),(.028,.038,.032),'Dark','Fork',.006)
    tube('brake clutch lever',[(s*.215,-.451,1.009),(s*.278,-.466,.993),(s*.345,-.448,.965)],.004,'Alloy','Fork')
    ell('lever rounded end',(s*.345,-.448,.965),(.006,.006,.006),'Alloy','Fork',seg=12,rings=6)
    cyl('switch button',(s*.249,-.444,1.009),(s*.249,-.449,1.009),.005,'Rubber','Fork',12)
    tube('control cable',[(s*.233,-.424,1.0),(s*.169,-.45,.96),(s*.09,-.465,.85),(s*.04,-.22,.69)],.0026,'Rubber','Fork',res=6)
# Compact tilted speedometer, no text or logo.
cyl('instrument can',(0,-.429,1.008),(0,-.454,1.076),.042,'Dark','Fork',40)
cyl('instrument rim',(0,-.454,1.076),(0,-.456,1.083),.043,'Chrome','Fork',40)
cyl('instrument face',(0,-.456,1.084),(0,-.457,1.086),.038,'Dark','Fork',40)
tube('speedometer needle',[(0,-.458,1.087),(-.018,-.466,1.11-.033)],.0011,'Alloy','Fork',res=6,smooth=False)
# Tail lens and compact amber indicators with rubbers.
box('tail lamp housing',(0,.946,.755),(.102,.035,.028),'Dark',b=.009)
box('tail red lens',(0,.966,.756),(.085,.008,.019),'Lamps',b=.006)
for s in [-1,1]:
    for y,z,g in [(-.51,.856,'Fork'),(.84,.747,'Frame')]:
        cyl('indicator stem',(s*.10,y,z),(s*.176,y,z),.005,'Dark',g,12)
        ell('indicator shell',(s*.183,y,z),(.025,.018,.016),'Dark',g,seg=20,rings=10)
        ell('indicator amber lens',(s*.183,y+(-.012 if y<0 else .012),z),(.021,.009,.012),'Lamps',g,seg=20,rings=10)

# Set the instrument crown at the specified 1.10 m overall height.
for o in bpy.data.objects:
    if o.type=='MESH' and (o.name.startswith('instrument ') or o.name.startswith('speedometer needle')):
        o.location.z-=.0027
# UVs: physical 0.5 m tiles, projection per face on machined and curved surfaces.
# Lamps receive small palette UV regions shared by all three colors.
for group,obs in parts.items():
    for o in obs:
        bpy.context.view_layer.objects.active=o
        bpy.ops.object.select_all(action='DESELECT');o.select_set(True)
        bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
        me=o.data
        bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)); bm.to_mesh(me); bm.free()
        if not me.uv_layers: me.uv_layers.new(name='UVMap')
        uv=me.uv_layers.active.data
        islamp=o.data.materials[0].name=='Lamps'
        for poly in me.polygons:
            ax=max(range(3),key=lambda i:abs(poly.normal[i])); axes=[i for i in range(3) if i!=ax]
            for li in poly.loop_indices:
                co=me.vertices[me.loops[li].vertex_index].co+o.location
                if islamp:
                    uu=.166 if 'headlamp' in o.name else (.50 if 'tail' in o.name else .833)
                    uv[li].uv=(uu+co.x*.1,co.z*3)
                else: uv[li].uv=(co[axes[0]]*2,co[axes[1]]*2)
# Join rigid assemblies and set meaningful origins.
pivots={'Frame':(0,0,0),'Fork':(0,-.435,.935),'Wheel_F':(0,-.71,.34),'Wheel_R':(0,.71,.34),'Kickstand':(.135,.15,.245)}
assemblies={}
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
    sc.cursor.location=pivots[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR');assemblies[name]=o
    # Explicitly triangulate for a stable delivery budget.
    mod=o.modifiers.new('game triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
# Preserve world positions under fork parenting.
o=assemblies['Wheel_F'];mw=o.matrix_world.copy();o.parent=assemblies['Fork'];o.matrix_world=mw
sockets={'Seat_Rider':(0,.405,.803),'Hand_L':(.315,-.402,.99),'Hand_R':(-.315,-.402,.99),'Foot_L':(.23,.24,.275),'Foot_R':(-.23,.24,.275),'Light_Head':(0,-.665,.903),'Light_Tail':(0,.975,.756)}
for name,loc in sockets.items():
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=loc;o.empty_display_size=.025
    if name in ['Hand_L','Hand_R','Light_Head']:
        o.parent=assemblies['Fork'];o.matrix_world=Matrix.Translation(loc)
allnodes=list(assemblies.values())+[bpy.data.objects[n] for n in sockets]
bpy.context.view_layer.update()
paints=[{'name':'Racing Green','linear_rgb':[.02,.17,.08]},{'name':'Oxblood','linear_rgb':[.27,.022,.014]},{'name':'Midnight Blue','linear_rgb':[.018,.048,.15]},{'name':'Ivory','linear_rgb':[.72,.64,.43]},{'name':'Graphite','linear_rgb':[.035,.040,.047]},{'name':'Burnt Copper','linear_rgb':[.36,.11,.028]}]
def xyz(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
pts=[o.matrix_world@Vector(c) for o in assemblies.values() 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)])
meta={'units':'metres','axes':{'front':'+Z','up':'+Y','rider_left':'+X'},'nodes':{o.name:{'world_position':xyz(o.matrix_world.translation),'parent':o.parent.name if o.parent else None} for o in allnodes},'bounding_box':{'min':[lo.x,lo.z,-hi.y],'max':[hi.x,hi.z,-lo.y]},'paints':paints,'fork_steering_axis':[0,.917,-.398]}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
tri=sum(len(o.data.polygons) for o in assemblies.values())
if tri>78000:
    ratio=74500/tri
    for o in assemblies.values():
        bpy.context.view_layer.objects.active=o; mod=o.modifiers.new('bounded game mesh','DECIMATE'); mod.ratio=ratio; mod.use_collapse_triangulate=True; bpy.ops.object.modifier_apply(modifier=mod.name)
    tri=sum(len(o.data.polygons) for o in assemblies.values())
# Remove collapsed microscopic triangles before export; preserve thin spokes.
for o in assemblies.values():
    bm=bmesh.new();bm.from_mesh(o.data)
    dead=[f for f in bm.faces if f.calc_area()<5.1e-13]
    bmesh.ops.delete(bm,geom=dead,context='FACES')
    bm.to_mesh(o.data);bm.free();o.data.update();o.data.name=o.name
tri=sum(len(o.data.polygons) for o in assemblies.values())
print('TRIANGLES',tri,flush=True)
bpy.ops.object.select_all(action='DESELECT')
for o in allnodes:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'cafe-racer.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False)
# Blender's exporter preserves the neutral image but omits the MixRGB tint.
# Set the standard glTF factor explicitly, preserving all embedded binary data.
import struct
path=ROOT/'cafe-racer.glb';raw=path.read_bytes();jsize=struct.unpack_from('<I',raw,12)[0]
doc=json.loads(raw[20:20+jsize]);binary_chunk=raw[20+jsize:]
paint_doc=next(m for m in doc['materials'] if m['name']=='Paint')
paint_doc['pbrMetallicRoughness']['baseColorFactor']=[.02,.17,.08,1]
encoded=json.dumps(doc,separators=(',',':')).encode();encoded+=b' '*((-len(encoded))%4)
path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(encoded)+len(binary_chunk))+struct.pack('<II',len(encoded),0x4e4f534a)+encoded+binary_chunk)
(ROOT/'NOTES.md').write_text('\n'.join(['Original retro cafe racer matching sheet proportions, metre scale, +Z front; articulated fork, wheels, stand and game sockets.',f'Triangles: {tri}; wheelbase 1.42 m; overall measured size '+str([round(hi[i]-lo[i],3) for i in range(3)])+'.','Materials: Paint, Chrome, Alloy, Dark, Rubber, Leather, Lamps; Paint is neutral-texture recolourable with clearcoat.','Textures: base 1024 px; normal and ORM 512 px; lamp palette 128 px; six shared surface sets baked with Cycles GPU.','Poly Haven CC0 ids: blue_metal_plate, brown_leather, rubber_tiles; discarded draft used metal_plate (https://polyhaven.com/license).','Differences: original unbranded face, default racing green instead of black; simplified fasteners and tread, no readable markings.'])+'\n')
# Studio daylight, cameras and ground are render-only, added after export.
world=sc.world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.55,.65,.78,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
floor=bpy.data.materials.new('Studio floor');floor.diffuse_color=(.42,.45,.47,1);floor.use_nodes=True;floor.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.42,.45,.47,1);floor.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.78
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.001));ground=bpy.context.object;ground.name='Studio';ground.data.materials.append(floor)
def area(name,loc,power,size,color):
    d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=color;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,.5))-o.location).to_track_quat('-Z','Y').to_euler()
area('large daylight key',(-3,-4,5),550,4,(1,.92,.82));area('sky fill',(3,-1,3),380,3,(.74,.84,1));area('long chrome reflection',(-2,3,3.5),500,3,(1,1,1))
camd=bpy.data.cameras.new('RenderCamera');cam=bpy.data.objects.new('RenderCamera',camd);sc.collection.objects.link(cam);sc.camera=cam
cam.location=(-4.0,-3.4,1.9);target=Vector((0,0,.53));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=2.70;camd.lens=60;camd.clip_end=1000
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.cycles.samples=40
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'cafe-racer.blend'))
def render(name):
    sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)
render('final.png')
hero=cam.matrix_world.copy();cam.location=(-4,0,.78);cam.rotation_euler=(Vector((0,0,.54))-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=2.48;render('side.png')
cam.matrix_world=hero;camd.ortho_scale=2.70
clay=bpy.data.materials.new('QA clay');clay.use_nodes=True;p=clay.node_tree.nodes['Principled BSDF'];p.inputs['Base Color'].default_value=(.38,.38,.38,1);p.inputs['Roughness'].default_value=.65
old={}
for o in assemblies.values():
    old[o.name]=list(o.data.materials)
    for i in range(len(o.data.materials)):o.data.materials[i]=clay
render('clay.png')
for o in assemblies.values():
    for i,m in enumerate(old[o.name]):o.data.materials[i]=m
# Six honest recolour renders, compose using Blender pixels (no Pillow dependency).
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=16
mix=next(n for n in M['Paint'].node_tree.nodes if n.type=='MIX_RGB')
grid=np.ones((720,1920,4),dtype=np.float32)
for i,p in enumerate(paints):
    mix.inputs[2].default_value=(*p['linear_rgb'],1);sc.render.filepath=str(ROOT/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'),check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);r=1-i//3;c=i%3;grid[r*360:(r+1)*360,c*640:(c+1)*640]=a;bpy.data.images.remove(im)
    (ROOT/f'paint_{i}.png').unlink()
mix.inputs[2].default_value=(.02,.17,.08,1)
im=bpy.data.images.new('Paint comparison',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()
print('COMPLETE',flush=True)
