"""Clean, deterministic Blender 5.2 build. Run headless with --python-exit-code 1.
Authoring coordinates: X rider left, -Y front, Z up. Export glTF Y up.
All photographic inputs are Poly Haven CC0; surface recipes baked by Cycles GPU.
"""
import bpy, bmesh, math, json, os, sys, struct, urllib.request
from pathlib import Path
from mathutils import Vector
from math import sin,cos,pi,sqrt
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=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
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
S.cycles.device='GPU'; S.cycles.use_denoising=True
S.render.image_settings.file_format='PNG'; S.render.image_settings.color_mode='RGBA'
S.view_settings.view_transform='AgX'
PAINTS=[('Race red',[.60,.02,.02]),('Pearl white',[.82,.84,.8]),('Midnight blue',[.012,.035,.19]),('Graphite',[.038,.044,.051]),('British green',[.014,.13,.068]),('Solar yellow',[.92,.49,.014])]
M={}; groups={k:[] for k in ['Frame','Fork','Wheel_F','Wheel_R','Kickstand']}; G='Frame'
def pt(p):return Vector((p[0],-p[1],p[2]))
def act(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def material(name,col,metal=0,rough=.4):
 m=bpy.data.materials.new(name);m.use_nodes=True;m.diffuse_color=(*col,1)
 b=m.node_tree.nodes.get('Principled BSDF');b.inputs['Base Color'].default_value=(*col,1);b.inputs['Metallic'].default_value=metal;b.inputs['Roughness'].default_value=rough
 if name=='Paint':b.inputs['Coat Weight'].default_value=1;b.inputs['Coat Roughness'].default_value=.05
 if name=='Glass':b.inputs['Transmission Weight'].default_value=.35;b.inputs['IOR'].default_value=1.46
 M[name]=m;return m
material('Paint',(.6,.02,.02),.22,.24)
material('Rubber',(.014,.016,.019),0,.7)
material('Alloy',(.52,.55,.59),.94,.25)
material('DarkMetal',(.037,.043,.05),.72,.32)
material('Saddle',(.025,.027,.032),0,.61)
material('Glass',(.15,.21,.25),.05,.12)
material('Lamps',(.9,.85,.7),.1,.18)

def finish(o,name,mat,bevel=0,sub=0):
 o.name=name;o.data.materials.append(M[mat]);groups[G].append(o)
 act(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if bevel:
  md=o.modifiers.new('Machined edge radii','BEVEL');md.width=bevel;md.segments=2
  bpy.ops.object.modifier_apply(modifier=md.name)
 if sub:
  md=o.modifiers.new('Continuous formed surface','SUBSURF');md.levels=sub
  bpy.ops.object.modifier_apply(modifier=md.name)
 for p in o.data.polygons:p.use_smooth=True
 return o

def mesh(name,verts,faces,mat,bevel=0,sub=0):
 me=bpy.data.meshes.new(name);me.from_pydata([pt(v) for v in verts],[],faces);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(name,me);bpy.context.collection.objects.link(o)
 finish(o,name,mat,bevel,sub)
 me=o.data
 uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers[0]
 for poly in me.polygons:
  ax=max(range(3),key=lambda i:abs(poly.normal[i]));a,b=[i for i in range(3) if i!=ax]
  for li in poly.loop_indices:
   v=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(v[a]*2,v[b]*2)
 return o

def sphere(name,pos,scale,mat,seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=pt(pos));o=bpy.context.object;o.scale=scale
 return finish(o,name,mat)

def box(name,pos,size,mat,bevel=.005):
 bpy.ops.mesh.primitive_cube_add(size=1,location=pt(pos));o=bpy.context.object;o.scale=(size[0],size[1],size[2]);return finish(o,name,mat,bevel)

def rod(name,a,b,r,mat,vertices=16,r2=None):
 va,vb=pt(a),pt(b);v=vb-va
 if r2 is None:bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=r,depth=v.length,location=(va+vb)/2)
 else:bpy.ops.mesh.primitive_cone_add(vertices=vertices,radius1=r,radius2=r2,depth=v.length,location=(va+vb)/2)
 o=bpy.context.object;o.rotation_euler=v.to_track_quat('Z','Y').to_euler();finish(o,name,mat,min(r*.12,.002) if r>.006 else 0)
 for poly in o.data.polygons:
  if abs(poly.normal.z)>.995:poly.use_smooth=False
 return o

def beam(name,a,b,width,height,mat):
 va,vb=pt(a),pt(b);v=vb-va
 bpy.ops.mesh.primitive_cube_add(size=1,location=(va+vb)/2);o=bpy.context.object;o.scale=(width,height,v.length);o.rotation_euler=v.to_track_quat('Z','Y').to_euler()
 return finish(o,name,mat,.007)

def tube(name,points,r,mat,res=8):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=12;cu.bevel_depth=r;cu.bevel_resolution=1;cu.resolution_u=2 if len(points)>12 else 6
 sp=cu.splines.new('BEZIER');sp.bezier_points.add(len(points)-1)
 for b,p in zip(sp.bezier_points,points):b.co=pt(p);b.handle_left_type='AUTO';b.handle_right_type='AUTO'
 o=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(o);act(o);bpy.ops.object.convert(target='MESH');return finish(bpy.context.object,name,mat)

def ring(name,c,ro,ri,depth,mat,n=64):
 # axis X
 vs=[]
 for x,r in [(-depth/2,ro),(-depth/2,ri),(depth/2,ro),(depth/2,ri)]:
  vs += [(c[0]+x,c[1]+r*cos(2*pi*i/n),c[2]+r*sin(2*pi*i/n)) for i in range(n)]
 fs=[]
 for i in range(n):
  j=(i+1)%n
  for a,b in [(0,1),(2,0),(1,3),(3,2)]:fs.append((a*n+i,a*n+j,b*n+j,b*n+i))
 return mesh(name,vs,fs,mat,0)

def loft(name,sections,mat,power=.75,n=16,sub=2):
 # forward position, half width, lower, upper
 vs=[]
 for f,w,lo,hi in sections:
  for i in range(n):
   a=2*pi*i/n; xx=math.copysign(abs(cos(a))**power,cos(a));zz=math.copysign(abs(sin(a))**power,sin(a))
   vs.append((w*xx,f,(lo+hi)/2+(hi-lo)/2*zz))
 fs=[tuple(reversed(range(n)))]
 for j in range(len(sections)-1):
  for i in range(n):fs.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
 fs.append(tuple((len(sections)-1)*n+i for i in range(n)))
 return mesh(name,vs,fs,mat,0,sub)

def panel(name,outline,mat,thick=.007,sub=2):
 # supported perimeter, convex internal patch. Polygon can vary across X for sculpted surface.
 c=Vector(tuple(sum(p[i] for p in outline)/len(outline) for i in range(3)));vs=list(outline)
 vs += [tuple(c+(Vector(p)-c)*.90) for p in outline];n=len(outline)
 fs=[tuple(n+i for i in range(n))]
 for i in range(n):fs.append((i,(i+1)%n,n+(i+1)%n,n+i))
 o=mesh(name,vs,fs,mat,0,sub);act(o);md=o.modifiers.new('Panel wall thickness','SOLIDIFY');md.thickness=thick;bpy.ops.object.modifier_apply(modifier=md.name)
 md=o.modifiers.new('Soft panel edge','BEVEL');md.width=.0015;md.segments=2;bpy.ops.object.modifier_apply(modifier=md.name)
 return o

def bolt(name,p,axis='X',r=.006):
 q=list(p);q[0 if axis=='X' else 2]+=.003
 return rod(name,p,q,r,'Alloy',6)

# WHEELS: 17-inch bead seat, true toroidal tyres with integral directional sipes.
for front,f,z,width in [(True,.7,.30,.12),(False,-.7,.315,.18)]:
 G='Wheel_F' if front else 'Wheel_R';N=448;K=40;vs=[]
 rad=.30 if front else .315;minor=.054 if front else .067;major=rad-minor
 for j in range(K):
  ph=2*pi*j/K;x=width*.5*sin(ph);rr=major+minor*cos(ph)
  for i in range(N):
   a=2*pi*i/N;phase=(a/(2*pi)*28+abs(sin(ph))*1.05)%1
   groove=max(0,1-abs(phase-.5)/.18)*.0045*max(0,cos(ph))
   r=rr-groove;vs.append((x,f+r*sin(a),z+r*cos(a)))
 fs=[]
 for j in range(K):
  for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,((j+1)%K)*N+(i+1)%N,((j+1)%K)*N+i))
 mesh('Directional sport tyre',vs,fs,'Rubber')
 ring('Cast alloy rim',(0,f,z),.218,.197,width*.83,'DarkMetal',96)
 for s in [-1,1]:
  ring('Rim rolled lip',(s*width*.43,f,z),.219,.214,.004,'Alloy',96)
  ring('Sidewall bead',(s*width*.45,f,z),.235,.231,.002,'Rubber',96)
 for k in range(5):
  a=2*pi*k/5+.18
  for sign in [-1,1]:
   b=a+sign*.11
   tube('Swept split spoke',[(0,f+.043*sin(a),z+.043*cos(a)),(0,f+.12*sin(a+.09),z+.12*cos(a+.09)),(0,f+.197*sin(b+.18),z+.197*cos(b+.18))],.012,'DarkMetal')
 rod('Axle hub',(-width*.55,f,z),(width*.55,f,z),.041,'Alloy',32)
 # floating drilled rotors with real holes via annular patch topology
 for s in ([-1,1] if front else [-1]):
  x=s*(.079 if front else .105);outer=.151 if front else .116;inner=outer-.032;n=80
  # ring built around each circular hole in a regular annular cell, gives actual voids
  verts=[];faces=[]
  for j in range(n):
   a=2*pi*j/n;b=2*pi*(j+1)/n;am=(a+b)/2;rm=(outer+inner)/2;hr=.0025
   outerpts=[(inner,a),(outer,a),(outer,b),(inner,b)]
   # eight boundary samples and eight hole samples
   boundary=[(inner,a),((inner+outer)/2,a),(outer,a),(outer,(a+b)/2),(outer,b),((inner+outer)/2,b),(inner,b),(inner,(a+b)/2)]
   base=len(verts)
   for r,t in boundary:verts.append((x,f+r*cos(t),z+r*sin(t)))
   for k in range(8):
    t=2*pi*k/8;verts.append((x,f+rm*cos(am)+hr*cos(t+am+pi),z+rm*sin(am)+hr*sin(t+am+pi)))
   # map hole ordering to boundary using rectangle around hole in radial/angular space
   for k,(dr,dt) in enumerate([(-1,-1),(0,-1),(1,-1),(1,0),(1,1),(0,1),(-1,1),(-1,0)]):
    l=sqrt(dr*dr+dt*dt);verts[base+8+k]=(x,f+(rm+hr*dr/l)*cos(am+hr*dt/l/rm),z+(rm+hr*dr/l)*sin(am+hr*dt/l/rm))
   for k in range(8):faces.append((base+k,base+(k+1)%8,base+8+(k+1)%8,base+8+k))
  ob=mesh('Perforated steel brake track',verts,faces,'Alloy');act(ob);md=ob.modifiers.new('Rotor thickness','SOLIDIFY');md.thickness=.003;bpy.ops.object.modifier_apply(modifier=md.name)
  ring('Rotor carrier hub',(x,f,z),.063,.045,.004,'DarkMetal',48)
  for j in range(7):
   a=2*pi*j/7
   rod('Rotor carrier arm',(x,f+.052*sin(a),z+.052*cos(a)),(x,f+(inner+.002)*sin(a+.20),z+(inner+.002)*cos(a+.20)),.008,'DarkMetal',8)
  for poly in ob.data.polygons:poly.use_smooth=False
  for j in range(8):
   a=2*pi*j/8;bolt('Rotor floating bobbin',(x+s*.003,f+(inner+.003)*sin(a),z+(inner+.003)*cos(a)),r=.006)
 # valve
 rod('Tyre valve',(.028,f+.185,z+.085),(.035,f+.205,z+.094),.004,'Rubber')

