"""CH-47-style game transport. Clean build: Blender 5.2 -b --python-exit-code 1 --python this.py
Author axes: X=left, Y=aft, Z=up. glTF converts to X=left Y=up Z=nose.
Shared 1 m / 2 m tiled Cycles bakes, eight materials, articulated named assemblies.
Scope: hero/side/clay/paint-grid and runtime preview; 60k-140k triangles, <=12 MB,
1024 px colour/normal and 512 px ORM. At most three visual revisions.
"""
import bpy, math, os, json, sys, struct, urllib.request, subprocess, time
import numpy as np
from mathutils import Vector
from math import sin,cos,pi
ROOT=os.path.dirname(os.path.abspath(__file__))
os.makedirs(ROOT,exist_ok=True)
TEX=os.path.join(ROOT,'textures');os.makedirs(TEX,exist_ok=True)
START=time.time()
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
sc.cycles.samples=24;sc.cycles.use_denoising=True;sc.cycles.device='GPU'
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.render.bake.margin=8
sc.world=bpy.data.worlds.new('Daylight');sc.world.use_nodes=True
sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.63,.71,.83,1)
sc.world.node_tree.nodes['Background'].inputs[1].default_value=.55
sc.view_settings.view_transform='AgX'
GROUPS={k:[] for k in ['Fuselage','Rotor_Front','Rotor_Rear','Door_L','Door_R','Ramp']}
MATS={};G='Fuselage'

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

def material(name,color,rough=.7,metal=0):
 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['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal;MATS[name]=m;return m

paint=material('Paint',(.1,.11,.07));metal=material('Metal',(.25,.27,.28),.43,.85)
rubber=material('Rubber',(.017,.021,.024),.82);interior=material('Interior',(.105,.12,.105),.86)
glass=material('Glass',(.12,.19,.23),.15);lamps=material('Lamps',(.7,.8,.67),.24);rotormat=material('Rotor',(.038,.045,.045),.56,.2)

def add(o,name,mat,group=None):
 o.name=name
 if mat:o.data.materials.append(mat)
 if group is not False:GROUPS[group or G].append(o)
 return o

def applymod(o,m):
 activate(o);bpy.ops.object.modifier_apply(modifier=m.name)

def bevel(o,w=.02,n=3):
 m=o.modifiers.new('Manufactured edge radius','BEVEL');m.width=w;m.segments=n;applymod(o,m)
 return o

def smooth(o):
 for p in o.data.polygons:p.use_smooth=True
 return o

def box(name,loc,size,mat=paint,bev=.03,group=None):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.scale=size;activate(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 add(o,name,mat,group)
 if bev:bevel(o,bev,3)
 # weighted corner normals keep plates planar
 m=o.modifiers.new('Plate normals','WEIGHTED_NORMAL');m.keep_sharp=True;applymod(o,m)
 return o

def mesh(name,vs,fs,mat=paint,group=None):
 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);add(o,name,mat,group)
 import bmesh
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 return o

def uv_project(o,scale=1.5):
 me=o.data
 if not me.uv_layers:me.uv_layers.new(name='UVMap')
 uv=me.uv_layers.active.data
 for f in me.polygons:
  n=f.normal;axis=max(range(3),key=lambda k:abs(n[k]));ab=[(1,2),(0,2),(0,1)][axis]
  for li in f.loop_indices:
   v=o.matrix_world@me.vertices[me.loops[li].vertex_index].co;uv[li].uv=(v[ab[0]]/scale,v[ab[1]]/scale)

def cyl(name,a,b,r,mat=metal,verts=24,r2=None,group=None):
 a=Vector(a);b=Vector(b);mid=(a+b)/2
 bpy.ops.mesh.primitive_cone_add(vertices=verts,radius1=r,radius2=r if r2 is None else r2,depth=(b-a).length,location=mid)
 o=bpy.context.object;o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler();add(o,name,mat,group);bevel(o,min(r*.12,.014),1);smooth(o);return o

def tube(name,points,r=.014,mat=metal,group=None):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=1
 sp=cu.splines.new('POLY');sp.points.add(len(points)-1)
 for p,co in zip(sp.points,points):p.co=(*co,1)
 ob=bpy.data.objects.new(name,cu);sc.collection.objects.link(ob);activate(ob);bpy.ops.object.convert(target='MESH');add(ob,name,mat,group);smooth(ob);return ob

def sphere(name,loc,scale,mat=paint,seg=24,rings=12,group=None):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc);o=bpy.context.object;o.scale=scale;activate(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);add(o,name,mat,group);smooth(o);return o

def loft(name,sections,mat=paint,n=48,power=3.6,group=None):
 # section = longitudinal position, half width, low, high; rounded rectangular superellipse
 vs=[]
 for y,w,lo,hi in sections:
  for j in range(n):
   t=2*pi*j/n;c=cos(t);s=sin(t);vs.append((w*math.copysign(abs(c)**(2/power),c),y,(lo+hi)/2+(hi-lo)/2*math.copysign(abs(s)**(2/power),s)))
 fs=[]
 for k in range(len(sections)-1):
  for j in range(n):a=k*n+j;b=k*n+(j+1)%n;fs.append((a,b,b+n,a+n))
 fs += [tuple(reversed(range(n))),tuple((len(sections)-1)*n+j for j in range(n))]
 o=mesh(name,vs,fs,mat,group);smooth(o);return o

def boolean(o,cutter):
 m=o.modifiers.new('Actual opening','BOOLEAN');m.operation='DIFFERENCE';m.solver='EXACT';m.object=cutter;applymod(o,m)
 for group in GROUPS.values():
  if cutter in group:group.remove(cutter)
 bpy.data.objects.remove(cutter,do_unlink=True)

def rounded_rect(x,y,z,w,h,r=.09):
 ps=[]
 for cy,cz,start in [(y+w/2-r,z+h/2-r,0),(y-w/2+r,z+h/2-r,90),(y-w/2+r,z-h/2+r,180),(y+w/2-r,z-h/2+r,270)]:
  for i in range(7):
   a=math.radians(start+i*90/6);ps.append((x,cy+r*cos(a),cz+r*sin(a)))
 return ps

def pane(name,points,group=None):
 o=mesh(name,points,[tuple(range(len(points)))],glass,group);m=o.modifiers.new('Laminated glass','SOLIDIFY');m.thickness=.012;applymod(o,m);bevel(o,.012,2)
 tube(name+' gasket',points+[points[0]],.026,rubber,group);tube(name+' alloy framing',points+[points[0]],.043,paint,group)
 return o

def rivet(loc,axis,mat=metal,r=.014):
 a=Vector(loc);n=Vector(axis);return cyl('Flush fastener',a-n*.004,a+n*.008,r,mat,8)

