import bpy, math, os, json, urllib.request, subprocess
from mathutils import Vector
from pathlib import Path
from math import sin,cos,pi
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU';sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG';sc.world.color=(.25,.25,.25)
sc.view_settings.view_transform='AgX'
# Photograph-derived, tiled material bakes: all outputs portable glTF PBR.
SOURCES=['metal_plate','rubber_tiles','brown_leather']
texdir=ROOT/'textures';texdir.mkdir(exist_ok=True)
for ident in SOURCES:
 for typ in ['diff','nor_gl','arm']:
  p=texdir/f'{ident}_{typ}_1k.jpg'
  if not p.exists():
   subprocess.run(['curl','-fL','--retry','3','-A','Mozilla/5.0','-o',str(p),f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{'albedo' if ident=='brown_leather' and typ=='diff' else typ}_1k.jpg'],check=True)

def newmat(name):
 m=bpy.data.materials.new(name);m.use_nodes=True;m.node_tree.nodes.clear();return m
mats=[]
configs=[('Paint','metal_plate',(.9,.9,.9),.29,.35,.035),('Chrome','metal_plate',(.58,.63,.68),.22,1,.025),('Rubber','rubber_tiles',(.009,.010,.012),.82,0,.22),('Black hardware','metal_plate',(.028,.033,.039),.38,.65,.1),('Saddle leather','brown_leather',(.16,.045,.018),.58,0,.45),('Amber reflector','metal_plate',(.85,.19,.018),.23,.1,.04)]
for name,src,col,rough,metal,strength in configs:
 m=newmat(name);nt=m.node_tree;n=nt.nodes;l=nt.links
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord');images={}
 for typ in ['diff','nor_gl','arm']:
  t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(texdir/f'{src}_{typ}_1k.jpg'),check_existing=True)
  if typ!='diff':t.image.colorspace_settings.name='Non-Color'
  l.new(uv.outputs['UV'],t.inputs['Vector']);images[typ]=t
 bw=n.new('ShaderNodeRGBToBW');l.new(images['diff'].outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=(*[c*.72 for c in col],1);ramp.color_ramp.elements[1].color=(*col,1);l.new(bw.outputs[0],ramp.inputs[0]);l.new(ramp.outputs[0],bs.inputs['Base Color'])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=180;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=strength;l.new(images['nor_gl'].outputs['Color'],nm.inputs['Color'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.13;bump.inputs['Distance'].default_value=.00012;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(images['arm'].outputs[0],sep.inputs[0]);rr=n.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=rough*.8;rr.inputs['To Max'].default_value=min(.98,rough*1.2);l.new(sep.outputs[1],rr.inputs['Value']);l.new(rr.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(m)
 baked={}
 for mode,res in [('base',1024 if name=='Saddle leather' else 512),('normal',512),('orm',512)]:
  im=bpy.data.images.new(name+'_'+mode,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if mode=='base' else 'Non-Color'
  target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
  if mode=='normal':l.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL',margin=4)
  else:
   em=n.new('ShaderNodeEmission')
   if mode=='base':l.new(ramp.outputs[0],em.inputs[0])
   else:
    combine=n.new('ShaderNodeCombineColor');combine.inputs[0].default_value=1;combine.inputs[2].default_value=metal;l.new(rr.outputs[0],combine.inputs[1]);l.new(combine.outputs[0],em.inputs[0])
   l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=4)
  im.filepath_raw=str(texdir/f'{name.replace(" ","_")}_{mode}.png');im.file_format='PNG';im.save();baked[mode]=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])
 for mode in baked:
  t=n.new('ShaderNodeTexImage');t.image=baked[mode]
  if mode=='base':
   if name=='Paint':
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.30,.05,.40,1);l.new(t.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],bs.inputs['Base Color']);m['paint_mix']=mix.name
   else:l.new(t.outputs[0],bs.inputs['Base Color'])
  elif mode=='normal':
   nm=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],nm.inputs[1]);l.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sep=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
   group=bpy.data.node_groups.get('glTF Material Output')
   if not group:
    group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   g=n.new('ShaderNodeGroup');g.node_tree=group;l.new(sep.outputs[0],g.inputs['Occlusion'])
 if name=='Paint':bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 mats.append(m)
 print('BAKED',name,flush=True)
