"""Clean procedural scooter build. Blender 5.2; CC0 Poly Haven inputs only.
Run: blender -b --python-exit-code 1 --python build_city-scooter.py
Coordinates: Blender X=left, -Y=front, Z=up; glTF X=left,Y=up,Z=front.
"""
import bpy, math, os, json, struct, subprocess, sys, time
import numpy as np
from mathutils import Vector
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences
try: prefs.compute_device_type='OPTIX'; prefs.get_devices()
except: prefs.compute_device_type='CUDA'; prefs.get_devices()
for d in prefs.devices: d.use=d.type!='CPU'
sc.cycles.device='GPU'; sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'; sc.world.color=(.22,.22,.22)
sc.view_settings.view_transform='AgX'
# Execution preflight on the same GPU, exporter and importer as the build.
bpy.ops.mesh.primitive_cube_add(size=.2)
probe=bpy.context.object
pm=bpy.data.materials.new('Preflight');pm.use_nodes=True;probe.data.materials.append(pm)
probe_image=bpy.data.images.new('Preflight bake',width=64,height=64)
pn=pm.node_tree.nodes.new('ShaderNodeTexImage');pn.image=probe_image;pm.node_tree.nodes.active=pn
sc.cycles.samples=2;sc.render.bake.use_pass_direct=False;sc.render.bake.use_pass_indirect=False
bpy.ops.object.bake(type='DIFFUSE')
bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',use_selection=True)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'))
assert any(o.type=='MESH' for o in sc.objects)
bpy.ops.object.camera_add(location=(.5,-.5,.4));sc.camera=bpy.context.object;sc.camera.rotation_euler=(-sc.camera.location).to_track_quat('-Z','Y').to_euler()
sc.render.resolution_x=64;sc.render.resolution_y=64;sc.render.resolution_percentage=100;sc.render.filepath=str(ROOT/'preflight.png');bpy.ops.render.render(write_still=True)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
sc.cycles.samples=16
print('PREFLIGHT GPU bake/export/import/render OK',flush=True)
GROUPS={k:[] for k in ['Frame','Fork','Wheel_F','Wheel_R','Kickstand']}
M={}
def active(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def finish(o,mat='Paint',group='Frame',smooth=True):
 o.data.materials.clear(); o.data.materials.append(M[mat]); GROUPS[group].append(o)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 return o

def apply(o):
 active(o); bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 for mod in list(o.modifiers): bpy.ops.object.modifier_apply(modifier=mod.name)

def mesh(name,verts,faces,mat='Paint',group='Frame',uv=None):
 me=bpy.data.meshes.new(name); me.from_pydata(verts,[],faces); me.update()
 ob=bpy.data.objects.new(name,me); sc.collection.objects.link(ob); finish(ob,mat,group)
 layer=me.uv_layers.new(name='UVMap')
 for p in me.polygons:
  for li in p.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co
   layer.data[li].uv=uv[me.loops[li].vertex_index] if uv else (co.y*1.7,co.z*1.7)
 return ob

def bevel(o,w=.003,n=3):
 m=o.modifiers.new('Rolled manufactured edge','BEVEL');m.width=w;m.segments=n
 apply(o); return o

def box(name,loc,dim,mat='Paint',group='Frame',bev=.008):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=dim;apply(o);finish(o,mat,group)
 return bevel(o,bev,3)

def ell(name,loc,scale,mat='Paint',group='Frame',seg=40,rings=20):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;apply(o);return finish(o,mat,group)

def rod(name,a,b,r,mat='Chrome',group='Frame',n=16):
 a,b=Vector(a),Vector(b);bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=(b-a).length,location=(a+b)/2)
 o=bpy.context.object;o.name=name;o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler();apply(o);finish(o,mat,group);return bevel(o,min(r*.18,.0015),2)

def tube(name,pts,r,mat='Chrome',group='Frame',closed=False,n=8):
 # parallel rings along an authored polyline; dense paths are supplied where curvature matters
 vs=[];fs=[];uv=[];N=len(pts)
 for j,p in enumerate(pts):
  p=Vector(p);t=Vector(pts[(j+1)%N])-Vector(pts[(j-1)%N]) if closed else Vector(pts[min(N-1,j+1)])-Vector(pts[max(0,j-1)])
  t.normalize();ref=Vector((1,0,0)) if abs(t.x)<.9 else Vector((0,1,0));u=t.cross(ref).normalized();v=t.cross(u).normalized()
  for k in range(n):vs.append(p+r*(u*cos(2*pi*k/n)+v*sin(2*pi*k/n)));uv.append((k/n,j/max(N-1,1)))
 for j in range(N if closed else N-1):
  for k in range(n):fs.append((j*n+k,j*n+(k+1)%n,((j+1)%N)*n+(k+1)%n,((j+1)%N)*n+k))
 if not closed:fs.extend([tuple(reversed(range(n))),tuple((N-1)*n+k for k in range(n))])
 return mesh(name,vs,fs,mat,group,uv)

def ringx(name,cy,cz,r,minor,x=0,mat='Chrome',group='Frame',N=64,n=8):
 return tube(name,[(x,cy+r*cos(2*pi*j/N),cz+r*sin(2*pi*j/N)) for j in range(N)],minor,mat,group,True,n)