# Physical rounded shell; open cockpit and rear cargo interior are genuine voids.
hull=loft('Cabin pressure shell',[(-.25,1.28,.75,3.28),(.1,1.39,.68,3.38),(.5,1.43,.65,3.42),(2,1.44,.65,3.42),(8.8,1.44,.65,3.42),(9.5,1.41,.72,3.42),(10.3,1.34,.95,3.5),(11.25,1.22,1.4,3.58),(12.1,1.03,1.98,3.64),(12.8,.86,2.42,3.68),(13.2,.7,2.66,3.7)])
inner=loft('Interior cavity',[(-.4,1.12,.88,3.10),(.2,1.29,.81,3.25),(8.7,1.29,.81,3.25),(9.4,1.25,.89,3.26),(10.4,1.12,1.24,3.31)],None,group=False);boolean(hull,inner)
# Cargo portal at rear lower rising hull, filled by hinged ramp below.
cut=mesh('Ramp aperture',[(-1.05,9.8,.7),(1.05,9.8,.7),(1.05,9.8,2.8),(-1.05,9.8,2.8),(-.78,12.25,2.35),(.78,12.25,2.35),(.78,12.25,3.3),(-.78,12.25,3.3)],[(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],None,False);boolean(hull,cut)
# Cockpit lower nose curves (sheet: blunt drooped radar nose).
nose=loft('Formed nose lower skin',[(-2.6,.14,1.32,1.55),(-2.5,.51,1.18,1.85),(-2.2,.92,1.0,2.13),(-1.65,1.17,.8,2.13),(-.8,1.29,.73,2.11),(-.25,1.31,.73,2.08)],power=3.1)
inside=loft('Cockpit cavity',[(-2.18,.46,1.35,3.5),(-1.8,.84,1.19,3.5),(-.8,1.16,1.02,3.5),(-.1,1.18,1.02,3.5)],None,power=3,group=False);boolean(nose,inside)
# canopy panelling, with glass between real frames
for s in [-1,1]:
 pane('Windshield',[(s*.045,-2.23,2.10),(s*.87,-2.01,2.12),(s*.97,-.99,3.08),(s*.045,-1.13,3.2)])
 pane('Forward quarter glass',[(s*.94,-1.98,2.12),(s*1.26,-1.36,2.12),(s*1.29,-.47,3.12),(s*1.03,-.99,3.08)])
 pane('Pilot door upper glazing',[(s*1.29,-1.28,2.12),(s*1.34,-.28,2.12),(s*1.3,-.24,3.12),(s*1.31,-.42,3.12)])
 look=[(s*1.05,-1.91,1.95),(s*1.27,-1.3,1.99),(s*1.26,-.5,1.97),(s*1.15,-.77,1.48),(s*.96,-1.51,1.49)]
 cv=[(x+d,y,z) for d in [-.3,.3] for x,y,z in look];cf=[tuple(range(5)),tuple(range(5,10))]+[(k,(k+1)%5,(k+1)%5+5,k+5) for k in range(5)]
 boolean(nose,mesh('Lookdown aperture',cv,cf,None,False))
 pane('Pilot downward view',look)
 # windshield wiper pivots, arms and blade
 cyl('Wiper spindle',(s*.32,-2.29,2.12),(s*.32,-2.33,2.12),.05,metal)
 tube('Windscreen wiper',[(s*.32,-2.33,2.12),(s*.59,-1.78,2.54),(s*.74,-1.55,2.74)],.015,rubber)
 tube('Wiper blade',[(s*.48,-1.80,2.55),(s*.80,-1.34,2.88)],.025,rubber)
# Crown follows the actual window perimeter, without an overhanging visor.
outline=[(-.045,-1.13,3.2),(-.97,-.99,3.08),(-1.03,-.99,3.08),(-1.29,-.47,3.12),(-1.3,-.24,3.12),(-1.30,.20,3.25),(1.30,.20,3.25),(1.3,-.24,3.12),(1.29,-.47,3.12),(1.03,-.99,3.08),(.97,-.99,3.08),(.045,-1.13,3.2)]
roof=mesh('Fitted canopy crown',outline+[(x,y,z+.10) for x,y,z in outline],[tuple(reversed(range(12))),tuple(range(12,24))]+[(j,(j+1)%12,(j+1)%12+12,j+12) for j in range(12)])
bevel(roof,.025,3)
for s in [-1,1]:
 for pts in [[(s*1.05,-1.91,1.95),(s*1.27,-1.3,1.99),(s*1.26,-.5,1.97),(s*1.34,-.28,2.12),(s*1.26,-1.36,2.12),(s*.94,-1.98,2.12)],[(s*1.26,-.5,1.97),(s*1.15,-.77,1.48),(s*1.29,-.23,1.38),(s*1.34,-.28,2.12)]]:
  o=mesh('Canopy structural cheek',pts,[tuple(range(len(pts)))])
  m=o.modifiers.new('Sheet thickness','SOLIDIFY');m.thickness=.035;applymod(o,m);bevel(o,.013,2)
# Nose equipment and landing lamps
sphere('Chin sensor housing',(0,-2.12,.84),(.22,.25,.28),rubber)
sphere('Sensor glass',(0,-2.34,.86),(.15,.022,.14),glass)
for s in [-1,1]:
 sphere('Landing lamp bezel',(s*.79,-1.94,1.17),(.17,.05,.1),metal)
 sphere('Landing lamp lens',(s*.79,-1.985,1.18),(.13,.019,.071),lamps)
 tube('Pitot mast',[(s*.78,-2.03,1.85),(s*1.03,-2.17,1.91),(s*1.03,-2.67,2.03)],.018,metal)
# cockpit interior
box('Cockpit floor',(0,-.9,1.12),(2.28,2.2,.1),interior,.025)
for s in [-1,1]:
 box('Seat cushion',(s*.57,-.55,1.5),(.59,.62,.16),interior,.09)
 b=box('Armoured seat back',(s*.57,-.24,1.89),(.65,.15,.77),interior,.08);b.rotation_euler.x=.1
 box('Head cushion',(s*.57,-.16,2.36),(.38,.18,.26),rubber,.07)
 for off in [-.18,.18]:tube('Seat harness',[(s*.57+off,-.36,2.15),(s*.57+off*.55,-.39,1.71),(s*.57+off,-.86,1.58)],.027,rubber)
 cyl('Cyclic control',(s*.57,-.97,1.19),(s*.57,-1.02,1.84),.025,metal)
 cyl('Cyclic grip',(s*.57,-1.02,1.79),(s*.57,-1.04,1.94),.043,rubber)
 box('Rudder pedals',(s*.57,-1.54,1.29),(.38,.16,.08),metal,.018)
 box('Instrument binnacle',(s*.54,-1.53,1.98),(.9,.29,.22),rubber,.07)
 for off in [-.24,0,.24]:
  b=box('Multifunction display',(s*.54+off,-1.356,2.0),(.18,.016,.135),glass,.012)
  for q in range(3):sphere('Panel button',(s*.54+off-.055+q*.055,-1.344,1.905),(.008,.006,.008),lamps,8,4)
box('Centre console',(0,-.95,1.51),(.28,.94,.44),rubber,.04)
for k in range(7):box('Console switch',(0,-1.26+k*.105,1.744),(.13,.035,.012),metal,.004)
# Cabin windows cut all the way through the hull; thin glazing within seals.
for s in [-1,1]:
 for y in [2.4,4.25,6.1,8.15]:
  cut=box('Window cutter',(s*1.4,y,2.27),(.48,.53,.66),None,.105,False);boolean(hull,cut)
  ps=rounded_rect(s*1.452,y,2.27,.56,.69,.11)
  pane('Cabin window',ps)
 # side access opening and sliding door
 dy=.77;cut=box('Door cut',(s*1.4,dy,1.96),(.48,.81,1.94),None,.11,False);boolean(hull,cut)
 frame=box('Door structural jamb',(s*1.35,dy,1.96),(.27,.91,2.06),paint,.07)
 aperture=box('Door jamb void',(s*1.35,dy,1.96),(.6,.77,1.91),None,.07,False);boolean(frame,aperture)
 G='Door_L' if s==1 else 'Door_R'
 box('Passenger door',(s*1.405,dy,1.96),(.07,.77,1.91),paint,.07)
 tube('Door seal',rounded_rect(s*1.447,dy,1.96,.81,1.96,.09)+[rounded_rect(s*1.447,dy,1.96,.81,1.96,.09)[0]],.014,rubber)
 box('Recessed door latch',(s*1.45,dy-.19,1.91),(.034,.12,.06),metal,.014)
 for z in [1.34,2.55]:cyl('Door hinge',(s*1.453,dy+.39,z-.08),(s*1.453,dy+.39,z+.08),.034,metal)
 G='Fuselage'
 tube('Sliding door rail',[(s*1.49,.32,3.01),(s*1.49,2.09,3.01)],.026,metal)
 for zz in [.68,.94]:tube('Boarding step',[(s*1.32,.42,zz),(s*1.7,.42,zz),(s*1.7,1.06,zz),(s*1.32,1.06,zz)],.026,metal)
 for yy in [.42,1.06]:tube('Step vertical stanchion',[(s*1.69,yy,.68),(s*1.69,yy,.95),(s*1.39,yy,1.04)],.027,metal)
 # long roof rain gutter and clipped pipe
 tube('Roof gutter',[(s*1.22,.5,3.34),(s*1.22,9.4,3.34)],.024,metal)
 for y in np.arange(.6,9.4,.72):
  box('Gutter mounting lug',(s*1.238,float(y),3.27),(.08,.05,.13),paint,.012)
 # seams + rows of flush rivets along each plate bay
 for y in [1.45,3.3,5.2,7.25,9.15]:
  tube('Vertical lap seam',[(s*1.446,y,1.05),(s*1.449,y,2.95)],.0045,rubber)
  for z in np.arange(1.1,2.98,.22):rivet((s*1.454,y+.043,float(z)),(s,0,0),paint,.012)
 for z in [1.06,2.95]:
  tube('Longitudinal skin lap',[(s*1.431,.25,z),(s*1.447,8.95,z)],.0045,rubber)
  for y in np.arange(.4,9,.26):rivet((s*1.457,float(y),z+.038),(s,0,0),paint,.011)
 # recessed service hatch and fasteners, set above sponson
 for y in [3.15,6.92,9.18]:
  ps=rounded_rect(s*1.447,y,1.46,.58,.43,.045);tube('Service panel outline',ps+[ps[0]],.006,rubber)
  for yy in [y-.22,y+.22]:
   for zz in [1.31,1.61]:rivet((s*1.455,yy,zz),(s,0,0),metal,.012)
# Internal cabin floor, ribs and troop seats (inside all openings).
box('Cargo floor',(0,5.13,.97),(2.48,10.2,.13),interior,.025)
for y in np.arange(.5,10,.9):
 tube('Cabin frame',[( -1.23,float(y),1.03),(-1.23,float(y),2.83),(-1.0,float(y),3.17),(1.0,float(y),3.17),(1.23,float(y),2.83),(1.23,float(y),1.03)],.035,interior)
for s in [-1,1]:
 for y in np.arange(1.9,9.1,.9):
  box('Troop seat',(s*.91,float(y),1.36),(.58,.65,.11),interior,.05)
  box('Troop seat back',(s*1.18,float(y),1.68),(.09,.65,.58),interior,.03)
  cyl('Seat support',(s*.88,float(y),1),(s*.88,float(y),1.32),.022,metal)
  tube('Seat restraint',[(s*1.12,float(y)-.18,1.96),(s*.88,float(y),1.43),(s*.7,float(y)+.2,1.42)],.02,rubber)
# Rear ramp, closed, hinge at bottom forward edge; incline to aft portal.
G='Ramp'
ramp=mesh('Cargo loading ramp',[(-1.12,9.82,.84),(1.12,9.82,.84),(.83,12.25,2.11),(-.83,12.25,2.11)],[(0,1,2,3)],paint)
m=ramp.modifiers.new('Ramp structural thickness','SOLIDIFY');m.thickness=.12;applymod(ramp,m);bevel(ramp,.035,3)
for x in [-.8,-.4,0,.4,.8]:tube('Ramp skid rail',[(x,9.86,.73),(x,12.2,1.99)],.018,metal)
for y in np.arange(10.05,12.1,.3):
 z=.84+(float(y)-9.82)*1.27/2.43
 tube('Ramp tread', [(-1.02+(float(y)-9.82)*.10,float(y),z+.035),(1.02-(float(y)-9.82)*.10,float(y),z+.035)],.015,rubber)
cyl('Ramp hinge',(-1.18,9.82,.84),(1.18,9.82,.84),.068,metal,32)
G='Fuselage'
for s in [-1,1]:
 tube('Rear portal sill',[(s*1.16,9.82,.84),(s*.86,12.32,2.15),(s*.80,12.7,3.29)],.065,paint)
 cyl('Ramp hydraulic body',(s*1.12,10.1,1.6),(s*1.09,11.27,2.38),.05,metal)
 cyl('Ramp hydraulic piston',(s*1.09,11.27,2.38),(s*1.065,11.82,2.7),.024,metal)
# Visible closed ramp perimeter, sill and hinge barrels.
for ss in [-1,1]:tube('Ramp perimeter seal',[(ss*1.12,9.83,.77),(ss*.83,12.25,2.01)],.018,rubber)
tube('Ramp upper seal',[(-.83,12.25,2.015),(.83,12.25,2.015)],.018,rubber)
for xx in np.arange(-.96,1.01,.24):cyl('Ramp hinge knuckle',(float(xx),9.82,.84),(float(xx)+.13,9.82,.84),.077,metal,16)
# Front transmission fairing and rear tall pylon, both shaped longitudinal lofts.
loft('Forward transmission cowling',[(-.48,.68,3.23,3.43),(-.27,.68,3.22,3.85),(0,.67,3.24,4.02),(.65,.69,3.3,4.02),(1.15,.7,3.31,3.89),(1.85,.66,3.32,3.48)],power=5)
PYLON=[(8.98,.60,3.24,3.38),(9.48,.71,3.24,4.11),(10.18,.79,3.27,4.94),(10.8,.79,3.29,5.13),(11.8,.77,3.1,5.18),(12.78,.73,2.76,5.13),(13.2,.66,2.7,4.97)]
loft('Aft pylon',PYLON,power=8)
def pylon_x(y,z):
 for a,b in zip(PYLON,PYLON[1:]):
  if a[0]<=y<=b[0]:
   t=(y-a[0])/(b[0]-a[0]);w=a[1]*(1-t)+b[1]*t;lo=a[2]*(1-t)+b[2]*t;hi=a[3]*(1-t)+b[3]*t
   return w*max(.001,1-abs((z-(lo+hi)/2)/((hi-lo)/2))**8)**.125
 return .66
# drive shaft tunnel along roof
loft('Drive shaft tunnel',[(1.16,.24,3.29,3.56),(1.45,.25,3.3,3.59),(9.5,.25,3.3,3.59),(9.92,.22,3.35,3.75)],power=4)
for y in [2,3.7,5.4,7.1,8.8]:tube('Tunnel band',[(-.24,y,3.43),(-.2,y,3.58),(.2,y,3.58),(.24,y,3.43)],.013,metal)
for s in [-1,1]:
 # engine nacelles, axial intake front and exhaust aft
 nac=loft('Turboshaft nacelle',[(9.28,.28,3.37,3.91),(9.47,.37,3.27,4.02),(10.2,.4,3.25,4.05),(10.91,.35,3.28,4.0),(11.37,.29,3.32,3.92)],paint,n=40,power=2.15)
 for v in nac.data.vertices:v.co.x+=s*1.12
 # intake dark throat surrounded by toroidal metal lip
 cyl('Engine intake',(s*1.12,9.19,3.65),(s*1.12,9.35,3.65),.257,rubber,40)
 for rad in [.29,.215]:
  ps=[(s*1.12+rad*cos(t),9.19,3.65+rad*sin(t)) for t in np.linspace(0,2*pi,49)];tube('Intake rolled lip',ps,.025,metal)
 for k in range(12):
  a=2*pi*k/12;tube('Intake guide vane',[(s*1.12+.11*cos(a),9.175,3.65+.11*sin(a)),(s*1.12+.22*cos(a+.18),9.175,3.65+.22*sin(a+.18))],.011,metal)
 sphere('Intake spinner',(s*1.12,9.15,3.65),(.091,.11,.091),metal)
 cyl('Exhaust thermal shroud',(s*1.12,11.1,3.64),(s*1.12,11.69,3.64),.268,metal,40)
 cyl('Exhaust black throat',(s*1.12,11.68,3.64),(s*1.12,11.7,3.64),.215,rubber,40)
 for y in [9.54,10.25,10.81,11.3,11.55]:tube('Nacelle strap',[(s*1.12+.34*cos(t),y,3.65+.34*sin(t)) for t in np.linspace(0,2*pi,41)],.012,metal)
 # pylon maintenance panel and vent louvres
 ps=[(s*(pylon_x(y,z)+.006),y,z) for x,y,z in rounded_rect(s*.923,11.18,4.51,.85,.77,.06)];tube('Pylon service seam',ps+[ps[0]],.007,rubber)
 for y in np.arange(10.65,11.65,.11):box('Pylon ventilation louvre',(s*(pylon_x(float(y),4.02)+.01),float(y),4.02),(.033,.045,.22),rubber,.008)
 # rear stub aero surfaces with endplate, matching reference silhouette
 box('Aft stabilizer',(s*1.26,12.41,3.43),(1.24,.66,.12),paint,.05)
 fin=mesh('Stabilizer endplate',[(s*1.86,11.92,3.05),(s*1.86,11.89,3.55),(s*1.86,12.19,4.37),(s*1.86,12.7,4.34),(s*1.86,12.82,3.0)],[(0,1,2,3,4)],paint)
 m=fin.modifiers.new('Fin skin','SOLIDIFY');m.thickness=.09;applymod(fin,m);bevel(fin,.04,3)
 # side fuel/gear sponsons: streamlined oblong, not boxes
 sp=loft('Fuel sponson',[(6.15,.13,.91,1.3),(6.42,.41,.72,1.52),(6.85,.48,.68,1.58),(8.4,.46,.69,1.57),(9.15,.34,.78,1.37),(9.47,.07,.87,1.14)],paint,power=3.4)
 for v in sp.data.vertices:v.co.x+=s*1.48
 for y in [6.9,8.52]:
  tube('Sponson skin seam',[(s*1.86,y,.85),(s*1.96,y,1.18),(s*1.85,y,1.48)],.007,rubber)
 box('Sponson inspection cover',(s*1.955,7.33,1.18),(.028,.49,.34),paint,.045)
 for y in [7.13,7.53]:
  for z in [1.05,1.31]:rivet((s*1.977,y,z),(s,0,0),metal,.013)
# Four individual pneumatic landing wheels.
for s in [-1,1]:
 for y,r,x in [(-.36,.29,1.13),(8.15,.37,1.7)]:
  z=r
  cyl('Landing oleo',(s*x,y,z+.04),(s*(x-.08),y-.05,1.06),.058,metal)
  cyl('Landing strut sleeve',(s*(x-.08),y-.05,.75),(s*(x-.08),y-.05,1.22),.095,paint)
  tube('Gear trailing brace',[(s*(x-.08),y-.48,.92),(s*x,y,.4)],.036,metal)
  tube('Brake hose',[(s*(x+.06),y-.08,1.06),(s*(x+.12),y-.13,.67),(s*(x+.08),y-.03,.34)],.013,rubber)
  # tyre profile revolved about X; profile has flattened broad tread
  vs=[];fs=[];N=56;P=16
  for k in range(P):
   a=2*pi*k/P;xx=.11*cos(a);rr=r-.095+.095*sin(a)
   for j in range(N):t=2*pi*j/N;vs.append((s*x+xx,y+rr*sin(t),z+rr*cos(t)))
  for k in range(P):
   for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,((k+1)%P)*N+(j+1)%N,((k+1)%P)*N+j))
  smooth(mesh('Pneumatic tyre',vs,fs,rubber))
  cyl('Wheel hub',(s*x-.12,y,z),(s*x+.12,y,z),r*.48,metal,40)
  cyl('Wheel centre',(s*(x+.122),y,z),(s*(x+.14),y,z),r*.21,paint,24)
  for k in range(8):
   a=2*pi*k/8;rivet((s*(x+.143),y+r*.32*sin(a),z+r*.32*cos(a)),(s,0,0),metal,.013)
  # cut-looking fine tread grooves carried by small rubber ribs
  for k in range(40):
   t=2*pi*k/40;ps=[]
   for xx in [-.07,0,.07]:ps.append((s*x+xx,y+(r+.001)*sin(t+xx*.4),z+(r+.001)*cos(t+xx*.4)))
   tube('Tyre transverse tread',ps,.006,rubber)
