"""Clean build: Blender 5.2, Cycles OPTIX, CC0 surface bakes, metre/Y-up glTF.
Run: blender -b --python-exit-code 1 --python build_mountain-bike.py
Authoring coordinates: X rider left, -Y forward, Z up. Max 3 visual revisions.
Hero/closest view 1920x1080, side orthographic; 20-50k tris, <=6 mats, <=12MB.
"""
import bpy, bmesh, math, os, json, struct, urllib.request, time
from mathutils import Vector
from pathlib import Path
from math import sin, cos, pi
import numpy as np
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
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)
S=bpy.context.scene; S.unit_settings.system='METRIC'; S.render.engine='CYCLES'
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'
S.cycles.device='GPU'; S.cycles.samples=24; S.cycles.use_denoising=True
S.render.image_settings.file_format='PNG'; S.render.film_transparent=False
S.view_settings.view_transform='AgX'
PAINTS=[('Ember Orange',[.80,.25,.02]),('Alpine Sage',[.16,.25,.12]),('Glacier Blue',[.045,.24,.36]),('Oxblood',[.30,.025,.035]),('Warm Chalk',[.72,.69,.59]),('Midnight',[.026,.042,.066])]
# Required photo sources are fetched deterministically, cached on subsequent clean runs.
IDS=['metal_plate','rubber_tiles','brown_leather']
for aid in IDS:
    for mp in ['diff','nor_gl','arm']:
        path=TEX/f'{aid}_{mp}_1k.jpg'
        if not path.exists():
            remote_map='albedo' if aid=='brown_leather' and mp=='diff' else mp
            url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{aid}/{aid}_{remote_map}_1k.jpg'
            print('DOWNLOAD',url,flush=True); urllib.request.urlretrieve(url,path)

