"""Clean-room procedural European double decker; Blender 5.2, metres.
Run: blender -b --python-exit-code 1 --python build_double-decker.py
CC0 source maps downloaded on demand; Cycles GPU material bakes, GLB and renders.
"""
import bpy, math, os, json, struct, urllib.request, time
from mathutils import Vector
from math import sin,cos,pi,sqrt
ROOT=os.path.dirname(os.path.abspath(__file__));os.chdir(ROOT)
os.makedirs('textures',exist_ok=True)
START=time.time()
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.materials):bpy.data.materials.remove(d)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU';sc.cycles.samples=12;sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.view_settings.view_transform='AgX'
PAINTS=[('City red',[.62,.02,.02]),('Atlantic blue',[.018,.105,.28]),('Forest green',[.028,.20,.10]),('Champagne',[.55,.43,.27]),('Silver blue',[.48,.58,.67]),('Ivory',[.82,.76,.61])]
IDS=['metal_plate','rubber_tiles','fabric_pattern_07']
# Required source maps. No other image sources.
source={}
for ident in IDS:
 for kind in ['diff','nor_gl','arm']:
  path=f'textures/{ident}_{kind}_1k.jpg'
  if not os.path.exists(path):
   remote_kind='col_1' if ident=='fabric_pattern_07' and kind=='diff' else kind
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{remote_kind}_1k.jpg'
   print('DOWNLOAD',url,flush=True);urllib.request.urlretrieve(url,path)
  im=bpy.data.images.load(os.path.join(ROOT,path),check_existing=True)
  if kind!='diff':im.colorspace_settings.name='Non-Color'
  source[ident,kind]=im

def node(nt,typ):return nt.nodes.new(typ)
def value(nt,val):
 n=node(nt,'ShaderNodeValue');n.outputs[0].default_value=val;return n.outputs[0]
def mix(nt,a,b,f):
 n=node(nt,'ShaderNodeMixRGB');n.blend_type='MIX';n.inputs[0].default_value=f
 if hasattr(a,'node'):nt.links.new(a,n.inputs[1])
 else:n.inputs[1].default_value=(*a[:3],1)
 if hasattr(b,'node'):nt.links.new(b,n.inputs[2])
 else:n.inputs[2].default_value=(*b[:3],1)
 return n.outputs[0]
def mathn(nt,op,a,b):
 n=node(nt,'ShaderNodeMath');n.operation=op
 for v,i in [(a,0),(b,1)]:
  if hasattr(v,'node'):nt.links.new(v,n.inputs[i])
  else:n.inputs[i].default_value=v
 return n.outputs[0]
def tex(nt,im):
 n=node(nt,'ShaderNodeTexImage');n.image=im;return n