# Underside ribs, small antennae, cable fittings, hoist/tie-down rings.
for y in [1.3,3.1,5.3,7.4,9.0]:box('Belly doubler',(0,y,.655),(1.7,.13,.055),paint,.014)
for y in [1.8,5.8]:
 fin=mesh('Belly aerial',[(-.035,y,.69),(.035,y,.69),(.025,y+.2,.35),(-.025,y+.2,.35)],[(0,1,2,3)],rubber);m=fin.modifiers.new('Aerial thickness','SOLIDIFY');m.thickness=.04;applymod(fin,m);bevel(fin,.008,2)
for y in [3,7.8]:cyl('Roof antenna',(0,y,3.55),(0,y,3.87),.025,metal)
for s in [-1,1]:
 for y in [1.75,5.4,9.3]:
  tube('Tie down eye',[(s*1.34,y,.87),(s*1.5,y,.8),(s*1.52,y+.09,.79),(s*1.37,y+.13,.87)],.024,metal)
 # long HF aerial, attached at insulated stand-offs
 for y in [2,8.7]:cyl('Aerial stand off',(s*1.3,y,3.19),(s*1.55,y,3.29),.022,rubber)
 tube('HF aerial wire',[(s*1.55,2,3.29),(s*1.55,8.7,3.29)],.005,metal)
