"""Original 7 m shuttle. Blender 5.2; clean scene -> CC0 tiles -> Cycles bake -> GLB + proofs."""
import bpy, bmesh, math, os, json, struct, urllib.request, subprocess, time
import numpy as np
from mathutils import Vector
from math import sin,cos,pi,sqrt
ROOT=os.path.dirname(os.path.abspath(__file__))
os.chdir(ROOT)
START=time.time()
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
prefs=bpy.context.preferences.addons['cycles'].preferences
try:prefs.compute_device_type='OPTIX';prefs.get_devices()
except:prefs.compute_device_type='CUDA';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
sc.cycles.device='GPU';sc.cycles.samples=24;sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.world.color=(.25,.25,.25)
GROUPS={};CURRENT='Body';MATS={};BAKES={}
def reg(o,mat):
 o.data.materials.append(MATS[mat]);GROUPS.setdefault(CURRENT,[]).append(o);return o
def smooth(o):
 for p in o.data.polygons:p.use_smooth=True
 return o
def apply(o):
 bpy.context.view_layer.objects.active=o;o.select_set(True)
 for mod in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)
 o.select_set(False)
def bevel(o,w=.02,n=3):
 mod=o.modifiers.new('Formed radii','BEVEL');mod.width=w;mod.segments=n
 mod=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True
 return o
def mesh(name,vs,fs,mat,bev=0):
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o);reg(o,mat)
 if bev:bevel(o,bev,3)
 return o
def box(name,loc,dims,mat,bev=.02):
 x,y,z=[d/2 for d in dims]
 vs=[(-x,-y,-z),(x,-y,-z),(x,y,-z),(-x,y,-z),(-x,-y,z),(x,-y,z),(x,y,z),(-x,y,z)]
 fs=[(3,2,1,0),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)]
 o=mesh(name,vs,fs,mat);o.location=loc
 if bev:bevel(o,bev,1 if name=='Siped tyre tread' else 3)
 return o
def cyl(name,loc,r,depth,mat,axis='Z',v=32):
 vs=[]
 for h in [-depth/2,depth/2]:
  for i in range(v):
   a=2*pi*i/v;p=(r*cos(a),r*sin(a),h)
   if axis=='X':p=(h,r*cos(a),r*sin(a))
   elif axis=='Y':p=(r*cos(a),h,-r*sin(a))
   vs.append(p)
 fs=[tuple(reversed(range(v))),tuple(range(v,2*v))]+[(i,(i+1)%v,(i+1)%v+v,i+v) for i in range(v)]
 o=mesh(name,vs,fs,mat);o.location=loc;bevel(o,.003,2);smooth(o);return o
def tube(name,pts,r,mat,res=8):
 # Swept round tube, tangent-aligned rings; remove coincident path samples.
 pts=[p for i,p in enumerate(pts) if i==0 or (Vector(p)-Vector(pts[i-1])).length>1e-7]
 vs=[];fs=[]
 for i,p in enumerate(pts):
  t=Vector(pts[min(i+1,len(pts)-1)])-Vector(pts[max(i-1,0)]);t.normalize();u=t.cross(Vector((0,0,1)))
  if u.length<.01:u=t.cross(Vector((0,1,0)))
  u.normalize();v=t.cross(u)
  for j in range(res):vs.append(Vector(p)+r*(cos(j*2*pi/res)*u+sin(j*2*pi/res)*v))
 for i in range(len(pts)-1):
  for j in range(res):a=i*res+j;b=i*res+(j+1)%res;fs.append((a,b,b+res,a+res))
 fs.append(tuple(reversed(range(res))));fs.append(tuple((len(pts)-1)*res+j for j in range(res)))
 return smooth(mesh(name,vs,fs,mat))
def torus(name,loc,major,minor,mat,axis='X',N=64,n=12):
 vs=[];fs=[]
 for i in range(N):
  a=2*pi*i/N
  for j in range(n):
   b=2*pi*j/n;v=((major+minor*cos(b))*cos(a),(major+minor*cos(b))*sin(a),minor*sin(b))
   if axis=='X':v=(v[2],v[0],v[1])
   elif axis=='Y':v=(v[0],v[2],v[1])
   vs.append(tuple(v[k]+loc[k] for k in range(3)))
 for i in range(N):
  for j in range(n):fs.append((i*n+j,((i+1)%N)*n+j,((i+1)%N)*n+(j+1)%n,i*n+(j+1)%n))
 return smooth(mesh(name,vs,fs,mat))
def uv_project(o):
 me=o.data
 if not me.uv_layers:me.uv_layers.new(name='UVMap')
 uv=me.uv_layers.active.data
 for p in me.polygons:
  n=p.normal;axis=max(range(3),key=lambda a:abs(n[a]));axes=[a for a in range(3) if a!=axis]
  for li in p.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co;uv[li].uv=(co[axes[0]]*.7,co[axes[1]]*.7)
def gettex(id,map):
 path=os.path.join(ROOT,'textures',f'{id}_{map}_1k.jpg');os.makedirs(os.path.dirname(path),exist_ok=True)
 if not os.path.exists(path):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
  subprocess.run(['curl','-L','--fail','--retry','2','-A','Mozilla/5.0',url,'-o',path],check=True)
 im=bpy.data.images.load(path,check_existing=True)
 if map!='diff':im.colorspace_settings.name='Non-Color'
 return im