materials={};sets={}
# Bake tile materials rather than unique per-part atlases: consistent detail density.
for name,ident,col,rough,metal in [
 ('Paint','metal_plate',(.94,.94,.94),.33,.12),
 ('Rubber','rubber_tiles',(.022,.025,.027),.77,0),
 ('Metal','metal_plate',(.42,.46,.49),.26,.90),
 ('Interior','rubber_tiles',(.11,.125,.14),.69,0),
 ('Fabric','fabric_pattern_07',(.035,.075,.145),.88,0),
 ('Rails','metal_plate',(.80,.49,.055),.34,.30),
 ('Glass','metal_plate',(.14,.21,.23),.12,0),
 ('Lamps','metal_plate',(.9,.9,.9),.19,.12)]:
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs['Surface'])
 photo=tex(nt,source[ident,'diff']);bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(photo.outputs[0],bw.inputs[0])
 ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=(*[c*(.94 if name=='Paint' else .98 if name=='Rubber' else .72) for c in col],1);ramp.color_ramp.elements[1].color=(*col,1);nt.links.new(bw.outputs[0],ramp.inputs[0]);color=ramp.outputs[0]
 uv=node(nt,'ShaderNodeTexCoord');noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=110;noise.inputs['Roughness'].default_value=.75;nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
 if name=='Paint':
  sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],sep.inputs[0]);low=mathn(nt,'SUBTRACT',.19,sep.outputs['Y']);low=mathn(nt,'MAXIMUM',low,0);low=mathn(nt,'MULTIPLY',low,noise.outputs['Fac']);low=mathn(nt,'MULTIPLY',low,1.8)
  grime=node(nt,'ShaderNodeMixRGB');nt.links.new(low,grime.inputs[0]);nt.links.new(color,grime.inputs[1]);grime.inputs[2].default_value=(.48,.49,.49,1);color=grime.outputs[0]
 if name=='Lamps':
  sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],sep.inputs[0]);r=node(nt,'ShaderNodeValToRGB');r.color_ramp.interpolation='CONSTANT'
  r.color_ramp.elements.remove(r.color_ramp.elements[1]);r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.95,.82,.57,1)
  r.color_ramp.elements.new(.333).color=(.65,.008,.004,1);r.color_ramp.elements.new(.666).color=(1,.24,.009,1);nt.links.new(sep.outputs['X'],r.inputs[0]);color=r.outputs[0]
 if name=='Glass':color=mix(nt,col,col,0)
 nt.links.new(color,bs.inputs['Base Color']);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 nor=tex(nt,source[ident,'nor_gl']);nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.035 if name in ['Paint','Metal','Rails'] else .025 if name=='Rubber' else .40;nt.links.new(nor.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
 if name in ['Glass','Lamps']:nm.inputs['Strength'].default_value=0
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,0));plane=bpy.context.object;plane.name='BakeTile';plane.data.materials.append(m)
 outputs={}
 for kind,size in [('base',1024 if name not in ['Glass','Lamps'] else 64),('normal',1024 if name not in ['Glass','Lamps'] else 64),('orm',512 if name not in ['Glass','Lamps'] else 64)]:
  im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
  if kind!='base':im.colorspace_settings.name='Non-Color'
  target=tex(nt,im);nt.nodes.active=target
  if kind=='base':
   em=node(nt,'ShaderNodeEmission');nt.links.new(color,em.inputs[0]);nt.links.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  elif kind=='normal':
   nt.links.new(bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
  else:
   arm=tex(nt,source[ident,'arm']);sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],sep.inputs[0]);comb=node(nt,'ShaderNodeCombineColor')
   nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',sep.outputs[0],.12),.88),comb.inputs[0]);nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',sep.outputs[1],.12),max(.04,rough-.06)),comb.inputs[1]);comb.inputs[2].default_value=metal
   em=node(nt,'ShaderNodeEmission');nt.links.new(comb.outputs[0],em.inputs[0]);nt.links.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+kind+'.png');im.file_format='PNG';im.save();outputs[kind]=im
 bpy.data.objects.remove(plane,do_unlink=True)
 if name in ['Metal','Rails']:outputs['normal']=sets['Paint']['normal']
 if name=='Interior':outputs['normal']=sets['Rubber']['normal']
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs['Surface'])
 bc=tex(nt,outputs['base']);nt.links.new(bc.outputs['Color'],bs.inputs['Base Color'])
 if name=='Paint':
  mul=node(nt,'ShaderNodeMixRGB');mul.name='PaintTint';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*PAINTS[0][1],1);nt.links.new(bc.outputs[0],mul.inputs[1]);nt.links.new(mul.outputs[0],bs.inputs['Base Color']);bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 arm=tex(nt,outputs['orm']);sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],bs.inputs['Roughness']);nt.links.new(sep.outputs[2],bs.inputs['Metallic'])
 group=bpy.data.node_groups.get('glTF Material Output')
 if group is None:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(nt,'ShaderNodeGroup');gn.node_tree=group;nt.links.new(sep.outputs[0],gn.inputs['Occlusion'])
 normal=tex(nt,outputs['normal']);normalmap=node(nt,'ShaderNodeNormalMap');nt.links.new(normal.outputs[0],normalmap.inputs[0] if False else normalmap.inputs['Color']);nt.links.new(normalmap.outputs[0],bs.inputs['Normal'])
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.27;bs.inputs['Transmission Weight'].default_value=.12;bs.inputs['IOR'].default_value=1.45
  m.surface_render_method='BLENDED';m.use_transparency_overlap=False
 if name=='Lamps':nt.links.new(bc.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.65
 materials[name]=m;sets[name]=outputs
 print('BAKED',name,flush=True)

json.dump({'engine':'Cycles','device':'OptiX GPU','samples':12,'sources':IDS,'method':'Shared UV tile shaders; EMIT base and packed ORM, tangent-space NORMAL; source photographic maps and procedural lower-panel grime baked to PNG','base_normal_max':1024,'orm_max':512},open('bake-provenance.json','w'),indent=2)
# Geometry authored Z up, front -Y. glTF conversion maps front to +Z.
parts={};group='Body';shape_cache={}
def register(o,mat):
 o.data.materials.append(materials[mat]);parts.setdefault(group,[]).append(o);return o
def bevel(o,w=.02,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
 try:
  mod=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True;bpy.ops.object.modifier_apply(modifier=mod.name)
 except:pass
 return o
def box(n,loc,dim,mat='Interior',r=.02,seg=3):
 key=('box',tuple(round(d,7) for d in dim),mat,r,seg)
 if key in shape_cache:
  o=bpy.data.objects.new(n,shape_cache[key]);bpy.context.collection.objects.link(o);o.location=loc;parts.setdefault(group,[]).append(o);return o
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=n;o.dimensions=dim;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,mat);bevel(o,r,seg);shape_cache[key]=o.data;return o
def mesh(n,verts,faces,mat,bev=0):
 me=bpy.data.meshes.new(n);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(n,me);bpy.context.collection.objects.link(o);register(o,mat)
 # planar metric UVs, matching dominant face, for reusable baked tiles
 uv=me.uv_layers.new(name='UVMap')
 for poly in me.polygons:
  normal=poly.normal;axis=max(range(3),key=lambda i:abs(normal[i]));axes=[i for i in range(3) if i!=axis]
  for li in poly.loop_indices:
   v=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(v[axes[0]]*.65,v[axes[1]]*.65)
 if bev:bevel(o,bev)
 return o
def cyl(n,loc,r,depth,mat='Metal',axis='Z',verts=32):
 key=('cyl',r,depth,mat,axis,verts)
 if key in shape_cache:
  o=bpy.data.objects.new(n,shape_cache[key]);bpy.context.collection.objects.link(o);o.location=loc;parts.setdefault(group,[]).append(o);return o
 bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=depth,location=loc);o=bpy.context.object;o.name=n
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);register(o,mat);bevel(o,min(.006,r*.15),2);shape_cache[key]=o.data;return o
def tube(n,points,r=.018,mat='Rails',sides=8):
 verts=[]
 for i,p in enumerate(points):
  p=Vector(p);d=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
  d.normalize();a=d.cross(Vector((0,0,1)))
  if a.length<.1:a=d.cross(Vector((0,1,0)))
  a.normalize();b=d.cross(a)
  for j in range(sides):verts.append(p+r*(a*cos(j*2*pi/sides)+b*sin(j*2*pi/sides)))
 faces=[]
 for i in range(len(points)-1):
  for j in range(sides):faces.append((i*sides+j,i*sides+(j+1)%sides,(i+1)*sides+(j+1)%sides,(i+1)*sides+j))
 faces.extend([tuple(reversed(range(sides))),tuple((len(points)-1)*sides+j for j in range(sides))]);o=mesh(n,verts,faces,mat)
 for p in o.data.polygons:p.use_smooth=True
 return o
def lathe(n,center,profile,mat='Rubber',steps=96):
 x,y,z=center;vs=[]
 for dx,rad in profile:
  for j in range(steps):a=j*2*pi/steps;vs.append((x+dx,y+rad*sin(a),z+rad*cos(a)))
 fs=[]
 for k in range(len(profile)-1):
  for j in range(steps):fs.append((k*steps+j,k*steps+(j+1)%steps,(k+1)*steps+(j+1)%steps,(k+1)*steps+j))
 o=mesh(n,vs,fs,mat)
 for p in o.data.polygons:p.use_smooth=True
 return o