def download(id):
 out={}
 for typ in ['diff','nor_gl','arm']:
  remote_typ='albedo' if id=='brown_leather' and typ=='diff' else typ
  p=TEX/f'{id}_{typ}_1k.jpg'
  if not p.exists():subprocess.run(['curl','-fL','--retry','2','-A','Mozilla/5.0','-sS',f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{remote_typ}_1k.jpg','-o',str(p)],check=True)
  out[typ]=bpy.data.images.load(str(p),check_existing=True)
  if typ!='diff':out[typ].colorspace_settings.name='Non-Color'
 return out

print('START texture sources',flush=True)
sources={i:download(i) for i in ['metal_plate','brown_leather','rubber_tiles']}

def source_material(name,id,col,rough,metal,normal_strength):
 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=sources[id]['diff']
 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].position=.1;ramp.color_ramp.elements[1].position=.85
 ramp.color_ramp.elements[0].color=tuple(c*.72 for c in col)+(1,);ramp.color_ramp.elements[1].color=(*col,1)
 noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=170;noise.inputs['Detail'].default_value=2
 mix=N.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.055;L.new(ramp.outputs[0],mix.inputs[1]);L.new(noise.outputs['Fac'],mix.inputs[2]);L.new(mix.outputs[0],bs.inputs['Base Color'])
 arm=N.new('ShaderNodeTexImage');arm.image=sources[id]['arm'];sep=N.new('ShaderNodeSeparateColor');L.new(arm.outputs[0],sep.inputs[0])
 rmath=N.new('ShaderNodeMath');rmath.operation='MULTIPLY_ADD';L.new(sep.outputs[1],rmath.inputs[0]);rmath.inputs[1].default_value=.13;rmath.inputs[2].default_value=rough-.065;L.new(rmath.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 norm=N.new('ShaderNodeTexImage');norm.image=sources[id]['nor_gl'];nm=N.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=normal_strength;L.new(norm.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
 if name=='Leather':
  coords=N.new('ShaderNodeTexCoord');mapping=N.new('ShaderNodeVectorMath');mapping.operation='SCALE';mapping.inputs[3].default_value=4;L.new(coords.outputs['UV'],mapping.inputs[0])
  for scan in [tex,arm,norm]:L.new(mapping.outputs[0],scan.inputs['Vector'])
 # Material result is flattened by Cycles into a reusable tiled set.
 bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(m);active(plane)
 target=N.new('ShaderNodeTexImage');N.active=target
 result={}
 for typ,size in [('BaseColor',1024),('Normal',1024),('ORM',512)]:
  im=bpy.data.images.new(name+'_'+typ,width=size,height=size,alpha=False);im.colorspace_settings.name='sRGB' if typ=='BaseColor' else 'Non-Color';target.image=im;N.active=target
  if typ=='BaseColor':
   em=N.new('ShaderNodeEmission');L.new(mix.outputs[0],em.inputs[0]);L.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT');L.new(bs.outputs[0],out.inputs['Surface'])
  elif typ=='Normal':bpy.ops.object.bake(type='NORMAL')
  else:
   comb=N.new('ShaderNodeCombineColor');L.new(sep.outputs[0],comb.inputs[0]);L.new(rmath.outputs[0],comb.inputs[1]);comb.inputs[2].default_value=metal
   em=N.new('ShaderNodeEmission');L.new(comb.outputs[0],em.inputs[0]);L.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT');L.new(bs.outputs[0],out.inputs['Surface'])
  im.filepath_raw=str(TEX/(name+'_'+typ+'.png'));im.file_format='PNG';im.save();result[typ]=im
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear();out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0])
 for typ,im in result.items():
  t=N.new('ShaderNodeTexImage');t.image=im;t.name=typ
  if typ=='BaseColor':L.new(t.outputs[0],bs.inputs['Base Color'])
  elif typ=='Normal':nm=N.new('ShaderNodeNormalMap');L.new(t.outputs[0],nm.inputs[1]);L.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sp=N.new('ShaderNodeSeparateColor');L.new(t.outputs[0],sp.inputs[0]);L.new(sp.outputs[1],bs.inputs['Roughness']);L.new(sp.outputs[2],bs.inputs['Metallic'])
   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')
   g=N.new('ShaderNodeGroup');g.node_tree=ng;L.new(sp.outputs[0],g.inputs['Occlusion'])
 if name=='Paint':bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 M[name]=m
 print('BAKED',name,flush=True)

for args in [('Paint','metal_plate',(.98,.98,.98),.29,.05,.004),('Rubber','rubber_tiles',(.022,.026,.029),.77,0,.35),('Chrome','metal_plate',(.66,.69,.72),.23,1,.008),('Leather','brown_leather',(.009,.011,.013),.6,0,.17),('DarkMetal','metal_plate',(.075,.081,.085),.42,.72,.22)]:source_material(*args)
# Three coloured lens bands, one emissive material. Bake colour and ORM through Cycles.
m=bpy.data.materials.new('Lamps');m.use_nodes=True;nt=m.node_tree;N=nt.nodes;L=nt.links;bs=N.get('Principled BSDF');out=N.get('Material Output')
uv=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(uv.outputs['UV'],sep.inputs[0]);ramp=N.new('ShaderNodeValToRGB');L.new(sep.outputs['X'],ramp.inputs[0]);cr=ramp.color_ramp;cr.interpolation='CONSTANT';cr.elements.remove(cr.elements[1]);cr.elements[0].color=(.95,.90,.74,1);cr.elements.new(.34).color=(.48,.008,.004,1);cr.elements.new(.67).color=(1,.19,.005,1);L.new(ramp.outputs[0],bs.inputs['Base Color']);L.new(ramp.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.05;bs.inputs['Transmission Weight'].default_value=.55;bs.inputs['IOR'].default_value=1.5;bs.inputs['Roughness'].default_value=.2
bpy.ops.mesh.primitive_plane_add();p=bpy.context.object;p.data.materials.append(m);active(p)
ims={}
for typ in ['BaseColor','Normal','ORM']:
 im=bpy.data.images.new('Lamps_'+typ,width=128,height=128,alpha=False);im.colorspace_settings.name='sRGB' if typ=='BaseColor' else 'Non-Color';t=N.new('ShaderNodeTexImage');t.image=im;N.active=t
 if typ=='BaseColor':bpy.ops.object.bake(type='DIFFUSE')
 elif typ=='Normal':bpy.ops.object.bake(type='NORMAL')
 else:
  em=N.new('ShaderNodeEmission');em.inputs[0].default_value=(1,.15,.18,1);L.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');L.new(bs.outputs[0],out.inputs[0])
 im.filepath_raw=str(TEX/('Lamps_'+typ+'.png'));im.file_format='PNG';im.save();ims[typ]=im
bpy.data.objects.remove(p,do_unlink=True);N.clear();out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0]);t=N.new('ShaderNodeTexImage');t.image=ims['BaseColor'];L.new(t.outputs[0],bs.inputs['Base Color']);L.new(t.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.05;bs.inputs['Transmission Weight'].default_value=.55;bs.inputs['IOR'].default_value=1.5;bs.inputs['Roughness'].default_value=.2
tn=N.new('ShaderNodeTexImage');tn.image=ims['Normal'];nm=N.new('ShaderNodeNormalMap');L.new(tn.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
to=N.new('ShaderNodeTexImage');to.image=ims['ORM'];sp=N.new('ShaderNodeSeparateColor');L.new(to.outputs[0],sp.inputs[0]);L.new(sp.outputs[1],bs.inputs['Roughness']);L.new(sp.outputs[2],bs.inputs['Metallic']);g=N.new('ShaderNodeGroup');g.node_tree=bpy.data.node_groups['glTF Material Output'];L.new(sp.outputs[0],g.inputs['Occlusion'])
M['Lamps']=m

def lampuv(o,band):
 for d in o.data.uv_layers.active.data:d.uv=(band,.5)
 return o

# Tyres: 12-inch rim; 450 mm rolling diameter. Cross section deliberately flattened at tread.
for name,cy in [('Wheel_F',-.65),('Wheel_R',.65)]:
 prof=[(-.054,.159),(-.065,.181),(-.063,.203),(-.045,.218),(-.021,.225),(.021,.225),(.045,.218),(.063,.203),(.065,.181),(.054,.159)]
 vs=[];fs=[];uv=[];N=96
 for j in range(N):
  a=2*pi*j/N
  for k,(x,r) in enumerate(prof):vs.append((x,cy+r*cos(a),.225+r*sin(a)));uv.append((j/N*4,k/(len(prof)-1)))
 for j in range(N):
  for k in range(len(prof)-1):a=j*len(prof)+k;b=((j+1)%N)*len(prof)+k;fs.append((a,b,b+1,a+1))
 mesh(name+' tyre carcass',vs,fs,'Rubber',name,uv)
 # Thin raised tread blocks separated by convincing diagonal grooves.
 for j in range(64):
  a=2*pi*j/64
  for side in [-1,1]:
   pts=[]
   for k in range(4):
    x=side*(.005+k*.014);aa=a+side*k*.012;r=.2226-.006*(abs(x)/.05)**2
    pts.append((x,cy+r*cos(aa),.225+r*sin(aa)))
   tube('Moulded tread',pts,.0024,'Rubber',name,n=5)
 for s in [-1,1]:
  ringx('Tyre mould line',cy,.225,.187,.0016,s*.064,'Rubber',name)
  ringx('Rolled steel rim',cy,.225,.153,.007,s*.055,'Chrome',name)
  ringx('Rim lip',cy,.225,.137,.003,s*.065,'Chrome',name)
  rod('Pressed wheel dish',(s*.018,cy,.225),(s*.062,cy,.225),.148,'Chrome',name,64)
  ell('Domed drum cover',(s*.066,cy,.225),(.016,.091,.091),'Chrome',name,40,16)
  for j in range(10):
   a=2*pi*j/10;yy=cy+.11*cos(a);zz=.225+.11*sin(a)
   rod('Rim fastener',(s*.061,yy,zz),(s*.070,yy,zz),.005,'DarkMetal',name,6)
  rod('Axle cap',(s*.071,cy,.225),(s*.084,cy,.225),.022,'Chrome',name,32)
  rod('Valve',(s*.058,cy+.119,.30),(s*.071,cy+.126,.307),.003,'Rubber',name,8)

# Front mudguard with a rolled rim; construction shell, no intersecting sphere.
vs=[];uv=[];fs=[];NY=40;NX=20
for j in range(NY+1):
 t=j/NY;y=-.9225+.5475*t
 for k in range(NX+1):
  u=2*k/NX-1;w=.075+.054*sin(pi*t)**.5;z=.42-.07*t+.17*sin(pi*t)**.65-.052*abs(u)**2
  vs.append((u*w,y,z));uv.append((k/NX,j/NY))
for j in range(NY):
 for k in range(NX):a=j*(NX+1)+k;fs.append((a,a+1,a+NX+2,a+NX+1))
o=mesh('Front pressed mudguard',vs,fs,'Paint','Fork',uv);m=o.modifiers.new('Steel thickness','SOLIDIFY');m.thickness=.004;apply(o);bevel(o,.002,2)
for s in [0,NX]:tube('Mudguard edge bead',[vs[j*(NX+1)+s] for j in range(NY+1)],.0025,'Chrome','Fork')
tube('Mudguard crest',[(0,-.88+i*.30/24,.42-.07*((.0425+i*.30/24)/.5475)+.17*sin(pi*((.0425+i*.30/24)/.5475))**.65+.004) for i in range(25)],.006,'Chrome','Fork')
# Fork, single-side trailing link, shock and brake cable.
rod('Steering sleeve',(0,-.345,.385),(0,-.445,.948),.033,'Paint','Fork',32)
rod('Fork leg',(.083,-.52,.51),(.083,-.60,.265),.017,'Chrome','Fork',24)
rod('Trailing link',(.082,-.56,.245),(.082,-.65,.225),.023,'DarkMetal','Fork',24)
rod('Shock body',(.09,-.48,.405),(.09,-.58,.255),.018,'DarkMetal','Fork',24)
# spring helix
pts=[]
for j in range(145):
 t=j/144;a=2*pi*9*t;pts.append((.09+.022*cos(a),-.48-.10*t+.017*sin(a),.405-.15*t+.011*sin(a)))
tube('Fork spring',pts,.003,'Chrome','Fork',n=6)
tube('Front brake cable',[(.09,-.51,1.02),(.105,-.47,.9),(.112,-.44,.68),(.115,-.46,.5),(.12,-.56,.29),(.086,-.65,.25)],.003,'Rubber','Fork')

# Shield flowing continuously into the floorboard.
rows=[(.20,.255,.215),(.10,.255,.22),(-.08,.255,.23),(-.23,.27,.24),(-.32,.32,.255),(-.37,.41,.263),(-.405,.54,.266),(-.44,.68,.26),(-.475,.82,.25),(-.477,.889,.222),(-.468,.926,.175),(-.46,.944,.09),(-.46,.949,.012)]
# smooth interpolate the profile using Catmull-Rom

def cat(points,steps=5):
 out=[]
 for i in range(len(points)-1):
  p0=np.array(points[max(i-1,0)]);p1=np.array(points[i]);p2=np.array(points[i+1]);p3=np.array(points[min(i+2,len(points)-1)])
  for j in range(steps):
   t=j/steps;out.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t))
 out.append(np.array(points[-1]));return out