# Photographic sources are deliberately shared. Neutral metal microfinish and woven upholstery.
PH={id:{mp:gettex(id,mp) for mp in ['diff','nor_gl','arm']} for id in ['metal_plate','denim_fabric','rubber_tiles']}
SPECS={'Paint':((.94,.945,.95),.38,.15,'metal_plate',.008), 'Rubber':((.028,.031,.035),.74,0,'rubber_tiles',.28),'Metal':((.48,.51,.54),.42,.78,'metal_plate',.08),'Interior':((.085,.09,.10),.66,0,'rubber_tiles',.15),'Fabric':((.13,.155,.175),.85,0,'denim_fabric',.70),'Glass':((.07,.115,.13),.10,0,'metal_plate',.008),'Lamps':((.95,.80,.56),.2,.15,'metal_plate',.02)}
ao_group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ao_group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
# A representative UV tile is baked once per reusable material, preserving scan alignment.
for name,(col,rough,metal,src,amount) in SPECS.items():
 m=bpy.data.materials.new(name);m.use_nodes=True;MATS[name]=m;nt=m.node_tree;nt.nodes.clear();nd=nt.nodes;ln=nt.links
 out=nd.new('ShaderNodeOutputMaterial');p=nd.new('ShaderNodeBsdfPrincipled');ln.new(p.outputs['BSDF'],out.inputs['Surface'])
 uv=nd.new('ShaderNodeTexCoord');tex=nd.new('ShaderNodeTexImage');tex.image=PH[src]['diff'];ln.new(uv.outputs['UV'],tex.inputs['Vector'])
 bw=nd.new('ShaderNodeRGBToBW');ln.new(tex.outputs['Color'],bw.inputs[0]);mix=nd.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=amount;mix.inputs[1].default_value=(*col,1);ln.new(bw.outputs[0],mix.inputs[2])
 noise=nd.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=17;noise.inputs['Detail'].default_value=3;ln.new(uv.outputs['UV'],noise.inputs['Vector'])
 grime=nd.new('ShaderNodeMixRGB');grime.blend_type='MULTIPLY';grime.inputs[0].default_value=.04 if name=='Paint' else .09;ln.new(mix.outputs[0],grime.inputs[1]);ln.new(noise.outputs['Fac'],grime.inputs[2]);ln.new(grime.outputs[0],p.inputs['Base Color'])
 ntex=nd.new('ShaderNodeTexImage');ntex.image=PH[src]['nor_gl'];ln.new(uv.outputs['UV'],ntex.inputs[0]);normal=nd.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=0 if name in ['Paint','Glass','Lamps'] else (.003 if name=='Metal' else (.28 if name=='Fabric' else .07));ln.new(ntex.outputs['Color'],normal.inputs['Color']);ln.new(normal.outputs[0],p.inputs['Normal'])
 arm=nd.new('ShaderNodeTexImage');arm.image=PH[src]['arm'];ln.new(uv.outputs['UV'],arm.inputs[0]);sep=nd.new('ShaderNodeSeparateColor');ln.new(arm.outputs[0],sep.inputs[0])
 rm=nd.new('ShaderNodeMath');rm.operation='MULTIPLY_ADD';rm.inputs[1].default_value=.008 if name in ['Paint','Glass'] else .06;rm.inputs[2].default_value=rough-(.004 if name in ['Paint','Glass'] else .03);ln.new(sep.outputs[1],rm.inputs[0]);ln.new(rm.outputs[0],p.inputs['Roughness']);p.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2);tile=bpy.context.object;tile.name='BakeTile';tile.data.materials.append(m)
 bake=nd.new('ShaderNodeTexImage');nd.active=bake
 images={}
 for typ,size in [('Base',1024),('Normal',512),('ORM',512)]:
  im=bpy.data.images.new(name+'_'+typ,width=size,height=size,alpha=False);bake.image=im;nd.active=bake
  if typ!='Base':im.colorspace_settings.name='Non-Color'
  sc.render.bake.margin=8;sc.cycles.samples=8
  if typ=='Normal':ln.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   em=nd.new('ShaderNodeEmission')
   if typ=='Base':ln.new(grime.outputs[0],em.inputs[0])
   else:
    combine=nd.new('ShaderNodeCombineColor');ao=nd.new('ShaderNodeMath');ao.operation='MULTIPLY_ADD';ao.inputs[1].default_value=.035 if name=='Paint' else .25;ao.inputs[2].default_value=1-ao.inputs[1].default_value;ln.new(sep.outputs[0],ao.inputs[0]);ln.new(ao.outputs[0],combine.inputs[0]);ln.new(rm.outputs[0],combine.inputs[1]);combine.inputs[2].default_value=metal;ln.new(combine.outputs[0],em.inputs[0])
   ln.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');nd.remove(em)
  im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+typ+'.png');im.file_format='PNG';im.save();images[typ]=im
 bpy.data.objects.remove(tile,do_unlink=True)
 nt.nodes.clear();out=nd.new('ShaderNodeOutputMaterial');p=nd.new('ShaderNodeBsdfPrincipled');ln.new(p.outputs[0],out.inputs[0]);p.inputs['Metallic'].default_value=metal
 t=nd.new('ShaderNodeTexImage');t.image=images['Base'];ln.new(t.outputs[0],p.inputs['Base Color'])
 n=nd.new('ShaderNodeTexImage');n.image=images['Normal'];nm=nd.new('ShaderNodeNormalMap');ln.new(n.outputs[0],nm.inputs['Color']);ln.new(nm.outputs[0],p.inputs['Normal'])
 orm=nd.new('ShaderNodeTexImage');orm.image=images['ORM'];sp=nd.new('ShaderNodeSeparateColor');ln.new(orm.outputs[0],sp.inputs[0]);ln.new(sp.outputs[1],p.inputs['Roughness']);ln.new(sp.outputs[2],p.inputs['Metallic'])
 hook=nd.new('ShaderNodeGroup');hook.node_tree=ao_group;ln.new(sp.outputs[0],hook.inputs['Occlusion'])
 if name=='Paint':p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
 if name=='Glass':p.inputs['Alpha'].default_value=.76;p.inputs['Transmission Weight'].default_value=0;m.surface_render_method='BLENDED' if 'BLENDED' in [i.identifier for i in m.bl_rna.properties['surface_render_method'].enum_items] else 'DITHERED';m.diffuse_color=(*col,.76)
 if name=='Lamps':
  # Three bands provide white, red and amber in ONE emissive lamp material.
  arr=np.ones((1024,1024,4),dtype=np.float32)
  arr[:,:341,:3]=(.94,.80,.59);arr[:,341:682,:3]=(.65,.008,.004);arr[:,682:,:3]=(1,.26,.008)
  images['Base'].pixels.foreach_set(arr.ravel());images['Base'].save();ln.new(t.outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=.35
 BAKES[name]=images
print('BAKES DONE',time.time()-START,flush=True)
# Geometry is authored Z-up, front -Y: Blender glTF conversion yields front +Z.
def sx(z):
 return np.interp(z,[.25,.5,1.1,1.34,2.2,2.48,2.75],[.93,.98,1.01,1.0,.925,.865,.73])
def sidept(s,y,z,off=0):return (s*(float(sx(z))*float(np.interp(y,[-3.15,-3.02,-2.75,3.55,3.81],[.85,.93,1,1,.965]))+off),y,z)
def rr(y0,y1,z0,z1,r,n=8):
 pts=[]
 for yc,zc,a in [(y1-r,z1-r,0),(y0+r,z1-r,90),(y0+r,z0+r,180),(y1-r,z0+r,270)]:
  for i in range(n):
   ang=math.radians(a+i*90/(n-1));pts.append((yc+r*cos(ang),zc+r*sin(ang)))
 return pts
# rr above traverses starting rightmost top-right, across top then left etc.
def side_ring(name,s,outer,inner,mat,off=0):
 a=rr(*outer);b=rr(*inner);N=len(a);vs=[sidept(s,y,z,off) for y,z in a+b];fs=[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)];o=mesh(name,vs,fs,mat);solid=o.modifiers.new('Panel gauge','SOLIDIFY');solid.thickness=.006;bevel(o,.002,2);return o