# Lower body side panels have true wheel cut-outs.
AXLES=[-2.8,1.56,2.8];radius=.51
# Doors on vehicle RIGHT side, front entrance and center exit.
DOORS=[(-4.20,1.12),(-.12,1.12)]
def archbottom(y):
 z=.30
 for a in AXLES:
  dy=abs(y-a)
  if dy<.605:z=max(z,.51+sqrt(.605**2-dy**2))
 return z
for side in [-1,1]:
 intervals=[(-5.06,5.06)]
 if side==-1:
  intervals=[(-5.06,-4.78),(-3.62,-.70),(.46,5.06)]
 for lo,hi in intervals:
  steps=int((hi-lo)/.055)+1;ys=[lo+(hi-lo)*i/steps for i in range(steps+1)];vs=[]
  for y in ys:
   for x,z in [(side*1.265,archbottom(y)),(side*1.265,1.31),(side*1.20,archbottom(y)),(side*1.20,1.31)]:vs.append((x,y,z-max(0,z-4.2)*.95*(abs(x)/1.25)**6))
  fs=[]
  for i in range(steps):
   for a,b in [(0,1),(1,3),(3,2),(2,0)]:fs.append((4*i+a,4*(i+1)+a,4*(i+1)+b,4*i+b))
  fs.extend([(0,2,3,1),(4*steps,4*steps+1,4*steps+3,4*steps+2)])
  mesh('Sculpted skirt with wheel apertures',vs,fs,'Paint',.007)
 # interdeck panel, thin reveal strips and roof rail
 box('Continuous interdeck skin',(side*1.237,0,2.655),(.076,9.91,.69),'Paint',.035,4)
 for z in [1.30,2.26,3.015,4.19]:box('Window gasket rail',(side*1.258,0,z),(.045,(9.77 if z>4 else 9.91 if z>2.8 else 10.02),.058),'Rubber',.014)
 box('Cant rail',(side*1.236,0,4.21),(.055,9.77,.075),'Paint',.025,4)
 box('Continuous lower header backing',(side*1.24,0,2.296),(.075,10.01,.065),'Rubber',.012)
 # Upper deck windows framed individually, uninterrupted full length
 bounds=[-4.89,-3.79,-2.36,-.93,.50,1.93,3.36,4.89]
 for a,b in zip(bounds,bounds[1:]):
  box('Upper safety glass',(side*1.231,(a+b)/2,3.605),(.015,b-a-.065,1.115),'Glass',.045,5)
  box('Upper window mullion',(side*1.253,a,3.604),(.06,.060,1.22),'Rubber',.015)
  if a in [-3.79,.50]:
   box('Opening vent divider',(side*1.247,(a+b)/2,3.96),(.025,b-a-.08,.037),'Rubber',.008)
 # Lower side glass bays (right side leaves gaps for doors)
 bays=[(-5.02,-3.64),(-3.59,-2.00),(-1.95,-.73),(.49,1.82),(1.88,3.25),(3.31,4.55)] if side==-1 else [(-5.02,-3.70),(-3.64,-2.15),(-2.09,-.58),(-.52,1.05),(1.11,2.66),(2.72,4.57)]
 for a,b in bays:
  if side==-1 and a<-4.7:continue
  box('Lower tinted glazing',(side*1.245,(a+b)/2,1.785),(.015,b-a,.91),'Glass',.022)
  for y in [a,b]:box('Lower window pillar',(side*1.255,y,1.785),(.07,.055,1.0),'Rubber',.012)
  if a<1.5 and a>-3.7:box('Vent window bar',(side*1.262,(a+b)/2,2.07),(.025,b-a,.026),'Rubber',.007)
 # rear engine enclosure side
 box('Rear service pillar',(side*1.24,4.86,1.79),(.09,.49,.96),'Paint',.025)
 for zc,h in [(1.74,.46),(.73,.53)]:
  box('Inset engine side grille',(side*1.284,4.80,zc),(.014,.39,h),'Rubber',.012)
  for k in range(10):box('Cooling louvre',(side*1.295,4.80,zc-h*.44+k*h*.09),(.025,.36,.014),'Metal',.004,2)
 # wheel arch lips, inner tubs and splash guards
 for y in AXLES:
  points=[(side*1.277,y+.612*cos(pi*i/48),.51+.612*sin(pi*i/48)) for i in range(49)]
  tube('Rolled wheel arch edge',points,.017,'Rubber',8)
  tube('Inner fender lip',[(side*1.207,p[1],p[2]-.025) for p in points],.039,'Interior',8)
  box('Rear mud flap',(side*1.12,y+.54,.29),(.26,.035,.28),'Rubber',.009)
 # panel seams and quick-release fasteners
 for y in [-4.95,-1.28,.77,3.63,4.48]:
  if side==-1 and any(abs(y-c)<w/2 for c,w in DOORS):continue
  zmin=archbottom(y)+.025
  if zmin<1.26:box('Vertical service panel shut line',(side*1.271,y,(zmin+1.25)/2),(.008,.008,1.25-zmin),'Rubber',.002,2)
  for z in [.39,1.20]:
   if z>archbottom(y):cyl('Panel fastener',(side*1.280,y,z),.013,.008,'Metal','X',12)
 # side rub strip and amber marker lenses
 for y in [-4.97,-1.49,.87,3.60,4.99]:
  if side==-1 and any(abs(y-c)<w/2 for c,w in DOORS):continue
  box('Side marker base',(side*1.282,y,.49),(.026,.12,.047),'Rubber',.011)
  o=box('Amber side marker',(side*1.301,y,.49),(.018,.086,.025),'Lamps',.009);o['lamp_uv']=.83