# Mesh builder, Z-up Blender, forward -Y => glTF forward +Z.
class Geo:
 def __init__(self):self.v=[];self.f=[];self.mi=[];self.uv=[];self.boxverts=set()
 def face(self,verts,mat,uv=None):self.f.append(verts);self.mi.append(mat);self.uv.append(uv or [(0,0),(1,0),(1,1),(0,1)][:len(verts)])
 def tube(self,pts,r,mat=1,n=12,caps=True):
  pts=[Vector(p) for p in pts];base=len(self.v);dist=0;old_u=None
  for j,p in enumerate(pts):
   tangent=(pts[min(j+1,len(pts)-1)]-pts[max(0,j-1)]).normalized();ref=Vector((1,0,0)) if abs(tangent.x)<.9 else Vector((0,1,0));u=(old_u-tangent*old_u.dot(tangent)).normalized() if old_u is not None else tangent.cross(ref).normalized();v=tangent.cross(u).normalized();old_u=u
   for i in range(n):self.v.append(tuple(p+r*(cos(i*2*pi/n)*u+sin(i*2*pi/n)*v)))
   if j:
    length=(p-pts[j-1]).length
    for i in range(n):self.face([base+(j-1)*n+i,base+(j-1)*n+(i+1)%n,base+j*n+(i+1)%n,base+j*n+i],mat,[(i/n,dist),( (i+1)/n,dist),((i+1)/n,dist+length*8),(i/n,dist+length*8)])
    dist+=length*8
  if caps:
   self.face([base+i for i in reversed(range(n))],mat,[(.5+.5*cos(i*2*pi/n),.5+.5*sin(i*2*pi/n)) for i in reversed(range(n))]);self.face([base+(len(pts)-1)*n+i for i in range(n)],mat,[(.5+.5*cos(i*2*pi/n),.5+.5*sin(i*2*pi/n)) for i in range(n)])
 def rod(self,a,b,r,mat=1,n=12):self.tube([a,b],r,mat,n)
 def ring(self,c,R,r,mat=1,N=64,n=8,axis='X'):
  base=len(self.v)
  for j in range(N):
   a=2*pi*j/N
   for k in range(n):
    b=2*pi*k/n
    p=(r*sin(b),(R+r*cos(b))*cos(a),(R+r*cos(b))*sin(a))
    if axis=='Z':p=(p[1],p[2],p[0])
    self.v.append(tuple(Vector(c)+Vector(p)))
  for j in range(N):
   for k in range(n):self.face([base+j*n+k,base+((j+1)%N)*n+k,base+((j+1)%N)*n+(k+1)%n,base+j*n+(k+1)%n],mat,[(j/N,k/n),((j+1)/N,k/n),((j+1)/N,(k+1)/n),(j/N,(k+1)/n)])
 def box(self,c,size,mat=3,angle=0):
  base=len(self.v);self.boxverts.update(range(base,base+8))
  for x,y,z in [(-1,-1,-1),(-1,1,-1),(1,1,-1),(1,-1,-1),(-1,-1,1),(-1,1,1),(1,1,1),(1,-1,1)]:
   x,y,z=x*size[0]/2,y*size[1]/2,z*size[2]/2;self.v.append((c[0]+x,c[1]+y*cos(angle)-z*sin(angle),c[2]+y*sin(angle)+z*cos(angle)))
  for f in [(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)]:self.face([base+i for i in f],mat)
 def object(self,name,pivot=(0,0,0)):
  me=bpy.data.meshes.new(name);me.from_pydata(self.v,[],self.f);me.materials.clear()
  for m in mats:me.materials.append(m)
  weights=me.attributes.new('bevel_weight_edge','FLOAT','EDGE')
  for e in me.edges:weights.data[e.index].value=1.0 if all(v in self.boxverts for v in e.vertices) else 0.0
  uv=me.uv_layers.new()
  for p,mi,coords in zip(me.polygons,self.mi,self.uv):
   p.material_index=mi;p.use_smooth=len(p.vertices)==4
   for li,co in zip(p.loop_indices,coords):uv.data[li].uv=co
  ob=bpy.data.objects.new(name,me);sc.collection.objects.link(ob)
  for v in me.vertices:v.co-=Vector(pivot)
  ob.location=pivot
  return ob

