#!/usr/bin/env python3
"""Stealth interceptor. Clean, deterministic Blender 5.2 build in metres.
Run: blender -b --python-exit-code 1 --python build_stealth-interceptor.py
Only photographic inputs: CC0 Poly Haven blue_metal_plate (diff/nor_gl/arm).
The script downloads source scans, Cycles-bakes shared tiles, builds, validates,
exports Y-up GLB, tests moving pivots and renders all requested views.
"""
import bpy, bmesh, math, os, json, random, struct, urllib.request, subprocess
from pathlib import Path
from mathutils import Vector, Matrix
from mathutils.bvhtree import BVHTree
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
QA=ROOT/'qa'; QA.mkdir(exist_ok=True)
random.seed(26)
bpy.ops.wm.read_factory_settings(use_empty=True)
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
S.cycles.max_bounces=8;S.cycles.transmission_bounces=6
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'
S.cycles.device='GPU'
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.view_settings.exposure=-0.75
S.render.resolution_percentage=100
M={};GROUPS={'Hull':[],'Canopy':[],'BayDoor_L':[],'BayDoor_R':[]}
PAINTS=[('Obsidian',[.04,.04,.045]),('Naval grey',[.19,.23,.28]),('Olive drab',[.12,.15,.075]),('Desert sand',[.49,.38,.23]),('Arctic white',[.74,.79,.82]),('Deep crimson',[.22,.028,.035])]

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,metal=0,rough=.5,emission=0,alpha=1):
 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,alpha)
 p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
 if emission:p.inputs['Emission Color'].default_value=(*color,1);p.inputs['Emission Strength'].default_value=emission
 p.inputs['Alpha'].default_value=alpha
 if alpha<1:
  m.surface_render_method='DITHERED';p.inputs['Transmission Weight'].default_value=.12;p.inputs['IOR'].default_value=1.45
 M[name]=m;return m

material('Paint',(.04,.04,.045),.28,.64)
material('Alloy',(.19,.22,.26),.85,.38)
material('Ceramic',(.025,.029,.034),.5,.72)
material('Interior',(.034,.042,.049),.12,.76)
material('Glass',(.035,.060,.085),0,.20,alpha=.48)
material('Engines',(1,.12,.032),.2,.38,emission=2.4)
material('Lamps',(.8,.018,.009),.25,.36,emission=1.6)

# Source retrieval follows Poly Haven's public CC0 distribution URL exactly.
def download():
 for mp in ['diff','nor_gl','arm']:
  path=TEX/f'blue_metal_plate_{mp}_1k.jpg'
  if not path.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/blue_metal_plate/blue_metal_plate_{mp}_1k.jpg'
   subprocess.run(['curl','--fail','--retry','3','-L','-A','Mozilla/5.0',url,'-o',str(path)],check=True)
  assert path.stat().st_size>10000

# UVs carry real-scale tiling. Large surfaces never depend on a unique giant atlas.
def uv_project(o,scale=1.8):
 me=o.data;uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers[0]
 for p in me.polygons:
  n=p.normal;drop=max(range(3),key=lambda i:abs(n[i]));axes=[i for i in range(3) if i!=drop]
  for li in p.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co
   uv.data[li].uv=(co[axes[0]]/scale,co[axes[1]]/scale)

def bevel(o,w=.012,segs=3):
 if not w:return
 segs=2 if w>=.010 else 1
 activate(o);mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=w;mod.segments=segs
 mod.limit_method='ANGLE';mod.angle_limit=.10
 bpy.ops.object.modifier_apply(modifier=mod.name)
 # broad planes stay planar; only the curved chamfer polygons interpolate.
 for p in o.data.polygons:p.use_smooth=p.area<.012
 try:
  m=o.modifiers.new('Weighted surface normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=40
  bpy.ops.object.modifier_apply(modifier=m.name)
 except:pass

def mesh(name,verts,faces,mat='Paint',group='Hull',bev=0):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);me.materials.append(M[mat])
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
 bevel(o,bev);uv_project(o)
 if mat=='Lamps':
  u=.8 if any(t in name.lower() for t in ['console','instrument']) else .25
  for d in o.data.uv_layers[0].data:d.uv=(u,.5)
 if group:GROUPS[group].append(o)
 return o

def prism(name,points,depth=.025,mat='Paint',group='Hull',bev=.008,normal=None):
 v=[Vector(p) for p in points]
 n=Vector(normal) if normal else (v[1]-v[0]).cross(v[2]-v[0]).normalized()
 # Caller supplies outward face winding, or explicit outward normal.
 verts=[tuple(p) for p in v]+[tuple(p-n*depth) for p in v];l=len(v)
 faces=[tuple(range(l)),tuple(reversed(range(l,2*l)))]+[(i,(i+1)%l,(i+1)%l+l,i+l) for i in range(l)]
 return mesh(name,verts,faces,mat,group,bev)

def box(name,loc,dims,mat='Paint',group='Hull',bev=.012,rot=None):
 x,y,z=[d/2 for d in dims]
 verts=[(a*x,b*y,c*z) for a,b,c 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)]]
 if rot is None and 'SURFACE' in globals() and any(t in name.lower() for t in ['sensor','optical aperture','thermal radiator','rcs','nav light','navigation lens','beacon']):
  rot=surface_normal(loc[0],loc[1]).to_track_quat('Z','Y').to_matrix()
 R=Matrix.Identity(3) if rot is None else rot
 verts=[tuple(R@Vector(v)+Vector(loc)) for v in verts]
 return mesh(name,verts,[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)],mat,group,bev)

def beam(name,a,b,width=.028,depth=None,mat='Alloy',group='Hull',bev=.004):
 a,b=Vector(a),Vector(b);v=b-a
 return box(name,(a+b)/2,(width,depth or width,v.length),mat,group,bev,v.to_track_quat('Z','Y').to_matrix())

def cyl(name,loc,radius,depth,mat='Alloy',group='Hull',axis=(0,0,1),vertices=16,bev=.002):
 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=radius,depth=depth,location=loc)
 o=bpy.context.object;o.name=name;o.rotation_euler=Vector(axis).to_track_quat('Z','Y').to_euler()
 activate(o);bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
 o.data.materials.append(M[mat]);bevel(o,bev,2);uv_project(o,.4)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 if group:GROUPS[group].append(o)
 return o

