#!/usr/bin/env python3
"""Original 24 m assault bomber. Run with Blender 5.2 --background --python-exit-code 1.
Clean build: downloads CC0 inputs, models geometry, bakes Cycles PBR tiles, exports,
renders six liveries and checks pivots. Blender coordinates X left, Y aft, Z up.
Target: 50-110k triangles, <=9 materials, 1024 RGB/normal, 512 ORM, <12 MB.
Views: front hero, side orthographic, rear, underside articulation; 3 revision cap.
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, struct, random
from mathutils import Vector, Matrix, Quaternion
from pathlib import Path
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
OUT=ROOT/'baked'; OUT.mkdir(exist_ok=True)
random.seed(37)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.materials): bpy.data.materials.remove(d)
S=bpy.context.scene; S.unit_settings.system='METRIC'; S.unit_settings.scale_length=1
S.render.engine='CYCLES'; S.cycles.samples=32; S.cycles.use_denoising=True
P=bpy.context.preferences.addons['cycles'].preferences
P.compute_device_type='OPTIX'; P.get_devices()
for d in P.devices:d.use=(d.type!='CPU')
S.cycles.device='GPU'; S.cycles.max_bounces=6; S.cycles.transparent_max_bounces=8
S.render.image_settings.file_format='PNG'; S.render.film_transparent=False
S.view_settings.view_transform='AgX'; S.view_settings.look='AgX - Medium High Contrast'
S.render.resolution_percentage=100
GROUPS={}; MATS={}; ASSET=[]; PIVOTS={}
PAINTS=[('Gunmetal',[.20,.22,.24]),('Olive drab',[.23,.26,.14]),('Naval grey',[.38,.44,.49]),('Desert sand',[.52,.40,.23]),('Arctic white',[.75,.80,.83]),('Deep navy',[.035,.075,.135])]

def mat(name,col,metal=0,rough=.45):
 m=bpy.data.materials.new(name);m.use_nodes=True
 p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*col,1);p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
 m.diffuse_color=(*col,1);MATS[name]=m;return m
paint=mat('Paint',PAINTS[0][1],.64,.4)
alloy=mat('Alloy',(.28,.31,.33),.85,.34)
carbon=mat('Carbon',(.032,.039,.044),.66,.5)
glass=mat('Glass',(.025,.052,.075),.08,.16)
p=glass.node_tree.nodes.get('Principled BSDF');p.inputs['Alpha'].default_value=.34;p.inputs['Transmission Weight'].default_value=0
if hasattr(glass,'surface_render_method'):glass.surface_render_method='DITHERED'
engines=mat('Engines',(.25,.045,.008),.25,.4)
p=engines.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(1,.18,.018,1);p.inputs['Emission Strength'].default_value=2.2
lamps=mat('Lamps',(.3,.5,.6),.1,.32)
# Shared tiny palette: red, green, ice blue, amber. UV positions select colours.
im=bpy.data.images.new('Lamp_palette',width=16,height=4)
cols=[(1,.012,.004,1),(.01,1,.11,1),(.25,.65,1,1),(1,.28,.015,1)]
im.pixels=[v for y in range(4) for x in range(16) for v in cols[x//4]]; im.filepath_raw=str(OUT/'Lamps.png');im.file_format='PNG';im.save()
nt=lamps.node_tree;t=nt.nodes.new('ShaderNodeTexImage');t.image=im;t.interpolation='Closest';p=nt.nodes.get('Principled BSDF');nt.links.new(t.outputs['Color'],p.inputs['Base Color']);nt.links.new(t.outputs['Color'],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=2

def mesh(name,verts,faces,material=paint,group='Hull',bevel=0.02,segments=2):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 ob=bpy.data.objects.new(name,me);S.collection.objects.link(ob)
 me.materials.append(material)
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 bpy.context.view_layer.objects.active=ob;ob.select_set(True)
 if bevel:
  mod=ob.modifiers.new('Machined edge radii','BEVEL');mod.width=bevel;mod.segments=segments
  if material==paint:
   me.materials.append(alloy);mod.affect='EDGES';mod.material=1
  bpy.ops.object.modifier_apply(modifier=mod.name)
 # Planar box mapping at 2 m per tile; all parts share the baked surface sets.
 uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
 for f in me.polygons:
  no=f.normal;axis=max(range(3),key=lambda i:abs(no[i]));axes=[i for i in range(3) if i!=axis]
  for li in f.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(co[axes[0]]/2,co[axes[1]]/2)
 for f in me.polygons:f.use_smooth=False
 ob.select_set(False)
 GROUPS.setdefault(group,[]).append(ob);return ob

def box(name,c,size,ma=paint,gr='Hull',bev=.035):
 x,y,z=c;a,b,d=[v/2 for v in size]
 return mesh(name,[(x+i*a,y+j*b,z+k*d) for i,j,k in [(-1,-1,-1),(-1,-1,1),(-1,1,-1),(-1,1,1),(1,-1,-1),(1,-1,1),(1,1,-1),(1,1,1)]],[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)],ma,gr,bev)

def plate(name,pts,thick=.06,ma=paint,gr='Hull',bev=.018):
 pts=[Vector(v) for v in pts];n=(pts[1]-pts[0]).cross(pts[2]-pts[0]).normalized();N=len(pts)
 return mesh(name,[tuple(p+n*thick/2) for p in pts]+[tuple(p-n*thick/2) for p in pts],[tuple(range(N)),tuple(reversed(range(N,2*N)))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)],ma,gr,bev)

def beam(name,a,b,r=.045,ma=alloy,gr='Hull',N=10):
 return cyl(name,a,b,r,ma,gr,N,bev=0)

def cyl(name,a,b,r,ma=alloy,gr='Hull',N=32,r2=None,bev=.008):
 a,b=Vector(a),Vector(b);z=(b-a).normalized();u=z.cross(Vector((0,0,1)))
 if u.length<.1:u=z.cross(Vector((0,1,0)))
 u.normalize();v=z.cross(u);r2=r if r2 is None else r2
 vs=[tuple(c+radius*(math.cos(i*2*math.pi/N)*u+math.sin(i*2*math.pi/N)*v)) for c,radius in [(a,r),(b,r2)] for i in range(N)]
 fs=[tuple(reversed(range(N))),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
 ob=mesh(name,vs,fs,ma,gr,bev,1)
 for f in ob.data.polygons:
  if len(f.vertices)==4:f.use_smooth=True
 return ob

def tube(name,c,axis,profile,ma=alloy,gr='Hull',N=64):
 c=Vector(c);d=Vector(axis).normalized();u=d.cross(Vector((0,0,1)))
 if u.length<.1:u=d.cross(Vector((0,1,0)))
 u.normalize();v=d.cross(u)
 vs=[tuple(c+d*t+rad*(u*math.cos(i*2*math.pi/N)+v*math.sin(i*2*math.pi/N))) for t,rad in profile for i in range(N)]
 fs=[]
 for j in range(len(profile)):
  k=(j+1)%len(profile)
  for i in range(N):q=(i+1)%N;fs.append((j*N+i,j*N+q,k*N+q,k*N+i))
 ob=mesh(name,vs,fs,ma,gr,0)
 for f in ob.data.polygons:f.use_smooth=True
 return ob

def socket(name,world,parent=None):
 ob=bpy.data.objects.new(name,None);S.collection.objects.link(ob);ob.empty_display_size=.18
 if parent:
  ob.parent=parent;ob.location=parent.matrix_world.inverted()@Vector(world)
 else:ob.location=world
 ASSET.append(ob);return ob

def bolts_on_quad(pts,ma=alloy,gr='Hull',r=.035):
 a,b,c,d=map(Vector,pts);n=(b-a).cross(c-a).normalized()
 for u,v in [(.06,.09),(.94,.09),(.94,.91),(.06,.91)]:
  p=a*(1-u)*(1-v)+b*u*(1-v)+c*u*v+d*(1-u)*v
  cyl('Captive fastener',p+n*.04,p+n*.07,r,ma,gr,8,bev=.002)

def outline(name,pts,r=.035,ma=alloy,gr='Hull',closed=True):
 for i in range(len(pts) if closed else len(pts)-1):beam(name,pts[i],pts[(i+1)%len(pts)],r,ma,gr,8)

# Main hull: eight-faced continuous armoured loft, widening behind a blunt wedge nose.
SECS=[(-12,2.02,-1.30,.06),(-10.65,2.35,-1.45,.35),(-7.8,3.0,-1.65,1.08),(-4.8,3.55,-1.72,1.58),(-1.7,3.95,-1.78,1.8),(2.1,4.15,-1.78,1.82),(6.6,4.2,-1.7,1.7),(9.6,3.95,-1.45,1.55),(10.65,3.72,-1.18,1.35)]
def ring(sec):
 y,w,bot,top=sec
 cham=min(.65,(top-bot)*.24)
 return [Vector(v) for v in [(w,y,bot+cham),(w,y,top-cham),(w-cham*1.7,y,top),(-w+cham*1.7,y,top),(-w,y,top-cham),(-w,y,bot+cham),(-w+cham*1.6,y,bot),(w-cham*1.6,y,bot)]]
rings=[ring(s) for s in SECS]
verts=[tuple(p) for r in rings for p in r];faces=[tuple(reversed(range(8))),tuple(range(64,72))]
for k in range(len(rings)-1):
 for j in range(8):faces.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
core=mesh('Pressure hull',verts,faces,carbon,bevel=.075)
# Genuine cockpit recess cut through top skin; canopy is hollow and operable.
cut=box('Cockpit machining cutter',(0,-7.1,2.0),(2.1,6.1,2.7),carbon,gr='_cut',bev=.12)
bpy.context.view_layer.objects.active=core
mod=core.modifiers.new('Cockpit volume','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name)
bpy.data.objects.remove(cut,do_unlink=True);GROUPS.pop('_cut')
cut=box('Bay machining cutter',(0,1,-2.0),(4.5,12.2,2.1),carbon,gr='_cut',bev=.08)
bpy.context.view_layer.objects.active=core
mod=core.modifiers.new('Open torpedo cassette','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name)
bpy.data.objects.remove(cut,do_unlink=True);GROUPS.pop('_cut')
# Staggered armour courses follow the continuous loft. Their seam stations are
# independent of hull profile stations; each cast panel crosses the profile joins.
def ring_at(y):
 for a,b in zip(SECS,SECS[1:]):
  if a[0]<=y<=b[0]:
   t=(y-a[0])/(b[0]-a[0]);return [p.lerp(q,t) for p,q in zip(ring(a),ring(b))]
 return ring(SECS[0 if y<SECS[0][0] else -1])
for j in range(8):
 if j in (0,4):cuts=[-12,-10.65,-7.8,-4.8,-1.7,2.1,6.6,9.6,10.65]
 elif j in (1,3):cuts=[-12,-9.8,-6.95,-3.95,-.85,2.95,7.45,10.65]
 elif j in (5,7):cuts=[-12,-11.5,-8.65,-5.65,-2.55,1.25,5.75,8.75,10.65]
 elif j==2:cuts=[-12,-10.65,-4.55,-1.0,3.8,8.0,10.65]
 else:cuts=[-12,-9.0,-5.12,7.12,10.65]
 for k,(ya,yb) in enumerate(zip(cuts,cuts[1:])):
  if j==2 and k==1:continue
  if j==6 and k==2:continue
  ya+=.018;yb-=.018
  ys=[ya]+[q[0] for q in SECS if ya<q[0]<yb]+[yb]
  rr=[ring_at(y) for y in ys];a,b=rr[0][j],rr[0][(j+1)%8];c=rr[-1][(j+1)%8]
  normal=(b-a).cross(c-a).normalized();mid=(a+b+c)/3
  if normal.dot(Vector((mid.x,0,mid.z)))<0:normal=-normal
  vs=[]
  for r in rr:
   a,b=r[j],r[(j+1)%8];m=(a+b)/2
   vs.extend([m+(a-m)*.992+normal*.065,m+(b-m)*.992+normal*.065])
  N=len(vs);vs+= [v-normal*.10 for v in vs]
  fs=[]
  for q in range(len(rr)-1):
   i=q*2;fs.extend([(i,i+1,i+3,i+2),(N+i+2,N+i+3,N+i+1,N+i),(i,i+2,N+i+2,N+i),(i+1,N+i+1,N+i+3,i+3)])
  fs.extend([(0,N,N+1,1),(N-2,N-1,2*N-1,2*N-2)])
  mesh('Staggered armour course',list(map(tuple,vs)),fs,paint,bevel=.022)
  if j in (0,1,3,4) and k>0:bolts_on_quad([vs[0],vs[1],vs[N-1],vs[N-2]],r=.031)
# Long chine reinforcements integrated into shoulder edges.
for sign in [-1,1]:
 for k in range(1,7):
  a=rings[k][1 if sign==1 else 4];b=rings[k+1][1 if sign==1 else 4]
  beam('Continuous shoulder cap',a+Vector((sign*.045,0,.1)),b+Vector((sign*.045,0,.1)),.055,alloy,N=8)
# Nose intake faces, dark recessed throats and stacked aero/thermal louvers.
for sign in [-1,1]:
 pts=[(sign*.34,-12.10,-1.09),(sign*1.70,-12.10,-1.09),(sign*1.79,-12.10,-.17),(sign*.28,-12.10,-.17)]
 plate('Nose radiator shadow',pts,.05,carbon,bev=.055)
 outline('Nose radiator lip',pts,.055,alloy)
 for i in range(6):
  z=-.98+i*.14
  beam('Nose radiator fin',(sign*.43,-12.15,z),(sign*1.60,-12.15,z),.025,carbon,N=8)
plate('Central prow armour',[(-.22,-12.08,-1.1),(.22,-12.08,-1.1),(.30,-12.16,.0),(-.30,-12.16,.0)],.1,paint,bev=.025)
# Recessed thermal exchangers follow the actual hull taper to avoid burying fins.
def hull_at(y):
 for a,b in zip(SECS,SECS[1:]):
  if a[0]<=y<=b[0]:
   t=(y-a[0])/(b[0]-a[0]);return [a[i]*(1-t)+b[i]*t for i in range(1,4)]
 return list(SECS[0 if y<SECS[0][0] else -1][1:])
for sign in [-1,1]:
 y=-3.2;span=1.7
 def vx(yy,extra=0):return sign*(hull_at(yy)[0]+.10+extra)
 pts=[(vx(y-span/2),y-span/2,-.94),(vx(y+span/2),y+span/2,-.94),(vx(y+span/2),y+span/2,.77),(vx(y-span/2),y-span/2,.77)]
 plate('Recessed exchanger well',pts,.12,carbon,bev=.08)
 outline('Exchanger cast surround',[(x+sign*.10,a,b) for x,a,b in pts],.075,alloy)
 for z in np.linspace(-.79,.62,12):beam('Heat exchanger slat',(vx(y-span*.4,.10),y-span*.4,z),(vx(y+span*.4,.10),y+span*.4,z),.042,carbon,N=8)
 # small lateral fixed housings / RCS sockets
 x=sign*3.05
 box('Flush RCS fairing',(x,-7.1,-.20),(.26,1.25,.32),alloy,bev=.1)
 for yy in [-7.48,-7.13,-6.78]:cyl('RCS port',(x+sign*.11,yy,-.20),(x+sign*.19,yy,-.20),.065,carbon,N=16)
# Raised service spine behind the cockpit, deliberately narrow and integrated.
spine=[(-4.52,1.02,1.55,2.43),(-2.8,1.18,1.72,2.17),(1.5,1.15,1.80,2.08),(7.8,1.14,1.60,1.93),(9.7,.95,1.5,1.77)]
for a,b in zip(spine,spine[1:]):
 y,w,lo,hi=a;Y,W,LO,HI=b
 pts=[(-w,y,hi),(w,y,hi),(W,Y,HI),(-W,Y,HI)]
 plate('Dorsal armoured spine',pts,.18,paint,bev=.055)
 for sign in [-1,1]:
  plate('Dorsal spine sidewall',[(sign*w,y,lo),(sign*W,Y,LO),(sign*W,Y,HI),(sign*w,y,hi)],.10,paint,bev=.03)
# End cap closes the fairing around the canopy hinge without a daylight seam.
plate('Spine forward closure',[(-1.04,-4.51,1.52),(1.04,-4.51,1.52),(1.04,-4.51,2.43),(-1.04,-4.51,2.43)],.10,paint,bev=.025)
# cockpit well, stepped seats and instrument panels
box('Cabin black tub',(0,-7.25,.64),(1.92,5.65,.22),carbon,bev=.08)
for y,z in [(-7.9,.83),(-5.85,1.12)]:
 box('Seat suspension',(0,y,z),(.65,.68,.25),alloy,bev=.06)
 box('Seat cushion',(0,y,z+.18),(.68,.65,.20),carbon,bev=.09)
 box('Seat back',(0,y+.29,z+.61),(.69,.18,.75),carbon,bev=.08)
 box('Head restraint',(0,y+.32,z+1.04),(.43,.19,.24),carbon,bev=.065)
 for x in [-.23,.23]:box('Seat harness',(x,y+.175,z+.64),(.055,.035,.63),alloy,bev=.012)
 box('Instrument coaming',(0,y-.57,z+.65),(1.35,.25,.30),carbon,bev=.055)
 for x in [-.41,0,.41]:
  ob=box('Flight display',(x,y-.715,z+.68),(.28,.025,.16),lamps,bev=.015)
  for uv in ob.data.uv_layers.active.data:uv.uv=(.63,.5)
 beam('Control column',(0,y-.2,z+.25),(0,y-.37,z+.60),.035,carbon,N=12)
 beam('Control grip',(-.13,y-.37,z+.62),(.13,y-.37,z+.62),.04,alloy,N=12)
# Multi-pane framed greenhouse, side openings and compound sloped windscreen.
C=[(-10.4,.64,.37,.54),(-7.72,1.04,1.03,2.20),(-6.28,1.08,1.32,2.48),(-4.55,.98,1.56,2.43)]
for k,(a,b) in enumerate(zip(C,C[1:])):
 y,w,lo,hi=a;Y,W,LO,HI=b
 for sign in [-1,1]:
  pts=[(sign*w,y,lo),(sign*W,Y,LO),(sign*W*.79,Y,HI),(sign*w*.79,y,hi)]
  plate('Canopy side glazing',pts,.022,glass,'Canopy',.008)
  outline('Canopy armoured frame',pts,.086,paint,'Canopy')
 pts=[(-w*.79,y,hi),(w*.79,y,hi),(W*.79,Y,HI),(-W*.79,Y,HI)]
 plate('Forward windshield' if k==0 else 'Roof glazing',pts,.025,glass if k<2 else paint,'Canopy',.008)
 outline('Canopy roof frame',pts,.075,paint,'Canopy')
# rear bulkhead closes canopy; hinge spans across the roof's aft lower edge.
y,w,lo,hi=C[-1]
plate('Canopy rear shell',[(-w,y,lo),(w,y,lo),(w*.79,y,hi),(-w*.79,y,hi)],.09,paint,'Canopy',.035)
beam('Canopy transverse hinge',(-1.05,-4.55,2.48),(1.05,-4.55,2.48),.10,alloy,'Canopy',24)
PIVOTS['Canopy']=((0,-4.55,2.48),(-1,0,0),'Hull')
# Wings: cast wedge roots, broad short swept tip plates, bevelled thick edges.
for sign in [-1,1]:
 pts=[(sign*3.65,1.05,.30),(sign*5.0,2.45,.15),(sign*8,4.75,-.16),(sign*7.90,8.70,-.22),(sign*4,9.18,.15)]
 wing=plate('Short armoured wing',pts,.32,paint,bev=.075)
 # separate top skins leave narrow channels and provide load-bearing visual scale
 for j in range(3):
  ya=3.4+j*1.65;yb=ya+1.55
  p=[(sign*4.5,ya,.34),(sign*7.7,max(ya,4.85),-.02),(sign*7.65,yb+.1,-.02),(sign*4.5,yb,.34)]
  plate('Wing upper access plate',p,.045,paint,bev=.014)
 # Thick tapered wing-root casting, with an open forward-facing recessed intake.
 front=[(sign*4.03,.72,-.18),(sign*5.28,1.48,-.18),(sign*5.28,1.48,.79),(sign*4.03,.72,.91)]
 rear=[(sign*4.12,4.3,-.12),(sign*5.40,4.3,-.12),(sign*5.40,4.3,.27),(sign*4.12,4.3,.57)]
 for j in range(4):plate('Wing root cast fairing',[front[j],front[(j+1)%4],rear[(j+1)%4],rear[j]],.10,paint,bev=.045)
 throat=[(x,y+.12,z) for x,y,z in front]
 plate('Wing root recessed throat',throat,.045,carbon,bev=.035)
 outline('Wing root intake lip',front,.085,alloy)
 for t in np.linspace(.12,.88,8):
  x=sign*(4.03+1.25*t);y=.72+.76*t
  beam('Vertical intake vane',(x,y+.035,-.08),(x,y+.035,.80-.12*t),.030,carbon,N=8)
 # Wing-root dorsal armour housings.
 box('Wing actuator fairing',(sign*3.2,3.25,1.48),(1.20,3.35,.68),paint,bev=.15)
 for j in range(6):
  box('Dorsal cooling slot',(sign*3.2,2.18+j*.33,1.839),(.72,.105,.035),carbon,bev=.025)
 # Recessed tip lamps, housed in metal bezel; navigation side convention X+ is left.
 ob=cyl('Navigation lens',(sign*7.84,5.15,-.16),(sign*8,5.15,-.16),.07,lamps,N=20)
 for uv in ob.data.uv_layers.active.data:uv.uv=(.125 if sign==1 else .375,.5)
 # Two removable missiles on launch shoes, fixed to the hull in shipped closed pose.
 for j,x in enumerate([5.15,6.70]):
  x*=sign;yy=5.65+j*.35;zz=-.76
  box('Missile launch shoe',(x,yy,.0),(.22,1.85,.45),carbon,bev=.04)
  for sy in [yy-.55,yy+.55]:box('Pylon clamp',(x,sy,-.37),(.42,.18,.24),alloy,bev=.035)
  cyl('Missile body',(x,yy-1.25,zz),(x,yy+1.55,zz),.18,alloy,N=32,bev=.015)
  cyl('Missile ogive',(x,yy-1.92,zz),(x,yy-1.25,zz),.025,carbon,N=32,r2=.18,bev=.01)
  tube('Missile arming collar',(x,yy-1.0,zz),(0,1,0),[(0,.184),(.12,.184),(.12,.17),(0,.17)],carbon,N=32)
  for ang in [0,math.pi/2,math.pi,3*math.pi/2]:
   dx,dz=math.cos(ang),math.sin(ang)
   plate('Missile stabilizer',[(x+dx*.14,yy+.65,zz+dz*.14),(x+dx*.45,yy+1.2,zz+dz*.45),(x+dx*.45,yy+1.6,zz+dz*.45),(x+dx*.14,yy+1.5,zz+dz*.14)],.035,carbon,bev=.008)
# Belly bomb bay: long armoured cassette with separate centre-split doors and hinges.
# Static chamfered perimeter bridges the pressure hull to the hinged door seals.
for sign in [-1,1]:
 pts=[(sign*2.25,-5.15,-2.02),(sign*2.82,-5.15,-1.60),(sign*2.82,7.15,-1.60),(sign*2.25,7.15,-2.02)]
 plate('Bay perimeter armoured coaming',pts,.16,paint,bev=.045)
for yy in [-5.12,7.12]:
 plate('Bay transverse seal housing',[(-2.28,yy,-2.06),(2.28,yy,-2.06),(2.55,yy,-1.56),(-2.55,yy,-1.56)],.16,carbon,bev=.035)
box('Torpedo bay ceiling',(0,1,-.94),(4.6,12.3,.14),carbon,bev=.05)
for sign in [-1,1]:
 box('Torpedo bay sidewall',(sign*2.26,1,-1.43),(.15,12.1,1.0),carbon,bev=.04)
 beam('Torpedo suspension rail',(sign*.74,-4.7,-1.13),(sign*.74,6.7,-1.13),.07,alloy,N=12)
 for y in [-3.2,1.1,5.1]:
  cyl('Internal torpedo body',(sign*.74,y-1.5,-1.55),(sign*.74,y+1.4,-1.55),.26,carbon,N=24,bev=.01)
  cyl('Torpedo warhead',(sign*.74,y-1.95,-1.55),(sign*.74,y-1.5,-1.55),.045,alloy,N=24,r2=.26,bev=.01)
  box('Torpedo release clamp',(sign*.74,y,-1.27),(.65,.22,.25),alloy,bev=.035)
for sign,label in [(1,'L'),(-1,'R')]:
 gr='BayDoor_'+label
 pts=[(sign*.035,-5.05,-2.22),(sign*2.26,-5.05,-1.94),(sign*2.26,7.04,-1.94),(sign*.035,7.04,-2.22)]
 plate('Torpedo bay door',pts,.36,paint,gr,bev=.055)
 for yy in [-4.85,-1.0,2.85,6.8]:
  beam('Bay door cross rib',(sign*.16,yy,-2.40),(sign*2.15,yy,-2.15),.05,alloy,gr,N=8)
 for yy in [-4.5,-.8,2.9,6.5]:
  cyl('Bay hinge pin',(sign*2.28,yy-.28,-1.9),(sign*2.28,yy+.28,-1.9),.10,alloy,N=20)
  box('Bay hinge strap',(sign*2.15,yy,-1.98),(.35,.24,.19),carbon,gr,bev=.03)
 for yy in [-3.0,.85,4.70]:
  for direction in [-1,1]:
   beam('Bay embossed diagonal',(sign*.28,yy+direction*1.4,-2.36),(sign*2.05,yy-direction*1.4,-2.17),.028,carbon,gr,N=8)
 PIVOTS[gr]=((sign*2.26,1,-1.92),(0,0,sign),'Hull')
# Retracted service / landing doors flush in lower chine (no extended gear).
for sign in [-1,1]:
 for yy in [-6.7,8.1]:
  j=7 if sign==1 else 5
  a,b=ring_at(yy-.55),ring_at(yy+.55)
  pts=[a[j].lerp(a[(j+1)%8],.2),a[j].lerp(a[(j+1)%8],.8),b[j].lerp(b[(j+1)%8],.8),b[j].lerp(b[(j+1)%8],.2)]
  normal=(pts[1]-pts[0]).cross(pts[2]-pts[0]).normalized()
  if normal.z>0:normal=-normal
  pts=[v+normal*.115 for v in pts]
  plate('Closed service hatch',pts,.065,paint,bev=.025)
  bolts_on_quad(pts,r=.024)
# Three main engines in a tight transverse row. Deep concentric nozzles and heat petals.
for e,x in enumerate([-2.58,0,2.58],1):
 z=.0
 tube('Engine armoured sleeve',(x,0,z),(0,1,0),[(8.9,1.29),(10.45,1.29),(11.65,1.18),(11.96,1.12),(11.96,.97),(11.55,.99),(10.4,1.10),(8.9,1.10)],carbon,N=64)
 for y,rad in [(10.30,1.30),(10.65,1.27),(11.82,1.15)]:
  tube('Engine rolled reinforcement',(x,y,z),(0,1,0),[(-.07,rad),(.07,rad),(.09,rad-.08),(-.09,rad-.08)],alloy,N=64)
 # Petals taper and reveal a black expansion gap between them.
 for k in range(18):
  a=2*math.pi*k/18+.024;b=2*math.pi*(k+1)/18-.024
  pts=[(x+1.30*math.cos(a),10.5,z+1.30*math.sin(a)),(x+1.30*math.cos(b),10.5,z+1.30*math.sin(b)),(x+1.145*math.cos(b),11.78,z+1.145*math.sin(b)),(x+1.145*math.cos(a),11.78,z+1.145*math.sin(a))]
  plate('Thermal nozzle petal',pts,.06,carbon,bev=.018)
  ang=(a+b)/2
  beam('Engine latch rail',(x+1.33*math.cos(ang),10.05,z+1.33*math.sin(ang)),(x+1.33*math.cos(ang),10.8,z+1.33*math.sin(ang)),.045,carbon,N=8)
 # Recessed amber annulus. Visible glow is inside, sockets at nozzle exits.
 tube('Combustion glow ring',(x,10.97,z),(0,1,0),[(0,.83),(.05,.83),(.05,.71),(0,.71)],engines,N=64)
 tube('Internal nozzle ribs',(x,11.0,z),(0,1,0),[(0,1.01),(.11,1.01),(.11,.94),(0,.94)],alloy,N=64)
 cyl('Engine dark throat',(x,10.78,z),(x,10.83,z),.90,carbon,N=64,bev=.02)
 cyl('Engine central spike',(x,10.84,z),(x,11.28,z),.36,alloy,N=48,r2=.15,bev=.015)
 for a in np.linspace(0,2*math.pi,24,endpoint=False):
  pts=[(x+.38*math.cos(a),10.87,z+.38*math.sin(a)),(x+.85*math.cos(a+.09),10.91,z+.85*math.sin(a+.09)),(x+.85*math.cos(a+.17),10.96,z+.85*math.sin(a+.17)),(x+.40*math.cos(a+.10),10.96,z+.40*math.sin(a+.10))]
  plate('Nozzle guide vane',pts,.025,carbon,bev=.006)
# Turrets have integral traverse rings and separate gun elevation cradles.
def turret(idx,pivot,width,depth,height,barrel_length,twin):
 x,y,z=pivot;gr='Turret_'+str(idx);gun='Gun_'+str(idx)
 cyl('Traverse race fixed',(x,y,z-.12),(x,y,z+.06),width*.59,alloy,N=64)
 cyl('Traverse skirt',(x,y,z),(x,y,z+.14),width*.55,carbon,gr,N=64)
 # angular six-sided turret shell, front mantlet recessed
 vs=[(x+sx*width*.5,y+sy*depth*.5,z+.13) for sx,sy in [(-1,-1),(1,-1),(1,1),(-1,1)]]+[(x+sx*width*.36,y+sy*depth*.35,z+height) for sx,sy in [(-1,-1),(1,-1),(1,1),(-1,1)]]
 mesh('Cast turret armour',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)],carbon,gr,.065)
 zgun=z+height*.55;ygun=y-depth*.43
 cyl('Elevation trunnion',(x-width*.51,ygun,zgun),(x+width*.51,ygun,zgun),.26,alloy,gr,N=32)
 for sx in [-1,1]:
  box('Turret cheek applique',(x+sx*width*.44,y,z+height*.48),(.18,depth*.62,height*.6),alloy,gr,bev=.05)
  for yy in [-.3,0,.3]:box('Turret cooling slit',(x+sx*width*.54,y+yy,z+height*.52),(.03,.055,height*.26),carbon,gr,bev=.008)
 box('Armoured sight hood',(x,y-depth*.06,z+height+.03),(.48,.55,.18),carbon,gr,bev=.045)
 offsets=[-.32,.32] if twin else [0]
 tips=[]
 for off in offsets:
  a=(x+off,ygun,zgun);end=(x+off,ygun-barrel_length,zgun)
  cyl('Gun receiver',(a[0],ygun+.20,zgun),(a[0],ygun-.65,zgun),.18,carbon,gun,N=32)
  cyl('Gun barrel',(a[0],ygun-.55,zgun),end,.075,alloy,gun,N=32,bev=.01)
  tube('Perforated barrel shroud',(a[0],ygun-.85,zgun),(0,-1,0),[(0,.15),(.65,.15),(.65,.105),(0,.105)],carbon,gun,N=32)
  for kk in range(5):
   yy=ygun-.9-kk*.12
   for sx in [-1,1]:cyl('Shroud cooling inset',(a[0]+sx*.148,yy,zgun),(a[0]+sx*.158,yy,zgun),.032,alloy,gun,N=10,bev=.002)
  tube('Muzzle brake',end,(0,1,0),[(0,.12),(.32,.12),(.32,.065),(0,.065)],carbon,gun,N=32)
  tips.append(end)
 PIVOTS[gr]=(pivot,(0,1,0),'Hull');PIVOTS[gun]=((x,ygun,zgun),(-1 if idx==1 else 1,0,0),gr)
 return tips
TIPS1=turret(1,(0,.3,2.08),2.1,2.0,.93,2.7,True)
TIPS2=turret(2,(0,-9.2,-2.05),1.25,1.40,.65,2.85,False)
# Chin turret normally below hull: its armour extends upward; barrel lives under the nose.
# Additional restrained construction details, flush inspection panels and tie-down fittings.
for sign in [-1,1]:
 for y in [-.8,5.8,8.2]:
  x=sign*2.32;z=1.89 if y<3 else 1.77
  box('Flush roof inspection panel',(x,y,z),(.72,1.1,.075),paint,bev=.045)
  box('Recessed latch',(x,y-.28,z+.044),(.22,.10,.025),carbon,bev=.018)
  for xx in [-.25,.25]:
   for yy in [-.4,.4]:cyl('Panel screw',(x+xx,y+yy,z+.02),(x+xx,y+yy,z+.06),.03,alloy,N=8,bev=.002)
 for yy in [-4.6,7.9]:
  x=sign*2.1;z=hull_at(yy)[2]+.085
  for xx in [x-.16,x+.16]:
   box('Tie down foot',(xx,yy,z),(.10,.13,.08),carbon,bev=.02)
   beam('Tie down handle',(xx,yy,z),(xx,yy,z+.16),.026,alloy,N=8)
  beam('Tie down handle',(x-.16,yy,z+.16),(x+.16,yy,z+.16),.026,alloy,N=8)
 # Belly longitudinal conduit with clamps, attached to bay surround.
 for yy in np.arange(-4,7,1.3):
  beam('Belly hydraulic conduit',(sign*2.55,yy,-1.73),(sign*2.55,yy+1.25,-1.73),.035,alloy,N=10)
  box('Conduit clamp',(sign*2.55,yy,-1.74),(.18,.10,.13),carbon,bev=.02)
# Staggered side applique, angled access hatches and flush attachment hardware.
for sign in [-1,1]:
 for yc,span in [(-6.1,2.35),(-.10,2.65),(6.25,2.25)]:
  ya=yc-span/2;yb=yc+span/2
  poly=[(ya,-.67),(yb-.22,-.67),(yb,-.43),(yb,.43),(yb-.26,.63),(ya+.18,.63),(ya,.43)]
  pts=[(sign*(hull_at(y)[0]+.105),y,z) for y,z in poly]
  plate('Stepped side applique',pts,.055,paint,bev=.022)
  for y,z in [(ya+.21,-.48),(yb-.27,-.48),(ya+.23,.43),(yb-.3,.39)]:
   x=sign*(hull_at(y)[0]+.155)
   cyl('Flush armor attachment',(x,y,z),(x+sign*.025,y,z),.032,alloy,N=8,bev=.003)
  x=sign*(hull_at(yc)[0]+.15)
  box('Recessed access lever',(x,yc+.4,.32),(.04,.34,.07),carbon,bev=.025)
# Structured aft deck: low trapezoid service housings and paired louver banks.
for sign in [-1,1]:
 for yc in [4.5,7.4]:
  pts=[(sign*1.3,yc-.65,1.90),(sign*2.65,yc-.65,1.90),(sign*2.65,yc+.65,1.79),(sign*1.3,yc+.65,1.79)]
  plate('Aft deck service module',pts,.14,paint,bev=.04)
  for yy in np.linspace(yc-.45,yc+.4,6):
   zz=1.98-(yy-(yc-.65))*.085
   beam('Aft deck cooling louver',(sign*1.55,yy,zz),(sign*2.42,yy,zz),.032,carbon,N=8)
# Beacon lens atop rear spine, one shared emissive material.
ob=cyl('Beacon lens',(0,7.2,1.95),(0,7.2,2.07),.085,lamps,N=20)
for uv in ob.data.uv_layers.active.data:uv.uv=(.875,.5)
print('GEOMETRY_BUILT',sum(len(v) for v in GROUPS.values()),flush=True)

# Join only rigid assemblies. Move vertex coordinates to the pivot without applying
# a moving node's transforms. Child local origins remain exact mechanical pivots.
OBJS={}
for name,parts in GROUPS.items():
 bpy.ops.object.select_all(action='DESELECT')
 for ob in parts:ob.select_set(True)
 bpy.context.view_layer.objects.active=parts[0];bpy.ops.object.join();ob=bpy.context.object;ob.name=name
 pivot=Vector(PIVOTS[name][0]) if name in PIVOTS else Vector((0,0,0))
 ob.data.transform(Matrix.Translation(-pivot));ob.location=pivot;OBJS[name]=ob;ASSET.append(ob)
 # recalculate normals and eliminate only true zero-area slivers before export
 bm=bmesh.new();bm.from_mesh(ob.data)
 bmesh.ops.triangulate(bm,faces=list(bm.faces))
 bad=[f for f in bm.faces if f.calc_area()<1e-8]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(ob.data);bm.free();ob.data.update()
for name,(pivot,axis,parent) in PIVOTS.items():
 ob=OBJS[name];par=OBJS[parent];ob.parent=par;ob.matrix_parent_inverse=Matrix.Identity(4);ob.location=Vector(pivot)-par.matrix_world.translation
bpy.context.view_layer.update()
# Normalize dimensions by editing mesh coordinates and pivot locations, not node transforms.
# Hinge axes all align with principal axes; this preserves exact shared pivot positions.
pts=[o.matrix_world@v.co for o in OBJS.values() for v in o.data.vertices]
lo=Vector(tuple(min(p[i] for p in pts) for i in range(3)));hi=Vector(tuple(max(p[i] for p in pts) for i in range(3)))
scale=Vector((16/(hi.x-lo.x),24/(hi.y-lo.y),5.5/(hi.z-lo.z)))
center=(lo+hi)/2;center.x=0
D=Matrix.Diagonal((*scale,1))
for ob in OBJS.values():ob.data.transform(D)
for name,ob in OBJS.items():
 if ob.parent:ob.location=Vector([ob.location[i]*scale[i] for i in range(3)])
 else:ob.location=Vector([(ob.location[i]-center[i])*scale[i] for i in range(3)])
# Put the Hull origin exactly at the asset COM frame without applying any moving transforms.
hull=OBJS['Hull'];offset=hull.location.copy();hull.data.transform(Matrix.Translation(offset));hull.location=(0,0,0)
for child in hull.children:child.location+=offset
bpy.context.view_layer.update()
def wp(p):return tuple((Vector(p)-center)[i]*scale[i] for i in range(3))
for e,x in enumerate([-2.58,0,2.58],1):socket('Thruster_'+str(e),wp((x,11.96,0)),OBJS['Hull'])
for i,p in enumerate(TIPS1,1):socket('Muzzle_'+str(i),wp(p),OBJS['Gun_1'])
socket('Muzzle_3',wp(TIPS2[0]),OBJS['Gun_2'])
for sign in [1,-1]:
 for j,x in enumerate([5.15,6.7]):socket('Hardpoint_'+str((0 if sign==1 else 2)+j+1),wp((sign*x,3.8+j*.35,-.76)),OBJS['Hull'])
for row,y in enumerate([-3.2,1.1,5.1]):
 for side,x in enumerate([.74,-.74]):socket('Hardpoint_'+str(5+row*2+side),wp((x,y,-1.95)),OBJS['Hull'])
socket('Seat_Pilot',wp((0,-7.9,1.05)),OBJS['Hull']);socket('Seat_Copilot',wp((0,-5.85,1.3)),OBJS['Hull'])
socket('Camera_Chase',(0,21,8),OBJS['Hull'])
socket('Light_Nav_L',wp((8,5.15,-.16)),OBJS['Hull']);socket('Light_Nav_R',wp((-8,5.15,-.16)),OBJS['Hull']);socket('Light_Beacon',wp((0,7.2,2.07)),OBJS['Hull'])

# Non-exported photographic studio lights in open space. World camera background
# is a faint blue gradient; non-camera world rays provide soft neutral daylight.
world=bpy.data.worlds.new('Space gradient');S.world=world;world.use_nodes=True
n=world.node_tree.nodes;l=world.node_tree.links;n.clear();out=n.new('ShaderNodeOutputWorld');mix=n.new('ShaderNodeMixShader');lp=n.new('ShaderNodeLightPath');bg=n.new('ShaderNodeBackground');bg.inputs[0].default_value=(.18,.21,.27,1);bg.inputs[1].default_value=.38
sky=n.new('ShaderNodeBackground');tc=n.new('ShaderNodeTexCoord');sep=n.new('ShaderNodeSeparateXYZ');ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.0;ramp.color_ramp.elements[0].color=(.003,.005,.009,1);ramp.color_ramp.elements[1].position=.75;ramp.color_ramp.elements[1].color=(.018,.029,.052,1)
l.new(tc.outputs['Normal'],sep.inputs[0]);l.new(sep.outputs['Z'],ramp.inputs[0]);l.new(ramp.outputs[0],sky.inputs[0]);l.new(lp.outputs['Is Camera Ray'],mix.inputs[0]);l.new(bg.outputs[0],mix.inputs[1]);l.new(sky.outputs[0],mix.inputs[2]);l.new(mix.outputs[0],out.inputs[0])
def area(name,pos,power,size,color):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=color;o=bpy.data.objects.new(name,d);S.collection.objects.link(o);o.location=pos;o.rotation_euler=(-o.location).to_track_quat('-Z','Y').to_euler()
area('White upper front left key',(5,-12,19),8200,10,(1,.96,.90));area('Soft cool fill',(-12,-4,8),3800,13,(.64,.79,1));area('Aft edge light',(4,13,12),6200,9,(.76,.87,1));area('Belly bounce',(2,-4,-9),4200,10,(.7,.8,1));area('Camera-side broad fill',(17,-10,6),5000,12,(.9,.94,1))
camd=bpy.data.cameras.new('Render camera');cam=bpy.data.objects.new('Render camera',camd);S.collection.objects.link(cam);S.camera=cam;camd.type='ORTHO';camd.lens=50
HERO=(28,-35,22)
def camera(pos,ortho=30):
 cam.location=pos;cam.rotation_euler=(Vector((0,0,0))-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=ortho
camera(HERO,29.8)
def render(path,w=1920,h=1080,samples=32):
 S.render.resolution_x=w;S.render.resolution_y=h;S.cycles.samples=samples;S.render.filepath=str(ROOT/path);bpy.ops.render.render(write_still=True);print('RENDERED',path,flush=True)
clay=mat('QA clay',(.32,.34,.37),.0,.6)
S.view_layers[0].material_override=clay
render('clay.png',1920,1080,20)
S.view_layers[0].material_override=None
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'geometry.blend'))

# Bake every textured surface from registered CC0 scans plus procedural fine wear.
# Shared 2m tiles preserve close viewing density across the entire ship.
def download(id,map):
 fn=f'{id}_{map}_1k.jpg';path=TEX/fn
 if not path.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{fn}'
  subprocess.run(['curl','-fL','--retry','2','-A','ForgeAssetBuilder/1.0',url,'-o',str(path)],check=True,stdout=subprocess.DEVNULL)
 return bpy.data.images.load(str(path),check_existing=True)

SHARED_NORMALS={}
def bake_surface(ma,id,tone,metal,rough):
 print('BAKE_START',ma.name,flush=True)
 src=bpy.data.materials.new(ma.name+'_bake_source');src.use_nodes=True;nt=src.node_tree;n=nt.nodes;l=nt.links;n.clear()
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 tc=n.new('ShaderNodeTexCoord');diff=n.new('ShaderNodeTexImage');diff.image=download(id,'diff');l.new(tc.outputs['UV'],diff.inputs[0])
 norm=n.new('ShaderNodeTexImage');norm.image=download(id,'nor_gl');norm.image.colorspace_settings.name='Non-Color';l.new(tc.outputs['UV'],norm.inputs[0])
 arm=n.new('ShaderNodeTexImage');arm.image=download(id,'arm');arm.image.colorspace_settings.name='Non-Color';l.new(tc.outputs['UV'],arm.inputs[0])
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs['Color'],sep.inputs[0])
 bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs[0],bw.inputs[0])
 ra=n.new('ShaderNodeValToRGB');ra.color_ramp.elements[0].position=.02;ra.color_ramp.elements[1].position=.16
 if ma==paint:
  ra.color_ramp.elements[0].color=(.40,.40,.40,1);ra.color_ramp.elements[1].color=(.95,.95,.95,1)
 else:
  ra.color_ramp.elements[0].color=(*[x*.55 for x in tone],1);ra.color_ramp.elements[1].color=(*[x*1.35 for x in tone],1)
 l.new(bw.outputs[0],ra.inputs[0])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=95;noise.inputs['Detail'].default_value=3;l.new(tc.outputs['UV'],noise.inputs[0])
 nr=n.new('ShaderNodeMapRange');nr.inputs['To Min'].default_value=.76;nr.inputs['To Max'].default_value=1;l.new(noise.outputs['Fac'],nr.inputs[0])
 mul=n.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.32;l.new(ra.outputs[0],mul.inputs[1]);l.new(nr.outputs[0],mul.inputs[2]);l.new(mul.outputs[0],bs.inputs['Base Color'])
 # Patchy neutral grime and fine micrometeorite pitting, baked into the common tile.
 grime=n.new('ShaderNodeTexNoise');grime.inputs['Scale'].default_value=5;grime.inputs['Detail'].default_value=4;l.new(tc.outputs['UV'],grime.inputs[0])
 gr=n.new('ShaderNodeMapRange');gr.inputs['To Min'].default_value=.48;gr.inputs['To Max'].default_value=1;l.new(grime.outputs['Fac'],gr.inputs[0])
 weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.42;l.new(mul.outputs[0],weather.inputs[1]);l.new(gr.outputs[0],weather.inputs[2]);l.new(weather.outputs[0],bs.inputs['Base Color'])
 pits=n.new('ShaderNodeTexVoronoi');pits.inputs['Scale'].default_value=110;l.new(tc.outputs['UV'],pits.inputs['Vector'])
 pitmask=n.new('ShaderNodeValToRGB');pitmask.color_ramp.elements[0].position=.027;pitmask.color_ramp.elements[0].color=(.22,.22,.22,1);pitmask.color_ramp.elements[1].position=.060;pitmask.color_ramp.elements[1].color=(1,1,1,1);l.new(pits.outputs['Distance'],pitmask.inputs[0])
 micro=n.new('ShaderNodeMixRGB');micro.blend_type='MULTIPLY';micro.inputs[0].default_value=.8;l.new(weather.outputs[0],micro.inputs[1]);l.new(pitmask.outputs[0],micro.inputs[2]);l.new(micro.outputs[0],bs.inputs['Base Color'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.28;l.new(norm.outputs[0],nm.inputs['Color'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.12;bump.inputs['Distance'].default_value=.004;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 rr=n.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=rough-.13;rr.inputs['To Max'].default_value=rough+.16;l.new(sep.outputs['Green'],rr.inputs[0]);l.new(rr.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,35));ob=bpy.context.object;ob.name='_BakeSurface';ob.data.materials.append(src)
 bake=n.new('ShaderNodeTexImage');n.active=bake
 def pass_image(name,res,typ,channel=None):
  img=bpy.data.images.new(ma.name+'_'+name,res,res,alpha=False);img.colorspace_settings.name='sRGB' if name=='BaseColor' else 'Non-Color';bake.image=img;n.active=bake
  if channel is not None:
   em=n.new('ShaderNodeEmission');l.new(channel,em.inputs[0]);l.new(em.outputs[0],out.inputs[0])
  bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);bpy.context.view_layer.objects.active=ob;S.cycles.samples=12;S.render.bake.margin=8;S.render.bake.use_selected_to_active=False
  bpy.ops.object.bake(type=typ,pass_filter={'COLOR'} if typ=='DIFFUSE' else set())
  if channel is not None:l.new(bs.outputs[0],out.inputs[0])
  img.filepath_raw=str(OUT/(img.name+('.jpg' if name=='BaseColor' else '.png')));img.file_format='JPEG' if name=='BaseColor' else 'PNG';img.save();return img
 base=pass_image('BaseColor',1024,'EMIT',micro.outputs[0])
 normal=pass_image('Normal',1024,'NORMAL')
 if id in SHARED_NORMALS:normal=SHARED_NORMALS[id]
 else:SHARED_NORMALS[id]=normal
 # Pack true channels in the node graph, then Cycles EMIT bake to linear ORM.
 comb=n.new('ShaderNodeCombineColor');comb.mode='RGB';l.new(sep.outputs['Red'],comb.inputs[0]);l.new(rr.outputs[0],comb.inputs[1]);comb.inputs[2].default_value=metal
 orm=pass_image('ORM',512,'EMIT',comb.outputs[0])
 bpy.data.objects.remove(ob,do_unlink=True)
 # Portable PBR graph with glTF-compatible normal + packed occlusion/roughness/metallic.
 nt=ma.node_tree;n=nt.nodes;l=nt.links;n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 bt=n.new('ShaderNodeTexImage');bt.name='Baked_BaseColor';bt.image=base
 if ma==paint:
  factor=n.new('ShaderNodeMixRGB');factor.name='Faction colour';factor.blend_type='MULTIPLY';factor.inputs[0].default_value=1;factor.inputs[2].default_value=(*PAINTS[0][1],1);l.new(bt.outputs[0],factor.inputs[1]);l.new(factor.outputs[0],bs.inputs['Base Color'])
 else:l.new(bt.outputs[0],bs.inputs['Base Color'])
 no=n.new('ShaderNodeTexImage');no.image=normal;nm=n.new('ShaderNodeNormalMap');l.new(no.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 ot=n.new('ShaderNodeTexImage');ot.image=orm;sp=n.new('ShaderNodeSeparateColor');l.new(ot.outputs[0],sp.inputs[0]);l.new(sp.outputs['Green'],bs.inputs['Roughness']);l.new(sp.outputs['Blue'],bs.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=n.new('ShaderNodeGroup');g.node_tree=group;l.new(sp.outputs['Red'],g.inputs['Occlusion'])
 print('BAKE_DONE',ma.name,flush=True)
for ma,id,tone,met,rough in [(paint,'blue_metal_plate',(.8,.8,.8),.42,.48),(alloy,'metal_plate_02',(.24,.26,.28),.84,.39),(carbon,'metal_plate_02',(.035,.042,.05),.68,.46)]:bake_surface(ma,id,tone,met,rough)

# Export only gameplay nodes. Force direct texture path for exporter and patch
# baseColorFactor (glTF's faction colour multiplier), then restore render graph.
bpy.ops.object.select_all(action='DESELECT')
for o in ASSET:o.select_set(True)
nt=paint.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');bt=nt.nodes['Baked_BaseColor'];fac=nt.nodes['Faction colour'];nt.links.new(bt.outputs[0],bs.inputs['Base Color'])
glb=ROOT/'heavy-bomber-opera.glb'
bpy.ops.export_scene.gltf(filepath=str(glb),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_animations=False,export_cameras=False,export_lights=False,export_extras=True,export_image_format='AUTO')
raw=glb.read_bytes();jlen=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jlen]);binary=raw[20+jlen:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[*PAINTS[0][1],1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
glb.write_bytes(struct.pack('<III',0x46546c67,2,12+8+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+binary)
nt.links.new(fac.outputs[0],bs.inputs['Base Color'])
tris=sum(len(p.vertices)-2 for o in OBJS.values() for p in o.data.polygons)
assert 50000 <= tris <= 110000, f'Triangle budget: {tris}'
assert glb.stat().st_size <= 12_000_000, 'GLB size budget exceeded'
print('EXPORTED',tris,'triangles',glb.stat().st_size,'bytes',flush=True)
# Metadata in the delivered glTF frame (X left, Y up, Z nose).
def gltf(v):return [round(float(v[0]),6),round(float(v[2]),6),round(float(-v[1]),6)]
bpy.context.view_layer.update();pts=[o.matrix_world@v.co for o in OBJS.values() for v in o.data.vertices];bbmin=[min(p[i] for p in pts) for i in range(3)];bbmax=[max(p[i] for p in pts) for i in range(3)]
metadata={'asset':'heavy-bomber-opera','units':'metres','forward':'+Z','up':'+Y','left':'+X','bounding_box':{'min':[bbmin[0],bbmin[2],-bbmax[1]],'max':[bbmax[0],bbmax[2],-bbmin[1]],'dimensions':[16,5.5,24]},'triangles':tris,'materials':[m['name'] for m in doc['materials']],'file_bytes':glb.stat().st_size,'axes':{k:list(v[1]) for k,v in PIVOTS.items()},'paints':[{'name':n,'linear_rgb':v} for n,v in PAINTS],'nodes':{o.name:{'world_position':gltf(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty'} for o in ASSET}}
(ROOT/'nodes.json').write_text(json.dumps(metadata,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(["Original heavy space-opera assault bomber; 24 × 16 × 5.5 m; closed flight configuration, articulated turrets, canopy and bay doors.",f"Triangles: {tris:,}; GLB: {glb.stat().st_size/1048576:.2f} MiB; rigid assemblies joined; no lights or cameras exported.","Materials: Paint, Alloy, Carbon, Glass, Engines, Lamps; Paint neutral texture × linear gunmetal [0.20, 0.22, 0.24].","Textures: Cycles-baked 1024² base colours and two shared normals; 512² ORM; 16×4 lamp palette; 2 m physical tiling.","Poly Haven CC0 IDs: metal_plate_02, blue_metal_plate; https://polyhaven.com/a/metal_plate_02 ; https://polyhaven.com/a/blue_metal_plate ; licence https://polyhaven.com/license .","Reference differences: original staggered armour layout, blunter twin-grille prow, revised canopy and nozzle petals; no lettering or insignia."])+ '\n')
# Production renders.
camera(HERO,29.8);render('final.png',1920,1080,48)
camera((32,0,1.5),28);render('side.png',1920,1080,28)
camera((23,33,16),29.8);render('rear.png',1440,900,24)
# Mechanical check: turn each moving part 30 degrees about its declared pivot.
base_locs={name:OBJS[name].location.copy() for name in PIVOTS}
for name,(_,axis,_) in PIVOTS.items():
 # Convert the metadata's glTF parent-space axis back to Blender coordinates.
 axis_blender=Vector((axis[0],-axis[2],axis[1]))
 OBJS[name].rotation_euler=Quaternion(axis_blender,math.radians(30)).to_euler()
for name in PIVOTS:assert (OBJS[name].location-base_locs[name]).length<1e-8
camera((25,-30,-18),30);render('articulation.png',1440,900,24)
camera(HERO,29.8);render('articulation_top.png',1440,900,24)
for name in PIVOTS:OBJS[name].rotation_euler=(0,0,0)
camera(HERO,29.8)
# Build six images into one 3x2 contact sheet without a dependency on Pillow.
frames=[]
for name,col in PAINTS:
 fac.inputs[2].default_value=(*col,1);render('_paint_'+name.replace(' ','_')+'.png',640,360,16)
 im=bpy.data.images.load(str(ROOT/('_paint_'+name.replace(' ','_')+'.png')),check_existing=False)
 frames.append(np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,pixels in enumerate(frames):canvas[(1-i//3)*360:(2-i//3)*360,(i%3)*640:(i%3+1)*640]=pixels
im=bpy.data.images.new('Six faction paints',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
fac.inputs[2].default_value=(*PAINTS[0][1],1)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'heavy-bomber-opera.blend'))
print('BUILD_COMPLETE',flush=True)
