"""Road racing bicycle. Run with Blender 5.2 --background --python-exit-code 1 --python this_file.
Coordinates authored X rider-left, -Y forward, Z up; glTF converts to X left/Y up/Z forward.
All inputs are Poly Haven CC0; all output material maps are Cycles-baked reusable tiles.
"""
import bpy, math, os, json, urllib.request, subprocess, time
import numpy as np
from mathutils import Vector
from math import sin,cos,pi
ROOT=os.path.dirname(os.path.abspath(__file__))
os.chdir(ROOT)
START=time.time()
import hashlib
print('SOURCE_SHA256',hashlib.sha256(open(__file__,'rb').read()).hexdigest(),flush=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene;S.unit_settings.system='METRIC';S.render.engine='CYCLES'
S.cycles.samples=24;S.cycles.use_denoising=True
pref=bpy.context.preferences.addons['cycles'].preferences
pref.compute_device_type='OPTIX';pref.get_devices()
for d in pref.devices:d.use=d.type=='OPTIX'
S.cycles.device='GPU';S.render.image_settings.file_format='PNG'
S.view_settings.view_transform='AgX'
os.makedirs('textures',exist_ok=True)
PAINTS=[('Atlantic blue',[.03,.12,.45]),('Carbon black',[.012,.016,.022]),('Pearl white',[.72,.76,.80]),('Racing red',[.48,.018,.028]),('British green',[.015,.12,.065]),('Champagne titanium',[.42,.32,.20])]
GROUPS={k:[] for k in ['Frame','Fork','Wheel_F','Wheel_R','Crank','Pedal_L','Pedal_R','Kickstand']}
MAT={}
def xyz(p):return Vector((p[0],-p[1],p[2]))
def semantic(v):return [v.x,v.z,-v.y]
def select(obs):
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0]
def reg(o,name,mat,group):
 o.name=name;o.data.materials.append(MAT[mat]);GROUPS[group].append(o)
 for p in o.data.polygons:p.use_smooth=True
 return o
def mesh(name,verts,faces,mat,group,uv=None):
 me=bpy.data.meshes.new(name);me.from_pydata([xyz(v) for v in verts],[],faces);me.update()
 o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o)
 if uv:
  lay=me.uv_layers.new(name='UVMap')
  for poly in me.polygons:
   for li in poly.loop_indices:lay.data[li].uv=uv[me.loops[li].vertex_index]
 return reg(o,name,mat,group)
def tube(name,pts,r,mat,group,n=12,rx=None):
 pts=[Vector(p) for p in pts];verts=[];uv=[];length=0
 for j,p in enumerate(pts):
  if j:length+=(p-pts[j-1]).length
  t=(pts[min(j+1,len(pts)-1)]-pts[max(j-1,0)]).normalized()
  u=Vector((1,0,0));u=(u-t*u.dot(t)).normalized() if abs(t.x)<.98 else Vector((0,1,0))
  v=t.cross(u).normalized(); rr=r[j] if isinstance(r,list) else r
  xx=(rx[j] if isinstance(rx,list) else rx) if rx is not None else rr
  for k in range(n):
   a=2*pi*k/n;verts.append(tuple(p+u*xx*cos(a)+v*rr*sin(a)));uv.append((k/n,length*7))
 faces=[]
 for j in range(len(pts)-1):
  for k in range(n):a=j*n+k;b=j*n+(k+1)%n;faces.append((a,b,b+n,a+n))
 faces+=[tuple(range(n-1,-1,-1)),tuple((len(pts)-1)*n+k for k in range(n))]
 return mesh(name,verts,faces,mat,group,uv)
def smoothpath(points,steps=5):
 ps=[Vector(p) for p in points];out=[]
 for i in range(len(ps)-1):
  a=ps[max(0,i-1)];b=ps[i];c=ps[i+1];d=ps[min(len(ps)-1,i+2)]
  for j in range(steps):
   t=j/steps;out.append(tuple(.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t)))
 out.append(tuple(ps[-1]));return out
def cable(name,pts,r,mat='Rubber',group='Frame',n=6):return tube(name,smoothpath(pts),r,mat,group,n)
def cyl(name,a,b,r,mat,group,n=16):return tube(name,[a,b],r,mat,group,n)
def ellipsoid(name,p,scale,mat,group,seg=20,rings=10):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=xyz(p));o=bpy.context.object;o.scale=scale
 # Blender axes x,-forward,up; scales passed in those axes
 select([o]);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 return reg(o,name,mat,group)
def box(name,p,size,mat,group,bev=.002):
 bpy.ops.mesh.primitive_cube_add(size=1,location=xyz(p));o=bpy.context.object;o.scale=size;select([o]);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 mod=o.modifiers.new('Machined edge radius','BEVEL');mod.width=min(bev,min(size)*.4);mod.segments=2;bpy.ops.object.modifier_apply(modifier=mod.name)
 return reg(o,name,mat,group)