def smooth(points,steps=5):
 p=[Vector(points[0])]+[Vector(x) for x in points]+[Vector(points[-1])];res=[]
 for i in range(1,len(p)-2):
  for j in range(steps):
   t=j/steps;res.append(.5*((2*p[i])+(-p[i-1]+p[i+1])*t+(2*p[i-1]-5*p[i]+4*p[i+1]-p[i+2])*t*t+(-p[i-1]+3*p[i]-3*p[i+1]+p[i+2])*t*t*t))
 res.append(p[-2]);return res
G={k:Geo() for k in ['Frame','Fork','Wheel_F','Wheel_R','Crank','Pedal_L','Pedal_R','Kickstand']}
f=G['Frame'];fork=G['Fork'];bb=Vector((0,.13,.285));seat=Vector((0,.235,.572));headlo=Vector((0,-.407,.606));headhi=Vector((0,-.445,.724));steer=(headhi-headlo).normalized()
# Main butted tubes and rear triangles.
for a,b,r in [(bb,seat,.016),(seat,headhi,.017),(bb,headlo,.021),(headlo,headhi,.022)]:f.rod(a,b,r,0,24)
for s in [-1,1]:
 rear=(s*.052,.55,.26)
 f.tube([tuple(bb+Vector((s*.026,0,0))),(s*.049,.31,.25),rear],.009,0,14)
 f.rod(tuple(seat+Vector((s*.013,0,-.012))),rear,.009,0,14)
 f.rod((s*.056,.55,.26),(s*.056,.495,.273),.017,0,16)
# Weld bead collars along tube ends, narrow bright ridges.
for a,b,r in [(bb,seat,.016),(seat,headhi,.017),(bb,headlo,.021)]:
 d=(b-a).normalized()
 for end in [a+d*.022,b-d*.024]:
  f.tube([end-d*.0015,end+d*.0015],r+.0012,0,24)
f.rod((-.042,.13,.285),(.042,.13,.285),.023,0,24)
f.rod((0,.231,.577),(0,.267,.716),.012,3,20)
f.rod((0,.231,.572),(0,.237,.592),.019,3,20)
f.rod((-.024,.235,.583),(.024,.235,.583),.0045,1,10)
# Shaped leather saddle loft with rounded shoulder and raised tail.
sections=[(.104,.004,.719,.004),(.11,.017,.719,.018),(.14,.026,.72,.022),(.20,.046,.725,.027),(.265,.079,.735,.031),(.31,.081,.741,.030),(.34,.061,.737,.026),(.349,.030,.733,.014)]
# Catmull interpolation of four scalar profile values.
raw=sections;sections=[]
for j in range(len(raw)-1):
 a=raw[max(0,j-1)];b0=raw[j];c0=raw[j+1];d=raw[min(len(raw)-1,j+2)]
 for k0 in range(3):
  t=k0/3;sections.append(tuple(.5*(2*b0[i]+(-a[i]+c0[i])*t+(2*a[i]-5*b0[i]+4*c0[i]-d[i])*t*t+(-a[i]+3*b0[i]-3*c0[i]+d[i])*t*t*t) for i in range(4)))
sections.append(raw[-1]);b=len(f.v);N=24
for y,w,z,h in sections:
 for i in range(N):
  a=2*pi*i/N;f.v.append((w*cos(a),y,z+h*sin(a)))
for j in range(len(sections)-1):
 for i in range(N):f.face([b+(j+1)*N+i,b+(j+1)*N+(i+1)%N,b+j*N+(i+1)%N,b+j*N+i],4,[(i/N,j/6),((i+1)/N,j/6),((i+1)/N,(j+1)/6),(i/N,(j+1)/6)])
f.face([b+i for i in range(N)],4,[(.5,.5)]*N);f.face([b+(len(sections)-1)*N+i for i in reversed(range(N))],4,[(.5,.5)]*N)
for s in [-1,1]:
 f.tube(smooth([(s*.025,.135,.706),(s*.031,.2,.694),(s*.032,.30,.704)]),.003,1,8)
 f.tube(smooth([(s*.025,.142,.715),(s*.05,.21,.718),(s*.079,.29,.728),(s*.052,.34,.727)]),.0008,4,6)