def fastener(p,n=(0,0,1),r=.020,group='Hull'):
 p=Vector(p);n=Vector(n)
 if tuple(n)==(0,0,1) and 'SURFACE' in globals() and p.z>0:n=surface_normal(p.x,p.y)
 cyl('Captive titanium fastener',p,r,.009,'Alloy',group,n,12,.002)
 # dark hex drive, genuinely recessed into its head
 cyl('Hex recess',p+n*.0055,r*.41,.0015,'Ceramic',group,n,6,0)

def inset(points,amount=.018):
 v=[Vector(p) for p in points];c=sum(v,Vector())/len(v)
 return [tuple(p+(c-p).normalized()*amount) for p in v]

# y runs nose to tail; glTF conversion sends nose to +Z, Z to +Y.
# y, width, crown halfwidth, shoulder halfwidth, crown z, shoulder z, edge z, belly z
SECTIONS=[(-7,.24,.12,.19,.02,.0,-.015,-.07),(-4.7,1.43,.52,.78,.53,.37,-.015,-.46),(-1.2,3.25,.78,1.20,1.55,1.12,0,-1.00),(-.30,3.72,.82,1.30,1.55,1.10,0,-1.00),(1.70,4.75,1.08,1.75,1.29,.92,0,-1.00),(4.90,3.30,1.13,1.73,.81,.63,0,-1.00),(7,2.35,1.05,1.62,.23,.18,-.035,-.39)]

def ring(s):
 y,w,c,sh,top,zsh,e,bot=s
 return [(c,y,top),(sh,y,zsh),(w,y,e),(w,y,e-.07),(sh,y,bot*.79),(c,y,bot),(-c,y,bot),(-sh,y,bot*.79),(-w,y,e-.07),(-w,y,e),(-sh,y,zsh),(-c,y,top)]
RINGS=[ring(s) for s in SECTIONS]
# Closed underlying pressure hull, triangulated before robust aperture cuts.
verts=[v for r in RINGS for v in r];faces=[]
for j in range(len(RINGS)-1):
 for k in range(12):
  # Build a closed pressure vessel before all aperture booleans.
  # bottom weapon bays are created by a boolean through this shell below
  faces.append((j*12+k,j*12+(k+1)%12,(j+1)*12+(k+1)%12,(j+1)*12+k))
faces.extend([tuple(reversed(range(12))),tuple((len(RINGS)-1)*12+k for k in range(12))])
hull=mesh('Pressure hull',verts,faces,'Paint','Hull',.014)
# Non-planar loft quads must be triangulated before exact CSG. An untriangulated
# beveled lateral cutter can otherwise silently erase the pressure vessel.
bm=bmesh.new();bm.from_mesh(hull.data)
bmesh.ops.triangulate(bm,faces=list(bm.faces),quad_method='BEAUTY',ngon_method='BEAUTY')
bm.to_mesh(hull.data);bm.free()
def validate_pressure_hull():
 bm=bmesh.new();bm.from_mesh(hull.data)
 volume=bm.calc_volume(signed=True);open_edges=sum(not e.is_manifold for e in bm.edges);bm.free()
 assert volume>70 and open_edges==0,('Invalid pressure hull',volume,open_edges)
 print('PRESSURE_HULL_VOLUME',round(volume,3),flush=True)
