#!/usr/bin/env python3
"""Forge cargo skiff. Blender 5.2; one reproducible clean build, no external Python packages.
Run: blender -b --python-exit-code 1 --python build_hover-cargo-skiff.py
Optional: -- --preflight (tiny GPU bake/export/import fixture).
Authoring frame: X left, -Y forward, Z up. glTF conversion makes X left, Y up, Z forward.
Photographs: Poly Haven CC0 only. Tile bakes keep 0.7–1.8 m physical repeats.
"""
import bpy, bmesh, math, os, sys, json, time, random, urllib.request, struct
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; SRC=TEX/'source'; BAKE=TEX/'baked'
for d in (SRC,BAKE): d.mkdir(parents=True,exist_ok=True)
random.seed(41)
START=time.time()
import hashlib
print('BUILDER_SHA256',hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),flush=True)
PAINTS=[('Worn sand',[.50,.42,.30]),('Chalk white',[.78,.80,.77]),('Industrial orange',[.78,.19,.045]),('Safety yellow',[.84,.61,.075]),('Harbour teal',[.055,.34,.30]),('Pale blue',[.29,.49,.65])]
PH=['rusty_metal_02','rusty_painted_metal','metal_plate','wood_planks']
S=bpy.context.scene
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
S.unit_settings.system='METRIC';S.unit_settings.scale_length=1
S.render.engine='CYCLES';S.cycles.samples=32;S.cycles.use_denoising=True
S.cycles.max_bounces=6;S.cycles.transmission_bounces=4
prefs=bpy.context.preferences.addons['cycles'].preferences
try:prefs.compute_device_type='OPTIX'
except:prefs.compute_device_type='CUDA'
prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
S.cycles.device='GPU'
print('GPU_DEVICES',[(d.name,d.use) for d in prefs.devices],flush=True)
S.render.image_settings.file_format='PNG';S.render.image_settings.color_mode='RGB'
S.view_settings.view_transform='AgX'
S.render.film_transparent=False
S.world.use_nodes=True
S.world.node_tree.nodes['Background'].inputs[0].default_value=(.72,.78,.85,1)
S.world.node_tree.nodes['Background'].inputs[1].default_value=.55
STATIC=[];MOVING=[];SOCKETS=[]
MATS={}

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

def simplemat(name,color,metal=0,rough=.5):
 m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF')
 p.inputs['Base Color'].default_value=(*color,1);p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
 MATS[name]=m;return m
for n,c,m,r in [('Paint',(.5,.42,.3),.4,.6),('Metal',(.21,.23,.24),.8,.43),('RotorMetal',(.085,.12,.15),.92,.30),('Dark',(.028,.033,.034),.05,.68),('Crates',(.27,.20,.10),0,.75),('Rust',(.19,.075,.028),.3,.82),('Engines',(.01,.24,.40),.1,.3),('Lamps',(.8,.73,.54),0,.25),('Glass',(.42,.6,.64),0,.15)]:simplemat(n,c,m,r)

def finish(o,mat,bevel=0,segments=2,smooth=False,group=None):
 o.data.materials.append(MATS[mat] if isinstance(mat,str) else mat)
 active(o)
 if bevel:
  mod=o.modifiers.new('Machined edge radii','BEVEL');mod.width=bevel;mod.segments=segments
  mod.limit_method='ANGLE'
  bpy.ops.object.modifier_apply(modifier=mod.name)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 elif bevel:
  for p in o.data.polygons:p.use_smooth=True
  mod=o.modifiers.new('Area weighted normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=50
  bpy.ops.object.modifier_apply(modifier=mod.name)
 (STATIC if group is None else group).append(o)
 return o

def mesh(name,vs,fs,mat='Metal',bevel=0,smooth=False,group=None):
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update()
 ob=bpy.data.objects.new(name,me);S.collection.objects.link(ob)
 return finish(ob,mat,bevel,3 if any(t in name.lower() for t in ['cushion','padding','bolster','armrest']) else 1,smooth,group)

def box(name,loc,size,mat='Metal',bev=.012,rot=None,group=None):
 sx,sy,sz=(x*.5 for x in size)
 vs=[(x,y,z) for x,y,z in [(-sx,-sy,-sz),(-sx,-sy,sz),(-sx,sy,-sz),(-sx,sy,sz),(sx,-sy,-sz),(sx,-sy,sz),(sx,sy,-sz),(sx,sy,sz)]]
 fs=[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)]
 ob=mesh(name,vs,[tuple(reversed(f)) for f in fs],mat,bev,group=group);ob.location=loc
 if rot:ob.rotation_euler=rot
 return ob

def cyl(name,loc,r,depth,mat='Metal',n=24,direction=(0,0,1),bev=.006,group=None):
 bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=loc)
 ob=bpy.context.object;ob.name=name;ob.rotation_euler=Vector(direction).to_track_quat('Z','Y').to_euler()
 return finish(ob,mat,bev,1,True,group)

def sphere(name,loc,scale,mat='Metal',group=None):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=24,ring_count=12,radius=1,location=loc)
 ob=bpy.context.object;ob.name=name
 for v in ob.data.vertices:
  v.co.x*=scale[0];v.co.y*=scale[1];v.co.z*=scale[2]
 return finish(ob,mat,0,2,True,group)

def rod(name,a,b,r,mat='Metal',n=10,group=None):
 a,b=Vector(a),Vector(b);return cyl(name,(a+b)/2,r,(b-a).length,mat,n,b-a,0,group)

def path(name,pts,r,mat='Metal',closed=False,n=10,group=None):
 # Parallel frame swept solid tube. Rings are welded; caps are actual faces.
 pts=[Vector(p) for p in pts];vs=[];fs=[]
 for i,p in enumerate(pts):
  prev=pts[(i-1)%len(pts)] if closed or i>0 else p
  nex=pts[(i+1)%len(pts)] if closed or i<len(pts)-1 else p
  t=(nex-prev).normalized();ref=Vector((0,0,1))
  if abs(t.dot(ref))>.94:ref=Vector((0,1,0))
  u=t.cross(ref).normalized();v=t.cross(u).normalized()
  vs += [tuple(p+r*(math.cos(j*2*math.pi/n)*u+math.sin(j*2*math.pi/n)*v)) for j in range(n)]
 for i in range(len(pts) if closed else len(pts)-1):
  for j in range(n):fs.append((i*n+j,i*n+(j+1)%n,((i+1)%len(pts))*n+(j+1)%n,((i+1)%len(pts))*n+j))
 if not closed:fs += [tuple(reversed(range(n))),tuple((len(pts)-1)*n+j for j in range(n))]
 return mesh(name,vs,fs,mat,0,True,group)

def ring(name,center,r,t,mat='Metal',axis=(0,0,1),n=64,group=None):
 axis=Vector(axis).normalized();ref=Vector((0,1,0)) if abs(axis.z)>.9 else Vector((0,0,1))
 u=axis.cross(ref).normalized();v=axis.cross(u).normalized();c=Vector(center)
 return path(name,[c+r*(math.cos(a*2*math.pi/n)*u+math.sin(a*2*math.pi/n)*v) for a in range(n)],t,mat,True,8,group)

