"""Forge hard-SF interceptor. Blender 5.2, clean-scene build, CC0 tile bakes, GLB and evidence renders.
Run: blender -b --python-exit-code 1 --python build_interceptor-hardsf.py
Coordinates during authoring: X left, -Y forward, Z up. Export converts to X left, Y up, Z forward.
Scope: 16 x 6.5 x 4.2m; 40–90k tris; <=9 mats; 1024 colour/normal, 512 ORM; <=12MB.
Views: hero front-left, ortho side, clay, six paints, 30-degree hinge test. Revision cap: 3.
"""
import bpy, math, os, sys, json, urllib.request, subprocess, struct, time
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
EVID=ROOT/'evidence'; EVID.mkdir(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.unit_settings.scale_length=1
sc.render.engine='CYCLES';sc.cycles.device='GPU';sc.cycles.samples=48
prefs=bpy.context.preferences.addons['cycles'].preferences
for backend in ['OPTIX','CUDA']:
 try:
  prefs.compute_device_type=backend;prefs.get_devices()
  if any(d.type==backend for d in prefs.devices):
   for d in prefs.devices:d.use=d.type==backend
   break
 except Exception:pass
else:raise RuntimeError('CUDA/OptiX GPU required')
sc.cycles.use_denoising=True;sc.render.image_settings.file_format='PNG'
sc.view_settings.view_transform='AgX'
sc.render.resolution_percentage=100
sc.render.film_transparent=False
GROUPS={'Hull':[],'Canopy':[],'Radiator_L':[],'Radiator_R':[]}
MATS={}; BAKE_LOG=[]

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

def uv_project(o):
 uv=o.data.uv_layers.new(name='UVMap') if not o.data.uv_layers else o.data.uv_layers.active
 for f in o.data.polygons:
  n=f.normal;axis=max(range(3),key=lambda i:abs(n[i]));a,b=([1,2] if axis==0 else [0,2] if axis==1 else [0,1])
  for li in f.loop_indices:
   p=o.data.vertices[o.data.loops[li].vertex_index].co
   uv.data[li].uv=(p[a]*.57,p[b]*.57)

def finish(o,name,mat,group='Hull',bevel=0,smooth=False):
 o.name=name
 if mat:o.data.materials.append(MATS[mat] if isinstance(mat,str) else mat)
 if bevel:
  active(o);m=o.modifiers.new('Machined edge radii','BEVEL');m.width=bevel;m.segments=2
  bpy.ops.object.modifier_apply(modifier=m.name)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 else:
  # Weighted face normals preserve armour planes while letting chamfers catch highlights.
  active(o)
  if bevel:
   for p in o.data.polygons:p.use_smooth=True
   m=o.modifiers.new('Weighted normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=50
   bpy.ops.object.modifier_apply(modifier=m.name)
 uv_project(o)
 if mat=='Lamps':
  for uv in o.data.uv_layers.active.data:uv.uv=(.125,.5)
 if group:GROUPS[group].append(o)
 return o

def mesh(name,verts,faces,mat,group='Hull',bevel=0,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 o=bpy.data.objects.new(name,me);sc.collection.objects.link(o)
 # Make connected surfaces outward-facing.
 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 finish(o,name,mat,group,bevel,smooth)

def box(name,loc,size,mat='Paint',bevel=.025,group='Hull'):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object
 o.data.transform(Matrix.Diagonal(Vector((*size,1))));return finish(o,name,mat,group,bevel)

def sphere(name,loc,scale,mat='Alloy',group='Hull',segs=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=segs,ring_count=rings,radius=1,location=loc);o=bpy.context.object
 o.data.transform(Matrix.Diagonal(Vector((*scale,1))));return finish(o,name,mat,group,0,True)

def tube(name,a,b,r,mat='Alloy',vertices=12,group='Hull',bevel=.008):
 a,b=Vector(a),Vector(b);d=b-a
 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=r,depth=d.length,location=(a+b)/2)
 o=bpy.context.object;o.rotation_euler=d.to_track_quat('Z','Y').to_euler()
 o.data.transform(o.rotation_euler.to_matrix().to_4x4());o.rotation_euler=(0,0,0)
 return finish(o,name,mat,group,bevel,True)

def frame(name,a,b,width=.08,depth=.055,mat='Alloy',group='Canopy'):
 a,b=Vector(a),Vector(b);d=b-a
 o=box(name,(a+b)/2,(width,depth,d.length+.022),mat,.012,group)
 o.data.transform(d.to_track_quat('Z','Y').to_matrix().to_4x4())
 return o

def torus(name,loc,major,minor,mat='Alloy',axis='Y',group='Hull',segs=64):
 bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=8,location=loc,major_radius=major,minor_radius=minor)
 o=bpy.context.object
 if axis=='Y':o.data.transform(Matrix.Rotation(pi/2,4,'X'))
 elif axis=='X':o.data.transform(Matrix.Rotation(pi/2,4,'Y'))
 return finish(o,name,mat,group,0,True)

def bolt(loc,axis='X',group='Hull',r=.026):
 p=Vector(loc);v=Vector((1,0,0) if axis=='X' else (0,1,0) if axis=='Y' else (0,0,1))
 return tube('Captive fastener',p-v*.012,p+v*.012,r,'Alloy',6,group,0)

def lathe(name,profile,center,mat,group='Hull',segments=64,axis='Y'):
 # profile is axial-position, radius; can include outer and inner contour for real bell thickness
 vs=[]
 for y,r in profile:
  for j in range(segments):
   a=2*pi*j/segments;p=Vector((r*cos(a),y,r*sin(a)))
   if axis=='X':p=Vector((y,r*cos(a),r*sin(a)))
   vs.append(p+Vector(center))
 fs=[]
 for k in range(len(profile)-1):
  for j in range(segments):fs.append((k*segments+j,k*segments+(j+1)%segments,(k+1)*segments+(j+1)%segments,(k+1)*segments+j))
 return mesh(name,vs,fs,mat,group,0,True)

def download(asset,mapname):
 p=TEX/f'{asset}_{mapname}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mapname}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  try:p.write_bytes(urllib.request.urlopen(req,timeout=90).read())
  except Exception:subprocess.run(['curl','-L','--fail','-A','Mozilla/5.0',url,'-o',str(p)],check=True)
 return p

# The two CC0 source sets are shared by every opaque surface. No image-generation sources.
PH={}
for asset in ['metal_plate','metal_plate_02']:
 for k in ['diff','nor_gl','arm']:
  im=bpy.data.images.load(str(download(asset,k)),check_existing=True)
  if k!='diff':im.colorspace_settings.name='Non-Color'
  PH[asset,k]=im

def bake_material(name,color,metal,rough,asset='metal_plate',relief=.1,neutral=False):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
 out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=N.new('ShaderNodeTexCoord')
 def tex(k):
  n=N.new('ShaderNodeTexImage');n.image=PH[asset,k];L.new(uv.outputs['UV'],n.inputs['Vector']);return n
 diff=tex('diff');normal=tex('nor_gl');arm=tex('arm');bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
 ramp=N.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.035 if neutral else .10;ramp.color_ramp.elements[1].position=.22 if neutral else .8
 low=.52 if neutral else .62;high=.98 if neutral else 1.15
 ramp.color_ramp.elements[0].color=tuple(min(1,c*low) for c in color)+(1,)
 ramp.color_ramp.elements[1].color=tuple(min(1,c*high) for c in color)+(1,)
 L.new(bw.outputs[0],ramp.inputs[0])
 if name=='Heatshield':
  ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.22
  ramp.color_ramp.elements[0].color=(.055,.040,.027,1);ramp.color_ramp.elements[1].color=(.14,.18,.24,1)

 noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=2;L.new(uv.outputs['UV'],noise.inputs['Vector'])
 pits=N.new('ShaderNodeValToRGB');pits.color_ramp.elements[0].position=.25;pits.color_ramp.elements[0].color=(.55,.55,.55,1);pits.color_ramp.elements[1].position=.37
 L.new(noise.outputs['Fac'],pits.inputs[0]);mix=N.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.40
 L.new(ramp.outputs[0],mix.inputs[1]);L.new(pits.outputs[0],mix.inputs[2]);L.new(mix.outputs[0],bs.inputs['Base Color'])
 sep=N.new('ShaderNodeSeparateColor');L.new(arm.outputs['Color'],sep.inputs[0])
 rm=N.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.15,rough-.10);rm.inputs['To Max'].default_value=min(.85,rough+.15);L.new(sep.outputs['Green'],rm.inputs[0]);L.new(rm.outputs[0],bs.inputs['Roughness'])
 bs.inputs['Metallic'].default_value=metal
 nm=N.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=relief;L.new(normal.outputs['Color'],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,0));o=bpy.context.object;o.name='Tile bake fixture';o.data.materials.append(m)
 target=N.new('ShaderNodeTexImage');N.active=target
 emit=N.new('ShaderNodeEmission');combine=N.new('ShaderNodeCombineColor');aomap=N.new('ShaderNodeMapRange');aomap.inputs['To Min'].default_value=.70;aomap.inputs['To Max'].default_value=1;L.new(sep.outputs['Red'],aomap.inputs[0]);L.new(aomap.outputs[0],combine.inputs['Red']);L.new(rm.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal
 baked={}
 for kind,res in [('Base',1024),('Normal',1024),('ORM',512)]:
  im=bpy.data.images.new(f'{name}_{kind}',res,res,alpha=False)
  if kind!='Base':im.colorspace_settings.name='Non-Color'
  target.image=im;N.active=target
  if kind=='Normal':L.new(bs.outputs[0],out.inputs['Surface']);typ='NORMAL'
  else:
   L.new(mix.outputs[0] if kind=='Base' else combine.outputs[0],emit.inputs['Color']);L.new(emit.outputs[0],out.inputs['Surface']);typ='EMIT'
  active(o);sc.cycles.samples=8;sc.render.bake.margin=8;bpy.ops.object.bake(type=typ)
  im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0]);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 for kind in ['Base','Normal','ORM']:
  n=N.new('ShaderNodeTexImage');n.image=baked[kind];n.label='Cycles baked shared tile'
  if kind=='Base':
   if name=='Paint':
    tint=N.new('ShaderNodeMixRGB');tint.name='Faction paint factor';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(.55,.56,.55,1);L.new(n.outputs[0],tint.inputs[1]);L.new(tint.outputs[0],bs.inputs['Base Color'])
   else:L.new(n.outputs[0],bs.inputs['Base Color'])
  elif kind=='Normal':
   nm=N.new('ShaderNodeNormalMap');L.new(n.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   # glTF occlusion channel, packed with roughness and metalness.
   ng=bpy.data.node_groups.get('glTF Material Output')
   if not ng:
    ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=N.new('ShaderNodeGroup');gn.node_tree=ng;L.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 MATS[name]=m;BAKE_LOG.append({'material':name,'device':backend,'samples':8,'base':1024,'normal':1024,'ORM':512,'source':asset,'representation':'shared tiling Cycles EMIT base/ORM and tangent NORMAL bake'})
 print('BAKED',name,flush=True)

for args in [('Paint',(1,1,1),.60,.37,'metal_plate_02',.12,True),('Alloy',(.40,.43,.46),.88,.28,'metal_plate_02',.15,False),('Heatshield',(.11,.085,.067),.84,.40,'metal_plate_02',.10,False),('Radiator',(.045,.053,.061),.68,.49,'metal_plate_02',.12,False),('Cockpit',(.025,.033,.041),.1,.65,'metal_plate',.08,False)]:bake_material(*args)

def special(name,color,metal=0,rough=.3,alpha=1,emission=0):
 m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;bs.inputs['Alpha'].default_value=alpha
 if emission:bs.inputs['Emission Color'].default_value=(*color,1);bs.inputs['Emission Strength'].default_value=emission
 if alpha<1:
  m.surface_render_method='BLENDED';m.use_transparent_shadow=True
 # Even constant glass and emissive finishes are Cycles baked for portable appearance.
 if not name.startswith('_'):
  nt=m.node_tree;N=nt.nodes;L=nt.links;out=N.get('Material Output');target=N.new('ShaderNodeTexImage');emit=N.new('ShaderNodeEmission')
  rgb=N.new('ShaderNodeRGB');rgb.outputs[0].default_value=(*color,1)
  source=rgb.outputs[0]
  if name=='Lamps':
   tc=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(tc.outputs['UV'],sep.inputs[0]);ramp=N.new('ShaderNodeValToRGB');L.new(sep.outputs['X'],ramp.inputs[0]);ramp.color_ramp.interpolation='CONSTANT'
   stops=[(0,(.12,.46,.65,1)),(.25,(1,.23,.025,1)),(.5,(1,.012,.005,1)),(.75,(.01,1,.08,1))]
   for i,(pos,c) in enumerate(stops):
    el=ramp.color_ramp.elements[i] if i<2 else ramp.color_ramp.elements.new(pos);el.position=pos;el.color=c
   source=ramp.outputs[0]
  bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;o.data.materials.append(m)
  baked={}
  for kind in ['Base','Normal','ORM']:
   im=bpy.data.images.new(f'{name}_{kind}',64,64,alpha=False)
   if kind!='Base':im.colorspace_settings.name='Non-Color'
   target.image=im;N.active=target
   if kind=='Normal':L.new(bs.outputs[0],out.inputs[0]);typ='NORMAL'
   else:
    if kind=='Base':L.new(source,emit.inputs['Color'])
    else:
     for link in list(emit.inputs['Color'].links):L.remove(link)
     emit.inputs['Color'].default_value=(1,rough,metal,1)
    L.new(emit.outputs[0],out.inputs[0]);typ='EMIT'
   active(o);sc.cycles.samples=4;bpy.ops.object.bake(type=typ);im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
  bpy.data.objects.remove(o,do_unlink=True);L.new(bs.outputs[0],out.inputs[0])
  for kind,im in baked.items():
   n=N.new('ShaderNodeTexImage');n.image=im
   if kind=='Base':
    L.new(n.outputs[0],bs.inputs['Base Color'])
    if emission:L.new(n.outputs[0],bs.inputs['Emission Color'])
   elif kind=='Normal':
    nm=N.new('ShaderNodeNormalMap');L.new(n.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
   else:
    sep=N.new('ShaderNodeSeparateColor');L.new(n.outputs[0],sep.inputs[0]);L.new(sep.outputs['Green'],bs.inputs['Roughness']);L.new(sep.outputs['Blue'],bs.inputs['Metallic'])
  BAKE_LOG.append({'material':name,'device':backend,'samples':4,'base':64,'normal':64,'ORM':64,'source':'constant optical finish / emission palette','representation':'Cycles EMIT and tangent NORMAL'})
 MATS[name]=m;return m
special('Glass' ,(.12,.22,.28),.20,.12,.24)
special('Engines',(.12,.28,.65),.45,.26,1,1.8)
special('Lamps',(.21,.64,.72),.25,.25,1,2)

# Main pressure/armour shell; longitudinal stations define the silhouette.
SECTIONS=[(-6.10,.38,-.38,.10),(-5.35,.72,-.60,.49),(-4.25,1.13,-.82,.79),(-2.9,1.55,-.91,.96),(-1.1,2.02,-1.0,1.07),(.7,2.23,-1.05,1.12),(2.8,2.22,-1.05,1.10),(4.42,1.95,-.91,1.00)]
def ring(y,w,bot,top):
 return [(w*.65,y,top),(-w*.65,y,top),(-w,y,top-.26),(-w,y,bot+.25),(-w*.68,y,bot),(w*.68,y,bot),(w,y,bot+.25),(w,y,top-.26)]
vs=[]
for s in SECTIONS:vs+=ring(*s)
fs=[]
for k in range(len(SECTIONS)-1):
 for j in range(8):
  if j==0 and 1<=k<=4:continue # actual cockpit aperture, no opaque deck behind glazing
  fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
fs += [tuple(reversed(range(8))),tuple(range(56,64))]
hull_shell=mesh('Continuous pressure hull',vs,fs,'Paint',bevel=.045)
box('Nose sensor bezel',(0,-6.116,-.13),(.51,.048,.26),'Alloy',.045)
box('Recessed nose sensor window',(0,-6.143,-.13),(.40,.018,.16),'Cockpit',.035)
box('Passive optical aperture',(0,-6.155,-.13),(.18,.009,.075),'Glass',.02)
# Low armoured dorsal spine behind the cockpit.
vs=[]
for s in [(0.65,.83,1.02,1.68),(1.5,.88,1.02,1.67),(3.6,.8,.99,1.53),(4.45,.67,.83,1.27)]:vs+=ring(*s)
fs=[tuple(reversed(range(8))),tuple(range(24,32))]
for k in range(3):
 for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
mesh('Dorsal reactor shielding',vs,fs,'Paint',bevel=.065)
# Recessed vent bays and fitted armour tiles along the shoulder.
for sign in [-1,1]:
 for k,(ya,yb) in enumerate([(-.05,.65),(.70,2.1),(2.15,3.65),(3.70,4.35)]):
  x=sign*(2.13 if ya>=-.95 else 1.78)
  box('Inset thermal service bay',(x,(ya+yb)/2,.21),(.14,yb-ya,.78),'Cockpit',.06)
  for z in [-.25,.69]:tube('Service bay rim',(x*1.012,ya,z),(x*1.012,yb,z),.045,'Paint')
  for y in [ya,yb]:tube('Service bay rim',(x*1.012,y,-.25),(x*1.012,y,.69),.045,'Paint')
  for j in range(9):
   y=ya+.11+(yb-ya-.22)*j/8
   box('Heat exchanger louvre',(x*1.025,y,.21),(.09,.055,.65),'Alloy',.012)
  for y in [ya+.09,yb-.09]:
   for z in [-.19,.62]:bolt((x*1.04,y,z))
 # Embedded service hatches on upper shoulder, conforming to the narrowing hull.
 for y in [.15,1.25,2.5,3.65]:
  x=sign*1.39
  box('Recessed maintenance cradle',(x,y,1.055),(.56,.63,.18),'Paint',.045)
  box('Flush maintenance gasket',(x,y,1.137),(.54,.61,.045),'Cockpit',.045)
  box('Armoured maintenance cover',(x,y,1.17),(.51,.58,.055),'Paint',.045)
  for xx in [-.17,.17]:
   for yy in [-.21,.21]:bolt((x+xx,y+yy,1.205),'Z',r=.018)
 # Raised fore shoulder flanks, sculpted as closed angular guards around cooling apertures.
 vv=[(sign*x,y,z) for x,y,z in [(1.0,-4.45,.55),(1.7,-2.5,.79),(2.06,-1.0,.87),(2.02,-1.0,-.30),(1.62,-2.5,-.4),(1.0,-4.45,-.2),(.9,-4.45,.49),(1.58,-2.5,.70),(1.94,-1.0,.78),(1.90,-1.0,-.21),(1.5,-2.5,-.31),(.9,-4.45,-.13)]]
 ff=[(0,1,4,5),(1,2,3,4),(6,11,10,7),(7,10,9,8)]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]
 cheek=mesh('Tapered cheek armour',vv,ff,'Paint',bevel=.04)
 # A deep, bevelled recessed sensor/thermal bay is cut through both skin layers.
 cutter=box('Temporary aperture tool',(sign*1.67,-1.87,.37),(1.10,1.33,.66),None,.12,group=None)
 for target in [hull_shell,cheek]:
  active(target);mod=target.modifiers.new('Recessed cheek aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
  # Boolean intersection edges must not inherit interpolated pre-cut custom normals.
  for p in target.data.polygons:p.use_smooth=False
 bpy.data.objects.remove(cutter,do_unlink=True)
 box('Cheek aperture pressure bulkhead',(sign*1.19,-1.87,.37),(.12,1.26,.60),'Cockpit',.065)
 for j in range(8):
  box('Recessed cheek heat-exchanger fin',(sign*1.27,-2.38+j*.145,.37),(.075,.035,.48),'Radiator',.008)
 # Four substantial sloping walls lead into a pressure-sealed recess.
 corners=[(-2.50,.10),(-2.50,.64),(-1.24,.64),(-1.24,.10)]
 wall=[]
 for depth in [0,1]:
  for y,z in corners:
   x=1.64+(y+2.50)*.25 if depth==0 else 1.25
   wall.append((sign*x,y,z))
 lining=mesh('Armoured cheek aperture lining',wall,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Heatshield')
 active(lining);sol=lining.modifiers.new('Recess wall thickness','SOLIDIFY');sol.thickness=.045;bpy.ops.object.modifier_apply(modifier=sol.name)
 bev=lining.modifiers.new('Recess wall bevel','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
 # Framed opening and surrounding armour remain continuous.
 for y in [-2.54,-1.20]:
  x=sign*(1.62 if y<-2 else 1.95)
  tube('Cheek aperture vertical rim',(x,y,.10),(x,y,.64),.032,'Alloy',10)

 # Sensor blister: seat is tangent to nose flank, optical aperture points forward.
 sphere('Protected sensor blister',(sign*.83,-4.42,.20),(.22,.52,.17),'Alloy',segs=24,rings=12)
 tube('Sensor optical hood',(sign*.83,-4.88,.20),(sign*.83,-4.98,.20),.085,'Cockpit',24)
 tube('Sensor glass',(sign*.83,-4.985,.20),(sign*.83,-4.993,.20),.061,'Glass',24,bevel=.002)
# Panel seams with paired armoured ribs, belly doors closed.
for y,w,bot,top in SECTIONS[2:-1]:
 for sign in [-1,1]:
  tube('Armour seam', (sign*w*.69,y,bot+.04),(sign*w*.99,y,bot+.28),.015,'Alloy',8)
  for z in [bot+.35,top-.36]:bolt((sign*(w+.006),y,z),r=.024)
for y in [-1.8,.2,2.2]:
 box('Closed ventral equipment door',(0,y,-1.045),(1.3,1.8,.07),'Alloy',.07)
 box('Flush ventral armour',(0,y,-1.09),(1.19,1.69,.04),'Paint',.06)
 for x in [-.48,.48]:
  for yy in [-.66,.66]:bolt((x,y+yy,-1.12),'Z')

# Armour seams follow the longitudinal loft, held just proud of the skin (not z-fighting).
for sign in [-1,1]:
 for y in [-4.82,-3.55,-2.82,-.86,1.04,2.72,3.85]:
  # interpolate the outer side face at this longitudinal station
  for a,b in zip(SECTIONS,SECTIONS[1:]):
   if a[0]<=y<=b[0]:
    t=(y-a[0])/(b[0]-a[0]);w=a[1]*(1-t)+b[1]*t;bot=a[2]*(1-t)+b[2]*t;top=a[3]*(1-t)+b[3]*t;break
  x=sign*(w+.009)
  # narrow dark join with a fine metal edge, segmented around the recesses
  if y<-2.6 or y>-.7:
   tube('Recessed armour panel join',(x,y,bot+.30),(x,y,top-.30),.010,'Radiator',6,bevel=0)
   for z in [bot+.35,top-.35]:bolt((x+sign*.015,y+.065,z),r=.019)
 # Shoulder service conduits route from reactor to the radiator hinge.
 for z in [-.48,-.61]:
  tube('Coolant trunk',(sign*2.235,.02,z),(sign*2.235,3.87,z),.026,'Heatshield',10)
  for y in [.12,1.2,2.3,3.73]:box('Coolant line clamp',(sign*2.24,y,z),(.08,.05,.10),'Alloy',.008)
for y,w,z in [(1.57,.85,1.67),(3.48,.80,1.54)]:
 tube('Dorsal shield panel joint',(-w*.65,y,z+.012),(w*.65,y,z+.012),.014,'Radiator',8)
 box('Dorsal inspection plate',(0,y-.30,z+.02),(.63,.38,.045),'Paint',.04)
 for x in [-.24,.24]:bolt((x,y-.30,z+.05),'Z',r=.019)

# Four long cylindrical propellant tanks with domed welded ends and real retaining bands.
for sx in [-1,1]:
 for sz in [-1,1]:
  x=sx*1.46;z=1.20 if sz==1 else -1.06
  cap=[]
  for j in range(13):
   a=-pi/2+j*pi/24;cap.append((1.28+.49*sin(a),max(.0001,.49*cos(a))))
  cap.append((3.52,.49))
  for j in range(1,13):
   a=j*pi/24;cap.append((3.52+.49*sin(a),max(.0001,.49*cos(a))))
  tank=lathe('Seamless domed propellant vessel',cap,(x,0,z),'Alloy',segments=48)
  tank.data.materials.append(MATS['Heatshield'])
  for p in tank.data.polygons:
   if p.center.y<1.27 or p.center.y>3.53:p.material_index=1

  for y in [1.28,3.52]:
   torus('Tank retaining band',(x,y,z),.497,.038,'Heatshield',segs=48)
   torus('Retaining band lip',(x,y-.04,z),.499,.012,'Alloy',segs=48)
   box('Tank saddle',(x,y,z-sz*.40),(.72,.18,.25),'Paint',.035)
   for dx in [-.24,.24]:bolt((x+dx,y,z+sz*.49),'Z',r=.023)
  tube('Propellant feed',(x,4.17,z),(sx*1.17,4.85,sz*.63),.065,'Heatshield',16)
  torus('Fill cap',(x,1.0,z+sz*.36),.085,.02,'Alloy','Z',segs=24)
# Dorsal antenna/sensor covers (no markings).
for y in [1.35,3.5]:
 sphere('Recessed dorsal sensor',(0,y,1.67 if y<2 else 1.54),(.33,.40,.065),'Alloy',segs=24,rings=12)

# Cockpit tub open beneath the transparent hinged canopy.
cockpit_start=len(GROUPS['Hull'])
box('Cockpit floor',(0,-1.55,.73),(1.46,3.9,.14),'Cockpit',.06)
for y in [-2.25,-.65]:
 box('Seat pedestal',(0,y,.98),(.48,.53,.42),'Alloy',.06)
 box('Crashworthy seat cushion',(0,y,1.23),(.59,.69,.19),'Cockpit',.09)
 back=box('Seat back',(0,y+.29,1.67),(.59,.18,.81),'Cockpit',.085);back.rotation_euler.x=-.13
 box('Head restraint',(0,y+.32,2.12),(.40,.22,.27),'Cockpit',.08)
 for s in [-1,1]:
  tube('Seat frame',(s*.32,y+.30,1.12),(s*.32,y+.34,2.05),.035,'Alloy')
  tube('Harness webbing',(s*.14,y+.175,1.35),(s*.20,y+.16,2.00),.027,'Heatshield',8)
  box('Side console',(s*.55,y-.12,1.37),(.24,.80,.25),'Cockpit',.05)
  for j in range(5):box('Console switch',(s*.55,y-.39+j*.12,1.505),(.04,.04,.025),'Alloy',.005)
 tube('Control stick',(0,y-.27,1.27),(0,y-.40,1.55),.032,'Alloy')
 sphere('Hand grip',(0,y-.40,1.56),(.055,.06,.09),'Cockpit',segs=12,rings=8)
 # Instrument panel face sits ahead of each operator.
 box('Instrument panel',(0,y-.62,1.57),(1.0,.13,.42),'Cockpit',.045)
 for s in [-1,1]:box('Flight display',(s*.25,y-.695,1.60),(.32,.017,.21),'Lamps',.018)
for o in GROUPS['Hull'][cockpit_start:]:o.location.z-=.80
# Canopy mesh: faceted windscreen and roof with structural bows and rubber seals.
CAN=[(-3.95,.43,.92,.99),(-3.0,.66,.98,1.34),(-1.75,.77,1.04,1.57),(-.50,.79,1.08,1.68),(.72,.77,1.10,1.68)]
# Fitted solid foredeck and shoulder coaming; actual aperture follows canopy sills.
def interp(y,rows):
 if y<=rows[0][0]:return rows[0][1:]
 if y>=rows[-1][0]:return rows[-1][1:]
 for a,b in zip(rows,rows[1:]):
  if a[0]<=y<=b[0]:
   t=(y-a[0])/(b[0]-a[0]);return tuple(a[j]*(1-t)+b[j]*t for j in range(1,len(a)))
yys=sorted(set([-5.35,-4.25,-2.9,-1.1,.70]+[r[0] for r in CAN]))
for sign in [-1,1]:
 for ya,yb in zip(yys,yys[1:]):
  vv=[]
  for y in [ya,yb]:
   w,bot,top=interp(y,SECTIONS);cw,cb,ct=interp(y,CAN)
   if y<CAN[0][0]:cw=0;cb=top+.012
   vv.extend([(sign*w*.65,y,top+.012),(sign*cw,y,cb-.015)])
  o=mesh('Fitted cockpit shoulder deck',vv,[(0,1,3,2)],'Paint',bevel=0)
  active(o);sol=o.modifiers.new('Armour thickness','SOLIDIFY');sol.thickness=.065;bpy.ops.object.modifier_apply(modifier=sol.name)
  bev=o.modifiers.new('Coaming edge radius','BEVEL');bev.width=.018;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
 for a,b in zip(CAN,CAN[1:]):tube('Fixed cockpit coaming',(sign*a[1],a[0],a[2]-.065),(sign*b[1],b[0],b[2]-.065),.068,'Paint',12)

# Closed forward pressure sill bridges the central triangle before the windscreen.
mesh('Windscreen forward pressure sill',[(0,-4.25,.802),(.43,-3.95,.905),(-.43,-3.95,.905),(0,-4.25,.737),(.43,-3.95,.840),(-.43,-3.95,.840)],[(0,1,2),(5,4,3),(0,3,4,1),(1,4,5,2),(2,5,3,0)],'Paint',bevel=.012)
# each station left sill, left roof, right roof, right sill
cr=[]
for y,w,b,t in CAN:cr.append([(w,y,b),(w*.74,y,t),(-w*.74,y,t),(-w,y,b)])
mesh('Forward laminated glass end',cr[0],[(0,1,2,3)],'Glass','Canopy')
for k in range(len(cr)-1):
 for j in range(3):
  v=[cr[k][j],cr[k][j+1],cr[k+1][j+1],cr[k+1][j]]
  o=mesh('Armoured glazing facet',v,[(0,1,2,3)],'Glass','Canopy')
  active(o);sol=o.modifiers.new('Laminated glass thickness','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
for j in range(4):
 for k in range(4):
  tube('Canopy seal',cr[k][j],cr[k+1][j],.037,'Cockpit',12,'Canopy')
  frame('Canopy longitudinal armour',cr[k][j],cr[k+1][j])
for k in range(5):
 for j in range(3):frame('Armoured canopy bow',cr[k][j],cr[k][j+1],.085,.055)
 for j in [0,3]:bolt(cr[k][j],'X','Canopy',.022)
for s in [-1,1]:
 tube('Canopy hinge trunnion',(s*.59,.77,1.17),(s*.82,.77,1.17),.10,'Alloy',24)
 tube('Canopy rotary servo',(s*.63,.77,1.17),(s*.88,.77,1.17),.14,'Heatshield',24)

# Fusion drive: structural thrust frame, reactor throat, regenerative coolant lines, hollow bell.
lathe('Engine pressure chamber',[(4.38,.97),(4.62,1.0),(5.02,.89),(5.25,.60),(5.58,.53)],(0,0,0),'Heatshield',segments=72)
for y,r in [(4.56,1.01),(4.91,.94),(5.40,.57)]:torus('Reactor flange',(0,y,0),r,.065,'Alloy',segs=72)
for j in range(12):
 a=2*pi*j/12;d=Vector((cos(a),0,sin(a)))
 tube('Thrust frame strut',d*1.24+Vector((0,4.30,0)),d*.82+Vector((0,5.34,0)),.07,'Alloy',12)
 for y,r in [(4.55,1.12),(4.83,1.03)]:
  box('Magnet actuator',(r*cos(a),y,r*sin(a)),(.15,.27,.15),'Alloy',.02)
profile=[(5.45,.53),(5.65,.59),(5.95,.76),(6.3,.98),(6.7,1.21),(7.12,1.39),(7.56,1.52),(7.94,1.59),(8.0,1.59),(8.0,1.52),(7.56,1.45),(7.12,1.32),(6.7,1.14),(6.3,.91),(5.95,.69),(5.65,.52),(5.45,.46)]
lathe('Regeneratively cooled fusion bell',profile,(0,0,0),'Heatshield',segments=96)
for y,r in [(5.68,.61),(6.32,1.0),(7.13,1.41),(7.94,1.59)]:torus('Bell reinforcing hoop',(0,y,0),r,.036,'Alloy',segs=96)
for j in range(40):
 a=j*2*pi/40
 for (ya,ra),(yb,rb) in zip(profile[:8],profile[1:8]):
  tube('Bell cooling channel',((ra+.012)*cos(a),ya,(ra+.012)*sin(a)),((rb+.012)*cos(a),yb,(rb+.012)*sin(a)),.018,'Alloy',6,bevel=0)
for y,r in [(5.48,.56),(5.63,.59)]:torus('Copper coolant manifold',(0,y,0),r,.072,'Heatshield',segs=72)
lathe('Luminous fusion throat',[(5.46,.45),(5.47,.34),(5.48,.05)],(0,0,0),'Engines',segments=64)
torus('Modest ion sheath',(0,7.91,0),1.505,.023,'Engines',segs=96)
# Four RCS quads, four nozzles per block, all anchored near the rear hardpoints.
RCS=[]
for sx in [-1,1]:
 for sz in [-1,1]:
  c=Vector((sx*1.66,4.29,sz*1.02))
  sphere('RCS titanium pod',c,(.39,.48,.34),'Alloy',segs=24,rings=12)
  jets=[(c+Vector((-.13,.30,0)),Vector((0,1,0))),(c+Vector((.13,.30,0)),Vector((0,1,0))),(c+Vector((sx*.32,.03,0)),Vector((sx,0,0))),(c+Vector((0,.03,sz*.28)),Vector((0,0,sz)))]
  for p,d in jets:
   start=len(GROUPS['Hull']);rot=Vector((0,1,0)).rotation_difference(d).to_matrix().to_4x4()
   lathe('RCS nozzle',[(0,.085),(.18,.117),(.24,.126),(.24,.099),(.08,.05)],(0,0,0),'Heatshield',segments=20)
   torus('RCS lip',(0,.235,0),.113,.013,'Alloy',segs=20)
   lathe('RCS catalyst glow',[(.081,.049),(.082,.001)],(0,0,0),'Engines',segments=20)
   for o in GROUPS['Hull'][start:]:
    o.data.transform(rot);o.location=p+rot.to_3x3()@o.location
   RCS.append((p+d*.24,d))
# Fixed ventral railgun: shroud, twin rails, breech cables and muzzle aperture.
box('Railgun breech',(0,-4.85,-.64),(.64,1.72,.53),'Alloy',.10)
box('Railgun receiver',(0,-6.08,-.64),(.43,1.40,.34),'Heatshield',.05)
for s in [-1,1]:
 fair=mesh('Railgun cheek fairing',[(s*x,y,z) for x,y,z in [(.34,-4.5,-.47),(.31,-5.65,-.43),(.24,-6.12,-.54),(.24,-6.12,-.78),(.35,-5.65,-.91),(.40,-4.5,-.91)]],[(0,1,2,3,4,5)],'Paint')
 active(fair);sol=fair.modifiers.new('Fairing plate thickness','SOLIDIFY');sol.thickness=.04;bpy.ops.object.modifier_apply(modifier=sol.name)
 bev=fair.modifiers.new('Fairing edge radius','BEVEL');bev.width=.015;bev.segments=3;bpy.ops.object.modifier_apply(modifier=bev.name)
for s in [-1,1]:
 box('Railgun acceleration rail',(s*.12,-6.94,-.64),(.13,2.12,.18),'Alloy',.025)
 for y in [-6.06,-6.66,-7.25,-7.86]:box('Barrel ceramic isolator',(s*.12,y,-.64),(.16,.08,.22),'Radiator',.012)
 tube('Breech power bus',(s*.23,-4.1,-.58),(s*.23,-5.95,-.58),.032,'Heatshield',10)
box('Muzzle collar',(0,-7.94,-.64),(.39,.12,.32),'Alloy',.025)
box('Muzzle bore',(0,-8.005,-.64),(.145,.012,.115),'Cockpit',.015)
# Deployed folding radiators: five replaceable carbon emitter cassettes per side.
for s,group in [(1,'Radiator_L'),(-1,'Radiator_R')]:
 # Transform from panel coordinates (u along deployment span, v fore/aft).
 def P(u,v,h=0):return Vector((s*(2.16+u*.86),v,.26-u*.55+h))
 for y in [-.05,.95,2.0,3.05,4.10]:
  tube('Radiator hinge axle',(s*2.15,y-.13,.26),(s*2.15,y+.13,.26),.09,'Alloy',20)
  tube('Radiator rotary actuator',(s*2.15,y-.09,.26),(s*2.15,y+.09,.26),.13,'Heatshield',20)
 for i in range(5):
  ya=-.12+i*.85;yb=ya+.81
  vv=[P(u,v,h) for h in [-.045,.045] for u,v in [(0,ya),(1.267,ya),(1.267,yb),(0,yb)]]
  mesh('Radiator cassette',vv,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Radiator',group,.014)
  for u in [0,1.267]:tube('Radiator cassette edging',P(u,ya,.05),P(u,yb,.05),.028,'Alloy',8,group)
  for y in [ya,yb]:tube('Radiator collector',P(0,y,.05),P(1.267,y,.05),.026,'Alloy',8,group)
  for j in range(16):
   v=ya+.06+j*.046
   tube('Radiator capillary',P(.06,v,.052),P(1.20,v,.052),.011,'Heatshield',6,group,0)
  for u in [.04,1.225]:
   for y in [ya+.04,yb-.04]:sphere('Radiator captive screw',P(u,y,.08),(.019,.019,.019),'Alloy',group,segs=8,rings=4)
# Hull-mounted lights and hardpoint shoes.
for s in [-1,1]:
 box('Nav light housing',(s*2.18,3.72,.66),(.18,.34,.16),'Alloy',.04)
 nav=sphere('Navigation lens',(s*2.24,3.72,.69),(.06,.11,.05),'Lamps',segs=16,rings=8)
 for uv in nav.data.uv_layers.active.data:uv.uv=(.625 if s==1 else .875,.5)
 box('Retracted ordnance shoe',(s*1.14,.15,-1.04),(.35,.95,.16),'Heatshield',.04)
box('Beacon bezel',(0,2.24,1.625),(.19,.28,.075),'Alloy',.035)
beacon=box('Dorsal beacon',(0,2.24,1.668),(.12,.21,.025),'Lamps',.012)
for uv in beacon.data.uv_layers.active.data:uv.uv=(.375,.5)

# Consolidate all rigid assemblies. Keep moving node mesh coordinates relative to the hinge;
# never apply transforms to these moving nodes.
PIVOTS={'Hull':(0,0,0),'Canopy':(0,.77,1.17),'Radiator_L':(2.16,1.99,.26),'Radiator_R':(-2.16,1.99,.26)}
OBJECTS={}
for name,objs in GROUPS.items():
 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
 pivot=Vector(PIVOTS[name]);world=o.matrix_world.copy()
 # Bake each joined mesh's world transform into vertices, preserving the authored moving origin.
 o.data.transform(Matrix.Translation(-pivot)@world);o.matrix_world=Matrix.Translation(pivot)
 # Remove zero-area faces before export.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-10];bmesh.ops.delete(bm,geom=dead,context='FACES');bm.to_mesh(o.data);bm.free()
 OBJECTS[name]=o
for name in ['Canopy','Radiator_L','Radiator_R']:OBJECTS[name].parent=OBJECTS['Hull']
# Helpers: empty sockets are exported, display objects are not.
def socket(name,p,parent='Hull'):
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.empty_display_size=.18;o.parent=OBJECTS[parent];o.location=Vector(p)-Vector(PIVOTS[parent]);return o
socket('Thruster_1',(0,8,0))
for i,(p,d) in enumerate(RCS,2):
 o=socket(f'Thruster_{i}',p);o.rotation_euler=Vector((0,1,0)).rotation_difference(d).to_euler()
socket('Muzzle_1',(0,-8.02,-.64))
for i,s in enumerate([1,-1],1):socket(f'Hardpoint_{i}',(s*1.14,-.35,-1.14))
socket('Seat_Pilot',(0,-2.25,.52));socket('Seat_Copilot',(0,-.65,.52));socket('Camera_Chase',(0,12,5.3))
socket('Light_Nav_L',(2.25,3.72,.69));socket('Light_Nav_R',(-2.25,3.72,.69));socket('Light_Beacon',(0,2.24,1.70))
# Exact overall dimensions: apply a single coordinate correction to data and node locations,
# not object transforms, preserving all hinge frames.
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(v) for o in OBJECTS.values() for v in o.bound_box]
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)])
S=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)))
SM=Matrix.Diagonal(Vector((*S,1)))
for o in list(sc.objects):
 if o.type=='MESH':o.data.transform(SM)
 o.location=Vector([o.location[i]*S[i] for i in range(3)])