# Articulated three-blade rotors; spanwise twist, NACA-like closed airfoil profile.
for name,y,hubz,phase in [('Rotor_Front',0,4.4,12),('Rotor_Rear',11.8,5.53,72)]:
 G=name
 cyl('Mainshaft',(0,y,hubz-.4),(0,y,hubz+.06),.105,metal,32)
 cyl('Swashplate lower',(0,y,hubz-.21),(0,y,hubz-.13),.34,metal,40)
 cyl('Swashplate upper',(0,y,hubz-.07),(0,y,hubz+.015),.3,metal,40)
 sphere('Rotor central head',(0,y,hubz+.05),(.25,.25,.12),metal,32,12)
 for k in range(3):
  a=math.radians(phase+k*120);er=Vector((cos(a),sin(a),0));et=Vector((-sin(a),cos(a),0));h=Vector((0,y,hubz))
  cyl('Blade grip',h+er*.2,h+er*.78,.093,metal,24)
  cyl('Flap hinge',h+er*.5-et*.13,h+er*.5+et*.13,.07,metal,16)
  cyl('Pitch link',h+er*.36-et*.17+Vector((0,0,-.2)),h+er*.71-et*.18+Vector((0,0,.035)),.024,metal,12)
  tube('Pitch cable',[h+er*.22+et*.19,h+er*.48+et*.21+Vector((0,0,.065)),h+er*.79+et*.19],.011,rubber)
  vs=[];fs=[];ns=30;nc=24
  # closed upper/lower perimeter with finite trailing edge, no coincident vertices
  profile=[(0.5-.5*cos(pi*j/12),1) for j in range(13)]+[(0.5-.5*cos(pi*j/12),-1) for j in range(11,0,-1)]
  for i in range(ns):
   rr=.72+(9.15-.72)*i/(ns-1);frac=(rr-.72)/8.43
   chord=.56 if rr<8.7 else .56-(rr-8.7)*.22
   if rr<1.15:chord=.28+(rr-.72)/.43*.28
   twist=math.radians(10-14*frac);sweep=max(0,rr-8.3)*.18
   for u,sign in profile:
    thickness=5*.115*(.2969*math.sqrt(max(u,0))-.126*u-.3516*u*u+.2843*u**3-.1015*u**4)
    cross=(u-.28)*chord;hh=sign*max(thickness*chord,.0015)+.013*sin(pi*u)
    v=h+er*rr+et*(cross*cos(twist)-hh*sin(twist)+sweep)+Vector((0,0,cross*sin(twist)+hh*cos(twist)-.07*frac**2))
    vs.append(tuple(v))
  nc=len(profile)
  for i in range(ns-1):
   for j in range(nc):fs.append((i*nc+j,i*nc+(j+1)%nc,(i+1)*nc+(j+1)%nc,(i+1)*nc+j))
  fs +=[tuple(reversed(range(nc))),tuple((ns-1)*nc+j for j in range(nc))]
  smooth(mesh('Twisted composite rotor airfoil',vs,fs,rotormat))
  # narrow erosion shield follows leading quarter, slightly proud only at edge
  for rr in [.9,1.12]:cyl('Blade root bolt',h+er*rr+Vector((0,0,-.025)),h+er*rr+Vector((0,0,.065)),.027,metal,12)
 G='Fuselage'