def side_window(s,y0,y1,z0=1.39,z1=2.22):
 side_ring('Pressed window surround',s,(y0,y1,z0-.065,z1+.065,.03),(y0+.033,y1-.033,z0,z1,.10),'Rubber')
 side_ring('EPDM glazing gasket',s,(y0+.030,y1-.030,z0-.003,z1+.003,.10),(y0+.044,y1-.044,z0+.012,z1-.012,.09),'Rubber',.001)
 pts=rr(y0+.044,y1-.044,z0+.012,z1-.012,.09);o=mesh('Tinted safety glazing',[sidept(s,y,z,-.002) for y,z in pts],[tuple(range(len(pts)))],'Glass');return o
# Chassis and floor (no opaque volume behind the glazing).
box('Cabin floor',(0,.63,.48),(1.85,6.02,.12),'Interior',.03)
box('Underfloor structure',(0,.65,.33),(1.52,5.95,.14),'Rubber',.04)
for x in [-.60,.60]:box('Longitudinal chassis member',(x,.56,.29),(.12,5.65,.13),'Metal',.015)
for y in [-2.15,2.15]:cyl('Axle tube',(0,y,.365),.075,1.69,'Metal','X');box('Axle spring hanger',(0,y,.34),(1.3,.18,.15),'Rubber')
# Lower side skins following circular wheel openings.
def bottom(y):
 z=float(np.interp(y,[-3.10,-2.90,-2.65],[.45,.40,.36]))
 for c in [-2.15,2.15]:
  d=abs(y-c)
  if d<.425:z=max(z,.375+sqrt(.425**2-d*d))
 return z
def lower(s,ya,yb):
 ys=sorted(set(round(q,10) for q in ([ya,yb]+[ya+(yb-ya)*i/70 for i in range(71)]+[c+d for c in [-2.15,2.15] for d in [-.425,.425] if ya<c+d<yb])))
 vs=[]
 for y in ys:
  bz=bottom(y)
  for t in [0,.08,.35,.65,1]:
   top=float(np.interp(y,[-3.1,-3.02,-2.94,-2.67,-2.38,-.99,-.98,3.8],[1.075,1.145,1.185,1.265,1.315,1.355,1.325,1.325]));z=bz+(top-bz)*t;vs.append(sidept(s,y,z))
 fs=[]
 for i in range(len(ys)-1):
  for j in range(4):a=i*5+j;fs.append((a,a+5,a+6,a+1))
 o=smooth(mesh('Sculpted lower quarter',vs,[tuple(reversed(f)) for f in fs] if s<0 else fs,'Paint'));o.data.materials.append(MATS['Rubber']);
 for f in o.data.polygons:
  coords=[o.data.vertices[i].co for i in f.vertices]
  if sum(v.y for v in coords)/len(coords)<-2.73 and sum(v.z for v in coords)/len(coords)<.76:f.material_index=1
 m=o.modifiers.new('Steel skin','SOLIDIFY');m.thickness=.025;bevel(o,.004,2)
 # Sacrificial protective side rubbing band follows the same wheel clearance.
 vs=[];fs=[]
 for y in ys:
  z=max(.61,bottom(y)+.006)
  for zz in [z,max(z+.004,.80)]:vs.append(sidept(s,y,zz,.012))
 for i in range(len(ys)-1):fs.append((i*2,i*2+2,i*2+3,i*2+1))
 mesh('Lower moulded rubbing strip',vs,fs,'Rubber')
for s in [-1,1]:
 for ya,yb in [(-3.02,-2.31),(-2.30,-.99),(-.98,.66),(.67,3.78)]:
  CURRENT='Door_1' if s==-1 and ya==-2.30 else ('Door_2' if s==-1 and ya==-.98 else 'Body');lower(s,ya,yb)
 CURRENT='Body'
 for c in [-2.15,2.15]:
  pts=[sidept(s,c+.433*cos(a),.375+.433*sin(a),.008) for a in np.linspace(0,pi,65)]
  tube('Rolled wheelarch edge',pts,.012,'Paint',10)
  vs=[]
  for a in np.linspace(0,pi,65):
   for r,off in [(.425,.026),(.45,.021),(.49,0)]:vs.append(sidept(s,c+r*cos(a),.375+r*sin(a),off))
  smooth(mesh('Pressed wheel arch flare',vs,[(i*3+j,(i+1)*3+j,(i+1)*3+j+1,i*3+j+1) for i in range(64) for j in range(2)],'Paint'))
  pts=[(s*.965,c+.414*cos(a),.375+.414*sin(a)) for a in np.linspace(0,pi,65)]
  tube('Wheelarch inner liner',pts,.032,'Rubber',8)
 # Passenger windows, door rail and raised upper side panel.
 for y0,y1 in [(-.975,.66),(.67,2.17),(2.18,3.77)]:
  CURRENT='Door_2' if s==-1 and y0==-.975 else 'Body';side_window(s,y0,y1)
 CURRENT='Body'
 tube('Roof drip channel',[sidept(s,y,2.35,.006) for y in np.linspace(-1.05,3.73,40)],.012,'Paint')
 # slider rail only right
 if s==-1:
  tube('Sliding door lower guide',[sidept(s,y,1.31,.019) for y in [.52,1,2,3.53]],.014,'Metal')
  box('Recessed upper door track',(-.954,1.27,2.27),(.028,4.36,.031),'Rubber',.008)
 # long lower sill, interrupted arches
 for ya,yb in [(-1.66,1.66),(2.64,3.73)]:tube('Lower sill folded flange',[sidept(s,ya,.39,.002),sidept(s,yb,.39,.002)],.018,'Paint')
# One continuous formed roof shell; rounded transverse shoulders, aerodynamic cab rise.
CURRENT='Body'
ry=[-1.62,-1.51,-1.37,-1.20,-1.00,-.80,3.47,3.65,3.77,3.81]
rz=[2.30,2.42,2.55,2.65,2.72,2.75,2.75,2.745,2.73,2.69]
vs=[];N=65
for y in np.linspace(ry[0],ry[-1],100):
 z=float(np.interp(y,ry,rz));amplitude=z-2.285
 for i in range(N):
  u=-1+2*i/(N-1);w=.925 if y>-.98 else float(np.interp(y,[-1.62,-.98],[.865,.925]))
  vs.append((w*u,y,z-amplitude*abs(u)**10))
fs=[]
for j in range(99):
 for i in range(N-1):a=j*N+i;fs.append((a,a+1,a+1+N,a+N))
o=smooth(mesh('Continuous formed high roof',vs,fs,'Paint'));m=o.modifiers.new('Roof steel gauge','SOLIDIFY');m.thickness=.024
# Rounded rear roof closure, following the same cross section.
vs=[]
for i in range(N):
 u=-1+2*i/(N-1);vs.extend([(u*.925,3.80,2.28),(u*.925,3.80,2.69-(2.69-2.285)*abs(u)**10)])