def node(nt,typ): return nt.nodes.new(typ)
def bake_material(name,aid,low,high,rough,metal,normal_strength):
    """Bake a repeatable material tile in Cycles, re-used by every matching part.
    Photograph + subtle procedural dust, micro-abrasion -> base / normal / ORM.
    All exported maps are baked images, no procedural dependency remains.
    """
    m=bpy.data.materials.new(name+'_source'); m.use_nodes=True; nt=m.node_tree; nt.nodes.clear()
    out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled'); nt.links.new(bs.outputs['BSDF'],out.inputs['Surface'])
    uv=node(nt,'ShaderNodeTexCoord')
    ims={}
    for mp in ['diff','nor_gl','arm']:
        n=node(nt,'ShaderNodeTexImage'); n.image=bpy.data.images.load(str(TEX/f'{aid}_{mp}_1k.jpg'),check_existing=True)
        if mp!='diff': n.image.colorspace_settings.name='Non-Color'
        nt.links.new(uv.outputs['UV'],n.inputs['Vector']); ims[mp]=n
    bw=node(nt,'ShaderNodeRGBToBW'); nt.links.new(ims['diff'].outputs['Color'],bw.inputs[0])
    ramp=node(nt,'ShaderNodeValToRGB'); nt.links.new(bw.outputs[0],ramp.inputs[0]); ramp.color_ramp.elements[0].color=(*low,1); ramp.color_ramp.elements[1].color=(*high,1)
    noise=node(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=115; noise.inputs['Detail'].default_value=2
    nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
    dust=node(nt,'ShaderNodeMixRGB'); dust.blend_type='MULTIPLY'; dust.inputs[0].default_value=.055; nt.links.new(ramp.outputs[0],dust.inputs[1]); nt.links.new(noise.outputs['Fac'],dust.inputs[2])
    nt.links.new(dust.outputs[0],bs.inputs['Base Color'])
    nm=node(nt,'ShaderNodeNormalMap'); nm.inputs['Strength'].default_value=normal_strength; nt.links.new(ims['nor_gl'].outputs[0],nm.inputs['Color'])
    bump=node(nt,'ShaderNodeBump'); bump.inputs['Strength'].default_value=.11; bump.inputs['Distance'].default_value=.00008; nt.links.new(noise.outputs['Fac'],bump.inputs['Height']); nt.links.new(nm.outputs[0],bump.inputs['Normal']); nt.links.new(bump.outputs[0],bs.inputs['Normal'])
    sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(ims['arm'].outputs[0],sep.inputs[0])
    rg=node(nt,'ShaderNodeMapRange'); rg.inputs['To Min'].default_value=rough-.065; rg.inputs['To Max'].default_value=rough+.065; nt.links.new(sep.outputs['Green'],rg.inputs[0]); nt.links.new(rg.outputs[0],bs.inputs['Roughness']); bs.inputs['Metallic'].default_value=metal
    bpy.ops.mesh.primitive_plane_add(size=1); plane=bpy.context.object; plane.name='BakeTile'; plane.data.materials.append(m)
    S.cycles.samples=8; S.render.bake.margin=4
    target=node(nt,'ShaderNodeTexImage'); baked={}
    def do_bake(kind,res,source=None):
        im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
        if kind!='Base': im.colorspace_settings.name='Non-Color'
        target.image=im; nt.nodes.active=target
        if source is not None:
            em=node(nt,'ShaderNodeEmission'); nt.links.new(source,em.inputs[0]); nt.links.new(em.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='EMIT'); nt.links.new(bs.outputs[0],out.inputs[0]); nt.nodes.remove(em)
        else: bpy.ops.object.bake(type='NORMAL')
        im.filepath_raw=str(TEX/(name+'_'+kind+'.png')); im.file_format='PNG'; im.save(); baked[kind]=im
    do_bake('Base',512,dust.outputs[0]); do_bake('Normal',512)
    # Packed occlusion/roughness/metalness is actually baked through emission.
    pack=node(nt,'ShaderNodeCombineColor'); ao=node(nt,'ShaderNodeMapRange'); ao.inputs['To Min'].default_value=.86; ao.inputs['To Max'].default_value=1
    nt.links.new(sep.outputs['Red'],ao.inputs[0]); nt.links.new(ao.outputs[0],pack.inputs['Red']); nt.links.new(rg.outputs[0],pack.inputs['Green']); pack.inputs['Blue'].default_value=metal
    do_bake('ORM',256,pack.outputs[0])
    bpy.data.objects.remove(plane,do_unlink=True)
    final=bpy.data.materials.new(name); final.use_nodes=True; ft=final.node_tree; p=ft.nodes.get('Principled BSDF')
    ns={}
    for kind,im in baked.items():
        t=node(ft,'ShaderNodeTexImage'); t.image=im; t.label=kind; ns[kind]=t
    ft.links.new(ns['Base'].outputs[0],p.inputs['Base Color'])
    n=node(ft,'ShaderNodeNormalMap'); n.inputs['Strength'].default_value=1; ft.links.new(ns['Normal'].outputs[0],n.inputs['Color']); ft.links.new(n.outputs[0],p.inputs['Normal'])
    sp=node(ft,'ShaderNodeSeparateColor'); ft.links.new(ns['ORM'].outputs[0],sp.inputs[0]); ft.links.new(sp.outputs['Green'],p.inputs['Roughness']); ft.links.new(sp.outputs['Blue'],p.inputs['Metallic'])
    g=bpy.data.node_groups.get('glTF Material Output')
    if not g:
        g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=node(ft,'ShaderNodeGroup'); gn.node_tree=g; ft.links.new(sp.outputs['Red'],gn.inputs['Occlusion'])
    if name=='Paint': p.inputs['Coat Weight'].default_value=1; p.inputs['Coat Roughness'].default_value=.05
    bpy.data.materials.remove(m)
    print('BAKED',name,flush=True)
    return final

# Geometry is constructed first; material slots get their baked counterparts after clay proof.
MATS=[]
for name,col in [('Paint',(.8,.25,.02)),('Rubber',(.015,.017,.018)),('Alloy',(.5,.53,.55)),('Black',(.026,.03,.035)),('Leather',(.16,.055,.025))]:
    m=bpy.data.materials.new(name+'_clayphase'); m.diffuse_color=(*col,1); MATS.append(m)
P,R,A,B,L=range(5)
class Mesh:
    def __init__(self,name): self.name=name; self.v=[]; self.f=[]; self.mi=[]; self.sm=[]; self.uv=[]
    def add(self,vs,fs,mat,smooth=True,uv=None):
        off=len(self.v); self.v.extend([tuple(v) for v in vs]); self.f.extend([tuple(off+i for i in f) for f in fs]); self.mi.extend([mat]*len(fs)); self.sm.extend([smooth]*len(fs))
        if uv is None: uv=[(v[1]*3.7,v[2]*3.7+v[0]*3.7) for v in vs]
        self.uv.extend(uv)
    def object(self,origin=(0,0,0)):
        me=bpy.data.meshes.new(self.name); me.from_pydata(self.v,[],self.f); me.update()
        bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bm.to_mesh(me); bm.free()
        o=bpy.data.objects.new(self.name,me); bpy.context.collection.objects.link(o)
        for m in MATS: me.materials.append(m)
        for p,mi,sm in zip(me.polygons,self.mi,self.sm): p.material_index=mi; p.use_smooth=sm
        uv=me.uv_layers.new(name='UVMap')
        for p in me.polygons:
            for li in p.loop_indices: uv.data[li].uv=self.uv[me.loops[li].vertex_index]
        # Geometry already evaluated, scale and rotation identity; movable origins retained.
        for v in me.vertices: v.co-=Vector(origin)
        o.location=origin
        return o

def tube(g,pts,r,mat,n=12,caps=True):
    pts=[Vector(p) for p in pts]; vs=[]; uv=[]; fs=[]; dist=0
    if isinstance(r,(int,float)): r=[r]*len(pts)
    for j,p in enumerate(pts):
        t=(pts[min(j+1,len(pts)-1)]-pts[max(j-1,0)]).normalized()
        u=Vector((1,0,0));
        if abs(t.dot(u))>.97: u=Vector((0,1,0))
        u=(u-t*t.dot(u)).normalized(); v=t.cross(u)
        if j: dist+=(p-pts[j-1]).length
        rr=r[j]; rx,ry=rr if isinstance(rr,tuple) else (rr,rr)
        for i in range(n):
            a=2*pi*i/n; vs.append(p+u*cos(a)*rx+v*sin(a)*ry); uv.append((i/n,dist*6))
        if j:
            for i in range(n): fs.append(((j-1)*n+i,(j-1)*n+(i+1)%n,j*n+(i+1)%n,j*n+i))
    if caps: fs.extend([tuple(reversed(range(n))),tuple((len(pts)-1)*n+i for i in range(n))])
    g.add(vs,fs,mat,True,uv)
def rod(g,a,b,r,mat,n=12):
    a,b=Vector(a),Vector(b); d=b-a; e=min(.0012,d.length*.08)
    if r<=.004:
        tube(g,[a,b],r,mat,min(n,8)); return
    tube(g,[a,a+d.normalized()*e,b-d.normalized()*e,b],[r*.88,r,r,r*.88],mat,n)
def smoothpath(pts,steps=5):
    pts=[Vector(p) for p in pts]; out=[]
    for i in range(len(pts)-1):
        a,b,c,d=pts[max(0,i-1)],pts[i],pts[i+1],pts[min(i+2,len(pts)-1)]
        for j in range(steps):
            t=j/steps; out.append(.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t))
    return out+[pts[-1]]