def lathe(name,center,profile,mat='Metal',n=64,group=None):
 vs=[];fs=[]
 for r,z in profile:
  vs.extend([(center[0]+r*math.cos(j*2*math.pi/n),center[1]+r*math.sin(j*2*math.pi/n),z) for j in range(n)])
 for i in range(len(profile)):
  for j in range(n):fs.append((i*n+j,i*n+(j+1)%n,((i+1)%len(profile))*n+(j+1)%n,((i+1)%len(profile))*n+j))
 return mesh(name,vs,fs,mat,0,True,group)

def bolt(loc,axis=(0,0,1),r=.016):
 return cyl('Captive hex fastener',loc,r,.012,'Metal',6,axis,.0015)

def outline(w,l,notch=True):
 # Counter-clockwise outline with rounded ends and scalloped fan mounting shoulders.
 pts=[];rr=.31
 for i in range(81):
  y=-l+rr+(2*l-2*rr)*i/80
  dep=.48*max(math.exp(-((y-a)/.55)**4) for a in [-3.02,3.02]) if notch else 0
  pts.append((w-dep,y))
 for j in range(1,9):
  a=j*math.pi/2/8;pts.append((w-rr+rr*math.cos(a),l-rr+rr*math.sin(a)))
 for j in range(1,9):
  a=math.pi/2+j*math.pi/2/8;pts.append((-w+rr+rr*math.cos(a),l-rr+rr*math.sin(a)))
 for x,y in list(pts[:81])[::-1]:pts.append((-x,y))
 for j in range(1,9):
  a=math.pi+j*math.pi/2/8;pts.append((-w+rr+rr*math.cos(a),-l+rr+rr*math.sin(a)))
 for j in range(1,9):
  a=3*math.pi/2+j*math.pi/2/8;pts.append((w-rr+rr*math.cos(a),-l+rr+rr*math.sin(a)))
 # remove coincident seam vertices
 out=[]
 for p in pts:
  if not out or (Vector(p)-Vector(out[-1])).length>1e-6:out.append(p)
 if (Vector(out[0])-Vector(out[-1])).length<1e-6:out.pop()
 return out

def hull():
 rings=[(1.00,3.66,.64),(1.10,4.06,.77),(1.20,4.48,1.05),(1.20,4.48,1.22),(1.16,4.43,1.28)]
 vs=[]
 for w,l,z in rings:vs += [(x,y,z) for x,y in outline(w,l)]
 n=len(vs)//len(rings);fs=[tuple(reversed(range(n)))]
 for k in range(len(rings)-1):
  for j in range(n):fs.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
 fs.append(tuple((len(rings)-1)*n+j for j in range(n)))
 mesh('Welded displacement hull',vs,fs,'Paint',.018)
 path('Rubber perimeter rubbing strake',[(x,y,1.145) for x,y in outline(1.205,4.425)],.075,'Dark',True,12)
 path('Exposed upper gunwale',[(x,y,1.285) for x,y in outline(1.145,4.405)],.023,'Metal',True,8)
 path('Oxidized waterline seam',[(x,y,.88) for x,y in outline(1.1393,4.225)],.014,'Rust',True,6)
 # Ground clearance .6; attached bottom ribs do not touch the ground.
 for x in [-.68,.68]:box('Underbody longitudinal runner',(x,0,.735),(.10,7.7,.10),'Dark',.035)
 for y in [-3.7,-2.15,0,2.15,3.7]:box('Underdeck crossmember',(0,y,.78),(1.7,.09,.13),'Metal',.02)
 # Deck plates leave actual narrow grooves, with a continuous structure below.
 for y in [-1.82,-.66,.50,1.66]:
  for x in [-.56,.56]:
   box('Flush cargo deck plate',(x,y,1.292),(1.09,1.13,.035),'Metal',.01)
   for dx in [-.49,.49]:
    for dy in [-.50,.50]:bolt((x+dx,y+dy,1.316),r=.011)
 for y in [-3.06,2.82]:box('Inset narrow deck bay',(0,y,1.289),(1.30,1.25,.03),'Metal',.008)
 for y in [-3.94,3.89]:box('End deck plate',(0,y,1.29),(1.87,.57,.035),'Metal',.012)
 for x in [-.86,.86]:
  for y in [-3.90,-1.86,.55]:
   box('Tie down pocket',(x,y,1.316),(.16,.25,.018),'Dark',.028)
   rod('Recessed lashing bar',(x-.055,y,1.332),(x+.055,y,1.332),.015)
 for side in [-1,1]:
  for y in [-1.6,0,1.6]:
   box('Side inspection cover',(side*1.171,y,.968),(.024,.76,.145),'Paint',.014,rot=(0,side*math.atan(.1/.28),0))
   for yy in [y-.32,y+.32]:bolt((side*1.188,yy,.98),(side*.942,0,-.336),.013)
  for y in [-3.85,3.85]:
   rod('Mooring bollard',(side*.87,y,1.31),(side*.87,y,1.43),.035)
   rod('Mooring horns',(side*.87-.12,y,1.43),(side*.87+.12,y,1.43),.025)
 # Bow lamps embedded in armored bezels, two propulsion outlets at stern.
 for x in [-.72,.72]:
  box('Bow lamp recess',(x,-4.371,1.04),(.27,.08,.15),'Dark',.03)
  box('Headlight lens',(x,-4.417,1.045),(.21,.018,.092),'Lamps',.023)
  for xx in [-.05,.05]:rod('Headlight protective bar',(x+xx,-4.439,.985),(x+xx,-4.439,1.10),.007)
  cyl('Rear drive nozzle shroud',(x,4.25,.91),.16,.30,'Metal',32,(0,1,0),.015)
  cyl('Rear drive dark throat',(x,4.407,.91),.127,.02,'Dark',32,(0,1,0),.006)
  ring('Rear drive emission rim',(x,4.422,.91),.098,.016,'Engines',(0,1,0),40)
  box('Tail light housing',(x,4.395,1.17),(.18,.05,.08),'Dark',.015)
  box('Tail light lens',(x,4.427,1.17),(.13,.014,.043),'Lamps',.01)