o=smooth(mesh('Rear roof crown closure',vs,[(2*i,2*i+2,2*i+3,2*i+1) for i in range(N-1)],'Paint'));m=o.modifiers.new('Rear crown gauge','SOLIDIFY');m.thickness=.022
# Custom rounded side cab windows on sloping A-pillar.
for s in [-1,1]:
 CURRENT='Door_1' if s==-1 else 'Body'
 yz=[(-2.38,1.35),(-2.30,1.53),(-1.64,2.24),(-1.40,2.285),(-.99,2.285),(-.99,1.42),(-1.10,1.35)]
 rounded=[]
 for i,p2 in enumerate(yz):
  p0=Vector(yz[i-1]);p1=Vector(p2);p3=Vector(yz[(i+1)%len(yz)]);r=min(.065,(p0-p1).length*.28,(p3-p1).length*.28)
  a=p1+(p0-p1).normalized()*r;b=p1+(p3-p1).normalized()*r
  for t in np.linspace(0,1,7):rounded.append(tuple((1-t)**2*a+2*t*(1-t)*p1+t*t*b))
 yz=rounded
 centre=Vector((sum(a for a,b in yz)/len(yz),sum(b for a,b in yz)/len(yz)))
 inner=[]
 for y,z in yz:
  d=Vector((y,z))-centre;inner.append(tuple(centre+d*.91))
 vs=[sidept(s,y,z) for y,z in yz+inner];N=len(yz);o=mesh('Cab door window frame',vs,[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)],'Paint');m=o.modifiers.new('Door gauge','SOLIDIFY');m.thickness=.035;bevel(o,.024,5)
 tube('Cab glazing seal',[sidept(s,y,z,.004) for y,z in inner+[inner[0]]],.009,'Rubber')
 mesh('Cab side glass',[sidept(s,y,z,-.012) for y,z in inner],[tuple(range(N))],'Glass')
 tube('Quarterlight divider',[sidept(s,-1.965,1.47,.005),sidept(s,-1.80,1.93,.005)],.015,'Rubber')
 # Inner door card
 box('Cab door card',(s*.89,-1.54,1.09),(.065,1.06,.35),'Interior',.06)
 for yy in [-1.23]:
  box('Door handle pocket',(s*1.007,yy,1.22),(.025,.26,.082),'Rubber',.035)
  tube('Door pull handle',[(s*1.031,yy-.085,1.226),(s*1.056,yy-.055,1.24),(s*1.056,yy+.085,1.24)],.016,'Interior',10)
 CURRENT='Body'
 # A-pillar shaped load-bearing edge
 # A-pillar is the integrated steel skin below, without separate tubular trim.
 vs=[]
 for v in np.linspace(0,1,22):
  z=1.37+.91*v;y=-2.38+.78*v;vs.extend([(s*(.88-.075*v),y,z),sidept(s,y,z)])
 o=smooth(mesh('Curved A pillar skin',vs,[(2*i,2*i+2,2*i+3,2*i+1) for i in range(21)],'Paint'));m=o.modifiers.new('Pillar steel','SOLIDIFY');m.thickness=.022
 # mirror brackets and housings
 tube('Mirror support',[(s*.94,-2.04,1.36),(s*1.13,-2.00,1.46),(s*1.15,-1.96,1.59)],.024,'Rubber',10)
 box('Mirror sculpted housing',(s*1.15,-1.98,1.59),(.19,.20,.32),'Rubber',.055)
 box('Mirror reflective face',(s*1.152,-1.867,1.60),(.14,.012,.246),'Metal',.03)
# Sloping panoramic windscreen with a gently bowed surface.
CURRENT='Body'
def wind(u,v,off=0):
 z=1.37+.91*v;y=-2.38+.78*v-.065*(1-u*u)+off;x=u*(.88-.075*v);return (x,y,z)
vs=[wind(-1+2*i/32,j/16) for j in range(17) for i in range(33)];fs=[]
for j in range(16):
 for i in range(32):a=j*33+i;fs.append((a,a+1,a+34,a+33))
smooth(mesh('Panoramic windscreen',vs,fs,'Glass'))
pts=[wind(u,0,-.007) for u in np.linspace(-1,1,33)]+[wind(1,v,-.007) for v in np.linspace(0,1,17)]+[wind(u,1,-.007) for u in np.linspace(1,-1,33)]+[wind(-1,v,-.007) for v in np.linspace(1,0,17)]
tube('Windscreen bonded black surround',pts+[pts[0]],.009,'Rubber',10)
# Bonded glass meets the continuous roof brow directly.
# Closed formed brow bridges the roof nose to the bonded windshield header.
vs=[]
for i in range(33):
 u=-1+2*i/32;vs.extend([wind(u,1,.012),(u*.83,-1.60,2.30-.02*(1-sqrt(max(0,1-u*u))))])
smooth(mesh('Formed windscreen roof brow',vs,[(2*i,2*i+1,2*i+3,2*i+2) for i in range(32)],'Paint'))

# Hood and face lofts: narrow curved nose, rising bonnet, no signature badge or grille.
ys=[-3.13,-3.115,-3.09,-3.06,-2.94,-2.67,-2.38];zs=[1.08,1.115,1.14,1.155,1.24,1.32,1.37];ws=[.51,.53,.70,.91,.98,.985,.91]
vs=[]
for y,z,w in zip(ys,zs,ws):
 for i in range(25):u=-1+2*i/24;vs.append((u*w,y,z-.055*u*u))
fs=[]
for j in range(len(ys)-1):
 for i in range(24):a=j*25+i;fs.append((a,a+1,a+26,a+25))
o=smooth(mesh('Compound curved bonnet',vs,fs,'Paint'));m=o.modifiers.new('Hood steel','SOLIDIFY');m.thickness=.024;bevel(o,.008,3)
for s in [-1,1]:
 tube('Bonnet shutline',[(s*w,y,z-.055) for y,z,w in zip(ys[1:],zs[1:],ws[1:])],.005,'Rubber',8)
# Recessed cowl sheet bridges the curved glazing base and bonnet rear edge.
vs=[]
for u in np.linspace(-1,1,49):vs.extend([wind(u,0,.001),(u*.91,-2.38,1.37-.055*u*u)])
o=smooth(mesh('Integrated windshield cowl',vs,[(2*i,2*i+1,2*i+3,2*i+2) for i in range(48)],'Paint'));m=o.modifiers.new('Cowl steel gauge','SOLIDIFY');m.thickness=.015
# Formed front fascia wraps continuously into both front quarter panels.
for part,zs,mat in [('Painted nose',[.66,.78,.92,1.08],'Paint'),('Moulded bumper',[.43,.47,.53,.63,.70],'Rubber')]:
 vs=[];fs=[]
 for z in zs:
  for i in range(65):
   a=-pi/2+pi*i/64;x=.988*sin(a);y=-3.13+.34*(1-cos(a))
   y+=.045*((z-.55)/.2)**2 if mat=='Rubber' else 0
   vs.append((x,y,z+.05*abs(sin(a))**2 if mat=='Paint' else z))
 for j in range(len(zs)-1):
  for i in range(64):a=j*65+i;fs.append((a,a+1,a+66,a+65))
 o=smooth(mesh(part,vs,fs,mat));md=o.modifiers.new('Formed panel thickness','SOLIDIFY');md.thickness=.025;bevel(o,.012,3)