# Headset, gyro rotor, upper steering stem.
for t,r,mat in [(-.006,.026,1),(.123,.027,1),(.133,.032,3),(.146,.03,1),(.158,.021,3)]:
 c=headlo+steer*t;fork.tube([c-steer*.003,c+steer*.003],r,mat,24)
fork.rod(headhi,headhi+steer*.045,.016,1,20)
stem=headhi+steer*.045
fork.box(tuple(stem+Vector((0,-.025,.004))),(.05,.074,.026),3)
for x in [-.018,.018]:
 for y in [-.05,-.003]:fork.rod((x,stem.y+y,stem.z+.014),(x,stem.y+y,stem.z+.019),.004,1,6)
# Swept fork legs and crown; pegs are fixed to fork/frame, not rotating wheels.
for s in [-1,1]:
 fork.tube(smooth([(s*.045,-.55,.26),(s*.044,-.494,.46),(s*.039,-.442,.568),(s*.020,-.408,.60)]),.016,0,16)
 fork.rod((s*.048,-.55,.26),(s*.048,-.519,.291),.019,0,16)
fork.rod((-.037,-.413,.586),(.037,-.413,.586),.022,0,20)
# High-rise U-bar with short backsweep, width exactly .72 m.
barz=.793;bary=stem.y-.045
pts=[(-.35,bary+.028,.982),(-.25,bary+.024,.982),(-.207,bary+.014,.955),(-.164,bary,.83),(-.125,bary,.794),(0,bary,.789),(.125,bary,.794),(.164,bary,.83),(.207,bary+.014,.955),(.25,bary+.024,.982),(.35,bary+.028,.982)]
fork.tube(smooth(pts,4),.011,1,16);fork.rod((-.185,bary+.006,.91),(.185,bary+.006,.91),.007,1,14)
for s in [-1,1]:
 fork.rod((s*.249,bary+.024,.982),(s*.345,bary+.028,.982),.017,2,20)
 fork.rod((s*.345,bary+.028,.982),(s*.36,bary+.028,.982),.018,3,20)
 for i in range(17):
  x=s*(.254+i*.0053);fork.ring((x,bary+.026,.982),.017,.00065,2,20,4)
 fork.rod((s*.234,bary+.022,.982),(s*.244,bary+.022,.982),.018,3,16)
 fork.tube(smooth([(s*.236,bary+.022,.974),(s*.24,bary-.025,.962),(s*.29,bary-.032,.953),(s*.31,bary-.019,.955)]),.004,3,10)
 # Upper cable loops into gyro, twin lower brake lines follow frame.
 fork.tube(smooth([(s*.233,bary-.009,.97),(s*.20,-.64,.904),(s*.075,-.59,.755),(s*.032,-.452,.731)]),.0023,2,8)
 f.tube(smooth([(s*.025,-.438,.719),(s*.03,-.39,.675),(s*.025,-.12,.632),(s*.025,.225,.559),(s*.04,.35,.46)]),.0019,2,8)
 for y in [-.22,.10]:f.rod((s*.022,y,.65-(y+.22)*.18),(s*.032,y,.65-(y+.22)*.18),.003,1,8)
 # Rear U brake arm and pad.
 f.tube(smooth([(s*.034,.4,.41),(s*.055,.375,.445),(s*.045,.334,.478)]),.006,3,10)
 f.box((s*.022,.4,.41),(.013,.032,.014),2)
 f.rod((s*.037,.375,.433),(s*.06,.375,.433),.007,1,10)
f.rod((-.049,.334,.478),(.049,.334,.478),.002,1,8)
# Four hollow axle pegs, annular machined rims and inset bore.
for y,owner in [(-.55,fork),(.55,f)]:
 for s in [-1,1]:
  owner.rod((s*.041,y,.26),(s*.065,y,.26),.009,1,12)
  owner.tube([(s*.06,y,.26),(s*.16,y,.26)],.018,1,24,False)
  owner.tube([(s*.16,y,.26),(s*.137,y,.26)],.014,3,24,False)
  owner.ring((s*.16,y,.26),.016,.002,1,24,6)
  owner.ring((s*.063,y,.26),.018,.001,3,24,6)