# Front right quarterlight closes the slim bay ahead of the entry door.
box('Entry quarterlight',(-1.245,-4.925,1.785),(.015,.205,.89),'Glass',.020)
box('Quarterlight rear frame',(-1.26,-4.815,1.785),(.04,.035,.98),'Rubber',.009)
# Full length interior floors, upper floor cutout above staircase.
box('Lower floor',(0,0,.59),(2.38,10.05,.13),'Interior',.04)
box('Upper floor front',(0,-2.0,2.79),(2.37,6.08,.13),'Interior',.022)
box('Upper floor rear',(0,4.1,2.79),(2.37,1.84,.13),'Interior',.022)
box('Upper floor stair aisle',(0.52,2.14,2.79),(1.25,2.02,.13),'Interior',.016)
# Curved roof surface spans sides with rolled shoulders, 4.40 high.
profile=[(x,4.36-.152*(abs(x)/1.25)**6) for x in [-1.25,-1.23,-1.19,-1.13,-1.04,-.90,-.70,-.40,0,.40,.70,.90,1.04,1.13,1.19,1.23,1.25]]
vs=[(x,sign*(5.035+.265*sqrt(max(0,1-(x/1.25)**2))-.08-.66*.13),z) for sign in [-1,1] for x,z in profile];N=len(profile);fs=[(i,i+1,N+i+1,N+i) for i in range(N-1)];roof=mesh('Crowned aluminium roof',vs,fs,'Paint');sol=roof.modifiers.new('Roof thickness','SOLIDIFY');sol.thickness=.06;bpy.context.view_layer.objects.active=roof;bpy.ops.object.modifier_apply(modifier=sol.name);bevel(roof,.015,3)
for y in [-2.25,3.1]:
 box('Roof hatch gasket',(0,y,4.365),(.78,.68,.018),'Rubber',.06,4)
 box('Low emergency hatch',(0,y,4.382),(.72,.62,.028),'Paint',.055,4)
print('SIDE_SHELL_BUILT',time.time()-START,flush=True)

# Curved end bands defined as rounded width station surfaces. Front glazing slopes inward.
def endband(name,front,z0,z1,mat):
 # side-to-front wrap, curved corners, rear symmetrical
 sign=-1 if front else 1;vs=[];steps=96
 for z in [z0,z1]:
  inset=.08*min(1,max(0,(z-1.05)/1.22))+max(0,z-3.7)*.13 + max(0,.7-z)*.06
  for i in range(steps+1):
   a=-pi/2+pi*i/steps;x=1.25*sin(a);y=sign*(5.035+.265*cos(a)-inset);vs.append((x,y,z-max(0,z-4.2)*.95*(abs(x)/1.25)**6))
 fs=[(i,i+1,steps+2+i,steps+1+i) for i in range(steps)]
 if not front:fs=[tuple(reversed(f)) for f in fs]
 o=mesh(name,vs,fs,mat);s=o.modifiers.new('Pressed shell thickness','SOLIDIFY');s.thickness=.045 if mat!='Glass' else .001;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=s.name)
 if mat!='Glass':bevel(o,.015,3)
 for p in o.data.polygons:p.use_smooth=True
 return o
for front in [True,False]:
 for z0,z1,mat in [(.30,.93,'Paint'),(.93,1.05,'Rubber'),(1.05,2.27,'Glass'),(2.27,2.89,'Rubber'),(2.89,3.02,'Rubber'),(3.02,4.20,'Glass'),(4.20,4.32,'Rubber'),(4.32,4.36,'Paint')]:
  if not front and z0<2.89:mat='Paint' if z0!=2.27 else 'Rubber'
  endband('Front' if front else 'Rear',front,z0,z1,mat)
 # Pressed corner pillars have a flattened rounded cross section, with real side wrap.
 for side in [-1,1]:
  vs=[];zs=[.34,.50,1.05,2.27,3.02,3.7,4.10,4.205]
  for z in zs:
   yy=(-1 if front else 1)*(5.005-.08*min(1,max(0,(z-1.05)/1.22))-max(0,z-3.7)*.13)
   for k in range(16):
    a=2*pi*k/16;vs.append((side*(1.221+.052*cos(a)),yy+.090*sin(a),z))
  fs=[(i*16+k,i*16+(k+1)%16,(i+1)*16+(k+1)%16,(i+1)*16+k) for i in range(len(zs)-1) for k in range(16)]
  fs.extend([tuple(range(15,-1,-1)),tuple((len(zs)-1)*16+k for k in range(16))])
  o=mesh('Sculpted end pillar',vs,fs,'Paint',.007)
  for polygon in o.data.polygons:polygon.use_smooth=True
# Front lower sculpted fascia panel, bumper splits, lamp pods original vertical capsules.
vs=[(x,-(5.035+.265*sqrt(1-(x/1.25)**2))-.009,z) for z in [.36,.51] for x in [-.89,-.70,-.45,0,.45,.70,.89]]
mesh('Flush curved front bumper',vs,[(i,i+1,i+8,i+7) for i in range(6)],'Paint')
box('Front lower air intake',(0,-5.336,.40),(.83,.014,.055),'Rubber',.017)
vs=[(x,-(5.035+.265*sqrt(1-(x/1.25)**2))+.066,z) for z in [2.435,2.685] for x in [-.92,-.69,-.46,-.23,0,.23,.46,.69,.92]]
mesh('Unlettered destination display',vs,[(i,i+1,i+10,i+9) for i in range(8)],'Rubber')
def nose_y(x,z):return -(5.035+.265*sqrt(max(0,1-(x/1.25)**2))-max(0,.7-z)*.06)
for s in [-1,1]:
 contour=[(.68,.56),(.67,.76),(.84,.82),(1.12,.88),(1.16,.76),(1.13,.55)]
 verts=[(s*x,nose_y(s*x,z)-.016,z) for x,z in contour]
 pod=mesh('Integrated headlamp recess',verts,[tuple(range(len(verts)))],'Rubber')
 sol=pod.modifiers.new('Lamp well thickness','SOLIDIFY');sol.thickness=.018;bpy.context.view_layer.objects.active=pod;bpy.ops.object.modifier_apply(modifier=sol.name);bevel(pod,.011,3)
 tube('Swept lamp surround',[(s*x,nose_y(s*x,z)-.032,z) for x,z in contour+[contour[0]]],.012,'Metal',10)
 cover=mesh('Curved protective headlamp glass',[(s*x,nose_y(s*x,z)-.072,z) for x,z in contour],[tuple(range(len(contour)))],'Glass')
 for polygon in cover.data.polygons:polygon.use_smooth=True
 for dx,zz,r in [(-.09,.704,.063),(.065,.727,.050),(.035,.606,.025)]:
  xx=s*(.92+dx);yy=nose_y(xx,zz)-.031
  cyl('Lamp reflector bezel',(xx,yy,zz),r+.012,.018,'Metal','Y',32)
  o=cyl('Warm white headlight lens',(xx,yy-.012,zz),r,.014,'Lamps','Y',32);o['lamp_uv']=.15
 o=box('Front direction lamp',(s*1.025,nose_y(s*1.025,.822)-.032,.822),(.12,.022,.022),'Lamps',.012);o['lamp_uv']=.83
 # wipers with pivots, arms and blades
 cyl('Wiper spindle',(s*.66,nose_y(s*.66,1.02)-.025,1.02),.022,.034,'Metal','Y',16)
 tube('Windshield wiper arm',[(s*x,nose_y(s*x,z)+.08*max(0,(z-1.05)/1.22)-.03,z) for x,z in [(.66,1.02),(.44,1.31),(.18,1.42)]],.012,'Rubber')
 tube('Wiper rubber blade',[(s*x,nose_y(s*x,z)+.08*(z-1.05)/1.22-.022,z) for x,z in [(.50,1.33),(.11,1.72)]],.017,'Rubber')
 # substantial mirrors mounted to actual A pillar
 tube('Mirror curved support',[(s*1.20,-4.90,2.58),(s*1.40,-5.00,2.57),(s*1.55,-5.13,2.50),(s*1.57,-5.18,2.32)],.040,'Rubber',12)
 box('Mirror shell',(s*1.55,-5.18,2.19),(.18,.20,.39),'Rubber',.075,6)
 box('Mirror reflective face',(s*1.55,-5.068,2.19),(.125,.010,.30),'Metal',.047,4)
 # upper guard rails curved round top windshield corners
 # Upper windscreen gasket is integrated in the end band.