box('Lower grille recess',(0,-3.128,.568),(1.27,.029,.14),'Interior',.04)
gpoly=[(-.53,1.08),(.53,1.08),(.49,.82),(.42,.78),(-.42,.78),(-.49,.82)]
o=mesh('Integrated grille recess',[(x,-3.145+.06*abs(x),z) for x,z in gpoly],[tuple(range(6))],'Rubber');md=o.modifiers.new('Grille depth','SOLIDIFY');md.thickness=.03;bevel(o,.022,4)
tube('Grille bright perimeter',[(x,-3.161+.06*abs(x),z) for x,z in gpoly+[gpoly[0]]],.013,'Metal',10)
for z,w in [(.855,.88),(.936,.945),(1.017,.98)]:tube('Horizontal satin grille blade',[(-w/2,-3.16,z),(0,-3.178,z+.008),(w/2,-3.16,z)],.012,'Metal',10)
for x in np.linspace(-.55,.55,20):box('Lower grille fin',(float(x),-3.148,.567),(.008,.025,.115),'Rubber',.003)
def lampbox(name,loc,dims,band,bev=.015):
 o=box(name,loc,dims,'Lamps',bev);o['lamp_band']=band;return o
for s in [-1,1]:
 # Original swept, recessed lamp aperture; layered lens and two projector optics.
 poly=[(.545,.99),(.555,1.12),(.945,1.195),(.920,1.03),(.81,.995)]
 def frontpoly(name,poly,depth,mat):
  pts=[(s*x,-3.18+.34*(1-sqrt(max(.01,1-(x/.988)**2)))+depth,z) for x,z in poly]
  o=mesh(name,pts,[tuple(range(len(pts)))],mat);md=o.modifiers.new('Lens housing gauge','SOLIDIFY');md.thickness=.015;bevel(o,.012,3);return o
 frontpoly('Swept headlamp recess',poly,0,'Rubber')
 center=Vector((.745,1.075));inside=[tuple(center+(Vector(p)-center)*.84) for p in poly]
 frontpoly('Smoked optical housing',inside,-.006,'Interior')
 for x,z in [(.64,1.05),(.78,1.085)]:
  yy=-3.18+.34*(1-sqrt(max(.01,1-(x/.988)**2)))-.018
  cyl('Recessed projector cup',(s*x,yy,z),.047,.014,'Metal','Y',32)
  lampbox('Projector optic',(s*x,yy-.012,z),(.063,.012,.058),0,.023)
 o=tube('Swept daytime light guide',[(s*x,-3.18+.34*(1-sqrt(max(.01,1-(x/.988)**2)))-.026,z) for x,z in [(.57,1.008),(.80,1.013),(.89,1.063)]],.008,'Lamps',10);o['lamp_band']=0
 o=tube('Corner amber light guide',[(s*.908,-3.001,1.058),(s*.93,-2.971,1.155)],.008,'Lamps',10);o['lamp_band']=2
 frontpoly('Headlight protective lens',inside,-.045,'Glass')
 o=box('Fog light pocket',(s*.76,-3.045,.58),(.18,.035,.105),'Interior',.035);o.rotation_euler[2]=s*.42
 o=lampbox('Fog lamp',(s*.76,-3.066,.588),(.108,.009,.041),0,.015);o.rotation_euler[2]=s*.42
 # wiper assembly
 tube('Wiper arm',[(s*.60,-2.415,1.395),(s*.28,-2.385,1.44),(s*.13,-2.325,1.49)],.009,'Metal')
 tube('Wiper rubber blade',[(s*.13-.25,-2.328,1.50),(s*.13+.25,-2.328,1.50)],.012,'Rubber')
 box('Cowl vent',(s*.55,-2.46,1.343),(.37,.13,.018),'Rubber',.03)
 for i in range(8):box('Cowl vent louvre',(s*.55-.14+i*.04,-2.425,1.354),(.008,.10,.007),'Interior',.002)
# Rear twin doors and rear fittings.
for s in [-1,1]:
 CURRENT='Door_3' if s==1 else 'Door_4'
 box('Rear door lower pressing',(s*.471,3.772,.94),(.923,.067,1.04),'Paint',.085)
 box('Rear door top pressing',(s*.435,3.752,2.42),(.85,.07,.33),'Paint',.08)
 for x in [s*.05,s*.867]:box('Rear door window upright',(x,3.773,1.86),(.085,.06,.84),'Paint',.035)
 rear_seal=rr(s*.45-.405,s*.45+.405,1.435,2.305,.085);tube('Rear glass seal',[(xx,3.811,zz) for xx,zz in rear_seal+[rear_seal[0]]],.024,'Rubber',8)
 # Rear glass is in an open door aperture, not backed by a solid panel.
 o=box('Rear window',(s*.45,3.827,1.87),(.748,.009,.799),'Glass',.085)
 box('Rear lower recessed stamping',(s*.46,3.813,.85),(.67,.014,.49),'Paint',.065)
 tube('Rear door waist pressing',[(s*.05,3.815,1.35),(s*.45,3.835,1.33),(s*.82,3.81,1.36)],.015,'Paint')
 for z in [.79,2.16]:
  box('Rear hinge leaf',(s*.905,3.805,z),(.13,.027,.083),'Metal',.01);cyl('Rear hinge pin',(s*.948,3.82,z),.016,.12,'Metal')
 box('Rear pull',(s*.12,3.845,1.16),(.18,.04,.045),'Rubber',.02)
 CURRENT='Body'
 tube('Rear formed corner pillar',[sidept(s,3.73,z,.009) for z in np.linspace(.76,2.30,28)],.036,'Paint',12)
 box('Rear lamp black surround',(s*.946,3.816,1.12),(.115,.062,.66),'Rubber',.025)
 for z,h,band in [(.87,.16,1),(1.05,.12,0),(1.18,.10,2),(1.34,.20,1)]:lampbox('Rear combination lens',(s*.946,3.854,z),(.086,.022,h),band,.016)
 for z in np.linspace(.82,1.42,22):box('Tail lens prism',(s*.946,3.869,float(z)),(.083,.005,.003),'Metal',.001)
box('Rear bumper',(0,3.782,.45),(1.96,.196,.19),'Rubber',.055)
box('Rear entry step',(0,3.844,.355),(1.29,.072,.054),'Interior',.015)
for x in np.linspace(-.58,.58,24):box('Rear step anti-slip rib',(float(x),3.845,.388),(.009,.066,.006),'Rubber',.002)
lampbox('High mounted stop lamp',(0,3.765,2.604),(.36,.022,.04),1,.015)
# Sliding leaf detailing / board step.
CURRENT='Door_2'
box('Sliding handle escutcheon',(-1.013,-.75,1.23),(.027,.25,.072),'Rubber',.03)
tube('Sliding door handle',[(-1.034,-.82,1.24),(-1.061,-.79,1.25),(-1.061,-.64,1.25)],.015,'Interior',10)
for z in [.43,2.3]:box('Sliding door roller bracket',(-.96,.59,z),(.09,.11,.045),'Metal',.014)
CURRENT='Body'
box('Boarding step',(-.99,-.38,.335),(.18,1.20,.075),'Rubber',.025)
for s in [-1,1]:
 for y in [-.9,1.24,3.57]:lampbox('Side amber marker',sidept(s,y,.70,.028),(.018,.105,.044),2,.015)