def cable(g,pts,r=.002,mat=B,n=6): tube(g,smoothpath(pts,6),r,mat,n)
def ring(g,center,radius,minor,mat,n=64,k=8,width=None):
    c=Vector(center); vs=[]; fs=[]; uv=[]
    for i in range(n):
        a=2*pi*i/n
        for j in range(k):
            b=2*pi*j/k; rr=radius+minor*cos(b)
            vs.append(c+Vector(((width or minor)*sin(b),rr*cos(a),rr*sin(a)))); uv.append((i/n*5,j/k))
    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))
    g.add(vs,fs,mat,True,uv)
def annulus(g,c,ri,ro,th,mat,n=64,wave=0):
    c=Vector(c); vs=[]; fs=[]
    for i in range(n):
        a=2*pi*i/n; rr=ro+wave*cos(a*12)
        for x,r in [(-th/2,ri),(-th/2,rr),(th/2,rr),(th/2,ri)]: vs.append(c+Vector((x,r*cos(a),r*sin(a))))
    for i in range(n):
        for j in range(4): fs.append((i*4+j,((i+1)%n)*4+j,((i+1)%n)*4+(j+1)%4,i*4+(j+1)%4))
    g.add(vs,fs,mat,False)
def box(g,center,size,mat,bevel=.001,basis=None,tread=False):
    # Chamfered octagonal prism, plus bevelled top and bottom edges.
    c=Vector(center); sx,sy,sz=[s/2 for s in size]; b=min(bevel,sx*.3,sy*.3,sz*.3)
    outline=[(-sx+b,-sy),(sx-b,-sy),(sx,-sy+b),(sx,sy-b),(sx-b,sy),(-sx+b,sy),(-sx,sy-b),(-sx,-sy+b)]
    vs=[]; fs=[]
    levels=[(-sz,0),(sz-b,0),(sz,b)] if tread else [(-sz,b),(-sz+b,0),(sz-b,0),(sz,b)]
    for z,sh in levels:
        for x,y in outline:
            vv=Vector((x*(1-sh/sx),y*(1-sh/sy),z)); vs.append(c+(basis@vv if basis else vv))
    for j in range(len(levels)-1):
        for i in range(8): fs.append((j*8+i,j*8+(i+1)%8,(j+1)*8+(i+1)%8,(j+1)*8+i))
    fs.extend([tuple(reversed(range(8))),tuple((len(levels)-1)*8+i for i in range(8))]); g.add(vs,fs,mat,False)
def bolt(g,p,axis=(1,0,0),r=.005):
    p=Vector(p); ax=Vector(axis); rod(g,p-ax*.001,p+ax*.001,r,A,12); rod(g,p+ax*.001,p+ax*.0015,r*.42,B,6)

frame=Mesh('Frame'); fork=Mesh('Fork'); wf=Mesh('Wheel_F'); wr=Mesh('Wheel_R'); crank=Mesh('Crank'); pl=Mesh('Pedal_L'); pr=Mesh('Pedal_R'); kick=Mesh('Kickstand')
F=Vector((0,-.556,.369)); RE=Vector((0,.556,.369)); BB=Vector((0,.115,.342)); HT=Vector((0,-.297,1.015)); HB=Vector((0,-.342,.905)); ST=Vector((0,.227,.823))
# Main hydroformed front triangle, section variation and soft bends.
p=smoothpath([HB,(0,-.265,.814),(0,-.12,.56),(0,.025,.37),BB],5)
tube(frame,p,[(.026+.004*sin(pi*i/(len(p)-1)),.033+.003*sin(pi*i/(len(p)-1))) for i in range(len(p))],P,16)
p=smoothpath([HT,(0,-.20,.968),(0,.02,.855),(0,.15,.789),ST],5); tube(frame,p,[(.021,.024)]*len(p),P,14)
tube(frame,smoothpath([BB,(0,.13,.47),(0,.176,.645),ST],6),.018,P,14)
rod(frame,HB,HT,.031,P,20)
# Headset seals and sculpted gusset below head.
steer_axis=(HT-HB).normalized()
for c in [HB-steer_axis*.004,HT+steer_axis*.004]: rod(frame,c-steer_axis*.004,c+steer_axis*.004,.033,B,20)
# Seat mast brace forms a real triangular opening.
tube(frame,[(0,.11,.802),(0,.18,.828),(0,.222,.852)],.010,P,10)
# Rear triangle: two curved chainstays, seatstays, bridge and pivots.
for s in [-1,1]:
    tube(frame,smoothpath([(s*.023,.115,.343),(s*.061,.26,.33),(s*.064,.46,.348),(s*.065,.556,.369)],5),[.014]*16,P,12)
    tube(frame,smoothpath([(s*.028,.16,.576),(s*.06,.32,.498),(s*.066,.556,.369)],7),.013,P,12)
    rod(frame,(s*.025,.158,.573),(s*.04,.097,.551),.015,B,12)
    for y,z in [(.158,.573),(.115,.342),(.556,.369)]: bolt(frame,(s*.071 if y==.556 else s*.04,y,z),(s,0,0),.008)