# Rear service engine grille and vertical tail lamp assemblies.
def rear_y(x,z):return 5.035+.265*sqrt(max(0,1-(x/1.25)**2))-.08*min(1,max(0,(z-1.05)/1.22))-max(0,z-3.7)*.13-max(0,.7-z)*.06
def rear_patch(name,xwidth,z0,z1,mat,offset):
 xs=[-xwidth/2+xwidth*i/20 for i in range(21)]
 vs=[(x,rear_y(x,z)+offset,z) for z in [z0,z1] for x in xs]
 o=mesh(name,vs,[(i+21,i+22,i+1,i) for i in range(20)],mat)
 for f in o.data.polygons:f.use_smooth=True
 return o
rear_patch('Integrated rear engine grille',1.91,1.535,2.145,'Rubber',.012)
for k in range(13):
 z=1.58+k*.041
 tube('Rear curved extractor slat',[(x,rear_y(x,z)+.025,z) for x in [-.885,-.7,-.45,0,.45,.7,.885]],.010,'Interior',8)
rear_patch('Curved engine access panel',1.66,.585,1.255,'Paint',.014)
for xx in [-.83,.83]:tube('Engine hatch seal',[(xx,rear_y(xx,z)+.015,z) for z in [.585,.9,1.255]],.007,'Rubber',8)
for zz in [.585,1.255]:tube('Engine hatch seal',[(x,rear_y(x,zz)+.015,zz) for x in [-.83,-.6,0,.6,.83]],.007,'Rubber',8)
box('Rear hatch handle',(0,rear_y(0,1.09)+.025,1.09),(.115,.024,.045),'Rubber',.012)
for s in [-1,1]:
 xx=s*1.04
 box('Tail lamp capsule',(xx,rear_y(xx,.93)+.015,.93),(.19,.050,.54),'Rubber',.082,5)
 for k in range(4):
  zz=.74+k*.12
  o=cyl('Rear lamp lens',(xx,rear_y(xx,zz)+.049,zz),.058,.016,'Lamps','Y',32);o['lamp_uv']=[.15,.5,.83,.5][k]
box('Chassis dark underpan',(0,.05,.34),(1.84,9.3,.16),'Rubber',.07)
for y in AXLES:cyl('Axle beam',(0,y,.51),.072,2.15,'Interior','X')
cyl('Exhaust outlet',(.82,5.14,.29),.055,.35,'Metal','Y',24)
# Passenger entry leaves: independent nodes, proper hinge/slide origins.
for door_index,(yc,width) in enumerate(DOORS):
 for leaf in range(2):
  group=f'Door_{door_index*2+leaf+1}';y=yc+(-1 if leaf==0 else 1)*width*.25
  for edge_y in [y-width/4+.018,y+width/4-.018]:box('Door vertical frame',(-1.273,edge_y,1.315),(.061,.045,1.95),'Rubber',.012)
  for edge_z in [.362,2.267]:box('Door horizontal frame',(-1.273,y,edge_z),(.061,width/2-.018,.045),'Rubber',.012)
  box('Door lower kick backing',(-1.273,y,.55),(.048,width/2-.05,.35),'Interior',.015)
  box('Door leaf glazing',(-1.312,y,1.53),(.012,width/2-.096,1.47),'Glass',.022)
  box('Door bottom kickplate',(-1.313,y,.50),(.012,width/2-.085,.19),'Interior',.017)
  box('Door separation rail',(-1.323,y,1.17),(.017,width/2-.055,.035),'Rubber',.009)
  tube('Interior door grab',[(-1.226,y-.11,.94),(-1.226,y-.11,1.55),(-1.226,y-.08,1.61)],.018,'Rails')
  for z in [.57,2.15]:cyl('Door hinge barrel',(-1.285,y-width*.24,z),.019,.15,'Metal','Z',16)
 group='Body'
 box('Entry threshold',(-1.06,yc,.327),(.47,width+.045,.055),'Metal',.013)
 box('Entry lower step',(-.90,yc,.407),(.62,width-.055,.08),'Interior',.02)
 box('Entry step yellow nosing',(-1.22,yc,.456),(.025,width-.075,.022),'Rails',.005)
 for yy in [yc-width/2-.03,yc+width/2+.03]:box('Door jamb',(-1.249,yy,1.315),(.105,.055,1.93),'Paint',.015)