# Wheels: shaped toroidal tyre, twin rim bead, 36 crossed spokes, nipples, hubs, tread.
for name,y in [('Wheel_F',-.55),('Wheel_R',.55)]:
 g=G[name];g.ring((0,y,.26),.232,.027,2,96,16)
 for s in [-1,1]:
  g.ring((s*.019,y,.26),.227,.002,2,96,6)
  g.ring((s*.015,y,.26),.212,.008,3,96,8)
  g.ring((s*.018,y,.26),.216,.0013,1,96,4)
  g.ring((s*.031,y,.26),.023,.004,3,32,8)
 g.rod((-.032,y,.26),(.032,y,.26),.015,3,20)
 for i in range(36):
  a=i*2*pi/36;sgn=1 if i%2 else -1;h=a+sgn*.52
  rim=(sgn*.009,y+.205*cos(a),.26+.205*sin(a));hub=(sgn*.033,y+.023*cos(h),.26+.023*sin(h))
  g.rod(hub,rim,.00085,1,6)
  nip=(sgn*.009,y+.213*cos(a),.26+.213*sin(a));g.rod(rim,nip,.0016,1,6)
 # Small staggered blocks follow tread crown, not sidewall.
 for j in range(72):
  for row in [-1,0,1]:
   a=2*pi*(j+(0.5 if row!=0 else 0))/72;r=.2576 if row==0 else .253
   g.box((row*.016,y+r*cos(a),.26+r*sin(a)),(.012,.0048,.012),2,a)
 g.rod((0,y,.462),(0,y,.448),.0022,1,8)
# Drivetrain on rider's right (negative X), chain stays rigid Frame.
c=G['Crank'];c.rod((-.065,.13,.285),(.065,.13,.285),.009,1,16)
ringc=(-.052,.13,.285);c.ring(ringc,.062,.006,3,48,8)
for i in range(8):
 a=i*pi/4;c.rod((-.052,.13+.012*cos(a),.285+.012*sin(a)),(-.052,.13+.059*cos(a),.285+.059*sin(a)),.0055,3,8)
c.rod((-.059,.13,.285),(-.044,.13,.285),.016,3,20)
for i in range(28):
 a=i*2*pi/28;c.box((-.052,.13+.069*cos(a),.285+.069*sin(a)),(.008,.008,.007),3,a)
# Cranks slightly angled from horizontal; independent self-levelling pedals.
pedals={}
for s in [-1,1]:
 p=(s*.086,.13-s*.164,.285+s*.035);pedals['Pedal_L' if s==1 else 'Pedal_R']=p
 c.tube([(s*.049,.13,.285),(s*.068,.13-s*.065,.285+s*.014),p],.011,3,12)
 c.rod((s*.061,.13,.285),(s*.067,.13,.285),.006,1,6)
 pg=G['Pedal_L' if s==1 else 'Pedal_R'];pg.rod(p,(s*.142,p[1],p[2]),.005,1,10)
 # open platform, perimeter cage and centre bridge.
 center=(s*.133,p[1],p[2]);xx=[s*.089,s*.175];yy=[p[1]-.043,p[1]+.043]
 for x in xx:pg.rod((x,yy[0],p[2]),(x,yy[1],p[2]),.006,3,8)
 for y0 in [yy[0],p[1],yy[1]]:pg.rod((xx[0],y0,p[2]),(xx[1],y0,p[2]),.006,3,8)
 for x in [s*.10,s*.125,s*.151,s*.17]:
  for y0 in yy:pg.rod((x,y0,p[2]+.004),(x,y0,p[2]+.009),.0015,1,6)
 for y0 in yy:pg.box((s*.133,y0,p[2]),(.035,.003,.007),5)
# Chain: individual side plates and rollers on tangent path (one speed).
f.ring((-.052,.55,.26),.027,.004,3,32,8)
path=[]
# unequal pulley external tangents, sampled as a capsule with changing radius
for i in range(22):
 a=-pi/2-pi*i/21;path.append(Vector((-.052,.13+.072*cos(a),.285+.072*sin(a))))
for i in range(22):
 a=pi/2-pi*i/21;path.append(Vector((-.052,.55+.03*cos(a),.26+.03*sin(a))))