rod(frame,(-.061,.315,.497),(.061,.315,.497),.010,P,12)
rod(frame,(-.042,.115,.342),(.042,.115,.342),.029,B,20)
# Rear air shock: upper eyelet, anodised air can, polished sliding shaft, reservoir.
shA=Vector((0,-.055,.826)); shB=Vector((0,.151,.582)); sd=(shB-shA).normalized()
rod(frame,shA,shA+sd*.22,.022,B,20); rod(frame,shA+sd*.014,shA+sd*.024,.025,A,20)
rod(frame,shA+sd*.215,shB,.009,A,16); rod(frame,shA+sd*.21,shA+sd*.224,.022,B,20)
rod(frame,shA+Vector((.023,.023,-.038)),shA+Vector((.023,.085,-.098)),.012,B,12)
for p in [shA,shB]:
    rod(frame,p+Vector((-.032,0,0)),p+Vector((.032,0,0)),.012,B,14)
    for s in [-1,1]: bolt(frame,p+Vector((s*.034,0,0)),(s,0,0),.006)
for s in [-1,1]: tube(frame,[(s*.024,-.087,.905),(s*.024,-.06,.844),(s*.024,-.055,.826)],.008,P,10)
# Dropper mast, collar and saddle rails.
SP=Vector((0,.307,1.091)); sd=(SP-ST).normalized()
rod(frame,ST,ST+sd*.098,.017,B,16); rod(frame,ST+sd*.088,ST+sd*.11,.021,B,16); rod(frame,ST+sd*.106,SP,.0125,B,16)
rod(frame,ST-sd*.003,ST+sd*.014,.021,B,16)
bolt(frame,(.024,.23,.831),(1,0,0),.005)
for x in [-.022,.022]: cable(frame,[(x,.22,1.101),(x,.25,1.073),(x,.337,1.074),(x,.365,1.119)],.003,A,8)
rod(frame,(-.034,.304,1.081),(.034,.304,1.081),.009,B,12)
# Saddle loft: long narrow nose, broad back, rounded leather crown.
vs=[]; fs=[]; sections=[(.159,.014,1.111),(.177,.023,1.115),(.232,.026,1.112),(.28,.051,1.108),(.335,.073,1.12),(.391,.064,1.127),(.411,.043,1.124)]
sections=[tuple(p) for p in smoothpath(sections,3)]
for y,w,z in sections:
    for i in range(16):
        a=2*pi*i/16; vs.append((w*cos(a),y,z+.010*sin(a)))
for j in range(len(sections)-1):
    for i in range(16): fs.append((j*16+i,j*16+(i+1)%16,(j+1)*16+(i+1)%16,(j+1)*16+i))
fs.extend([tuple(reversed(range(16))),tuple((len(sections)-1)*16+i for i in range(16))]); frame.add(vs,fs,L,True)
for s in [-1,1]: cable(frame,[(s*w*.97,y,z-.003) for y,w,z in sections],.0012,B,6)
# Fork assembly. The y/z steering axis passes through head tube.
for s in [-1,1]:
    axle=F+Vector((s*.048,0,0)); crown=HB+Vector((s*.048,-.008,-.027)); d=(crown-axle).normalized()
    rod(fork,axle,axle+d*.282,.023,B,18); rod(fork,axle+d*.274,axle+d*.302,.026,B,18)
    rod(fork,axle+d*.296,crown,.017,A,18); rod(fork,crown-d*.011,crown+d*.019,.026,B,18)
    bolt(fork,axle+Vector((s*.012,0,0)),(s,0,0),.009)
    bolt(fork,crown+d*.02,d,.012)
# Crown and curved tyre bridge.
cable(fork,[tuple(HB+Vector((-.064,-.008,-.027))),tuple(HB+Vector((0,-.005,-.01))),tuple(HB+Vector((.064,-.008,-.027)))],.025,B,12)
cable(fork,[(-.048,-.476,.602),(-.045,-.48,.745),(0,-.485,.755),(.045,-.48,.745),(.048,-.476,.602)],.012,B,10)
# Stem and 780mm low-rise handlebar.
steerTop=HT+steer_axis*.055; rod(fork,HT,steerTop,.014,B,16)
barC=Vector((0,-.376,1.117)); rod(fork,steerTop,barC,.022,B,14)
rod(fork,barC+Vector((-.025,0,0)),barC+Vector((.025,0,0)),.022,B,16)
barpts=[(-.379,-.349,1.134),(-.265,-.365,1.13),(-.16,-.379,1.108),(0,-.376,1.117),(.16,-.379,1.108),(.265,-.365,1.13),(.379,-.349,1.134)]
tube(fork,smoothpath(barpts,4),.0105,B,14)
for s in [-1,1]:
    gripA=Vector((s*.27,-.364,1.131)); gripB=Vector((s*.379,-.349,1.134)); rod(fork,gripA,gripB,.0155,R,16)
    for t in [.03,.96]:
        p=gripA.lerp(gripB,t); d=(gripB-gripA).normalized(); rod(fork,p-d*.003,p+d*.003,.0165,B,16)
    # Fine grip ribs.
    for j in range(14):
        p=gripA.lerp(gripB,.10+j*.056); d=(gripB-gripA).normalized(); rod(fork,p-d*.0006,p+d*.0006,.0158,R,12)
    box(fork,(s*.25,-.378,1.12),(.026,.034,.025),B,.003)
    cable(fork,[(s*.244,-.39,1.117),(s*.275,-.423,1.108),(s*.329,-.419,1.11)],.0045,B,8)
    rod(fork,(s*.245,-.375,1.101),(s*.225,-.387,1.077),.004,B,8)
    bolt(fork,(s*.248,-.386,1.137),(0,0,1),.004)