rows=cat(rows,4);vs=[];uv=[];fs=[];NX=32
for j,(y,z,w) in enumerate(rows):
 for k in range(NX+1):
  u=2*k/NX-1;bulge=.038*(1-u*u)*min(1,max(0,(z-.27)/.3));vs.append((u*w,y-bulge,z));uv.append((k/NX,j/(len(rows)-1)*1.7))
for j in range(len(rows)-1):
 for k in range(NX):a=j*(NX+1)+k;fs.append((a,a+1,a+NX+2,a+NX+1))
o=mesh('Continuous steel legshield and floor',vs,fs,uv=uv);m=o.modifiers.new('Pressed steel gauge','SOLIDIFY');m.thickness=.007;apply(o);bevel(o,.002,2)
for k in [0,NX]:tube('Shield rolled perimeter',[vs[j*(NX+1)+k] for j in range(len(rows))],.0045,'Chrome')
# central pressed horn cover tapering up the face
ell('Central horn casting',(0,-.445,.474),(.057,.033,.10),'Paint',seg=40,rings=20)
rod('Horn black inset',(0,-.478,.49),(0,-.484,.49),.032,'DarkMetal',n=40)
rod('Horn trim',(0,-.485,.49),(0,-.49,.49),.033,'Chrome',n=40)
for i in range(-3,4):
 x=i*.006;dz=math.sqrt(max(0,.027**2-x*x));rod('Horn vent',(x,-.494,.49-dz),(x,-.494,.49+dz),.0019,'DarkMetal',n=6)