# Wheels: revolved radial tyre profile, circumferential grooves, siped tread and ventilated steel rim.
for s,label in [(1,'L'),(-1,'R')]:
 for y,ax in [(-2.15,'F'),(2.15,'R')]:
  CURRENT='Wheel_'+ax+label;x=s*.86;zc=.365
  profile=[(-.124,.246),(-.13,.283),(-.119,.328),(-.089,.359),(-.059,.365),(.059,.365),(.089,.359),(.119,.328),(.13,.283),(.124,.246)]
  vs=[];fs=[];N=96
  for off,r in profile:
   for i in range(N):a=i*2*pi/N;vs.append((x+off,y+r*cos(a),zc+r*sin(a)))
  for j in range(len(profile)-1):
   for i in range(N):a=j*N+i;b=j*N+(i+1)%N;fs.append((a,b,b+N,a+N))
  smooth(mesh('Tyre carcass radial profile',vs,fs,'Rubber'))
  for off in [-.066,-.023,.023,.066]:torus('Tread longitudinal rib',(x+off,y,zc),.360,.004,'Rubber','X',96,6)
  # tread blocks follow tyre circumference and retain a real channel between rows
  for i in range(48):
   a=2*pi*i/48
   for row,off in enumerate([-.075,-.025,.025,.075]):
    aa=a+(.016 if row%2 else 0);r=.362
    o=box('Siped tyre tread',(x+off,y+r*cos(aa),zc+r*sin(aa)),(.040,.024,.007),'Rubber',.0015);o.rotation_euler[0]=aa-pi/2
  outer=x+s*.126
  torus('Sidewall bead',(outer,y,zc),.267,.007,'Rubber','X',80,8)
  torus('Sidewall moulding',(outer-s*.006,y,zc),.324,.003,'Rubber','X',80,6)
  cyl('Brake disc',(x,y,zc),.213,.04,'Metal','X',64)
  cyl('Pressed steel wheel',(outer-s*.025,y,zc),.243,.045,'Metal','X',64)
  torus('Rim rolled lip',(outer+s*.009,y,zc),.234,.012,'Metal','X',80,10)
  torus('Rim dish ridge',(outer+s*.012,y,zc),.175,.011,'Metal','X',64,10)
  cyl('Hub centre',(outer+s*.024,y,zc),.092,.06,'Metal','X',48)
  cyl('Hub dust cap',(outer+s*.056,y,zc),.056,.012,'Interior','X',40)
  for i in range(12):
   a=2*pi*i/12
   cyl('Deep rim ventilation',(outer+s*.003,y+.198*cos(a),zc+.198*sin(a)),.023,.004,'Interior','X',16)
  for i in range(6):
   a=2*pi*i/6;cyl('Wheel lug nut',(outer+s*.04,y+.116*cos(a),zc+.116*sin(a)),.015,.024,'Metal','X',6)
  cyl('Inflation valve',(outer+s*.02,y+.214,zc+.06),.007,.025,'Rubber','X',10)
CURRENT='Body'
# Visible cabin, fifteen passengers plus driver = sixteen seats.
def seat(x,y,driver=False):
 box('Seat pedestal',(x,y,.675),(.25,.29,.29),'Metal',.022)
 box('Upholstered seat cushion',(x,y-.015,.874),(.405,.43,.14),'Fabric',.063)
 o=box('Contoured seat back',(x,y+.175,1.178),(.415,.15,.59),'Fabric',.066);o.rotation_euler[0]=-.095
 box('Seat back shell',(x,y+.26,1.155),(.373,.055,.49),'Interior',.043)
 box('Padded head restraint',(x,y+.195,1.548),(.27,.145,.20),'Fabric',.061)
 for dx in [-.075,.075]:cyl('Headrest post',(x+dx,y+.18,1.444),.011,.17,'Metal')
 for dx in [-.198,.198]:
  tube('Seat cushion piping',[(x+dx,y-.16,.91),(x+dx,y+.15,.91)],.005,'Interior',6)
  tube('Seat seam',[(x+dx*.76,y+.089,1.02),(x+dx*.76,y+.117,1.37)],.004,'Interior',6)
  box('Armrest',(x+dx,y+.025,1.047),(.052,.29,.051),'Interior',.021)
 tube('Three point seat belt',[(x-.145,y+.085,1.42),(x+.13,y+.08,1.02),(x+.15,y-.15,.93)],.011,'Rubber',6)
 box('Seatbelt buckle',(x+.20,y-.10,.97),(.029,.045,.075),'Interior',.01)
 if not driver:tube('Back grab handle',[(x-.16,y+.29,1.38),(x-.16,y+.31,1.47),(x+.16,y+.31,1.47),(x+.16,y+.29,1.38)],.012,'Interior',8)
seat(.54,-1.30,True)
for y in [-.10,.62,1.34,2.06,2.78]:
 for x in [.61,-.25,-.69]:seat(x,y)
# dashboard shapes and vents
box('Dashboard main moulding',(0,-2.10,1.13),(1.77,.45,.26),'Interior',.085)
box('Dashboard lower console',(-.11,-1.96,.90),(.41,.28,.38),'Interior',.055)
box('Instrument binnacle',(.53,-1.998,1.307),(.50,.24,.14),'Rubber',.05)
for x in [.40,.63]:
 cyl('Instrument silver ring',(x,-1.861,1.294),.063,.015,'Metal','Y',40)
 cyl('Unlettered gauge face',(x,-1.85,1.294),.054,.008,'Interior','Y',32)
 tube('Gauge needle',[(x,-1.839,1.294),(x-.026,-1.839,1.315)],.0025,'Lamps',6)
box('Centre display',(-.05,-1.864,1.238),(.25,.014,.135),'Rubber',.013)
for x in [-.68,-.36,.84]:
 box('HVAC vent surround',(x,-1.866,1.22),(.16,.018,.08),'Rubber',.02)
 for j in range(4):box('HVAC vent slat',(x,-1.85,1.19+j*.017),(.13,.012,.004),'Metal',.001)