for x in [-.014,.014]:
    for z in [1.105,1.128]: bolt(fork,(x,-.396,z),(0,-1,0),.0038)
# Hydraulic hose loops: front follows fork, rear follows downtube.
cable(fork,[(-.248,-.399,1.119),(-.17,-.458,1.064),(.03,-.467,.987),(.065,-.427,.887),(.065,-.451,.689),(.063,-.527,.438)],.0022,B,7)
cable(frame,[(.245,-.396,1.117),(.17,-.441,1.042),(-.035,-.398,.96),(-.034,-.30,.851),(-.036,-.12,.545),(-.029,.09,.373),(-.067,.37,.362),(-.067,.52,.389)],.002,B,7)
# Downtube protective rubber strip and discreet cable ports.
tube(frame,[(0,-.203,.642),(0,-.11,.486),(0,.022,.348)],[(.021,.003)]*3,R,10)
for y,z in [(-.23,.762),(.025,.41)]: box(frame,(.028,y,z),(.004,.023,.011),B,.001)
# Wheels: 29-inch overall including real chamfered staggered tread.
from mathutils import Matrix
for g,c in [(wf,F),(wr,RE)]:
    ring(g,c,.332,.031,R,96,16,width=.030)
    ring(g,c,.309,.008,B,96,8,width=.018)
    # Sidewall moulding lips.
    for x in [-.024,.024]: ring(g,c+Vector((x,0,0)),.322,.0012,R,96,5)
    # 48 repeating tread pitches; alternating central + shoulder blocks.
    for j in range(64):
        for row in [-1,0,1]:
            theta=2*pi*(j+(0.48 if row==0 else 0))/64
            xx=row*.022; rad=.364 if row==0 else .352
            tangent=Vector((0,-sin(theta),cos(theta))); radial=Vector((0,cos(theta),sin(theta)))
            basis=Matrix(((1,0,0),tuple(tangent),tuple(radial))).transposed()
            center=c+Vector((xx,rad*cos(theta),rad*sin(theta)))
            box(g,center,(.015 if row else .021,.022 if row else .018,.010),R,.0014,basis,tread=True)
    # Hub and flanges with crossed double-sided spokes.
    rod(g,c+Vector((-.05,0,0)),c+Vector((.05,0,0)),.014,B,16)
    for s in [-1,1]:
        rod(g,c+Vector((s*.033-.003,0,0)),c+Vector((s*.033+.003,0,0)),.025,A,16)
        for i in range(16):
            an=2*pi*(i+(0.5 if s<0 else 0))/16; at=an+(.32 if i%2 else -.32)
            h=c+Vector((s*.034,.022*cos(an),.022*sin(an))); rim=c+Vector((s*.008,.307*cos(at),.307*sin(at)))
            tube(g,[h,rim],.00095,A,6)
            e=(h-rim).normalized(); rod(g,rim,rim+e*.01,.0017,B,6)
    # Valve stem.
    rod(g,c+Vector((0,0,.294)),c+Vector((0,0,.308)),.0025,B,8)
    # Ventilated rotor: narrow wave outer band, web arms and open slots.
    rc=c+Vector((.052,0,0)); annulus(g,rc,.088,.100,.0018,A,96,.0015)
    annulus(g,rc,.014,.025,.002,A,32)
    for j in range(6):
        an=2*pi*j/6; p=rc+Vector((0,.021*cos(an),.021*sin(an))); q=rc+Vector((0,.09*cos(an+.25),.09*sin(an+.25)))
        # Flat radial swept arm with rounded edges.
        tube(g,[p,q],[(.0014,.006)]*2,A,8)
        bolt(g,rc+Vector((.002,.019*cos(an),.019*sin(an))),(1,0,0),.0027)
    for j in range(30):
        an=2*pi*j/30; p=rc+Vector((0,.077*cos(an),.077*sin(an))); q=rc+Vector((0,.09*cos(an+.045),.09*sin(an+.045)))
        tube(g,[p,q],[(.001,.0025)]*2,A,6)
    # Inner ring completes elongated ventilation openings.
    annulus(g,rc,.074,.077,.0018,A,96)
# Brake calipers, physically mounted to respective rigid component.
for g,c in [(fork,F),(frame,RE)]:
    box(g,c+Vector((.054,-.064,.041)),(.032,.043,.029),B,.004)
    for z in [.027,.055]: bolt(g,c+Vector((.072,-.065,z)),(1,0,0),.004)