validate_pressure_hull()
# A closed-bottom cockpit well preserves the uninterrupted ventral shell.
# Boolean operations on an open shell otherwise produce spurious underside caps.
xy=[(-.78,-4.7),(.78,-4.7),(1.20,-1.2),(1.30,-.3),(-1.30,-.3),(-1.20,-1.2)]
cut=prism('Cockpit well cutter',[(x,y,3) for x,y in xy],3.15,'Interior',None,0,normal=(0,0,1))
activate(hull);md=hull.modifiers.new('Sealed cockpit well','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cut
bpy.ops.object.modifier_apply(modifier=md.name);validate_pressure_hull();bpy.data.objects.remove(cut,do_unlink=True)
# Cut real exhaust passages through the aft pressure shell.
for sign in [-1,1]:
 cut=box('Exhaust duct cutter',(sign*1.42,6.70,-.12),(1.34,1.50,.31),'Ceramic',None,0)
 activate(hull);md=hull.modifiers.new('Buried engine duct','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cut
 bpy.ops.object.modifier_apply(modifier=md.name);validate_pressure_hull();bpy.data.objects.remove(cut,do_unlink=True)

# The center tail notch breaks up the aft plane, leaving twin buried shoulders.
cut=prism('Central tail relief cutter',[(-.40,6.68,2),(.40,6.68,2),(.57,7.2,2),(-.57,7.2,2)],4,'Ceramic',None,0,normal=(0,0,1))
activate(hull);md=hull.modifiers.new('Central trailing notch','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cut
bpy.ops.object.modifier_apply(modifier=md.name);validate_pressure_hull();bpy.data.objects.remove(cut,do_unlink=True)
# The outer ends of the buried slots remain visible in the side silhouette.
for sign in [-1,1]:
 cut=box('Lateral exhaust aperture',(sign*2.72,6.25,-.12),(1.50,1.90,.29),'Ceramic',None,0)
 activate(hull);md=hull.modifiers.new('Lateral engine slot','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cut
 bpy.ops.object.modifier_apply(modifier=md.name);validate_pressure_hull();bpy.data.objects.remove(cut,do_unlink=True)

# Matching chamfered apertures and flush closed doors in the flat central belly.
for sign,label in [(1,'L'),(-1,'R')]:
 xy=[(sign*.20,-.96),(sign*.30,-1.00),(sign*.78,-.78),(sign*.78,2.05),(sign*.20,2.17)]
 c=np.mean(np.array(xy),axis=0)
 aperture=[tuple(c+(np.array(p)-c)*1.014) for p in xy]
 cut=prism('Bay aperture cutter',[(x,y,-.68) for x,y in aperture],.65,'Ceramic',None,0,normal=(0,0,1))
 activate(hull);md=hull.modifiers.new('Internal bay aperture','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cut
 bpy.ops.object.modifier_apply(modifier=md.name);validate_pressure_hull();bpy.data.objects.remove(cut,do_unlink=True)
 box('Bay interior',(sign*.49,.6,-.71),(.55,3.13,.08),'Interior',bev=.015)
 for dx in [-.28,.28]:box('Bay wall',(sign*.49+dx,.6,-.85),(.025,3.15,.30),'Ceramic',bev=.006)
 box('Internal launch rail',(sign*.49,.6,-.77),(.18,2.75,.06),'Alloy',bev=.008)
 for yy in [-.55,.6,1.65]:beam('Rail lock',(sign*.30,yy,-.78),(sign*.67,yy,-.78),.055,mat='Alloy')
 group='BayDoor_'+label
 prism('Sealed weapon bay door',[(x,y,-1.004) for x,y in xy],.035,'Paint',group,.006,normal=(0,0,-1))
 for yy in [-.58,.0,.65,1.3,1.89]:
  beam('Door internal rib',(sign*.24,yy,-.969),(sign*.72,yy,-.969),.025,mat='Ceramic',group=group)
 for yy in [-.68,1.88]:fastener((sign*.34,yy,-1.007),(0,0,-1),.017,group)
 for yy in [-.62,.50,1.64]:cyl('Concealed bay hinge',(sign*.79,yy,-1.00),.022,.19,'Alloy','Hull',(0,1,0),16,.003)

uv_project(hull)

# Planar skins: true inset expansion joints, not a noisy raised armor stack.
# Each broad quadrant stays clean; detail concentrates on serviceable areas.
for j in range(6):
 for k in [0,1,4,6,9,10,11]:
  if j in [1,2] and k in [0,10,11]:continue
  if k in [4,6] and j in [1,2,3,4]:continue
  p=[RINGS[j][k],RINGS[j][(k+1)%12],RINGS[j+1][(k+1)%12],RINGS[j+1][k]]
  if j==5 and k==11:
   p[2]=tuple(Vector(p[1]).lerp(Vector(p[2]),.83));p[3]=tuple(Vector(p[0]).lerp(Vector(p[3]),.83))
  n=(Vector(p[1])-Vector(p[0])).cross(Vector(p[2])-Vector(p[0])).normalized()
  if (sum(q[2] for q in p)/4>0 and n.z<0) or (sum(q[2] for q in p)/4<-.2 and n.z>0):n=-n
  pts=[tuple(Vector(q)+n*.018) for q in inset(p,.028)]
  prism('Flush radar absorbent panel',pts,.028,'Paint','Hull',.008,n)

# Sample the actual manufactured skin with ray casts so every detail sits on
# the rendered triangles, including the places where adjacent facets change.
_topverts=[];_topfaces=[]
for ob in GROUPS['Hull']:
 start=len(_topverts);_topverts.extend([v.co.copy() for v in ob.data.vertices])
 _topfaces.extend([tuple(start+i for i in f.vertices) for f in ob.data.polygons])
SURFACE=BVHTree.FromPolygons(_topverts,_topfaces,all_triangles=False)
def surface_normal(x,y):
 hit,n,idx,dist=SURFACE.ray_cast(Vector((x,y,10)),Vector((0,0,-1)))
 return n if hit is not None else Vector((0,0,1))

# Surface sampler over a top planar segment, used to anchor EVERY fitting.
def top_z(x,y):
 hit,n,idx,dist=SURFACE.ray_cast(Vector((x,y,10)),Vector((0,0,-1)))
 if hit is not None:return hit.z+.005
 for j in range(len(SECTIONS)-1):
  a,b=SECTIONS[j],SECTIONS[j+1]
  if a[0]-1e-5<=y<=b[0]+1e-5:
   t=(y-a[0])/(b[0]-a[0]);vals=[a[k]*(1-t)+b[k]*t for k in range(8)]
   _,w,c,sh,z,zsh,e,bot=vals;x=abs(x)
   if x<=c:return z+.024
   if x<=sh:return z+(zsh-z)*(x-c)/(sh-c)+.024
   return zsh+(e-zsh)*(x-sh)/(w-sh)+.024
 return .1

def surface_point(x,y,offset=0):return (x,y,top_z(x,y)+offset)
def detail_panel(name,xy,mat='Paint',group='Hull'):
 # Access covers are coplanar hull skin inside a narrow manufactured seam.
 # Trace sampled surface triangles rather than spanning a folded quad.
 if mat=='Ceramic':trace(name,xy+xy[:1],mat='Ceramic',width=.012)
 return None

def trace(name,xy,mat='Lamps',width=.011):
 for a,b in zip(xy,xy[1:]):
  a,b=Vector(a),Vector(b);steps=max(1,math.ceil((b-a).length/.12))
  for k in range(steps):
   p=a.lerp(b,k/steps);q=a.lerp(b,(k+1)/steps)
   zp,zq=top_z(*p),top_z(*q);mid=(p+q)/2
   # Stop at expansion grooves rather than bridge a recessed joint in mid-air.
   if abs(top_z(*mid)-(zp+zq)/2)>.004:continue
   beam(name,(p.x,p.y,zp-.006),(q.x,q.y,zq-.006),width,width*.40,mat,bev=.001)

# A few long controlled red seams track actual breaks in the diamond planes.
for sign in [-1,1]:
 trace('Embedded red facet conductor',[(sign*1.04,-3.5),(sign*1.49,-1.1),(sign*1.57,-.2),(sign*2.02,1.7),(sign*1.94,4.9),(sign*1.78,6.35)])
 trace('Outer diagonal accent',[(sign*2.25,-.9),(sign*3.91,1.75),(sign*3.08,4.55)],width=.008)
 # polygon access hatch, narrow dark seal, flush second inner panel
 for y0,y1,x0,x1 in [(-3.9,-2.75,.91,1.33),(.15,1.43,1.94,2.65),(2.15,3.80,1.95,2.63),(4.9,5.8,1.95,2.40)]:
  xy=[(sign*x0,y0),(sign*(x1-.12),y0+.13),(sign*x1,y1-.08),(sign*(x0+.05),y1)]
  detail_panel('Maintenance recess seal',xy,'Ceramic')
  c=np.mean(np.array(xy),axis=0);inner=[tuple(c+(np.array(p)-c)*.95) for p in xy]
  detail_panel('Flush service hatch',inner,'Paint')
  for x,y in [inner[0],inner[2]]:fastener(surface_point(x,y,.005),r=.017)
 # Leading edge replaceable conductive trim, below the skin edge.
 for j in range(4):
  a=RINGS[j][2 if sign>0 else 9];b=RINGS[j+1][2 if sign>0 else 9]
  beam('Leading edge narrow erosion strip',Vector(a)+Vector((0,0,-.020)),Vector(b)+Vector((0,0,-.020)),.028,.016,'Alloy',bev=.003)
 # Small flush sensor wells on nose, fastened frame, dark optical lens.
 for yy in [-4.2,-3.85]:
  x=sign*.94
  box('Sensor rim',surface_point(x,yy,-.004),(.17,.23,.012),'Alloy',bev=.012)
  box('Black optical aperture',surface_point(x,yy,.004),(.12,.18,.006),'Glass',bev=.008)

# Cockpit is an actual open tub under transparent separate hinged glazing.
box('Cockpit floor',(0,-2.45,.00),(1.25,2.8,.12),'Interior',bev=.025)
for sign in [-1,1]:
 box('Cockpit tub side',(sign*.64,-2.5,.45),(.12,2.50,.73),'Interior',bev=.025)
 box('Side console',(sign*.49,-2.40,.64),(.27,1.45,.18),'Ceramic',bev=.022)
 for yy in [-2.9,-2.62,-2.33,-2.03]:
  box('Illuminated console key',(sign*.5,yy,.737),(.06,.10,.009),'Lamps',bev=.004)
box('Pilot seat cushion',(0,-1.88,.27),(.58,.65,.16),'Interior',bev=.055)
box('Seat shell',(0,-1.43,.65),(.68,.14,.89),'Alloy',bev=.04,rot=Matrix.Rotation(math.radians(-11),3,'X'))
box('Pilot padded back',(0,-1.49,.67),(.54,.15,.70),'Interior',bev=.045,rot=Matrix.Rotation(math.radians(-11),3,'X'))
box('Headrest',(0,-1.35,1.12),(.35,.20,.24),'Interior',bev=.025)
for x in [-.18,.18]:
 beam('Restraint harness',(x,-1.58,.92),(x*.8,-1.78,.31),.055,.018,'Ceramic',bev=.006)
beam('Control stick',(0,-2.52,.16),(0,-2.63,.66),.043,mat='Alloy')
box('Stick grip',(0,-2.63,.67),(.095,.08,.18),'Interior',bev=.025)
box('Instrument binnacle',(0,-3.2,.56),(1.12,.35,.30),'Ceramic',bev=.03)
for x in [-.35,0,.35]:
 box('MFD glass',(x,-3.27,.73),(.28,.22,.018),'Glass',bev=.012)
 for k in range(3):box('Instrument luminous segment',(x-.08+k*.075,-3.28,.743),(.035,.12,.004),'Lamps',bev=.001)
# glazing polys follow original body surface without a bubble canopy
canopy_polys=[]
for j in [1,2]:
 for k in [0,10,11]:
  p=[RINGS[j][k],RINGS[j][(k+1)%12],RINGS[j+1][(k+1)%12],RINGS[j+1][k]]
  canopy_polys.append(p)
  if j==2:
   prism('Faceted canopy rear cap',p,.025,'Paint','Canopy',.006,normal=(0,0,1))
  else:
   prism('Faceted smoked canopy pane',inset(p,.037),.012,'Glass','Canopy',.005,normal=(0,0,1))
  for a,b in zip(p,p[1:]+p[:1]):beam('Canopy bonded frame',a,b,.060,.032,'Paint','Canopy',.006)
# Transverse structural brace lies exactly on the faceted windshield.
for k in [0,10,11]:
 a=Vector(RINGS[1][k]).lerp(Vector(RINGS[2][k]),.68)
 b=Vector(RINGS[1][(k+1)%12]).lerp(Vector(RINGS[2][(k+1)%12]),.68)
 beam('Windshield transverse brace',a,b,.042,.032,'Paint','Canopy',.005)
# split windshield with transverse brace at a true plane location
for sign in [-1,1]:
 a=Vector(RINGS[1][0 if sign>0 else 11]);b=Vector(RINGS[2][0 if sign>0 else 11])
 # long edges backed with weather seals
 beam('Canopy weather seal',a-Vector((0,0,.027)),b-Vector((0,0,.027)),.064,.028,'Ceramic','Hull',.004)
for xx in [-.60,.60]:cyl('Canopy hinge pin',(xx,-.30,1.53),.05,.24,'Alloy','Hull',(1,0,0),24,.005)
# rear cockpit firewall closes the pressure opening
prism('Cockpit rear firewall',[(-1.30,-.27,1.10),(-.82,-.27,1.53),(.82,-.27,1.53),(1.30,-.27,1.10),(1.0,-.27,-.1),(-1.0,-.27,-.1)],.08,'Interior',bev=.014,normal=(0,-1,0))

# Two buried engine tunnels; the flat slot throats sit behind overlapping lips.
# Each engine is an actual tapered octagonal rectangular duct, with cooling fins.
def nozzle_ring(cx,y,w,h,z):
 c=.075
 return [(cx-w/2+c,y,z+h/2),(cx+w/2-c,y,z+h/2),(cx+w/2,y,z+h/2-c),(cx+w/2,y,z-h/2+c),(cx+w/2-c,y,z-h/2),(cx-w/2+c,y,z-h/2),(cx-w/2,y,z-h/2+c),(cx-w/2,y,z+h/2-c)]
for sign,idx in [(1,1),(-1,2)]:
 cx=sign*1.42;front=nozzle_ring(cx,6.15,1.30,.32,-.12);outer=nozzle_ring(cx,7,1.57,.47,-.12)
 inn=nozzle_ring(cx,6.96,1.37,.29,-.12)
 vs=outer+inn+front
 fs=[]
 for k in range(8):
  q=(k+1)%8
  fs.append((k,q,8+q,8+k))
  if k!=(2 if sign>0 else 6):fs.append((8+k,8+q,16+q,16+k))
 mesh('Recessed flattened nozzle surround',vs,fs,'Ceramic','Hull',.008)
 prism('Hot engine core',nozzle_ring(cx,6.82,1.30,.29,-.12),.035,'Engines','Hull',.012,normal=(0,1,0))
 # Low integral trapezoid shrouds blend into the aft facets.
 sh=[]
 for yy,half,crown,rise in [(4.22,.49,.26,.018),(5.35,.65,.38,.22),(6.95,.74,.55,.035)]:
  edgez=top_z(cx+sign*half,yy)+.012
  topz=max(top_z(cx,yy)+rise,edgez+.04)
  sh.extend([(cx-half,yy,edgez),(cx-crown,yy,topz),(cx+crown,yy,topz),(cx+half,yy,edgez)])
 faces=[(0,1,2,3),(8,11,10,9)]
 for j in range(2):
  for k in range(3):faces.append((j*4+k,j*4+k+1,(j+1)*4+k+1,(j+1)*4+k))
 mesh('Integral faceted engine shroud',sh,faces,'Paint','Hull',.012)
 # Flat cooling inlays follow the crown instead of projecting as fins.
 for k in range(7):
  yy=5.43+k*.10;t=(yy-5.35)/1.60
  za=sh[5][2]*(1-t)+sh[9][2]*t
  beam('Inset thermal cooling groove',(cx-.25,yy,za+.003),(cx+.25,yy,za+.003),.024,.007,'Ceramic',bev=.002)
 # A recessed side wall reflects the same hot core through the side slot.
 prism('Side-visible hot throat',[(cx+sign*.64,5.55,-.055),(cx+sign*.64,6.95,-.01),(cx+sign*.64,6.95,-.25),(cx+sign*.64,5.55,-.205)],.008,'Engines','Hull',.006,normal=(sign,0,0))
 beam('Heat resistant nozzle lip',(cx-.66,6.99,.14),(cx+.66,6.99,.14),.055,.038,'Alloy',bev=.006)
 beam('Lower exhaust lip',(cx-.66,6.99,-.38),(cx+.66,6.99,-.38),.060,.034,'Alloy',bev=.006)
 for k in range(12):
  x=cx-.61+k*.111
  beam('Recessed nozzle guide vane',(x,6.28,-.23),(x,6.90,-.23),.018,.10,'Alloy',bev=.003)
# Broad aft deck hatch, spine seam, recessed heat exchangers.
for y0,y1 in [(.1,1.43),(1.91,3.08),(3.24,4.65)]:
 pts=[(-.74,y0),(.74,y0),(.81,y1),(-.81,y1)]
 detail_panel('Dorsal access hatch seal',pts,'Ceramic')
 c=np.mean(np.array(pts),axis=0);detail_panel('Dorsal pressure hatch',[tuple(c+(np.array(p)-c)*.967) for p in pts])
 for x in [-.60,.60]:
  for y in [y0+.14,y1-.14]:fastener(surface_point(x,y,.005),r=.022)
# Clean service fastener runs, ~20 mm heads give an honest scale cue.
for sign in [-1,1]:
 for j in range(1,6):
  a,b=SECTIONS[j],SECTIONS[j+1]
  for k in range(9):
   t=(k+.5)/9;y=a[0]+t*(b[0]-a[0]);w=a[1]*(1-t)+b[1]*t
   x=sign*(w-.16)
   fastener(surface_point(x,y,.003),r=.011)
 # Side trailing electro-optical panels and short formation strips
 trace('Aft running light',[(sign*2.74,3.7),(sign*2.51,4.20)],width=.013)
 for y in [-.4,.2,.8,1.4,2.0,2.6]:
  x=sign*1.40
  fastener(surface_point(x,y,.005),r=.014)
# Small flush RCS ports: square recesses with metal rectangular throats.
for sign in [-1,1]:
 for y in [-4.25,4.10]:
  for k in range(3):
   x=sign*(.86+k*.125) if y<0 else sign*(2.25+k*.125)
   box('RCS port well',surface_point(x,y,-.007),(.09,.19,.012),'Ceramic',bev=.007)
   box('RCS throat',surface_point(x,y,0),(.037,.085,.004),'Alloy',bev=.002)
# sparse chipped edges: actual tiny flush slivers, with no painted symbols
for sign in [-1,1]:
 for k in range(22):
  j=random.choice([1,3,4]);a,b=SECTIONS[j],SECTIONS[j+1];t=random.uniform(.08,.92)
  y=a[0]+(b[0]-a[0])*t;w=a[1]*(1-t)+b[1]*t;x=sign*(w-random.uniform(.09,.17))
  beam('Fine exposed edge wear',surface_point(x,y,.010),surface_point(x+sign*.025,y+.045,.010),.006,.003,'Alloy',bev=.001)

# Navigation lights are flush physical lenses; a tiny shared colour trim in Lamps
# carries red, green, instrument cyan and beacon white in ONE material.
for sign in [-1,1]:
 p=surface_point(sign*4.66,1.70,.018)
 box('Nav light housing',p,(.12,.18,.025),'Ceramic',bev=.008)
 lamp=box('Navigation lens',Vector(p)+Vector((0,0,.015)),(.070,.13,.012),'Lamps',bev=.004)
 for d in lamp.data.uv_layers[0].data:d.uv=(.25 if sign>0 else .6,.5)
p=surface_point(0,2.72,.002)
box('Beacon housing',p,(.09,.13,.008),'Ceramic',bev=.01)
lamp=box('Beacon lens',Vector(p)+Vector((0,0,.005)),(.045,.075,.004),'Lamps',bev=.006)
for d in lamp.data.uv_layers[0].data:d.uv=(.25,.5)

# Consolidate fixed geometry into exactly one node and preserve true local pivots.
def join_group(name,pivot=(0,0,0)):
 objs=GROUPS[name];bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.select_set(True)
 bpy.context.view_layer.objects.active=objs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 # All source geometry is already in world coordinates. Translate mesh to pivot,
 # then place the node there; never apply a moving node's transforms.
 p=Vector(pivot);o.data.transform(Matrix.Translation(-p));o.location=p
 o.rotation_euler=(0,0,0);o.scale=(1,1,1)
 # Remove numerical zero-area faces without simplifying actual manufactured detail.
 bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.triangulate(bm,faces=list(bm.faces),quad_method='BEAUTY',ngon_method='BEAUTY')
 bad=[f for f in bm.faces if f.calc_area()<1e-10]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free();o.data.update()
 return o
H=join_group('Hull')
C=join_group('Canopy',(0,-.30,1.53))
DL=join_group('BayDoor_L',(.79,.60,-1.00));DR=join_group('BayDoor_R',(-.79,.60,-1.00))
for o in [C,DL,DR]:o.parent=H
MOVING={'Canopy':(-1,0,0),'BayDoor_L':(0,-1,0),'BayDoor_R':(0,1,0)}

def empty(name,pos,parent=H):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pos;o.parent=parent;o.empty_display_size=.15;return o
for sign,i in [(1,1),(-1,2)]:
 empty('Thruster_'+str(i),(sign*1.42,7,-.12))
 empty('Hardpoint_'+str(i),(sign*.49,-.68,-.78))
empty('Seat_Pilot',(0,-1.88,.33))
empty('Camera_Chase',(0,11.5,5.5))
empty('Light_Nav_L',(4.72,1.70,.025));empty('Light_Nav_R',(-4.72,1.70,.025));empty('Light_Beacon',(0,2.72,1.16))

# Texture bakes happen on a physical one-tile sample, shared by every component.
# These are actual Cycles EMIT/NORMAL bakes, including the scan and node detail.
def bake_materials():
 download()
 source={}
 for mp in ['diff','nor_gl','arm']:
  im=bpy.data.images.load(str(TEX/f'blue_metal_plate_{mp}_1k.jpg'),check_existing=True)
  if mp!='diff':im.colorspace_settings.name='Non-Color'
  source[mp]=im
 for ob in [H,C,DL,DR]:ob.hide_render=True;ob.hide_set(True)
 for name in M:
  m=M[name];nt=m.node_tree;nt.nodes.clear();L=nt.links
  out=nt.nodes.new('ShaderNodeOutputMaterial');p=nt.nodes.new('ShaderNodeBsdfPrincipled');L.new(p.outputs['BSDF'],out.inputs['Surface'])
  uv=nt.nodes.new('ShaderNodeTexCoord')
  simple=name in ['Glass','Engines','Lamps']
  size=64 if simple else 1024
  nodes={}
  for mp in source:
   n=nt.nodes.new('ShaderNodeTexImage');n.image=source[mp];L.new(uv.outputs['UV'],n.inputs['Vector']);nodes[mp]=n
  bw=nt.nodes.new('ShaderNodeRGBToBW');L.new(nodes['diff'].outputs['Color'],bw.inputs[0])
  noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=11;noise.inputs['Detail'].default_value=5
  L.new(uv.outputs['UV'],noise.inputs['Vector'])
  ramp=nt.nodes.new('ShaderNodeValToRGB');L.new(bw.outputs[0],ramp.inputs[0])
  palettes={'Paint':((.64,.64,.64,1),(.98,.98,.98,1)), 'Alloy':((.10,.12,.145,1),(.30,.34,.38,1)), 'Ceramic':((.012,.016,.021,1),(.062,.070,.081,1)), 'Interior':((.025,.028,.031,1),(.09,.10,.112,1))}
  if not simple:
   lo,hi=palettes[name];ramp.color_ramp.elements[0].position=.035;ramp.color_ramp.elements[0].color=lo
   ramp.color_ramp.elements[1].position=.26;ramp.color_ramp.elements[1].color=hi
   mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.22
   L.new(ramp.outputs[0],mix.inputs[1]);L.new(noise.outputs['Fac'],mix.inputs[2]);base=mix.outputs[0]
   nm=nt.nodes.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.35 if name=='Paint' else .32
   L.new(nodes['nor_gl'].outputs['Color'],nm.inputs['Color'])
   bump=nt.nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.13;bump.inputs['Distance'].default_value=.012
   L.new(noise.outputs['Fac'],bump.inputs['Height']);L.new(nm.outputs[0],bump.inputs['Normal']);L.new(bump.outputs[0],p.inputs['Normal'])
  else:
   rgb=nt.nodes.new('ShaderNodeRGB');rgb.outputs[0].default_value={'Glass':(.035,.060,.085,1),'Engines':(1,.12,.032,1),'Lamps':(.80,.018,.009,1)}[name];base=rgb.outputs[0]
   if name=='Lamps':
    uvsep=nt.nodes.new('ShaderNodeSeparateXYZ');L.new(uv.outputs['UV'],uvsep.inputs[0])
    palette=nt.nodes.new('ShaderNodeValToRGB');palette.color_ramp.interpolation='CONSTANT'
    palette.color_ramp.elements[0].position=0;palette.color_ramp.elements[0].color=(.80,.018,.009,1)
    palette.color_ramp.elements[1].position=.5;palette.color_ramp.elements[1].color=(.025,.65,.10,1)
    e=palette.color_ramp.elements.new(.7);e.color=(.035,.32,.48,1)
    e=palette.color_ramp.elements.new(.9);e.color=(.68,.75,.80,1)
    L.new(uvsep.outputs[0],palette.inputs[0]);base=palette.outputs[0]
  rough={'Paint':.73,'Alloy':.37,'Ceramic':.72,'Interior':.77,'Glass':.20,'Engines':.38,'Lamps':.36}[name]
  metal={'Paint':.18,'Alloy':.85,'Ceramic':.5,'Interior':.12,'Glass':0,'Engines':.2,'Lamps':.25}[name]
  sep=nt.nodes.new('ShaderNodeSeparateColor');L.new(nodes['arm'].outputs['Color'],sep.inputs[0])
  roughmath=nt.nodes.new('ShaderNodeMath');roughmath.operation='MULTIPLY_ADD';roughmath.inputs[1].default_value=.32;roughmath.inputs[2].default_value=rough-.15;L.new(sep.outputs[1],roughmath.inputs[0])
  orm=nt.nodes.new('ShaderNodeCombineColor');orm.inputs[0].default_value=1;orm.inputs[1].default_value=rough;orm.inputs[2].default_value=metal
  if not simple:
   ao=nt.nodes.new('ShaderNodeMath');ao.operation='MULTIPLY_ADD';ao.inputs[1].default_value=.23;ao.inputs[2].default_value=.77;L.new(sep.outputs[0],ao.inputs[0]);L.new(ao.outputs[0],orm.inputs[0]);L.new(roughmath.outputs[0],orm.inputs[1])
  bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,20));sample=bpy.context.object;sample.name='Temporary bake tile';sample.data.materials.append(m)
  emit=nt.nodes.new('ShaderNodeEmission');emit.inputs['Strength'].default_value=1
  baked={}
  for kind in ['base','normal','orm']:
   res=min(size,512) if kind=='orm' else size
   im=bpy.data.images.new(name+'_'+kind,res,res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color'
   dst=nt.nodes.new('ShaderNodeTexImage');dst.image=im;nt.nodes.active=dst
   for n in nt.nodes:n.select=False
   dst.select=True
   if kind=='normal':L.new(p.outputs['BSDF'],out.inputs[0]);btype='NORMAL'
   else:
    L.new(base if kind=='base' else orm.outputs[0],emit.inputs[0]);L.new(emit.outputs[0],out.inputs[0]);btype='EMIT'
   activate(sample);S.cycles.samples=12;S.render.bake.margin=8;bpy.ops.object.bake(type=btype)
   im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save()
   baked[kind]=im
  bpy.data.objects.remove(sample,do_unlink=True)
  nt.nodes.clear();out=nt.nodes.new('ShaderNodeOutputMaterial');p=nt.nodes.new('ShaderNodeBsdfPrincipled');L=nt.links;L.new(p.outputs[0],out.inputs[0])
  ns={}
  for kind,im in baked.items():
   n=nt.nodes.new('ShaderNodeTexImage');n.image=im;n.label='Cycles baked '+kind;ns[kind]=n
  L.new(ns['base'].outputs['Color'],p.inputs['Base Color'])
  if name=='Paint':
   tint=nt.nodes.new('ShaderNodeMixRGB');tint.name='Faction colour';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(*PAINTS[0][1],1)
   L.new(ns['base'].outputs[0],tint.inputs[1]);L.new(tint.outputs[0],p.inputs['Base Color'])
  nm=nt.nodes.new('ShaderNodeNormalMap');L.new(ns['normal'].outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],p.inputs['Normal'])
  sep=nt.nodes.new('ShaderNodeSeparateColor');L.new(ns['orm'].outputs[0],sep.inputs[0]);L.new(sep.outputs[1],p.inputs['Roughness']);L.new(sep.outputs[2],p.inputs['Metallic'])
  # recognized glTF occlusion node; shares the exact ORM image
  g=bpy.data.node_groups.get('glTF Material Output')
  if not g:
   g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
  occ=nt.nodes.new('ShaderNodeGroup');occ.node_tree=g;L.new(sep.outputs[0],occ.inputs['Occlusion'])
  if name in ['Engines','Lamps']:
   L.new(ns['base'].outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=2.4 if name=='Engines' else 1.6
  if name=='Glass':
   p.inputs['Specular IOR Level'].default_value=.28;p.inputs['Alpha'].default_value=.48;p.inputs['Transmission Weight'].default_value=.12;p.inputs['IOR'].default_value=1.45;m.surface_render_method='DITHERED'
  print('BAKED',name,size,flush=True)
 for ob in [H,C,DL,DR]:ob.hide_render=False;ob.hide_set(False)

# Studio light in open space; background is camera-only world, no ground.
def lighting():
 world=bpy.data.worlds.new('Deep blue space');S.world=world;world.use_nodes=True;nt=world.node_tree;nt.nodes.clear();L=nt.links
 out=nt.nodes.new('ShaderNodeOutputWorld');lp=nt.nodes.new('ShaderNodeLightPath')
 env=nt.nodes.new('ShaderNodeBackground');env.inputs['Color'].default_value=(.19,.24,.32,1);env.inputs['Strength'].default_value=.32
 bg=nt.nodes.new('ShaderNodeBackground');bg.inputs['Strength'].default_value=1
 tc=nt.nodes.new('ShaderNodeTexCoord');sep=nt.nodes.new('ShaderNodeSeparateXYZ');L.new(tc.outputs['Normal'],sep.inputs[0])
 ramp=nt.nodes.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.0;ramp.color_ramp.elements[0].color=(.002,.004,.009,1);ramp.color_ramp.elements[1].position=.8;ramp.color_ramp.elements[1].color=(.012,.022,.043,1);L.new(sep.outputs[2],ramp.inputs[0]);L.new(ramp.outputs[0],bg.inputs[0])
 mix=nt.nodes.new('ShaderNodeMixShader');L.new(lp.outputs['Is Camera Ray'],mix.inputs[0]);L.new(env.outputs[0],mix.inputs[1]);L.new(bg.outputs[0],mix.inputs[2]);L.new(mix.outputs[0],out.inputs[0])
 def area(name,loc,power,color,size,target=(0,0,0),shape='DISK',size_y=None):
  d=bpy.data.lights.new(name,'AREA');d.energy=power;d.color=color;d.shape=shape;d.size=size
  if size_y:d.size_y=size_y
  o=bpy.data.objects.new(name,d);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
 area('White upper front left key',(4,-6,10),2300,(1,.96,.90),7)
 area('Soft cool fill',(-6,-1,5),1700,(.58,.72,1),9)
 area('Aft white edge light',(2,8,7),2600,(.77,.86,1),6)
 area('Lower hull bounce',(1,-2,-6),900,(.72,.78,.88),8)

def camera(loc,target=(0,0,0),ortho=None):
 if not S.camera:
  d=bpy.data.cameras.new('RenderCamera');o=bpy.data.objects.new('RenderCamera',d);bpy.context.collection.objects.link(o);S.camera=o
 o=S.camera;o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler();o.data.lens=48
 if ortho:o.data.type='ORTHO';o.data.ortho_scale=ortho
 else:o.data.type='PERSP'
 return o

def render(path,w=1920,h=1080,samples=40):
 S.render.resolution_x=w;S.render.resolution_y=h;S.cycles.samples=samples;S.render.filepath=str(path);bpy.ops.render.render(write_still=True)

# Upper facets dominate the low airframe profile; map the attached pivots and
# their geometry together to keep every opening and hinge connected.
def profile_z(z):return z*(1.08 if z>=0 else .86)
for ob in [H,C,DL,DR]:
 old=ob.location.z;new=profile_z(old)
 for v in ob.data.vertices:v.co.z=profile_z(v.co.z+old)-new
 ob.location.z=new
for ob in S.objects:
 if ob.type=='EMPTY':ob.location.z=profile_z(ob.location.z)
bpy.context.view_layer.update()

lighting();camera((13,-19,10.7),(0,-.15,.05),19.1)
# Clay gate before texture baking, preserves silhouettes and true seams.
clay=material('_QA_Clay',(.19,.19,.19),.05,.66)
S.view_layers[0].material_override=clay
S.view_settings.exposure=0
render(ROOT/'clay.png',1920,1080,24)
S.view_layers[0].material_override=None
S.view_settings.exposure=-.75
# Remove QA material from bake list. It never appears on the asset.
M.pop('_QA_Clay')
bake_materials()

# Adjust any numerical bevel overshoot by a single GLOBAL vertex mapping. Moving
# local vertices and pivot locations are mapped together, never transform-applied.
bpy.context.view_layer.update()
objs=[H,C,DL,DR]
pts=[o.matrix_world@v.co for o in objs for v in o.data.vertices]
lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
scale=Vector((9.5/(hi.x-lo.x),14/(hi.y-lo.y),2.6/(hi.z-lo.z)))
center=Vector(((hi.x+lo.x)/2,(hi.y+lo.y)/2,0))
for o in objs:
 for v in o.data.vertices:v.co=Vector([(v.co[i]-(center[i] if o==H else 0))*scale[i] for i in range(3)])
 o.location=Vector((0,0,0)) if o==H else Vector([(o.location[i]-center[i])*scale[i] for i in range(3)])
 # Final coordinates are float32 in glTF: discard numerical slivers AFTER the
 # dimension mapping as well, so export cannot collapse a surviving triangle.
 bm=bmesh.new();bm.from_mesh(o.data)
 bad=[f for f in bm.faces if f.calc_area()<1e-8]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free();o.data.update()
for o in S.objects:
 if o.type=='EMPTY':o.location=Vector([(o.location[i]-center[i])*scale[i] for i in range(3)])
bpy.context.view_layer.update()
# Serialize moving axes in PARENT space after Blender -> glTF axis conversion.
def glv(v):return [round(float(v[0]),6),round(float(v[2]),6),round(float(-v[1]),6)]
allnodes=objs+[o for o in S.objects if o.type=='EMPTY']
pts=[o.matrix_world@v.co for o in objs for v in o.data.vertices]
lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)])
tri=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objs)
meta={'asset':'stealth-interceptor','units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'Centre of mass near hull midpoint','bounding_box':{'min':[lo.x,lo.z,-hi.y],'max':[hi.x,hi.z,-lo.y],'dimensions':[hi.x-lo.x,hi.z-lo.z,hi.y-lo.y]},'triangles':tri,'nodes':{o.name:{'position':glv(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty'} for o in allnodes},'axes':{n:glv(a) for n,a in MOVING.items()},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'nozzles':{'Thruster_1':{'exhaust':[0,0,-1]},'Thruster_2':{'exhaust':[0,0,-1]}},'material_count':len(M),'bake':{'engine':'Cycles','device':'OPTIX','samples':12,'source':'Poly Haven blue_metal_plate / CC0','passes':['EMIT base color','NORMAL tangent','EMIT packed occlusion roughness metallic'],'texture_sizes':'1024 base and normal; 512 ORM; 64 small optical/emissive maps'},'pivot_test':{}}
# Export with a directly connected neutral Paint image; assign its exact factor
# in serialized glTF, which avoids exporter-dependent Mix node recognition.
pnt=M['Paint'].node_tree;pp=pnt.nodes.get('Principled BSDF');tint=pnt.nodes.get('Faction colour');base=[n for n in pnt.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_base'][0]
pnt.links.new(base.outputs[0],pp.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in allnodes:o.select_set(True)
bpy.context.view_layer.objects.active=H
path=ROOT/'stealth-interceptor.glb'
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_animations=False,export_image_format='AUTO')
# A binary-preserving JSON edit: paint tint, Glass blend, and socket metadata.
b=path.read_bytes();jl=struct.unpack_from('<I',b,12)[0];g=json.loads(b[20:20+jl]);rest=b[20+jl:]
tri=sum(g['accessors'][p['indices']]['count']//3 for m in g['meshes'] for p in m['primitives']);meta['triangles']=tri
for m in g['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.04,.04,.045,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
for n in g['nodes']:
 if n['name'] in MOVING:n['extras']={'axis':meta['axes'][n['name']],'closed':True}
j=json.dumps(g,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(j)+len(rest))+struct.pack('<I4s',len(j),b'JSON')+j+rest)
pnt.links.new(tint.outputs[0],pp.inputs['Base Color'])
print('EXPORTED',path,tri,path.stat().st_size,flush=True)
assert 30000<=tri<=80000,('triangle budget',tri)
assert path.stat().st_size<=12*1024*1024,('GLB bytes',path.stat().st_size)
# Thirty-degree articulation tests: hinge points stay fixed to micron precision.
for o in [C,DL,DR]:
 before=o.matrix_world.translation.copy();axis=Vector(MOVING[o.name]);o.rotation_mode='AXIS_ANGLE';o.rotation_axis_angle=(math.radians(30),*axis);bpy.context.view_layer.update()
 error=(o.matrix_world.translation-before).length
 meta['pivot_test'][o.name]={'degrees':30,'pivot_displacement_metres':error};assert error<1e-7
camera((12,-16,10),(0,0,0),19)
render(QA/'moving_30deg.png',1280,720,20)
for o in [C,DL,DR]:o.rotation_axis_angle=(0,1,0,0)
# Show both bay doors and the canopy independently in evidence views.
camera((11,-12,-10),(0,0,-.2),18)
for o in [DL,DR]:o.rotation_axis_angle=(math.radians(30),*MOVING[o.name])
render(QA/'bays_30deg.png',1280,720,20)
for o in [DL,DR]:o.rotation_axis_angle=(0,1,0,0)
bpy.context.view_layer.update()
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
# Ship final source scene for optional inspection on GPU host; not part of GLB.
bpy.ops.wm.save_as_mainfile(filepath=str(QA/'source.blend'))
camera((13,-19,10.7),(0,-.15,.05),19.1);render(ROOT/'final.png',1920,1080,128)
camera((19,0,.15),(0,0,.15),17.1);render(ROOT/'side.png',1920,1080,40)
camera((11,17,8),(0,1,.1),18.5);render(QA/'rear.png',1280,720,32)
camera((6,-10,7),(0,-2,.7),8.4);render(QA/'cockpit.png',1280,720,32)
# Six identical camera and light conditions; no text over the actual model.
camera((13,-19,10.7),(0,-.15,.05),19.1)
paint_images=[]
for name,col in PAINTS:
 tint.inputs[2].default_value=(*col,1);render(QA/f'paint_{name}.png',640,360,48)
 im=bpy.data.images.load(str(QA/f'paint_{name}.png'),check_existing=False)
 arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);paint_images.append(arr)
# Image pixel buffers are bottom-up. Bottom row is paints 4–6.
canvas=np.concatenate([np.concatenate(paint_images[3:6],axis=1),np.concatenate(paint_images[0:3],axis=1)],axis=0)
im=bpy.data.images.new('Six paint contact sheet',1920,720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
tint.inputs[2].default_value=(*PAINTS[0][1],1)
notes=[
'Original 14.0 × 9.5 × 2.6 m faceted stealth interceptor; flush hinged canopy with cockpit, closed hinged internal weapon bays, buried twin slot engines.',
f'{tri:,} triangles; four mesh nodes; Y up, nose +Z, +X left; all fixed pieces joined into Hull; 30° hinge checks passed.',
'Materials: Paint, Alloy, Ceramic, Interior, Glass (BLEND), Engines, Lamps; neutral Paint texture × default linear [0.04, 0.04, 0.045].',
f'Cycles-baked shared tiling base/normal ≤1024 px, packed ORM ≤512 px; optical/emissive maps 64 px; GLB {path.stat().st_size/1048576:.2f} MiB.',
'Poly Haven CC0: blue_metal_plate (diff, nor_gl, arm), https://polyhaven.com/a/blue_metal_plate ; licence https://polyhaven.com/license .',
'Differences: original clipped nose and broad notched tail, flatter dorsal deck, revised service seams and nozzle internals; cleaner wear than the sheet; no markings.'
]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('BUILD_COMPLETE',flush=True)