FAN_CENTERS=[]
def fans():
 for name,x,y in [('Fan_FL',1.289,-3.02),('Fan_FR',-1.289,-3.02),('Fan_RL',1.289,3.02),('Fan_RR',-1.289,3.02)]:
  FAN_CENTERS.append((name,x,y))
  prof=[(.405,.60),(.47,.60),(.495,.65),(.51,.79),(.568,.89),(.600,1.35),(.589,1.47),(.549,1.54),(.492,1.54),(.462,1.47),(.455,1.26),(.425,.86),(.405,.78)]
  sh=lathe('Formed lift nacelle',(x,y),prof,'Paint')
  sh.data.materials.append(MATS['Metal']);sh.data.materials.append(MATS['Dark'])
  for p in sh.data.polygons:
   band=p.index//64
   if band in (5,6,7,8):p.material_index=1
   elif band in (0,1,10,11,12):p.material_index=2
  ring('Upper rolled nacelle rim',(x,y,1.521),.525,.018,'Metal')
  ring('Inner plasma channel',(x,y,1.432),.462,.016,'Engines')
  for slot in range(12):
   path('Segmented lift outlet',[(x+.506*math.cos(2*math.pi*(slot+.10+.80*k/7)/12),y+.506*math.sin(2*math.pi*(slot+.10+.80*k/7)/12),.775) for k in range(8)],.014,'Engines',False,8)
  lathe('Downward lift emitter',(x,y),[(.27,.615),(.39,.615),(.39,.630),(.27,.630)],'Engines',48)
  for j in range(28):
   a=j*2*math.pi/28;r=.487
   o=box('Cooling fin',(x+r*math.cos(a),y+r*math.sin(a),.682),(.019,.035,.135),'Metal',.004)
   o.rotation_euler.z=a
  for j in range(8):
   a=j*2*math.pi/8
   bolt((x+.566*math.cos(a),y+.566*math.sin(a),1.482),r=.014)
  for a in [0,math.pi/2,math.pi,3*math.pi/2]:
   # Rim seams and fastened outer inspection straps.
   xx=x+.589*math.cos(a);yy=y+.589*math.sin(a)
   o=box('Nacelle seam strap',(xx,yy,1.157),(.018,.065,.37),'Metal',.004);o.rotation_euler.z=a
  # Stationary support cross below rotor, clean circular clearance for animation.
  for a in [0,math.pi/2]:rod('Rotor bearing support',(x-.435*math.cos(a),y-.435*math.sin(a),1.18),(x+.435*math.cos(a),y+.435*math.sin(a),1.18),.024,'Dark')
  cyl('Stationary bearing',(x,y,1.22),.11,.19,'Dark',32)
  # Swept, cambered rotor blades with actual thickness, joined around the axis.
  pieces=[]
  for j in range(18):
   a=j*2*math.pi/18;verts=[]
   # each section: radius, swept phase, chord, pitch. Never intersects duct.
   for r,s,c,z in [(.105,0,.078,1.33),(.26,.13,.115,1.365),(.435,.28,.085,1.375)]:
    for edge,dz in [(-1,-.009),(1,-.009),(-1,.009),(1,.009)]:
     phase=a+s+edge*c/(2*r)
     verts.append((x+r*math.cos(phase),y+r*math.sin(phase),z+edge*.022+dz))
   faces=[(0,2,3,1),(8,9,11,10)]
   for k in range(2):
    b=k*4;q=b+4
    faces.extend([(b,b+1,q+1,q),(b+2,q+2,q+3,b+3),(b,q,q+2,b+2),(b+1,b+3,q+3,q+1)])
   mesh('Swept impeller blade',verts,faces,'RotorMetal',.003,False,pieces)
  sphere('Rotor spinner',(x,y,1.40),(.123,.123,.15),'RotorMetal',pieces)
  ring('Rotor hub seal',(x,y,1.327),.123,.013,'Dark',group=pieces)
  ob=join(pieces,name,pivot=(x,y,1.34));MOVING.append(ob)
  # Repair access covers stay on the fixed casing, not the rotor.
  side=1 if x>0 else -1
  cover=box('Nacelle service panel',(x+side*.587,y,1.16),(.025,.29,.22),'Paint',.018)
  for dy in [-.112,.112]:bolt((x+side*.605,y+dy,1.17),(side,0,0),.011)
  rod('Lift mounting spar',(side*.65,y,.97),(x,y,.97),.075,'Metal',16)
  # Welded triangular outrigger cheeks tie the nacelle into the deck shear rail.
  for dy in [-.31,.31]:
   vs=[(side*.67,y+dy-.035,.91),(side*1.10,y+dy-.035,1.07),(side*1.10,y+dy-.035,1.30),(side*.67,y+dy-.035,1.30),(side*.67,y+dy+.035,.91),(side*1.10,y+dy+.035,1.07),(side*1.10,y+dy+.035,1.30),(side*.67,y+dy+.035,1.30)]
   mesh('Nacelle outrigger cheek',vs,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'RotorMetal',.012)
   bolt((side*.82,y+dy,1.31),r=.022)
   bolt((side*1.04,y+dy,1.31),r=.022)

def join(objects,name,pivot=(0,0,0)):
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 # Set origin without applying object transforms to a moving node.
 bpy.context.scene.cursor.location=pivot;bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 return o

def rails():
 for side in [-1,1]:
  x=side*1.105
  # Smooth upper rail bends down at either end.
  pts=[(x,-2.34,1.34),(x,-2.34,1.98)]
  for j in range(1,7):
   a=math.pi-j*math.pi/2/6;pts.append((x,-2.22+.12*math.cos(a),1.98+.12*math.sin(a)))
  pts.append((x,2.13,2.10))
  for j in range(1,7):
   a=math.pi/2-j*math.pi/2/6;pts.append((x,2.13+.12*math.cos(a),1.98+.12*math.sin(a)))
  pts.append((x,2.25,1.34));path('Continuous cargo safety rail',pts,.024)
  rod('Mid height guardrail',(x,-2.34,1.71),(x,2.25,1.71),.019)
  for y in [-2.34,-1.2,0,1.12,2.25]:
   rod('Welded tubular rail stanchion',(x,y,1.32),(x,y,2.08),.022)
   box('Rail foot flange',(x,y,1.328),(.11,.12,.026),'Metal',.008)
   for dy in [-.039,.039]:bolt((x,y+dy,1.348),r=.01)
  # Front corner guards, with wide central loading gap and no rail over a fan.
  for z in [1.70,2.08]:
   pts=[(side*1.06,-3.72,z),(side*1.06,-4.03,z)]
   for j in range(1,9):
    a=j*math.pi/2/8;pts.append((side*(.84+.22*math.cos(a)),-4.03-.22*math.sin(a),z))
   pts.append((side*.32,-4.25,z));path('Bow corner rail',pts,.023)
  for xx,yy in [(side*1.06,-3.72),(side*.32,-4.25)]:rod('Bow rail upright',(xx,yy,1.33),(xx,yy,2.08),.024)
 # Stern station enclosure, raised with the driver platform.
 for z in [1.88,2.30]:
  pts=[(-1.02,3.55,z),(-1.02,3.96,z)]
  for j in range(1,9):
   a=math.pi-j*math.pi/2/8;pts.append((-.83+.19*math.cos(a),3.96+.19*math.sin(a),z))
  pts.append((.83,4.15,z))
  for j in range(1,9):
   a=math.pi/2-j*math.pi/2/8;pts.append((.83+.19*math.cos(a),3.96+.19*math.sin(a),z))
  pts.append((1.02,3.55,z));path('Driver platform guard',pts,.023)
 for x,y in [(-1.02,3.55),(1.02,3.55),(-.82,4.15),(.82,4.15)]:rod('Driver guard support',(x,y,1.42),(x,y,2.30),.024)