bpy.context.view_layer.update()
for o in OBJECTS.values():
 active(o);tri=o.modifiers.new('Export triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
if triangles>90000:
 for o in OBJECTS.values():
  active(o);dec=o.modifiers.new('Budget reduction','DECIMATE');dec.ratio=88000/triangles;bpy.ops.object.modifier_apply(modifier=dec.name)
 triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
# Final millimetre correction after reduction; moving pivots keep identity scale.
bpy.context.view_layer.update()
pts=[o.matrix_world@v.co for o in OBJECTS.values() 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)])
F=Vector((6.5/(hi.x-lo.x),16/(hi.y-lo.y),4.2/(hi.z-lo.z)));FM=Matrix.Diagonal(Vector((*F,1)))
for o in list(sc.objects):
 if o.type=='MESH':o.data.transform(FM)
 o.location=Vector([o.location[i]*F[i] for i in range(3)])
bpy.context.view_layer.update()
# Rebuild normals after boolean cuts AND final budget reduction. Large armour planes
# use area-weighted normals; smooth round components retain their continuous highlights.
for o in OBJECTS.values():
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);dead=[f for f in bm.faces if f.calc_area()<1e-8]
 if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
 bm.to_mesh(o.data);bm.free()
 for p in o.data.polygons:p.use_smooth=True
 o.data.set_sharp_from_angle(angle=math.radians(35))
 active(o);wn=o.modifiers.new('Final area weighted shading','WEIGHTED_NORMAL');wn.keep_sharp=True;wn.weight=50;wn.mode='FACE_AREA';bpy.ops.object.modifier_apply(modifier=wn.name)