# floor nonslip strips inset in chrome rails
for s in [-1,1]:
 for xx in [.085,.145,.202]:
  box('Floor runner',(s*xx,-.015,.266),(.013,.40,.008),'Chrome',bev=.004)
  box('Floor rubber rib',(s*xx,-.015,.271),(.007,.385,.004),'Rubber',bev=.0017)
box('Rear floor structural sill',(0,.19,.273),(.25,.20,.045),'Paint',bev=.020)
# structural tunnel rising towards saddle, smooth waisted rear body.
# Horizontal elliptical rings form a continuous pressed shell with a concave step-through neck.
sections=[(.292,.05,.39,.60),(.31,.17,.19,.87),(.36,.219,.24,.914),(.46,.24,.292,.923),(.57,.23,.28,.90),(.69,.205,.20,.845),(.76,.18,.145,.75),(.786,.14,.17,.69),(.789,.025,.40,.46)]
sec=cat(sections,6);vs=[];fs=[];uv=[];N=64
for j,(z,w,front,rear) in enumerate(sec):
 for k in range(N):
  a=2*pi*k/N;vs.append((w*cos(a),(front+rear)/2+(rear-front)/2*sin(a),z));uv.append((k/N,j/(len(sec)-1)))
for j in range(len(sec)-1):
 for k in range(N):fs.append((j*N+k,j*N+(k+1)%N,(j+1)*N+(k+1)%N,(j+1)*N+k))