G='Frame'
# Main twin-spar chassis and engine.
for s in [-1,1]:
 # Hollow cast aluminium main spars: a curved eight-corner beam, not a filled side plate.
 path=[(.115,.43,.815,.075),(.15,.24,.767,.102),(.17,.04,.696,.115),(.18,-.14,.578,.106),(.16,-.22,.435,.075)]
 vv=[];ff=[]
 for x,f,z,h in path:
  for dx,dz in [(-.018,-h*.42),(-.012,-h*.5),(.012,-h*.5),(.023,-h*.34),(.023,h*.34),(.012,h*.5),(-.012,h*.5),(-.018,h*.42)]:vv.append((s*(x+dx),f,z+dz))
 for k in range(len(path)-1):
  for j in range(8):ff.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 ff += [tuple(reversed(range(8))),tuple((len(path)-1)*8+j for j in range(8))]
 mesh('Cast twin spar beam',vv,ff,'Alloy',0,2)
 rod('Swingarm pivot boss',(s*.13,-.21,.435),(s*.182,-.21,.435),.043,'Alloy',32)
 tube('Subframe rail',[(s*.075,-.15,.57),(s*.08,-.37,.76),(s*.075,-.91,.89)],.018,'DarkMetal')
 tube('Engine cradle',[(s*.12,.37,.68),(s*.11,.24,.30),(s*.095,-.16,.28),(s*.115,-.25,.48)],.018,'DarkMetal')
 bolt('Frame pivot fastener',(s*.176,-.21,.45),r=.014)
box('Inline four cylinder block',(0,.12,.47),(.32,.28,.23),'DarkMetal',.022)
box('Cylinder head',(0,.13,.61),(.33,.21,.09),'Alloy',.012)
for i in range(2):box('Casting rib',(0,.13,.438+i*.026),(.342,.231,.009),'DarkMetal',.003)
for s in [-1,1]:
 # Cast casing: shallow convex shoulder, planar machined face, perimeter lugs.
 rod('Engine crankcase', (s*.14,.01,.418),(s*.184,.01,.418),.094,'DarkMetal',48)
 ring('Crankcase machined gasket edge',(s*.187,.01,.418),.092,.084,.004,'Alloy',64)
 rod('Cast cover shoulder',(s*.187,.01,.418),(s*.197,.01,.418),.086,'DarkMetal',64,r2=.075)
 rod('Machined case face',(s*.197,.01,.418),(s*.200,.01,.418),.074,'DarkMetal',64)
 # Off-centre service plug and three raised cast strengthening ribs.
 rod('Service plug seal',(s*.2,.035,.432),(s*.202,.035,.432),.022,'Rubber',32)
 rod('Alternator inspection plug',(s*.202,.035,.432),(s*.204,.035,.432),.019,'Alloy',32)
 box('Inspection plug slot',(s*.205,.035,.432),(.0015,.022,.0028),'DarkMetal',.0005)
 for j in range(3):
  f=-.021-j*.017
  tube('Crankcase cast reinforcement',[(s*.20,f,.384),(s*.202,f,.404),(s*.20,f,.434)],.002,'DarkMetal')
 for j in range(9):
  a=2*pi*j/9;f=.01+.085*cos(a);h=.418+.085*sin(a)
  rod('Cast cover bolt boss',(s*.184,f,h),(s*.199,f,h),.009,'DarkMetal',12)
  rod('Crankcase recessed screw',(s*.199,f,h),(s*.202,f,h),.004,'Alloy',6)
 rod('Oil filler neck',(s*.175,-.04,.494),(s*.208,-.04,.506),.013,'DarkMetal',20)
 rod('Oil filler cap',(s*.208,-.04,.506),(s*.213,-.04,.508),.014,'DarkMetal',16)