for x in [-.16,-.06,.04]:cyl('Climate control',(x,-1.807,1.071),.022,.026,'Rubber','Y',20)
tube('Gear selector',[(-.18,-1.90,.86),(-.18,-1.78,1.00)],.018,'Metal');box('Gear selector knob',(-.18,-1.78,1.025),(.065,.07,.08),'Rubber',.027)
# steering hub origin is exact, with an inclined wheel rim.
CURRENT='SteeringWheel';hub=Vector((.54,-1.71,1.22))
torus('Steering rim',(0,0,0),.177,.020,'Rubber','Z',64,12)
cyl('Steering hub',(0,0,0),.060,.065,'Interior','Z',32)
for a in [0,2*pi/3,4*pi/3]:tube('Steering spoke',[(.025*cos(a),.025*sin(a),.01),(.157*cos(a),.157*sin(a),0)],.017,'Interior',8)
# transform entire wheel from local XY into tilted orientation
from mathutils import Matrix
R=Matrix.Rotation(math.radians(62),4,'X')
for o in GROUPS[CURRENT]:o.matrix_world=Matrix.Translation(hub)@R@o.matrix_world
CURRENT='Body'
tube('Steering column',[(.54,-1.91,.91),tuple(hub)],.029,'Interior')
# Interior wall cards and modest handrails, ceiling lamps
for s in [-1,1]:
 box('Passenger lower side lining',(s*.917,1.40,1.06),(.05,4.52,.42),'Interior',.03)
 tube('Roof interior grab rail',[(s*.72,-.65,2.17),(s*.72,3.20,2.17)],.017,'Metal',10)
 for y in [-.48,.96,2.4]:tube('Rail support',[(s*.72,y,2.17),(s*.83,y,2.31)],.012,'Metal')
tube('Boarding assist handrail',[(-.80,-.83,.70),(-.80,-.83,1.81),(-.73,-.83,1.92)],.022,'Metal',12)
for y in [.1,1.5,2.9]:lampbox('Cabin light',(0,y,2.59),(.22,.16,.018),0,.02)
# roof ribs, antenna, exhaust, suspension and mudflaps
for x in [-.52,-.26,0,.26,.52]:tube('Roof shallow stiffener',[(x,-.65,2.744),(x,3.32,2.744)],.011,'Paint',8)
tube('Roof antenna base',[(.52,-1.38,2.54),(.52,-1.39,2.61)],.027,'Rubber');tube('Roof antenna',[(.52,-1.39,2.61),(.52,-1.30,2.73)],.006,'Rubber',8)
for s in [-1,1]:
 for y in [-1.71,2.59]:box('Flexible mud flap',(s*.86,y,.255),(.26,.019,.28),'Rubber',.018)
tube('Exhaust tailpipe',[(.64,2.73,.25),(.76,3.22,.25),(.84,3.46,.24)],.033,'Metal',16)
# Apply all construction modifiers, project shared tile UVs and merge rigid assemblies.
print('GEOMETRY BUILT',sum(len(v) for v in GROUPS.values()),time.time()-START,flush=True)
# Evaluate all modifiers on one stable dependency graph before mutating the scene.
# This avoids thousands of operator-triggered scene evaluations.
bpy.context.view_layer.update();deps=bpy.context.evaluated_depsgraph_get()
prepared=[(o,bpy.data.meshes.new_from_object(o.evaluated_get(deps),preserve_all_data_layers=True,depsgraph=deps)) for obs in GROUPS.values() for o in obs]
for o,me in prepared:
 o.modifiers.clear();o.data=me
finished=[]
origins={'Wheel_FL':(.86,-2.15,.365),'Wheel_FR':(-.86,-2.15,.365),'Wheel_RL':(.86,2.15,.365),'Wheel_RR':(-.86,2.15,.365),'Door_1':(-.98,-2.30,1.15),'Door_2':(-.98,-.98,1.34),'Door_3':(.948,3.79,1.5),'Door_4':(-.948,3.79,1.5),'SteeringWheel':tuple(hub),'Body':(0,0,0)}
for name,obs in GROUPS.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:
  uv_project(o)
  if 'lamp_band' in o:
   u=(int(o['lamp_band'])+.5)/3
   for lp in o.data.uv_layers.active.data:lp.uv=(u,.5)
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 sc.cursor.location=origins[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR');finished.append(o)
 # normals recalculate to make custom side panels consistent
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=1e-7);bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=1e-7);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free();o.data.update()
 bpy.ops.object.select_all(action='DESELECT')
