#!/usr/bin/env python3
"""Clean Blender 5.2 build: CC0 source downloads, Cycles tile bakes, mesh, GLB and QA renders.
Authoring frame: X left, Y aft, Z up. glTF exporter converts to X left, Y up, Z forward.
Run: blender -b --python-exit-code 1 --python build_landspeeder.py
Optional -- --draft renders at 960x540; full run emits every delivery artifact.
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, sys, time, hashlib
import numpy as np
from mathutils import Vector, Matrix
from math import sin, cos, pi, exp, sqrt
ROOT=os.path.dirname(os.path.abspath(__file__))
TEX=os.path.join(ROOT,'textures'); os.makedirs(TEX,exist_ok=True)
DRAFT='--draft' in sys.argv
START=time.time()
SOURCE_HASH=hashlib.sha256(open(__file__,'rb').read()).hexdigest()
print('BUILD_START',SOURCE_HASH,flush=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene; sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
sc.render.engine='CYCLES';sc.cycles.samples=32;sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
sc.cycles.device='GPU';sc.cycles.max_bounces=8;sc.cycles.transparent_max_bounces=8
sc.view_settings.view_transform='AgX';sc.view_settings.look='AgX - Medium High Contrast';sc.view_settings.exposure=-.35
sc.render.image_settings.file_format='PNG';sc.render.image_settings.color_mode='RGBA'
sc.render.resolution_percentage=100
M={}; PARTS=[]; MOVING=[]; SOCKETS=[]
PAINTS=[('Sand gold',[.75,.55,.20]),('Pearl white',[.82,.86,.87]),('Signal red',[.58,.035,.025]),('Deep teal',[.025,.25,.27]),('Glacier blue',[.25,.48,.64]),('Graphite',[.055,.065,.075])]

def download(asset,map):
 p=os.path.join(TEX,asset+'_'+map+'_1k.jpg')
 if not os.path.exists(p):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{map}_1k.jpg'
  subprocess.run(['curl','--fail','-L','-A','Mozilla/5.0','--retry','2','-sS',url,'-o',p],check=True)
 return p
PH={}
for asset in ['metal_plate','fabric_leather_02']:
 PH[asset]={}
 for mp in ['diff','nor_gl','arm']:
  p=download(asset,mp); im=bpy.data.images.load(p,check_existing=True)
  if mp!='diff': im.colorspace_settings.name='Non-Color'
  PH[asset][mp]=im

def node(nt,typ):return nt.nodes.new(typ)
def link(nt,a,b):nt.links.new(a,b)
def mathnode(nt,op,a=None,b=None):
 n=node(nt,'ShaderNodeMath');n.operation=op
 for i,v in enumerate([a,b]):
  if v is not None:
   if isinstance(v,(int,float)):n.inputs[i].default_value=v
   else:link(nt,v,n.inputs[i])
 return n.outputs[0]
def color_ramp(nt,sock,low,high,p0=0,p1=1):
 n=node(nt,'ShaderNodeValToRGB');n.color_ramp.elements[0].position=p0;n.color_ramp.elements[0].color=(*low,1)
 n.color_ramp.elements[1].position=p1;n.color_ramp.elements[1].color=(*high,1);link(nt,sock,n.inputs[0]);return n.outputs['Color']

def source_mat(name,base,metal,rough,photo=None,normal_strength=.1):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');link(nt,p.outputs[0],out.inputs['Surface'])
 p.inputs['Base Color'].default_value=(*base,1);p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
 uv=node(nt,'ShaderNodeTexCoord');noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=65;noise.inputs['Detail'].default_value=3
 link(nt,uv.outputs['UV'],noise.inputs['Vector'])
 c=color_ramp(nt,noise.outputs['Fac'],tuple(v*.88 for v in base),tuple(min(v*1.04,1) for v in base),.15,.85)
 r=color_ramp(nt,noise.outputs['Fac'],(rough*.83,)*3,(min(rough*1.18,1),)*3)
 if photo:
  tex={}
  for key,img in PH[photo].items():
   n=node(nt,'ShaderNodeTexImage');n.image=img;link(nt,uv.outputs['UV'],n.inputs['Vector']);tex[key]=n
  bw=node(nt,'ShaderNodeRGBToBW');link(nt,tex['diff'].outputs['Color'],bw.inputs[0])
  c=color_ramp(nt,bw.outputs[0],tuple(v*.78 for v in base),tuple(min(v*1.05,1) for v in base),.05,.8)
  sep=node(nt,'ShaderNodeSeparateColor');link(nt,tex['arm'].outputs['Color'],sep.inputs['Color'])
  r=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',sep.outputs['Green'],rough*.35),rough*.75)
  nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=normal_strength;link(nt,tex['nor_gl'].outputs[0],nm.inputs['Color']);link(nt,nm.outputs[0],p.inputs['Normal'])
 else:
  bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=0 if name=='Glass' else .12;bump.inputs['Distance'].default_value=.0002
  link(nt,noise.outputs['Fac'],bump.inputs['Height']);link(nt,bump.outputs[0],p.inputs['Normal'])
 if name=='Paint':
  r=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',sep.outputs['Green'],.035),.275)
  c=color_ramp(nt,noise.outputs['Fac'],(.81,.81,.81),(.98,.98,.98),.18,.8)
  # Sparse submillimetre pits and uneven polish, stored in neutral tiled textures.
  vor=node(nt,'ShaderNodeTexVoronoi');vor.inputs['Scale'].default_value=150;link(nt,uv.outputs['UV'],vor.inputs['Vector'])
  pits=mathnode(nt,'LESS_THAN',vor.outputs['Distance'],.065)
  mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.4;link(nt,c,mix.inputs[1]);link(nt,color_ramp(nt,pits,(1,1,1),(.6,.6,.6)),mix.inputs[2]);c=mix.outputs[0]
  p.inputs['Coat Weight'].default_value=.24;p.inputs['Coat Roughness'].default_value=.18
 if name in ('Engines','Lamps'):
  sep=node(nt,'ShaderNodeSeparateXYZ');link(nt,uv.outputs['UV'],sep.inputs[0])
  if name=='Engines': c=color_ramp(nt,sep.outputs['X'],(.015,.55,1), (1,.15,.012),.49,.51)
  else:c=color_ramp(nt,sep.outputs['X'],(.8,.93,1),(1,.012,.006),.49,.51)
  link(nt,c,p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=5 if name=='Engines' else 3
 if name=='Glass':
  p.inputs['Alpha'].default_value=.32;p.inputs['Transmission Weight'].default_value=.25
  m.surface_render_method='BLENDED';m.use_transparent_shadow=True
 link(nt,c,p.inputs['Base Color']);link(nt,r,p.inputs['Roughness'])
 M[name]=m
 return m
source_mat('Paint',(.93,.93,.93),.50,.29,'metal_plate',0.0)
source_mat('Chrome',(.55,.59,.62),1,.19,'metal_plate',.018)
source_mat('Alloy',(.12,.145,.17),.85,.36,'metal_plate',.045)
source_mat('Rubber',(.017,.022,.026),.03,.64,'fabric_leather_02',.04)
source_mat('Leather',(.025,.030,.034),.0,.73,'fabric_leather_02',.10)
source_mat('Engines',(.1,.7,1),.3,.26)
source_mat('Lamps',(.9,.9,1),.2,.2)
source_mat('Glass',(.16,.25,.29),.10,.045)

def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def apply(o,mod):
 active(o);bpy.ops.object.modifier_apply(modifier=mod.name)

def mesh(name,verts,faces,mat='Paint',smooth=True):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 ob=bpy.data.objects.new(name,me);sc.collection.objects.link(ob);ob.data.materials.append(M[mat]);PARTS.append(ob)
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 for p in me.polygons:p.use_smooth=smooth
 return ob

def bevel(o,w=.02,segs=3):
 m=o.modifiers.new('Manufactured edge radii','BEVEL');m.width=w;m.segments=segs
 apply(o,m)
 return o

def box(name,loc,size,mat='Alloy',bev=.02):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=size
 active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 o.data.materials.append(M[mat]);PARTS.append(o)
 if bev:bevel(o,bev,3)
 for p in o.data.polygons:p.use_smooth=True
 wn=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL');wn.keep_sharp=True;apply(o,wn)
 return o

def sphere(name,loc,size,mat='Paint',segments=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=segments,ring_count=rings,radius=1,location=loc)
 o=bpy.context.object;o.name=name;o.scale=size;o.data.materials.append(M[mat]);PARTS.append(o)
 active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 for p in o.data.polygons:p.use_smooth=True
 return o

def cyl(name,loc,r,depth,mat='Alloy',axis=(0,0,1),verts=48,r2=None):
 if r2 is None:bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=depth,location=loc)
 else:bpy.ops.mesh.primitive_cone_add(vertices=verts,radius1=r,radius2=r2,depth=depth,location=loc)
 o=bpy.context.object;o.name=name;o.rotation_euler=Vector(axis).to_track_quat('Z','Y').to_euler();o.data.materials.append(M[mat]);PARTS.append(o)
 bevel(o,.006,2)
 for p in o.data.polygons:p.use_smooth=True
 return o

def tube(name,pts,r,mat='Chrome',cyclic=False,res=8):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=12
 sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,co in zip(sp.points,pts):p.co=(*co,1)
 sp.use_cyclic_u=cyclic;cu.bevel_depth=r;cu.bevel_resolution=1;cu.resolution_u=1
 ob=bpy.data.objects.new(name,cu);sc.collection.objects.link(ob);cu.materials.append(M[mat]);PARTS.append(ob)
 active(ob);bpy.ops.object.convert(target='MESH');return ob

def ring(name,center,ro,ri,depth,mat='Alloy',axis='Y',n=64):
 verts=[]
 for d,r in [(-depth/2,ro),(depth/2,ro),(depth/2,ri),(-depth/2,ri)]:
  for i in range(n):
   a=2*pi*i/n;v=(r*cos(a),d,r*sin(a)) if axis=='Y' else (r*cos(a),r*sin(a),d)
   verts.append(tuple(center[k]+v[k] for k in range(3)))
 faces=[]
 for j in range(4):
  for i in range(n):faces.append((j*n+i,j*n+(i+1)%n,((j+1)%4)*n+(i+1)%n,((j+1)%4)*n+i))
 return bevel(mesh(name,verts,faces,mat),.003,2)

def palette(o,warm=False):o['palette_u']=.75 if warm else .25

def rr(cx,cy,w,h,r,n=8):
 pts=[]
 for x,y,a in [(cx+w/2-r,cy+h/2-r,0),(cx-w/2+r,cy+h/2-r,90),(cx-w/2+r,cy-h/2+r,180),(cx+w/2-r,cy-h/2+r,270)]:
  for i in range(n+1):
   t=math.radians(a+i*90/n);pts.append((x+r*cos(t),y+r*sin(t)))
 return pts

# Smooth station interpolation; no primitive fuselage approximations.
ST=[(-2.40,.74,.97,.70),(-2.32,.87,1.00,.67),(-2.1,.97,1.06,.65),(-1.65,1.045,1.16,.65),(-1.25,1.04,1.20,.64),(-.55,1.005,1.23,.64),(0,.99,1.24,.64),(.65,1.045,1.28,.64),(1.2,1.10,1.34,.65),(1.75,1.065,1.33,.67),(2.15,1.025,1.27,.68),(2.28,1.015,1.26,.70)]
def interp(y,col):
 if y<=ST[0][0]:return ST[0][col]
 if y>=ST[-1][0]:return ST[-1][col]
 for i in range(len(ST)-1):
  if ST[i][0]<=y<=ST[i+1][0]:
   x0,x1=ST[i][0],ST[i+1][0];t=(y-x0)/(x1-x0);v0,v1=ST[i][col],ST[i+1][col]
   m0=(v1-ST[max(i-1,0)][col])/(x1-ST[max(i-1,0)][0])
   m1=(ST[min(i+2,len(ST)-1)][col]-v0)/(ST[min(i+2,len(ST)-1)][0]-x0)
   return (2*t**3-3*t*t+1)*v0+(t**3-2*t*t+t)*(x1-x0)*m0+(-2*t**3+3*t*t)*v1+(t**3-t*t)*(x1-x0)*m1

def top(y,u):
 w=interp(y,1);h=interp(y,2)
 f=.135*exp(-((y+1.42)/.46)**2)+.16*exp(-((y-1.35)/.6)**2)
 z=h-.045*u*u+f*exp(-((abs(u)-.78)/.29)**2)+.145*exp(-((y-2.26)/.40)**2)*exp(-((abs(u)-.68)/.27)**2)
 return (w*u,y,z)

BODY_SKIN=[]
BODY_US=sorted(set(float(u) for u in np.linspace(-1,1,33))|{-.78,.78})
BODY_YS=sorted(set(float(y) for y in np.linspace(-2.4,2.28,65))|{-.58,.88})
def top_patch(name,ya,yb,ua=-1,ub=1,ny=26,nx=32):
 rows=[y for y in BODY_YS if ya-1e-6<=y<=yb+1e-6];ny=len(rows)-1
 cols=[u for u in BODY_US if min(ua,ub)-1e-6<=u<=max(ua,ub)+1e-6];nx=len(cols)-1
 verts=[]
 for y in rows:
  for u in cols:verts.append(top(y,u))
 faces=[(j*(nx+1)+i,j*(nx+1)+i+1,(j+1)*(nx+1)+i+1,(j+1)*(nx+1)+i) for j in range(ny) for i in range(nx)]
 o=mesh(name,verts,faces);BODY_SKIN.append(o);return o

# Skin tops leave a genuine cockpit aperture.
top_patch('Long sculpted bonnet',-2.4,-.58,ny=38)
rear_deck=top_patch('Raised rear deck',.88,2.28,ny=30)
for s in [-1,1]:top_patch('Cockpit sill',-.58,.88,ua=s*.78,ub=s,ny=30,nx=8)
# Outer hull: full sculpted bands with a tucked lower sill.
ys=BODY_YS
FLANKS={}
def bottom(y):return interp(y,3)-.075*(exp(-((y+1.38)/.39)**4)+exp(-((y-1.39)/.42)**4))
def flank(y,t,s=1):
 w=interp(y,1);zt=top(y,s)[2];zb=bottom(y)
 sculpt=.10*sin(pi*t)**2*exp(-((y-.05)/.78)**2)
 fender=.065*(exp(-((y+1.38)/.43)**4)+exp(-((y-1.40)/.52)**4))*sin(pi*t)**1.2
 x=s*(w*(1-.12*t-.035*sin(pi*t))+.018*sin(2*pi*t)-sculpt+fender+.060*exp(-((y-2.04)/.40)**4)*sin(pi*t))
 return(x,y,zt*(1-t)+zb*t)
def flank_at(y,z,s=1):
 t=max(0,min(1,(top(y,s)[2]-z)/(top(y,s)[2]-bottom(y))))
 return flank(y,t,s)
for s in [-1,1]:
 vs=[];n=14
 for y in ys:
  w=interp(y,1);zt=top(y,s)[2];zb=bottom(y)
  for j in range(n+1):
   t=j/n
   vs.append(flank(y,t,s))
 fs=[]
 for k in range(len(ys)-1):
  for j in range(n):fs.append((k*(n+1)+j,k*(n+1)+j+1,(k+1)*(n+1)+j+1,(k+1)*(n+1)+j))
 o=mesh('Swept flank',vs,fs);BODY_SKIN.append(o);FLANKS[s]=o
# Belly panel follows the perimeter.
verts=[]
for y in ys:
 w=interp(y,1)*.88;z=bottom(y)
 verts.extend([(-w,y,z),(w,y,z)])
BODY_SKIN.append(mesh('Underbody',verts,[(2*i,2*i+1,2*i+3,2*i+2) for i in range(len(ys)-1)],'Alloy'))
# End closures share exactly the skin perimeter, creating a continuous manifold shell.
for y in [-2.4,2.28]:
 pts=[top(y,u) for u in BODY_US]
 pts += [flank(y,j/14,1) for j in range(1,15)]
 pts += [flank(y,j/14,-1) for j in range(14,0,-1)]
 BODY_SKIN.append(mesh('End body closure',pts,[tuple(range(len(pts)))],'Alloy' if y>0 else 'Paint'))
bpy.ops.object.select_all(action='DESELECT')
for o in BODY_SKIN:o.select_set(True);PARTS.remove(o)
bpy.context.view_layer.objects.active=BODY_SKIN[0];bpy.ops.object.join();body=bpy.context.object;body.name='Continuous coachwork';PARTS.append(body)
bm=bmesh.new();bm.from_mesh(body.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(body.data);bm.free()
sub=body.modifiers.new('Smooth coachbuilt shoulder radii','SUBSURF');sub.levels=1;sub.render_levels=1;apply(body,sub)
so=body.modifiers.new('Continuous pressed skin thickness','SOLIDIFY');so.thickness=.018;apply(body,so)
bevel(body,.012,3)
rear_deck=body;FLANKS={-1:body,1:body}
# Interior well, padded inner door faces, dashboard and rear bulkhead.
box('Cockpit floor',(0,.12,.77),(1.55,1.47,.12),'Rubber',.055)
box('Rear bulkhead',(0,.84,1.025),(1.59,.12,.50),'Leather',.055)
for s in [-1,1]:
 box('Upholstered inner door',(s*.700,.14,1.015),(.08,1.35,.36),'Leather',.035)
 box('Inset armrest',(s*.66,.15,1.10),(.105,.61,.075),'Leather',.025)
 box('Interior release handle',(s*.67,-.10,1.18),(.038,.16,.027),'Chrome',.01)
# Rounded inner rim continuous above the well.
for s in [-1,1]:
 tube('Cockpit chrome lip',[(s*.79,y,top(y,s*.78)[2]+.009) for y in np.linspace(-.55,.88,36)],.012)
box('Dashboard',(0,-.48,1.18),(1.50,.27,.22),'Leather',.065)
box('Centre tunnel',(0,.12,.92),(.24,1.1,.28),'Alloy',.045)
box('Console leather rest',(0,.37,1.08),(.20,.42,.045),'Leather',.018)
# Seats have shells, sculpted bolsters, separate cushions, piping and stitched ribs.
for s in [-1,1]:
 x=s*.405
 shell=box('Seat composite shell',(x,.39,1.06),(.60,.69,.54),'Rubber',.12)
 shell.rotation_euler.x=math.radians(-9)
 box('Seat cushion',(x,.20,.975),(.47,.55,.13),'Leather',.055)
 back=box('Seat back',(x,.55,1.26),(.47,.19,.53),'Leather',.075);back.rotation_euler.x=math.radians(-10)
 head=box('Head restraint',(x,.61,1.55),(.34,.18,.25),'Leather',.075);head.rotation_euler.x=math.radians(-8)
 for side in [-1,1]:
  bol=box('Lateral seat bolster',(x+side*.25,.29,1.12),(.115,.57,.30),'Leather',.055)
  bol.rotation_euler.y=side*.12
  tube('Seat piping',[(x+side*.192,.0,.998),(x+side*.192,.37,1.008),(x+side*.192,.445,1.16),(x+side*.192,.50,1.44)],.0032,'Rubber')
 for k in range(5):
  yy=.0+k*.087
  tube('Cushion stitch',[(x-.16,yy,1.042),(x+.16,yy,1.042)],.0016,'Rubber')
  zz=1.12+k*.063
  tube('Backrest stitch',[(x-.16,.428+(zz-1.12)*.17,zz),(x+.16,.428+(zz-1.12)*.17,zz)],.0016,'Rubber')
 box('Seatbelt buckle',(x-s*.26,.28,1.09),(.045,.065,.085),'Rubber',.01)
 box('Buckle release',(x-s*.26,.266,1.134),(.030,.037,.012),'Alloy',.004)
 # Behind-seat streamlined buttress; loft rises only adjacent to cockpit.
 v=[];f=[]
 for j in range(22):
  t=j/21;y=.72+t*.72;zc=1.32+.40*(1-t)**1.7;ww=.29*(1-.43*t)
  for i in range(17):
   a=pi*i/16;v.append((x+ww*cos(a),y,1.30+(zc-1.30)*sin(a)))
 f.append(tuple(reversed(range(17))))
 for j in range(21):
  for i in range(16):f.append((j*17+i,j*17+i+1,(j+1)*17+i+1,(j+1)*17+i))
 o=mesh('Integrated rear headrest fairing',v,f);sol=o.modifiers.new('Fairing thickness','SOLIDIFY');sol.thickness=.018;apply(o,sol)

# Wraparound raked glass: curved lower and upper edges and substantial metal frame.
def glasspt(u,t):
 x=(.82-.085*t)*u
 y=-.95+.55*t+.30*abs(u)**2
 z=1.245+.34*t-.025*u*u
 return(x,y,z)
v=[glasspt(-1+2*i/40,j/12) for j in range(13) for i in range(41)]
f=[(j*41+i,j*41+i+1,(j+1)*41+i+1,(j+1)*41+i) for j in range(12) for i in range(40)]
o=mesh('Laminated windscreen',v,f,'Glass');sol=o.modifiers.new('Glass thickness','SOLIDIFY');sol.thickness=.006;apply(o,sol)
for t in [0,1]:tube('Windscreen brightwork',[glasspt(u,t) for u in np.linspace(-1,1,65)],.014)
for u in [-1,1]:tube('Windscreen pillar',[glasspt(u,t) for t in np.linspace(0,1,22)],.018)
tube('Windscreen lower seal',[tuple(Vector(glasspt(u,0))+Vector((0,.01,-.012))) for u in np.linspace(-1,1,65)],.018,'Rubber')
# Two buried wiper pivots and blades, visible at close range.
for x in [-.35,.35]:
 cyl('Wiper spindle',(x,-.89,1.25),.015,.012,'Alloy')
 tube('Wiper arm',[(x,-.89,1.26),(x+.12,-.86,1.28)],.005,'Alloy')
 tube('Wiper rubber',[(x-.01,-.865,1.28),(x+.25,-.82,1.28)],.005,'Rubber')
# Flush cowl grille follows the panoramic glass: dark slot with fine metal webs.
v=[];f=[]
for x in np.linspace(-.65,.65,65):
 yy=-1.015+.30*(x/.82)**2
 for dy in [-.027,.027]:v.append(tuple(Vector(top(yy+dy,x/interp(yy+dy,1)))+Vector((0,0,.003))))
for i in range(64):f.append((2*i,2*i+1,2*i+3,2*i+2))
mesh('Cowl vent dark slot',v,f,'Rubber')
for x in np.linspace(-.64,.64,44):
 yy=-1.015+.30*(x/.82)**2
 pts=[tuple(Vector(top(yy+dy,x/interp(yy+dy,1)))+Vector((0,0,.004))) for dy in np.linspace(-.026,.026,5)]
 tube('Cowl vent fine web',pts,.002,'Alloy')

# Yoke assembly built at its steering-column pivot, never transformed after creation.
pivot=Vector((.405,-.245,1.255));axis=Vector((0,.8,.6)).normalized()
cyl('Steering column',pivot-axis*.12,.043,.27,'Alloy',axis=axis,verts=32)
yoke_start=len(PARTS)
# Points in its local plane, normal roughly parallel to the steering column.
def yp(x,z):return pivot+Vector((x,-.6*z,.8*z))
tube('Yoke lower bow',[yp(x,z) for x,z in [(-.215,.075),(-.218,-.065),(-.155,-.12),(0,-.14),(.155,-.12),(.218,-.065),(.215,.075)]],.027,'Leather')
box('Yoke hub',pivot,(.13,.075,.10),'Alloy',.025)
for s in [-1,1]:
 tube('Yoke spoke',[yp(s*.025,-.02),yp(s*.17,-.055)],.018,'Chrome')
 cyl('Thumb control',yp(s*.19,.04),.013,.009,'Alloy',axis=(0,-1,.2),verts=16)
# Instruments contain no glyphs or text.
for x in [.31,.48]:
 cyl('Instrument bezel',(x,-.332,1.295),.058,.024,'Chrome',axis=(0,1,.35),verts=40)
 c=cyl('Blank instrument glass',(x,-.318,1.30),.047,.010,'Glass',axis=(0,1,.35),verts=40)
# instrument objects stay in Hull, not on the Yoke.
yoke_parts=PARTS[yoke_start:yoke_start+6]
# Explicit membership by names avoids dashboard accidentally moving.
yoke_parts=[o for o in PARTS if o.name.startswith(('Yoke lower bow','Yoke hub','Yoke spoke','Thumb control'))]
for o in yoke_parts:PARTS.remove(o)
bpy.ops.object.select_all(action='DESELECT')
for o in yoke_parts:o.select_set(True)
bpy.context.view_layer.objects.active=yoke_parts[0];bpy.ops.object.join();yoke=bpy.context.object;yoke.name='Yoke'
# Move mesh coordinates into pivot space without applying node transforms.
world=yoke.matrix_world.copy()
for vert in yoke.data.vertices:vert.co=world@vert.co-pivot
yoke.matrix_world=Matrix.Translation(pivot);MOVING.append(yoke)

# Chrome strips follow the actual skin, ending ahead of each recessed flank inlet.
LAMP_POS={}
for s in [-1,1]:
 for ya,yb in [(-2.28,.15),(1.90,2.15)]:
  pts=[]
  for y in np.linspace(ya,yb,65):
   z=.965+.055*(y+2.28)/4.43
   p=Vector(flank_at(y,z,s));p.x+=s*.005;pts.append(p)
  tube('Polished beltline',pts,.009,'Chrome')
  tube('Beltline rubber gasket',[(p.x-s*.003,p.y,p.z-.010) for p in pts],.006,'Rubber')

 # A boolean hole in the skin, lined by a dark recessed air duct.
 outline=[(.38,.83),(.49,1.00),(.70,1.195),(.99,1.22),(1.02,1.13),(.86,.87),(.58,.79)]
 cv=[]
 for xx in [.79,1.35]:
  cv.extend([(s*xx,yy,zz) for yy,zz in outline])
 n=len(outline);cf=[tuple(reversed(range(n))),tuple(range(n,n*2))]
 cf.extend([(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)])
 cutter=mesh('Intake cutter',cv,cf,'Alloy',False)
 mod=FLANKS[s].modifiers.new('Recessed intake aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;apply(FLANKS[s],mod)
 PARTS.remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
 outer=[Vector(flank_at(yy,zz,s)) for yy,zz in outline]
 inner=[Vector((p.x-s*.095,p.y,p.z)) for p in outer]
 wall=mesh('Intake duct wall',[tuple(p) for p in outer+inner],[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],'Alloy')
 mesh('Intake dark depth',[tuple(p) for p in inner],[tuple(range(n))],'Rubber')
 tube('Intake rolled body edge',outer,.009,'Paint',True)
 for k in range(4):
  yy=.66+k*.055
  a=Vector(flank_at(yy,.89,s));b=Vector(flank_at(yy+.095,1.10,s));a.x-=s*.055;b.x-=s*.055
  tube('Intake internal turning vane',[a,b],.007,'Alloy')
 # Delicate shut line with a rounded lower door corner.
 yz=[(-.54,1.24),(-.56,1.09),(-.53,.91),(-.50,.86),(-.44,.83),(-.32,.82),(.13,.82),(.27,.85),(.34,.93),(.43,1.13),(.47,1.27)]
 pts=[tuple(Vector(flank_at(yy,zz,s))+Vector((s*.002,0,0))) for yy,zz in yz]
 tube('Door panel seam',pts,.0024,'Rubber')
 p=flank_at(.23,1.22,s);box('Flush exterior handle',(p[0]+s*.005,.23,1.22),(.012,.135,.019),'Chrome',.006)
 tube('Hood separation',[tuple(Vector(top(y,s*.65))+Vector((0,0,.001))) for y in np.linspace(-2.21,-.73,40)],.002,'Rubber')
 # Recessed smoked lenses and two narrow LED elements conform to the bonnet.
 x=s*.65;y=-1.94
 def onhood(xx,yy,dz):return tuple(Vector(top(yy,xx/interp(yy,1)))+Vector((0,0,dz)))
 def hoodlens(name,w,h,rad,dz,mat):
  outline=rr(x,y,w,h,rad);v=[onhood(x,y,dz)];f=[];n=len(outline)
  for j in range(1,5):
   for xx,yy in outline:v.append(onhood(x+(xx-x)*j/4,y+(yy-y)*j/4,dz))
  for i in range(n):f.append((0,1+i,1+(i+1)%n))
  for j in range(3):
   for i in range(n):a=1+j*n+i;b=1+j*n+(i+1)%n;f.append((a,b,b+n,a+n))
  return mesh(name,v,f,mat)
 outline2=rr(x,y,.30,.15,.035)
 hoodlens('Headlamp black cavity',.295,.145,.034,.006,'Rubber')
 hoodlens('Smoked headlamp lens',.275,.126,.029,.011,'Glass')
 for dy in [-.030,.017]:
  l=tube('Thin LED headlamp strip',[onhood(xx,y+dy,.010) for xx in np.linspace(x-.105,x+.105,18)],.004,'Lamps');palette(l)
 tube('Headlamp fine bezel',[onhood(xx,yy,.009) for xx,yy in outline2],.003,'Chrome',True)
 LAMP_POS[s]=onhood(x,y,.012)

# Narrow rounded nose grille: frame, inset opening, individual radiused slats.
yfront=-2.407
profile=rr(0,.845,1.36,.24,.10,12)
mesh('Nose intake dark cavity',[(x,yfront,z) for x,z in profile],[tuple(range(len(profile)))],'Rubber')
tube('Nose grille chrome surround',[(x,yfront-.005,z) for x,z in profile],.012,'Chrome',True)
for k in range(7):
 z=.762+k*.027;w=.64 if k in (0,6) else .665
 tube('Nose horizontal grille slat',[(-w,yfront-.009,z),(w,yfront-.009,z)],.0055,'Alloy')
for x in [-.45,0,.45]:tube('Grille vertical brace',[(x,yfront+.003,.752),(x,yfront+.003,.94)],.004,'Alloy')

# Twin longitudinal turbine chambers are faired INTO the rear body corners.
for s in [-1,1]:
 x=s*.68;z=1.070
 # Exhaust nacelle has progressively nested lip, liner and interior, never a top-mounted cylinder.
 ring('Turbine outer armoured collar',(x,2.24,z),.310,.285,.12,'Paint')
 ring('Turbine dark separation',(x,2.335,z),.287,.257,.075,'Rubber')
 ring('Exhaust polished lip',(x,2.361,z),.27,.248,.048,'Chrome')
 ring('Ceramic exhaust throat',(x,2.28,z),.249,.175,.22,'Alloy')
 r=ring('Blue ion annulus',(x,2.387,z),.240,.222,.012,'Engines');palette(r)
 r=ring('Amber ignition annulus',(x,2.391,z),.204,.188,.010,'Engines');palette(r,True)
 cyl('Dark turbine interior',(x,2.205,z),.185,.018,'Rubber',axis=(0,1,0),verts=64)
 cyl('Turbine centre cone',(x,2.24,z),.080,.09,'Alloy',axis=(0,1,0),verts=48,r2=.045)
 for k in range(24):
  a=2*pi*k/24
  pts=[]
  for r,aa,dy in [(.08,a,.318),(.18,a+.18,.302),(.185,a+.30,.32),(.085,a+.1,.34)]:pts.append((x+r*cos(aa),1.92+dy,z+r*sin(aa)))
  o=mesh('Radial turbine vane',pts,[(0,1,2,3)],'Chrome');so=o.modifiers.new('Blade gauge','SOLIDIFY');so.thickness=.004;apply(o,so)
 for k in range(12):
  a=2*pi*k/12
  cyl('Exhaust rim fastener',(x+.282*cos(a),2.38,z+.282*sin(a)),.008,.012,'Chrome',axis=(0,1,0),verts=8)
 # Engine barrel lies BELOW the rear deck in a deep machined service bay.
 bay_outline=rr(x,1.79,.49,.64,.07,8)
 bv=[]
 for h in [1.15,1.80]:bv.extend([(xx,yy,h) for xx,yy in bay_outline])
 n=len(bay_outline);bf=[tuple(reversed(range(n))),tuple(range(n,2*n))]
 bf.extend([(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)])
 cutter=mesh('Engine bay cutter',bv,bf,'Alloy',False)
 mod=rear_deck.modifiers.new('Inset turbine service opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;apply(rear_deck,mod)
 PARTS.remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
 outer=[Vector(top(yy,xx/interp(yy,1))) for xx,yy in bay_outline]
 inner=[Vector((p.x,p.y,1.16)) for p in outer]
 mesh('Dark engine bay lining',[tuple(p) for p in outer+inner],[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],'Rubber')
 tube('Rolled engine bay edge',outer,.009,'Paint',True)
 cyl('Buried turbine barrel',(x,1.78,1.070),.265,.88,'Chrome',axis=(0,1,0),verts=64)
 for yy in [1.51,1.96]:ring('Turbine compression hoop',(x,yy,1.070),.271,.259,.025,'Chrome')
 r=ring('Thermal barrel joint',(x,2.045,1.070),.267,.259,.014,'Engines');palette(r,True)
 for a in [pi*.24,pi*.5,pi*.76]:
  tube('Barrel longitudinal casing rib',[(x+.268*cos(a),yy,1.070+.268*sin(a)) for yy in np.linspace(1.50,2.08,14)],.005,'Alloy')
 # Tail lamp just outside the turbine mouth.
 box('Tail lamp bezel',(s*.985,2.27,1.21),(.052,.040,.11),'Alloy',.015)
 l=box('Tail lamp',(s*.985,2.296,1.21),(.027,.010,.080),'Lamps',.008);palette(l,True)
# Rear central grille and diffuser.
box('Rear turbine bridge',(0,2.281,1.02),(.72,.035,.36),'Rubber',.08)
for k in range(7):tube('Rear grille slat',[(-.36,2.307,.88+k*.04),(.36,2.307,.88+k*.04)],.009,'Alloy')
for x in [-.34,-.17,0,.17,.34]:
 box('Diffuser strake',(x,1.99,.705),(.026,.42,.075),'Alloy',.012)
# Small trailing spoiler wing with real airfoil section and low supports.
for s in [-1,1]:
 # Swept painted pylons end inside the airfoil's lower skin.
 vv=[]
 for xx in [s*.37-.025,s*.37+.025]:
  vv.extend([(xx,1.91,1.27),(xx,2.045,1.27),(xx,2.075,1.492),(xx,1.99,1.492)])
 bevel(mesh('Swept spoiler pylon',vv,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)]),.012,3)
v=[];f=[];n=40
# A thin NACA section, with a rounded leading edge and tapered trailing edge.
chord=[(1-cos(pi*i/16))/2 for i in range(17)]
profile=[]
for side,ts in [(1,chord),(-1,list(reversed(chord[1:-1])))]:
 for t in ts:
  thickness=5*.12*.29*(.2969*sqrt(t)-.126*t-.3516*t*t+.2843*t**3-.1036*t**4)
  profile.append((1.87+.29*t,1.50+.010*sin(pi*t)+side*thickness))
npf=len(profile)
for j in range(n+1):
 x=-.99+1.98*j/n
 for y,z in profile:v.append((x,y,z-.055*(abs(x)/.99)**4))
for j in range(n):
 for k in range(npf):f.append((j*npf+k,j*npf+(k+1)%npf,(j+1)*npf+(k+1)%npf,(j+1)*npf+k))
f.extend([tuple(reversed(range(npf))),tuple(n*npf+k for k in range(npf))]);bevel(mesh('Rear spoiler airfoil',v,f),.002,2)
# Four underbody repulsor pods with suspension pucks and vented heat sinks.
for sy,y in enumerate([-1.38,1.39]):
 for sx,s in enumerate([-1,1]):
  x=s*.755
  cyl('Pad mounting neck',(x,y,.645),.135,.14,'Alloy',verts=48)
  cyl('Repulsor armoured housing',(x,y,.552),.272,.13,'Alloy',verts=64,r2=.243)
  ring('Repulsor polished retaining rim',(x,y,.486),.251,.224,.038,'Chrome',axis='Z')
  r=cyl('Repulsor emitter',(x,y,.464),.248,.028,'Engines',verts=64);palette(r)
  ring('Repulsor centre isolator',(x,y,.448+.017),.100,.078,.017,'Alloy',axis='Z',n=48)
  for k in range(6):
   a=2*pi*k/6
   b=box('Repulsor shroud segment',(x+.25*cos(a),y+.25*sin(a),.552),(.035,.095,.052),'Alloy',.012);b.rotation_euler.z=a
  for a in [0,pi/2,pi,pi*1.5]:
   cyl('Pad securing bolt',(x+.23*cos(a),y+.23*sin(a),.623),.012,.012,'Chrome',verts=8)

# Explicit scale compensation produces the exact authored ground-space bounding box.
def bounds(obs):
 bpy.context.view_layer.update();pts=[o.matrix_world@Vector(v) for o in obs for v in o.bound_box]
 return Vector([min(p[i] for p in pts) for i in range(3)]),Vector([max(p[i] for p in pts) for i in range(3)])
lo,hi=bounds(PARTS+MOVING);sz=hi-lo
S=Vector((2.2/sz.x,4.8/sz.y,1.3/sz.z));offset=Vector((-(hi.x+lo.x)/2*S.x,-(hi.y+lo.y)/2*S.y,.45-lo.z*S.z))
def corrected(p):return Vector((p[0]*S.x,p[1]*S.y,p[2]*S.z))+offset
for o in PARTS:
 w=o.matrix_world.copy()
 for ve in o.data.vertices:ve.co=corrected(w@ve.co)
 o.matrix_world=Matrix.Identity(4)
for o in MOVING:
 for ve in o.data.vertices:ve.co=Vector((ve.co.x*S.x,ve.co.y*S.y,ve.co.z*S.z))
 o.location=corrected(o.location)
# Meter-scaled triplanar UVs. Every part shares its material's one-metre tile.
def uvmap(o):
 uv=o.data.uv_layers.new(name='UVMap') if not o.data.uv_layers else o.data.uv_layers[0]
 for poly in o.data.polygons:
  axis=max(range(3),key=lambda i:abs(poly.normal[i]))
  for li in poly.loop_indices:
   co=o.data.vertices[o.data.loops[li].vertex_index].co
   if 'palette_u' in o: xy=(o['palette_u'],.5)
   elif axis==0:xy=(co.y,co.z)
   elif axis==1:xy=(co.x,co.z)
   else:xy=(co.x,co.y)
   if o.data.materials[poly.material_index].name in ['Leather','Rubber']:xy=(xy[0]*4,xy[1]*4)
   uv.data[li].uv=xy
for o in PARTS+MOVING:uvmap(o)
# Exactly one rigid mesh node, plus the separately pivoted yoke.
bpy.ops.object.select_all(action='DESELECT')
for o in PARTS:o.select_set(True)
bpy.context.view_layer.objects.active=PARTS[0];bpy.ops.object.join();hull=bpy.context.object;hull.name='Hull';hull.data.name='Hull_Geometry';yoke.data.name='Yoke_Geometry';PARTS=[hull]
# Triangulate, weld exact duplicate vertices and eliminate tiny degenerate faces before export.
for o in PARTS+MOVING:
 bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.000001)
 bmesh.ops.dissolve_degenerate(bm,edges=bm.edges,dist=.0000001)
 bmesh.ops.triangulate(bm,faces=bm.faces)
 bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free();o.data.update()

# Reduce redundant tessellation to a fixed shipping budget.
ratio=min(1.0,76000/sum(len(o.data.polygons) for o in PARTS+MOVING))
for o in PARTS+MOVING:
 dec=o.modifiers.new('Game triangle budget','DECIMATE');dec.ratio=ratio;dec.use_collapse_triangulate=True;apply(o,dec)
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.dissolve_degenerate(bm,edges=bm.edges,dist=.0000001);bmesh.ops.triangulate(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
# Named game attachment sockets; all coordinates are converted by the same scale as geometry.
def socket(name,p,parent=None):
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.07;o.location=corrected(p)
 if parent:
  bpy.context.view_layer.update();world=o.matrix_world.copy();o.parent=parent;o.matrix_world=world
 SOCKETS.append(o);return o
socket('Seat_Driver',(.405,.24,.99));socket('Seat_Passenger',(-.405,.24,.99))
socket('Hand_L',tuple(yp(.215,.01)),yoke);socket('Hand_R',tuple(yp(-.215,.01)),yoke)
socket('Foot_L',(.52,-.42,.82));socket('Foot_R',(.30,-.42,.82))
for i,s in enumerate([-1,1],1):socket(f'Thruster_{i}',(s*.68,2.397,1.070))
for i,(x,y) in enumerate([(-.755,-1.38),(.755,-1.38),(-.755,1.39),(.755,1.39)],1):socket(f'Lift_{i}',(x,y,.45))
socket('Camera_Chase',(0,5.6,2.75))
for side,s in [('L',1),('R',-1)]:
 socket('Light_Head_'+side,LAMP_POS[s]);socket('Light_Tail_'+side,(s*.985,2.303,1.21))

# Light-ground presentation with broad softbox daylight and warm/cool reflections.
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True
world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.73,.83,1)
world.node_tree.nodes['Background'].inputs[1].default_value=.38
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,0));ground=bpy.context.object;ground.name='_StudioGround'
gm=bpy.data.materials.new('_Ground');gm.diffuse_color=(.58,.61,.63,1);gm.use_nodes=True;gp=gm.node_tree.nodes.get('Principled BSDF');gp.inputs['Base Color'].default_value=(.58,.61,.63,1);gp.inputs['Roughness'].default_value=.8;ground.data.materials.append(gm)
def area(name,loc,power,size,color,target=(0,0,0)):
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;data.color=color
 o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler();return o
area('_Key', (1,-4,7),1100,6,(1,.91,.82))
area('_Rim',(-4,2,5),1000,5,(.72,.84,1))
area('_Fill',(4,3,4),650,4,(1,1,1))
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.lens=48
lo,hi=bounds(PARTS+MOVING);ctr=(lo+hi)/2

def camera(mode='hero'):
 cam.data.type='PERSP'
 if mode=='hero':pos=ctr+Vector((7.0,-8.0,3.55));target=ctr+Vector((0,-.1,.05));cam.data.lens=52
 elif mode=='rear':pos=ctr+Vector((5.9,7.7,3.8));target=ctr;cam.data.lens=52
 elif mode=='close':pos=ctr+Vector((3.0,-3.1,2.25));target=Vector((0,-.1,1.16));cam.data.lens=65
 elif mode=='side':pos=Vector((8,0,1.10));target=Vector((0,0,1.10));cam.data.type='ORTHO';cam.data.ortho_scale=6.15
 elif mode=='yoke':pos=Vector((2.3,-2.7,2.65));target=Vector((.1,.0,1.23));cam.data.lens=65
 cam.location=pos;cam.rotation_euler=(target-pos).to_track_quat('-Z','Y').to_euler()

def render(file,mode='hero',w=1920,h=1080,samples=32):
 camera(mode);sc.render.resolution_x=w;sc.render.resolution_y=h;sc.cycles.samples=samples;sc.render.filepath=os.path.join(ROOT,file)
 bpy.ops.render.render(write_still=True)
# Clay first: its result precedes baking and stays available for silhouette review.
clay=bpy.data.materials.new('_Clay');clay.use_nodes=True;cp=clay.node_tree.nodes.get('Principled BSDF');cp.inputs['Base Color'].default_value=(.38,.38,.38,1);cp.inputs['Roughness'].default_value=.55
sc.view_layers[0].material_override=clay
render('clay.png',w=1280 if DRAFT else 1920,h=720 if DRAFT else 1080,samples=20)
sc.view_layers[0].material_override=None

# Cycles material-result bakes on a metre-square tile, shared by every part.
# ORM is genuinely baked as R=occlusion, G=roughness, B=metallic, 512 maximum.
def bake_material(name):
 mat=M[name];nt=mat.node_tree;p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');out=next(n for n in nt.nodes if n.type=='OUTPUT_MATERIAL')
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-20));plane=bpy.context.object;plane.name='_BakeTile';plane.data.materials.append(mat)
 for o in PARTS+MOVING:o.hide_render=True
 ground.hide_render=True
 active(plane);sc.cycles.samples=8;sc.render.bake.margin=8;sc.render.bake.use_clear=True
 size=1024 if name in ['Paint','Chrome','Alloy','Leather','Rubber'] else 128
 target=node(nt,'ShaderNodeTexImage');em=node(nt,'ShaderNodeEmission')
 channels={}
 # Capture sockets before disconnecting the output.
 color=p.inputs['Base Color'].links[0].from_socket
 rough=p.inputs['Roughness'].links[0].from_socket
 for kind,res in [('BaseColor',size),('Normal',size),('ORM',min(size,512))]:
  im=bpy.data.images.new(name+'_'+kind,res,res,alpha=False)
  if kind!='BaseColor':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  for n in nt.nodes:n.select=False
  target.select=True
  if kind=='Normal':
   link(nt,p.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
  else:
   if kind=='BaseColor':link(nt,color,em.inputs['Color'])
   else:
    combine=node(nt,'ShaderNodeCombineColor');combine.mode='RGB'
    combine.inputs['Red'].default_value=1;link(nt,rough,combine.inputs['Green']);combine.inputs['Blue'].default_value=p.inputs['Metallic'].default_value
    link(nt,combine.outputs[0],em.inputs['Color'])
   link(nt,em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=os.path.join(TEX,name+'_'+kind+'.png');im.file_format='PNG';im.save();channels[kind]=im
 bpy.data.objects.remove(plane,do_unlink=True)
 for o in PARTS+MOVING:o.hide_render=False
 ground.hide_render=False
 # Replace source graph with portable glTF PBR; no procedural nodes survive.
 metal=p.inputs['Metallic'].default_value
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');link(nt,p.outputs[0],out.inputs[0])
 tex={}
 for kind,img in channels.items():
  n=node(nt,'ShaderNodeTexImage');n.image=img;tex[kind]=n
 link(nt,tex['BaseColor'].outputs['Color'],p.inputs['Base Color'])
 sep=node(nt,'ShaderNodeSeparateColor');sep.mode='RGB';link(nt,tex['ORM'].outputs['Color'],sep.inputs['Color']);link(nt,sep.outputs['Green'],p.inputs['Roughness']);link(nt,sep.outputs['Blue'],p.inputs['Metallic'])
 nm=node(nt,'ShaderNodeNormalMap');link(nt,tex['Normal'].outputs['Color'],nm.inputs['Color']);link(nt,nm.outputs[0],p.inputs['Normal'])
 # Blender glTF occlusion hook.
 ng=bpy.data.node_groups.get('glTF Material Output')
 if not ng:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(nt,'ShaderNodeGroup');gn.node_tree=ng;link(nt,sep.outputs['Red'],gn.inputs['Occlusion'])
 if name=='Paint':
  mult=node(nt,'ShaderNodeMixRGB');mult.name='Faction paint factor';mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(*PAINTS[0][1],1)
  link(nt,tex['BaseColor'].outputs[0],mult.inputs[1]);link(nt,mult.outputs[0],p.inputs['Base Color']);p.inputs['Coat Weight'].default_value=.24;p.inputs['Coat Roughness'].default_value=.18
 if name in ['Engines','Lamps']:
  link(nt,tex['BaseColor'].outputs['Color'],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=5 if name=='Engines' else 3
 if name=='Glass':
  p.inputs['Alpha'].default_value=.32;p.inputs['Transmission Weight'].default_value=.25;mat.surface_render_method='BLENDED'
 print('BAKED',name,flush=True)
for name in M:bake_material(name)

# Export only asset meshes and named attachment empties; no studio helpers.
asset=PARTS+MOVING+SOCKETS
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
bpy.context.view_layer.objects.active=hull
outpath=os.path.join(ROOT,'landspeeder.glb')
bpy.ops.export_scene.gltf(filepath=outpath,export_format='GLB',use_selection=True,export_apply=False,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False)
# Normalize Paint's factor explicitly in the GLB JSON: portable game recolour contract.
import struct
raw=open(outpath,'rb').read();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);binchunk=raw[20+jl:]
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
open(outpath,'wb').write(struct.pack('<III',0x46546c67,2,20+len(js)+len(binchunk))+struct.pack('<II',len(js),0x4e4f534a)+js+binchunk)
# Manifest is always game frame, world positions, and metres.
def game(p):return [round(p.x,6),round(p.z,6),round(-p.y,6)]
bpy.context.view_layer.update();lo,hi=bounds(PARTS+MOVING)
manifest={'build_source_sha256':SOURCE_HASH,'asset':'landspeeder.glb','units':'metres','coordinates':{'front':'+Z','up':'+Y','left':'+X'},'bounding_box':{'min':[lo.x,lo.z,-hi.y],'max':[hi.x,hi.z,-lo.y],'dimensions':[hi.x-lo.x,hi.z-lo.z,hi.y-lo.y]},'nodes':{},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
for o in asset:
 entry={'world_position':game(o.matrix_world.translation),'type':'mesh' if o.type=='MESH' else 'empty'}
 if o==yoke:entry['axes']={'steering':[0,.6,-.8],'units':'degrees','test_rotation':30,'range':[-45,45]}
 if o.name.startswith('Thruster_'):entry['exhaust_direction']=[0,0,-1]
 if o.name.startswith('Lift_'):entry['direction']=[0,-1,0]
 if o.parent:entry['parent']=o.parent.name
 manifest['nodes'][o.name]=entry
tris=sum(len(o.data.polygons) for o in PARTS+MOVING)
assert 30000<=tris<=80000, f'Triangle budget: {tris}'
assert os.path.getsize(outpath)<=12*1024*1024, 'GLB byte budget'
assert len(M)<=9 and all(max(im.size)<=1024 for mat in M.values() for n in mat.node_tree.nodes if n.type=='TEX_IMAGE' for im in [n.image]), 'Texture/material budget'
assert abs(lo.z-.45)<.003 and all(abs(v-t)<.003 for v,t in zip(hi-lo,(2.2,4.8,1.3))), 'Metre bounds'
manifest['triangles']=tris;manifest['materials']=list(M);manifest['bake']={'engine':'Cycles','device':'OptiX','samples':8,'type':'shared material tiles','sources':['metal_plate','fabric_leather_02']}
json.dump(manifest,open(os.path.join(ROOT,'nodes.json'),'w'),indent=2)
notes=[
 'Original two-seat open sports hover car; 4.8 x 2.2 x 1.3 m, lowest surface 0.45 m, integrated rear turbines and pivoted steering yoke.',
 f'{tris:,} triangles; Hull + Yoke mesh nodes; {len(SOCKETS)} attachment empties; GLB {os.path.getsize(outpath)/1048576:.2f} MiB.',
 '8 materials: Paint, Chrome, Alloy, Rubber, Leather, Engines, Lamps, Glass; neutral paint texture and game recolour factor.',
 'Cycles OptiX baked shared base/normal tiles up to 1024 px; packed ORM up to 512 px; engine/lamp/glass tiles 128 px.',
 'Poly Haven CC0 IDs: metal_plate, fabric_leather_02; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/fabric_leather_02 ; license https://polyhaven.com/license .',
 'Reference differences: default sand gold with red variant, original slimmer LED nose lamps, shorter rear headrest fairings and recessed turbine service bays; no exhaust plume or dust meshes.'
]
open(os.path.join(ROOT,'NOTES.md'),'w').write('\n'.join(notes)+'\n')
print('EXPORT_COMPLETE',tris,os.path.getsize(outpath),time.time()-START,flush=True)
# Save checkpoint only on remote; all deliverables derive from this same clean run.
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'landspeeder.blend'))
render('final.png',w=960 if DRAFT else 1920,h=540 if DRAFT else 1080,samples=32)
render('side.png','side',w=1280 if DRAFT else 1920,h=720 if DRAFT else 1080,samples=24)
render('rear.png','rear',w=1280,h=720,samples=24)
# Exercise the real steering pivot by rotating the node 30 degrees, preserving its origin.
render('yoke_neutral.png','yoke',w=1280,h=720,samples=20)
bpy.context.view_layer.update();pivot_before=yoke.matrix_world.translation.copy()
hand_before={o.name:(o.matrix_world.translation-pivot_before).length for o in SOCKETS if o.parent==yoke}
yoke.rotation_mode='AXIS_ANGLE';yoke.rotation_axis_angle=(math.radians(30),axis.x,axis.y,axis.z)
render('yoke_test.png','yoke',w=1280,h=720,samples=20)
bpy.context.view_layer.update()
assert (yoke.matrix_world.translation-pivot_before).length<1e-7
assert all(abs((o.matrix_world.translation-pivot_before).length-hand_before[o.name])<1e-6 for o in SOCKETS if o.parent==yoke)
manifest['pivot_test']={'node':'Yoke','rotation_degrees':30,'pivot_translation_error_m':(yoke.matrix_world.translation-pivot_before).length,'hand_radius_preserved':True}
json.dump(manifest,open(os.path.join(ROOT,'nodes.json'),'w'),indent=2)
yoke.rotation_axis_angle=(0,axis.x,axis.y,axis.z)
# Six identical cameras, composed with Blender image pixels (no external image dependency).
imgs=[]
for i,(name,rgb) in enumerate(PAINTS):
 M['Paint'].node_tree.nodes['Faction paint factor'].inputs[2].default_value=(*rgb,1)
 path=f'_paint_{i}.png';render(path,w=640,h=360,samples=16)
 im=bpy.data.images.load(os.path.join(ROOT,path),check_existing=False)
 a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);imgs.append(a.reshape(360,640,4))
# Pixel memory is bottom-up. Default/white/red occupy the upper row.
canvas=np.concatenate([np.concatenate(imgs[3:6],axis=1),np.concatenate(imgs[0:3],axis=1)],axis=0)
im=bpy.data.images.new('Paint variants',1920,720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
M['Paint'].node_tree.nodes['Faction paint factor'].inputs[2].default_value=(*PAINTS[0][1],1)
for i in range(6):os.unlink(os.path.join(ROOT,f'_paint_{i}.png'))
print('ALL_DONE',time.time()-START,flush=True)