# Exhaust header quartet, collector, pipe that rises to the underseat muffler.
for x in [-.105,-.035,.035,.105]:
 tube('Stainless exhaust header',[(x,.235,.60),(x,.34,.50),(x,.36,.31),(x,.20,.205),(x*.5,-.12,.21)],.016,'Alloy')
tube('Exhaust collector and up-pipe',[(0,-.1,.21),(.105,-.27,.30),(.14,-.40,.50),(.14,-.53,.70),(.072,-.64,.804),(0,-.75,.876)],.027,'Alloy')
loft('Underseat exhaust silencer',[(-.98,.073,.88,.945),(-.93,.079,.865,.945),(-.68,.08,.79,.865),(-.62,.065,.79,.85)],'Alloy',.5,16,1)
rod('Dark exhaust outlet',(0,-.96,.918),(0,-.976,.922),.043,'DarkMetal',48)
rod('Exhaust inner bore',(0,-.979,.922),(0,-.981,.922),.031,'Rubber',40)
for f,h in [(-.90,.898),(-.73,.841)]:
 tube('Silencer clamp',[(-.068,f,h-.03),(-.081,f,h),(0,f,h+.042),(.081,f,h),(.068,f,h-.03)],.004,'DarkMetal')
# Underseat exhaust heat shield and chassis-fixed pipe mounting.
for f,h,x in [(-.43,.56,.147),(-.55,.733,.134)]:
 tube('Exhaust heat shield rolled edge',[(x-.018,f-.015,h-.045),(x+.022,f-.012,h-.036),(x+.026,f+.007,h+.039),(x-.012,f+.014,h+.046)],.003,'Alloy')
 tube('Exhaust pipe hanger',[(x,f,h),(x-.04,f+.02,h+.03),(.07,f+.02,h+.05)],.005,'DarkMetal')
# Swingarm, rising-rate rear suspension, drive chain.
for s in [-1,1]:
 beam('Cast box section swingarm',(s*.116,-.2,.405),(s*.135,-.70,.315),.052,.090,'DarkMetal')
 beam('Swingarm upper brace',(s*.118,-.27,.435),(s*.136,-.66,.363),.041,.032,'DarkMetal')
 box('Axle adjuster',(s*.141,-.703,.315),(.029,.058,.038),'Alloy',.004)
 bolt('Axle nut',(s*.159,-.70,.315),r=.012)
rod('Rear shock', (0,-.34,.43),(0,-.29,.70),.019,'Alloy',24)
pts=[]
for i in range(201):
 t=i/200;a=t*2*pi*9;pts.append((.036*cos(a),-.34+.05*t+.036*sin(a),.44+.24*t))
tube('Rear shock coil',pts,.005,'DarkMetal')
# chain and sprockets on rider left
for f,z,r in [(-.7,.315,.105),(-.185,.408,.047)]:
 ring('Chain sprocket',(.157,f,z),r,r*.7,.007,'Alloy',64)
 for j in range(32):
  a=j*2*pi/32;rod('Sprocket tooth',(.153,f+r*sin(a),z+r*cos(a)),(.161,f+r*sin(a),z+r*cos(a)),.005,'Alloy',6)
# capsule chain loop with roller pins; repeated linked side plates
path=[]
for i in range(39):
 t=i/39;path.append((-.7+.515*t,.421+.033*t))
for i in range(13):
 a=pi/2-pi*i/13;path.append((-.185+.047*cos(a),.408+.047*sin(a)))
for i in range(39):
 t=i/39;path.append((-.185-.515*t,.361-.151*t))
for i in range(24):
 a=-pi/2-pi*i/24;path.append((-.7+.105*cos(a),.315+.105*sin(a)))
for i,(f,z) in enumerate(path):
 f2,z2=path[(i+1)%len(path)]
 for x in [.153,.168]:rod('Chain link plate',(x,f,z),(x,f2,z2),.004,'DarkMetal',6)
 rod('Chain roller',(.151,f,z),(.17,f,z),.0033,'Alloy',8)
tube('Chain guard',[(.167,-.73,.449),(.168,-.48,.454),(.16,-.25,.482)],.009,'DarkMetal')
for side in [-1,1]:
 tube('Frame casting rib',[(side*.182,.21,.764),(side*.19,.02,.69),(side*.193,-.17,.548)],.003,'Alloy')
 for f,h in [(.09,.71),(-.18,.55)]:bolt('Frame cast boss',(side*.185,f,h),r=.008)
# radiator body and real cooling matrix
box('Radiator',(0,.40,.56),(.30,.043,.237),'DarkMetal',.013)
for i in range(24):box('Radiator cooling row',(0,.425,.455+i*.0085),(.282,.005,.0023),'Alloy',.0006)
for x in [-.13,-.09,-.045,0,.045,.09,.13]:rod('Radiator grille',(x,.432,.46),(x,.432,.66),.002,'DarkMetal',6)
tube('Coolant hose',[(.14,.37,.61),(.135,.29,.56),(.135,.17,.50)],.013,'Rubber')
# Rear brake hardware follows the fixed swingarm, with a flexible hydraulic line.
box('Rear brake caliper',(-.115,-.632,.391),(.048,.072,.055),'DarkMetal',.007)
beam('Rear caliper carrier',(-.108,-.70,.315),(-.108,-.632,.382),.012,.022,'Alloy')
tube('Rear brake hose',[(-.138,-.63,.408),(-.143,-.48,.432),(-.142,-.29,.461),(-.18,-.26,.414)],.003,'Rubber')
# Sculpted tank, saddle, and tapered tail.
# Authored tank cross-sections: broad forward shoulders, a flat crown and recessed knee flats.
stations=[(-.26,.074,.774,.833),(-.22,.097,.77,.852),(-.13,.147,.768,.937),(-.015,.19,.773,.982),(.16,.204,.791,.994),(.28,.174,.803,.978),(.37,.107,.812,.909),(.39,.067,.816,.862)]
profile=[(0,1),(.58,1),(.93,.81),(1,.54),(.67,.16),(.43,0),(0,0),(-.43,0),(-.67,.16),(-1,.54),(-.93,.81),(-.58,1)]
vv=[];ff=[];n=len(profile)
for f,w,lo,hi in stations:
 for x,t in profile:vv.append((x*w,f,lo+(hi-lo)*t))
ff=[tuple(reversed(range(n)))]
for j in range(len(stations)-1):
 for i in range(n):ff.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
ff.append(tuple((len(stations)-1)*n+i for i in range(n)))
tank=mesh('Shouldered fuel tank',vv,ff,'Paint',0,2)
hit,location,normal,index=tank.ray_cast(Vector((0,-.092,2)),Vector((0,0,-1)));cap_z=location.z if hit else .982
# fuel filler on tank top, orient along Z
rod('Fuel filler surround',(0,.092,cap_z+.001),(0,.092,cap_z+.008),.047,'Alloy',48)
# reorient cap cylinder built along vertical correctly
rod('Fuel filler inset',(0,.092,cap_z+.008),(0,.092,cap_z+.010),.033,'DarkMetal',40)
box('Fuel filler latch',(0,.09,cap_z+.013),(.024,.018,.004),'Alloy',.002)
for j in range(6):
 a=2*pi*j/6;bolt('Fuel cap screw',(.039*cos(a),.092+.039*sin(a),cap_z+.010),'Z',.0025)