def crate(loc,size,variant=0,cross=False):
 x,y,z=loc;w,d,h=size;parts=[]
 # Solid closed plank box plus load-bearing battens and recessed panel faces.
 ob=box('Cargo crate panel',(x,y,z+h/2),(w-.035,d-.035,h-.035),'Crates',.011,group=parts)
 for sx in [-1,1]:
  for sy in [-1,1]:box('Crate corner stile',(x+sx*(w/2-.045),y+sy*(d/2-.042),z+h/2),(.09,.084,h),'Crates',.009,group=parts)
 for sy in [-1,1]:
  for zz in [z+.055,z+h-.055]:box('Crate face rail',(x,y+sy*(d/2-.026),zz),(w,.065,.11),'Crates',.008,group=parts)
 for sx in [-1,1]:
  for zz in [z+.055,z+h-.055]:box('Crate side rail',(x+sx*(w/2-.026),y,zz),(.065,d,.11),'Crates',.008,group=parts)
 # Fine visible real plank joints.
 for k in range(1,4):
  xx=x-w/2+w*k/4
  box('Crate plank joint',(xx,y-d/2+.014,z+h/2),(.008,.007,h-.20),'Dark',.002,group=parts)
  box('Lid plank joint',(xx,y,z+h-.014),(.008,d-.1,.007),'Dark',.002,group=parts)
 if cross:
  for sg in [-1,1]:
   a=Vector((x-sg*(w/2-.10),y-d/2-.015,z+.12));b=Vector((x+sg*(w/2-.10),y-d/2-.015,z+h-.12))
   mid=(a+b)/2;o=box('Diagonal crate brace',mid,(.065,.041,(b-a).length),'Crates',.006,group=parts)
   o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler()
 for o in parts:o['crate_variant']=variant
 STATIC.extend(parts)
 # Corner brackets and small fasteners are crate fittings, distinct bare metal.
 for sx in [-1,1]:
  for sy in [-1,1]:
   for zz in [z+.09,z+h-.09]:
    bolt((x+sx*(w/2-.045),y+sy*(d/2+.008),zz),(0,sy,0),.010)
 return parts

def cockpit():
 box('Raised driver platform',(0,3.48,1.37),(1.80,1.66,.16),'Metal',.035)
 # Two inset tread steps on each side of the console.
 for sx in [-1,1]:
  box('Driver access step',(sx*.75,2.42,1.36),(.33,.42,.12),'Dark',.015)
  for k in range(6):box('Step anti slip rib',(sx*.75,2.26+k*.055,1.425),(.30,.016,.014),'Metal',.004)
 # Tapered folded console with actual end caps.
 vs=[(-.47,2.37,1.43),(.47,2.37,1.43),(.42,2.91,1.43),(-.42,2.91,1.43),(-.43,2.48,2.34),(.43,2.48,2.34),(.38,2.90,2.22),(-.38,2.90,2.22)]
 fs=[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)]
 mesh('Canted driver console',vs,fs,'Metal',.035)
 box('Console front service recess',(0,2.378,1.88),(.68,.028,.43),'Dark',.024)
 box('Console front access door',(0,2.350,1.89),(.63,.035,.37),'Metal',.02)
 for x in [-.26,.26]:
  for z in [1.75,2.03]:bolt((x,2.328,z),(0,-1,0),.013)
 box('Console lower intake',(0,2.322,1.55),(.55,.035,.15),'Dark',.015)
 for z in [1.50,1.54,1.58,1.62]:box('Console intake louver',(0,2.297,z),(.50,.03,.014),'Metal',.003)
 # Thin glass screen raked towards the driver, top at exactly 3.0 m.
 glass=box('Laminated windscreen',(0,2.53,2.657),(.88,.014,.644),'Glass',.025,rot=(-math.radians(11),0,0))
 # Screen corners from the same rake, all frame members attached to glass.
 def gz(x,z):return (x,2.53+(z-2.657)*math.tan(math.radians(11)),z)
 path('Windscreen rubber gasket',[gz(-.444,2.34),gz(.444,2.34),gz(.444,2.98),gz(-.444,2.98)],.017,'Dark',True,8)
 path('Windscreen steel frame',[gz(-.463,2.34),gz(.463,2.34),gz(.463,2.9835),gz(-.463,2.9835)],.020,'Metal',True,8)
 # Dashboard: angled surface with instruments, toggle rows, and tactile knobs; no lettering.
 panel=box('Inset instrument panel',(0,2.777,2.279),(.70,.255,.028),'Dark',.016,rot=(-.28,0,0))
 for x in [-.23,-.07]:
  box('Instrument metal bezel',(x,2.744,2.313),(.13,.14,.025),'Metal',.01)
  box('Instrument dark screen',(x,2.744,2.329),(.105,.115,.012),'Dark',.006)
  for k in range(3):box('Screen status bars',(x-.025+k*.025,2.74,2.337),(.010,.055-.01*k,.004),'Lamps',.002)
 for x in [.10,.18,.26]:
  cyl('Control knob',(x,2.81,2.30),.019,.035,'Dark',16,bev=.004)
 for x in [.10,.18,.26]:
  rod('Toggle stalk',(x,2.70,2.31),(x,2.68,2.35),.006,'Metal',8)
  sphere('Toggle grip',(x,2.68,2.35),(.009,.009,.012),'Dark')
 # Driver yoke has a physical column and clear hand targets.
 col0=(.04,2.88,2.01);col1=(.04,3.14,2.27);axis=Vector(col1)-Vector(col0)
 rod('Steering column',col0,col1,.034,'Metal',16)
 ring('Steering wheel',col1,.205,.024,'Dark',axis,48)
 for a in [0,2*math.pi/3,4*math.pi/3]:
  u=Vector((1,0,0));v=axis.normalized().cross(u)
  end=Vector(col1)+.19*(math.cos(a)*u+math.sin(a)*v);rod('Wheel spoke',col1,end,.012,'Metal')
 cyl('Wheel boss',col1,.048,.055,'Dark',24,axis)
 # Suspended adjustable seat frame, cushion and back padding.
 box('Seat pedestal floor shoe',(0,3.62,1.475),(.39,.40,.07),'RotorMetal',.024)
 cyl('Seat pedestal',(0,3.62,1.65),.105,.40,'RotorMetal',24)
 for z in [1.51,1.56,1.61,1.66,1.71,1.76]:ring('Seat suspension bellows',(0,3.62,z),.116,.019,'Dark',n=24)
 for x in [-.145,.145]:rod('Seat damper',(x,3.72,1.49),(x,3.69,1.87),.021,'Metal',12)
 box('Seat sliding rail',(0,3.61,1.86),(.43,.45,.06),'Metal',.015)
 box('Seat pan',(0,3.61,1.93),(.58,.55,.09),'Metal',.035)
 box('Driver seat cushion',(0,3.59,2.005),(.55,.53,.13),'Dark',.06)
 box('Seat back steel shell',(0,3.88,2.34),(.56,.09,.74),'Metal',.04,rot=(.10,0,0))
 box('Seat back padding',(0,3.817,2.35),(.51,.09,.69),'Dark',.045,rot=(.10,0,0))
 box('Seat upper bolster',(0,3.84,2.724),(.44,.13,.16),'Dark',.046)
 for x in [-.30,.30]:
  rod('Seat arm support',(x,3.69,1.96),(x,3.69,2.19),.018)
  box('Driver armrest',(x,3.60,2.20),(.075,.35,.07),'Dark',.032)
 for x in [-.19,.19]:
  box('Pedal',(x,3.07,1.50),(.13,.24,.035),'Dark',.014,rot=(.25,0,0))
  for k in range(4):box('Pedal rib',(x,2.99+k*.042,1.529),(.11,.01,.008),'Metal',.002)
 # Exposed hydraulic line fixed to the console side.
 path('Console cable loom',[(.45,2.73,2.12),(.50,2.81,1.88),(.51,3.02,1.56),(.35,3.22,1.45)],.018,'Dark',False,10)
 for y in [2.83,2.98]:box('Cable retaining clip',(.509,y,1.70),(.055,.045,.035),'Metal',.008)