# Stairs: visible through lower right side glazing, ascending toward rear.
for i in range(11):
 y=.82+i*.203;z=.67+i*.199
 box('Stair tread',(-.72,y,z),(.79,.225,.065),'Interior',.012,2)
 box('Stair riser',(-.72,y+.10,z+.094),(.77,.034,.17),'Interior',.007,2)
 box('Stair safety nosing',(-.72,y-.106,z+.036),(.76,.019,.012),'Rails',.004,2)
for x in [-1.12,-.30]:
 tube('Stair continuous handrail',[(x,.57,1.27),(x,.68,1.40),(x,2.93,3.60),(x,3.11,3.61)],.020,'Rails',10)
 for i in [0,4,8,10]:tube('Stair upright',[(x,.82+i*.203,.73+i*.199),(x,.82+i*.203,1.52+i*.199)],.016,'Rails')
print('SHELL_AND_STAIRS_BUILT',time.time()-START,flush=True)
# Solid stair stringers support treads and make the stair silhouette legible.
for x in [-1.135,-.305]:
 verts=[]
 for i in range(11):
  y=.82+i*.203;z=.67+i*.199
  verts.extend([(x,y-.11,z+.015),(x,y+.11,z+.015)])
 verts.extend([(x,2.96,2.43),(x,.71,.40)])
 o=mesh('Stair structural stringer',verts,[tuple(range(len(verts)))],'Interior')
 mod=o.modifiers.new('Stringer sheet thickness','SOLIDIFY');mod.thickness=.034;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name);bevel(o,.006,2)
# Seats: contoured cushions, polymer back shells, aisle grabs and floor pedestals.
def seat(x,y,z,driver=False):
 box('Seat pedestal',(x,y,z+.15),(.075,.22,.28),'Metal',.014,2)
 box('Seat undertray',(x,y,z+.325),(.43,.45,.064),'Interior',.035,3)
 box('Woven seat cushion',(x,y-.02,z+.38),(.435,.45,.10),'Fabric',.043,4)
 back=box('Seat shaped shell',(x,y+.195,z+.69),(.44,.075,.60),'Interior',.055,4);back.rotation_euler[0]=-.105
 back=box('Upholstered seat back',(x,y+.143,z+.70),(.39,.07,.51),'Fabric',.044,4);back.rotation_euler[0]=-.105
 tube('Seat integrated top grab',[(x-.13,y+.206,z+.99),(x-.13,y+.206,z+1.035),(x+.13,y+.206,z+1.035),(x+.13,y+.206,z+.99)],.013,'Interior',8)
for y in [-4.20,-3.46,-2.72,-1.98,-1.24,-.50,.24,.98,1.72,2.46,3.20,3.94,4.66]:
 for x in [-.94,-.48,.48,.94]:
  if y>1.0 and y<3.1 and x<0:continue
  seat(x,y,2.87)
for y in [-2.65,-1.91,-1.17,.55,3.63,4.42]:
 for x in [-.92,-.47,.47,.92]:
  if x<0 and y>.45 and y<3.2:continue
  if x<0 and y<-1.1 and y>-2.9:continue # front axle raised seating zone remains elsewhere
  seat(x,y,1.07 if any(abs(y-a)<.70 for a in AXLES) else .67)
# Lower wheel housings are visible to passengers; raised seats over arches.
for y in AXLES:
 for s in [-1,1]:box('Interior wheel housing',(s*.97,y,.87),(.44,1.20,.49),'Interior',.19,5)
for y in [-2.65,-1.88]:
 for x in [-.90,-.45]:seat(x,y,1.07)
# Deck stanchions, ceiling rails and driver cab.
for z0,z1 in [(.68,2.57),(2.87,4.22)]:
 for s in [-1,1]:
  tube('Longitudinal passenger rail',[(s*.30,-3.45,z1-.08),(s*.30,4.49,z1-.08)],.018,'Rails')
  for y in [-3.35,-.82,.44,3.38]:
   tube('Stanchion',[(s*.31,y,z0),(s*.31,y,z1-.08)],.018,'Rails',10)
   cyl('Stanchion foot',(s*.31,y,z0+.012),.046,.022,'Metal','Z',16)
 # cream colored ceiling light housings use Metal and modest white lamps
 for y in [-3,-.4,2.2,4.2]:
  box('Interior ceiling strip',(0,y,z1+.015),(.10,.54,.03),'Interior',.011)
  o=box('Ceiling diffuser',(0,y,z1-.006),(.066,.47,.012),'Lamps',.008);o['lamp_uv']=.15
seat(.68,-4.25,.73,True)
box('Driver platform',(.67,-4.28,.67),(1.00,1.22,.12),'Interior',.035)
box('Sculpted dashboard',(.49,-4.94,1.20),(1.34,.48,.34),'Interior',.13,6)
box('Instrument binnacle',(.63,-4.84,1.41),(.43,.24,.17),'Rubber',.060,4)
for x in [.52,.72]:
 cyl('Instrument bezel',(x,-4.706,1.44),.052,.012,'Metal','Y',24)
 cyl('Dark unlettered dial',(x,-4.695,1.44),.044,.014,'Rubber','Y',24)