loft('Rider saddle',[(-.64,.09,.802,.868),(-.53,.137,.772,.826),(-.32,.137,.752,.804),(-.22,.104,.754,.813),(-.20,.086,.77,.83)],'Saddle',.45,16,2)
loft('Tail fairing',[(-1.05,.018,.955,.976),(-.965,.082,.916,.974),(-.79,.123,.86,.951),(-.64,.126,.808,.895),(-.55,.125,.76,.849),(-.40,.12,.742,.811),(-.26,.102,.755,.79)],'Paint',.52,16,2)
loft('Pillion pad',[(-.985,.035,.973,.988),(-.94,.078,.968,.997),(-.77,.105,.936,.972),(-.694,.095,.912,.941)],'Saddle',.55,16,2)
loft('Tail undertray',[(-.97,.059,.879,.93),(-.85,.09,.847,.91),(-.67,.111,.765,.858),(-.49,.119,.714,.794),(-.32,.105,.699,.763),(-.24,.065,.725,.775)],'DarkMetal',.6,16,2)
# Rear inner fender wraps the street tyre and closes the view into the seat tray.
vv=[];ff=[]
for j in range(17):
 a=-.24+j/16*1.4
 for k in range(11):
  u=-1+2*k/10;rr=.35-.018*u*u;vv.append((.104*u,-.70+rr*sin(a),.315+rr*cos(a)))
for j in range(16):
 for k in range(10):ff.append((j*11+k,j*11+k+1,(j+1)*11+k+1,(j+1)*11+k))
o=mesh('Rear moulded inner fender',vv,ff,'DarkMetal');act(o);md=o.modifiers.new('Hugger moulded wall','SOLIDIFY');md.thickness=.004;bpy.ops.object.modifier_apply(modifier=md.name)
for side in [-1,1]:
 tube('Hugger swingarm mount',[(side*.095,-.48,.60),(side*.123,-.44,.47),(side*.131,-.52,.406)],.006,'DarkMetal')