fs.append(tuple(reversed(range(N))));fs.append(tuple((len(sec)-1)*N+k for k in range(N)))
mesh('Rear monocoque',vs,fs,uv=uv)
# Removable side cowls, with distinctive domed teardrop profile, lower edge flat.
for s in [-1,1]:
 outline=[(.17,.34),(.20,.48),(.28,.59),(.41,.654),(.58,.655),(.75,.59),(.87,.47),(.91,.335),(.77,.318),(.53,.318),(.31,.32)]
 outline=cat(outline+[outline[0]],5)[:-1];cy=.55;cz=.47;vs=[(s*.307,cy,cz)];uv=[(.5,.5)];fs=[];N=len(outline)
 for rj in range(1,10):
  r=rj/9
  for y,z in outline:
   # outer edge seats into monocoque; dome is broad, not spherical
   x=s*(.235+.075*(1-r*r));vs.append((x,cy+(y-cy)*r,cz+(z-cz)*r));uv.append((.5+(y-cy)*1.2*r,.5+(z-cz)*2*r))
 for k in range(N):fs.append((0,1+k,1+(k+1)%N))
 for rj in range(8):
  for k in range(N):a=1+rj*N+k;b=1+rj*N+(k+1)%N;fs.append((a,a+N,b+N,b))
 o=mesh('Removable rounded cowl',vs,fs,'Paint',uv=uv);m=o.modifiers.new('Panel shell','SOLIDIFY');m.thickness=.004;apply(o)
 # Folded panel return closes the removable cover into the inner chassis.
 flange=[]
 for y,z in outline:flange.extend([(s*.235,y,z),(s*.10,max(y,.31) if y<.31 else y,z)])
 faces=[(2*k,2*((k+1)%N),2*((k+1)%N)+1,2*k+1) for k in range(N)]
 mesh('Cowl folded return flange',flange,faces,'Paint')
 tube('Cowl rubber welt',[(s*.237,y,z) for y,z in outline],.004,'Rubber',closed=True)
 def cowl_x(y,z):
  q=np.array([y-cy,z-cz]); best=100.0
  for ii in range(len(outline)):
   a=np.array(outline[ii])-[cy,cz];b=np.array(outline[(ii+1)%len(outline)])-[cy,cz]
   try:
    t,u=np.linalg.solve(np.column_stack([q,a-b]),a)
    if t>0 and 0<=u<=1:best=min(best,t)
   except np.linalg.LinAlgError:pass
  r=min(1,1/best);return .235+.075*(1-r*r)+.002
 # bright side swage nested on dome
 path=cat([(.33,.34),(.30,.45),(.30,.51),(.36,.54),(.53,.548),(.71,.535),(.81,.49)],6)
 tube('Side cowl trim',[(s*cowl_x(y,z),y,z) for y,z in path],.003,'Chrome')
 # small latch, two panel fasteners
 rod('Cowl latch',(s*cowl_x(.41,.596),.41,.596),(s*(cowl_x(.41,.596)+.005),.41,.596),.009,'Chrome',n=16)
 for yy in [.24,.83]:rod('Cowl fixing',(s*.222,yy,.348),(s*.23,yy,.348),.004,'Chrome',n=6)
 if s==-1:
  for j in range(5):
   yy=.68+j*.025;tube('Cooling louvre',[(s*cowl_x(yy,zz),yy,zz) for zz in [.39,.43,.48]],.004,'DarkMetal')
# Saddle: rounded but substantially flat top, tailored perimeter and strap.
seat=box('Dual leather saddle',(0,.48,.825),(.365,.68,.115),'Leather',bev=.045)
# subtle crowned seating pads
# The single upholstered shell avoids intersecting crown surfaces.
for z in [.79]:
 pts=[]
 for j in range(96):
  a=2*pi*j/96;x=.177*math.copysign(abs(cos(a))**.42,cos(a));y=.48+.33*math.copysign(abs(sin(a))**.42,sin(a));pts.append((x,y,z))
 tube('Saddle stitched piping',pts,.002,'Leather',closed=True,n=6)