# Localized carbon deposits: a conforming thin surface with a Cycles-baked soft opacity mask.
soot=material('Soot',(.015,.012,.009),.94)
for ss in [-1,1]:
 vs=[];fs=[]
 for j in range(9):
  z=3.2+j*1.1/8
  for i in range(21):
   y=11.72+i*1.32/20;vs.append((ss*(pylon_x(y,z)+.009),y,z))
 for j in range(8):
  for i in range(20):a=j*21+i;fs.append((a,a+1,a+22,a+21))
 ob=mesh('Exhaust carbon deposit',vs,fs,soot);ob['soot']=True
 uv=ob.data.uv_layers.new(name='UVMap')
 for f in ob.data.polygons:
  for li in f.loop_indices:
   v=ob.data.vertices[ob.data.loops[li].vertex_index].co;uv.data[li].uv=((v.y-11.72)/1.32,(v.z-3.2)/1.1)
# Navigation fixtures, share one Lamps material with coloured lenses.
LAMP_OBS=[]
for name,loc,col in [('Light_Nav_L',(1.986,6.56,1.39),'red'),('Light_Nav_R',(-1.986,6.56,1.39),'green'),('Light_Beacon',(0,12.4,5.21),'red')]:
 sphere('Lamp mounting',loc,(.105,.11,.055),rubber)
 o=sphere(name+' lens',(loc[0],loc[1],loc[2]+.038),(.073,.078,.058),lamps,16,8);o['lamp_color']=col;LAMP_OBS.append(o)