for s in [-1,1]:
 tube('Seat edge piping',[(s*.11,-.25,.797),(s*.137,-.39,.801),(s*.123,-.54,.821)],.0023,'Saddle')
 # Close the airbox/tank mounting pocket against the frame, with a recessed knee panel.
 panel('Tank knee pocket',[(s*.145,.24,.820),(s*.15,.07,.805),(s*.145,-.14,.804),(s*.125,-.255,.786),(s*.143,-.28,.73),(s*.15,-.18,.685),(s*.15,.035,.723)],'DarkMetal',.010,2)
 # side infill below seat
 panel('Underseat frame infill',[(s*.148,-.16,.763),(s*.143,-.34,.784),(s*.126,-.51,.795),(s*.13,-.54,.725),(s*.143,-.34,.615),(s*.15,-.24,.622)],'DarkMetal',.008,2)
 # One uninterrupted double-curved side shell from nose shoulder to sump return.
 # Shared vertices remove the former upper/lower daylight gap entirely.
 stations=[(.845,.035,.801,.756),(.81,.105,.852,.748),(.73,.181,.889,.716),(.60,.214,.895,.677),(.54,.216,.883,.51),(.45,.217,.853,.335),(.36,.215,.824,.183),(.25,.214,.797,.156),(.12,.208,.744,.153),(-.03,.201,.682,.153),(-.18,.18,.561,.155),(-.30,.143,.285,.159),(-.40,.11,.19,.164)]
 vv=[];ff=[];nc=21
 for f,w,top,bot in stations:
  for j in range(nc):
   t=j/(nc-1)
   xx=w-.032*(1-t)**2+.030*sin(pi*t)-.11*(max(0,(t-.67)/.33)**1.5)
   vv.append((s*max(.004,xx),f,top*(1-t)+bot*t))
 for k in range(len(stations)-1):
  for j in range(nc-1):ff.append((k*nc+j,k*nc+j+1,(k+1)*nc+j+1,(k+1)*nc+j))
 o=mesh('Continuous side fairing',vv,ff,'Paint',0,2);act(o);md=o.modifiers.new('Moulded ABS wall','SOLIDIFY');md.thickness=.005;bpy.ops.object.modifier_apply(modifier=md.name)
 shell=o
 # Genuine apertures with visible wall thickness and the mechanical assemblies behind them.
 for label,outline in [('Radiator hot air outlet',[(.49,.707),(.35,.754),(.16,.684),(.29,.676)]),('Lower cooling aperture',[(.33,.25),(.32,.44),(.265,.285)])]:
  n=len(outline);vv=[(s*x,f,h) for x in [.06,.32] for f,h in outline]
  ff=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
  cutter=mesh('Temporary aperture tool',vv,ff,'DarkMetal',.002);groups[G].remove(cutter)
  act(shell);md=shell.modifiers.new(label,'BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cutter;bpy.ops.object.modifier_apply(modifier=md.name);bpy.data.objects.remove(cutter,do_unlink=True)
 cutter=rod('Temporary crankcase aperture',(s*.10,.01,.418),(s*.32,.01,.418),.101,'DarkMetal',64)
 groups[G].remove(cutter);act(shell);md=shell.modifiers.new('Crankcase access opening','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cutter;bpy.ops.object.modifier_apply(modifier=md.name);bpy.data.objects.remove(cutter,do_unlink=True)
 # Moulded return and concealed mounting tabs join the fairing edge to its spar.
 tube('Fairing upper rolled return',[(s*.198,.43,.843),(s*.197,.31,.812),(s*.185,.14,.746),(s*.176,-.03,.679),(s*.15,-.17,.56)],.0032,'Paint')
 for f,h,x in [(.31,.803,.185),(.13,.728,.174)]:
  rod('Fairing mounting spacer',(s*(x-.015),f,h),(s*(x+.02),f,h),.007,'Rubber',12)
 # Tucked inner radiator duct blocks the see-through outer vent without filling the opening.
 panel('Radiator duct inner wall',[(s*.128,.51,.66),(s*.143,.43,.75),(s*.141,.18,.67),(s*.11,.25,.59)],'DarkMetal',.006,1)
 for f,z,x in [(.52,.671,.227),(.31,.22,.169),(-.16,.23,.14),(.27,.767,.219)]:bolt('Fairing quarter-turn fixing',(s*x,f,z),r=.0035)
 # rearsets: metal triangular heel brackets, knurled pegs and shift/brake controls
 panel('Rearset heel bracket',[(s*.187,-.21,.495),(s*.191,-.38,.445),(s*.191,-.24,.356)],'Alloy',.009,1)
 for j in range(4):rod('Heel guard recess',(s*.199,-.25-j*.015,.446-j*.012),(s*.202,-.25-j*.015,.446-j*.012),.007,'DarkMetal',12)
 rod('Rider footpeg',(s*.18,-.285,.382),(s*.281,-.285,.382),.012,'Alloy',20)
 for j in range(7):ring('Footpeg knurl',(s*(.203+j*.01),-.285,.382),.0126,.010,.002,'DarkMetal',16)
 tube('Foot control lever',[(s*.213,-.28,.375),(s*.225,-.17,.354),(s*.24,-.13,.365)],.006,'Alloy')
 rod('Pedal rubber',(s*.23,-.13,.365),(s*.27,-.13,.365),.009,'Rubber')
 # passenger peg hangers
 # Cast triangular passenger hanger with a genuine central opening and radiused edges.
 outer=[(s*.124,-.51,.793),(s*.183,-.55,.661),(s*.154,-.33,.734)]
 c=Vector(tuple(sum(p[k] for p in outer)/3 for k in range(3)))
 inner=[tuple(c+(Vector(p)-c)*.60) for p in outer]
 vv=[]
 for dx in [-.006,.006]:
  vv += [(x+dx,f,h) for x,f,h in outer+inner]
 ff=[]
 for j in range(3):
  k=(j+1)%3
  ff += [(j,k,k+3,j+3),(j+6,j+9,k+9,k+6),(j,j+6,k+6,k),(j+3,k+3,k+9,j+9)]
 mesh('Cast passenger hanger',vv,ff,'Alloy',.003)
 for x,f,h in outer[:1]+outer[2:]:rod('Passenger hanger bolt',(x,f,h),(x+s*.010,f,h),.006,'Alloy',6)
 rod('Passenger footpeg',(s*.177,-.55,.667),(s*.242,-.55,.667),.009,'Alloy')
# Closely fitted upper/lower panel seam. It sits on the same continuous formed hull.
def shell_x(f,h):
 for i in range(len(stations)-1):
  f0,w0,t0,b0=stations[i];f1,w1,t1,b1=stations[i+1]
  if f1<=f<=f0:
   u=(f0-f)/(f0-f1);w=w0+(w1-w0)*u;top=t0+(t1-t0)*u;bot=b0+(b1-b0)*u;t=max(0,min(1,(top-h)/(top-bot)))
   return w-.032*(1-t)**2+.030*sin(pi*t)-.11*(max(0,(t-.67)/.33)**1.5)
 return .2
for side in [-1,1]:
 shell=next(o for o in groups['Frame'] if o.name.startswith('Continuous side fairing') and o.data.vertices[0].co.x*side>0)
 def surface_x(f,h):
  hit,loc,normal,index=shell.ray_cast(Vector((side*.4,-f,h)),Vector((-side,0,0)))
  return loc.x if hit else side*shell_x(f,h)
 path=[(.50,.66),(.44,.64),(.34,.615),(.23,.599),(.12,.58),(.02,.555),(-.08,.51)]
 points=[]
 for (f0,h0),(f1,h1) in zip(path,path[1:]):
  for j in range(7):
   t=j/7;f=f0+(f1-f0)*t;h=h0+(h1-h0)*t;points.append((surface_x(f,h)+side*.0003,f,h))
 tube('Fairing panel joint',points,.00075,'Rubber')
 for f,h in [(.45,.65),(.24,.61),(.04,.576)]:
  x=surface_x(f,h);rod('Panel captive screw',(x,f,h),(x+side*.002,f,h),.0032,'Alloy',12)
# belly pan closes underside
loft('Belly pan',[(-.40,.025,.16,.18),(-.32,.06,.146,.175),(.22,.10,.144,.176),(.34,.075,.17,.20)],'Paint',.55,16,2)
# nose bridge + distinctive original light housings
loft('Front nose bridge',[(.697,.158,.742,.89),(.765,.157,.741,.859),(.825,.119,.749,.826),(.854,.06,.755,.800),(.858,.009,.766,.782)],'Paint',.65,16,2)
# Recessed lamp apertures, cut through the real fairing shell before mounting lenses.
for side in [-1,1]:
 tangent=Vector((side*.76,-.65,0));normal=Vector((side*.65,.76,0));center=Vector((side*.119,.792,.811))
 outline=[(-.068,-.015),(-.061,.019),(.060,.023),(.072,-.007),(.022,-.023)]
 def lamp_point(u,h,depth=0):return tuple(center+tangent*u+Vector((0,0,h+u*.15))+normal*depth)
 cutvs=[lamp_point(u,h,d) for d in [-.085,.085] for u,h in outline];n=len(outline)
 cutfs=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 cutter=mesh('Temporary lamp aperture tool',cutvs,cutfs,'DarkMetal');groups[G].remove(cutter)
 shells=[o for o in groups[G] if o.name.startswith(('Continuous side fairing','Front nose bridge'))]
 for shell in shells:
  act(shell);mod=shell.modifiers.new('Recessed headlamp aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 bpy.data.objects.remove(cutter,do_unlink=True)
 # Recess wall and narrow, fitted rubber trim surround the glass.
 vv=[lamp_point(u*k,h*k,d) for k,d in [(1.035,.002),(.95,.002),(.95,-.035)] for u,h in outline]
 ff=[]
 for j in range(2):
  for i in range(n):ff.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
 ob=mesh('Headlamp aperture surround',vv,ff,'Rubber',.001,1)
 panel('Headlamp reflector backing',[lamp_point(u*.94,h*.94,-.030) for u,h in outline],'Alloy',.003,1)
 panel('Flush headlamp glazing',[lamp_point(u*.945,h*.945,.001) for u,h in outline],'Glass',.002,1)
 for u in [-.031,.029]:
  cp=Vector(lamp_point(u,0,-.016));ob=sphere('Recessed projector optic',tuple(cp),(.013,.012,.011),'Lamps',24,12);ob['lamp']='head'
  tube('Projector reflector rim',[lamp_point(u+.015*cos(j*2*pi/16),.013*sin(j*2*pi/16),-.016) for j in range(17)],.0018,'Alloy')
panel('Central ram air mouth',[(-.026,.838,.804),(.026,.838,.804),(.029,.85,.771),(-.029,.85,.771)],'DarkMetal',.013,1)
for x in [-.019,-.01,0,.01,.019]:rod('Intake screen bar',(x,.852,.776),(x,.840,.799),.001,'DarkMetal',6)
# Bring the nose back/down against the axle-aligned reference silhouette.
for ob in groups['Frame']:
 if ob.name.startswith(('Continuous side fairing','Front nose bridge','Headlamp','Flush headlamp','Recessed projector','Projector reflector','Central ram','Intake screen')):
  inv=ob.matrix_world.inverted()
  for v in ob.data.vertices:
   w=ob.matrix_world@v.co;f=-w.y;t=max(0,min(1,(f-.55)/.20));w.y+=.045*t;w.z-=.035*t;v.co=inv@w
# Curved smoked screen: narrow pointed foot, rounded shoulders, a swept upper rim.
def screen_edge(t):
 return (.073+.067*sin(pi*t*.82),.708-.255*t,.866+.239*t)
screen=[];faces=[];R=20;C=28
for j in range(R):
 t=j/(R-1);w,f,z=screen_edge(t)
 for i in range(C):
  u=-1+2*i/(C-1)
  screen.append((w*u,f+.030*(1-u*u)*sin(pi*t*.7),z+.035*(1-u*u)*t))
for j in range(R-1):
 for i in range(C-1):faces.append((j*C+i,j*C+i+1,(j+1)*C+i+1,(j+1)*C+i))
o=mesh('Smoked polycarbonate windscreen',screen,faces,'Glass');act(o);md=o.modifiers.new('Screen thickness','SOLIDIFY');md.thickness=.0025;bpy.ops.object.modifier_apply(modifier=md.name)
# Formed instrument-cowl apron between the lamp bridge and windscreen foot.
vv=[];ff=[]
for j in range(7):
 t=j/6
 for k in range(17):
  u=-1+k/8;w=.129*(1-t)+.074*t
  vv.append((u*w,.749*(1-t)+.708*t+.012*(1-u*u)*sin(pi*t),.805*(1-t)+.866*t-.014*u*u*(1-t)))
for j in range(6):
 for k in range(16):ff.append((j*17+k,j*17+k+1,(j+1)*17+k+1,(j+1)*17+k))
o=mesh('Formed front screen apron',vv,ff,'Paint',0,1);act(o);md=o.modifiers.new('Apron wall','SOLIDIFY');md.thickness=.005;bpy.ops.object.modifier_apply(modifier=md.name)
# Narrow double-curved supporting lips. The rear edge leaves the cockpit open.
for s in [-1,1]:
 vv=[];ff=[]
 for k in range(13):
  t=k/12;w,f,z=screen_edge(t);depth=.045+.035*sin(pi*t)
  for j in range(7):
   v=j/6;vv.append((s*(w+.017*sin(pi*v)+.008*v),f-.010*v,z-depth*v))
 for k in range(12):
  for j in range(6):ff.append((k*7+j,k*7+j+1,(k+1)*7+j+1,(k+1)*7+j))
 o=mesh('Sculpted cockpit supporting lip',vv,ff,'Paint',0,2);act(o);md=o.modifiers.new('Cowl lip thickness','SOLIDIFY');md.thickness=.005;bpy.ops.object.modifier_apply(modifier=md.name)
 tube('Screen edge seal',[(s*screen_edge(t)[0],screen_edge(t)[1],screen_edge(t)[2]) for t in [0,.2,.4,.6,.8,1]],.002,'Rubber')
 for t in [.18,.44,.70]:
  w,f,z=screen_edge(t);bolt('Screen fastener',(s*(w+.004),f-.004,z-.012),r=.003)
 tube('Mirror stalk',[(s*.127,.64,.922),(s*.235,.57,1.015),(s*.289,.53,1.032)],.007,'DarkMetal')
 sphere('Mirror mounting foot',(s*.127,.64,.922),(.013,.019,.013),'DarkMetal',16,8)
 sphere('Aerodynamic mirror housing',(s*.299,.53,1.04),(.056,.022,.033),'DarkMetal',24,12)
 sphere('Mirror face',(s*.299,.510,1.042),(.049,.004,.026),'Alloy',24,12)
# tail lighting, indicators, bracket with blank plate
box('Undertail lamp housing',(0,-1.011,.948),(.098,.045,.034),'DarkMetal',.007)
ob=panel('Tail light',[(-.043,-1.035,.958),(.043,-1.035,.958),(.038,-1.038,.938),(-.038,-1.038,.938)],'Lamps',.004,1);ob['lamp']='tail'
# A moulded, ribbed plate carrier, fixed to the undertray at two bolted ears.
beam('Moulded rear fender spine',(0,-.855,.867),(0,-.958,.803),.066,.022,'DarkMetal')
beam('Moulded rear plate neck',(0,-.955,.809),(0,-.976,.742),.047,.017,'DarkMetal')
for side in [-1,1]:
 beam('Rear fender reinforcing rib',(side*.028,-.862,.856),(side*.022,-.965,.777),.006,.012,'DarkMetal')
 rod('Tail fender attachment bolt',(side*.022,-.857,.87),(side*.022,-.857,.882),.004,'Alloy',6)
ob=box('Blank rear plate',(0,-.976,.741),(.145,.008,.105),'DarkMetal',.006);ob.rotation_euler.x=math.radians(-20)
for side in [-1,1]:
 rod('Rear plate securing screw',(side*.053,-.983,.766),(side*.053,-.987,.766),.003,'Alloy',12)
for front in [True,False]:
 for s in [-1,1]:
  f=.51 if front else -.91;h=.70 if front else .844;x=.242 if front else .126
  if not front:rod('Rear indicator mounting stay',(s*.062,f,.888),(s*(x-.03),f,h),.0045,'DarkMetal',12)
  rod('Signal rubber stem',(s*(x-.03),f,h),(s*x,f,h),.006,'Rubber')
  o=sphere('Amber indicator',(s*(x+.012),f,h),(.018,.014,.013),'Lamps',20,10);o['lamp']='amber'
# FRONT STEERING ASSEMBLY
G='Fork'
for s in [-1,1]:
 rod('Inverted fork outer tube',(s*.104,.488,.822),(s*.104,.613,.504),.024,'Alloy',32)
 rod('Fork exposed stanchion',(s*.104,.604,.527),(s*.104,.695,.313),.017,'Alloy',32)
 rod('Fork lower casting',(s*.104,.672,.39),(s*.104,.715,.288),.027,'DarkMetal',24)
 ring('Front axle collar',(s*.124,.70,.30),.022,.009,.012,'Alloy',32)
 # calipers fixed to fork, outside disc at trailing angle
 box('Radial brake caliper',(s*.097,.604,.343),(.047,.058,.091),'DarkMetal',.010)
 for h in [.32,.361]:bolt('Caliper mounting bolt',(s*.123,.60,h),r=.005)
 tube('Braided brake hose',[(s*.125,.608,.36),(s*.13,.58,.60),(s*.123,.47,.82),(s*.20,.4,.907)],.0032,'Rubber')
for h,f in [(.79,.50),(.861,.472)]:box('Triple clamp',(0,f,h),(.26,.056,.023),'DarkMetal',.01)
rod('Steering stem',(0,.46,.87),(0,.49,.79),.031,'Alloy',32)
# front hugger, curved surface conforming to tyre
vs=[];fs=[]
for j in range(19):
 a=-.90+j/18*1.70
 for k in range(13):
  u=-1+2*k/12;rr=.33-.014*u*u;vs.append((.077*u,.70+rr*sin(a),.30+rr*cos(a)))
for j in range(18):
 for k in range(12):fs.append((j*13+k,j*13+k+1,(j+1)*13+k+1,(j+1)*13+k))
o=mesh('Front fender',vs,fs,'Paint');act(o);md=o.modifiers.new('Fender shell','SOLIDIFY');md.thickness=.004;bpy.ops.object.modifier_apply(modifier=md.name)
for s in [-1,1]:
 tube('Fender fork mounting',[(s*.071,.63,.60),(s*.083,.65,.49),(s*.107,.679,.39)],.009,'DarkMetal')
 # clip-ons, grips full outer spread .72 m
 rod('Clip-on clamp',(s*.098,.465,.857),(s*.145,.442,.863),.025,'DarkMetal',24)
 rod('Handlebar',(s*.13,.442,.863),(s*.348,.368,.893),.011,'Alloy',24)
 rod('Textured grip',(s*.235,.404,.878),(s*.348,.368,.893),.017,'Rubber',32)
 rod('Bar end weight',(s*.348,.368,.893),(s*.355,.364,.895),.016,'DarkMetal',24)
 box('Switchgear',(s*.217,.407,.883),(.035,.043,.037),'DarkMetal',.006)
 box('Switch button',(s*.217,.391,.904),(.009,.014,.005),'Saddle',.002)
 tube('Brake or clutch lever',[(s*.191,.424,.88),(s*.236,.452,.894),(s*.315,.429,.901),(s*.345,.411,.895)],.005,'Alloy')
 sphere('Lever ball',(s*.345,.411,.895),(.007,.007,.007),'Alloy',12,8)
 tube('Control cable',[(s*.21,.416,.876),(s*.15,.37,.842),(s*.075,.37,.77)],.003,'Rubber')
rod('Brake fluid reservoir',(.173,.438,.915),(.173,.438,.948),.018,'Saddle',24)
rod('Reservoir cap',(.173,.438,.948),(.173,.438,.951),.020,'DarkMetal',24)
# Instruments face toward rider, no text
ob=box('Instrument pod',(0,.485,.936),(.147,.041,.081),'DarkMetal',.013);ob.rotation_euler.x=math.radians(25)
ob=box('Instrument glass',(0,.465,.942),(.125,.005,.059),'Glass',.008);ob.rotation_euler.x=math.radians(25)
# Clip-on drop angle is applied to all control fittings together.
for ob in groups['Fork']:
 if ob.name.startswith(('Clip-on','Handlebar','Textured grip','Bar end','Switch','Brake or clutch lever','Lever ball','Control cable')):
  inv=ob.matrix_world.inverted()
  for v in ob.data.vertices:
   w=ob.matrix_world@v.co;w.z-=.26*max(0,abs(w.x)-.13);v.co=inv@w
# retracted side stand
G='Kickstand'
rod('Stand pivot',(.15,-.16,.274),(.18,-.16,.274),.014,'Alloy',20)
tube('Retracted kickstand',[(.171,-.16,.274),(.185,-.33,.254),(.194,-.48,.254)],.009,'DarkMetal')
box('Stand foot',(.194,-.48,.254),(.045,.043,.013),'DarkMetal',.004)
rod('Stand return spring',(.165,-.19,.283),(.174,-.34,.274),.005,'Alloy',12)
G='Frame'

# Join into the five contract nodes and set exact articulation pivots.
origins={'Frame':(0,0,0),'Fork':(0,.475,.830),'Wheel_F':(0,.7,.30),'Wheel_R':(0,-.7,.315),'Kickstand':(.171,-.16,.274)}
# Assign Lamps UV band before joining; one material, three lamp colours.
for obs in groups.values():
 for o in obs:
  if 'lamp' in o:
   band={'head':.16,'tail':.50,'amber':.84}[o['lamp']]
   if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
   for v in o.data.uv_layers.active.data:v.uv=(band,.5)
assets=[]
for name,obs in groups.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 S.cursor.location=pt(origins[name]);bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Weld matching construction seams before budget reduction.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=1e-7);bm.to_mesh(o.data);bm.free()
 # Bound the evaluated assembly, including bevels; no export-only hidden subdivision.
 o.data.calc_loop_triangles();count=len(o.data.loop_triangles)
 target={'Frame':36000,'Fork':7500,'Wheel_F':17000,'Wheel_R':18000,'Kickstand':500}[name]
 if count>target:
  md=o.modifiers.new('Game mesh budget','DECIMATE');md.ratio=target/count;md.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=md.name)
 print('ASSEMBLY',name,'source triangles',count,'target',target,flush=True)
 # Remove zero-area faces before export.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.dissolve_degenerate(bm,dist=.0000001,edges=list(bm.edges));bmesh.ops.recalc_face_normals(bm,faces=bm.faces)
 # Split the shading at actual folded edges and thin shell returns. This prevents
 # front and back wall normals from contaminating the large painted surfaces.
 for e in bm.edges:
  if e.is_manifold:
   shell=any(o.data.materials[f.material_index].name in ['Paint','Glass'] for f in e.link_faces)
   e.smooth=e.calc_face_angle(0)<math.radians(38 if shell else 75)
 bm.to_mesh(o.data);bm.free()
 # Keep the contact patch and contractual outer dimensions exact after reduction.
 for v in o.data.vertices:
  w=o.matrix_world@v.co
  if name.startswith('Wheel') and w.z<.0001:v.co.z=-o.location.z
  if name=='Fork':v.co.x=max(-.36,min(.36,v.co.x))
  if name=='Frame' and w.z>1.14:v.co.z=1.14
  if name=='Frame' and w.y>1.0:v.co.y=min(1.05,v.co.y)
 o.data.update()
 assets.append(o)
fork=bpy.data.objects['Fork'];wf=bpy.data.objects['Wheel_F'];mw=wf.matrix_world.copy();wf.parent=fork;wf.matrix_world=mw
sockets={'Seat_Rider':(0,-.395,.82),'Hand_L':(.295,.386,.8431),'Hand_R':(-.295,.386,.8431),'Foot_L':(.255,-.285,.382),'Foot_R':(-.255,-.285,.382),'Light_Head':(0,.798,.780),'Light_Tail':(0,-1.039,.948)}
for name,pos in sockets.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pt(pos);o.empty_display_size=.035
 if name.startswith('Hand'):
  bpy.context.view_layer.update();mw=o.matrix_world.copy();o.parent=fork;o.matrix_world=mw
 assets.append(o)
S.cursor.location=(0,0,0)
# Studio setup, excluded from export.
world=bpy.data.worlds.new('Soft daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.67,.74,.86,1);world.node_tree.nodes['Background'].inputs[1].default_value=.42;S.world=world
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.name='Studio ground';ground.location.z=-.001
m=bpy.data.materials.new('Studio');m.use_nodes=True;m.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.205,.23,.265,1);m.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8;ground.data.materials.append(m)
def area(name,loc,power,size,color):
 bpy.ops.object.light_add(type='AREA',location=pt(loc));o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.data.color=color;o.rotation_euler=(pt((0,0,.5))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight key',(2.4,2.5,4.1),280,3.5,(1,.96,.92));area('Cool fill',(-2,1.3,2.1),160,3,(.80,.87,1));area('Tail rim',(1,-2.5,3),320,3,(1,1,1))
bpy.ops.object.camera_add();cam=bpy.context.object;S.camera=cam
hero=(3.4,2.9,1.7)
def camera(loc,ortho=None):
 cam.location=pt(loc);cam.rotation_euler=(pt((0,0,.57))-cam.location).to_track_quat('-Z','Y').to_euler()
 cam.data.type='ORTHO' if ortho else 'PERSP';cam.data.lens=57
 if ortho:cam.data.ortho_scale=ortho

def render(name,w=1920,h=1080,samples=24):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
camera(hero)
clay=bpy.data.materials.new('Inspection grey');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.36,.36,.36,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.5
S.view_layers[0].material_override=clay;render('clay.png',1920,1080,16);S.view_layers[0].material_override=None
print('GEOMETRY_COMPLETE',flush=True)

# Photographic CC0 sources, fetched reproducibly. Download all matching maps.
IDS=['blue_metal_plate','fabric_leather_02','rubber_tiles']
for id in IDS:
 for suffix in ['diff','nor_gl','arm']:
  path=TEX/f'{id}_{suffix}_1k.jpg'
  if not path.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{suffix}_1k.jpg'
   req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
   with urllib.request.urlopen(req,timeout=90) as r:path.write_bytes(r.read())
print('PHOTOS_READY',flush=True)
# Bake seven reusable tiled recipes. The metallic materials intentionally share the same scan.
# Cycles emission passes capture albedo/ORM; NORMAL captures the complete scan+micro-bump recipe.
recipes={'Paint':('blue_metal_plate',(.92,.92,.92),.12,.32,.006),'Rubber':('rubber_tiles',(.022,.025,.029),0,.71,.28),'Alloy':('blue_metal_plate',(.38,.41,.45),.92,.31,.035),'DarkMetal':('blue_metal_plate',(.041,.047,.054),.76,.35,.06),'Saddle':('fabric_leather_02',(.032,.034,.039),0,.60,.42),'Glass':('blue_metal_plate',(.26,.32,.36),0,.09,0),'Lamps':('blue_metal_plate',(1,1,1),.05,.21,.02)}
# Put the bake fixture away from the motorcycle, then delete it.
bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-10));fixture=bpy.context.object
baked={}
for name,(id,col,metal,rough,strength) in recipes.items():
 source=bpy.data.materials.new('SOURCE_'+name);source.use_nodes=True;n=source.node_tree.nodes;l=source.node_tree.links;n.clear()
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord');imgs={}
 for suffix in ['diff','nor_gl','arm']:
  t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(TEX/f'{id}_{suffix}_1k.jpg'),check_existing=True)
  if suffix!='diff':t.image.colorspace_settings.name='Non-Color'
  l.new(uv.outputs['UV'],t.inputs['Vector']);imgs[suffix]=t
 bw=n.new('ShaderNodeRGBToBW');l.new(imgs['diff'].outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.8
 variation=.09 if name in ['Paint','Glass'] else .32
 ramp.color_ramp.elements[0].color=(*(c*(1-variation) for c in col),1);ramp.color_ramp.elements[1].color=(*col,1);l.new(bw.outputs[0],ramp.inputs[0]);color=ramp.outputs[0]
 if name=='Lamps':
  sep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],sep.inputs[0]);ramp2=n.new('ShaderNodeValToRGB');ramp2.color_ramp.interpolation='CONSTANT'
  ramp2.color_ramp.elements.remove(ramp2.color_ramp.elements[1]);ramp2.color_ramp.elements[0].color=(1,.88,.67,1)
  e=ramp2.color_ramp.elements.new(.333);e.color=(.65,.004,.003,1);e=ramp2.color_ramp.elements.new(.666);e.color=(1,.22,.005,1);l.new(sep.outputs[0],ramp2.inputs[0]);color=ramp2.outputs[0]
 l.new(color,bs.inputs['Base Color'])
 norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=strength;l.new(imgs['nor_gl'].outputs[0],norm.inputs['Color'])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=180;noise.inputs['Detail'].default_value=2;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.1;bump.inputs['Distance'].default_value=.00004 if name=='Paint' else .00045;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(norm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(imgs['arm'].outputs[0],sep.inputs[0]);rmath=n.new('ShaderNodeMath');rmath.operation='MULTIPLY_ADD';rmath.inputs[1].default_value=.12;rmath.inputs[2].default_value=max(.04,rough-.06);l.new(sep.outputs['Green'],rmath.inputs[0]);l.new(rmath.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 fixture.data.materials.clear();fixture.data.materials.append(source);act(fixture);S.cycles.samples=4;S.render.bake.margin=4
 target=n.new('ShaderNodeTexImage');n.active=target
 ims={}
 for kind,res in [('base',1024),('normal',1024),('orm',512)]:
  if name=='Lamps':res=128 if kind!='orm' else 64
  image=bpy.data.images.new(name+'_'+kind,res,res,alpha=False);image.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';target.image=image;n.active=target
  if kind=='normal':l.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   em=n.new('ShaderNodeEmission')
   if kind=='base':l.new(color,em.inputs[0])
   else:
    comb=n.new('ShaderNodeCombineColor');comb.inputs['Red'].default_value=1;comb.inputs['Blue'].default_value=metal;l.new(rmath.outputs[0],comb.inputs['Green']);l.new(comb.outputs[0],em.inputs[0])
   l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  image.filepath_raw=str(TEX/(name+'_'+kind+'.png'));image.file_format='PNG';image.save();ims[kind]=image
 # Runtime portable material: standard glTF texture sockets only.
 mat=M[name];nn=mat.node_tree.nodes;ll=mat.node_tree.links;nn.clear();out2=nn.new('ShaderNodeOutputMaterial');p=nn.new('ShaderNodeBsdfPrincipled');ll.new(p.outputs[0],out2.inputs[0]);texnodes={}
 for kind in ims:
  t=nn.new('ShaderNodeTexImage');t.image=ims[kind];texnodes[kind]=t
 ll.new(texnodes['base'].outputs[0],p.inputs['Base Color'])
 nm=nn.new('ShaderNodeNormalMap');ll.new(texnodes['normal'].outputs[0],nm.inputs[0 if False else 'Color']);ll.new(nm.outputs[0],p.inputs['Normal'])
 sp=nn.new('ShaderNodeSeparateColor');ll.new(texnodes['orm'].outputs[0],sp.inputs[0]);ll.new(sp.outputs['Green'],p.inputs['Roughness']);ll.new(sp.outputs['Blue'],p.inputs['Metallic'])
 # glTF occlusion group.
 ng=bpy.data.node_groups.get('glTF Material Output')
 if ng is None:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=nn.new('ShaderNodeGroup');gn.node_tree=ng;ll.new(sp.outputs['Red'],gn.inputs['Occlusion'])
 if name=='Paint':p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
 if name=='Glass':p.inputs['Transmission Weight'].default_value=1;p.inputs['IOR'].default_value=1.46
 if name=='Lamps':ll.new(texnodes['base'].outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=3
 baked[name]=(texnodes['base'],p)
 print('BAKED',name,flush=True)
bpy.data.objects.remove(fixture,do_unlink=True)
# Export with neutral Paint image; insert the required factor into the serialized glTF JSON.
bpy.ops.object.select_all(action='DESELECT')
for o in assets:o.select_set(True)
bpy.context.view_layer.objects.active=bpy.data.objects['Frame']
export=ROOT/'sport-bike.glb'
bpy.ops.export_scene.gltf(filepath=str(export),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False)
data=export.read_bytes();jl,jt=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jl]);binary=data[20+jl:]
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[.60,.02,.02,1]
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);export.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+binary)
# Metadata in exported coordinates; transform properties expressed in metres.
def gl(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
bpy.context.view_layer.update();pts=[o.matrix_world@Vector(c) for o in assets if o.type=='MESH' for c in o.bound_box]
lo=[min(gl(p)[i] for p in pts) for i in range(3)];hi=[max(gl(p)[i] for p in pts) for i in range(3)]
tri=sum(len(o.data.loop_triangles) for o in [] )
tri=0
for o in assets:
 if o.type=='MESH':o.data.calc_loop_triangles();tri+=len(o.data.loop_triangles)
meta={'units':'metres','axes':{'front':'+Z','up':'+Y','left':'+X'},'nodes':{o.name:{'position':gl(o.matrix_world.translation),'type':o.type,'parent':o.parent.name if o.parent else None} for o in assets},'bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'paints':[{'name':n,'linear_rgb':v} for n,v in PAINTS],'steering_axis':[0,.936329,-.351123],'wheel_spin_axis':[1,0,0],'triangles':tri,'material_count':7,'glb_bytes':export.stat().st_size}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(['Original-faced fully faired 600 cc sport motorcycle reconstructed from sheet.jpg, metre scale, articulated game nodes.',f'Triangles: {tri:,}; GLB: {export.stat().st_size/1048576:.2f} MiB.','Materials: Paint, Rubber, Alloy, DarkMetal, Saddle, Glass, Lamps (7).','Cycles GPU baked base/normal 1024 px, ORM 512 px; Lamps 128/64 px; shared tiled material sets.','Poly Haven CC0 ids: blue_metal_plate, fabric_leather_02, rubber_tiles; https://polyhaven.com/license .','Differences: original lamp layout and race-red default; simplified internal engine and chain; panel contours and windscreen rake remain an approximation.'])+'\n')
print('FIRST_COMPLETE_GLB',tri,export.stat().st_size,flush=True)
# Paint multiplier for rendering, matching the serialized baseColorFactor.
t,p=baked['Paint'];n=M['Paint'].node_tree.nodes;l=M['Paint'].node_tree.links
mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;l.new(t.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color'])
mix.inputs[2].default_value=(*PAINTS[0][1],1)
camera(hero);render('final.png',1920,1080,32)
camera((2.7,-3.4,1.65));render('rear.png',1280,720,24)
camera((2.1,1.6,1.05));render('close.png',1920,1080,24)
camera((3.4,0,.57),2.48);render('side.png',1920,1080,24)
camera(hero)
# Grid assembled using Blender image buffers; same camera/light and exposure in all six.
import numpy as np
cells=[]
for i,(name,col) in enumerate(PAINTS):
 mix.inputs[2].default_value=(*col,1);render(f'paint_{i}.png',640,360,12)
 im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'),check_existing=False);a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);cells.append(a.reshape(360,640,4));bpy.data.images.remove(im)
out=np.zeros((720,1920,4),dtype=np.float32)
for i,a in enumerate(cells):out[(1-i//3)*360:(2-i//3)*360,(i%3)*640:(i%3+1)*640]=a
im=bpy.data.images.new('Paint variants',1920,720,alpha=True);im.pixels.foreach_set(out.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
mix.inputs[2].default_value=(*PAINTS[0][1],1)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'sport-bike.blend'))
print('COMPLETE',json.dumps(meta),flush=True)