# Rear cassette: ten narrow stepped sprockets with actual teeth.
rod(wr,RE+Vector((-.084,0,0)),RE+Vector((-.029,0,0)),.014,A,16)
for j in range(10):
    rad=.093-j*.0068; xc=-.035-j*.0046; cc=RE+Vector((xc,0,0)); annulus(wr,cc,rad*.66,rad,.0016,A,48)
    for k in range(3):
        an=2*pi*k/3+j*.15; rod(wr,cc+Vector((0,.011*cos(an),.011*sin(an))),cc+Vector((0,rad*.72*cos(an),rad*.72*sin(an))),.003,A,6)
    for k in range(18+j//2):
        an=2*pi*k/(18+j//2); rod(wr,cc+Vector((0,(rad-.001)*cos(an),(rad-.001)*sin(an))),cc+Vector((0,(rad+.002)*cos(an),(rad+.002)*sin(an))),.0018,A,5)
# Chainring and hollow spider, cranks, pedal platforms.
cc=BB+Vector((-.079,0,0)); annulus(crank,cc,.066,.076,.003,B,64)
for i in range(32):
    an=2*pi*i/32; rod(crank,cc+Vector((0,.075*cos(an),.075*sin(an))),cc+Vector((0,.080*cos(an),.080*sin(an))),.0022,A,6)
for i in range(4):
    an=2*pi*i/4+.4; p=cc+Vector((0,.014*cos(an),.014*sin(an))); q=cc+Vector((0,.068*cos(an+.12),.068*sin(an+.12))); tube(crank,[p,q],[(.006,.010)]*2,B,10); bolt(crank,q+Vector((-.006,0,0)),(-1,0,0),.004)
rod(crank,BB+Vector((-.098,0,0)),BB+Vector((.098,0,0)),.015,A,16)
pedalpoints={}
for s,g in [(1,pl),(-1,pr)]:
    end=BB+Vector((s*.10,-s*.168,.014*s)); start=BB+Vector((s*.087,0,0))
    tube(crank,[start,start.lerp(end,.15),start.lerp(end,.86),end],[(.011,.016),(.01,.016),(.008,.012),(.009,.013)],B,12)
    bolt(crank,start+Vector((s*.016,0,0)),(s,0,0),.010)
    pc=end+Vector((s*.04,0,0)); pedalpoints[s]=pc
    rod(g,end,pc+Vector((s*.044,0,0)),.006,A,12)
    # Open platform perimeter with traction pins and two crossbars.
    for xx in [-.045,.045]: box(g,pc+Vector((xx,0,0)),(.012,.087,.014),B,.002)
    for yy in [-.04,.04]: box(g,pc+Vector((0,yy,0)),(.087,.012,.014),B,.002)
    for yy in [-.015,.015]: box(g,pc+Vector((0,yy,0)),(.078,.007,.009),B,.001)
    for xx in [-.04,0,.04]:
        for yy in [-.036,.036]: rod(g,pc+Vector((xx,yy,.006)),pc+Vector((xx,yy,.011)),.0018,A,6)
# Rear derailleur body, hanger, pulley cage and jockey wheels.
box(frame,(-.086,.572,.324),(.028,.04,.035),B,.003)
cable(frame,[(-.09,.568,.326),(-.105,.615,.273),(-.10,.615,.217),(-.098,.57,.205)],.008,B,10)
for y,z in [(.604,.275),(.574,.207)]:
    annulus(frame,(-.096,y,z),.006,.019,.007,B,20)
    bolt(frame,(-.104,y,z),(-1,0,0),.004)
# Continuous chain with distinct side plates and rollers (not an opaque belt).
chainpts=[Vector((-.08,.115+.077*cos(2*pi*i/24),.342+.077*sin(2*pi*i/24))) for i in []]
# Closed outline follows chainring, selected cassette and tensioner.
path=[(-.082,.115,.421),(-.0534,.55,.435),(-.0534,.622,.39),(-.087,.626,.277),(-.096,.592,.19),(-.093,.561,.19),(-.084,.101,.265),(-.082,.04,.30),(-.082,.037,.365),(-.082,.07,.41),(-.082,.115,.421)]
cp=smoothpath(path,8)
lengths=[0]
for a,b in zip(cp,cp[1:]): lengths.append(lengths[-1]+(b-a).length)
N=int(lengths[-1]/.011)
for i in range(N):
    t=i*lengths[-1]/N; j=next(j for j in range(len(lengths)-1) if lengths[j]<=t<=lengths[j+1]); p=cp[j].lerp(cp[j+1],(t-lengths[j])/(lengths[j+1]-lengths[j])); d=(cp[j+1]-cp[j]).normalized()
    for s in [-1,1]: tube(frame,[p-d*.0046+Vector((s*.003,0,0)),p+d*.0046+Vector((s*.003,0,0))],[(.0009,.0024)]*2,A,6)
    rod(frame,p+Vector((-.004,0,0)),p+Vector((.004,0,0)),.0015,B,6)
# Compact chainstay-mounted kickstand, folded rearwards clear of the tyre.
KP=Vector((.082,.305,.351)); box(frame,KP,(.028,.045,.031),B,.003); bolt(frame,KP+Vector((.018,0,0)),(1,0,0),.006)
tube(kick,[KP,KP+Vector((.008,.055,-.025)),KP+Vector((.011,.23,-.01))],.006,B,10); box(kick,KP+Vector((.011,.238,-.01)),(.024,.025,.013),R,.003)

origins={'Frame':(0,0,0),'Fork':tuple(HT),'Wheel_F':tuple(F),'Wheel_R':tuple(RE),'Crank':tuple(BB),'Pedal_L':tuple(pedalpoints[1]),'Pedal_R':tuple(pedalpoints[-1]),'Kickstand':tuple(KP)}
objects={g.name:g.object(origins[g.name]) for g in [frame,fork,wf,wr,crank,pl,pr,kick]}
def parent_keep(o,p):
    bpy.context.view_layer.update(); w=o.matrix_world.copy(); o.parent=p; o.matrix_world=w
parent_keep(objects['Wheel_F'],objects['Fork'])
for name in ['Pedal_L','Pedal_R']: parent_keep(objects[name],objects['Crank'])
parent_keep(objects['Kickstand'],objects['Frame'])
objects['Fork']['steering_axis_local']=[0,float(steer_axis.y),float(steer_axis.z)]
objects['Fork']['steering_axis_gltf']=[0,float(steer_axis.z),float(-steer_axis.y)]
for name in ['Wheel_F','Wheel_R','Crank','Pedal_L','Pedal_R']: objects[name]['rotation_axis']=[1,0,0]
sockets={'Seat_Rider':((0,.307,1.145),'Frame'),'Hand_L':((.325,-.356,1.133),'Fork'),'Hand_R':((-.325,-.356,1.133),'Fork'),'Foot_L':(tuple(pedalpoints[1]+Vector((0,0,.012))),'Pedal_L'),'Foot_R':(tuple(pedalpoints[-1]+Vector((0,0,.012))),'Pedal_R'),'Light_Head':((0,-.405,1.073),'Fork'),'Light_Tail':((0,.414,1.115),'Frame')}
for name,(p,par) in sockets.items():
    o=bpy.data.objects.new(name,None); bpy.context.collection.objects.link(o); o.location=p; parent_keep(o,objects[par]); objects[name]=o
# Fit manufacturing envelope exactly, keeping all pivot/socket coordinates coherent.
bpy.context.view_layer.update()
all_world={n:[o.matrix_world@v.co for v in o.data.vertices] for n,o in objects.items() if o.type=='MESH'}
all_pts=[p for ps in all_world.values() for p in ps]
low=Vector(tuple(min(p[i] for p in all_pts) for i in range(3))); high=Vector(tuple(max(p[i] for p in all_pts) for i in range(3)))
fit_scale=Vector((.78/(high.x-low.x),1.85/(high.y-low.y),1.15/(high.z-low.z)))
def fitted(p): return Vector((p.x*fit_scale.x,p.y*fit_scale.y,(p.z-low.z)*fit_scale.z))
oldorig={n:o.matrix_world.translation.copy() for n,o in objects.items()}; parents={n:o.parent.name if o.parent else None for n,o in objects.items()}
for o in objects.values(): o.parent=None
for n,o in objects.items():
    origin=Vector((0,0,0)) if n=='Frame' else fitted(oldorig[n]); o.location=origin
    if o.type=='MESH':
        for v,p in zip(o.data.vertices,all_world[n]): v.co=fitted(p)-origin
for n,p in parents.items():
    if p: parent_keep(objects[n],objects[p])
steer_axis=Vector((0,steer_axis.y*fit_scale.y,steer_axis.z*fit_scale.z)).normalized()
objects['Fork']['steering_axis_local']=list(steer_axis); objects['Fork']['steering_axis_gltf']=[steer_axis.x,steer_axis.z,-steer_axis.y]
print('RAW_TRIANGLES',sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objects.values() if o.type=='MESH'),flush=True)
# Stage is excluded from selection export.
world=bpy.data.worlds.new('Soft daylight'); S.world=world; world.use_nodes=True; world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.82,1); world.node_tree.nodes['Background'].inputs[1].default_value=.45
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.003)); floor=bpy.context.object; floor.name='StudioFloor'
fm=bpy.data.materials.new('Studio'); fm.use_nodes=True; fp=fm.node_tree.nodes.get('Principled BSDF'); fp.inputs['Base Color'].default_value=(.24,.27,.29,1); fp.inputs['Roughness'].default_value=.82; floor.data.materials.append(fm)
def area(name,p,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); bpy.context.collection.objects.link(o); o.location=p; o.rotation_euler=(Vector((0,0,.55))-o.location).to_track_quat('-Z','Y').to_euler()
area('Daylight key',(3,-4,5),500,4,(1,.93,.85)); area('Sky fill',(-3,-1,3),350,3,(.72,.84,1)); area('Rim',(1,3,4),600,3,(1,1,1))
camd=bpy.data.cameras.new('Camera'); cam=bpy.data.objects.new('Camera',camd); bpy.context.collection.objects.link(cam); S.camera=cam
# Camera distance fits all wheel/handlebar bounds with intentional presentation margin.
def camera(side=False):
    target=Vector((0,0,.57)); cam.location=target+Vector((3.5,-2.45,1.2) if not side else (4,0,0)); cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler(); camd.type='ORTHO'; camd.ortho_scale=2.52 if not side else 2.24; camd.lens=55