# Order: bottom front -> around front left -> top front -> top rear -> around rear -> bottom rear.
lengths=[(path[(i+1)%len(path)]-p).length for i,p in enumerate(path)];total=sum(lengths);count=int(total/.0127)
for k in range(count):
 def sample(d):
  d%=total
  for i,L in enumerate(lengths):
   if d<=L:return path[i].lerp(path[(i+1)%len(path)],d/L)
   d-=L
 a=sample(k*total/count);b=sample((k+1)*total/count)
 for side in [-1,1]:f.rod(a+Vector((side*.003,0,0)),b+Vector((side*.003,0,0)),.0021,1,6)
 f.rod(a+Vector((-.004,0,0)),a+Vector((.004,0,0)),.0027,3,8)
# Compact retracted stand beside left chainstay.
k=G['Kickstand'];pivot=(.042,.245,.26);k.rod(pivot,(.063,.439,.276),.006,3,12);k.box((.063,.441,.276),(.025,.028,.014),2);f.rod((.025,.245,.26),(.05,.245,.26),.010,3,12)
# Objects and articulation. All geometry baked into mesh coordinates.
pivots={'Frame':(0,0,0),'Fork':tuple(headlo),'Wheel_F':(0,-.55,.26),'Wheel_R':(0,.55,.26),'Crank':tuple(bb),'Kickstand':pivot,**pedals}
obs={name:g.object(name,pivots[name]) for name,g in G.items()}
def parent(child,pa):
 w=child.matrix_world.copy();child.parent=pa;child.matrix_world=w
bpy.context.view_layer.update()
parent(obs['Wheel_F'],obs['Fork']);parent(obs['Pedal_L'],obs['Crank']);parent(obs['Pedal_R'],obs['Crank'])
# Tight bevels only on genuinely hard corners; shade preserving crisp flats.
for ob in obs.values():
 bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);bpy.context.view_layer.objects.active=ob
 bevel=ob.modifiers.new('Machined edge radii','BEVEL');bevel.width=.00065;bevel.segments=1;bevel.limit_method='WEIGHT'
 bpy.ops.object.modifier_apply(modifier=bevel.name)
 # Preserve object axes for wheel/crank spin; geometry is already applied.
tri=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in obs.values())
print('BEFORE BUDGET',tri,flush=True)
if tri>49000:
 for ob in obs.values():
  bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);bpy.context.view_layer.objects.active=ob
  dec=ob.modifiers.new('Game mesh budget','DECIMATE');dec.ratio=47000/tri
  bpy.ops.object.modifier_apply(modifier=dec.name)