# Bake eight shared tile materials with photographic maps and node detail.
print('GEOMETRY_READY',sum(len(o.data.polygons) for g in GROUPS.values() for o in g),flush=True)

def download(id,map):
 path=os.path.join(TEX,f'{id}_{map}_1k.jpg')
 if not os.path.exists(path):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req,timeout=90) as r:open(path,'wb').write(r.read())
 return path

PH=['blue_metal_plate','rubberized_track','fabric_leather_01']
source={}
for id in PH:
 for mp in ['diff','nor_gl','arm']:
  img=bpy.data.images.load(download(id,mp),check_existing=True)
  if mp!='diff':img.colorspace_settings.name='Non-Color'
  source[id,mp]=img

# Bake plane covers one UV repeat; geometry uses physical density, never a whole-aircraft atlas.
bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-20));bp=bpy.context.object;bp.name='Bake fixture'
configs=[('Paint','blue_metal_plate',(.94,.94,.94),.73,0,1024),('Metal','blue_metal_plate',(.34,.36,.37),.42,.83,512),('Rubber','rubberized_track',(.028,.033,.038),.83,0,512),('Interior','fabric_leather_01',(.13,.15,.125),.88,0,512),('Rotor','blue_metal_plate',(.065,.072,.077),.56,.18,1024),('Glass','blue_metal_plate',(.16,.24,.28),.15,0,64),('Lamps','blue_metal_plate',(.9,.92,.8),.24,0,64),('Soot','blue_metal_plate',(.015,.012,.009),.94,0,256)]
for name,id,tint,rough,met,res in configs:
 m=MATS[name];nt=m.node_tree;nt.nodes.clear();n=nt.nodes;l=nt.links
 out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
 tex=n.new('ShaderNodeTexImage');tex.image=source[id,'diff']
 gray=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],gray.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');l.new(gray.outputs[0],ramp.inputs[0])
 if name=='Paint':lo=.48;hi=.99
 else:lo=.6;hi=1.15
 ramp.color_ramp.elements[0].position=.008 if id=='blue_metal_plate' else .12;ramp.color_ramp.elements[0].color=(*[v*lo for v in tint],1)
 ramp.color_ramp.elements[1].position=.15 if id=='blue_metal_plate' else .8;ramp.color_ramp.elements[1].color=(*[min(1,v*hi) for v in tint],1)
 uv=n.new('ShaderNodeTexCoord');sep=n.new('ShaderNodeVectorMath');sep.operation='MULTIPLY';l.new(uv.outputs['UV'],sep.inputs[0]);sep.inputs[1].default_value=(32,2.5,1)
 noise=n.new('ShaderNodeTexNoise');l.new(sep.outputs[0],noise.inputs['Vector']);noise.inputs['Scale'].default_value=3;noise.inputs['Detail'].default_value=3
 stain=n.new('ShaderNodeValToRGB');l.new(noise.outputs['Fac'],stain.inputs[0]);stain.color_ramp.elements[0].position=.26;stain.color_ramp.elements[0].color=(.72,.72,.72,1);stain.color_ramp.elements[1].position=.57;stain.color_ramp.elements[1].color=(1,1,1,1)
 mul=n.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.16 if name=='Paint' else .12;l.new(ramp.outputs[0],mul.inputs[1]);l.new(stain.outputs[0],mul.inputs[2]);l.new(mul.outputs[0],p.inputs['Base Color'])
 nor=n.new('ShaderNodeTexImage');nor.image=source[id,'nor_gl'];nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.05 if name in ['Paint','Rotor','Glass','Lamps'] else .24;l.new(nor.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=source[id,'arm'];sp=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sp.inputs[0])
 rr=n.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';l.new(sp.outputs[1],rr.inputs[0]);rr.inputs[1].default_value=.30 if name=='Paint' else .16;rr.inputs[2].default_value=rough-(.15 if name=='Paint' else .08);l.new(rr.outputs[0],p.inputs['Roughness']);p.inputs['Metallic'].default_value=met
 if name=='Lamps':
  uvc=n.new('ShaderNodeTexCoord');xyz=n.new('ShaderNodeSeparateXYZ');l.new(uvc.outputs['UV'],xyz.inputs[0])
  pal=n.new('ShaderNodeValToRGB');pal.color_ramp.interpolation='CONSTANT';pal.color_ramp.elements.remove(pal.color_ramp.elements[1])
  pal.color_ramp.elements[0].color=(.8,.008,.003,1)
  pal.color_ramp.elements.new(1/3).color=(.003,.5,.018,1);pal.color_ramp.elements.new(2/3).color=(.9,.92,.8,1)
  l.new(xyz.outputs['X'],pal.inputs[0]);l.new(pal.outputs[0],p.inputs['Base Color'])
 # Bake colour, tangent normal, then packed occlusion/roughness/metal via emission.
 bp.data.materials.clear();bp.data.materials.append(m);activate(bp);sc.cycles.samples=4
 baked={}
 for kind in ['base','normal','orm']:
  size= min(res,512) if kind=='orm' else res
  img=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=(name=='Soot' and kind=='base'))
  if kind!='base':img.colorspace_settings.name='Non-Color'
  target=n.new('ShaderNodeTexImage');target.image=img;n.active=target
  if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
  elif kind=='normal':bpy.ops.object.bake(type='NORMAL')
  else:
   comb=n.new('ShaderNodeCombineColor');ao=n.new('ShaderNodeMath');ao.operation='MULTIPLY_ADD';l.new(sp.outputs[0],ao.inputs[0]);ao.inputs[1].default_value=.2;ao.inputs[2].default_value=.8
   l.new(ao.outputs[0],comb.inputs[0]);l.new(rr.outputs[0],comb.inputs[1]);comb.inputs[2].default_value=met
   emit=n.new('ShaderNodeEmission');l.new(comb.outputs[0],emit.inputs[0]);l.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');l.new(p.outputs[0],out.inputs[0])
  img.filepath_raw=os.path.join(TEX,name+'_'+kind+('.png' if kind=='orm' or (name=='Soot' and kind=='base') else '.jpg'));img.file_format='PNG' if kind=='orm' or (name=='Soot' and kind=='base') else 'JPEG';sc.render.image_settings.quality=95;img.save();baked[kind]=img
 if name=='Soot':
  uvn=n.new('ShaderNodeTexCoord');sub=n.new('ShaderNodeVectorMath');sub.operation='SUBTRACT';l.new(uvn.outputs['UV'],sub.inputs[0]);sub.inputs[1].default_value=(.5,.47,0)
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';l.new(sub.outputs[0],stretch.inputs[0]);stretch.inputs[1].default_value=(2,2.2,0)
  length=n.new('ShaderNodeVectorMath');length.operation='LENGTH';l.new(stretch.outputs[0],length.inputs[0])
  inv=n.new('ShaderNodeMath');inv.operation='SUBTRACT';inv.inputs[0].default_value=1;l.new(length.outputs['Value'],inv.inputs[1]);inv.use_clamp=True
  power=n.new('ShaderNodeMath');power.operation='POWER';l.new(inv.outputs[0],power.inputs[0]);power.inputs[1].default_value=2
  em=n.new('ShaderNodeEmission');l.new(power.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0])
  alpha=bpy.data.images.new('Soot baked opacity',width=256,height=256,alpha=False);alpha.colorspace_settings.name='Non-Color'
  at=n.new('ShaderNodeTexImage');at.image=alpha;n.active=at;bpy.ops.object.bake(type='EMIT')
  ar=np.array(alpha.pixels[:]).reshape(-1,4);col=np.array(baked['base'].pixels[:]).reshape(-1,4);col[:,3]=ar[:,0]*.67
  rgba=bpy.data.images.new('Soot_RGBA_baked',width=256,height=256,alpha=True);rgba.pixels.foreach_set(col.astype(np.float32).ravel());rgba.alpha_mode='STRAIGHT';rgba.filepath_raw=os.path.join(TEX,'Soot_base.png');rgba.file_format='PNG';sc.render.image_settings.file_format='PNG';sc.render.image_settings.color_mode='RGBA';rgba.save_render(rgba.filepath_raw,scene=sc);baked['base']=rgba;bpy.data.images.remove(alpha);print('SOOT_ALPHA_READY',float(col[:,3].max()),flush=True)
 nt.nodes.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
 for kind,img in baked.items():
  t=n.new('ShaderNodeTexImage');t.image=img;t.label='Cycles baked '+kind;t.extension='REPEAT'
  if kind=='base':
   if name=='Paint':
    mix=n.new('ShaderNodeMixRGB');mix.name='Paint tint';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.1,.11,.07,1);l.new(t.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color'])
   else:l.new(t.outputs[0],p.inputs['Base Color'])
   if name=='Soot':l.new(t.outputs['Alpha'],p.inputs['Alpha']);m.surface_render_method='DITHERED'
   if name=='Lamps':l.new(t.outputs['Color'],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=.55
  elif kind=='normal':
   normal=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],normal.inputs['Color']);l.new(normal.outputs[0],p.inputs['Normal'])
  else:
   sp2=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sp2.inputs[0]);l.new(sp2.outputs[1],p.inputs['Roughness']);l.new(sp2.outputs[2],p.inputs['Metallic'])
   # glTF recognizes occlusion through its settings group.
   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')
   oc=n.new('ShaderNodeGroup');oc.node_tree=ng;l.new(sp2.outputs[0],oc.inputs['Occlusion'])
 if name=='Glass':
  p.inputs['Alpha'].default_value=.31;p.inputs['Transmission Weight'].default_value=.25;p.inputs['IOR'].default_value=1.46
  m.surface_render_method='DITHERED';m.use_transparency_overlap=False

 print('BAKED',name,flush=True)