camera(); S.render.resolution_x=1920; S.render.resolution_y=1080; S.render.resolution_percentage=100
clay=bpy.data.materials.new('QA Grey'); clay.use_nodes=True; cb=clay.node_tree.nodes.get('Principled BSDF'); cb.inputs['Base Color'].default_value=(.38,.38,.38,1); cb.inputs['Roughness'].default_value=.48
S.view_layers[0].material_override=clay; S.cycles.samples=24; S.render.filepath=str(ROOT/'clay.png'); bpy.ops.render.render(write_still=True); S.view_layers[0].material_override=None
print('CLAY_COMPLETE',flush=True)
# Five photographic baked finishes. Neutral paint stays recolourable.
MATS=[bake_material('Paint','metal_plate',(.72,.72,.72),(.96,.96,.96),.28,.25,.12),bake_material('Rubber','rubber_tiles',(.008,.009,.010),(.035,.039,.041),.78,0,.28),bake_material('Alloy','metal_plate',(.36,.39,.41),(.64,.67,.69),.28,.96,.19),bake_material('Black','metal_plate',(.014,.018,.023),(.035,.042,.049),.36,.58,.13),bake_material('Leather','brown_leather',(.058,.018,.008),(.22,.082,.034),.52,0,.45)]
for o in objects.values():
    if o.type=='MESH':
        indices=[p.material_index for p in o.data.polygons]
        o.data.materials.clear()
        for m in MATS: o.data.materials.append(m)
        for p,idx in zip(o.data.polygons,indices): p.material_index=idx
# Explicit triangulation yields the exact delivered budget.
for o in objects.values():
    if o.type=='MESH':
        bm=bmesh.new(); bm.from_mesh(o.data); bmesh.ops.triangulate(bm,faces=bm.faces); bm.to_mesh(o.data); bm.free()