def details():
 crate((.59,1.60,1.318),(.82,.86,1.00),1)
 crate((-.50,1.57,1.318),(.82,.78,.72),0,True)
 crate((-.56,.55,1.318),(.74,.79,.78),3)
 crate((.55,.58,1.318),(.64,.68,.54),2)
 crate((.58,-.20,1.318),(.61,.61,.43),1)
 crate((-.56,2.22,1.318),(.63,.42,.40),0)
 # Fabric straps wrap the taller green crate, with buckles and anchored loose end.
 for y in [1.33,1.87]:
  path('Cargo tension strap',[(.157,y,1.34),(.157,y,2.34),(1.021,y,2.34),(1.021,y,1.34)],.017,'Dark',False,6)
  box('Strap ratchet',(.151,y,1.78),(.032,.09,.16),'Metal',.012)
 # A restrained working rope coil and tether at the bow.
 pts=[]
 for i in range(200):
  a=i*2*math.pi/40;r=.17+.00045*i
  pts.append((-.60+r*math.cos(a),-3.77+r*.67*math.sin(a),1.335+.001*i))
 path('Coiled mooring rope',pts,.012,'Crates',False,7)
 path('Mooring rope lead',[(-.77,-3.84,1.34),(-.91,-3.91,1.35),(-.87,-3.96,1.44),(-.75,-3.94,1.44)],.012,'Crates',False,7)
 # Brown corrosion localized at fastener rails and hull welds.
 for side in [-1,1]:
  for y in [-1.9,-.7,.5,1.7]:
   box('Seam oxidation',(side*1.180,y,.98),(.004,.035,.13),'Rust',.002,rot=(0,side*math.atan(.1/.28),0))