def ring(name,p,profile,mat,group,N=128):
 verts=[];uv=[];M=len(profile)
 for i in range(N):
  a=2*pi*i/N
  for j,(x,r) in enumerate(profile):verts.append((p[0]+x,p[1]+r*sin(a),p[2]+r*cos(a)));uv.append((i/N*6,j/M))
 faces=[]
 for i in range(N):
  for j in range(M):faces.append((i*M+j,((i+1)%N)*M+j,((i+1)%N)*M+(j+1)%M,i*M+(j+1)%M))
 return mesh(name,verts,faces,mat,group,uv)
def torus(name,p,major,minor,mat,group,N=96,M=8):
 return ring(name,p,[(minor*sin(2*pi*j/M),major+minor*cos(2*pi*j/M)) for j in range(M)],mat,group,N)
def cog(name,x,f,h,r,teeth,mat,group,inner=None):
 inner=inner or r*.70;N=teeth*4;verts=[]
 for xx,rrtype in [(x-.0012,0),(x+.0012,0),(x+.0012,1),(x-.0012,1)]:
  for i in range(N):
   a=2*pi*i/N;rr=(r if i%4 in (1,2) else r-.0026) if rrtype==0 else inner
   verts.append((xx,f+sin(a)*rr,h+cos(a)*rr))
 faces=[]
 for j in range(4):
  for i in range(N):faces.append((j*N+i,j*N+(i+1)%N,((j+1)%4)*N+(i+1)%N,((j+1)%4)*N+i))
 o=mesh(name,verts,faces,mat,group)
 select([o]);dec=o.modifiers.new('Sprocket topology economy','DECIMATE');dec.ratio=.40;bpy.ops.object.modifier_apply(modifier=dec.name)
 return o
# Six portable materials, with actual Cycles bakes of photographic and procedural microstructure.
def download(asset,map):
 path=f'textures/{asset}_{map}_1k.jpg'
 if not os.path.isfile(path):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{map}_1k.jpg'
  subprocess.run(['curl','--fail','-L','-A','Mozilla/5.0','--retry','2','-sS',url,'-o',path],check=True)
 return bpy.data.images.load(os.path.abspath(path),check_existing=True)