tube('Steering column',[(.65,-4.83,1.07),(.65,-4.60,1.34)],.052,'Interior',16)
box('Driver divider',(.14,-4.20,1.07),(.055,1.02,.76),'Interior',.033)
box('Driver protective glass',(.14,-4.20,1.73),(.014,.94,.53),'Glass',.026)
box('Cab rear partition',(.65,-3.68,1.13),(1.07,.065,.93),'Interior',.04)
box('Cab rear partition glass',(.65,-3.68,1.81),(1.07,.02,.44),'Glass',.024)
# Steering wheel independent origin at hub.
group='SteeringWheel';hub=Vector((.65,-4.58,1.39));axis=Vector((0,-.55,.835));u=Vector((1,0,0));v=axis.cross(u)
tube('Steering rim',[hub+.205*(u*cos(i*2*pi/64)+v*sin(i*2*pi/64)) for i in range(65)],.019,'Rubber',12)
for a in [pi/2,7*pi/6,11*pi/6]:tube('Steering spoke',[hub,hub+.187*(u*cos(a)+v*sin(a))],.022,'Interior',8)
o=cyl('Steering hub',hub,.069,.042,'Rubber','Z',32);o.rotation_euler=axis.to_track_quat('Z','Y').to_euler()
group='Body'
print('INTERIOR_BUILT',time.time()-START,flush=True)
# Six road wheels. High resolution profiles carry sidewalls, bead and circumferential tread channels.
wheelcenters={}
for i,y in enumerate(AXLES):
 for side in [-1,1]:
  suffix=('F' if i==0 else 'R')+('L' if side==1 else 'R')+('2' if i==2 else '')
  group='Wheel_'+suffix;center=(side*1.107,y,.51);wheelcenters[group]=center
  prof=[(-.16,.32),(-.173,.385),(-.157,.451),(-.129,.488),(-.098,.504),(-.067,.51),(-.030,.51),(-.023,.501),(-.016,.51),(.016,.51),(.023,.501),(.030,.51),(.067,.51),(.098,.504),(.129,.488),(.157,.451),(.173,.385),(.16,.32),(-.16,.32)]
  lathe('Radial tyre carcass',center,prof,'Rubber',96)
  # moulded sidewall rings, shallow tread sipes; no oversized off-road blocks
  for dx in [-.159,.159]:
   lathe('Tyre mould ring',(center[0]+dx,y,.51),[(-.004,.405),(0,.410),(.004,.405)],'Rubber',96)
  for j in range(64):
   a=j*2*pi/64
   for dx in [-.080,.080]:
    o=box('Fine road tread block',(center[0]+dx,y+.503*sin(a),.51+.503*cos(a)),(.054,.022,.010),'Rubber',.003,1);o.rotation_euler[0]=-a
  # outward-facing cast rim, deep cylindrical barrel and dish
  sign=side;cx=center[0]
  profile=[(sign*.145,.321),(sign*.179,.315),(sign*.183,.293),(sign*.156,.273),(sign*.084,.248),(sign*.053,.156),(sign*.089,.112),(sign*.124,.106)]
  lathe('Forged recessed rim',center,profile,'Metal',80)
  cyl('Brake disc',(cx+sign*.05,y,.51),.265,.025,'Interior','X',64)
  cyl('Hub cover',(cx+sign*.152,y,.51),.104,.073,'Metal','X',48)
  cyl('Hub center',(cx+sign*.194,y,.51),.067,.012,'Interior','X',32)
  for j in range(10):
   a=j*2*pi/10
   yy=y+.139*sin(a);zz=.51+.139*cos(a)
   cyl('Lug seat',(cx+sign*.103,yy,zz),.024,.015,'Interior','X',16)
   cyl('Wheel lug bolt',(cx+sign*.118,yy,zz),.014,.027,'Metal','X',6)
   a+=pi/10;yy=y+.227*sin(a);zz=.51+.227*cos(a)
   cyl('Rim ventilation recess',(cx+sign*.132,yy,zz),.036,.010,'Rubber','X',20)
   # metallic inner perimeter, looks like pressed ventilation wells
   lathe('Vent chamfer',(cx+sign*.137,yy,zz),[(-.003,.031),(0,.036),(.003,.038)],'Metal',20)
  cyl('Tyre inflation valve',(cx+sign*.175,y+.274,.56),.010,.041,'Rubber','X',12)
group='Body'
print('WHEELS_BUILT',time.time()-START,flush=True)
# World-height paint UVs keep the neutral baked road film low on every painted panel.
bpy.context.view_layer.update()
for objects in parts.values():
 for o in objects:
  if not o.data.materials or o.data.materials[0].name!='Paint':continue
  if o.data.users>1:o.data=o.data.copy()
  uv=o.data.uv_layers.active
  if uv is None:uv=o.data.uv_layers.new(name='UVMap')
  for poly in o.data.polygons:
   for li in poly.loop_indices:
    v=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co
    if abs(poly.normal.z)>.8:uv.data[li].uv=(v.y*.25,v.x*.19+.5)
    else:uv.data[li].uv=((v.y if abs(poly.normal.x)>.5 else v.x)*.25,v.z*.20)
# Material UV assignments; all lamps share a baked colour strip.
for objects in parts.values():
 for o in objects:
  if 'lamp_uv' in o:
   if o.data.users>1:o.data=o.data.copy()
   uv=o.data.uv_layers.active
   if uv:
    for loop in uv.data:loop.uv=(float(o['lamp_uv']),.5)
# Join rigid assemblies, applying every transform and modifier and setting pivots.
export_objects=[]
for name,objects in parts.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 objects[0].data=objects[0].data.copy()
 bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 if o.data.users>1:o.data=o.data.copy()
 # Exact game pivots, with identity rotation and scale
 if name in wheelcenters:pivot=wheelcenters[name]
 elif name=='SteeringWheel':pivot=hub
 elif name.startswith('Door_'):
  k=int(name.split('_')[1])-1;yc,w=DOORS[k//2];pivot=(-1.273,yc+(-w/2 if k%2==0 else w/2),.36)
 else:pivot=(0,0,0)
 sc.cursor.location=pivot;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Weld same-coincident endpoints and remove degenerate geometry from caps.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001);bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.0000001);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
 export_objects.append(o)
# Socket nodes, not renderable geometry.
sockets={'Seat_Driver':(.68,-4.25,1.14),'Door_Front':(-1.45,-4.20,.34),'Exhaust':(.82,5.30,.29),'Light_Head_L':(.92,-5.245,.73),'Light_Head_R':(-.92,-5.245,.73),'Light_Tail_L':(1.04,5.232,.98),'Light_Tail_R':(-1.04,5.232,.98)}
for n,p in sockets.items():
 o=bpy.data.objects.new(n,None);bpy.context.collection.objects.link(o);o.location=p;o.empty_display_size=.10;export_objects.append(o)
# Enforce triangle budget with geometry-aware decimation only if necessary.
def tricount():return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in export_objects if o.type=='MESH')
tri=tricount();print('RAW_TRIANGLES',tri,flush=True)
if tri>126000:
 ratio=125000/tri
 for o in export_objects:
  if o.type!='MESH':continue
  bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Game mesh optimisation','DECIMATE');mod.ratio=ratio;mod.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=mod.name)