# Adaptive geometric simplification removes oversampled bevels while retaining the silhouette.
source_tri=0
for o in finished:o.data.calc_loop_triangles();source_tri+=len(o.data.loop_triangles)
ratio=min(1,116000/source_tri)
if ratio<1:
 for o in finished:
  mod=o.modifiers.new('Game triangle budget','DECIMATE');mod.ratio=ratio;mod.use_collapse_triangulate=True;apply(o)
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=1e-7);bm.to_mesh(o.data);bm.free()
# Rebuild corner shading on the final topology while preserving broad curved panels.
for o in finished:
 o.data.set_sharp_from_angle(angle=math.radians(42))
 for f in o.data.polygons:f.use_smooth=True
 mod=o.modifiers.new('Final weighted surface normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=30;apply(o)
# Hold exact length/height and tyre contact without moving the axle centres.
allpts=[o.matrix_world@v.co for o in finished for v in o.data.vertices]
ymin=min(v.y for v in allpts);ymax=max(v.y for v in allpts);zmax=max(v.z for v in allpts)
for o in finished:
 inv=o.matrix_world.inverted()
 for v in o.data.vertices:
  w=o.matrix_world@v.co
  if not o.name.startswith('Wheel_'):
   if w.y < -2.15:w.y=-2.15+(w.y+2.15)*(.97/(-ymin-2.15))
   elif w.y > 2.15:w.y=2.15+(w.y-2.15)*(1.73/(ymax-2.15))
   if w.z>2.30:w.z=2.30+(w.z-2.30)*(.45/(zmax-2.30))
  else:
   dy=w.y-o.location.y;dz=w.z-.365;rad=math.hypot(dy,dz)
   if rad>.365:w.y=o.location.y+dy*.365/rad;w.z=.365+dz*.365/rad
  v.co=inv@w
 o.data.update()
 for v in o.data.vertices:
  if o.name.startswith('Wheel_') and (o.matrix_world@v.co).z<.0001:v.co.z=-.365
 o.data.update()
def fitted_y(y):
 if y < -2.15:return -2.15+(y+2.15)*(.97/(-ymin-2.15))
 if y > 2.15:return 2.15+(y-2.15)*(1.73/(ymax-2.15))
 return y
# Door hinges follow the fitted body. Wheel pivots remain at the fixed axle centres.
for o in finished:
 if o.name in ['Door_1','Door_3','Door_4']:
  pos=list(origins[o.name]);pos[1]=fitted_y(3.82 if o.name in ['Door_3','Door_4'] else pos[1]);sc.cursor.location=pos
  bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');o.select_set(False)
# Required gameplay sockets.
sockets={'Seat_Driver':(.54,-1.30,.91),'Door_Front':(-1.10,-.40,.36),'Exhaust':(.84,3.46,.24),'Light_Head_L':(.737,-3.18+.34*(1-sqrt(1-(.737/.988)**2))-.018,1.075),'Light_Head_R':(-.737,-3.18+.34*(1-sqrt(1-(.737/.988)**2))-.018,1.075),'Light_Tail_L':(.946,3.85,1.24),'Light_Tail_R':(-.946,3.85,1.24)}
for name,loc in sockets.items():o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=(loc[0],fitted_y(loc[1]),loc[2]);finished.append(o)
# Inspection studio, made before render; never part of selected export.
paints=[{'name':'Porcelain White','linear_rgb':[.82,.82,.83]},{'name':'Graphite Grey','linear_rgb':[.18,.20,.225]},{'name':'Atlantic Blue','linear_rgb':[.028,.11,.22]},{'name':'Forest Green','linear_rgb':[.055,.16,.11]},{'name':'Burgundy','linear_rgb':[.22,.035,.044]},{'name':'Sandstone','linear_rgb':[.40,.33,.22]}]
# Count evaluated triangles and scene bounds.
tri=sum(len(o.data.polygons) for o in []);tri=0
for o in finished:
 if o.type=='MESH':o.data.calc_loop_triangles();tri+=len(o.data.loop_triangles)
pts=[o.matrix_world@v.co for o in finished if o.type=='MESH' for v in o.data.vertices]
def game(v):return [float(v[0]),float(v[2]),float(-v[1])]
lo=[min(game(p)[i] for p in pts) for i in range(3)];hi=[max(game(p)[i] for p in pts) for i in range(3)]
manifest={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','seat_count':16,'wheelbase':4.3,'triangles':tri,'materials':list(MATS),'bounding_box':{'min':lo,'max':hi,'size':[hi[i]-lo[i] for i in range(3)]},'body_dimensions_nominal':[2.02,2.75,7.0],'nodes':[{'name':o.name,'type':o.type,'world_position':game(o.location)} for o in finished],'paints':paints}
open('nodes.json','w').write(json.dumps(manifest,indent=2))
# GLB with direct baked node links; patch Paint factor and Glass BLEND as explicit contract.
for o in finished:o.select_set(True)
bpy.context.view_layer.objects.active=finished[0]
bpy.ops.export_scene.gltf(filepath=os.path.join(ROOT,'minibus.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_extras=False)
def patchglb():
 p='minibus.glb';buf=open(p,'rb').read();ln,typ=struct.unpack_from('<II',buf,12);j=json.loads(buf[20:20+ln]);binchunk=buf[20+ln:]
 for m in j.get('materials',[]):
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.82,.82,.83,1]
  if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
 j['asset']['extras']={'generator_script':'build_minibus.py','front':'+Z','unit':'metres'}
 raw=json.dumps(j,separators=(',',':')).encode();raw+=b' '*((-len(raw))%4);open(p,'wb').write(struct.pack('<III',0x46546c67,2,12+8+len(raw)+len(binchunk))+struct.pack('<II',len(raw),0x4e4f534a)+raw+binchunk)
patchglb()
print('EXPORT COMPLETE',tri,os.path.getsize('minibus.glb'),time.time()-START,flush=True)
open('NOTES.md','w').write(f'Original 7 m high-roof, 16-seat shuttle with sliding right door, twin rear doors, articulated wheels and visible cabin; hero/side show the Graphite paint variant.\nTriangles: {tri:,}; wheelbase 4.300 m; nominal body 7.0 × 2.02 × 2.75 m (mirrors extend width).\nMaterials: Paint, Rubber, Metal, Interior, Fabric, Glass, Lamps; neutral Paint texture with recolour factor and clearcoat.\nTextures: Cycles GPU baked reusable 1024² base and 512² normal/ORM tiles; glTF binary {os.path.getsize("minibus.glb")/1048576:.2f} MiB.\nPoly Haven CC0: metal_plate, denim_fabric, rubber_tiles — https://polyhaven.com/license ; source maps downloaded from dl.polyhaven.org.\nDifferences: original unbadged grille and lamps; simplified underside, seat mechanisms and door internals; body surfacing remains more angular than the reference; no manufacturer lettering.\n')
# Render paint factor exactly as in the exported PBR material.
nt=MATS['Paint'].node_tree;p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');t=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image==BAKES['Paint']['Base']);mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(t.outputs[0],mix.inputs[1]);mix.inputs[2].default_value=(*paints[0]['linear_rgb'],1);nt.links.new(mix.outputs[0],p.inputs['Base Color'])
# Save reproducible scene before studio objects.
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'minibus.blend'))
# Daylight studio ground.
mat=bpy.data.materials.new('Studio only');mat.diffuse_color=(.23,.26,.29,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.82
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));floor=bpy.context.object;floor.name='Studio floor';floor.data.materials.append(mat)
world=sc.world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.65,.72,.82,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.4
for name,loc,power,size,color in [('Key',(-4,-5,8),1800,6,(1,.93,.83)),('Fill',(5,-1,6),1300,5,(.80,.88,1)),('Rim',(0,6,7),2100,5,(1,1,1))]:
 data=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=loc;data.energy=power;data.shape='DISK';data.size=size;data.color=color;o.rotation_euler=(Vector((0,.3,1.2))-o.location).to_track_quat('-Z','Y').to_euler()
camdata=bpy.data.cameras.new('Studio camera');cam=bpy.data.objects.new('Studio camera',camdata);sc.collection.objects.link(cam);sc.camera=cam
hero=(-9.8,-12.5,4.7);target=(0,.35,1.29)
def camera(loc,target,ortho=None):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camdata.type='ORTHO' if ortho else 'PERSP';camdata.lens=53
 if ortho:camdata.ortho_scale=ortho
mix.inputs[2].default_value=(*paints[1]['linear_rgb'],1) # Hero uses reference-like graphite; GLB default remains white.
camera(hero,target);sc.view_settings.view_transform='AgX';sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
sc.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
camera((-12,.35,1.4),(0,.35,1.4),8.35);sc.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
camera(hero,target)
clay=bpy.data.materials.new('Clay proof');clay.use_nodes=True;cp=clay.node_tree.nodes.get('Principled BSDF');cp.inputs['Base Color'].default_value=(.36,.36,.36,1);cp.inputs['Roughness'].default_value=.65
sc.view_layers[0].material_override=clay;sc.render.filepath=os.path.join(ROOT,'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
# Six matching cameras, rendered and assembled inside Blender with numpy.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
canvas=np.zeros((720,1920,4),np.float32)
for i,palette in enumerate(paints):
 mix.inputs[2].default_value=(*palette['linear_rgb'],1);sc.render.filepath=os.path.join(ROOT,f'paint_{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath,check_existing=False);ar=np.array(im.pixels[:],np.float32).reshape(360,640,4);row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=ar;bpy.data.images.remove(im)
im=bpy.data.images.new('Six paint variants',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
mix.inputs[2].default_value=(*paints[0]['linear_rgb'],1)
print('ALL COMPLETE',time.time()-START,flush=True)