tri=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in obs.values())
print('TRIANGLES',tri,flush=True)
# Sockets inherit corresponding moving assemblies.
sockets={'Seat_Rider':((0,.275,.776),'Frame'),'Hand_L':((.298,bary+.026,.982),'Fork'),'Hand_R':((-.298,bary+.026,.982),'Fork'),'Foot_L':((.13,pedals['Pedal_L'][1],pedals['Pedal_L'][2]+.014),'Pedal_L'),'Foot_R':((-.13,pedals['Pedal_R'][1],pedals['Pedal_R'][2]+.014),'Pedal_R'),'Light_Head':((0,-.466,.728),'Fork'),'Light_Tail':((0,.352,.734),'Frame')}
for name,(p,pa) in sockets.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=p;bpy.context.view_layer.update();parent(o,obs[pa])
paints=[('Plum',(.30,.05,.40)),('British racing green',(.025,.13,.075)),('Cobalt',(.025,.10,.42)),('Oxblood',(.28,.022,.025)),('Warm ivory',(.72,.67,.52)),('Graphite',(.045,.052,.065))]
bpy.context.view_layer.update()
asset=[*obs.values()]+[bpy.data.objects[k] for k in sockets]
def gl(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
bounds=[o.matrix_world@Vector(c) for o in obs.values() for c in o.bound_box];gv=[gl(v) for v in bounds]
manifest={'units':'metres','front':'+Z','up':'+Y','nodes':{o.name:{'world_position':gl(o.matrix_world.translation),'parent':o.parent.name if o.parent else None} for o in asset},'bounding_box':{'min':[min(v[i] for v in gv) for i in range(3)],'max':[max(v[i] for v in gv) for i in range(3)]},'steering_axis':gl(steer),'paints':[{'name':n,'linear_rgb':v} for n,v in paints],'triangles':tri}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
# Exporter's simple Multiply recognition carries neutral map and color factor.
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
paint_nt=mats[0].node_tree
paint_bs=next(n for n in paint_nt.nodes if n.type=='BSDF_PRINCIPLED')
paint_mix=paint_nt.nodes[mats[0]['paint_mix']]
paint_base=paint_mix.inputs[1].links[0].from_socket
paint_nt.links.new(paint_base,paint_bs.inputs['Base Color'])
bpy.ops.export_scene.gltf(filepath=str(ROOT/'bmx-bike.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_cameras=False,export_lights=False)
# Set the glTF factor explicitly: Blender's generic MixRGB exporter otherwise folds color into the image.
import struct
p=ROOT/'bmx-bike.glb';raw=p.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
for material in doc['materials']:
 if material['name']=='Paint':material['pbrMetallicRoughness']['baseColorFactor']=[.30,.05,.40,1]
encoded=json.dumps(doc,separators=(',',':')).encode();encoded+=b' '*((-len(encoded))%4)
p.write_bytes(struct.pack('<III',0x46546C67,2,12+8+len(encoded)+len(tail))+struct.pack('<II',len(encoded),0x4E4F534A)+encoded+tail)
paint_nt.links.new(paint_mix.outputs[0],paint_bs.inputs['Base Color'])
(ROOT/'NOTES.md').write_text('\n'.join(["Freestyle BMX, metric articulated game asset; purple clearcoat frame/fork, 20-inch class wheels, gyro and four pegs.",f"Triangles: {tri:,}; all hard-edge bevel modifiers applied.","Materials: Paint, Chrome, Rubber, Black hardware, Saddle leather, Amber reflector (6).","Baked Cycles tiled maps: base 512px (leather 1024px), normal 512px, ORM 512px; shared across rigid parts.","Poly Haven CC0 ids: metal_plate, rubber_tiles, brown_leather; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/rubber_tiles ; https://polyhaven.com/a/brown_leather","Differences: required purple frame/fork replaces reference chrome/black; original tread layout, pedal cage and compact retracted kickstand."])+ '\n')
# Soft daylight studio for objective silhouette/material inspection.
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.0015));ground=bpy.context.object;ground.name='Studio ground';gm=bpy.data.materials.new('Studio');gm.use_nodes=True;gm.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.24,.265,.29,1);gm.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.85;ground.data.materials.append(gm)
world=bpy.data.worlds.new('Daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.57,.66,.8,1);world.node_tree.nodes['Background'].inputs[1].default_value=.5
for loc,power,size in [((2,-3,4),450,4),((-3,-1,2),350,3),((1,3,3),500,3)]:
 bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector((0,0,.5))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=2.42
cam.location=(-2.3,-3.2,1.64);target=Vector((0,0,.47));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
sc.cycles.samples=40
sc.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
camloc=cam.location.copy();camrot=cam.rotation_euler.copy()
cam.location=(-3,0,.51);cam.rotation_euler=(Vector((0,0,.51))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=2.10;sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
cam.location=camloc;cam.rotation_euler=camrot;cam.data.ortho_scale=2.42
clay=bpy.data.materials.new('QA clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.23,.23,.23,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.68;sc.view_layers[0].material_override=clay;sc.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
sc.cycles.samples=16;sc.render.resolution_x=640;sc.render.resolution_y=360
for i,(name,color) in enumerate(paints):
 mats[0].node_tree.nodes[mats[0]['paint_mix']].inputs[2].default_value=(*color,1);sc.render.filepath=str(ROOT/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
# Assemble paint grid using Blender image buffers; no third-party dependency.
import numpy as np
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i in range(6):
 im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'));a=np.array(im.pixels[:]).reshape(360,640,4);row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=a
im=bpy.data.images.new('Paint variants',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
# Verify that the serialized asset renders correctly after a fresh glTF import.
for o in asset:o.hide_render=True
bpy.data.node_groups['glTF Material Output'].name='Source baked occlusion'
bpy.ops.import_scene.gltf(filepath=str(ROOT/'bmx-bike.glb'))
sc.render.resolution_x=1280;sc.render.resolution_y=720;sc.cycles.samples=24
sc.render.filepath=str(ROOT/'roundtrip.png');bpy.ops.render.render(write_still=True)
print('COMPLETE',flush=True)