# Passenger strap follows top and curves down the edges.
strap_path=cat([(-.18,.535,.803),(-.177,.535,.862),(-.15,.535,.886),(0,.535,.891),(.15,.535,.886),(.177,.535,.862),(.18,.535,.803)],5)
strap_vs=[(x,y+d,z) for x,y,z in strap_path for d in [-.012,.012]]
strap_fs=[(2*j,2*j+1,2*j+3,2*j+2) for j in range(len(strap_path)-1)]
o=mesh('Flat leather passenger strap',strap_vs,strap_fs,'Leather');m=o.modifiers.new('Leather thickness','SOLIDIFY');m.thickness=.002;apply(o);bevel(o,.0007,2)
for s in [-1,1]:
 rod('Seat strap button',(s*.179,.535,.81),(s*.184,.535,.81),.007,'Chrome',n=16)
 for yy in [.30,.68]:rod('Seat rivet',(s*.18,yy,.802),(s*.185,yy,.802),.003,'Chrome',n=12)
# Tailored saddle narrows at the rider's thighs and gently rises toward the passenger.
for ob in GROUPS['Frame']:
 if any(token in ob.name for token in ['saddle','Seat crown','Saddle stitched','passenger strap','strap button','Seat rivet']):
  for v in ob.data.vertices:
   world=ob.matrix_world@v.co;t=max(0,min(1,(world.y-.14)/.68));world.x*=.84+.16*min(1,t*2);world.z+=.012*t*t
   v.co=ob.matrix_world.inverted()@world
tube('Passenger grab loop',cat([(-.14,.78,.765),(-.16,.865,.765),(0,.9,.773),(.16,.865,.765),(.14,.78,.765)],8),.009,'Chrome')
# Handlebar nacelle, round lamp, grips, cable levers and instruments.
rod('Painted steering neck',(0,-.44,.885),(0,-.49,1.061),.039,'Paint','Fork',32)
nacelle=cat([(-.630,.077,1.065),(-.601,.081,1.065),(-.557,.070,1.06),(-.5,.048,1.049),(-.461,.031,1.039)],6)
nv=[];nf=[]
for y,r,z in nacelle:
 for k in range(64):a=2*pi*k/64;nv.append((r*cos(a),y,z+r*sin(a)))
for j in range(len(nacelle)-1):
 for k in range(64):nf.append((j*64+k,j*64+(k+1)%64,(j+1)*64+(k+1)%64,(j+1)*64+k))
nf.extend([tuple(reversed(range(64))),tuple((len(nacelle)-1)*64+k for k in range(64))]);mesh('Tapered handlebar nacelle',nv,nf,'Paint','Fork')
for s in [-1,1]:
 tube('Swept handlebar',cat([(0,-.48,1.055),(s*.13,-.465,1.04),(s*.24,-.43,1.035),(s*.255,-.426,1.033)],8),.021,'Paint','Fork',n=12)
 rod('Grip metal collar',(s*.245,-.428,1.034),(s*.258,-.426,1.033),.022,'Chrome','Fork',24)
 rod('Rubber hand grip',(s*.257,-.426,1.033),(s*.35,-.419,1.03),.020,'Rubber','Fork',32)
 ell('Grip end cap',(s*.354,-.419,1.03),(.006,.02,.02),'Rubber','Fork',24,12)
 for j in range(11):
  x=s*(.263+j*.0075)
  # ring planes approximately perpendicular to X
  ringx('Grip ribs',-.423,1.032,.0202,.0007,x,'Rubber','Fork',24,5)
 box('Lever perch',(s*.237,-.455,1.038),(.026,.035,.025),'DarkMetal','Fork',.004)
 tube('Brake lever',cat([(s*.235,-.47,1.046),(s*.265,-.492,1.044),(s*.33,-.494,1.042),(s*.353,-.48,1.045)],7),.004,'Chrome','Fork')
 ell('Lever ball',(s*.353,-.48,1.045),(.006,.006,.006),'Chrome','Fork',16,8)
 box('Switch housing',(s*.24,-.421,1.05),(.025,.027,.026),'Rubber','Fork',.006)
 box('Switch rocker',(s*.24,-.405,1.052),(.010,.008,.009),'DarkMetal','Fork',.002)
# lens front normal -Y
rod('Headlight chrome bezel',(0,-.611,1.065),(0,-.632,1.065),.085,'Chrome','Fork',80)
rod('Headlight gasket',(0,-.633,1.065),(0,-.636,1.065),.078,'Rubber','Fork',80)
o=ell('Headlight fluted lens',(0,-.638,1.065),(.075,.014,.075),'Lamps','Fork',64,24);lampuv(o,.17)
for i in range(-12,13):
 x=i*.0055;h=math.sqrt(max(0,.073**2-x*x));pts=[]
 for j in range(12):
  z=-h+2*h*j/11;y=-.638-.014*math.sqrt(max(0,1-(x/.075)**2-(z/.075)**2));pts.append((x,y-.0007,1.065+z))
 o=tube('Lens optical flutes',pts,.0008,'Lamps','Fork',n=5);lampuv(o,.17)
# speedometer, blank original face with index geometry, no lettering.
ell('Instrument painted pedestal',(0,-.465,1.083),(.043,.047,.023),'Paint','Fork',40,16)
ell('Instrument chrome surround',(0,-.455,1.105),(.039,.044,.009),'Chrome','Fork',40,16)
ell('Instrument glass',(0,-.455,1.111),(.034,.039,.003),'DarkMetal','Fork',40,12)
for j in range(9):
 a=pi*.2+pi*1.6*j/8;rod('Gauge index',(.025*cos(a),-.455+.029*sin(a),1.115),(.029*cos(a),-.455+.034*sin(a),1.115),.0012,'Chrome','Fork',6)
