"""Intercity touring coach, metres; Blender Z-up authoring, front -Y -> glTF +Z.
Clean rebuild: blender -b --python-exit-code 1 --python build_intercity-coach.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=170;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=.22;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),.24,.28,'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',(.47,.48,.46),.36,.65,'metal_plate',.02),('Glass',(.075,.105,.12),.115,.15,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=(.85,.85,.86,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=.86;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)

# Coach architecture: front -Y, three axle centres symmetric about the required origin.
AXLES=[-3.72,2.48,3.72]
DOORS=[(-5.78,-4.84),(-.62,.27)]
box('Ladder chassis',(0,0,.34),(2.20,11.96,.20),'Trim',.06)
box('Raised aisle and left seat deck',(.435,.50,1.405),(1.51,10.72,.115),'Cabin',.028,tint=(.45,.45,.43))
for a,b in [(-4.86,-.62),(.27,5.86)]:box('Right seat deck with entry stairwell',(-.755,(a+b)/2,1.405),(.87,b-a,.115),'Cabin',.018,tint=(.45,.45,.43))
box('Driver cab floor',(.30,-5.24,.56),(1.70,1.64,.12),'Cabin',.03,tint=(.38,.38,.37))
box('Headliner',(0,.35,3.41),(2.35,11.30,.085),'Cabin',.06,tint=(1,1,.95))
# Continuous roof has a shallow crosswise crown and rounded end shoulders.
verts=[];rings=[]
for y,w,z in [(-5.45,1.255,3.51),(5.78,1.255,3.51),(5.98,1.244,3.50),(6.10,1.212,3.46),(6.18,1.145,3.39)]:
 ring=[]
 for j in range(25):
  a=math.pi*j/24;ring.append(len(verts));verts.append((w*math.cos(a),y,z+.075*math.sin(a)))
 rings.append(ring)
faces=[]
for a,b in zip(rings,rings[1:]):
 for j in range(24):faces.append((a[j],b[j],b[j+1],a[j+1]))
faces+=[tuple(rings[-1])]
o=mesh('Crowned roof skin',verts,faces,'Paint',.012)
sol=o.modifiers.new('Roof thickness','SOLIDIFY');sol.thickness=.032;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name)
# Compound curved brow follows the glass exactly and blends into the crown.
v=[]
for i in range(9):
 t=i/8;h=math.sin(t*math.pi/2);w=1.10+(1.255-1.10)*h
 for j in range(33):
  q=-1+2*j/32;x=w*q
  y=(-5.795+.19*abs(q)**4)*(1-t)+(-5.45)*t
  z=3.42+(3.51+.075*math.sqrt(max(0,1-q*q))-3.42)*h
  v.append((x,y,z))
f=[(i*33+j,i*33+j+1,(i+1)*33+j+1,(i+1)*33+j) for i in range(8) for j in range(32)]
o=mesh('Compound curved front roof brow',v,f,'Paint')
sol=o.modifiers.new('Brow formed shell','SOLIDIFY');sol.thickness=.032;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name)
for f in o.data.polygons:f.use_smooth=True

def edge_y(z):
 rows=[(1.22,-5.993),(1.70,-5.99),(2.65,-5.85),(3.29,-5.675),(3.42,-5.605)]
 if z<=rows[0][0]:return rows[0][1]
 for (z0,y0),(z1,y1) in zip(rows,rows[1:]):
  if z<=z1:return y0+(y1-y0)*(z-z0)/(z1-z0)
 return rows[-1][1]

def door_leading_y(z):return edge_y(z)+.213

# Side belt gently rises behind the front entry and falls into the rear quarter.
def belt(y):
 if y < -4.35:
  t=max(0,min(1,(y+5.93)/1.58));t=t*t*(3-2*t);return 1.20+1.18*t
 if y > 5.15:
  t=max(0,min(1,(y-5.15)/1.02));t=t*t*(3-2*t);return 2.38-.83*t
 return 2.38

def panel(side,a,b):
 ys=[a+(b-a)*j/max(2,int((b-a)/.04)) for j in range(max(2,int((b-a)/.04))+1)]
 v=[];K=7
 for inward in [0,.035]:
  for y in ys:
   bottom=.30
   for ay in AXLES:
    d=abs(y-ay)
    if d<.557:bottom=max(bottom,.50+math.sqrt(.557**2-d*d))
   for k in range(K):
    z=bottom+(1.432-bottom)*k/(K-1)
    x=side*(1.245+.030*math.sin(math.pi*(z-.30)/1.132)-inward)
    v.append((x,y,z))
 L=K*len(ys);f=[]
 for i in range(len(ys)-1):
  for j in range(K-1):
   k=i*K+j;f.extend([(k,k+K,k+K+1,k+1),(L+k+1,L+k+K+1,L+k+K,L+k)])
  k=i*K;f.extend([(k,L+k,L+k+K,k+K),(k+K-1,k+2*K-1,L+k+2*K-1,L+k+K-1)])
 for j in range(K-1):f.extend([(j,j+1,L+j+1,L+j),(L-K+j,2*L-K+j,2*L-K+j+1,L-K+j+1)])
 if side==-1:f=[tuple(reversed(q)) for q in f]
 o=mesh('Crowned luggage and wheelarch skin',v,f,'Paint',.005)
 for face in o.data.polygons:
  for li in face.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-.30)/1.132)

for side in [-1,1]:
 intervals=[(-5.96,6.09)]
 if side==-1:
  intervals=[];a=-5.96
  for l,r in DOORS:intervals.append((a,l));a=r
  intervals.append((a,6.09))
 for a,b in intervals:
  cuts=[a]+[y for y in [-4.43,-3.01,-1.5,.02,1.58,3.13,4.42,5.9] if a<y<b]+[b]
  for l,r in zip(cuts,cuts[1:]):
   panel(side,l+.006,r-.006)
   if r-l>.7 and not any(l<ay<r for ay in AXLES):
    yy=(l+r)/2
    box('Flush luggage latch pocket',(side*1.263,yy,.61),(.016,.135,.067),'Trim',.011)
    box('Flush luggage handle',(side*1.276,yy,.625),(.014,.093,.024),'Metal',.005)
    for yz in [l+.10,r-.10]:cyl('Panel hinge pin',(side*1.268,yz,1.365),.013,.007,'Metal','X',12)
  # Painted upper waist and slim alloy swoop: closed strip between waist and windows.
  ys=[a+(b-a)*j/max(2,int((b-a)/.05)) for j in range(max(2,int((b-a)/.05))+1)]
  v=[(side*(1.256+.011*math.sin((z-1.425)*math.pi/max(.02,belt(y)-1.425))),y,z) for y in ys for z in [1.425,max(1.43,belt(y))]]
  f=[(2*i,2*i+2,2*i+3,2*i+1) if side==1 else (2*i+1,2*i+3,2*i+2,2*i) for i in range(len(ys)-1)]
  o=mesh('Sculpted upper waist',v,f,'Paint');sol=o.modifiers.new('Waist skin','SOLIDIFY');sol.thickness=.025;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name)
  v=[(side*1.27,y,belt(y)+dz) for y in ys for dz in [0,.039]]
  mesh('Fine satin belt trim',v,[(2*i,2*i+2,2*i+3,2*i+1) for i in range(len(ys)-1)],'Accent')
 # upper panoramic glazing divided by deep black pillars, no backing across windows
 cuts=[-5.88,-4.82,-3.26,-1.70,-.14,1.42,2.98,4.54,6.06]
 if side==-1:cuts=sorted(set(cuts+list(DOORS[1])))
 for a,b in zip(cuts,cuts[1:]):
  # The front door carries its own glazing.
  if a < -5.8:continue
  if side==-1 and a>=DOORS[1][0] and b<=DOORS[1][1]:continue
  yy=[a+.012+(b-a-.024)*j/20 for j in range(21)]
  v=[]
  for y in yy:
   for z in [belt(y)+.043,3.421]:
    x=side*(1.251-.043*((z-2.10)/1.35)**2);v.append((x,y,z))
  f=[(2*i,2*i+2,2*i+3,2*i+1) if side==1 else (2*i+1,2*i+3,2*i+2,2*i) for i in range(20)]
  mesh('Panoramic tinted window',v,f,'Glass')
  for y in [a,b]:
   lo=belt(y)+.03
   box('Window black ceramic mullion',(side*1.233,y,(lo+3.43)/2),(.052,.067,3.43-lo),'Trim',.012)
 box('Continuous glazing top seal',(side*1.216,.3125,3.425),(.062,11.435,.079),'Trim',.024)
 box('Painted roof edge rail',(side*1.235,.3125,3.493),(.073,11.435,.065),'Paint',.024)
 # Interior window rail and sidewall below window sill.
 for a,b in ([(-4.51,-.65),(.30,6.01)] if side==-1 else [(-4.51,6.01)]):box('Upholstered cabin sidewall',(side*1.188,(a+b)/2,1.80),(.07,b-a,.66),'Cabin',.025,tint=(.85,.85,.79))
 for ay in AXLES:
  pts=[(side*1.264,ay+.560*math.cos(t),.50+.560*math.sin(t)) for t in [math.pi*j/64 for j in range(65)]]
  tube('Rolled wheel arch edge',pts,.016,'Paint')
  arc=[math.pi*j/64 for j in range(65)]
  v=[(side*.84,ay,.29)]+[(side*.84,ay+.556*math.cos(t),.50+.556*math.sin(t)) for t in arc]
  mesh('Opaque wheel tub rear wall',v,[(0,j+1,j+2) for j in range(64)],'Trim')
  v=[(side*x,ay+.553*math.cos(t),.50+.553*math.sin(t)) for x in [.84,1.245] for t in arc]
  mesh('Curved wheel tub',v,[(j,j+1,j+66,j+65) for j in range(64)],'Trim')
  box('Trailing rubber mud flap',(side*1.08,ay+.61,.34),(.35,.025,.30),'Trim',.007)
 for yy in [-4.55,-2.0,.98,4.63,5.98]:
  box('Side repeater gasket',(side*1.269,yy,.62),(.018,.12,.042),'Trim',.01)
  box('Amber side repeater',(side*1.280,yy,.624),(.008,.089,.025),'Lamps',.007,tint=(1,.35,.01))
 # Rear cooling intakes above rear wheels and large aft radiator service grille.
 for yy in [2.47,3.71]:
  box('High engine cooling recess',(side*1.264,yy,1.285),(.019,1.12,.232),'Trim',.017)
  for zz in [1.21,1.26,1.31,1.36]:box('Horizontal aluminium louver',(side*1.280,yy,zz),(.026,1.07,.019),'Accent',.006)
 box('Rear radiator recess',(side*1.266,5.36,.90),(.027,1.27,.81),'Trim',.15)
 for j in range(11):
  zz=.55+j*.062;ww=1.10-.26*abs(j-5)/5
  box('Radiator swept louver',(side*1.284,5.36,zz),(.034,ww,.026),'Accent',.009)
 for yy in [4.82,5.88]:cyl('Radiator quarter-turn latch',(side*1.30,yy,.92),.018,.007,'Metal','X',16)
# Bespoke curved side corner glass closes the junction to the swept windshield.
for side in [-1,1]:
 rows=[(1.22,1.20),(1.70,1.208),(2.65,1.203),(3.29,1.165),(3.42,1.10)]
 v=[]
 for z,w in rows:
  a=edge_y(z);b=door_leading_y(z)-.023 if side==-1 else -4.82
  v.extend([(side*w,a,z),(side*(1.251-.043*((z-2.10)/1.35)**2),b,z)])
 f=[(i*2,i*2+1,i*2+3,i*2+2) if side==1 else (i*2+2,i*2+3,i*2+1,i*2) for i in range(4)]
 mesh('Wrapped driver side quarter glass',v,f,'Glass')
 if side==-1:
  tube('Entry wraparound glass rear seal',[(side*(1.251-.043*((z-2.10)/1.35)**2),door_leading_y(z)-.010,z) for z,w in rows],.025,'Trim')
 tube('Corner glazing upper frame',[(side*1.10,-5.605,3.42),(side*1.208,-4.82,3.42)],.026,'Trim')
# Fixed transom above the shorter high-floor middle exit.
a,b=DOORS[1]
mesh('Middle exit fixed upper transom',[(-1.242,a,2.70),(-1.242,b,2.70),(-1.208,b,3.421),(-1.208,a,3.421)],[(3,2,1,0)],'Glass')
box('Middle exit transom seal',(-1.248,(a+b)/2,2.68),(.046,b-a,.045),'Trim',.010)
# Coach entry doors: one full leaf per opening, both move independently.
for di,(a,b) in enumerate(DOORS):
 g=f'Door_{di+1}';x=-1.263;mid=(a+b)/2;w=b-a-.022
 for y in [a+.011,b-.011]:box('Door perimeter upright',(x,y,1.86),(.065,.050,3.12),'Trim',.013,g)
 for z in [.315,.89,2.10,3.408]:box('Door cross rail',(x,mid,z),(.064,w,.044),'Trim',.010,g)
 box('Painted entry kick panel',(x,mid,.601 if di==0 else 1.199),(.049,w-.055,.514 if di==0 else 1.710),'Paint',.025,g)
 for z0,z1 in ([ (.92,2.07),(2.13,3.375)] if di==0 else [(2.13,3.375)]):
  mesh('Door tinted glass',[(x-.011,a+.047,z0),(x-.011,b-.047,z0),(x-.011,b-.047,z1),(x-.011,a+.047,z1)],[(3,2,1,0)],'Glass',group=g)
 box('Door pull recess',(x-.025,b-.125,1.20),(.028,.067,.19),'Trim',.016,g)
 box('Door pull handle',(x-.049,b-.125,1.21),(.021,.022,.128),'Metal',.009,g)
 for z in [.55,2.98]:cyl('Door hinge',(x+.012,b-.024 if di==0 else a+.024,z),.028,.16,'Metal','Z',20,g)
 tube('Door interior grab rail',[(x+.09,a+.18,.94),(x+.09,a+.18,1.61),(x+.09,b-.16,1.72)],.017,'Accent',g)
 box('Door inside kick card',(x+.049,mid,.62),(.025,w-.10,.46),'Cabin',.027,g,tint=(.45,.45,.42))
 # stair treads climb inboard from the threshold to the high floor
 for j in range(4):
  xx=-1.15+j*.24;zz=.38+j*.29
  box('Entry stair tread',(xx,mid,zz),(.245,w-.05,.055),'Cabin',.008,tint=(.38,.39,.38))
  box('Stair tread nosing',(xx-.12,mid,zz+.027),(.023,w-.065,.017),'Metal',.004)
  box('Entry stair riser',(xx+.118,mid,zz+.145),(.018,w-.05,.26),'Trim',.004)
 for y in [a-.055,b+.055]:tube('Entry stainless handrail',[(-1.10,y,.47),(-.89,y,1.28),(-.36,y,1.83)],.019,'Metal')
 box('Door overhead mechanism',(-1.11,mid,3.33 if di==0 else 2.59),(.18,w,.18),'Trim',.045)
# Front nose loft: broad curved glass, softly sculpted independent original fascia.
def front_loft(name,rows,mat):
 v=[]
 for z,y,w in rows:
  for j in range(33):
   x=-w+2*w*j/32;v.append((x,y+.19*(abs(x)/w)**4,z))
 f=[]
 for i in range(len(rows)-1):
  for j in range(32):k=i*33+j;f.append((k,k+1,k+34,k+33))
 o=mesh(name,v,f,mat)
 if mat!='Glass':
  sol=o.modifiers.new('Formed skin thickness','SOLIDIFY');sol.thickness=.035;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name);bevel(o,.012,3)
 return o
front_loft('Lower nose bumper',[(.29,-6.14,1.12),(.37,-6.24,1.22),(.61,-6.245,1.25),(.73,-6.22,1.25)],'Paint')
front_loft('Front sculpted fascia',[(.73,-6.22,1.25),(.85,-6.195,1.245),(1.015,-6.18,1.22),(1.10,-6.185,1.21)],'Paint')
front_loft('Windshield lower black frit',[(1.10,-6.187,1.21),(1.22,-6.19,1.205)],'Trim')
front_loft('Curved panoramic windshield',[(1.22,-6.183,1.20),(1.70,-6.18,1.208),(2.65,-6.04,1.203),(3.29,-5.865,1.165),(3.42,-5.795,1.10)],'Glass')
front_loft('Windshield top black frit',[(3.405,-5.804,1.108),(3.424,-5.793,1.10)],'Trim')
for side in [-1,1]:
 tube('Sculpted A pillar',[(side*1.213,-5.998,1.07),(side*1.234,-5.989,1.69),(side*1.221,-5.85,2.64),(side*1.179,-5.679,3.29),(side*1.105,-5.605,3.44)],.044,'Paint')
 tube('Windshield ceramic edge',[(side*1.191,-6.00,1.20),(side*1.199,-5.987,1.70),(side*1.19,-5.85,2.65),(side*1.152,-5.675,3.29)],.021,'Trim')
 box('Lower front corner return',(side*1.196,-6.004,.76),(.139,.235,.91),'Paint',.060)
 # Chamfered horizontal projector pods with a separate slim running lamp.
 outline=[(.64,.73),(.69,.935),(1.12,.958),(1.19,.89),(1.16,.73),(1.04,.675),(.72,.675)]
 v=[(side*x,-6.21+.19*(x/1.25)**4+dy,z) for dy in [.013,-.019] for x,z in outline];n=len(outline)
 f=[tuple(range(n)),tuple(reversed(range(n,2*n)))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 mesh('Original headlamp surround',v,f,'Trim',.018)
 for xx,rr in [(side*.80,.071),(side*1.025,.061)]:
  yy=-6.21+.19*(abs(xx)/1.25)**4
  cyl('Headlamp reflector',(xx,yy-.035,.816),rr,.018,'Metal','Y',48)
  cyl('Headlamp warm lens',(xx,yy-.047,.816),rr*.68,.009,'Lamps','Y',48,tint=(1,.88,.65))
  torus('Projector inner bezel',(xx,yy-.049,.816),rr*.86,.006,'Metal','Y',segments=48,minorseg=8)
 mesh('Clear headlamp lens',[(side*x,-6.21+.19*(x/1.25)**4-.057,z) for x,z in outline],[tuple(reversed(range(n))) if side==1 else tuple(range(n))],'Glass')
 tube('Swept warm daytime running light',[(side*.70,-6.239,.97),(side*.91,-6.191,1.004),(side*1.14,-6.119,1.049)],.012,'Lamps')
 box('Front amber indicator',(side*1.16,-6.128,.622),(.09,.027,.039),'Lamps',.012,tint=(1,.35,.01))
 box('Fog lamp dark pocket',(side*.99,-6.183,.433),(.215,.04,.085),'Trim',.030)
 box('Fog lamp lens',(side*.99,-6.208,.433),(.125,.01,.031),'Lamps',.014,tint=(1,.88,.65))
 # Swan-neck mirrors, mounted high, hanging down beside the driver's view.
 tube('Mirror cast support',[(side*1.15,-5.66,3.14),(side*1.30,-5.98,3.15),(side*1.50,-6.20,3.13),(side*1.54,-6.23,2.98)],.041,'Trim')
 box('Mirror painted back',(side*1.535,-6.207,2.73),(.176,.21,.53),'Paint',.085)
 box('Mirror black rubber frame',(side*1.535,-6.09,2.73),(.158,.029,.48),'Trim',.05)
 box('Reflective main mirror',(side*1.535,-6.070,2.795),(.130,.009,.29),'Metal',.037)
 box('Reflective convex lower mirror',(side*1.535,-6.070,2.57),(.13,.009,.13),'Metal',.028)
 # Wipers lie across the lower windshield, with pivots connected to scuttle.
 tube('Wiper arm',[(side*.77,-6.192,1.24),(side*.52,-6.207,1.37),(side*.14,-6.193,1.53)],.009,'Trim')
 tube('Wiper rubber blade',[(side*.58,-6.212,1.42),(side*.06,-6.193,1.55)],.013,'Trim')
 cyl('Wiper spindle',(side*.77,-6.196,1.23),.026,.018,'Trim','Y',24)
# Nose centre has a subtle satin wing and a low three-slot intake.
front_loft('Satin front service accent',[(.985,-6.213,.61),(1.041,-6.202,.66)],'Accent')
box('Lower grille recess',(0,-6.252,.455),(1.19,.022,.145),'Trim',.029)
for z in [.405,.454,.504]:box('Lower grille blade',(0,-6.270,z),(1.10,.015,.011),'Accent',.004)
front_loft('Lower bumper satin highlight',[(.327,-6.213,1.12),(.355,-6.242,1.18)],'Accent')
# Rear quarters and rear service panels close the coach.
for side in [-1,1]:
 box('Rounded rear pillar',(side*1.174,6.077,1.96),(.193,.263,2.98),'Paint',.088)
box('Rear engine door',(0,6.151,1.04),(2.30,.17,1.39),'Paint',.115)
box('Rear upper fascia',(0,6.153,2.004),(2.30,.143,.585),'Paint',.062)
box('Rear bumper',(0,6.143,.363),(2.37,.21,.18),'Paint',.073)
for x in [-1.135,1.135]:box('Rear glass frame upright',(x,6.106,2.877),(.129,.124,1.15),'Trim',.039)
for z in [2.324,3.397]:box('Rear glass frame horizontal',(0,6.107,z),(2.30,.13,.092),'Trim',.034)
mesh('Rear window',[(-1.073,6.182,2.371),(1.073,6.182,2.371),(1.060,6.182,3.352),(-1.060,6.182,3.352)],[(3,2,1,0)],'Glass')
box('Engine service door shutline',(0,6.245,1.699),(2.10,.008,.012),'Trim',.003)
box('Rear cooling intake',(0,6.242,.746),(1.26,.02,.49),'Trim',.08)
for z in [.55+i*.055 for i in range(8)]:box('Rear radiator louver',(0,6.258,z),(1.13,.025,.020),'Accent',.007)
for side in [-1,1]:
 box('Rear lamp bezel',(side*1.067,6.234,1.015),(.205,.05,.665),'Trim',.065)
 for z,h,col in [(.77,.15,(.8,.02,.012)),(.982,.18,(.8,.02,.012)),(1.158,.07,(1,.35,.01)),(1.255,.08,(.7,.74,.67))]:
  box('Rear lamp optical lens',(side*1.067,6.270,z),(.148,.025,h),'Lamps',.031,tint=col)
 for z in [.73,.79,.85,.94,1.,1.06]:box('Lamp lens moulded ribs',(side*1.067,6.286,z),(.130,.006,.005),'Lamps',.002,tint=(.8,.02,.012))
 cyl('Engine door latch',(side*.79,6.25,1.33),.023,.008,'Metal','Y',24)
cyl('Exhaust stainless tip',(.85,6.10,.263),.048,.23,'Metal','Y',32)
cyl('Exhaust dark bore',(.85,6.222,.263),.036,.005,'Trim','Y',32)
# Low streamlined climate pod (3.70m total height), access hatches and fan grilles.
box('Climate pod seal',(0,.75,3.568),(1.75,4.59,.062),'Trim',.15)
# Lofted pod has rounded plan corners, an inclined shoulder and a shallow crowned lid.
v=[];rings=[]
for z,w,length,radius in [(3.512,1.77,4.56,.28),(3.574,1.87,4.70,.36),(3.668,1.70,4.46,.38),(3.690,1.47,4.12,.38)]:
 ring=[]
 for cx,cy,start in [(w/2-radius,length/2-radius,0),(-w/2+radius,length/2-radius,90),(-w/2+radius,-length/2+radius,180),(w/2-radius,-length/2+radius,270)]:
  for j in range(9):
   a=math.radians(start+j*90/8);ring.append(len(v));v.append((cx+radius*math.cos(a),.75+cy+radius*math.sin(a),z))
 rings.append(ring)
f=[]
for lower,upper in zip(rings,rings[1:]):
 for j in range(36):k=(j+1)%36;f.append((lower[j],lower[k],upper[k],upper[j]))
f.extend([tuple(reversed(rings[0])),tuple(rings[-1])])
o=mesh('Aerodynamic climate pod',v,f,'Paint',.009)
for face in o.data.polygons:face.use_smooth=True
box('Climate condenser recess',(0,1.12,3.691),(1.22,2.22,.016),'Trim',.08)
for yy in [.39,1.11,1.83]:
 cyl('Condenser fan well',(0,yy,3.70),.28,.008,'Trim','Z',48)
 for rr in [.075,.125,.175,.225,.275]:torus('Fan guard ring',(0,yy,3.709),rr,.004,'Accent','Z',segments=48,minorseg=6)
 for j in range(8):
  a=j*math.pi/4;tube('Fan guard radial',[(0,yy,3.710),(.276*math.cos(a),yy+.276*math.sin(a),3.710)],.0035,'Accent')
for side in [-1,1]:
 for y in [-1.25+i*.09 for i in range(45)]:box('Pod side louver',(side*.914,y,3.600),(.013,.028,.079),'Trim',.007)
for yy in [-4.04,4.74]:
 box('Roof escape hatch seal',(0,yy,3.581),(.94,.76,.030),'Trim',.06)
 box('Roof escape hatch lid',(0,yy,3.604),(.84,.66,.035),'Paint',.056)
 for x in [-.31,.31]:box('Hatch hinge',(x,yy+.30,3.633),(.09,.055,.026),'Metal',.009)
print('GEOMETRY exterior complete',flush=True)
# Touring interior: 2+2 reclining seats with headrests, centre aisle and overhead luggage racks.
def seat(x,y,driver=False):
 floor=.63 if driver else 1.463;mat='Trim' if driver else 'Interior'
 box('Seat pedestal',(x,y+.07,floor+.16),(.26,.28,.31),'Trim',.036)
 box('Seat polymer pan',(x,y,floor+.36),(.452,.48,.10),'Cabin',.044,tint=(.30,.31,.30))
 box('Upholstered seat cushion',(x,y-.016,floor+.418),(.421,.453,.116),mat,.054)
 back=box('Reclining upholstered back',(x,y+.206,floor+.865),(.435,.142,.865),mat,.065);back.rotation_euler.x=math.radians(-9)
 shell=box('Seat rear shell',(x,y+.303,floor+.831),(.455,.066,.80),'Cabin',.044,tint=(.34,.35,.34));shell.rotation_euler.x=math.radians(-9)
 box('Integrated headrest',(x,y+.269,floor+1.30),(.37,.17,.225),mat,.065)
 for sx in [-.224,.224]:
  tube('Armrest support',[(x+sx,y+.15,floor+.42),(x+sx,y+.12,floor+.68)],.013,'Metal')
  box('Padded armrest',(x+sx,y-.016,floor+.704),(.06,.37,.054),'Trim',.026)
 if not driver:
  box('Folded seatback tray',(x,y+.359,floor+.82),(.335,.026,.26),'Cabin',.021,tint=(.63,.65,.61))
  box('Seatback net pocket',(x,y+.36,floor+.48),(.32,.024,.16),'Trim',.022)
  tube('Seat shoulder grab',[(x-.13,y+.38,floor+1.10),(x-.13,y+.38,floor+1.19),(x+.13,y+.38,floor+1.19),(x+.13,y+.38,floor+1.10)],.011,'Accent')
for yy in [-4.14,-3.25,-2.36,-1.47,-.58,.31,1.20,2.09,2.98,3.87,4.76,5.63]:
 for side in [-1,1]:
  if side==-1 and -.90<yy<.50:continue
  for x in [side*.475,side*.972]:seat(x,yy)
for side in [-1,1]:
 box('Overhead luggage rack shelf',(side*.887,.67,3.106),(.58,10.57,.063),'Cabin',.031,tint=(.85,.84,.79))
 tube('Overhead rack curved lip',[(side*.593,-4.62,3.14),(side*.593,5.95,3.14)],.034,'Accent')
 for yy in [-4.4,-2.7,-1,.7,2.4,4.1,5.8]:
  tube('Overhead rack support',[(side*.62,yy,3.12),(side*.71,yy,3.38)],.014,'Metal')
  box('Individual reading light',(side*.85,yy,3.064),(.13,.07,.017),'Lamps',.009,tint=(.7,.74,.67))
 for yy in [-3.6,-.2,3.2]:box('Ceiling strip light',(side*.33,yy,3.352),(.035,2.75,.02),'Lamps',.006,tint=(.7,.74,.67))
seat(.69,-5.00,True)
box('Driver console base',(.44,-5.73,1.17),(1.38,.59,.37),'Trim',.12)
box('Padded dashboard top',(.16,-5.70,1.39),(1.88,.61,.127),'Trim',.062)
box('Instrument hood',(.67,-5.47,1.49),(.63,.31,.14),'Trim',.064)
box('Driver instrument screen',(.68,-5.304,1.496),(.365,.016,.074),'Lamps',.015,tint=(.09,.28,.35))
for x in [.40,.96]:cyl('Analogue instrument bezel',(x,-5.306,1.484),.049,.012,'Metal','Y',32)
for j in range(7):box('Console rocker switch',(.07+j*.058,-5.38,1.41),(.031,.026,.025),'Cabin',.005,tint=(.5,.52,.52))
for x in [-.50,1.01]:
 box('Dashboard air outlet',(x,-5.52,1.46),(.20,.12,.017),'Trim',.015)
 for j in range(5):box('Outlet grille vane',(x-.07+j*.035,-5.52,1.472),(.009,.091,.009),'Accent',.002)
tube('Steering column',[(.69,-5.39,.86),(.69,-5.23,1.49)],.044,'Trim')
hub=(.69,-5.19,1.54);g='SteeringWheel'
o=torus('Leather steering wheel rim',hub,.215,.021,'Trim','Z',g,64,12);o.rotation_euler.x=math.radians(24)
cyl('Steering wheel centre',hub,.072,.055,'Trim','Z',32,g)
for a in [0,2.1,4.2]:tube('Steering wheel spoke',[hub,(hub[0]+.195*math.cos(a),hub[1]+.195*math.sin(a)*math.cos(math.radians(24)),hub[2]+.195*math.sin(a)*math.sin(math.radians(24)))],.018,'Trim',g)
box('Driver side door card',(1.178,-5.22,1.02),(.09,1.05,.69),'Cabin',.036,tint=(.38,.39,.38))
box('Driver armrest',(1.109,-5.13,1.18),(.14,.50,.077),'Trim',.032)
box('Cab privacy bulkhead',(.47,-4.55,1.77),(1.46,.082,.66),'Cabin',.05,tint=(.69,.69,.65))
tube('Cab bulkhead polished handrail',[(-.27,-4.60,1.48),(-.27,-4.60,2.10),(1.12,-4.60,2.10)],.026,'Metal')
print('GEOMETRY cabin complete',flush=True)
# Wheels: lathed tyre profiles, lug nuts, dish, recessed vents, tread channels.
wheel_centers={}
for axle,yy in [('F',-3.72),('R',2.48),('R2',3.72)]:
 for side,label in [(1,'L'),(-1,'R')]:
  g='Wheel_'+('R'+label+'2' if axle=='R2' else 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=112
  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,112,6)
  for j in range(60):
   a=2*math.pi*j/60
   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,112,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];origin=(-1.263,b if idx==0 else a,.315)
 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)
 if name=='Door_1':
  a,b=DOORS[0]
  for v in o.data.vertices:
   world=o.matrix_world@v.co
   shift=door_leading_y(world.z)-a
   world.y+=shift*max(0,min(1,(b-world.y)/(b-a)))
   world.x+=.012+.043*((max(2.10,world.z)-2.10)/1.35)**2
   v.co=o.matrix_world.inverted()@world
 if name=='Door_2':
  for v in o.data.vertices:
   world=o.matrix_world@v.co
   if world.z>2.10:world.z=2.10+(world.z-2.10)*.56/1.33
   world.x+=.012+.043*((max(2.10,world.z)-2.10)/1.35)**2
   v.co=o.matrix_world.inverted()@world
 # 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':(.69,-5.00,1.09),'Door_Front':(-1.31,-5.31,.38),'Exhaust':(.85,6.22,.263),'Light_Head_L':(.91,-6.19,.816),'Light_Head_R':(-.91,-6.19,.816),'Light_Tail_L':(1.067,6.285,.982),'Light_Tail_R':(-1.067,6.285,.982)}
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)
# Exact nose-to-tail length and maximum height; axle origins remain untouched.
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);zmax=max(v.z for v in all_world)
def fit_y(y):
 if y < -3.72:return -3.72+(y+3.72)*2.53/(-ymin-3.72)
 if y > 3.72:return 3.72+(y-3.72)*2.53/(ymax-3.72)
 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)
   if world.z>3.40:world.z=3.40+(world.z-3.40)*.30/(zmax-3.40)
   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':'Pearl white','linear_rgb':[.85,.85,.86]},{'name':'Atlantic blue','linear_rgb':[.035,.075,.145]},{'name':'Burgundy','linear_rgb':[.27,.022,.034]},{'name':'Pine green','linear_rgb':[.027,.12,.087]},{'name':'Titanium silver','linear_rgb':[.36,.39,.43]},{'name':'Champagne','linear_rgb':[.52,.41,.25]}]
# 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/'intercity-coach.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/'intercity-coach.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']=[.85,.85,.86,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')
assert 70000 <= tri_count <= 130000, tri_count
assert f.stat().st_size <= 12000000, 'GLB exceeds game-weight budget'
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':7.44,'drive_axle_from_front_m':6.2,'body_dimensions_m':[2.55,3.70,12.50],'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.5 m high-floor intercity touring coach, three axles, two hinged entry leaves, furnished 2+2 cabin; Garage v2 axes and pivots.",f"Triangles: {tri_count:,}; GLB: {f.stat().st_size/1048576:.2f} MiB.","Materials (8): Paint, Trim, Metal, Interior, Accent, Glass, Lamps, Cabin; neutral Paint with factor [0.85,0.85,0.86], clearcoat 1.0.","Textures: Cycles OptiX GPU baked shared tiles; base colour 1024px, tangent normals 512px, ORM 512px; all embedded.","Poly Haven CC0 ids: metal_plate, denim_fabric, rubberized_track; licence https://polyhaven.com/license; source maps dl.polyhaven.org.","Differences: original fascia and lamp design; default pearl white; simplified undercarriage and seat stitching; mirrors extend beyond nominal 2.55 m shell 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
# Directional overcast sky: a bright horizon and broad cloud fields give the curved
# glazing real reflected variation without a non-CC0 photographic environment.
wn=S.world.node_tree;tc=wn.nodes.new('ShaderNodeTexCoord');sep=wn.nodes.new('ShaderNodeSeparateXYZ');wn.links.new(tc.outputs['Normal'],sep.inputs[0])
mapz=wn.nodes.new('ShaderNodeMapRange');mapz.inputs['From Min'].default_value=-1;mapz.inputs['From Max'].default_value=1;wn.links.new(sep.outputs['Z'],mapz.inputs['Value'])
sky=wn.nodes.new('ShaderNodeValToRGB');sky.color_ramp.elements.remove(sky.color_ramp.elements[1])
for i,(pos,col) in enumerate([(0,(.06,.075,.095,1)),(.43,(.16,.19,.23,1)),(.53,(1.1,1.16,1.22,1)),(1,(.30,.43,.62,1))]):
 e=sky.color_ramp.elements[0] if i==0 else sky.color_ramp.elements.new(pos);e.position=pos;e.color=col
wn.links.new(mapz.outputs['Result'],sky.inputs[0]);cloud=wn.nodes.new('ShaderNodeTexNoise');cloud.inputs['Scale'].default_value=3.5;cloud.inputs['Detail'].default_value=2;wn.links.new(tc.outputs['Normal'],cloud.inputs['Vector'])
cloudr=wn.nodes.new('ShaderNodeValToRGB');cloudr.color_ramp.elements[0].position=.35;cloudr.color_ramp.elements[0].color=(.22,.25,.30,1);cloudr.color_ramp.elements[1].position=.65;cloudr.color_ramp.elements[1].color=(1,1,1,1);wn.links.new(cloud.outputs['Fac'],cloudr.inputs[0])
mix=wn.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.7;wn.links.new(sky.outputs[0],mix.inputs[1]);wn.links.new(cloudr.outputs[0],mix.inputs[2]);wn.links.new(mix.outputs[0],wn.nodes['Background'].inputs[0])
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((-14.7,-17.8,5.2),(0,0,1.66),15.7)
S.render.resolution_x=1920;S.render.resolution_y=1080;S.render.resolution_percentage=100;S.cycles.samples=24
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'intercity-coach.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((-18,0,1.85),(0,0,1.85),13.8);render('side.png')
camera((-14.7,-17.8,5.2),(0,0,1.66),15.7)
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=(.85,.85,.86,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((12,17,7),(0,0,1.7),15.5);render('rear.png')
camera((-6,-10,4.2),(-.35,-4.7,1.7),6.1);render('close.png')
print('ALL_DELIVERABLES_READY',flush=True)