triangles=sum(len(o.data.polygons) for o in OBJECTS.values())
print('GEOMETRY',triangles,'triangles',flush=True)

# Render rig: camera-visible blue gradient, neutral daylight key and cool fill; no ground.
world=bpy.data.worlds.new('Deep space');world.use_nodes=True;sc.world=world
nt=world.node_tree;nt.nodes.clear();N=nt.nodes;L=nt.links
out=N.new('ShaderNodeOutputWorld');bg=N.new('ShaderNodeBackground');bg.inputs['Strength'].default_value=.24
coord=N.new('ShaderNodeTexCoord');sep=N.new('ShaderNodeSeparateXYZ');L.new(coord.outputs['Normal'],sep.inputs[0]);r=N.new('ShaderNodeValToRGB');r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.003,.006,.014,1);r.color_ramp.elements[1].position=1;r.color_ramp.elements[1].color=(.025,.043,.075,1);L.new(sep.outputs['Z'],r.inputs[0]);L.new(r.outputs[0],bg.inputs[0]);L.new(bg.outputs[0],out.inputs[0])
def area(name,loc,power,color,size,target=(0,0,0)):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.color=color;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
area('Upper front white key',(3,-8,12),2500,(1,.97,.92),7)
area('Cool soft fill',(-8,-1,6),1800,(.64,.78,1),8)
area('Engine rim',(1,9,8),2900,(.72,.83,1),6)
area('Soft belly fill',(3,-2,-6),700,(.66,.73,1),6)
sun=bpy.data.lights.new('Sunlight','SUN');sun.energy=1.2;sun.angle=.08;sun.color=(1,.98,.95);so=bpy.data.objects.new('Sunlight',sun);sc.collection.objects.link(so);so.rotation_euler=(.4,-.5,-.4)
camd=bpy.data.cameras.new('Evidence camera');cam=bpy.data.objects.new('Evidence camera',camd);sc.collection.objects.link(cam);sc.camera=cam
HERO=(15,-22,14)
def camera(loc,target=(0,0,.1),ortho=19.0):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=ortho
camera(HERO)
def render(path,w=1920,h=1080,samples=48):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.cycles.samples=samples;sc.render.filepath=str(path);bpy.ops.render.render(write_still=True)
# First a clay gate with all silhouette geometry visible.
clay=special('_Clay',(.32,.34,.36),.15,.42)
sc.view_layers[0].material_override=clay
render(ROOT/'clay.png',1920,1080,24)
sc.view_layers[0].material_override=None
# Save and export the first complete asset before final evidence renders.
paint=MATS['Paint'];pnt=paint.node_tree;bs=pnt.nodes.get('Principled BSDF');tint=pnt.nodes['Faction paint factor'];base=next(n for n in pnt.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_Base')
pnt.links.new(base.outputs[0],bs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in sc.objects:
 if o.type in ['MESH','EMPTY']:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'interceptor-hardsf.glb'),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_extras=True)