rod('Gauge needle',(0,-.455,1.116),(.018,-.469,1.116),.001,'Chrome','Fork',6)
# integrated small amber signals at lower nacelle; same Lamps material.
for s in [-1,1]:
 ell('Signal chrome mount',(s*.17,-.479,1.04),(.021,.014,.014),'Chrome','Fork',24,12)
 o=ell('Amber signal',(s*.17,-.488,1.04),(.016,.009,.010),'Lamps','Fork',24,12);lampuv(o,.83)
# Rear light sits on the trailing body, original oval capsule.
box('Tail lamp gasket',(0,.888,.543),(.127,.024,.067),'Rubber',bev=.018)
box('Tail lamp bezel',(0,.902,.543),(.119,.018,.061),'Chrome',bev=.016)
o=box('Red tail lens',(0,.913,.543),(.106,.013,.050),'Lamps',bev=.014);lampuv(o,.5)
for j in range(-4,5):
 o=rod('Tail lens flute',(j*.01,.921,.528),(j*.01,.921,.558),.001,'Lamps',n=6);lampuv(o,.5)
# transmission case, finned engine, exhaust, suspension, foot controls and retracted stand.
ell('Transmission case',(-.087,.54,.24),(.065,.24,.063),'DarkMetal',seg=40,rings=20)
for j in range(7):box('Engine cooling fin',(-.07,.34,.29+j*.009),(.16,.13,.004),'DarkMetal',bev=.001)
rod('Rear damper',(-.09,.75,.33),(-.10,.68,.62),.016,'DarkMetal',n=24)
tube('Exhaust header',cat([(.06,.32,.29),(.07,.25,.20),(.10,.39,.16),(.13,.58,.16)],8),.014,'DarkMetal')
ell('Exhaust silencer',(.12,.63,.16),(.06,.19,.038),'DarkMetal',seg=40,rings=16)
rod('Exhaust outlet',(.12,.78,.165),(.12,.86,.16),.013,'Chrome',n=20)
rod('Dark exhaust bore',(.12,.858,.16),(.12,.864,.16),.009,'DarkMetal',n=20)
rod('Brake pedal shaft',(.12,-.19,.274),(.12,-.13,.297),.007,'Chrome')
box('Brake pedal',(.12,-.13,.298),(.051,.026,.009),'Rubber',bev=.003)
# folded stand aligned backwards under the floor: actual pivot is retained.
for s in [-1,1]:
 tube('Retracted stand leg',cat([(s*.11,.13,.225),(s*.13,.22,.20),(s*.18,.40,.205),(s*.195,.45,.23)],6),.011,'DarkMetal','Kickstand')
 ell('Stand foot',(s*.195,.45,.23),(.02,.025,.014),'Rubber','Kickstand',20,10)
rod('Stand hinge',(-.14,.13,.225),(.14,.13,.225),.013,'DarkMetal','Kickstand')
tube('Stand return spring',[(.13+.009*cos(j*2*pi/10),.15+j*.002,.224+.009*sin(j*2*pi/10)) for j in range(55)],.0017,'Chrome','Kickstand',n=5)