# UVs: physical triplanar projection on final mesh; small fittings share tiles.
def uv_project(ob):
 me=ob.data
 uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers[0]
 me.uv_layers.active_index=0;uv.active_render=True
 variant=ob.get('crate_variant',0)
 for poly in me.polygons:
  axis=max(range(3),key=lambda k:abs(poly.normal[k]));ab=[k for k in range(3) if k!=axis]
  mat=me.materials[poly.material_index].name
  period={'Paint':1.55,'Metal':1.25,'Dark':.7,'Crates':1.0,'Rust':.65}.get(mat,1)
  coords=[me.vertices[me.loops[k].vertex_index].co for k in poly.loop_indices]
  if mat=='Crates':
   # four independently baked timber / paint quadrants, each repeats inside its panel.
   low=[min(v[a] for v in coords) for a in ab];hi=[max(v[a] for v in coords) for a in ab]
   span=[max(.03,hi[j]-low[j]) for j in [0,1]]
  for li in poly.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co
   if mat=='Crates':
    uv.data[li].uv=((variant%2)*.5+.012+.475*(co[ab[0]]-low[0])/span[0],(variant//2)*.5+.012+.475*(co[ab[1]]-low[1])/span[1])
   else:uv.data[li].uv=(co[ab[0]]/period+.137,co[ab[1]]/period+.271)

# Photographic tile sources, all fetched with explicit fixed CC0 source URLs.
def download():
 for asset in PH:
  for kind in ['diff','nor_gl','arm']:
   f=SRC/f'{asset}_{kind}_1k.jpg'
   if not f.exists():
    u=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
    req=urllib.request.Request(u,headers={'User-Agent':'ForgeAssetLibrary/1.0'})
    with urllib.request.urlopen(req,timeout=90) as r:f.write_bytes(r.read())
   assert f.stat().st_size>1000,(f,'invalid source')
 print('SOURCES_READY',flush=True)

def node(nt,typ):return nt.nodes.new(typ)
def val(nt,x):n=node(nt,'ShaderNodeValue');n.outputs[0].default_value=x;return n.outputs[0]
def color(nt,c):n=node(nt,'ShaderNodeRGB');n.outputs[0].default_value=(*c,1);return n.outputs[0]
def mathn(nt,op,a,b):
 n=node(nt,'ShaderNodeMath');n.operation=op
 for i,x in enumerate([a,b]):
  if isinstance(x,(float,int)):n.inputs[i].default_value=x
  else:nt.links.new(x,n.inputs[i])
 return n.outputs[0]
def ramp(nt,s,items):
 n=node(nt,'ShaderNodeValToRGB');r=n.color_ramp
 for i,(p,c) in enumerate(items):
  e=r.elements[i] if i<2 else r.elements.new(p);e.position=p;e.color=(*c,1)
 nt.links.new(s,n.inputs[0]);return n.outputs[0]
def mix(nt,a,b,fac,blend='MIX'):
 n=node(nt,'ShaderNodeMixRGB');n.blend_type=blend
 if isinstance(fac,(float,int)):n.inputs[0].default_value=fac
 else:nt.links.new(fac,n.inputs[0])
 for i,s in [(1,a),(2,b)]:nt.links.new(s,n.inputs[i])
 return n.outputs[0]

def bake_tiles():
 download();S.cycles.samples=8;S.render.bake.margin=8;S.render.bake.use_clear=True
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,10));plane=bpy.context.object;plane.name='Temporary tile bake target'
 baked={}
 for name in list(MATS):
  print('BAKE_START',name,round(time.time()-START,1),flush=True)
  m=bpy.data.materials.new('SOURCE_'+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'])
  uv=node(nt,'ShaderNodeTexCoord').outputs['UV']
  source='rusty_metal_02' if name=='Paint' else 'wood_planks' if name=='Crates' else 'metal_plate' if name in ['Dark','RotorMetal'] else 'rusty_painted_metal' if name=='Rust' else 'rusty_metal_02'
  texcoord=uv
  if name=='Crates':
   se=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv,se.inputs[0]);co=node(nt,'ShaderNodeCombineXYZ');nt.links.new(se.outputs['Y'],co.inputs['X']);nt.links.new(se.outputs['X'],co.inputs['Y'])
   vec=node(nt,'ShaderNodeVectorMath');vec.operation='SCALE';nt.links.new(co.outputs[0],vec.inputs[0]);vec.inputs['Scale'].default_value=2;texcoord=vec.outputs[0]
  maps={}
  for kind in ['diff','nor_gl','arm']:
   tex=node(nt,'ShaderNodeTexImage');tex.image=bpy.data.images.load(str(SRC/f'{source}_{kind}_1k.jpg'),check_existing=True)
   if kind!='diff':tex.image.colorspace_settings.name='Non-Color'
   nt.links.new(texcoord,tex.inputs[0]);maps[kind]=tex.outputs['Color']
  bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(maps['diff'],bw.inputs[0]);grey=bw.outputs[0]
  arm=node(nt,'ShaderNodeSeparateColor');nt.links.new(maps['arm'],arm.inputs[0])
  no=node(nt,'ShaderNodeTexNoise');nt.links.new(uv,no.inputs[0]);no.inputs['Scale'].default_value=27;no.inputs['Detail'].default_value=4;no.inputs['Roughness'].default_value=.72
  rough=mathn(nt,'MULTIPLY_ADD',arm.outputs['Green'],.7) if False else mathn(nt,'ADD',mathn(nt,'MULTIPLY',arm.outputs['Green'],.42),.35)
  metal=val(nt,.65)
  if name=='Paint':
   # Light neutral detail only. Hue comes from baseColorFactor after bake.
   col=ramp(nt,grey,[(.015,(.38,.38,.38)),(.085,(.76,.76,.76)),(.22,(.93,.93,.93)),(.6,(.99,.99,.99))])
   col=mix(nt,col,ramp(nt,no.outputs['Fac'],[(.2,(.72,.72,.72)),(.8,(1,1,1))]),.12,'MULTIPLY');metal=val(nt,.25)
  elif name=='Metal':
   col=mix(nt,maps['diff'],ramp(nt,grey,[(.05,(.048,.035,.025)),(.55,(.21,.24,.25)),(.85,(.34,.36,.36))]),.83)
   rough=mathn(nt,'ADD',mathn(nt,'MULTIPLY',arm.outputs['Green'],.43),.23);metal=val(nt,.82)
  elif name=='RotorMetal':
   col=ramp(nt,grey,[(0,(.035,.05,.065)),(.50,(.11,.15,.19)),(1,(.25,.29,.32))]);metal=val(nt,.94);rough=mathn(nt,'ADD',mathn(nt,'MULTIPLY',arm.outputs['Green'],.16),.24)
  elif name=='Dark':
   col=ramp(nt,grey,[(0,(.014,.017,.018)),(.75,(.052,.058,.058))]);metal=val(nt,.05);rough=val(nt,.72)
  elif name=='Crates':
   sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv,sep.inputs[0]);a=mathn(nt,'GREATER_THAN',sep.outputs['X'],.5);b=mathn(nt,'GREATER_THAN',sep.outputs['Y'],.5)
   # Four paint/grain combinations: wood, olive, faded ochre, slate.
   tint=mix(nt,color(nt,(.38,.23,.11)),color(nt,(.15,.21,.09)),a)
   tint2=mix(nt,color(nt,(.43,.32,.13)),color(nt,(.10,.15,.18)),a)
   tint=mix(nt,tint,tint2,b)
   grain=ramp(nt,grey,[(.025,(.15,.15,.15)),(.12,(.50,.50,.50)),(.30,(.93,.93,.93)),(.8,(1,1,1))])
   col=mix(nt,grain,tint,1,'MULTIPLY')
   fu=mathn(nt,'FRACT',mathn(nt,'MULTIPLY',sep.outputs['X'],2),0);fv=mathn(nt,'FRACT',mathn(nt,'MULTIPLY',sep.outputs['Y'],2),0)
   du=mathn(nt,'MINIMUM',fu,mathn(nt,'SUBTRACT',1,fu));dv=mathn(nt,'MINIMUM',fv,mathn(nt,'SUBTRACT',1,fv));edge=mathn(nt,'MINIMUM',du,dv)
   wear=mathn(nt,'MULTIPLY',mathn(nt,'LESS_THAN',edge,.055),mathn(nt,'GREATER_THAN',no.outputs['Fac'],.48))
   col=mix(nt,col,color(nt,(.40,.27,.13)),mathn(nt,'MULTIPLY',wear,.70));metal=val(nt,.02);rough=val(nt,.79)
  elif name=='Rust':
   col=mix(nt,maps['diff'],color(nt,(.31,.11,.032)),.72,'MULTIPLY');metal=val(nt,.25);rough=val(nt,.87)
  elif name=='Engines':col=color(nt,(.018,.47,.68));metal=val(nt,.15);rough=val(nt,.31)
  elif name=='Lamps':col=color(nt,(.95,.79,.45));metal=val(nt,0);rough=val(nt,.25)
  else:col=color(nt,(.44,.63,.66));metal=val(nt,0);rough=val(nt,.15)
  nt.links.new(col,bs.inputs['Base Color']);nt.links.new(rough,bs.inputs['Roughness']);nt.links.new(metal,bs.inputs['Metallic'])
  norm=node(nt,'ShaderNodeNormalMap');nt.links.new(maps['nor_gl'],norm.inputs['Color']);norm.inputs['Strength'].default_value=.3 if name in ['Paint','Dark'] else .52
  if name not in ['Glass','Engines','Lamps']:
   bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.14;bump.inputs['Distance'].default_value=.006
   nt.links.new(no.outputs['Fac'],bump.inputs['Height']);nt.links.new(norm.outputs[0],bump.inputs['Normal']);nt.links.new(bump.outputs[0],bs.inputs['Normal'])
  plane.data.materials.clear();plane.data.materials.append(m);active(plane)
  target=node(nt,'ShaderNodeTexImage');nt.nodes.active=target
  res=1024 if name in ['Paint','Metal','Crates'] else 512 if name in ['Dark','Rust','RotorMetal'] else 64
  result={}
  emit=node(nt,'ShaderNodeEmission')
  for kind in ['BaseColor','Normal','ORM']:
   sz=min(res,512) if kind=='ORM' else res
   img=bpy.data.images.new(name+'_'+kind,width=sz,height=sz,alpha=False)
   img.colorspace_settings.name='sRGB' if kind=='BaseColor' else 'Non-Color'
   target.image=img;nt.nodes.active=target
   if kind=='Normal':
    nt.links.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
   else:
    if kind=='BaseColor':nt.links.new(col,emit.inputs[0])
    else:
     comb=node(nt,'ShaderNodeCombineColor');nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',arm.outputs['Red'],.20),.80),comb.inputs['Red']);nt.links.new(rough,comb.inputs['Green']);nt.links.new(metal,comb.inputs['Blue']);nt.links.new(comb.outputs[0],emit.inputs[0])
    nt.links.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
   ext='.jpg' if kind=='BaseColor' else '.png'
   img.filepath_raw=str(BAKE/(name+'_'+kind+ext));img.file_format='JPEG' if kind=='BaseColor' else 'PNG';img.save();result[kind]=img
  baked[name]=result;bpy.data.materials.remove(m)
 bpy.data.objects.remove(plane,do_unlink=True)
 # Compact portable PBR graphs; all material appearance in baked maps.
 for name,imgs in baked.items():
  m=MATS[name];nt=m.node_tree;nt.nodes.clear();p=node(nt,'ShaderNodeBsdfPrincipled');out=node(nt,'ShaderNodeOutputMaterial');nt.links.new(p.outputs[0],out.inputs[0])
  for kind in ['BaseColor','Normal','ORM']:
   t=node(nt,'ShaderNodeTexImage');t.image=imgs[kind];t.interpolation='Linear'
   if kind=='BaseColor':
    if name=='Paint':
     mul=node(nt,'ShaderNodeMixRGB');mul.name='Faction tint';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*PAINTS[0][1],1);nt.links.new(t.outputs['Color'],mul.inputs[1]);nt.links.new(mul.outputs[0],p.inputs['Base Color'])
    else:nt.links.new(t.outputs['Color'],p.inputs['Base Color'])
   elif kind=='Normal':
    no=node(nt,'ShaderNodeNormalMap');nt.links.new(t.outputs['Color'],no.inputs['Color']);nt.links.new(no.outputs[0],p.inputs['Normal'])
   else:
    sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(t.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs['Green'],p.inputs['Roughness']);nt.links.new(sep.outputs['Blue'],p.inputs['Metallic'])
    group=bpy.data.node_groups.get('glTF Material Output')
    if not group:
     group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    g=node(nt,'ShaderNodeGroup');g.node_tree=group;nt.links.new(sep.outputs['Red'],g.inputs['Occlusion'])
  if name=='Glass':
   p.inputs['Alpha'].default_value=.27;p.inputs['Transmission Weight'].default_value=.22;p.inputs['IOR'].default_value=1.46;m.surface_render_method='BLENDED';m.use_transparency_overlap=False
  if name in ['Engines','Lamps']:
   p.inputs['Emission Color'].default_value=(.004,.19,1,1) if name=='Engines' else (1,.76,.36,1)
   p.inputs['Emission Strength'].default_value=5.5 if name=='Engines' else 1.8
 print('BAKES_COMPLETE',round(time.time()-START,1),flush=True)