raw_tri=sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH')
if raw_tri>49000:
    for o in objects.values():
        if o.type!='MESH': continue
        bpy.context.view_layer.objects.active=o
        mod=o.modifiers.new('Budget refinement','DECIMATE'); mod.ratio=48000/raw_tri; mod.use_collapse_triangulate=True
        bpy.ops.object.modifier_apply(modifier=mod.name)
print('BUDGET_TRIANGLES',sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH'),flush=True)
bpy.ops.object.select_all(action='DESELECT')
for o in objects.values(): o.select_set(True)
bpy.context.view_layer.objects.active=objects['Frame']
bpy.ops.export_scene.gltf(filepath=str(ROOT/'mountain-bike.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_extras=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO')
# glTF's tint is deliberately separate from its neutral baked image. Set precisely.
glb=ROOT/'mountain-bike.glb'; raw=glb.read_bytes(); jlen=struct.unpack_from('<I',raw,12)[0]; jd=json.loads(raw[20:20+jlen]); rest=raw[20+jlen:]
for m in jd['materials']:
    if m['name']=='Paint':
        m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.0]
        m.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1.0,'clearcoatRoughnessFactor':.05}
if 'KHR_materials_clearcoat' not in jd.setdefault('extensionsUsed',[]): jd['extensionsUsed'].append('KHR_materials_clearcoat')
js=json.dumps(jd,separators=(',',':')).encode(); js+=b' '*((-len(js))%4); glb.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(js)+len(rest))+struct.pack('<I4s',len(js),b'JSON')+js+rest)
def game(p): return [round(p[0],6),round(p[2],6),round(-p[1],6)]
bpy.context.view_layer.update(); pts=[o.matrix_world@v.co for o in objects.values() if o.type=='MESH' for v in o.data.vertices]; gp=[game(p) for p in pts]
lo=[min(p[i] for p in gp) for i in range(3)]; hi=[max(p[i] for p in gp) for i in range(3)]
tri=sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH')
meta={'coordinate_system':'+X rider left; +Y up; +Z front; metres','bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'triangles':tri,'glb_bytes':glb.stat().st_size,'materials':[m.name for m in MATS],'textures':{'base_color':[512,512],'normal':[512,512],'ORM':[256,256]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'nodes':{n:{'world_position':game(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for n,o in objects.items()},'steering':{'node':'Fork','axis':[0,float(steer_axis.z),float(-steer_axis.y)]},'bake':{'engine':'Cycles','device':'OPTIX','samples':8,'method':'Repeatable photo plus procedural material tiles; base EMIT, tangent NORMAL, packed ORM EMIT'}}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
(ROOT/'NOTES.md').write_text(f'Modern full-suspension 29-inch trail bike, articulated named nodes, metre-scale Y-up GLB; orange recolourable frame.\nTriangles: {tri:,}; GLB: {glb.stat().st_size/1048576:.2f} MiB.\nMaterials: Paint, Rubber, Alloy, Black, Leather; Paint clearcoat 1.0 / 0.05, six linear-RGB variants.\nTextures: five shared Cycles-baked sets, base/normal 512px and ORM 256px; OPTIX, 8 samples.\nPoly Haven CC0 ids: metal_plate, rubber_tiles, brown_leather (https://polyhaven.com/license; https://polyhaven.com/a/<id>).\nDifferences: orange frame instead of reference chrome; original simplified linkage, unbranded components, compact retracted kickstand added.\n')
print('EXPORT_COMPLETE',tri,glb.stat().st_size,meta['bounding_box'],flush=True)
# Display tint uses the exact same linear factors as the delivered glTF.
paint=MATS[0]; nt=paint.node_tree; bs=nt.nodes.get('Principled BSDF'); base=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.label=='Base')
mix=node(nt,'ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=1; nt.links.new(base.outputs[0],mix.inputs[1]); nt.links.new(mix.outputs[0],bs.inputs['Base Color']); mix.inputs[2].default_value=PAINTS[0][1]+[1]
S.view_layers[0].material_override=clay; S.cycles.samples=24; camera(); S.render.filepath=str(ROOT/'clay.png'); bpy.ops.render.render(write_still=True); S.view_layers[0].material_override=None
S.cycles.samples=64; camera(); S.render.filepath=str(ROOT/'final.png'); bpy.ops.render.render(write_still=True)
camera(True); S.render.filepath=str(ROOT/'side.png'); bpy.ops.render.render(write_still=True)
# Six identical cameras and lighting: assembled without external image dependencies.
camera(); S.render.resolution_x=640; S.render.resolution_y=420; camd.ortho_scale=2.35; S.cycles.samples=16
canvas=np.ones((840,1920,4),dtype=np.float32)
for i,(name,col) in enumerate(PAINTS):
    mix.inputs[2].default_value=col+[1]; path=ROOT/f'paint_{i}.png'; S.render.filepath=str(path); bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(str(path),check_existing=False); arr=np.array(im.pixels[:],dtype=np.float32).reshape(420,640,4); row=1-i//3; column=i%3; canvas[row*420:(row+1)*420,column*640:(column+1)*640]=arr; bpy.data.images.remove(im); path.unlink()
image=bpy.data.images.new('Six paint variants',width=1920,height=840); image.pixels.foreach_set(canvas.ravel()); image.filepath_raw=str(ROOT/'paints.png'); image.file_format='PNG'; image.save()
mix.inputs[2].default_value=PAINTS[0][1]+[1]
print('BUILD_COMPLETE',flush=True)