print('GEOMETRY built',flush=True)
# Recalculate winding, weld coincident vertices, collapse accidental zero-length geometry.
import bmesh
for obs in GROUPS.values():
 for o in obs:
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
# Join exactly the five requested rigid meshes.
origins={'Frame':(0,0,0),'Fork':(0,-.452784,.93),'Wheel_F':(0,-.65,.225),'Wheel_R':(0,.65,.225),'Kickstand':(0,.13,.225)}
objects={}
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
 sc.cursor.location=origins[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR');objects[name]=o
 # triangulation is applied so stats and export agree.
 mod=o.modifiers.new('Final triangulation','TRIANGULATE');apply(o)
raw_tri=sum(len(o.data.polygons) for o in objects.values())
if raw_tri>76000:
 for o in objects.values():
  mod=o.modifiers.new('Game triangle budget','DECIMATE');mod.ratio=74500/raw_tri;mod.use_collapse_triangulate=True;apply(o)
print('TRIANGLE BUDGET',raw_tri,'->',sum(len(o.data.polygons) for o in objects.values()),flush=True)
# Give Fork a local steering axis: glTF local Y lies along the raked head tube.
fork=objects['Fork'];old=fork.matrix_world.copy();fork.rotation_euler.x=math.atan2(.05,.176);bpy.context.view_layer.update();correction=fork.matrix_world.inverted()@old
for v in fork.data.vertices:v.co=correction@v.co
# Preserve assembled positions while parenting the front wheel.

w=objects['Wheel_F'];mw=w.matrix_world.copy();w.parent=objects['Fork'];w.matrix_world=mw
sockets={'Seat_Rider':(0,.32,.905),'Hand_L':(.305,-.422,1.032),'Hand_R':(-.305,-.422,1.032),'Foot_L':(.15,-.06,.28),'Foot_R':(-.15,-.06,.28),'Light_Head':(0,-.655,1.065),'Light_Tail':(0,.925,.543)}
for name,co in sockets.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=co
 if name in ['Hand_L','Hand_R','Light_Head']:
  mw=o.matrix_world.copy();bpy.context.view_layer.update();mw=o.matrix_world.copy();o.parent=objects['Fork'];o.matrix_world=mw
 objects[name]=o
bpy.context.view_layer.update()
tri=sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH')
pts=[o.matrix_world@v.co for o in objects.values() if o.type=='MESH' for v in o.data.vertices]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
def gl(p):return [round(p[0],6),round(p[2],6),round(-p[1],6)]
paints=[{'name':'Ivory','linear_rgb':[.85,.80,.66]},{'name':'Sage','linear_rgb':[.25,.39,.29]},{'name':'Bordeaux','linear_rgb':[.30,.035,.046]},{'name':'Petrol blue','linear_rgb':[.035,.18,.23]},{'name':'Powder blue','linear_rgb':[.38,.59,.65]},{'name':'Graphite','linear_rgb':[.068,.075,.085]}]
meta={'units':'metres','forward':'+Z','up':'+Y','left':'+X','nodes':{n:{'world_position':gl(o.matrix_world.translation),'parent':o.parent.name if o.parent else None} for n,o in objects.items()},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'triangles':tri,'paints':paints,'steering_axis':[0,.9619,.2733],'fork_local_steering_axis':[0,1,0],'wheel_spin_axis':[1,0,0],'rim_diameter_m':.3048,'wheelbase_m':1.3}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
# Export baked-only materials; patch the glTF factor explicitly (the neutral map stays untouched).
bpy.ops.object.select_all(action='DESELECT')
for o in objects.values():o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'city-scooter.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False)
p=ROOT/'city-scooter.glb';data=p.read_bytes();jl,jt=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jl]);rest=data[20+jl:]
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[.85,.80,.66,1];mat.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1,'clearcoatRoughnessFactor':.05}
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);p.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(rest))+struct.pack('<II',len(j),0x4e4f534a)+j+rest)
print('EXPORTED',tri,p.stat().st_size,flush=True)
(ROOT/'NOTES.md').write_text(f'Classic 125 cc steel step-through scooter, authored in metres with articulated steering, wheels and folded stand.\n{tri:,} triangles; 5 rigid mesh nodes and 7 attachment empties.\n6 materials: Paint, Rubber, Chrome, Leather, DarkMetal, Lamps; neutral recolourable Paint with clearcoat.\nCycles GPU baked base colour and normal 1024 px; packed ORM 512 px; Lamps 128 px.\nCC0 Poly Haven ids: metal_plate, brown_leather, rubber_tiles (https://polyhaven.com/license).\nReference differences: original unbranded face, cleaner wear and simplified concealed engine; 12-inch rims, 450 mm tyres.\n')
# Rendering uses the identical baked maps and the contract colour factor.
pnt=M['Paint'].node_tree;bs=pnt.nodes.get('Principled BSDF') or next(n for n in pnt.nodes if n.type=='BSDF_PRINCIPLED');tex=pnt.nodes.get('BaseColor');mult=pnt.nodes.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(.85,.80,.66,1);pnt.links.new(tex.outputs[0],mult.inputs[1]);pnt.links.new(mult.outputs[0],bs.inputs['Base Color'])
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.name='STUDIO ground';gm=bpy.data.materials.new('STUDIO ground');gm.diffuse_color=(.24,.265,.28,1);gm.use_nodes=True;gm.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.24,.265,.28,1);gm.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.82;ground.data.materials.append(gm);ground.location.z=-.001
world=sc.world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.64,.72,.83,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.45
for name,loc,power,size in [('Large soft daylight',( -3,-4,6),550,4),('Soft fill',(4,-1,3),350,3),('Rear reflection',(1,4,4),650,3)]:
 bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector((0,0,.55))-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.lens=55

def camera(loc,scale):cam.location=loc;cam.rotation_euler=(Vector((0,0,.57))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale

def render(name,w=1920,h=1080,samples=32):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)

camera((3.15,-4.3,2.28),2.8)
# Save the source scene for diagnosis; delivery is always generated from this script.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'source.blend'))
render('final.png')
camera((4,0,.67),2.45);render('side.png',1600,1000,24)
camera((3.15,-4.3,2.28),2.8)
clay=bpy.data.materials.new('STUDIO clay');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.42,.42,.42,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.7
sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
# A real six-render contact sheet, generated without external imaging dependencies.
canvas=np.ones((720,1920,4),dtype=np.float32)
for i,palette in enumerate(paints):
 mult.inputs[2].default_value=(*palette['linear_rgb'],1);render(f'paint_{i}.png',640,360,16)
 im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'),check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=arr;bpy.data.images.remove(im)
im=bpy.data.images.new('Six real paint variants',width=1920,height=720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
mult.inputs[2].default_value=(.85,.80,.66,1)
# Final hero is rendered from the serialized GLB, proving material/transform round-trip.
bpy.data.node_groups['glTF Material Output'].name='Authored occlusion export settings'
for o in list(objects.values()):bpy.data.objects.remove(o,do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'city-scooter.glb'))
render('final.png',1920,1080,32)
print('SERIALIZED GLB HERO rendered',flush=True)
print('COMPLETE',time.strftime('%H:%M:%S'),flush=True)