def socket(name,pos,axis=None):
 ob=bpy.data.objects.new(name,None);S.collection.objects.link(ob);ob.location=pos;ob.empty_display_size=.13
 # glTF uses the converted object basis: identity points local -Z rearward.
 if axis:ob['axis']=axis
 SOCKETS.append(ob);return ob

def sockets():
 for name,pos in [('Seat_Driver',(0,3.61,2.07)),('Hand_L',(.245,3.14,2.27)),('Hand_R',(-.165,3.14,2.27)),('Foot_L',(.19,3.07,1.54)),('Foot_R',(-.19,3.07,1.54)),('Camera_Chase',(0,7.8,4.1)),('Light_Head_L',(.72,-4.43,1.045)),('Light_Head_R',(-.72,-4.43,1.045)),('Light_Tail_L',(.72,4.43,1.17)),('Light_Tail_R',(-.72,4.43,1.17)),('Thruster_1',(.72,4.423,.91)),('Thruster_2',(-.72,4.423,.91))]:socket(name,pos)
 for i,(_,x,y) in enumerate(FAN_CENTERS,1):socket('Lift_'+str(i),(x,y,.60))

def stage():
 groundmat=simplemat('Ground_render_only',(.62,.64,.65),0,.88)
 ground=box('Ground_render_only',(0,0,-.07),(200,200,.1),groundmat,0,group=[])
 def area(n,loc,power,size,col):
  d=bpy.data.lights.new(n,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=col
  o=bpy.data.objects.new(n,d);S.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
 area('Large daylight softbox',(1,-5,10),1800,7,(1,.92,.82));area('Sky fill',(-5,1,7),1100,8,(.77,.86,1));area('Rear soft edge',(3,7,8),1600,6,(1,.98,.89))
 d=bpy.data.cameras.new('Render camera');cam=bpy.data.objects.new('Render camera',d);S.collection.objects.link(cam);S.camera=cam;d.lens=48
 return ground,cam

def camera(cam,side=False):
 # Bounding-box fitting, measured in camera space.
 obs=[bpy.data.objects.get('Hull')]+MOVING
 pts=[o.matrix_world@Vector(p) for o in obs if o for p in o.bound_box]
 lo=Vector(tuple(min(p[k] for p in pts) for k in range(3)));hi=Vector(tuple(max(p[k] for p in pts) for k in range(3)))
 center=(lo+hi)*.5
 direction=Vector((1,0,.065)) if side else Vector((11,-8,7.5))
 direction.normalize();cam.location=center+direction*19
 cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();bpy.context.view_layer.update()
 cam.data.type='ORTHO' if side else 'PERSP'
 if side:cam.data.ortho_scale=10.3
 else:
  inv=cam.matrix_world.inverted();q=[inv@p for p in pts];aspect=S.render.resolution_x/S.render.resolution_y
  # lens vertical field of view derived from actual landscape sensor fit.
  tanx=math.tan(cam.data.angle_x/2);tany=tanx/aspect
  required=max(max(abs(p.x)/tanx,abs(p.y)/tany)+p.z for p in q)
  cam.location+=direction*(required+.65)

def render(pathname,w=1920,h=1080,samples=32):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples
 S.render.filepath=str(ROOT/pathname);bpy.ops.render.render(write_still=True)
 print('RENDERED',pathname,round(time.time()-START,1),flush=True)

def clay(cam):
 c=simplemat('Clay_render_only',(.42,.43,.43),0,.62);S.view_layers[0].material_override=c
 render('clay.png',1920,1080,24);S.view_layers[0].material_override=None
 MATS.pop('Clay_render_only',None)

def paint_grid(cam):
 # Render identical framing; assemble via Blender image pixel buffers (no external packages).
 tiles=[];S.cycles.samples=12
 for i,(name,rgb) in enumerate(PAINTS):
  MATS['Paint'].node_tree.nodes['Faction tint'].inputs[2].default_value=(*rgb,1)
  render('paint_%d.png'%i,640,360,12)
  im=bpy.data.images.load(str(ROOT/('paint_%d.png'%i)),check_existing=False)
  tiles.append(np.array(im.pixels[:],dtype=np.float32).reshape((360,640,4)));bpy.data.images.remove(im)
 arr=np.zeros((720,1920,4),dtype=np.float32)
 for i,a in enumerate(tiles):
  row=1-i//3;col=i%3;arr[row*360:(row+1)*360,col*640:(col+1)*640]=a
 im=bpy.data.images.new('Six faction paints',width=1920,height=720,alpha=True);im.pixels.foreach_set(arr.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
 for i in range(6):(ROOT/('paint_%d.png'%i)).unlink()
 MATS['Paint'].node_tree.nodes['Faction tint'].inputs[2].default_value=(*PAINTS[0][1],1)

def exported_json_patch(path):
 # Explicit factor contract, because glTF exporters differ in recognition of tint nodes.
 data=path.read_bytes();length=struct.unpack_from('<I',data,12)[0];j=json.loads(data[20:20+length]);tail=data[20+length:]
 for m in j['materials']:
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.0]
  if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
 def fix_uv(v):
  if isinstance(v,dict):
   if 'texCoord' in v and v['texCoord']<0:v['texCoord']=0
   for q in v.values():fix_uv(q)
  elif isinstance(v,list):
   for q in v:fix_uv(q)
 fix_uv(j)
 # Explicit UV 0 and omitted UV 0 are equivalent; make representation canonical.
 def canonical(v):
  if isinstance(v,dict):
   if v.get('texCoord')==0:v.pop('texCoord')
   for q in list(v.values()):canonical(q)
  elif isinstance(v,list):
   for q in v:canonical(q)
 canonical(j)
 mats=[];lookup={};remap={}
 for i,m in enumerate(j['materials']):
  key=json.dumps(m,sort_keys=True)
  if key not in lookup:lookup[key]=len(mats);mats.append(m)
  remap[i]=lookup[key]
 for me in j['meshes']:
  for p in me['primitives']:p['material']=remap[p['material']]
 j['materials']=mats
 body=json.dumps(j,separators=(',',':')).encode();body+=b' '*((-len(body))%4)
 path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(body)+len(tail))+struct.pack('<I4s',len(body),b'JSON')+body+tail)
 return j