bpy.data.objects.remove(bp,do_unlink=True)
# Finish UVs, apply transforms, combine by rigid animation assembly.
ASSEMBLIES=[]
origins={'Fuselage':(0,0,0),'Rotor_Front':(0,0,4.4),'Rotor_Rear':(0,11.8,5.53),'Door_L':(1.405,1.16,1.96),'Door_R':(-1.405,1.16,1.96),'Ramp':(0,9.82,.84)}
for name,parts in GROUPS.items():
 for o in parts:
  activate(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
  if not o.get('soot'):uv_project(o,2 if name=='Fuselage' else 1.5)
  if len(o.data.materials)==1 and o.data.materials[0]==lamps:
   u={'red':1/6,'green':.5}.get(o.get('lamp_color'),5/6)
   for v in o.data.uv_layers.active.data:v.uv=(u,.5)
 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();o=bpy.context.object;o.name=name
 sc.cursor.location=origins[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 # apply triangulation so delivered budget is unambiguous
 mod=o.modifiers.new('Game triangles','TRIANGULATE');applymod(o,mod)
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=0.000001)
 bad=[f for f in bm.faces if f.calc_area()<1e-9]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
 bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
 ASSEMBLIES.append(o)
# Sockets, world-space metre hip points.
SOCKETS={ 'Seat_Pilot':(.57,-.55,1.64),'Seat_Copilot':(-.57,-.55,1.64),'Hardpoint_L1':(1.8,7.1,1.1),'Hardpoint_R1':(-1.8,7.1,1.1),'Light_Nav_L':(1.986,6.56,1.428),'Light_Nav_R':(-1.986,6.56,1.428),'Light_Beacon':(0,12.4,5.248),'Exhaust_L':(1.12,11.71,3.64),'Exhaust_R':(-1.12,11.71,3.64)}
for s in [1,-1]:
 for k,y in enumerate(np.arange(1.9,9.1,.9)):SOCKETS[f'Troop_{k+1+(0 if s==1 else 8)}']=(s*.91,float(y),1.44)
EMPTIES=[]
for name,loc in SOCKETS.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=loc;o.empty_display_size=.15;EMPTIES.append(o)
# clamp gross geometry to exact ground contact (tread extends a few millimetres).
for o in ASSEMBLIES:
 if o.name=='Fuselage':
  for v in o.data.vertices:
   if v.co.z<0:v.co.z=0
# Export, preserve textures and explicitly enforce scalar tint + alpha portability.
bpy.ops.object.select_all(action='DESELECT')
for o in ASSEMBLIES+EMPTIES:o.select_set(True)
GLB=os.path.join(ROOT,'chinook-helicopter.glb')
bpy.ops.export_scene.gltf(filepath=GLB,export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_cameras=False,export_lights=False)
raw=open(GLB,'rb').read();jlen,jtype=struct.unpack_from('<II',raw,12);doc=json.loads(raw[20:20+jlen]);binary=raw[20+jlen:]
for m in doc.get('materials',[]):
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.1,.11,.07,1]
 if m['name'] in ['Glass','Soot']:m['alphaMode']='BLEND';m['doubleSided']=True
 # avoid unused overcomplex transmission extensions; glass still alpha transparent
 if m['name']=='Glass':m.get('extensions',{}).pop('KHR_materials_transmission',None)