# Restore corner normals after weld and decimation. Split major hard edges first.
for o in export_objects:
 if o.type!='MESH':continue
 bpy.context.view_layer.objects.active=o
 for edge in o.data.edges:edge.use_edge_sharp=False
 bm=bmesh.new();bm.from_mesh(o.data)
 for edge in bm.edges:
  if len(edge.link_faces)==2 and edge.calc_face_angle()>math.radians(48):edge.smooth=False
 bm.to_mesh(o.data);bm.free()
 mod=o.modifiers.new('Final weighted manufacturing normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=100;mod.thresh=.005;bpy.ops.object.modifier_apply(modifier=mod.name)
tri=tricount()
# Export plain baked texture links. JSON factor supplies paint tint reliably.
paint=materials['Paint'];nt=paint.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');base=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image==sets['Paint']['base']);nt.links.new(base.outputs[0],bs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in export_objects:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=os.path.join(ROOT,'double-decker.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_image_format='AUTO')
# Paint contract and alpha mode are checked in serialized glTF, not inferred from nodes.
p=os.path.join(ROOT,'double-decker.glb');blob=open(p,'rb').read();jl,jt=struct.unpack_from('<II',blob,12);j=json.loads(blob[20:20+jl]);binary=blob[20+jl:]
for m in j['materials']:
 if m['name']=='Paint':
  m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1];m.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1,'clearcoatRoughnessFactor':.05}
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j.setdefault('extensionsUsed',[])
if 'KHR_materials_clearcoat' not in j['extensionsUsed']:j['extensionsUsed'].append('KHR_materials_clearcoat')
b=json.dumps(j,separators=(',',':')).encode();b+=b' '*((-len(b))%4)
open(p,'wb').write(struct.pack('<III',0x46546c67,2,20+len(b)+len(binary))+struct.pack('<II',len(b),0x4e4f534a)+b+binary)
# Restore render tint.
nt.links.new(nt.nodes['PaintTint'].outputs[0],bs.inputs['Base Color'])
def game(p):return [round(p[0],6),round(p[2],6),round(-p[1],6)]
allpoints=[o.matrix_world@Vector(c) for o in export_objects if o.type=='MESH' for c in o.bound_box]
gp=[game(p) for p in allpoints];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)]}
meta={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','bounding_box':bbox,'body_nominal_dimensions':[2.55,4.40,10.6],'wheelbase':5.6,'triangles':tri,'materials':list(materials),'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'nodes':[{'name':o.name,'type':o.type,'world_position':game(o.matrix_world.translation)} for o in export_objects]}
json.dump(meta,open('nodes.json','w'),indent=2)
notes=["Modern European tri-axle double-deck city bus, articulated passenger doors, detailed cab, seats, handrails and visible stairs.",f"Triangles: {tri:,}; GLB: {os.path.getsize(p)/1048576:.2f} MiB; meshes: {sum(o.type=='MESH' for o in export_objects)}.","Materials: Paint, Rubber, Metal, Interior, Fabric, Rails, Glass (BLEND), Lamps (emissive).","Cycles GPU baked base colour / normal: 1024px; ORM: 512px; uniform glass and lamp palettes: 64px.","Poly Haven CC0: metal_plate, rubber_tiles, fabric_pattern_07; https://polyhaven.com/license ; source maps retained in textures/.","Differences: original unbadged lamp design; default red instead of silver; simplified upholstery and engine bay; mirrors extend beyond nominal body width."]
open('NOTES.md','w').write('\n'.join(notes)+'\n')
print('EXPORTED',tri,os.path.getsize(p),time.time()-START,flush=True)
# Neutral studio daylight. Rendering helpers never enter the GLB.
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.72,.79,.88,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.025));floor=bpy.context.object;floor.name='Render ground';fm=bpy.data.materials.new('Studio only');fm.diffuse_color=(.21,.24,.28,1);fm.use_nodes=True;fm.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.21,.24,.28,1);fm.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.8;floor.data.materials.append(fm)
def area(n,loc,power,size,color):
 bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=n;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.data.color=color;o.rotation_euler=(Vector((0,0,2))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight key',(-7,-7,11),2100,8,(1,.93,.84));area('Sky fill',(6,-1,8),1500,7,(.72,.84,1));area('Rear softbox',(-2,8,9),2400,6,(1,1,1))
bpy.ops.object.camera_add();camera=bpy.context.object;sc.camera=camera;camera.data.type='ORTHO'
def view(loc,target=(0,0,2.1),scale=13.8):
 camera.location=loc;camera.rotation_euler=(Vector(target)-camera.location).to_track_quat('-Z','Y').to_euler();camera.data.ortho_scale=scale
view((-12,-15,6.1));sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'double-decker.blend'))
def render(file):sc.render.filepath=os.path.join(ROOT,file);bpy.ops.render.render(write_still=True);print('RENDERED',file,flush=True)
render('final.png')
view((-16,0,2.2),target=(0,0,2.2),scale=12.8);render('side.png')
view((12,15,6.1));render('rear.png')
view((-5,-8,3.3),target=(-.5,-3.75,1.5),scale=5.3);render('close.png')
view((0,0,17),target=(0,0,0),scale=12.5);render('top.png')
view((-12,-15,6.1));clay=bpy.data.materials.new('Clay override');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.36,.36,.36,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.60
sc.view_layers[0].material_override=clay;render('clay.png');sc.view_layers[0].material_override=None
# Six matched cameras, same lighting and exposure; compose directly with Blender pixel buffers.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
frames=[]
for k,(name,color) in enumerate(PAINTS):
 paint.node_tree.nodes['PaintTint'].inputs[2].default_value=(*color,1);render(f'paint_{k}.png')
 im=bpy.data.images.load(os.path.join(ROOT,f'paint_{k}.png'),check_existing=False);frames.append(list(im.pixels[:]))
import numpy as np
canvas=np.zeros((720,1920,4),dtype=np.float32)
for k,frame in enumerate(frames):
 row=1-k//3;col=k%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640,:]=np.array(frame).reshape((360,640,4))
im=bpy.data.images.new('Paint range',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
paint.node_tree.nodes['PaintTint'].inputs[2].default_value=(*PAINTS[0][1],1)
print('COMPLETE',time.time()-START,flush=True)
