"""City bus, metres; Blender Z-up authoring, front -Y -> glTF +Z.
Clean rebuild: blender -b --python-exit-code 1 --python build_city-bus.py
CC0 sources downloaded from Poly Haven. All eight surface recipes Cycles baked.
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, sys
import numpy as np
from mathutils import Vector
from pathlib import Path
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT); (ROOT/'textures').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
P=bpy.context.preferences.addons['cycles'].preferences;P.compute_device_type='OPTIX';P.get_devices()
for d in P.devices:d.use=d.type!='CPU'
S.cycles.device='GPU';S.cycles.use_denoising=True
S.render.image_settings.file_format='PNG';S.render.film_transparent=False
S.view_settings.view_transform='AgX'
parts={}; active_group='Body'; mats={}; sources={}
PH=['metal_plate','denim_fabric','rubberized_track']
for asset in PH:
 for kind in ['diff','nor_gl','arm']:
  f=ROOT/'textures'/f'{asset}_{kind}_1k.jpg'
  if not f.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
   subprocess.run(['curl','-f','-L','--retry','2','-s',url,'-o',str(f)],check=True)
  im=bpy.data.images.load(str(f));
  if kind!='diff':im.colorspace_settings.name='Non-Color'
  sources[asset,kind]=im

def node(nt,t):return nt.nodes.new(t)
def source_mat(name,base,rough,metal,asset=None,strength=.04):
 m=bpy.data.materials.new(name);m.use_nodes=True;n=m.node_tree;n.nodes.clear()
 out=node(n,'ShaderNodeOutputMaterial');p=node(n,'ShaderNodeBsdfPrincipled');n.links.new(p.outputs['BSDF'],out.inputs['Surface'])
 p.inputs['Base Color'].default_value=(*base,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal
 uv=node(n,'ShaderNodeTexCoord');noise=node(n,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=110;noise.inputs['Detail'].default_value=3;n.links.new(uv.outputs['UV'],noise.inputs['Vector'])
 ramp=node(n,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=tuple(c*.8 for c in base)+(1,);ramp.color_ramp.elements[1].color=tuple(min(1,c*1.05) for c in base)+(1,);n.links.new(noise.outputs['Fac'],ramp.inputs[0]);color=ramp.outputs[0]
 if asset:
  tex=node(n,'ShaderNodeTexImage');tex.image=sources[asset,'diff'];n.links.new(uv.outputs['UV'],tex.inputs['Vector'])
  bw=node(n,'ShaderNodeRGBToBW');n.links.new(tex.outputs['Color'],bw.inputs[0])
  mix=node(n,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.15 if name=='Paint' else .45;n.links.new(color,mix.inputs[1]);n.links.new(bw.outputs[0],mix.inputs[2]);color=mix.outputs[0]
  nm=node(n,'ShaderNodeTexImage');nm.image=sources[asset,'nor_gl'];n.links.new(uv.outputs['UV'],nm.inputs['Vector']);norm=node(n,'ShaderNodeNormalMap');norm.inputs['Strength'].default_value=strength;n.links.new(nm.outputs[0],norm.inputs['Color']);n.links.new(norm.outputs[0],p.inputs['Normal'])
  ar=node(n,'ShaderNodeTexImage');ar.image=sources[asset,'arm'];n.links.new(uv.outputs['UV'],ar.inputs['Vector']);sep=node(n,'ShaderNodeSeparateColor');n.links.new(ar.outputs[0],sep.inputs[0]);rm=node(n,'ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.03,rough-.08);rm.inputs['To Max'].default_value=min(1,rough+.1);n.links.new(sep.outputs['Green'],rm.inputs[0]);n.links.new(rm.outputs[0],p.inputs['Roughness'])
 if name=='Paint':
  # Baked neutral road film near lower edge of each panel; no coloured livery in Paint.
  sep=node(n,'ShaderNodeSeparateXYZ');n.links.new(uv.outputs['UV'],sep.inputs[0]);grad=node(n,'ShaderNodeMapRange');grad.inputs['From Min'].default_value=0;grad.inputs['From Max'].default_value=.24;grad.inputs['To Min'].default_value=.40;grad.inputs['To Max'].default_value=0;n.links.new(sep.outputs['Y'],grad.inputs[0])
  noise2=node(n,'ShaderNodeTexNoise');noise2.inputs['Scale'].default_value=35;noise2.inputs['Detail'].default_value=5;n.links.new(uv.outputs['UV'],noise2.inputs[0]);mult=node(n,'ShaderNodeMath');mult.operation='MULTIPLY';n.links.new(grad.outputs[0],mult.inputs[0]);n.links.new(noise2.outputs['Fac'],mult.inputs[1]);mix=node(n,'ShaderNodeMixRGB');n.links.new(mult.outputs[0],mix.inputs[0]);n.links.new(color,mix.inputs[1]);mix.inputs[2].default_value=(.25,.25,.25,1);color=mix.outputs[0]
 if name=='Lamps':
  sep=node(n,'ShaderNodeSeparateXYZ');n.links.new(uv.outputs['UV'],sep.inputs[0]);pal=node(n,'ShaderNodeValToRGB');pal.color_ramp.interpolation='CONSTANT'
  colors=[(1,.88,.65),(.8,.02,.012),(1,.35,.01),(.15,.7,.3),(.09,.28,.35),(.55,.58,.58),(.7,.74,.67),(1,1,1)]
  pal.color_ramp.elements.remove(pal.color_ramp.elements[1])
  for i,c in enumerate(colors):
   e=pal.color_ramp.elements[0] if i==0 else pal.color_ramp.elements.new(i/8)
   e.position=i/8;e.color=(*c,1)
  n.links.new(sep.outputs['X'],pal.inputs[0]);color=pal.outputs[0]
 n.links.new(color,p.inputs['Base Color'])
 return m,p,out

recipes=[('Paint',(.97,.97,.97),.3,.35,'metal_plate',.025),('Trim',(.026,.030,.033),.72,.05,'rubberized_track',.10),('Metal',(.42,.46,.49),.28,.85,'metal_plate',.09),('Interior',(.024,.055,.12),.9,0,'denim_fabric',.42),('Accent',(.82,.57,.045),.37,.12,'metal_plate',.02),('Glass',(.070,.085,.090),.13,.12,None,0),('Lamps',(.92,.92,.92),.2,.15,None,0),('Cabin',(.24,.26,.27),.7,.05,'rubberized_track',.08)]
# Shared tiles baked on a UV fixture, then used on repeated parts. 1024 albedo/512 normal/512 ORM.
def bake_material(recipe):
 name,base,rough,metal,asset,strength=recipe
 m,p,out=source_mat(*recipe);nt=m.node_tree
 bpy.ops.mesh.primitive_plane_add(size=1);fixture=bpy.context.object;fixture.name='BAKE_FIXTURE';fixture.data.materials.append(m)
 target=node(nt,'ShaderNodeTexImage');nt.nodes.active=target
 S.cycles.samples=4;S.render.bake.margin=8;S.render.bake.use_clear=True
 baked={}
 for typ,size in [('Base',1024),('Normal',512),('ORM',512)]:
  im=bpy.data.images.new(name+'_'+typ,width=size,height=size,alpha=False)
  if typ!='Base':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  if typ=='Base':
   em=node(nt,'ShaderNodeEmission');nt.links.new(p.inputs['Base Color'].links[0].from_socket,em.inputs[0]);nt.links.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');nt.links.new(p.outputs[0],out.inputs[0])
  elif typ=='Normal':bpy.ops.object.bake(type='NORMAL')
  else:
   combine=node(nt,'ShaderNodeCombineColor');combine.inputs['Red'].default_value=1;combine.inputs['Green'].default_value=rough;combine.inputs['Blue'].default_value=metal
   if p.inputs['Roughness'].is_linked:nt.links.new(p.inputs['Roughness'].links[0].from_socket,combine.inputs['Green'])
   emission=node(nt,'ShaderNodeEmission');nt.links.new(combine.outputs[0],emission.inputs[0]);nt.links.new(emission.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  ext='jpg' if typ=='Base' else 'png'
  im.filepath_raw=str(ROOT/'textures'/f'{name}_{typ}.{ext}');im.file_format='JPEG' if typ=='Base' else 'PNG';im.save()
  # Reload encoded albedo so AUTO export embeds the compact JPEG verbatim.
  if typ=='Base':im=bpy.data.images.load(im.filepath_raw,check_existing=False)
  baked[typ]=im
 bpy.data.objects.remove(fixture,do_unlink=True)
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(p.outputs[0],out.inputs[0])
 tex=node(nt,'ShaderNodeTexImage');tex.image=baked['Base'];nt.links.new(tex.outputs[0],p.inputs['Base Color'])
 nm=node(nt,'ShaderNodeTexImage');nm.image=baked['Normal'];normal=node(nt,'ShaderNodeNormalMap');nt.links.new(nm.outputs[0],normal.inputs['Color']);nt.links.new(normal.outputs[0],p.inputs['Normal'])
 orm=node(nt,'ShaderNodeTexImage');orm.image=baked['ORM'];sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(orm.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs['Green'],p.inputs['Roughness']);nt.links.new(sep.outputs['Blue'],p.inputs['Metallic'])
 # Standard glTF occlusion node convention.
 if not bpy.data.node_groups.get('glTF Material Output'):
  g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gnode=node(nt,'ShaderNodeGroup');gnode.node_tree=bpy.data.node_groups['glTF Material Output'];nt.links.new(sep.outputs['Red'],gnode.inputs['Occlusion'])
 if name=='Paint':
  fac=node(nt,'ShaderNodeMixRGB');fac.name='PaintTint';fac.blend_type='MULTIPLY';fac.inputs[0].default_value=1;fac.inputs[2].default_value=(.8,.8,.8,1);nt.links.new(tex.outputs[0],fac.inputs[1]);nt.links.new(fac.outputs[0],p.inputs['Base Color']);p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
 if name=='Glass':
  p.inputs['Alpha'].default_value=.47;p.inputs['Roughness'].default_value=.12;m.surface_render_method='BLENDED';m.use_backface_culling=True
 if name=='Cabin':
  vc=node(nt,'ShaderNodeVertexColor');vc.layer_name='Tint';mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(tex.outputs[0],mix.inputs[1]);nt.links.new(vc.outputs['Color'],mix.inputs[2]);nt.links.new(mix.outputs[0],p.inputs['Base Color'])
 if name=='Lamps':nt.links.new(tex.outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=.75
 mats[name]=m;print('BAKED',name,flush=True)
for recipe in recipes:bake_material(recipe)
S.cycles.samples=24

def add(o,mat='Paint',group=None,tint=None):
 o.data.materials.clear();o.data.materials.append(mats[mat]);parts.setdefault(group or active_group,[]).append(o)
 if mat=='Lamps':
  rgb=tint or (1,1,1)
  if rgb[0]>.75 and rgb[1]<.1:idx=1
  elif rgb[0]>.75 and rgb[1]<.6:idx=2
  elif rgb[1]>.5 and rgb[0]<.3:idx=3
  elif rgb[0]<.15:idx=4
  elif rgb[0]<.6:idx=5
  elif rgb[0]<.8:idx=6
  else:idx=0
  uv=o.data.uv_layers.active or o.data.uv_layers.new(name='UVMap')
  for l in uv.data:l.uv=((idx+.5)/8,.5)
 if tint and mat!='Lamps':
  c=o.data.color_attributes.new(name='Tint',type='FLOAT_COLOR',domain='CORNER')
  for v in c.data:v.color=(*tint,1)
 return o

def bevel(o,w=.015,seg=3):
 if w:
  mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=w;mod.segments=seg
  bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
 for f in o.data.polygons:f.use_smooth=True
 mod=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=40
 bpy.context.view_layer.objects.active=o
 try:bpy.ops.object.modifier_apply(modifier=mod.name)
 except: o.modifiers.remove(mod)
 return o

box_cache={}
def box(name,loc,dim,mat='Paint',r=.012,group=None,tint=None):
 key=(tuple(dim),r)
 if key in box_cache:
  o=bpy.data.objects.new(name,box_cache[key].copy());bpy.context.collection.objects.link(o);o.location=loc
  return add(o,mat,group,tint)
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=dim;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);bevel(o,r,3);box_cache[key]=o.data.copy();return add(o,mat,group,tint)

def mesh(name,verts,faces,mat='Paint',r=0,group=None,tint=None):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o)
 # Face-aligned physical UVs; paint panels retain vertical lower road-film zone.
 uv=me.uv_layers.new(name='UVMap')
 for f in me.polygons:
  axis=max(range(3),key=lambda i:abs(f.normal[i]));axes=[i for i in range(3) if i!=axis]
  coords=[me.vertices[me.loops[l].vertex_index].co for l in f.loop_indices]
  for l,co in zip(f.loop_indices,coords):uv.data[l].uv=(co[axes[0]]*.75,co[axes[1]]*.75)
 bevel(o,r,3);return add(o,mat,group,tint)

cyl_cache={}
def cyl(name,loc,r,depth,mat='Metal',axis='Z',n=48,group=None,tint=None):
 rot={'Z':(0,0,0),'X':(0,math.pi/2,0),'Y':(math.pi/2,0,0)}[axis]
 key=(r,depth,axis,n)
 if key in cyl_cache:
  o=bpy.data.objects.new(name,cyl_cache[key].copy());bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=rot
  return add(o,mat,group,tint)
 bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=loc,rotation=rot);o=bpy.context.object;o.name=name;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);bevel(o,min(.004,r*.12),2);cyl_cache[key]=o.data.copy();return add(o,mat,group,tint)

def tube(name,pts,r=.014,mat='Accent',group=None,n=12):
 # Parallel-frame tube, rounded joints via many curve points when supplied.
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=2;cu.bevel_depth=r;cu.bevel_resolution=2
 sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,co in zip(sp.points,pts):p.co=(*co,1)
 o=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(o);bpy.context.view_layer.objects.active=o;o.select_set(True);bpy.ops.object.convert(target='MESH');o=bpy.context.object;o.select_set(False);return add(o,mat,group)

def torus(name,loc,major,minor,mat='Trim',axis='X',group=None,segments=96,minorseg=12):
 rot={'X':(0,math.pi/2,0),'Y':(math.pi/2,0,0),'Z':(0,0,0)}[axis]
 bpy.ops.mesh.primitive_torus_add(major_segments=segments,minor_segments=minorseg,location=loc,rotation=rot,major_radius=major,minor_radius=minor)
 o=bpy.context.object;o.name=name
 for f in o.data.polygons:f.use_smooth=True
 return add(o,mat,group)

# Chassis and continuous passenger floor; origin is midpoint of axle pair on ground.
box('Chassis spine',(0,.35,.30),(2.25,11.75,.16),'Trim',.07)
box('Low aisle floor',(0,.25,.39),(2.36,11.65,.105),'Cabin',.025,tint=(.45,.46,.47))
box('Headlining',(0,.32,2.70),(2.38,11.55,.07),'Cabin',.04,tint=(1.0,1.0,.97))
# Curved crown roof, sculpted with superelliptical cross section.
def roof():
 verts=[];rings=[]
 for y,w,z in [(-5.65,1.11,2.72),(-5.57,1.22,2.81),(-5.40,1.275,2.82),(6.10,1.275,2.82),(6.28,1.20,2.81),(6.35,1.10,2.72)]:
  ring=[]
  for j in range(17):
   a=math.pi*j/16;ring.append(len(verts));verts.append((w*math.cos(a),y,z+.09*math.sin(a)))
  rings.append(ring)
 faces=[]
 for a,b in zip(rings,rings[1:]):
  for j in range(16):faces.append((a[j],b[j],b[j+1],a[j+1]))
 faces+=[tuple(reversed(rings[0])),tuple(rings[-1])]
 o=mesh('One piece curved roof',verts,faces,'Paint',.015)
 sol=o.modifiers.new('Roof skin thickness','SOLIDIFY');sol.thickness=.035;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name)
roof()
DOORS=[(-5.27,-4.05),(-.62,.64),(4.55,5.79)]
AXLES=[-2.95,2.95]
# Side lower sheet metal with genuine arch openings, extruded 32 mm.
def lower_panel(side,a,b):
 steps=max(2,int((b-a)/.045));ys=[a+(b-a)*j/steps for j in range(steps+1)]
 bottom=[]
 for y in ys:
  z=.29
  for ay in AXLES:
   d=abs(y-ay)
   if d<.62:z=max(z,.49+math.sqrt(.62*.62-d*d))
  bottom.append(z)
 v=[]
 for x in [side*1.257,side*1.225]:
  for y,z in zip(ys,bottom):v.extend([(x,y,z),(x,y,1.18)])
 L=2*len(ys);f=[]
 for i in range(len(ys)-1):
  k=i*2;f.extend([(k,k+2,k+3,k+1),(L+k+1,L+k+3,L+k+2,L+k),(k,L+k,L+k+2,k+2),(k+1,k+3,L+k+3,L+k+1)])
 f.extend([(0,1,L+1,L),(L-2,2*L-2,2*L-1,L-1)])
 if side==-1:f=[tuple(reversed(face)) for face in f]
 o=mesh('Arched lower body panel',v,f,'Paint',.006)
 # Normalize vertical UV to put grime on sill, independent of length.
 for poly in o.data.polygons:
  for li in poly.loop_indices:
   co=o.data.vertices[o.data.loops[li].vertex_index].co;o.data.uv_layers[0].data[li].uv=((co.y-a)/max(.8,b-a),(co.z-.29)/.89)
for side in [-1,1]:
 intervals=[(-5.44,6.14)]
 if side==-1:
  intervals=[];a=-5.44
  for l,r in DOORS:intervals.append((a,l));a=r
  intervals.append((a,6.14))
 for a,b in intervals:
  cuts=[a]+[v for v in [-3.78,-2.13,-.7,.73,2.15,3.76,4.48] if a<v<b]+[b]
  for l,r in zip(cuts,cuts[1:]):lower_panel(side,l+.004,r-.004)
 box('Upper black window header',(side*1.239,.35,2.58),(.071,11.57,.40),'Trim',.035)
 box('Roof gutter closure',(side*1.238,.35,2.805),(.075,11.58,.12),'Paint',.024)
 box('Roof edge alloy extrusion',(side*1.256,.35,2.815),(.033,11.58,.038),'Metal',.015)
 # bays between door pillars on boarding side, broader on left
 bays=[(-5.40,-5.29),(-4.03,-3.84),(-3.79,-2.02),(-1.98,-.65),(.67,2.05),(2.10,3.70),(3.75,4.52),(5.81,6.13)] if side==-1 else [(-5.39,-4.11),(-4.06,-2.13),(-2.08,-.16),(-.11,1.81),(1.86,3.78),(3.83,5.05),(5.1,6.13)]
 for a,b in bays:
  if b-a<.25:box('Structural upright',(side*1.244,(a+b)/2,1.81),(.075,b-a,1.3),'Trim',.01);continue
  box('Window gasket',(side*1.245,(a+b)/2,1.78),(.045,b-a,1.29),'Trim',.024)
  # gasket as a rim, remove solid backing and replace by four bars
  o=parts['Body'].pop();bpy.data.objects.remove(o,do_unlink=True)
  for yy in [a+.025,b-.025]:box('Window mullion',(side*1.25,yy,1.79),(.05,.05,1.35),'Trim',.009)
  for z in [1.205,2.425]:box('Window seal',(side*1.251,(a+b)/2,z),(.042,b-a,.115 if z<2 else .034),'Trim',.009)
  # Single outward facing quad, intentionally no real glass thickness.
  vs=[(side*1.244,a+.043,1.255),(side*1.244,b-.043,1.255),(side*1.244,b-.043,2.40),(side*1.244,a+.043,2.40)]
  if side==-1:vs.reverse()
  mesh('Tinted side glazing',vs,[(0,1,2,3)],'Glass')
  if b-a>1.1:
   box('Hopper ventilation crossbar',(side*1.259,(a+b)/2,2.15),(.031,b-a-.09,.026),'Trim',.007)
   box('Sliding window latch',(side*1.266,(a+b)/2,2.165),(.025,.10,.025),'Metal',.006)
 # Livery following arched beltline
 a=-5.41
 for left,right in (intervals if side==-1 else [(-5.41,6.11)]):
  pts=[]
  for j in range(int((right-left)/.06)+1):
   y=left+(right-left)*j/max(1,int((right-left)/.06));z=1.17
   for ay in AXLES:z+=.11*math.exp(-((y-ay)/.60)**4)
   pts.append((side*1.281,y,z))
  # flat ribbon avoids thick hose appearance
  v=[(x,y,z+dz) for x,y,z in pts for dz in [-.024,.024]];f=[(2*i,2*i+2,2*i+3,2*i+1) for i in range(len(pts)-1)];mesh('Yellow beltline ribbon',v,f,'Accent')
 # wheel arch rolled lips and suspension housings
 for ay in AXLES:
  pts=[(side*1.27,ay+.625*math.cos(t),.49+.625*math.sin(t)) for t in [math.pi*j/64 for j in range(65)]]
  tube('Rolled wheel arch lip',pts,.018,'Paint')
  box('Wheelhouse interior',(side*.91,ay,.65),(.63,1.43,.47),'Trim',.15)
 # underside sill and access hatch fasteners
 for y in [-4.0,-1.5,1.25,4.2,6.0]:
  if side==-1 and any(a<y<b for a,b in DOORS):continue
  for z in [.36,1.07]:cyl('Flush service fastener',(side*1.276,y,z),.012,.005,'Metal','X',16)
  box('Amber side repeater',(side*1.281,y,.55),(.018,.095,.035),'Lamps',.008,tint=(1,.35,.012))
print('GEOMETRY shell ready',flush=True)
# Six independent sliding leaves. origin on the slide line at leading/trailing edge.
for di,(a,b) in enumerate(DOORS):
 for leaf in range(2):
  g=f'Door_{di*2+leaf+1}';mid=a+(b-a)*(leaf+.5)/2;w=(b-a)/2-.018;x=-1.265
  for y in [mid-w/2,mid+w/2]:box('Door leaf stile',(x,y,1.37),(.063,.044,2.13),'Trim',.012,g)
  for z in [.31,.51,1.16,2.41]:box('Door leaf rail',(x,mid,z),(.064,w,.044),'Trim',.009,g)
  for z0,z1 in [(.54,1.13),(1.19,2.38)]:mesh('Door glass',[(x-.002,mid-w/2+.025,z0),(x-.002,mid+w/2-.025,z0),(x-.002,mid+w/2-.025,z1),(x-.002,mid-w/2+.025,z1)],[(3,2,1,0)],'Glass',group=g)
  box('Door kick plate',(x,mid,.405),(.059,w-.04,.12),'Metal',.01,g)
  box('Door pull',(x-.042,mid+(w*.29 if leaf==0 else -w*.29),1.24),(.033,.027,.20),'Trim',.012,g)
  tube('Door safety rail',[(x+.09,mid-w*.35,.77),(x+.09,mid-w*.35,1.13),(x+.09,mid+w*.35,1.38)],.015,'Accent',g)
  for z in [.56,2.20]:cyl('Door roller hardware',(x,mid-w/2,z),.037,.086,'Metal','Z',24,g)
 box('Door threshold',(-1.18,(a+b)/2,.315),(.20,b-a,.055),'Metal',.008)
 box('Door mechanism cover',(-1.14,(a+b)/2,2.51),(.21,b-a,.15),'Trim',.04)
 for y in [a-.06]:
  box('Request button bezel',(-1.303,y,1.36),(.035,.072,.115),'Trim',.017)
  cyl('Request button',(-1.326,y,1.38),.021,.012,'Lamps','X',24,tint=(.15,.7,.30))
# Front face: curved bumper and lower sculpted fascia, windshield above.
def front_loft(name,rows,mat):
 # rows=(z, centre_y, halfwidth); rounded transverse nose.
 v=[]
 for z,y,w in rows:
  for j in range(25):
   x=-w+2*w*j/24;v.append((x,y+.145*(abs(x)/w)**5,z))
 f=[]
 for i in range(len(rows)-1):
  for j in range(24):k=i*25+j;f.append((k,k+1,k+26,k+25))
 o=mesh(name,v,f,mat)
 if mat!='Glass':
  sol=o.modifiers.new('Shell thickness','SOLIDIFY');sol.thickness=.04;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name);bevel(o,.018,3)
front_loft('Rounded front bumper',[(.28,-5.46,1.08),(.34,-5.62,1.18),(.54,-5.65,1.24),(.67,-5.59,1.24)],'Paint')
front_loft('Original front service fascia',[(.67,-5.59,1.24),(.84,-5.585,1.23),(.96,-5.59,1.22),(1.04,-5.61,1.20)],'Paint')
front_loft('Windshield lower gasket',[(1.04,-5.605,1.20),(1.12,-5.60,1.19)],'Trim')
front_loft('Panoramic windshield',[(1.12,-5.592,1.18),(2.38,-5.47,1.155)],'Glass')
for side in [-1,1]:
 tube('Swept front A pillar',[(side*1.19,-5.48,1.01),(side*1.22,-5.40,1.40),(side*1.21,-5.33,2.30),(side*1.14,-5.34,2.69)],.054,'Paint')
 tube('Front glass edge seal',[(side*1.15,-5.61,1.09),(side*1.155,-5.48,2.40)],.018,'Trim')
 # Original slim twin-module lamps: separated horizontal units, not a manufacturer face.
 outline=[(.665,.725),(.715,.865),(1.115,.865),(1.185,.805),(1.145,.685),(.73,.685)]
 vs=[(side*x,-5.59+.145*(x/1.23)**5+dy,z) for dy in [.006,-.017] for x,z in outline]
 fs=[tuple(range(6)),tuple(reversed(range(6,12)))]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
 housing=mesh('Sculpted headlamp recess',vs,fs,'Trim',.014)
 # Continuous outer clear lens gives the projector assembly a finished appearance.
 mesh('Headlamp cover lens',[(side*x,-5.59+.145*(x/1.23)**5-.049,z) for x,z in outline],[tuple(reversed(range(6))) if side==1 else tuple(range(6))],'Glass')
 for xx in [side*.82,side*1.01]:
  cyl('Lamp reflector',(xx,-5.59+.145*(abs(xx)/1.23)**5-.028,.77),.068,.018,'Metal','Y',48)
  cyl('Headlight lens',(xx,-5.59+.145*(abs(xx)/1.23)**5-.040,.77),.049,.008,'Lamps','Y',48,tint=(1,.88,.65))
 box('Day running lamp',(side*.93,-5.575,.895),(.34,.018,.025),'Lamps',.008,tint=(1,.86,.62))
 box('Front turn signal',(side*1.14,-5.595,.61),(.07,.026,.08),'Lamps',.018,tint=(1,.3,.005))
 box('Front lower fog recess',(side*.96,-5.603,.43),(.24,.03,.095),'Trim',.032)
 box('Fog lamp',(side*.96,-5.624,.43),(.15,.012,.038),'Lamps',.015,tint=(.7,.74,.67))
 # Mirror arms and housings, genuine outside silhouette
 tube('Mirror curved arm',[(side*1.13,-5.32,2.51),(side*1.37,-5.54,2.52),(side*1.58,-5.61,2.46),(side*1.61,-5.59,2.30)],.037,'Trim')
 box('Mirror enclosure',(side*1.62,-5.58,2.16),(.18,.16,.42),'Trim',.07)
 box('Mirror reflective insert',(side*1.62,-5.488,2.16),(.13,.016,.33),'Metal',.043)
 # Windshield wipers: mounted on lower glass and touching it.
 tube('Wiper arm',[(side*.68,-5.655,1.10),(side*.44,-5.651,1.22),(side*.10,-5.603,1.77)],.010,'Trim')
 tube('Wiper blade',[(side*.13,-5.614,1.45),(side*.08,-5.583,1.91)],.014,'Trim')
 cyl('Wiper pivot',(side*.68,-5.658,1.10),.029,.015,'Trim','Y',24)
# recessed front grille only below lamps
box('Lower grille recess',(0,-5.653,.445),(1.08,.022,.12),'Trim',.025)
for z in [.405,.44,.475]:box('Lower grille slat',(0,-5.670,z),(1.02,.014,.012),'Metal',.004)
box('Front livery accent',(0,-5.633,1.012),(1.27,.024,.046),'Accent',.018)
# LED dot display with abstract route blocks, no glyphs or readable text.
def display_front():
 box('Front destination housing',(0,-5.435,2.575),(2.30,.105,.36),'Trim',.035)
 box('Front smoked display lens',(0,-5.496,2.575),(2.16,.018,.275),'Glass',.018)
 for row in range(7):
  for col in range(64):
   # sparse amber raster, intentionally non-linguistic pattern
   on=((col//8)%3==0 and row in [1,2,3,4,5]) or ((col+row*3)%11<3 and col>15)
   if on:box('Amber LED pixel',(-.99+col*.031,-5.509,2.492+row*.027),(.010,.004,.011),'Lamps',0,tint=(1,.42,.015))
display_front()
# Right side destination display inset into header
box('Side destination bezel',(-1.278,-2.2,2.605),(.025,1.48,.24),'Trim',.018)
for row in range(5):
 for col in range(43):
  if (col*3+row*5)%13<6:box('Side amber raster',(-1.295,-2.84+col*.03,2.548+row*.027),(.004,.011,.01),'Lamps',0,tint=(1,.40,.015))
# Rounded corner closing pillars connect end caps to side skins without daylight gaps.
for side in [-1,1]:
 box('Rear rounded corner pillar',(side*1.185,6.18,1.57),(.18,.29,2.40),'Paint',.085)
 box('Front lower corner return',(side*1.19,-5.40,.69),(.16,.20,.79),'Paint',.067)
# Rear upper glazing and maintenance panel
box('Rear engine bonnet',(0,6.265,.91),(2.34,.16,1.22),'Paint',.12)
box('Rear upper window surround',(0,6.254,2.03),(2.32,.11,1.23),'Trim',.13)
mesh('Rear window',[(-1.04,6.316,1.64),(1.04,6.316,1.64),(1.06,6.316,2.50),(-1.06,6.316,2.50)],[(3,2,1,0)],'Glass')
# remove backing where rear glass resides by replacing surround solid with frame
ob=[o for o in parts['Body'] if o.name.startswith('Rear upper window surround')][0];parts['Body'].remove(ob);bpy.data.objects.remove(ob,do_unlink=True)
for x in [-1.13,1.13]:box('Rear window upright',(x,6.25,2.07),(.12,.12,1.13),'Trim',.04)
for z in [1.55,2.61]:box('Rear window crossmember',(0,6.25,z),(2.27,.12,.12),'Trim',.04)
box('Rear bumper',(0,6.255,.37),(2.37,.18,.19),'Paint',.07)
for side in [-1,1]:
 box('Rear lamp cluster',(side*1.12,6.339,.98),(.14,.028,.59),'Trim',.047)
 for z,c in [(.76,(.8,.035,.016)),(.90,(.8,.035,.016)),(1.06,(1,.36,.012)),(1.20,(.8,.02,.012))]:box('Rear lamp lens',(side*1.12,6.359,z),(.097,.016,.09),'Lamps',.022,tint=c)
 # engine ventilation grille side, aft compartment
 box('Rear cooling recess',(side*1.28,5.93,2.05),(.033,.32,.92),'Trim',.012)
 for z in [1.65+i*.064 for i in range(13)]:box('Cooling louver',(side*1.302,5.93,z),(.035,.28,.023),'Metal',.006)
box('Rear service panel seam',(0,6.352,1.39),(1.98,.008,.012),'Trim',.003)
for x in [-.92,.92]:cyl('Bonnet latch',(x,6.357,.63),.028,.008,'Metal','Y',24)
cyl('Exhaust tip',(.83,6.20,.26),.043,.18,'Metal','Y',32)
# Roof AC pods and small hatches, fan guards modelled as concentric rings and blades.
box('Air conditioning plinth',(0,1.35,2.916),(1.76,2.65,.07),'Trim',.14)
box('Roof climate equipment pod',(0,1.35,2.939),(1.85,2.77,.162),'Paint',.098)
box('Pod recessed vent tray',(0,1.35,3.026),(1.55,2.30,.009),'Trim',.055)
for y in [.47,1.06,1.65,2.24]:
 cyl('Fan dark well',(0,y,3.036),.26,.012,'Trim','Z',64)
 for rr in [.08,.12,.16,.20,.25]:torus('Fan guard rings',(0,y,3.043),rr,.005,'Metal','Z',segments=48,minorseg=6)
 for j in range(8):
  a=j*math.pi/4;tube('Fan guard radial',[(0,y,3.043),(.25*math.cos(a),y+.25*math.sin(a),3.043)],.004,'Metal')
 cyl('Fan motor cap',(0,y,3.044),.062,.012,'Trim','Z',32)
for y in [-3.7,4.7]:
 box('Roof emergency hatch',(0,y,2.935),(.90,.73,.045),'Trim',.07)
 box('Hatch upper lid',(0,y,2.96),(.80,.64,.041),'Paint',.055)
for y in [-4.8,5.6]:box('Roof electrical housing',(.38,y,2.95),(.60,.37,.09),'Paint',.045)
print('GEOMETRY exterior ready',flush=True)
# Rows of seats, shaped seat pads, tubular supports, aisle rails. Cabin can be seen through glazing.
def seat(x,y,z=.47,driver=False):
 mat='Trim' if driver else 'Interior'
 box('Moulded seat shell',(x,y+.065,z+.37),(.455,.44,.12),'Cabin',.06,tint=(.21,.23,.25))
 box('Textile seat cushion',(x,y-.018,z+.445),(.413,.414,.12),mat,.057)
 back=box('Contoured upholstered back',(x,y+.215,z+.76),(.43,.105,.60),mat,.065);back.rotation_euler.x=math.radians(-7)
 box('Seat rear polymer shell',(x,y+.283,z+.755),(.46,.06,.63),'Cabin',.052,tint=(.20,.23,.25))
 for sx in [-.155,.155]:tube('Seat steel leg',[(x+sx,y+.10,z+.31),(x+sx,y+.10,z+.045)],.018,'Metal')
 tube('Seat back grab handle',[(x-.18,y+.29,z+.96),(x-.18,y+.29,z+1.105),(x+.18,y+.29,z+1.105),(x+.18,y+.29,z+.96)],.014,'Accent')
for y in [-3.46,-2.60,-1.65,-.76,.14,1.06,2.07,2.96,3.88,4.92,5.69]:
 for side in [-1,1]:
  if side==-1 and any(a-.28<y<b+.25 for a,b in DOORS):continue
  z=.43
  if any(abs(y-ay)<.77 for ay in AXLES):z=.72
  for x in [side*.52,side*.97]:
   # outboard cushions stay inside cabin width
   seat(x*.9,y,z)
# Standing zone lean pad and interior panels
for a,b in DOORS:
 for y in [a+.09,b-.09]:
  tube('Door standing pole',[(-.94,y,.45),(-.94,y,2.49),(-.73,y,2.56)],.017,'Accent')
  cyl('Grab pole foot',(-.94,y,.46),.045,.027,'Metal','Z',24)
for side in [-1,1]:
 tube('Overhead longitudinal grab rail',[(side*.56,-3.90,2.38),(side*.56,5.65,2.38)],.018,'Accent')
 for y in [-3.8,-1.5,1,3.8,5.5]:
  tube('Rail ceiling hanger',[(side*.56,y,2.38),(side*.56,y,2.66)],.014,'Metal')
  tube('Hanging grab loop',[(side*.56,y,2.36),(side*.56,y,2.15),(side*.56,y+.13,2.05),(side*.56,y+.25,2.15),(side*.56,y+.25,2.36)],.010,'Trim')
 for y in [-3,0,3]:box('Interior ceiling light',(side*.81,y,2.66),(.08,1.5,.018),'Lamps',.008,tint=(.55,.58,.58))
# driver cockpit on LEFT (+X), cab partition and dashboard
seat(.66,-4.45,.47,True)
box('Driver pedestal',(.65,-4.4,.44),(.66,.75,.16),'Trim',.035)
box('Dashboard base',(.39,-5.20,1.02),(1.52,.55,.30),'Trim',.10)
box('Instrument binnacle',(.66,-5.07,1.21),(.60,.25,.13),'Trim',.06)
box('Instrument screen',(.66,-4.924,1.23),(.34,.018,.088),'Lamps',.015,tint=(.09,.28,.35))
for x in [.39,.9]:cyl('Round gauge',(x,-4.933,1.235),.055,.01,'Metal','Y',32)
for i in range(7):box('Dashboard switch',(.07+i*.064,-4.934,1.14),(.031,.017,.025),'Cabin',.004,tint=(.4,.43,.44))
tube('Steering column',[(.67,-4.98,.63),(.67,-4.91,1.19)],.047,'Trim')
g='SteeringWheel';hub=(.67,-4.86,1.25)
o=torus('Steering wheel rim',hub,.21,.021,'Trim','Z',g,64,12);o.rotation_euler.x=math.radians(24)
cyl('Steering hub',hub,.067,.06,'Trim','Z',40,g)
for a in [0,2.1,4.2]:tube('Steering spoke',[hub,(hub[0]+.185*math.cos(a),hub[1]+.185*math.sin(a),hub[2])],.019,'Trim',g)
box('Cab partition lower',(.30,-3.96,1.04),(1.73,.06,1.12),'Cabin',.04,tint=(.24,.25,.25))
mesh('Cab protective glass',[(-.30,-3.95,1.6),(1.12,-3.95,1.6),(1.12,-3.95,2.25),(-.30,-3.95,2.25)],[(0,1,2,3)],'Glass')
tube('Cab partition top rail',[(-.33,-3.96,1.49),(-.33,-3.96,2.26),(1.12,-3.96,2.26)],.023,'Metal')
print('GEOMETRY interior ready',flush=True)
# Wheels: lathed tyre profiles, lug nuts, dish, recessed vents, tread channels.
wheel_centers={}
for axle,yy in [('F',-2.95),('R',2.95)]:
 for side,label in [(1,'L'),(-1,'R')]:
  g='Wheel_'+axle+label;cx=side*1.104;wheel_centers[g]=(cx,yy,.49)
  # lathe around X; max radius .49 exactly => tyres on ground.
  profile=[(-.16,.285),(-.177,.32),(-.176,.40),(-.160,.453),(-.132,.478),(-.092,.49),(.092,.49),(.132,.478),(.160,.453),(.176,.40),(.177,.32),(.16,.285)]
  v=[];N=144
  for x,r in profile:
   for j in range(N):a=2*math.pi*j/N;v.append((cx+x,yy+r*math.cos(a),.49+r*math.sin(a)))
  f=[]
  for i in range(len(profile)):
   for j in range(N):f.append((i*N+j,i*N+(j+1)%N,((i+1)%len(profile))*N+(j+1)%N,((i+1)%len(profile))*N+j))
  o=mesh('Radial commercial tyre',v,f,'Trim',group=g)
  for face in o.data.polygons:face.use_smooth=True
  # Deep circumferential tread channels and fine transverse sipes.
  for xx in [-.095,-.032,.032,.095]:torus('Tread channel',(cx+xx,yy,.49),.487,.0038,'Trim','X',g,144,6)
  for j in range(84):
   a=2*math.pi*j/84
   for xx in [-.13,.13]:
    pts=[]
    for k in range(3):
     x=cx+xx+(k-1)*.018;aa=a+(k-1)*.018;pts.append((x,yy+.489*math.cos(aa),.49+.489*math.sin(aa)))
    tube('Tread siping',pts,.0028,'Trim',g)
  outer=cx+side*.175
  torus('Raised sidewall bead',(outer,yy,.49),.416,.003,'Trim','X',g,144,6)
  torus('Rim rolled flange',(outer+side*.004,yy,.49),.302,.015,'Metal','X',g,96,12)
  cyl('Brake disc',(cx,yy,.49),.277,.16,'Metal','X',64,g)
  cyl('Pressed alloy rim',(outer-side*.022,yy,.49),.29,.05,'Metal','X',96,g)
  cyl('Wheel dish',(outer+side*.007,yy,.49),.185,.055,'Metal','X',64,g)
  for j in range(10):
   a=2*math.pi*j/10;y=yy+.231*math.cos(a);z=.49+.231*math.sin(a)
   cyl('Wheel cooling aperture',(outer+side*.010,y,z),.032,.008,'Trim','X',24,g)
   torus('Vent stamped edge',(outer+side*.015,y,z),.032,.003,'Metal','X',g,24,6)
   a+=.11;y=yy+.137*math.cos(a);z=.49+.137*math.sin(a)
   cyl('Lug recess',(outer+side*.035,y,z),.024,.012,'Trim','X',20,g)
   cyl('Hex wheel lug',(outer+side*.045,y,z),.016,.028,'Metal','X',6,g)
  cyl('Axle grease hub',(outer+side*.034,yy,.49),.095,.088 if axle=='R' else .045,'Metal','X',64,g)
  for zz in [.245]:pass
  box('Mud flap',(side*1.11,yy+.61,.30),(.31,.034,.32),'Trim',.012)
print('GEOMETRY wheels ready',flush=True)
# Consolidate rigid assemblies; apply all modifiers and transforms, then place exact origins.
assembled=[]
for name,objs in parts.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.select_set(True)
 bpy.context.view_layer.objects.active=objs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 if name in wheel_centers:origin=wheel_centers[name]
 elif name=='SteeringWheel':origin=hub
 elif name.startswith('Door_'):
  idx=int(name.split('_')[1])-1;a,b=DOORS[idx//2];origin=(-1.265,a if idx%2==0 else b,.31)
 else:origin=(0,0,0)
 S.cursor.location=origin;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Guarantee every colour-aware mesh corner has a white default.
 if not o.data.color_attributes.get('Tint'):
  col=o.data.color_attributes.new(name='Tint',type='FLOAT_COLOR',domain='CORNER')
  for c in col.data:c.color=(1,1,1,1)
 tri=o.modifiers.new('Runtime triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
 assembled.append(o)
# Enforce game geometry budget after assembly while preserving assembly/UV boundaries.
raw_tri=sum(len(o.data.polygons) for o in assembled)
print('RAW_TRIANGLES',raw_tri,flush=True)
if raw_tri>126000:
 ratio=117000/raw_tri
 for ob in assembled:
  bpy.context.view_layer.objects.active=ob
  dec=ob.modifiers.new('Game density optimization','DECIMATE');dec.ratio=ratio;dec.use_collapse_triangulate=True
  bpy.ops.object.modifier_apply(modifier=dec.name)
# Remove collapsed bevel/cap triangles, then enforce exact tyre-ground contact.
for ob in assembled:
 bm=bmesh.new();bm.from_mesh(ob.data)
 bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges))
 bad=[f for f in bm.faces if f.calc_area()<1e-12]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(ob.data);bm.free();ob.data.update()
 if ob.name.startswith('Wheel_'):
  k=.49/(-min(v.co.z for v in ob.data.vertices))
  for v in ob.data.vertices:v.co.y*=k;v.co.z*=k
 # Any dissolved n-gons are deterministically triangulated.
 bpy.context.view_layer.objects.active=ob
 tri=ob.modifiers.new('Final runtime triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
 wn=ob.modifiers.new('Final weighted normals','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=40;bpy.ops.object.modifier_apply(modifier=wn.name)
# Attachment empties use authored coordinates converted by standard glTF exporter.
sockets={'Seat_Driver':(.66,-4.45,.94),'Door_Front':(-1.31,-4.65,.34),'Exhaust':(.83,6.30,.26),'Light_Head_L':(.93,-5.68,.77),'Light_Head_R':(-.93,-5.68,.77),'Light_Tail_L':(1.12,6.37,.91),'Light_Tail_R':(-1.12,6.37,.91)}
for name,loc in sockets.items():o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=loc;assembled.append(o)
# Make the assembled nose-to-tail length exactly 12.0 m without changing axle spacing.
all_world=[o.matrix_world@v.co for o in assembled if o.type=='MESH' for v in o.data.vertices]
ymin=min(v.y for v in all_world);ymax=max(v.y for v in all_world)
def fit_y(y):
 if y < -2.95:return -2.95+(y+2.95)*2.70/(-ymin-2.95)
 if y > 2.95:return 2.95+(y-2.95)*3.40/(ymax-2.95)
 return y
for ob in assembled:
 old=ob.location.copy();new=old.copy();new.y=fit_y(old.y)
 if ob.type=='MESH' and not ob.name.startswith('Wheel_'):
  for v in ob.data.vertices:
   world=v.co+old;world.y=fit_y(world.y);v.co=world-new
  ob.data.update()
 ob.location=new
bpy.context.view_layer.update()
# Paint exporter factor is patched in the GLB to avoid exporter versions baking the tint.
paints=[{'name':'Porcelain','linear_rgb':[.8,.8,.8]},{'name':'Midnight blue','linear_rgb':[.025,.075,.18]},{'name':'Transit red','linear_rgb':[.50,.025,.035]},{'name':'Forest green','linear_rgb':[.025,.19,.105]},{'name':'Warm silver','linear_rgb':[.43,.46,.47]},{'name':'Saffron','linear_rgb':[.83,.38,.028]}]
# Bake material texture is neutral; disconnect tint for predictable standard glTF export.
pm=mats['Paint'];pn=pm.node_tree.nodes;pp=next(n for n in pn if n.type=='BSDF_PRINCIPLED');pt=pn['PaintTint'];base=pt.inputs[1].links[0].from_socket;pm.node_tree.links.new(base,pp.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in assembled:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'city-bus.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_attributes=False)
# Add default tint (preserving the neutral base texture) directly to serialized glTF JSON.
import struct
f=ROOT/'city-bus.glb';data=f.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);binary=data[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.8,.8,.8,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=False
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);f.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+binary)
pm.node_tree.links.new(pt.outputs[0],pp.inputs['Base Color'])
tri_count=sum(len(o.data.polygons) for o in assembled if o.type=='MESH')
def glcoord(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[o.matrix_world@Vector(c) for o in assembled if o.type=='MESH' for c in o.bound_box];gp=[glcoord(v) for v in pts]
manifest={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','wheelbase_m':5.9,'body_dimensions_m':[2.55,3.05,12.0],'bbox':{'min':[min(p[i] for p in gp) for i in range(3)],'max':[max(p[i] for p in gp) for i in range(3)]},'triangles':tri_count,'materials':[m for m in mats],'nodes':[{'name':o.name,'type':o.type,'world_position':glcoord(o.location)} for o in assembled],'paints':paints}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(["Modern 12 m low-floor city bus; six sliding door leaves, furnished cabin, original unbranded face; Garage v2 axes and pivots.",f"Triangles: {tri_count:,}; GLB: {f.stat().st_size/1048576:.2f} MiB.","Materials: Paint, Trim, Metal, Interior, Accent, Glass, Lamps, Cabin (8); neutral Paint texture with editable default factor [0.80,0.80,0.80].","Textures: Cycles GPU baked shared tiles; base colour 1024², tangent normal 512², ORM 512²; all images embedded.","Poly Haven CC0 ids: metal_plate, denim_fabric, rubberized_track; https://polyhaven.com/license (source maps via dl.polyhaven.org).","Differences: original lamp/fascia shapes; abstract LED raster without text; simplified underbody and seat upholstery; mirrors extend beyond nominal body width."])+ '\n')
print('COMPLETE_GLB',tri_count,f.stat().st_size,flush=True)
# Presentation scene (excluded from export).
ground=bpy.data.materials.new('Studio ground');ground.diffuse_color=(.34,.37,.40,1);ground.use_nodes=True;ground.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.83
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));bpy.context.object.name='Render ground';bpy.context.object.data.materials.append(ground)
S.world=bpy.data.worlds.new('Soft overcast daylight');S.world.use_nodes=True;S.world.node_tree.nodes['Background'].inputs[0].default_value=(.70,.78,.88,1);S.world.node_tree.nodes['Background'].inputs[1].default_value=.55
ld=bpy.data.lights.new('Large daylight softbox','AREA');lo=bpy.data.objects.new('Large daylight softbox',ld);bpy.context.collection.objects.link(lo);lo.location=(-7,-7,11);ld.energy=2400;ld.shape='DISK';ld.size=8
ld=bpy.data.lights.new('Rim sky','AREA');lo=bpy.data.objects.new('Rim sky',ld);bpy.context.collection.objects.link(lo);lo.location=(6,5,9);ld.energy=1700;ld.size=7
for o in [o for o in S.objects if o.type=='LIGHT']:o.rotation_euler=(Vector((0,0,1.1))-o.location).to_track_quat('-Z','Y').to_euler()
camd=bpy.data.cameras.new('Presentation Camera');cam=bpy.data.objects.new('Presentation Camera',camd);bpy.context.collection.objects.link(cam);S.camera=cam;camd.type='ORTHO'
def camera(loc,target,scale):cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=scale
camera((-13.5,-16.0,8.7),(0,.20,1.3),15.1)
S.render.resolution_x=1920;S.render.resolution_y=1080;S.render.resolution_percentage=100;S.cycles.samples=32
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'city-bus.blend'))
def render(name):S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)
render('final.png')
camera((-17,.35,1.52),(0,.35,1.52),13.5);render('side.png')
camera((-13.5,-16,8.7),(0,.20,1.3),15.1)
clay=bpy.data.materials.new('Clay review');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=.66
S.view_layers[0].material_override=clay;render('clay.png');S.view_layers[0].material_override=None
S.render.resolution_x=640;S.render.resolution_y=360;S.cycles.samples=12
for i,paint in enumerate(paints):pt.inputs[2].default_value=(*paint['linear_rgb'],1);render(f'paint-{i}.png')
pt.inputs[2].default_value=(.8,.8,.8,1)
# Contact sheet assembled with Blender image pixels; no external imaging dependency.
canvas=np.ones((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[:],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]=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()
# Additional diagnostic views for close-range and rear inspection.
S.render.resolution_x=1280;S.render.resolution_y=720;S.cycles.samples=24
camera((11,15,6.5),(0,.30,1.4),14.6);render('rear.png')
camera((-6,-8.5,3.4),(-.35,-3.85,1.2),5.4);render('close.png')
print('ALL_DELIVERABLES_READY',flush=True)