def export(hullob):
 obs=[hullob]+MOVING+SOCKETS;bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=hullob
 glb=ROOT/'hover-cargo-skiff.glb'
 bpy.ops.export_scene.gltf(filepath=str(glb),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_cameras=False,export_lights=False,export_extras=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_animations=False)
 j=exported_json_patch(glb)
 tri=sum(len(o.data.loop_triangles) for o in [hullob]+MOVING)
 def conv(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
 verts=[o.matrix_world@v.co for o in [hullob]+MOVING for v in o.data.vertices]
 xyz=[conv(v) for v in verts];lo=[min(v[k] for v in xyz) for k in range(3)];hi=[max(v[k] for v in xyz) for k in range(3)]
 records=[]
 for o in obs:
  rec={'name':o.name,'type':'mesh' if o.type=='MESH' else 'empty','position':conv(o.matrix_world.translation)}
  if o in MOVING:rec['axes']={'rotation':[0,1,0],'units':'radians','pivot':conv(o.matrix_world.translation)}
  if o.name.startswith('Thruster'):rec['exhaust_direction']=[0,0,-1]
  if o.name.startswith('Lift'):rec['lift_direction']=[0,-1,0]
  records.append(rec)
 manifest={'asset':'hover-cargo-skiff','units':'metres','coordinate_system':{'front':'+Z','up':'+Y','left':'+X'},'origin':'ground directly below vehicle centre','bounds':{'min':lo,'max':hi,'size':[round(hi[k]-lo[k],6) for k in range(3)]},'nodes':records,'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':tri,'materials':[m['name'] for m in j['materials']],'fan_test':{'angle_degrees':30,'axis':[0,1,0],'image':'fan_test.png','pivot_drift_metres':0},'texture_contract':{'base_and_normal_max':1024,'ORM_max':512},'bake_provenance':{'engine':'Cycles','device':'GPU OptiX/CUDA','samples':8,'base':'EMIT','normal':'NORMAL tangent','ORM':'EMIT R=scan micro-occlusion floor 0.8, G=roughness, B=metallic','tile_metres':{'Paint':1.55,'Metal':1.25,'Dark':.7,'Rust':.65}},'glb_bytes':glb.stat().st_size}
 (ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
 notes=["Original open utility hover skiff: 9.0 m long, 3.8 m wide, 2.4 m high, 0.6 m ride height; 4 pivoted lift fans, rear driver station and secured cargo.",f"Triangles: {tri:,}; 5 named mesh nodes (Hull + four fans), {len(SOCKETS)} gameplay sockets; GLB {glb.stat().st_size/1048576:.2f} MiB.","Materials: Paint, Metal, RotorMetal, Dark, Crates, Rust, Engines, Lamps, Glass; Paint neutral albedo × default linear [0.50, 0.42, 0.30]; Glass alpha BLEND.","Textures: Cycles GPU baked shared tiles; base/normal ≤1024 px, ORM ≤512 px; paint variants rendered in paints.png; fan 30-degree test in fan_test.png.","Poly Haven CC0 ids: "+', '.join(PH)+"; licence evidence https://polyhaven.com/license ; asset evidence "+' '.join('https://polyhaven.com/a/'+p for p in PH)+'.',"Reference differences: narrower scalloped mounting shoulders to meet 3.8 m total width, tan default paint, simplified cargo arrangement, original steering and deck fittings; ground dust/glow supplied by game sockets (render-only lift spill demonstrates the effect)."]
 (ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
 assert 30000<=tri<=90000,tri
 assert glb.stat().st_size<=12_000_000,glb.stat().st_size
 assert len(j['materials'])<=9
 assert abs(lo[1]-.60)<.002,(lo,hi)
 print('EXPORTED',tri,glb.stat().st_size,'BOUNDS',lo,hi,flush=True)

if '--preflight' in sys.argv:
 # Same GPU/device, tiny representative procedural bake and serializer round-trip.
 bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;m=MATS['Paint'];o.data.materials.append(m);nt=m.node_tree
 target=node(nt,'ShaderNodeTexImage');im=bpy.data.images.new('Preflight',64,64);target.image=im;nt.nodes.active=target;S.cycles.samples=2
 bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',use_selection=True)
 bpy.ops.object.delete(use_global=False);bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'))
 assert any(o.type=='MESH' for o in S.objects)
 print('PREFLIGHT_OK',flush=True);sys.exit(0)

hull();fans();rails();cockpit();details()
# Apply static transforms only, then project UVs, then join. Moving object transforms are never applied.
for o in STATIC:
 active(o);bpy.ops.object.transform_apply(location=True,rotation=True,scale=True);uv_project(o)
for o in MOVING[1:]:o.data=MOVING[0].data
uv_project(MOVING[0])
hullob=join(STATIC,'Hull',(0,0,0));active(hullob);bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
# Reduction retains the large silhouette, while trimming surplus chamfer faces.
for o in [hullob]+MOVING:o.data.calc_loop_triangles()
nt=sum(len(o.data.loop_triangles) for o in [hullob]+MOVING)
if nt>84000:
 active(hullob);dec=hullob.modifiers.new('Game triangle budget','DECIMATE');dec.ratio=(84000-sum(len(o.data.loop_triangles) for o in MOVING))/len(hullob.data.loop_triangles);bpy.ops.object.modifier_apply(modifier=dec.name)
# Remove numerical zero-area bevel remnants, preserving UV layers and pivot transforms.
for o in [hullob,MOVING[0]]:
 bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.triangulate(bm,faces=list(bm.faces))
 bad=[f for f in bm.faces if f.calc_area()<1e-11]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free();o.data.update();o.data.calc_loop_triangles()
for o in [hullob]+MOVING:
 o.data.uv_layers.active_index=0;o.data.uv_layers[0].active_render=True
 o.data.calc_loop_triangles()
print('GEOMETRY',sum(len(o.data.loop_triangles) for o in [hullob]+MOVING),'triangles',flush=True)
sockets();ground,cam=stage();MATS.pop('Ground_render_only',None)
S.render.resolution_x=1920;S.render.resolution_y=1080;camera(cam)
clay(cam)
bake_tiles()
# Presentation-only lift spill. Games recreate this through the four Lift sockets.
for _,x,y in FAN_CENTERS:
 d=bpy.data.lights.new('Lift spill render only','AREA');d.energy=14;d.shape='DISK';d.size=.44;d.color=(.025,.38,1)
 ob=bpy.data.objects.new('Lift spill render only',d);S.collection.objects.link(ob);ob.location=(x,y,.585)
# Save the genuine source model and scene before export; clean run is the delivery provenance.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'skiff_source.blend'))
export(hullob)
render('final.png',1920,1080,48)
# All four rotors turn about their own axis in place. Measure drift in world pivot explicitly.
before=[o.matrix_world.translation.copy() for o in MOVING]
for o in MOVING:o.rotation_euler.z+=math.radians(30)
bpy.context.view_layer.update()
assert max((o.matrix_world.translation-p).length for o,p in zip(MOVING,before))<1e-9
render('fan_test.png',1280,720,20)
for o in MOVING:o.rotation_euler.z-=math.radians(30)
camera(cam,True);render('side.png',1920,1080,28)
camera(cam);paint_grid(cam)
print('BUILD_COMPLETE_SECONDS',round(time.time()-START,1),flush=True)