S.cycles.samples=4
for name,asset,color,rough,metal,bump in [
 ('Paint','metal_plate',[.9,.9,.9],.29,.15,.000018),
 ('Carbon','metal_plate',[.018,.022,.026],.37,.12,.00004),
 ('Rubber','fabric_leather_02',[.011,.012,.013],.69,0,.00009),
 ('TapeLeather','fabric_leather_02',[.012,.014,.016],.76,0,.00013),
 ('Metal','metal_plate',[.38,.42,.46],.28,.95,.000018),
 ('DarkMetal','metal_plate',[.04,.047,.055],.30,.82,.000024)]:
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
 out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs['BSDF'],out.inputs['Surface'])
 tex=N.new('ShaderNodeTexImage');tex.image=download(asset,'diff')
 normtex=N.new('ShaderNodeTexImage');normtex.image=download(asset,'nor_gl');normtex.image.colorspace_settings.name='Non-Color'
 arm=N.new('ShaderNodeTexImage');arm.image=download(asset,'arm');arm.image.colorspace_settings.name='Non-Color'
 uv=N.new('ShaderNodeTexCoord');noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=90;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
 bw=N.new('ShaderNodeRGBToBW');L.new(tex.outputs['Color'],bw.inputs[0]);ramp=N.new('ShaderNodeValToRGB');L.new(bw.outputs[0],ramp.inputs[0])
 ramp.color_ramp.elements[0].color=(*[v*.75 for v in color],1);ramp.color_ramp.elements[1].color=(*color,1)
 col=ramp.outputs[0]
 if name in ['Carbon','Paint']:
  checker=N.new('ShaderNodeTexChecker');checker.inputs['Scale'].default_value=80;L.new(uv.outputs['UV'],checker.inputs['Vector'])
  mix=N.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.28 if name=='Carbon' else .035;L.new(col,mix.inputs[1]);L.new(checker.outputs['Color'],mix.inputs[2]);col=mix.outputs[0]
 L.new(col,bs.inputs['Base Color']);bs.inputs['Metallic'].default_value=metal
 sep=N.new('ShaderNodeSeparateColor');L.new(arm.outputs['Color'],sep.inputs[0])
 mr=N.new('ShaderNodeMapRange');L.new(sep.outputs['Green'],mr.inputs['Value']);mr.inputs['To Min'].default_value=rough-.055;mr.inputs['To Max'].default_value=rough+.055;L.new(mr.outputs[0],bs.inputs['Roughness'])
 nm=N.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.32 if name in ['TapeLeather','Rubber'] else .12;L.new(normtex.outputs[0],nm.inputs['Color'])
 bu=N.new('ShaderNodeBump');bu.inputs['Distance'].default_value=bump;bu.inputs['Strength'].default_value=.28;L.new(noise.outputs['Fac'],bu.inputs['Height']);L.new(nm.outputs[0],bu.inputs['Normal']);L.new(bu.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(m)
 emit=N.new('ShaderNodeEmission');maps={}
 for kind,res in [('Base',1024),('Normal',1024),('ORM',512)]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='Base' else 'Non-Color'
  target=N.new('ShaderNodeTexImage');target.image=im;N.active=target
  if kind=='Normal':L.new(bs.outputs[0],out.inputs['Surface']);btype='NORMAL'
  else:
   L.new(emit.outputs[0],out.inputs['Surface']);btype='EMIT'
   if kind=='Base':L.new(col,emit.inputs[0])
   else:
    comb=N.new('ShaderNodeCombineColor');comb.inputs['Red'].default_value=1;L.new(mr.outputs[0],comb.inputs['Green']);comb.inputs['Blue'].default_value=metal;L.new(comb.outputs[0],emit.inputs[0])
  select([plane]);bpy.ops.object.bake(type=btype,margin=4)
  im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+kind+'.png');im.file_format='PNG';im.save();maps[kind]=im
 bpy.data.objects.remove(plane,do_unlink=True);N.clear();out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0])
 base=N.new('ShaderNodeTexImage');base.image=maps['Base']
 if name=='Paint':
  mult=N.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;L.new(base.outputs[0],mult.inputs[1]);mult.inputs[2].default_value=(*PAINTS[0][1],1);L.new(mult.outputs[0],bs.inputs['Base Color']);mult.name='PaintFactor'
  bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 else:L.new(base.outputs[0],bs.inputs['Base Color'])
 otex=N.new('ShaderNodeTexImage');otex.image=maps['ORM'];sep=N.new('ShaderNodeSeparateColor');L.new(otex.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 normal=N.new('ShaderNodeTexImage');normal.image=maps['Normal'];nm=N.new('ShaderNodeNormalMap');L.new(normal.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
 # glTF recognized occlusion socket, sharing the same packed ORM image.
 grp=bpy.data.node_groups.get('glTF Material Output')
 if not grp:
  grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=N.new('ShaderNodeGroup');gn.node_tree=grp;L.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 MAT[name]=m;print('BAKED',name,flush=True)
S.cycles.samples=32
# Main carbon monocoque frame.
BB=(0,-.105,.275);SEAT=(0,-.245,.727);HT=(0,.312,.793);HB=(0,.365,.654);REAR=(0,-.515,.335);FRONT=(0,.515,.335)
tube('Sculpted down tube',[BB,(0,-.055,.312),(0,.20,.53),HB],[.027,.031,.031,.029],'Paint','Frame',20,rx=[.025,.028,.024,.024])
tube('Sloping top tube',[SEAT,(0,-.17,.746),(0,.20,.784),HT],[.020,.016,.016,.025],'Paint','Frame',20,rx=[.020,.021,.023,.024])
tube('Aero seat tube',[BB,(0,-.13,.38),(0,-.218,.65),SEAT],[.020,.019,.018,.021],'Paint','Frame',20,rx=[.021,.019,.017,.019])
tube('Head tube',[HB,(0,.345,.72),HT],[.032,.031,.027],'Paint','Frame',24)
cyl('Bottom bracket shell',(-.042,-.105,.275),(.042,-.105,.275),.029,'Paint','Frame',24)
for side in [-1,1]:
 tube('Chainstay',[(side*.019,-.105,.275),(side*.048,-.26,.289),(side*.065,-.49,.328),(side*.06,-.515,.335)],[.016,.014,.010,.009],'Paint','Frame',12,rx=[.012,.011,.009,.009])
 tube('Seatstay',[(side*.013,-.25,.705),(side*.035,-.335,.58),(side*.059,-.515,.335)],[.010,.008,.007],'Paint','Frame',12)
 ellipsoid('Rear dropout',(side*.06,-.515,.335),(.012,.016,.019),'DarkMetal','Frame',16,8)
# Unite painted frame pieces into a smooth monocoque, preserving the frame openings.
paintparts=[o for o in GROUPS['Frame'] if o.data.materials[0]==MAT['Paint']]
nonpaint=[o for o in GROUPS['Frame'] if o not in paintparts]
select(paintparts);bpy.ops.object.join();frame=bpy.context.object
rem=frame.modifiers.new('Carbon mould junctions','REMESH');rem.mode='VOXEL';rem.voxel_size=.0032;bpy.ops.object.modifier_apply(modifier=rem.name)
sm=frame.modifiers.new('Mould fillets','SMOOTH');sm.factor=.65;sm.iterations=4;bpy.ops.object.modifier_apply(modifier=sm.name)
de=frame.modifiers.new('Game surface budget','DECIMATE');de.ratio=.04;bpy.ops.object.modifier_apply(modifier=de.name)
sm=frame.modifiers.new('Polish mould surface','SMOOTH');sm.factor=.35;sm.iterations=2;bpy.ops.object.modifier_apply(modifier=sm.name)
GROUPS['Frame']=nonpaint+[frame]
for p in frame.data.polygons:p.use_smooth=True
# Wheels: 622 mm bead seat, 24 mm slicks, 46 mm carbon rim section.
for gr,f in [('Wheel_F',.515),('Wheel_R',-.515)]:
 center=(0,f,.335)
 torus('Slick 700c tyre',center,.323,.012,'Rubber',gr,112,10)
 rimprof=[(-.010,.312),(-.012,.306),(-.0115,.278),(-.007,.266),(.007,.266),(.0115,.278),(.012,.306),(.010,.312)]
 ring('Deep aero carbon rim',center,rimprof,'Carbon',gr,128)
 for side in [-1,1]:
  ring('Brake track',(0,f,.335),[(side*.0122,.303),(side*.0122,.310),(side*.0127,.310),(side*.0127,.303)],'DarkMetal',gr,96)
  torus('Tyre mould bead',(side*.0108,f,.335),.319,.00045,'Rubber',gr,64,4)
 cyl('Sealed hub barrel',(-.04,f,.335),(.04,f,.335),.014,'DarkMetal',gr,24)
 for side in [-1,1]:
  cyl('Spoke flange',(side*.027,f,.335),(side*.030,f,.335),.024,'DarkMetal',gr,24)
  cyl('Axle cap',(side*.039,f,.335),(side*.053,f,.335),.009,'Metal',gr,16)
 for i in range(28):
  a=2*pi*i/28;side=1 if i%2 else -1
  # modest crossed lacing at rear, radial at front
  hubangle=a+(.31*side if gr=='Wheel_R' else 0)
  a0=(side*.029,f+.020*sin(hubangle),.335+.020*cos(hubangle));a1=(side*.007,f+.269*sin(a),.335+.269*cos(a))
  cyl('Stainless tension spoke',a0,a1,.00078,'DarkMetal',gr,6)
  cyl('Spoke nipple',a1,(side*.007,f+.278*sin(a),.335+.278*cos(a)),.0015,'Metal',gr,6)
 cyl('Presta valve',(0,f+.263,.335),(0,f+.29,.335),.002,'Metal',gr,8)
 cyl('Valve cap',(0,f+.256,.335),(0,f+.264,.335),.0026,'DarkMetal',gr,8)
# Fork crown and tapered curved blades.
cyl('Lower headset bearing',(0,.361,.659),(0,.372,.633),.034,'DarkMetal','Fork',24)
for side in [-1,1]:
 tube('Aero fork blade',smoothpath([(side*.024,.366,.643),(side*.036,.407,.535),(side*.048,.466,.397),(side*.049,.515,.335)],4),[.026-i*.0012 for i in range(13)],'Paint','Fork',14,rx=[.014-i*.00035 for i in range(13)])
 ellipsoid('Fork axle lug',(side*.049,.515,.335),(.009,.014,.015),'DarkMetal','Fork',16,8)
 cyl('Front skewer',(side*.053,.515,.335),(side*.062,.515,.335),.006,'Metal','Fork',12)
cable('Quick release lever',[(.063,.515,.335),(.064,.49,.33),(.065,.482,.348)],.003,'DarkMetal','Fork')
# Seatpost, clamp, saddle rails and contoured shell.
cyl('Exposed aero seatpost',(0,-.245,.727),(0,-.318,.958),.014,'Carbon','Frame',20)
cyl('Seatpost collar',(0,-.247,.735),(0,-.251,.748),.021,'DarkMetal','Frame',24)
cyl('Seat binder bolt',(-.026,-.252,.743),(.026,-.252,.743),.004,'Metal','Frame',12)
for side in [-1,1]:
 cable('Saddle titanium rail',[(side*.024,-.39,.973),(side*.024,-.345,.946),(side*.019,-.276,.944),(side*.019,-.20,.973)],.003,'Metal','Frame',8)
box('Seat rail clamp',(0,-.314,.95),(.055,.035,.016),'DarkMetal','Frame',.004)
# Saddle loft: broad tail, slender nose, rolled upholstered edges.
sections=[(-.443,.009,.984,.006),(-.430,.050,.985,.009),(-.40,.072,.988,.012),(-.355,.075,.986,.013),(-.312,.061,.982,.012),(-.27,.034,.979,.010),(-.205,.020,.979,.009),(-.19,.012,.979,.006)]
verts=[];uv=[];faces=[]
for j,(f,w,h,th) in enumerate(sections):
 for k in range(16):
  a=2*pi*k/16;verts.append((w*cos(a),f,h+th*sin(a)));uv.append((k/16,j/8))
for j in range(len(sections)-1):
 for k in range(16):faces.append((j*16+k,j*16+(k+1)%16,(j+1)*16+(k+1)%16,(j+1)*16+k))
faces.extend([tuple(range(15,-1,-1)),tuple((len(sections)-1)*16+k for k in range(16))])
saddle=mesh('Pressure relief saddle',verts,faces,'TapeLeather','Frame',uv)
sub=saddle.modifiers.new('Upholstery contour','SUBSURF');sub.levels=1;select([saddle]);bpy.ops.object.modifier_apply(modifier=sub.name)
for side in [-1,1]:
 cable('Saddle edge seam',[(side*w*.96,f,h-.001) for f,w,h,th in sections],.0008,'DarkMetal','Frame',5)
# Stem and headset.
cyl('Upper headset',(0,.309,.799),(0,.30,.826),.023,'DarkMetal','Fork',24)
tube('Forged stem',[(0,.30,.824),(0,.34,.837),(0,.438,.847)],[.019,.017,.017],'Carbon','Fork',20)
cyl('Stem cap',(0,.302,.824),(0,.298,.833),.021,'DarkMetal','Fork',24)
cyl('Stem cap screw',(0,.297,.833),(0,.296,.836),.004,'Metal','Fork',8)
cyl('Bar centre section',(-.115,.44,.847),(.115,.44,.847),.0155,'Carbon','Fork',20)
for side in [-1,1]:
 # 420mm maximum measured bar width including tape.
 pts=[(side*.074,.44,.847),(side*.14,.445,.847),(side*.185,.47,.844),(side*.196,.514,.819),(side*.196,.53,.778),(side*.196,.511,.740),(side*.196,.47,.721),(side*.196,.413,.724)]
 path=smoothpath(pts,6);tube('Swept drop handlebar',path,.0135,'TapeLeather','Fork',12)
 # Helical tape seam follows the same centreline, with physical shallow relief.
 P=[Vector(p) for p in path];lengths=[0]
 for i in range(1,len(P)):lengths.append(lengths[-1]+(P[i]-P[i-1]).length)
 wrap=[]
 for k in range(200):
  dist=lengths[-1]*k/199;j=next((i for i in range(1,len(P)) if lengths[i]>=dist),len(P)-1)
  t=(dist-lengths[j-1])/max(1e-8,lengths[j]-lengths[j-1]);p=P[j-1].lerp(P[j],t);tang=(P[j]-P[j-1]).normalized()
  u=tang.cross(Vector((0,0,1)))
  if u.length<.1:u=tang.cross(Vector((0,1,0)))
  u.normalize();v=tang.cross(u);a=dist/.012*2*pi
  wrap.append(tuple(p+.01355*(u*cos(a)+v*sin(a))))
 tube('Overlapping tape spiral',wrap,.00065,'TapeLeather','Fork',3)
 cyl('Bar end plug',(side*.196,.413,.724),(side*.196,.406,.724),.013,'DarkMetal','Fork',16)
 # Ergonomic brake/shift hood rises naturally from forward bar bend.
 hoodpts=[(side*.191,.494,.824),(side*.191,.523,.837),(side*.191,.546,.868),(side*.191,.558,.870)]
 tube('Rubber brake hood',smoothpath(hoodpts,4),[.016+.003*sin(pi*i/12) for i in range(13)],'Rubber','Fork',14)
 cable('Sculpted brake lever',[(side*.194,.555,.854),(side*.194,.57,.823),(side*.194,.565,.78),(side*.194,.552,.756)],.0055,'DarkMetal','Fork',8)
 cable('Inner shift paddle',[(side*.181,.548,.835),(side*.181,.55,.800),(side*.181,.536,.785)],.003,'Metal','Fork',6)
 cyl('Lever hinge bolt',(side*.179,.54,.84),(side*.205,.54,.84),.0035,'Metal','Fork',10)
# Stem faceplate and four socket bolts.
box('Stem clamp faceplate',(0,.453,.847),(.042,.012,.035),'DarkMetal','Fork',.004)
for x in [-.013,.013]:
 for h in [.837,.857]:cyl('Clamp fastener',(x,.46,h),(x,.465,h),.0033,'Metal','Fork',8)
# Brake calipers, pad shoes and visible housings.
for gr,f,h in [('Fork',.397,.650),('Frame',-.323,.605)]:
 cyl('Brake anchor',(0,f-.017,h),(0,f+.013,h),.007,'Metal',gr,12)
 for side in [-1,1]:
  cable('Dual pivot caliper arm',[(0,f,h+.014),(side*.030,f,h+.010),(side*.037,f,h-.008),(side*.026,f,h-.030)],.005,'DarkMetal',gr,8)
  box('Brake shoe',(side*.017,f,h-.028),(.009,.035,.008),'Rubber',gr,.002)
  cyl('Caliper pivot bolt',(side*.019,f-.005,h+.009),(side*.019,f+.009,h+.009),.004,'Metal',gr,10)
 cyl('Cable barrel adjuster',(.026,f,h+.014),(.026,f,h+.034),.0034,'Metal',gr,10)
cable('Front brake housing',[(-.18,.535,.833),(-.11,.48,.79),(-.065,.39,.714),(.018,.39,.697),(.026,.397,.684)],.0024,group='Fork')
cable('Rear brake housing',[(.181,.535,.831),(.13,.43,.81),(.07,.30,.75),(.032,.19,.79),(.026,-.18,.758),(.026,-.28,.685),(.026,-.323,.639)],.0024)
cable('Rear shift housing',[(-.185,.51,.82),(-.09,.44,.77),(-.033,.327,.737)],.0019)
cable('Rear shift housing exit',[(-.052,-.435,.327),(-.078,-.55,.30),(-.080,-.556,.271),(-.079,-.514,.280)],.0019)
ellipsoid('Internal routing grommet',(-.027,.327,.737),(.004,.007,.005),'Rubber','Frame',12,6)
# Bottle mounting bosses, drain and cable grommets, no decals.
for f,h in [(.035,.411),(.105,.465)]:
 cyl('Bottle boss',(-.029,f,h),(-.033,f,h),.004,'DarkMetal','Frame',10)
 cyl('Bottle boss screw',(-.033,f,h),(-.035,f,h),.0027,'Metal','Frame',8)
# Drivetrain on rider RIGHT (-X).
for x,r,t in [(-.047,.079,50),(-.041,.057,34)]:cog('Machined chainring',x,-.105,.275,r,t,'DarkMetal','Crank',r*.76)
for j in range(5):
 a=2*pi*j/5+.2
 cyl('Five arm chainring spider',(-.049,-.105,.275),(-.049,-.105+.065*sin(a),.275+.065*cos(a)),.007,'DarkMetal','Crank',10)
 cyl('Chainring bolt',(-.051,-.105+.064*sin(a),.275+.064*cos(a)),(-.055,-.105+.064*sin(a),.275+.064*cos(a)),.0035,'Metal','Crank',8)
cyl('Crank spindle',(-.056,-.105,.275),(.056,-.105,.275),.013,'Metal','Crank',20)
# Crank offset chosen to expose chainring and both pedals in hero view.
PEDALS={}
for side,label in [(1,'L'),(-1,'R')]:
 p=(side*.07,-.105+side*.149,.275-side*.073);PEDALS[label]=p
 tube('Hollow forged crank',[(side*.052,-.105,.275),(side*.06,-.105+side*.06,.275-side*.027),p],[.014,.012,.009],'DarkMetal','Crank',12,rx=[.008,.007,.006])
 cyl('Crank fixing cap',(side*.057,-.105,.275),(side*.061,-.105,.275),.010,'DarkMetal','Crank',16)
 cyl('Pedal spindle',p,(side*.108,p[1],p[2]),.005,'Metal','Pedal_'+label,12)
 q=(side*.112,p[1],p[2]);gr='Pedal_'+label
 # Compact clipless pedal: sculpted body with open engagement loops and spring.
 box('Clipless composite body',q,(.056,.065,.012),'Carbon',gr,.006)
 for front in [-1,1]:
  cable('Cleat engagement jaw',[(q[0]-.020,q[1]+front*.023,q[2]+.006),(q[0]-.014,q[1]+front*.034,q[2]+.015),(q[0]+.014,q[1]+front*.034,q[2]+.015),(q[0]+.020,q[1]+front*.023,q[2]+.006)],.0028,'Metal',gr,8)
 cyl('Pedal retention spring',(q[0]-.017,q[1]-.023,q[2]),(q[0]+.017,q[1]-.023,q[2]),.004,'DarkMetal',gr,12)
# Cassette graduated sprockets with a real dark freehub and lockring.
cyl('Freehub',(-.063,-.515,.335),(-.031,-.515,.335),.018,'DarkMetal','Wheel_R',20)
for j in range(10):
 r=.049-j*.0030;cog('Cassette cog',-.035-j*.0028,-.515,.335,r,28-j,'Metal','Wheel_R',r*.65)
cyl('Cassette lockring',(-.064,-.515,.335),(-.066,-.515,.335),.014,'DarkMetal','Wheel_R',20)
# Rear derailleur parallelogram, cage, pulleys and cable loop.
cable('Derailleur hanger',[(-.066,-.515,.335),(-.075,-.53,.307),(-.076,-.514,.284)],.006,'Metal','Frame',8)
box('Derailleur knuckle',(-.08,-.507,.281),(.030,.031,.024),'DarkMetal','Frame',.005)
for x in [-.088,-.070]:
 cyl('Derailleur parallelogram',(x,-.513,.284),(x,-.472,.249),.005,'DarkMetal','Frame',8)
 cable('Pulley cage',[(x,-.47,.25),(x,-.479,.223),(x,-.505,.194)],.004,'DarkMetal','Frame',8)
for f,h in [(-.472,.248),(-.505,.197)]:
 cog('Jockey pulley',-.079,f,h,.015,11,'DarkMetal','Frame',.006)
 cyl('Pulley screw',(-.09,f,h),(-.066,f,h),.003,'Metal','Frame',10)
# Front derailleur clamp and guide cage by the double chainrings.
box('Front derailleur motorless body',(-.029,-.14,.376),(.022,.024,.032),'DarkMetal','Frame',.004)
for x in [-.053,-.038]:
 cable('Front derailleur chain guide',[(x,-.17,.353),(x,-.12,.367),(x,-.075,.354)],.003,'Metal','Frame',6)
# Closed chain loop, each link carries two side plates and a cylindrical pin.
chainpath=[]
# clockwise from top chainring, wrap on forward side and return via derailleur
for i in range(33):
 a=pi/2-pi*i/32;chainpath.append(Vector((-.050,-.105+.081*cos(a),.275+.081*sin(a))))
chainpath += [Vector((-.050,-.505,.183)),Vector((-.050,-.518,.195)),Vector((-.050,-.472,.251)),Vector((-.050,-.548,.318))]
for i in range(20):
 a=pi+(-pi/2)*i/19;chainpath.append(Vector((-.050,-.515+.034*cos(a),.335+.034*sin(a))))
chainpath.append(chainpath[0]);distances=[0]
for a,b in zip(chainpath,chainpath[1:]):distances.append(distances[-1]+(b-a).length)
count=round(distances[-1]/.0127)
for i in range(count):
 dist=i*distances[-1]/count;j=next(j for j in range(1,len(distances)) if distances[j]>=dist);t=(dist-distances[j-1])/(distances[j]-distances[j-1]);p=chainpath[j-1].lerp(chainpath[j],t);v=(chainpath[j]-chainpath[j-1]).normalized()
 for side in [-1,1]:
  a=p-v*.00655+Vector((side*.0024,0,0));b=p+v*.00655+Vector((side*.0024,0,0));cyl('Chain side plate',tuple(a),tuple(b),.0021,'Metal','Frame',6)
 cyl('Chain roller',tuple(p+Vector((-.0031,0,0))),tuple(p+Vector((.0031,0,0))),.0022,'DarkMetal','Frame',8)
# Tiny required retractable accessory, tucked alongside the left chainstay.
KICK=(.048,-.22,.286)
cyl('Kickstand pivot',(.039,-.22,.286),(.062,-.22,.286),.008,'DarkMetal','Kickstand',12)
cable('Retracted lightweight kickstand',[KICK,(.066,-.30,.298),(.074,-.45,.323)],.004,'DarkMetal','Kickstand',8)
box('Kickstand foot',(.074,-.45,.323),(.014,.022,.008),'Rubber','Kickstand',.003)
# Finish UVs, consolidate by rigid motion group, and set precise mechanical origins.
ORIGINS={'Frame':(0,0,0),'Fork':HT,'Wheel_F':FRONT,'Wheel_R':REAR,'Crank':BB,'Pedal_L':PEDALS['L'],'Pedal_R':PEDALS['R'],'Kickstand':KICK}
objects={}
for name,obs in GROUPS.items():
 select(obs);bpy.ops.object.join();o=bpy.context.object;o.name=name
 # Smart UV supplies missing UVs after remesh/cogs, then repeat fine materials at restrained scale.
 select([o]);bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.normals_make_consistent(inside=False) if hasattr(bpy.ops.mesh,'normals_make_consistent') else None
 bpy.ops.uv.smart_project(angle_limit=1.15,island_margin=.008);bpy.ops.object.mode_set(mode='OBJECT')
 # UV islands preserve geometry-scale texture frequencies by world-space face size.
 S.cursor.location=xyz(ORIGINS[name]);bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 for p in o.data.polygons:p.use_smooth=True
 objects[name]=o
current_tri=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objects.values())
if current_tri>49500:
 o=objects['Frame'];fc=sum(len(p.vertices)-2 for p in o.data.polygons);select([o]);dec=o.modifiers.new('Final budget guard','DECIMATE');dec.ratio=max(.5,1-(current_tri-48500)/fc);bpy.ops.object.modifier_apply(modifier=dec.name)
for child,parent in [('Wheel_F','Fork'),('Pedal_L','Crank'),('Pedal_R','Crank')]:
 o=objects[child];world=o.matrix_world.copy();o.parent=objects[parent];o.matrix_world=world
SOCKETS={'Seat_Rider':((0,-.335,1.00),'Frame'),'Hand_L':((.191,.529,.846),'Fork'),'Hand_R':((-.191,.529,.846),'Fork'),'Foot_L':((.112,PEDALS['L'][1],PEDALS['L'][2]+.014),'Pedal_L'),'Foot_R':((-.112,PEDALS['R'][1],PEDALS['R'][2]+.014),'Pedal_R'),'Light_Head':((0,.455,.85),'Fork'),'Light_Tail':((0,-.432,.974),'Frame')}
for name,(p,parent) in SOCKETS.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=xyz(p);bpy.context.view_layer.update();world=o.matrix_world.copy();o.parent=objects[parent];o.matrix_world=world;objects[name]=o
bpy.context.view_layer.update()
# Save structural manifest in exported (glTF) coordinates.
pts=[o.matrix_world@v.co for o in objects.values() if o.type=='MESH' for v in o.data.vertices]
pp=np.array([semantic(v) for v in pts]);lo=pp.min(axis=0).tolist();hi=pp.max(axis=0).tolist()
tri=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objects.values() if o.type=='MESH')
manifest={'units':'metres','axes':{'forward':'+Z','up':'+Y','rider_left':'+X'},'bounds':{'min':lo,'max':hi,'size':(np.array(hi)-lo).tolist()},'triangles':tri,'nodes':{n:{'world_position':semantic(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for n,o in objects.items()},'steering_axis':[0,.934,-.356],'wheel_spin_axis':[1,0,0],'crank_spin_axis':[1,0,0],'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
json.dump(manifest,open('nodes.json','w'),indent=2)
select(list(objects.values()))
bpy.ops.export_scene.gltf(filepath=os.path.join(ROOT,'road-bike.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_cameras=False,export_lights=False,export_image_format='AUTO')
# Ensure exact paint factor and shared ORM encoding in portable GLB (exporter graph patterns can vary).
import struct
raw=open('road-bike.glb','rb').read();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);binary=raw[28+jl:]
for m in doc['materials']:
 if m['name']=='Paint':
  m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
  assert 'baseColorTexture' in m['pbrMetallicRoughness'],'Paint albedo export failed'
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
open('road-bike.glb','wb').write(struct.pack('<III',0x46546c67,2,12+8+len(js)+8+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+struct.pack('<II',len(binary),0x004e4942)+binary)
assert 20000<=tri<=50000,tri
for socket,(pos,parent) in SOCKETS.items():assert (objects[socket].matrix_world.translation-xyz(pos)).length<1e-6
assert os.path.getsize('road-bike.glb')<=12*1024*1024
print('EXPORT_OK',tri,os.path.getsize('road-bike.glb'),manifest['bounds'],flush=True)
# Lighting, render views and paint proof. These objects never enter the export.
ground=bpy.data.materials.new('Studio only');ground.diffuse_color=(.34,.36,.38,1);ground.use_nodes=True;ground.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.78
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.001));floor=bpy.context.object;floor.data.materials.append(ground)
S.world=bpy.data.worlds.new('Soft daylight');S.world.use_nodes=True;S.world.node_tree.nodes.get('Background').inputs[0].default_value=(.64,.72,.85,1);S.world.node_tree.nodes.get('Background').inputs[1].default_value=.22
for name,loc,power,size,color in [('Daylight key',(-2.4,-1.5,3.5),180,3,(1,.94,.86)),('Cool fill',(2,-.5,2),70,2.5,(.80,.89,1)),('Long rim',(.5,2.2,2.8),160,2,(1,1,1))]:
 data=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,data);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,.5))-o.location).to_track_quat('-Z','Y').to_euler();data.energy=power;data.shape='DISK';data.size=size;data.color=color