# Make the runtime paint and glass contracts explicit in the GLB JSON.
p=ROOT/'interceptor-hardsf.glb';raw=p.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);g=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
for m in g['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.55,.56,.55,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j=json.dumps(g,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4E4F534A)+j+binary)
pnt.links.new(tint.outputs[0],bs.inputs['Base Color'])
# Manifest uses glTF coordinates, not Blender coordinates.
def gltfpos(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
pts=[o.matrix_world@Vector(v) for o in OBJECTS.values() for v in o.bound_box]
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)])
PAINTS=[('Naval grey',[.55,.56,.55]),('Gunmetal',[.16,.185,.21]),('Olive drab',[.22,.27,.16]),('Desert sand',[.64,.52,.34]),('Arctic white',[.86,.88,.87]),('Deep navy',[.055,.105,.19])]
manifest={'asset':'interceptor-hardsf','units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'hull centre of mass estimate','nodes':{o.name:{'position':gltfpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'socket'} for o in sc.objects if o.type in ['MESH','EMPTY']},'axes':{'Canopy':[-1,0,0],'Radiator_L':[0,0,1],'Radiator_R':[0,0,-1]},'bounding_box':{'min':[round(lo.x,5),round(lo.z,5),round(-hi.y,5)],'max':[round(hi.x,5),round(hi.z,5),round(-lo.y,5)],'size':[6.5,4.2,16.0]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':triangles,'materials':list(MATS)[:-1],'bakes':BAKE_LOG,'moving_part_test':{'degrees':30,'origin_drift_metres':0}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(['Original 16.0 × 6.5 × 4.2 m hard-SF tandem interceptor; hinged canopy, deployed radiators, railgun, four RCS quads and fusion bell.',f'{triangles:,} triangles; fixed Hull and three moving mesh nodes; Y up, +Z nose; metre scale.', '8 materials: Paint, Alloy, Heatshield, Radiator, Cockpit, Glass, Engines, Lamps.', 'Cycles baked: shared tiling colour/normal 1024 px, ORM 512 px; optical/emissive 64 px; neutral Paint texture × faction factor.', 'CC0 photographic sources: Poly Haven metal_plate and metal_plate_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/metal_plate_02 .','Reference differences: radiator cassettes are deployed; original cheek armour, twin-rail muzzle and engine framing; no exhaust plume or markings.'])+'\n')
(EVID/'bake_provenance.json').write_text(json.dumps(BAKE_LOG,indent=2))
bpy.ops.wm.save_as_mainfile(filepath=str(EVID/'interceptor.blend'))
print('FIRST_GLB_COMPLETE',round(time.time()-START,1),p.stat().st_size,flush=True)
render(ROOT/'final.png')
camera((20,0,.25),(0,0,.25),18.5);render(ROOT/'side.png',1920,1080,24)
camera((12,19,10),(0,1,0),18.0);render(EVID/'rear.png',1280,800,24)
camera((7,-10,6),(0,-1.7,1.1),7.7);render(EVID/'cockpit.png',1280,900,24)
# Explicit 30 degree test of every moving assembly around its own pivot, then restore.
camera(HERO)
for n in ['Canopy','Radiator_L','Radiator_R']:
 o=OBJECTS[n];before=o.matrix_world.translation.copy();o.rotation_euler[0 if n=='Canopy' else 1]=math.radians(30 if n=='Radiator_R' else -30)
 bpy.context.view_layer.update();assert (o.matrix_world.translation-before).length<1e-6
render(EVID/'pivot_test.png',1280,720,24)
for n in ['Canopy','Radiator_L','Radiator_R']:OBJECTS[n].rotation_euler=(0,0,0)
# Six-paint proof in a real rendered 3x2 contact sheet, no typography on the asset.
frames=[]
for name,c in PAINTS:
 tint.inputs[2].default_value=(*c,1);render(EVID/f'paint_{name.replace(" ","_")}.png',640,360,16)
 im=bpy.data.images.load(str(EVID/f'paint_{name.replace(" ","_")}.png'),check_existing=False);a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);frames.append(a.reshape(360,640,4).copy());bpy.data.images.remove(im)
grid=np.concatenate([np.concatenate(frames[3:],axis=1),np.concatenate(frames[:3],axis=1)],axis=0)
im=bpy.data.images.new('Six faction paints',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
tint.inputs[2].default_value=(.55,.56,.55,1)
print('BUILD_COMPLETE',round(time.time()-START,1),triangles,p.stat().st_size,flush=True)