jb=json.dumps(doc,separators=(',',':')).encode();jb+=b' '*((-len(jb))%4)
open(GLB,'wb').write(struct.pack('<4sII',b'glTF',2,12+8+len(jb)+len(binary))+struct.pack('<II',len(jb),0x4E4F534A)+jb+binary)

def game(v):return [round(float(v[0]),6),round(float(v[2]),6),round(float(-v[1]),6)]
pts=[o.matrix_world@v.co for o in ASSEMBLIES for v in o.data.vertices];gp=np.array([game(v) for v in pts]);tri=sum(len(o.data.polygons) for o in ASSEMBLIES)
paints=[{'name':'Olive drab','linear_rgb':[.10,.11,.07]},{'name':'Air-superiority light grey','linear_rgb':[.48,.53,.55]},{'name':'Dark grey','linear_rgb':[.115,.135,.15]},{'name':'Navy blue-grey','linear_rgb':[.075,.12,.16]},{'name':'Desert sand','linear_rgb':[.49,.37,.22]},{'name':'Black','linear_rgb':[.022,.026,.028]}]
manifest={'asset':'CH-47-style tandem transport','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'nodes':{o.name:{'world_position':game(o.location),'type':'mesh' if o.type=='MESH' else 'socket'} for o in ASSEMBLIES+EMPTIES},'bounding_box':{'min':gp.min(0).tolist(),'max':gp.max(0).tolist(),'size':(gp.max(0)-gp.min(0)).tolist()},'swept_rotor_bounds':{'min':[-9.15,0,-20.95],'max':[9.15,5.7,9.15]},'dimensions':{'fuselage_length':15.8,'rotor_diameter':18.3,'swept_length':30.1,'nominal_height':5.7},'triangles':tri,'materials':list(MATS),'paints':paints,'motion':{'Rotor_Front':{'axis':[0,1,0]},'Rotor_Rear':{'axis':[0,1,0],'counter_rotation':True},'Ramp':{'axis':[1,0,0],'closed':True},'Door_L':{'axis':[0,1,0]},'Door_R':{'axis':[0,1,0]}},'provenance':{'reference':'sheet.jpg','textures':[{'id':id,'license':'CC0-1.0','url':'https://polyhaven.com/a/'+id} for id in PH],'bake':'Cycles OptiX; 4 samples; shared tile colour, tangent normal, ORM; 2 m primary skin repeats'}}
open(os.path.join(ROOT,'nodes.json'),'w').write(json.dumps(manifest,indent=2))
open(os.path.join(ROOT,'NOTES.md'),'w').write(f'CH-47-style tandem heavy transport; metres, Y-up, nose +Z, articulated rotors/doors/closed ramp, visible cockpit and 16 troop seats.\nTriangles: {tri:,}; GLB {os.path.getsize(GLB)/1048576:.2f} MiB; six mesh nodes and {len(EMPTIES)} sockets.\nMaterials: Paint, Metal, Rubber, Interior, Rotor, Glass (BLEND), Lamps, Soot; neutral baked Paint tile multiplied by olive default [0.10, 0.11, 0.07].\nTextures: shared Cycles-baked base/normal up to 1024 px, ORM up to 512 px; tiling physical UVs, no whole-aircraft atlas.\nPoly Haven CC0 IDs: blue_metal_plate, rubberized_track, fabric_leather_01; licence https://polyhaven.com/license; asset pages https://polyhaven.com/a/<id>.\nDifferences: three blades per rotor and four single wheels per brief; no insignia; simplified cockpit systems and cabin fittings; reference grey shown among six recolours.\n')
assert 60000<=tri<=140000, f'Triangle budget: {tri}'
assert os.path.getsize(GLB)<=12*1024**2, 'GLB exceeds 12 MiB'
assert len(doc['materials'])<=8
assert abs(gp.min(0)[1])<.00001, 'Ground contact mismatch'
print('FIRST_COMPLETE_GLB',tri,os.path.getsize(GLB),'elapsed',time.time()-START,flush=True)
# Render stage: export above has no ground, camera or lighting.
sc.cycles.samples=40;sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
sc.render.image_settings.file_format='PNG';sc.render.image_settings.color_mode='RGB'
floor_mat=material('Presentation floor',(.22,.245,.26),.9)
ground=box('Presentation floor',(0,5,-.09),(200,200,.15),floor_mat,0,False)
def light(name,loc,energy,size,target):
 data=bpy.data.lights.new(name,'AREA');data.energy=energy;data.shape='DISK';data.size=size;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()
light('Overcast key',(7,-8,17),3400,10,(0,4,2));light('Sky rim',(-8,11,13),2800,9,(0,5,2));light('Front fill',(-9,-7,8),1500,10,(0,1,2))
sun_data=bpy.data.lights.new('Soft sun','SUN');sun_data.energy=1.5;sun_data.angle=.23;sun=bpy.data.objects.new('Soft sun',sun_data);sc.collection.objects.link(sun);sun.rotation_euler=(math.radians(24),math.radians(-28),math.radians(-25))
cam_data=bpy.data.cameras.new('Presentation camera');cam=bpy.data.objects.new('Presentation camera',cam_data);sc.collection.objects.link(cam);sc.camera=cam
cam_data.type='ORTHO'
def camera(loc,target,scale):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam_data.ortho_scale=scale

def render(name):
 sc.render.filepath=os.path.join(ROOT,name);bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)
camera((27,-32,15),(0,5.0,2.3),29.3)
render('final.png')
# Save genuine clay as override, before side camera.
clay=material('Review clay',(.44,.44,.44),.76);sc.view_layers[0].material_override=clay;render('clay.png');sc.view_layers[0].material_override=None
ground.hide_render=True
camera((35,5.4,2.6),(0,5.4,2.6),31.5);render('side.png')
ground.hide_render=False
# Recolour proof: six matched 3/4 views composed within Blender image data.
camera((27,-32,15),(0,5.0,2.3),29.3);sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
canvas=np.ones((720,1920,4),dtype=np.float32)
for k,pal in enumerate(paints):
 paint.node_tree.nodes['Paint tint'].inputs[2].default_value=(*pal['linear_rgb'],1)
 render('_paint_%d.png'%k)
 im=bpy.data.images.load(os.path.join(ROOT,'_paint_%d.png'%k),check_existing=False);arr=np.array(im.pixels[:]).reshape(360,640,4);row=1-k//3;col=k%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640,:]=arr;bpy.data.images.remove(im)
 os.remove(os.path.join(ROOT,'_paint_%d.png'%k))
im=bpy.data.images.new('Six real paint variants',width=1920,height=720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
paint.node_tree.nodes['Paint tint'].inputs[2].default_value=(.1,.11,.07,1)
print('COMPLETE',time.time()-START,flush=True)