camdata=bpy.data.cameras.new('Render camera');cam=bpy.data.objects.new('Render camera',camdata);bpy.context.collection.objects.link(cam);S.camera=cam
HERO=Vector((-2.15,-2.0,1.38));TARGET=Vector((0,0,.48))
def view(loc,target=TARGET,ortho=2.15):
 cam.location=loc;cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camdata.type='ORTHO';camdata.ortho_scale=ortho;camdata.lens=55
view(HERO,ortho=2.32)
S.render.resolution_x=1920;S.render.resolution_y=1080;S.render.resolution_percentage=100
S.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
view(Vector((-3,0,.51)),Vector((0,0,.51)),2.03);S.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
view(HERO,ortho=2.32)
clay=bpy.data.materials.new('Clay inspection');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.36,.36,.36,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.5
old={o:[m for m in o.data.materials] for o in objects.values() if o.type=='MESH'}
for o in old:
 for i in range(len(o.data.materials)):o.data.materials[i]=clay
S.render.filepath=os.path.join(ROOT,'clay.png');bpy.ops.render.render(write_still=True)
for o,mats in old.items():
 for i,m in enumerate(mats):o.data.materials[i]=m
S.render.resolution_x=640;S.render.resolution_y=360;S.cycles.samples=12
sheet=np.ones((720,1920,4),np.float32)
for i,(name,col) in enumerate(PAINTS):
 MAT['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*col,1)
 S.render.filepath=os.path.join(ROOT,f'paint_{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(S.render.filepath,check_existing=False);pix=np.array(im.pixels[:],np.float32).reshape(360,640,4)
 row=1-i//3;column=i%3;sheet[row*360:(row+1)*360,column*640:(column+1)*640]=pix;bpy.data.images.remove(im)
proof=bpy.data.images.new('Six paint finishes',1920,720,alpha=True);proof.pixels.foreach_set(sheet.reshape(-1));proof.filepath_raw=os.path.join(ROOT,'paints.png');proof.file_format='PNG';proof.save()
MAT['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*PAINTS[0][1],1)
notes=[
 'Modern 700c carbon road racing bicycle, reference-based original geometry; metres, +Z forward, articulated game nodes and sockets.',
 f'{tri:,} triangles; GLB {os.path.getsize("road-bike.glb")/1024/1024:.2f} MiB; dimensions '+ ' x '.join(f'{v:.3f}' for v in manifest['bounds']['size'])+' m (width, height, length).',
 '6 materials: Paint, Carbon, Rubber, TapeLeather, Metal, DarkMetal; glossy Paint neutral baked albedo × linear colour factor, clearcoat 1.0 / 0.05.',
 'Cycles OptiX baked reusable surface sets: base colour / tangent normal 1024², packed ORM 512²; same sets shared across all rigid parts.',
 'Poly Haven CC0: metal_plate, fabric_leather_02; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/fabric_leather_02 ; licence https://polyhaven.com/license .',
 'Differences: blue default per paint contract (black variant supplied), compact clipless pedals and a tucked retracted kickstand; carbon micro-weave is interpreted, no branding.'
]
open('NOTES.md','w').write('\n'.join(notes)+'\n')
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'road-bike.blend'))
print('ALL_DONE_SECONDS',time.time()-START,flush=True)
