#!/usr/bin/env python3
"""Light cruiser. Clean Blender 5.2 build; source textures are CC0 Poly Haven.
Run: blender -b --python-exit-code 1 --python build_light-cruiser.py
All design coordinates are metres in game frame (X left, Y up, Z forward).
The single script downloads sources, Cycles-bakes shared tiles, builds, exports,
checks articulation, renders all deliverables, and writes the node manifest.
"""
import bpy, bmesh, math, json, os, sys, struct, random, urllib.request, time
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
import ctypes
# The headless distribution ships Draco beside Blender, outside ld.so search paths.
draco_lib=Path(bpy.app.binary_path).parent/'lib'/'libdraco.so.9'
if draco_lib.exists():ctypes.CDLL(str(draco_lib),mode=ctypes.RTLD_GLOBAL)
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; SOURCE=TEX/'source'; QA=ROOT/'qa'
for p in (TEX,SOURCE,QA):p.mkdir(exist_ok=True,parents=True)
random.seed(41)
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.samples=24; SC.cycles.use_denoising=True
SC.cycles.max_bounces=6; SC.cycles.transparent_max_bounces=6
prefs=bpy.context.preferences.addons['cycles'].preferences
try:prefs.compute_device_type='OPTIX'
except:prefs.compute_device_type='CUDA'
prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
assert any(d.use for d in prefs.devices),'GPU required'
SC.cycles.device='GPU'
SC.render.image_settings.file_format='PNG'; SC.render.film_transparent=False
SC.view_settings.view_transform='AgX'; SC.view_settings.look='AgX - Medium High Contrast'; SC.view_settings.exposure=0.0
print('GPU',[(d.name,d.type,d.use) for d in prefs.devices],flush=True)
PH_IDS=['blue_metal_plate','metal_plate_02','rusty_metal_02']
for name in PH_IDS:
 for mp in ['diff','nor_gl','arm']:
  f=SOURCE/f'{name}_{mp}_1k.jpg'
  if not f.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{name}/{f.name}'
   req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0 Forge asset builder'})
   f.write_bytes(urllib.request.urlopen(req,timeout=90).read())

def B(p):return Vector((p[0],-p[2],p[1]))
def G(p):return [float(p[0]),float(p[2]),float(-p[1])]
def activate(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def mathnode(nt,op,a=None,b=None):
 n=nt.nodes.new('ShaderNodeMath');n.operation=op
 for i,v in enumerate((a,b)):
  if v is not None:
   if hasattr(v,'node'):nt.links.new(v,n.inputs[i])
   else:n.inputs[i].default_value=v
 return n.outputs[0]
def rgb(nt,col):
 n=nt.nodes.new('ShaderNodeRGB');n.outputs[0].default_value=(*col,1);return n.outputs[0]
def texnode(nt,path,noncolor=False):
 n=nt.nodes.new('ShaderNodeTexImage');n.image=bpy.data.images.load(str(path),check_existing=True)
 if noncolor:n.image.colorspace_settings.name='Non-Color'
 n.extension='REPEAT';return n

# A material specimen is baked, not a 320m unique atlas. Its UV tile is 8m.
def bake_tile(label,source,size,base_mode):
 print('BAKE',label,flush=True)
 mat=bpy.data.materials.new('SOURCE_'+label);mat.use_nodes=True;nt=mat.node_tree;nt.nodes.clear()
 out=nt.nodes.new('ShaderNodeOutputMaterial');bs=nt.nodes.new('ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
 uv=nt.nodes.new('ShaderNodeTexCoord')
 dif=texnode(nt,SOURCE/f'{source}_diff_1k.jpg');nor=texnode(nt,SOURCE/f'{source}_nor_gl_1k.jpg',True);arm=texnode(nt,SOURCE/f'{source}_arm_1k.jpg',True)
 for n in (dif,nor,arm):nt.links.new(uv.outputs['UV'],n.inputs['Vector'])
 bw=nt.nodes.new('ShaderNodeRGBToBW');nt.links.new(dif.outputs['Color'],bw.inputs[0])
 noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=95;noise.inputs['Detail'].default_value=3
 nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
 sep=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],sep.inputs[0])
 # Shared 8m scanned panel tile; macro panel seams are modelled geometry.
 ux=mathnode(nt,'FRACT',mathnode(nt,'MULTIPLY',sep.outputs['X'],1))
 vy=mathnode(nt,'FRACT',mathnode(nt,'MULTIPLY',sep.outputs['Y'],1))
 edge=mathnode(nt,'MAXIMUM',mathnode(nt,'LESS_THAN',ux,.006),mathnode(nt,'LESS_THAN',vy,.010))
 near=mathnode(nt,'MAXIMUM',mathnode(nt,'LESS_THAN',ux,.012),mathnode(nt,'LESS_THAN',vy,.018))
 pits=mathnode(nt,'LESS_THAN',noise.outputs['Fac'],.29)
 if base_mode=='paint':
  # Preserve the photograph's abrasion and blotches without a synthetic grain.
  coarse=nt.nodes.new('ShaderNodeTexNoise');coarse.inputs['Scale'].default_value=11;coarse.inputs['Detail'].default_value=3
  nt.links.new(uv.outputs['UV'],coarse.inputs['Vector'])
  grime=mathnode(nt,'MULTIPLY',mathnode(nt,'MAXIMUM',mathnode(nt,'SUBTRACT',coarse.outputs['Fac'],.54),0),.45)
  color=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',mathnode(nt,'POWER',bw.outputs[0],.5),2.4),.22)
  color=mathnode(nt,'MINIMUM',mathnode(nt,'MAXIMUM',mathnode(nt,'SUBTRACT',color,grime),.22),.94)
  color=mathnode(nt,'SUBTRACT',color,mathnode(nt,'MULTIPLY',pits,.11))
  metallic=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',mathnode(nt,'GREATER_THAN',bw.outputs[0],.09),.30),.13)
  rough=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',coarse.outputs['Fac'],.22),.42)
 elif base_mode=='metal':
  color=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',mathnode(nt,'POWER',bw.outputs[0],.5),.75),.12)
  metallic=rgb(nt,(.82,.82,.82));rough=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',noise.outputs['Fac'],.25),.43)
 else:
  mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(dif.outputs[0],mix.inputs[1]);mix.inputs[2].default_value=(.095,.082,.068,1)
  color=mix.outputs[0];metallic=rgb(nt,(.78,.78,.78));rough=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',noise.outputs['Fac'],.22),.53)
 nt.links.new(color,bs.inputs['Base Color']);nt.links.new(rough,bs.inputs['Roughness']);nt.links.new(metallic,bs.inputs['Metallic'])
 normal=nt.nodes.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.38 if base_mode=='paint' else .55;nt.links.new(nor.outputs[0],normal.inputs['Color'])
 bump=nt.nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.38;bump.inputs['Distance'].default_value=.035
 height=mathnode(nt,'SUBTRACT',mathnode(nt,'MULTIPLY',noise.outputs['Fac'],.12),mathnode(nt,'MULTIPLY',edge,.95))
 if base_mode=='paint':height=mathnode(nt,'MULTIPLY',noise.outputs['Fac'],.025)
 nt.links.new(height,bump.inputs['Height']);nt.links.new(normal.outputs[0],bump.inputs['Normal']);nt.links.new(bump.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=8);o=bpy.context.object;o.data.materials.append(mat)
 emit=nt.nodes.new('ShaderNodeEmission');target=nt.nodes.new('ShaderNodeTexImage');nt.nodes.active=target
 def bake(suffix,res,typ,socket=None):
  im=bpy.data.images.new(label+'_'+suffix,width=res,height=res,alpha=False)
  if suffix!='BaseColor':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  if typ=='EMIT':
   nt.links.new(socket,emit.inputs[0]);nt.links.new(emit.outputs[0],out.inputs[0])
  else:nt.links.new(bs.outputs[0],out.inputs[0])
  SC.cycles.samples=8;SC.render.bake.margin=8
  bpy.ops.object.bake(type=typ)
  im.filepath_raw=str(TEX/(label+'_'+suffix+('.jpg' if suffix=='BaseColor' else '.png')));im.file_format='JPEG' if suffix=='BaseColor' else 'PNG';im.save()
  return im
 base=bake('BaseColor',size,'EMIT',color)
 normalim=bake('Normal',size,'NORMAL')
 split=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],split.inputs[0])
 combine=nt.nodes.new('ShaderNodeCombineColor');combine.mode='RGB'
 ao=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',split.outputs[0],.23),.77)
 nt.links.new(ao,combine.inputs[0]);nt.links.new(rough,combine.inputs[1]);nt.links.new(metallic,combine.inputs[2])
 orm=bake('ORM',min(512,size),'EMIT',combine.outputs[0])
 bpy.data.objects.remove(o,do_unlink=True);bpy.data.materials.remove(mat)
 return base,normalim,orm

BAKES={}
for label,src,size,mode in [('HullTile','blue_metal_plate',1024,'paint'),('MetalTile','metal_plate_02',1024,'metal'),('HeatTile','rusty_metal_02',512,'heat')]:
 BAKES[label]=bake_tile(label,src,size,mode)
# Small shared signal/optical trims are also true Cycles bakes.
def bake_signal(label,base_color,mode,res):
 mat=bpy.data.materials.new('SOURCE_'+label);mat.use_nodes=True;nt=mat.node_tree;nt.nodes.clear()
 out=nt.nodes.new('ShaderNodeOutputMaterial');bs=nt.nodes.new('ShaderNodeBsdfPrincipled');em=nt.nodes.new('ShaderNodeEmission')
 uv=nt.nodes.new('ShaderNodeTexCoord');se=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],se.inputs[0])
 noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=110;noise.inputs['Detail'].default_value=2;nt.links.new(uv.outputs['UV'],noise.inputs[0])
 color=rgb(nt,base_color)
 if mode=='lamps':
  ramp=nt.nodes.new('ShaderNodeValToRGB');ramp.color_ramp.interpolation='CONSTANT'
  palette=[(.10,.42,.60),(.16,.58,.72),(.08,.28,.40),(.22,.54,.62),(.85,.38,.09),(.52,.65,.70),(1,.008,.004),(.008,.72,.06)]
  for e in list(ramp.color_ramp.elements)[1:]:ramp.color_ramp.elements.remove(e)
  for i,col in enumerate(palette):
   e=ramp.color_ramp.elements[0] if i==0 else ramp.color_ramp.elements.new(i/8)
   e.color=(*col,1)
  nt.links.new(se.outputs[0],ramp.inputs[0]);color=ramp.outputs[0]
 if mode=='engine':
  dist=nt.nodes.new('ShaderNodeVectorMath');dist.operation='DISTANCE';nt.links.new(uv.outputs['UV'],dist.inputs[0]);dist.inputs[1].default_value=(.5,.5,0)
  ramp=nt.nodes.new('ShaderNodeValToRGB');nt.links.new(dist.outputs['Value'],ramp.inputs[0])
  ramp.color_ramp.elements[0].position=0;ramp.color_ramp.elements[0].color=(1,.48,.07,1)
  ramp.color_ramp.elements[1].position=.62;ramp.color_ramp.elements[1].color=(.38,.027,.001,1)
  for pos,col in [(.25,(1,.22,.013,1)),(.32,(.16,.012,.001,1)),(.34,(1,.18,.008,1)),(.45,(.55,.06,.002,1)),(.48,(1,.24,.02,1))]:ramp.color_ramp.elements.new(pos).color=col
  color=ramp.outputs[0]
 mult=nt.nodes.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;nt.links.new(color,mult.inputs[1]);nt.links.new(mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',noise.outputs['Fac'],.20),.8),mult.inputs[2]);color=mult.outputs[0]
 nt.links.new(color,bs.inputs['Base Color']);bs.inputs['Roughness'].default_value=.22 if mode=='glass' else .42
 bump=nt.nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.025;bump.inputs['Distance'].default_value=.002;nt.links.new(noise.outputs['Fac'],bump.inputs['Height']);nt.links.new(bump.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=1);ob=bpy.context.object;ob.data.materials.append(mat)
 target=nt.nodes.new('ShaderNodeTexImage');nt.nodes.active=target;result=[]
 for suffix,sz,kind in [('BaseColor',res,'EMIT'),('Normal',min(res,128),'NORMAL'),('ORM',min(res,128),'EMIT')]:
  im=bpy.data.images.new(label+'_'+suffix,width=sz,height=sz,alpha=False)
  if suffix!='BaseColor':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  if kind=='NORMAL':nt.links.new(bs.outputs[0],out.inputs[0])
  else:
   nt.links.new(color if suffix=='BaseColor' else rgb(nt,(1,.22 if mode=='glass' else .42,.10 if mode=='glass' else 0)),em.inputs[0]);nt.links.new(em.outputs[0],out.inputs[0])
  SC.cycles.samples=4;bpy.ops.object.bake(type=kind)
  im.filepath_raw=str(TEX/(label+'_'+suffix+'.png'));im.file_format='PNG';im.save();result.append(im)
 bpy.data.objects.remove(ob,do_unlink=True);bpy.data.materials.remove(mat)
 return tuple(result)
BAKES['SignalTrim']=bake_signal('SignalTrim',(1,1,1),'lamps',256)
BAKES['EngineTrim']=bake_signal('EngineTrim',(1,.19,.014),'engine',256)
BAKES['OpticalTile']=bake_signal('OpticalTile',(.025,.16,.25),'glass',64)

MATS=[];FACTORS={};COLOR_NODES={};RENDER_OUTPUTS={}
def material(name,tile=None,factor=(1,1,1),metal=.0,rough=.5,emission=None,alpha=1):
 m=bpy.data.materials.new(name);m.use_nodes=True;m.use_backface_culling=True;nt=m.node_tree;bs=nt.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(*factor,alpha);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 if tile:
  base,nrm,orm=BAKES[tile]
  t=nt.nodes.new('ShaderNodeTexImage');t.image=base
  mul=nt.nodes.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*factor,1)
  nt.links.new(t.outputs[0],mul.inputs[1]);nt.links.new(mul.outputs[0],bs.inputs['Base Color']);COLOR_NODES[name]=mul;FACTORS[name]=factor
  n=nt.nodes.new('ShaderNodeTexImage');n.image=nrm;nm=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(n.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
  t=nt.nodes.new('ShaderNodeTexImage');t.image=orm;sp=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(t.outputs[0],sp.inputs[0]);nt.links.new(sp.outputs[1],bs.inputs['Roughness']);nt.links.new(sp.outputs[2],bs.inputs['Metallic'])
  grp=bpy.data.node_groups.get('glTF Material Output')
  if not grp:
   grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
  gn=nt.nodes.new('ShaderNodeGroup');gn.node_tree=grp;nt.links.new(sp.outputs[0],gn.inputs['Occlusion'])
 if emission:
  bs.inputs['Emission Color'].default_value=(*emission,1);bs.inputs['Emission Strength'].default_value=1.3 if name=='Lamps' else 2.8
  if tile:nt.links.new(COLOR_NODES[name].inputs[1].links[0].from_socket,bs.inputs['Emission Color'])
 if alpha<1:
  bs.inputs['Alpha'].default_value=alpha;bs.inputs['Transmission Weight'].default_value=0
  m.surface_render_method='DITHERED'
 if name in COLOR_NODES:
  RENDER_OUTPUTS[name]=COLOR_NODES[name].outputs[0]
  if name=='Paint':
   attr=nt.nodes.new('ShaderNodeVertexColor');attr.layer_name='Weathering'
   tone=nt.nodes.new('ShaderNodeMixRGB');tone.blend_type='MULTIPLY';tone.inputs[0].default_value=1
   nt.links.new(RENDER_OUTPUTS[name],tone.inputs[1]);nt.links.new(attr.outputs['Color'],tone.inputs[2]);nt.links.new(tone.outputs[0],bs.inputs['Base Color']);RENDER_OUTPUTS[name]=tone.outputs[0]
 MATS.append(m);return len(MATS)-1
PAINT=material('Paint','HullTile',(.60,.61,.62))
METAL=material('Titanium','MetalTile',(.59,.63,.67))
DARK=material('DarkMetal','MetalTile',(.105,.135,.17))
HEAT=material('Scorch','HeatTile',(.7,.76,.84))
GLASS=material('Glass','OpticalTile',factor=(1,1,1),alpha=.55)
LAMP=material('Lamps','SignalTrim',factor=(1,1,1),emission=(1,1,1))
ENGINE=material('Engines','EngineTrim',factor=(1,1,1),emission=(1,1,1))

# Geometry accumulation in game coordinates. Bevels are real geometry, no live modifiers.
CACHE={}
class Assembly:
 def __init__(self,name,pivot=(0,0,0),parent=None):self.name=name;self.pivot=Vector(pivot);self.parent=parent;self.v=[];self.f=[];self.mi=[];self.sm=[];self.o=None
 def add(self,verts,faces,mat,smooth=False):
  n=len(self.v);self.v.extend(verts);self.f.extend([tuple(n+i for i in f) for f in faces]);self.mi.extend([mat]*len(faces));self.sm.extend([smooth]*len(faces))
 def finish(self):
  me=bpy.data.meshes.new(self.name);me.from_pydata([B(Vector(v)-self.pivot) for v in self.v],[],self.f);me.update()
  o=bpy.data.objects.new(self.name,me);SC.collection.objects.link(o)
  for m in MATS:me.materials.append(m)
  for p,mi,sm in zip(me.polygons,self.mi,self.sm):p.material_index=mi;p.use_smooth=sm
  uv=me.uv_layers.new(name='UV_Metres')
  # Orthographic face projection, same world scale across all rigid parts.
  for p in me.polygons:
   n=p.normal;ax=max(range(3),key=lambda i:abs(n[i]))
   for li in p.loop_indices:
    w=me.vertices[me.loops[li].vertex_index].co+B(self.pivot)
    pair=(w.x,-w.y) if ax==2 else ((-w.y,w.z) if ax==0 else (w.x,w.z))
    scale=8 if p.material_index==PAINT else 4
    uv.data[li].uv=(pair[0]/scale,pair[1]/scale)
    if p.material_index==LAMP:
     c=G(p.center+B(self.pivot));index=(abs(int(c[2]/2.15))+int(abs(c[0])))%4
     if c[1]<-1 and 65<c[2]<102:index=4
     if c[1]>34:index=6
     if -139<c[2]<-135 and 1<c[1]<3:index=6 if c[0]>0 else 7
     uv.data[li].uv=((index+.46+(pair[0]%1)*.08)/8,.45+(pair[1]%1)*.10)
    elif p.material_index==ENGINE:
     c=G(w);cx=18 if c[0]>0 else -18;cy=8.8 if c[1]>0 else -8.8
     uv.data[li].uv=((c[0]-cx)/13+.5,(c[1]-cy)/13+.5)
  colors=me.color_attributes.new(name='Weathering',type='BYTE_COLOR',domain='CORNER')
  for p in me.polygons:
   c=G(p.center+B(self.pivot));tone=1.0
   if p.material_index==PAINT:
    batch=(int((c[2]+200)/14)*37+int((c[0]+40)/8)*17)%11
    tone=.76+batch*.022
    if c[1]<-2:tone*=.79
    if c[2]<-103:
     # Broad neutral heat/grime deposited around the engine-neck panel edges.
     tone*=.60+.40*min(1,abs(c[2]+119)/22)
   for li in p.loop_indices:
    t=tone
    if p.material_index==PAINT:
     v=G(me.vertices[me.loops[li].vertex_index].co+B(self.pivot))
     if v[2]<-95:t*=1-.45*math.exp(-((v[2]+119)/10)**2)
    colors.data[li].color=(t,t,t,1)
  o.location=B(self.pivot)
  if self.parent:
   o.parent=self.parent.o;o.location=B(self.pivot-self.parent.pivot)
  self.o=o
  return o

def beveled(v,f,width,segments=2):
 bm=bmesh.new();vv=[bm.verts.new(p) for p in v];bm.verts.ensure_lookup_table()
 for face in f:
  try:bm.faces.new([vv[i] for i in face])
  except ValueError:pass
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
 if width:
  bmesh.ops.bevel(bm,geom=list(bm.edges),offset=width,segments=segments,affect='EDGES',profile=.5)
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.verts.ensure_lookup_table();bm.verts.index_update()
 outv=[tuple(x.co) for x in bm.verts];outf=[tuple(x.index for x in face.verts) for face in bm.faces];bm.free();return outv,outf

def box(a,c,d,mat=PAINT,bev=.12,rot=None):
 key=(tuple(round(x,5) for x in d),bev)
 if key not in CACHE:
  x,y,z=[q/2 for q in d];v=[(-x,-y,-z),(x,-y,-z),(x,y,-z),(-x,y,-z),(-x,-y,z),(x,-y,z),(x,y,z),(-x,y,z)]
  f=[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)]
  CACHE[key]=beveled(v,f,min(bev,min(d)*.22),1 if min(d)<.7 else 2)
 v,f=CACHE[key];c=Vector(c)
 a.add([tuple((rot@Vector(p) if rot else Vector(p))+c) for p in v],f,mat)

def poly(a,verts,faces,mat,bev=.1):
 v,f=beveled(verts,faces,bev,2);a.add(v,f,mat)

def tube(a,p1,p2,r1,r2=None,mat=METAL,n=16,inside=0):
 p1=Vector(p1);p2=Vector(p2);d=(p2-p1).normalized();u=d.cross(Vector((0,1,0)))
 if u.length<.01:u=d.cross(Vector((1,0,0)))
 u.normalize();v=d.cross(u).normalized();r2=r1 if r2 is None else r2
 # Bevelled collars, capped or open-wall tube.
 length=(p2-p1).length;be=min(.06,r1*.12,length*.1)
 rings=[(0,r1-be),(be,r1),(length-be,r2),(length,r2-be)]
 if inside:rings += [(length,inside),(length-be,inside),(be,inside),(0,inside)]
 verts=[tuple(p1+d*t+u*(r*math.cos(j*2*math.pi/n))+v*(r*math.sin(j*2*math.pi/n))) for t,r in rings for j in range(n)]
 faces=[]
 for k in range(len(rings)-1):
  for j in range(n):faces.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
 if inside:
  for j in range(n):faces.append(((len(rings)-1)*n+j,(len(rings)-1)*n+(j+1)%n,(j+1)%n,j))
 else:faces.extend([tuple(reversed(range(n))),tuple((len(rings)-1)*n+j for j in range(n))])
 a.add(verts,faces,mat,True)

def ring(a,c,axis,r,thick,mat,n=32):
 c=Vector(c);d=Vector(axis).normalized();u=d.cross(Vector((0,1,0)))
 if u.length<.01:u=d.cross(Vector((1,0,0)))
 u.normalize();v=d.cross(u);vs=[];fs=[]
 for i in range(n):
  q=u*math.cos(i*2*math.pi/n)+v*math.sin(i*2*math.pi/n)
  for j in range(6):vs.append(tuple(c+q*(r+thick*math.cos(j*math.pi/3))+d*(thick*math.sin(j*math.pi/3))))
 for i in range(n):
  for j in range(6):fs.append((i*6+j,((i+1)%n)*6+j,((i+1)%n)*6+(j+1)%6,i*6+(j+1)%6))
 a.add(vs,fs,mat,True)

def octagon(w,h,cy=0):
 x=w/2;y=h/2;cx=min(w*.12,5);dy=min(h*.24,4)
 return [(x,cy+y-dy),(x-cx,cy+y),(-x+cx,cy+y),(-x,cy+y-dy),(-x,cy-y+dy),(-x+cx,cy-y),(x-cx,cy-y),(x,cy-y+dy)]

def loft(a,sections,mat=PAINT,bev=.15):
 vs=[]
 for z,w,h,y in sections:vs.extend([(x,yy,z) for x,yy in octagon(w,h,y)])
 fs=[]
 for k in range(len(sections)-1):
  for i in range(8):fs.append((k*8+i,k*8+(i+1)%8,(k+1)*8+(i+1)%8,(k+1)*8+i))
 fs.extend([tuple(reversed(range(8))),tuple((len(sections)-1)*8+i for i in range(8))]);poly(a,vs,fs,mat,bev)

H=Assembly('Hull');ASSEMBLIES=[H];SOCKETS=[]
def socket(name,p,parent=None):SOCKETS.append((name,Vector(p),parent))
# Closed internal load-bearing spine, upper and lower shell profiled separately.
SECTIONS=[(-131,60,28,0),(-92,60,28,0),(20,58,28,0),(62,56,25,0),(105,44,19,0),(145,28,10,0),(154,28,8,0)]
def profile(z):
 for l,r in zip(SECTIONS,SECTIONS[1:]):
  if l[0]<=z<=r[0]:
   t=(z-l[0])/(r[0]-l[0]);return tuple(l[i]*(1-t)+r[i]*t for i in (1,2,3))
 return SECTIONS[0][1:] if z<SECTIONS[0][0] else SECTIONS[-1][1:]
# Central recessed machinery strip between thick upper/lower hulls.
loft(H,[(z,w-3,2.6,0) for z,w,h,y in SECTIONS],DARK,.16)
loft(H,[(z,w,h/2-1, h/4+.5) for z,w,h,y in SECTIONS],PAINT,.55)
# Underbow hangar openings: omit the outer side walls for z=66..101.
for z0,z1 in [(-131,-92),(-92,20),(20,62),(62,66),(66,101),(101,105),(105,145),(145,154)]:
 w0,h0,_=profile(z0);w1,h1,_=profile(z1)
 if z0==66:
  # Floor, ceiling and inward bulkheads leave real side openings.
  loft(H,[(z0,w0,h0*.12,-h0*.44),(z1,w1,h1*.12,-h1*.44)],METAL,.18)
  loft(H,[(z0,w0-15,h0/2-2,-h0/4-1),(z1,w1-15,h1/2-2,-h1/4-1)],METAL,.15)
 else:loft(H,[(z0,w0,h0/2-1,-h0/4-.5),(z1,w1,h1/2-1,-h1/4-.5)],PAINT,.50)
# Recess lips: sloped cheek panels form the mouth's upper coaming.
for s in (-1,1):
 for zz in [67,100]:
  w,h,_=profile(zz);box(H,(s*(w/2-.9),-h*.27,zz),(1.8,h*.43,1.35),METAL,.25)
 w0,h0,_=profile(67);w1,h1,_=profile(100)
 tube(H,(s*(w0/2-.4),-h0*.48,67),(s*(w1/2-.4),-h1*.48,100),.48,mat=METAL,n=12)
 tube(H,(s*(w0/2-.25),-1.5,67),(s*(w1/2-.25),-1.5,100),.5,mat=METAL,n=12)
 for zz in range(70,100,4):
  w,h,_=profile(zz);box(H,(s*(w/2-6.5),-h*.27,zz),(.3,h*.3,.45),METAL,.06)
  box(H,(s*(w/2-2.4),-h*.40,zz),(3.6,.12,.25),LAMP,.035)
 # Loading gantry, overhead rails and piping inside the opening.
 tube(H,(s*16,-3,69),(s*12,-3,99),.24,mat=METAL,n=10)
 box(H,(s*15,-6,82),(1,3,8),DARK,.12)
 for zz in (74,87,96):
  w,h,_=profile(zz);xx=s*(w/2-5)
  box(H,(xx,-h*.40+1.4,zz),(2.5,2.8,3),METAL,.13)
  for dx in (-1.15,1.15):box(H,(xx+dx,-h*.40+1.4,zz),(.16,2.8,3.1),METAL,.04)
# Primary hull panel grid; long flat plates interrupted by transverse seams.
for z0,z1 in zip([-130,-116,-102,-88,-74,-60,-46,-32,-18,-4,10,24,38,52,66,80,94,108,122,136,145],[-116,-102,-88,-74,-60,-46,-32,-18,-4,10,24,38,52,66,80,94,108,122,136,145,153]):
 z0+=.12;z1-=.12;w0,h0,_=profile(z0);w1,h1,_=profile(z1)
 for side in (-1,1):
  # Broad top/bottom plating, with angular shoulder chamfers.
  for lane in range(4):
   f0=-.38+lane*.19;f1=f0+.186
   for up in (-1,1):
    y0=up*(h0/2+.08);y1=up*(h1/2+.08)
    vs=[(w0*f0,y0,z0),(w0*f1,y0,z0),(w1*f1,y1,z1),(w1*f0,y1,z1)]
    if side==-1:continue # one cover per face, not duplicate
    vs += [(x,y-up*.24,z) for x,y,z in vs]
    poly(H,vs,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],PAINT,.055)
  # vertical cheek plates above window line, and below except hangar.
  for up in (-1,1):
   if up==-1 and 65<((z0+z1)/2)<102:continue
   corner0=min((h0/2-1)*.24,4);y0a=up*(1+corner0+.08);y0b=up*(h0/2-corner0-.08)
   corner1=min((h1/2-1)*.24,4);y1a=up*(1+corner1+.08);y1b=up*(h1/2-corner1-.08)
   if abs(y0b)<2.2 or abs(y1b)<2.2:continue
   x0=side*(w0/2+.04);x1=side*(w1/2+.04)
   vs=[(x0,y0a,z0),(x0,y0b,z0),(x1,y1b,z1),(x1,y1a,z1)]
   vs += [(x-side*.22,y,z) for x,y,z in vs]
   poly(H,vs,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],PAINT,.06)
# Waist window strips at a believable deck height, 1.1m tall panes.
for side in (-1,1):
 for z in np.arange(-119,129,2.15):
  if -51<z<-9:continue
  w,h,_=profile(float(z));x=side*(w/2-.62)
  if z<58 or (int(z/9)%4==0):
   box(H,(x-side*.60,0,z),(1.35,1.44,1.94),DARK,.06)
   box(H,(x,0,z),(.22,1.12,1.72),GLASS if round(z/2.15)%11 in (3,7) else LAMP,.035)
   if int(z)%7==0:box(H,(x+side*.15,0,z),(.12,1.28,.13),METAL,.02)
  else:
   # exposed cables in the forward machinery trench
   box(H,(x-.35*side,0,z),(.42,1.5,1.52),METAL,.06)
 for zz in range(-120,130,8):
  w,h,_=profile(zz)
  for y in (-1.4,1.4):tube(H,(side*(w/2-.35),y,zz),(side*(profile(min(zz+7,145))[0]/2-.35),y,zz+7),.15,mat=METAL,n=8)
# Three full octagonal armour belts, with inset face plates, edge rails, fasteners.
for z in [-16,-30,-44]:
 for dz in [-5.9,5.9]:loft(H,[(z+dz-.25,64,32,0),(z+dz+.25,64,32,0)],DARK,.1)
 loft(H,[(z-5.45,63.2,31.2,0),(z+5.45,63.2,31.2,0)],METAL,.5)
 ringp=octagon(64,32)
 for k in range(8):
  p=Vector((*ringp[k],0));q=Vector((*ringp[(k+1)%8],0));center=(p+q)/2;d=q-p
  normal=Vector((center.x,center.y*2,0)).normalized()
  vs=[]
  for depth in [0,.32]:
   for v,dz in [(p,.0),(q,.0)]:pass
   for t,dz in [(.055,-4.8),(.945,-4.8),(.945,4.8),(.055,4.8)]:
    c=p.lerp(q,t)+normal*(.10+depth);vs.append((c.x,c.y,z+dz))
  poly(H,vs,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],METAL,.18)
  if k in (1,5):
   for xx in (-14,0,14):
    yy=(16.52 if k==1 else -16.52)
    box(H,(xx,yy,z),(.20,.10,9.2),DARK,.025)
  for t in (.12,.30,.50,.70,.88):
   c=p.lerp(q,t)+normal*.65
   for dz in (-4.4,4.4):tube(H,(c.x,c.y,z+dz),(c.x+normal.x*.16,c.y+normal.y*.16,z+dz),.25,mat=DARK,n=6)
# Stern machinery neck and octagonal engine casing. Four independent bells.
loft(H,[(-147,70,36,0),(-141,70,36,0),(-123,68,35,0),(-118,61,29,0)],PAINT,.65)
loft(H,[(-148.6,66,33,0),(-146.4,66,33,0)],DARK,.3)
for x in (-18,18):
 for y in (-8.8,8.8):
  tube(H,(x,y,-143),(x,y,-150),8.05,7.8,HEAT,n=48,inside=6.9)
  tube(H,(x,y,-150),(x,y,-155),7.8,7.2,HEAT,n=48,inside=6.1)
  tube(H,(x,y,-155),(x,y,-159.9),7.2,7.8,HEAT,n=48,inside=6.1)
  tube(H,(x,y,-158.6),(x,y,-159.2),7.75,7.82,ENGINE,n=48,inside=7.15)
  ring(H,(x,y,-159.7),(0,0,1),6.72,.17,ENGINE,n=40)
  for z in (-148,-151,-157.8,-159.7):ring(H,(x,y,z),(0,0,1),7.75 if z<-151 else 7.8,.25,METAL)
  # Interior throat remains deep behind the lip. No opaque flat end-cap at exit.
  tube(H,(x,y,-148),(x,y,-155.6),6.65,6.13,DARK,n=48,inside=5.65)
  tube(H,(x,y,-149.8),(x,y,-150.0),5.55,mat=ENGINE,n=48)
  for rr in (2.8,4.6):ring(H,(x,y,-150.3),(0,0,1),rr,.18,DARK,n=32)
  ring(H,(x,y,-155.2),(0,0,1),5.87,.21,ENGINE)
  ring(H,(x,y,-153),(0,0,1),6.10,.18,ENGINE)
  for j in range(24):
   ang=j*2*math.pi/24
   tube(H,(x+7.65*math.cos(ang),y+7.65*math.sin(ang),-150),(x+7.7*math.cos(ang),y+7.7*math.sin(ang),-159.2),.13,mat=METAL,n=6)
  socket('Thruster_'+str(len([s for s in SOCKETS if s[0].startswith('Thruster')])+1),(x,y,-159.9))
# Engine casing subdivisions, access covers and dark cooling stacks.
for side in (-1,1):
 for z in (-130,-141):
  box(H,(side*34.7,0,z),(.6,20,9.5),PAINT,.23)
  for y in (-7,7):
   box(H,(side*35.0,y,z),(.12,3.2,5.0),DARK,.06)
   for k in range(5):box(H,(side*35.08,y-1.1+k*.55,z),(.13,.18,4.5),METAL,.035)
 for x in (-23,0,23):
  for z in (-129,-142):
   box(H,(x,18.05,z),(15,.35,9),PAINT,.14)
   for k in range(5):box(H,(x,18.28,z-2+k),(10,.18,.20),DARK,.03)
 # Long heat-stained conduits disappear into the engine collar.
 for y in (-9,9):
  tube(H,(side*28.8,y,-104),(side*32,y,-126),.65,mat=HEAT,n=16)
  for z in (-108,-114,-120):tube(H,(side*29.4,y,z-.32),(side*29.4,y,z+.32),.85,mat=METAL,n=16)
# Command tower: asymmetric stepped foundations and a raked bridge face.
loft(H,[(-114,31,5,15.5),(-80,31,5,15.5),(-75,25,2,14)],PAINT,.35)
def tower_level(y0,y1,w0,w1,zf0,zf1,zb0,zb1,mat):
 vs=[(-w0/2,y0,zf0),(w0/2,y0,zf0),(w0/2,y0,zb0),(-w0/2,y0,zb0),(-w1/2,y1,zf1),(w1/2,y1,zf1),(w1/2,y1,zb1),(-w1/2,y1,zb1)]
 poly(H,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)],mat,.25)
tower_level(17,33.8,25,18,-80,-86,-111,-110,PAINT)
tower_level(17,31,10,8,-103,-106,-117,-114,METAL)
# Dark recessed bridge bands, translucent panes, interior stations.
for y,z,w in [(21.1,-81.7,23),(31.6,-85.4,18.8)]:
 box(H,(0,y,z+.28),(w,1.68,.36),DARK,.12)
 box(H,(0,y,z+.50),(w-.8,1.06,.16),LAMP,.04)
 box(H,(0,y,z+.66),(w-.65,1.22,.12),GLASS,.03)
 for x in np.arange(-w/2+.6,w/2,.95):box(H,(x,y,z+.78),(.12,1.45,.20),METAL,.025)
 for s in (-1,1):
  box(H,(s*(w/2-.18),y,z-4.2),(.32,1.3,8.4),DARK,.1)
  box(H,(s*(w/2+.02),y,z-4.2),(.12,.9,7.9),LAMP,.03)
  box(H,(s*(w/2+.12),y,z-4.2),(.10,1.06,8),GLASS,.025)
  for zz in np.arange(z-7.8,z,.95):box(H,(s*(w/2+.19),y,zz),(.17,1.2,.12),METAL,.02)
# Bridge panel breaks and roof equipment.
for side in (-1,1):
 for yy in (24,27.3):
  box(H,(side*10.6,yy,-99),(.4,2.8,9.5),PAINT,.12)
  for zz in (-97,-99,-101):box(H,(side*10.9,yy,zz),(.2,1.5,.26),DARK,.04)
 for zz in (-91,-98,-105):
  tube(H,(side*7,34.0,zz),(side*7,34.4,zz),.35,mat=METAL,n=8)
box(H,(0,34.1,-97),(16,.55,18),PAINT,.22)
box(H,(-3,34.8,-96),(5,1.2,5),DARK,.18)
tube(H,(-3,35.3,-96),(-3,36.1,-96),1.5,1.25,METAL,n=24)
# Antenna forest, grounded in roof fittings, max height 40m.
for x,z,height in [(5,-105,5.6),(3.2,-104,3.4),(-6,-106,2.8),(6,-91,1.5)]:
 tube(H,(x,34.4,z),(x,34.9,z),.55,.35,METAL,n=12)
 tube(H,(x,34.8,z),(x,34.4+height,z),.18,.06,DARK,n=10)
 if height>3:tube(H,(x-1.2,36,z),(x+1.2,36,z),.10,mat=METAL,n=8)
# Clean plate is balanced with discrete service bays, rails and human-scale fittings.
for side in (-1,1):
 for jz,z in enumerate((-113,-84,-61,2,23,48,112,132)):
  w,h,_=profile(z);x=side*w*.24;y=h/2+.30
  sx,sz,count=[(1.18,.76,6),(.72,1.2,9),(1.0,1.0,8)][jz%3]
  box(H,(x,y,z),(4.3*sx,.48,5.8*sz),DARK,.18)
  box(H,(x,y+.27,z),(3.8*sx,.18,5.2*sz),METAL,.12)
  for j in range(count):box(H,(x,y+.42,z+(-2.1+4.2*j/(count-1))*sz),(3.2*sx,.13,.23),DARK,.025)
  for zz in (-2.2*sz,2.2*sz):
   for xx in (-1.7*sx,1.7*sx):tube(H,(x+xx,y+.4,z+zz),(x+xx,y+.58,z+zz),.13,mat=METAL,n=6)
 for jz,z in enumerate((-109,-81,-58,1,22,49)):
  w,h,_=profile(z);x=side*(w/2+.3)
  sy,sz=[(1.0,1.18),(1.25,.68),(.78,.96)][jz%3]
  box(H,(x,-7,z),(.4,4.6*sy,7.6*sz),METAL,.15)
  box(H,(x+side*.24,-7,z),(.15,3.7*sy,6.5*sz),PAINT,.07)
  for yy in (-1.2*sy,1.3*sy):tube(H,(x+side*.4,-7+yy,z-2.3*sz),(x+side*.4,-7+yy,z+2.3*sz),.13,mat=DARK,n=8)
 # Utility piping along rear shoulders, with clips.
 for yy in (4.0,6.0):
  tube(H,(side*29.6,yy,-63),(side*29.6,yy,-112),.25,mat=METAL,n=10)
  for zz in range(-110,-62,6):box(H,(side*29.75,yy,zz),(.5,.8,.35),DARK,.06)
# Bow probe gun foundations and forward sensor array; not a pointed triangle.
for s in (-1,1):
 box(H,(s*7.3,0,149),(3.2,2.2,6.4),DARK,.3)
 tube(H,(s*7.3,0,151),(s*7.3,0,159.85),.45,.25,METAL,n=16)
 socket('Muzzle_'+str(9 if s<0 else 10),(s*7.3,0,160))
 box(H,(s*7,4.9,137),(4.5,1.2,5.7),PAINT,.25)
 for x in [-1,0,1]:tube(H,(s*7+x,5.05,140),(s*7+x,5.05,140.45),.27,mat=DARK,n=12)
# Traversing primary armament, all body and barrel shapes are local to actual pivots.
for idx,(z,up) in enumerate([(34,1),(-66,1),(28,-1),(-70,-1)],1):
 py=14*up;T=Assembly(f'Turret_{idx}',(0,py,z),H);A=Assembly(f'Gun_{idx}',(0,py+2.8*up,z+3.4),T)
 ASSEMBLIES.extend([T,A])
 tube(H,(0,13.7*up,z),(0,14.7*up,z),6.7,6.5,DARK,n=40)
 ring(H,(0,14.3*up,z),(0,1,0),6.5,.25,METAL,n=40)
 # faceted sloping gunhouse, 6m high above/below mounting axis.
 loft(T,[(z-7,10,3.8,py+2.1*up),(z-4.5,13,5.4,py+2.7*up),(z+5.1,12,4.8,py+2.4*up),(z+7.2,9,3.0,py+2.4*up)],METAL,.32)
 for s in (-1,1):
  box(T,(s*5.9,py+2.8*up,z-1),(1.0,3.2,7.6),METAL,.24)
  box(T,(s*6.3,py+2.8*up,z-2),(.35,1.1,3.2),DARK,.08)
  tube(T,(s*6.4,py+2.8*up,z+3.4),(s*6.8,py+2.8*up,z+3.4),.72,mat=METAL,n=20)
  box(T,(s*3.2,py+5.4*up,z-3.5),(3,1,3.6),METAL,.18)
 # Moving elevation cradle and twin recoiling-looking telescoping barrels.
 tube(A,(-4.8,py+2.8*up,z+3.4),(4.8,py+2.8*up,z+3.4),1.4,mat=DARK,n=24)
 for s in (-1,1):
  x=s*2.4;y=py+2.8*up
  tube(A,(x,y,z+3.2),(x,y,z+10.5),1.0,.78,DARK,n=20)
  tube(A,(x,y,z+9.8),(x,y,z+19.5),.64,.46,METAL,n=20)
  tube(A,(x,y,z+18.8),(x,y,z+24),.49,.48,DARK,n=20,inside=.30)
  for zz,rad in [(z+7,1.08),(z+10.2,.84),(z+18.7,.62),(z+22.8,.57)]:tube(A,(x,y,zz-.24),(x,y,zz+.24),rad,mat=METAL,n=20)
  socket(f'Muzzle_{2*idx-(1 if s==-1 else 0)}',(x,y,z+24),A)
 # roof hatch and optoelectronic sight are integral to traverse.
 tube(T,(0,py+5.2*up,z-3),(0,py+5.5*up,z-3),1.5,mat=DARK,n=24)
 box(T,(3.9,py+4.6*up,z+3),(1.4,1.1,2),DARK,.16)
 box(T,(3.9,py+4.6*up,z+4.04),(1.0,.5,.12),GLASS,.02)
# Give the gunhouses heavier armoured cheeks; vertices move, pivots never do.
for a in ASSEMBLIES[1:]:
 if a.name.startswith('Turret_'):
  anchor=a.pivot+Vector((0,2.8 if a.pivot.y>0 else -2.8,3.4))
  a.v=[tuple(anchor+(Vector(v)-anchor)*Vector((1.14,1.06,1.14))) for v in a.v]
# Point defence: fixed paired mounts at upper/lower shoulders, separately named sockets.
pd=0
for z in (-111,-57,4,56,118):
 for s in (-1,1):
  w,h,_=profile(z);x=s*(w*.34);y=h/2+.4;start_v=len(H.v);start_s=len(SOCKETS)
  tube(H,(x,y-.3,z),(x,y+.6,z),1.65,1.45,DARK,n=20)
  loft(H,[(z-1.6,2.7,1.9,y+1.3),(z+1.3,2.2,1.5,y+1.3)],METAL,.18) if False else None
  box(H,(x,y+1.1,z),(2.6,1.8,2.9),METAL,.25)
  for k in (-1,1):
   tube(H,(x+k*.57,y+1.3,z+.7),(x+k*.57,y+1.7,z+5),.23,.15,DARK,n=12)
   pd+=1;socket('Muzzle_'+str(10+pd),(x+k*.57,y+1.7,z+5))
  if z in (-111,4):
   center=Vector((x,y,z));target=Vector((s*(w/2+.10),7.2,z));R=Matrix.Rotation(-s*math.pi/2,3,'Z')
   H.v[start_v:]=[tuple(target+R@(Vector(v)-center)) for v in H.v[start_v:]]
   SOCKETS[start_s:]=[(n,target+R@(p-center),par) for n,p,par in SOCKETS[start_s:]]
# Small service rails, inspection ladders, tether rings and bolts: scale cues.
for s in (-1,1):
 for zz in (-116,-105,-82,-61,5,19,51):
  w,h,_=profile(zz);x=s*(w*.37);y=h/2+.22
  for k in range(5):tube(H,(x-.6,y,zz+k*.5),(x+.6,y,zz+k*.5),.06,mat=DARK,n=6)
  for dx in (-.6,.6):tube(H,(x+dx,y,zz),(x+dx,y,zz+2),.07,mat=METAL,n=8)
 for z0,z1 in [(-110,-84),(-77,-57),(1,22)]:
  x=s*20
  for zz in np.arange(z0,z1+1,2.4):tube(H,(x,14.0,zz),(x,15.0,zz),.065,mat=METAL,n=6)
  tube(H,(x,15,z0),(x,15,z1),.075,mat=METAL,n=8)
 for zz in range(-116,120,12):
  w,h,_=profile(zz);x=s*(w*.35)
  for dx in (-.7,.7):tube(H,(x+dx,h/2+.06,zz),(x+dx,h/2+.22,zz),.14,mat=METAL,n=6)
# Torpedo recesses and navigation beacons, no text or painted markings.
for s in (-1,1):
 for zz in (112,118):
  w,h,_=profile(zz);x=s*(w/2+.1)
  box(H,(x,-1.8,zz),(.36,2.2,3.2),DARK,.15)
  socket('Hardpoint_'+str(len([q for q in SOCKETS if q[0].startswith('Hardpoint')])+1),(x,-1.8,zz))
 box(H,(s*34.4,2.0,-137),(1.2,1,2.2),DARK,.2)
 box(H,(s*34.8,2.0,-137),(.4,.55,1.2),LAMP,.1)
box(H,(0,34.7,-96),(1.2,.45,1.2),DARK,.12)
box(H,(0,35.05,-96),(.65,.5,.65),LAMP,.10)
socket('Light_Nav_L',(35,2,-137));socket('Light_Nav_R',(-35,2,-137));socket('Light_Beacon',(0,35,-96));socket('Camera_Chase',(0,90,-240))
# Structural coamings and machinery islands interrupt the large plating fields.
# Each island has a dark recessed floor behind a bevelled protective surround.
for side in (-1,1):
 for zz in (7,86):
  w,h,_=profile(zz);xx=side*w*.24;yy=h/2+.16
  box(H,(xx,yy,zz),(7.2,.28,14.2),DARK,.12)
  for dx in (-3.4,3.4):box(H,(xx+dx,yy+.6,zz),(.55,1.15,14.5),PAINT,.12)
  for dz in (-7,7):box(H,(xx,yy+.6,zz+dz),(6.6,1.15,.55),PAINT,.12)
  for dx in (-1.8,0,1.8):
   tube(H,(xx+dx,yy+.65,zz-5.6),(xx+dx,yy+.65,zz+3.6),.32,mat=METAL,n=10)
   for dz in (-4.5,.5,3):ring(H,(xx+dx,yy+.65,zz+dz),(0,0,1),.34,.07,DARK,n=12)
  box(H,(xx,yy+.8,zz+4.8),(4.8,1.1,2.6),METAL,.18)
  for dz in (-6,6):
   for dx in (-3.1,3.1):tube(H,(xx+dx,yy+1.15,zz+dz),(xx+dx,yy+1.32,zz+dz),.18,mat=METAL,n=6)
 # Upper bow reinforcement strakes are integral, tapering into the prow.
 for z0,z1 in [(104,128),(129,146)]:
  w0,h0,_=profile(z0);w1,h1,_=profile(z1)
  xx0=side*w0*.37;xx1=side*w1*.37
  verts=[(xx0-.7,h0/2,z0),(xx0+.7,h0/2,z0),(xx1+.55,h1/2,z1),(xx1-.55,h1/2,z1)]
  verts += [(x,y+.58,z) for x,y,z in verts]
  poly(H,verts,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],PAINT,.10)
 # Thick ventral bay frame, deck rails, machinery and docking clamps.
 for zz in (72,80,91,98):
  w,h,_=profile(zz);xx=side*(w/2-5.6)
  box(H,(xx,-h*.41,zz),(2.5,.5,1.4),DARK,.10)
  tube(H,(xx,-h*.40,zz),(xx,-h*.21,zz),.20,mat=METAL,n=8)
  box(H,(xx,-h*.19,zz),(1.5,.4,1),METAL,.10)
 w,h,_=profile(83)
 for dy in (-2.5,-5.0):
  tube(H,(side*(w/2-7.3),dy,69),(side*(profile(99)[0]/2-7.3),dy,99),.18,mat=METAL,n=8)
 # Bridge flank pilasters, inset dark service panels and vertical conduits.
 for zz in (-104,-110):
  tube(H,(side*12.4,18.1,zz),(side*9.5,32,zz),.38,mat=METAL,n=10)
 for zz in (-96,-101):
  box(H,(side*11.7,23,zz),(.32,7.4,3.6),DARK,.10)
  box(H,(side*11.95,23,zz),(.30,6.8,3.0),PAINT,.10)
 # Rear collar has purposeful cooling and feed machinery at the same scale as bells.
 for zz in (-128,-140):
  tube(H,(side*31,11,zz),(side*31,-11,zz),.44,mat=METAL,n=12)
  for yy in (-9,-3,3,9):box(H,(side*31.3,yy,zz),(.8,.6,1.8),DARK,.10)
# Bridge front is split into three armoured facets and two brow caps.
for xx in (-7.5,0,7.5):
 tube(H,(xx,22.2,-82.05),(xx*.85,30.7,-85.1),.105,mat=DARK,n=8)
for yy,zz,ww in [(21.95,-81.7,24.2),(32.45,-85.45,20.1)]:
 box(H,(0,yy,zz+.4),(ww,.42,1.15),PAINT,.10)
# Central drive spine, feed plenums, cables and ports between the four bells.
box(H,(0,0,-149.2),(16.4,27.5,2.3),METAL,.55)
box(H,(0,0,-150.5),(11.8,22.7,.8),DARK,.35)
for xx in (-4.3,4.3):
 for yy in (-7,0,7):
  box(H,(xx,yy,-151.0),(3.4,5.4,1.0),HEAT,.22)
  tube(H,(xx,yy-2,-151.6),(xx,yy+2,-151.6),.37,mat=METAL,n=10)
for yy in (-13,13):
 for xx in np.arange(-7,8,2):box(H,(xx,yy,-150.7),(.8,1.2,.45),DARK,.08)
# Small roof radar and additional antenna trusses, mounted to real bases.
box(H,(5,34.6,-103),(3,1.2,3),DARK,.2)
for zz in (-109,-101):
 tube(H,(-6,34.5,zz),(-6,37.0,zz),.15,mat=METAL,n=8)
 tube(H,(-7.4,36.8,zz),(-4.6,36.8,zz),.10,mat=DARK,n=8)

# Exact mesh envelope, without applying any transform to moving nodes.
allv=np.array([v for a in ASSEMBLIES for v in a.v]);vmin=allv.min(axis=0);vmax=allv.max(axis=0)
scale=np.array([70.,60.,320.])/(vmax-vmin);shift=np.array([(vmax[0]+vmin[0])/2,0,(vmax[2]+vmin[2])/2])
for a in ASSEMBLIES:
 a.v=[tuple((np.array(v)-shift)*scale) for v in a.v]
 a.pivot=Vector((np.array(a.pivot)-shift)*scale)
SOCKETS=[(n,Vector((np.array(p)-shift)*scale),par) for n,p,par in SOCKETS]
H.pivot=Vector((0,0,0))
# Build named nodes and preserve moving-node origins (no transform_apply calls).
for a in ASSEMBLIES:a.finish()
for name,p,parent in SOCKETS:
 e=bpy.data.objects.new(name,None);SC.collection.objects.link(e);e.empty_display_size=.7;e.empty_display_type='PLAIN_AXES'
 e.parent=(parent or H).o;e.location=B(p-(parent or H).pivot)
 if name.startswith('Muzzle_') and int(name.split('_')[1])>=11:
  k=int(name.split('_')[1])-11;zpd=(-111,-57,4,56,118)[k//4];side=-1 if (k//2)%2==0 else 1
  direction=Vector((side*.4,0,4.3)) if zpd in (-111,4) else Vector((0,.4,4.3))
  direction=Vector(tuple(direction[i]*scale[i] for i in range(3)))
  e.rotation_euler=B(direction).to_track_quat('-Y','Z').to_euler()
 if name.startswith('Light_Nav_'):e['color']=[1,0,0] if name.endswith('L') else [0,1,0]
 if name.startswith('Thruster'):e['exhaust_axis']=[0,0,-1]
ASSET=[o for o in SC.objects if o.type in ('MESH','EMPTY')]
bpy.context.view_layer.update()
# Reject degenerate geometry before serialisation.
for a in ASSEMBLIES:
 bm=bmesh.new();bm.from_mesh(a.o.data);bad=[f for f in bm.faces if f.calc_area()<1e-9]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 # Explicit triangles are required for deterministic tangent export on bevelled ngons.
 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-9]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(a.o.data);bm.free()
triangles=sum(sum(len(p.vertices)-2 for p in a.o.data.polygons) for a in ASSEMBLIES)
assert 80000<=triangles<=200000,triangles
print('GEOMETRY',triangles,'triangles',len(ASSET),'nodes',flush=True)
# Standard glTF PBR output, with explicit baseColorFactors after export.
for name,node in COLOR_NODES.items():
 nt=bpy.data.materials[name].node_tree;bs=nt.nodes.get('Principled BSDF');nt.links.new(node.inputs[1].links[0].from_socket,bs.inputs['Base Color'])
activate(H.o)
for o in ASSET:o.select_set(True)
glb=ROOT/'light-cruiser.glb'
bpy.ops.export_scene.gltf(filepath=str(glb),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_cameras=False,export_lights=False,export_extras=True,export_texcoords=True,export_normals=True,export_tangents=True,export_image_format='AUTO',export_vertex_color='ACTIVE')
# Patch factors only. Geometry and images are Blender's exact clean export.
def patch_factors(path):
 data=path.read_bytes();pos=12;chunks=[]
 while pos<len(data):
  n,typ=struct.unpack_from('<II',data,pos);chunks.append((typ,data[pos+8:pos+8+n]));pos+=8+n
 j=json.loads(chunks[0][1])
 for m in j.get('materials',[]):
  name=m.get('name')
  if name in FACTORS:m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=[*FACTORS[name],.55 if name=='Glass' else 1]
  if name=='Glass':m['alphaMode']='BLEND'
 j['asset']['extras']={'source':'sheet.jpg; original procedural reconstruction','metres':True,'paint_contract':'Paint baseColorFactor only','texture_space':'world scale 8m hull / 4m hardware'}
 raw=json.dumps(j,separators=(',',':')).encode();raw+=b' ' *((-len(raw))%4);chunks[0]=(chunks[0][0],raw)
 out=b''.join(struct.pack('<II',len(d),t)+d for t,d in chunks);path.write_bytes(struct.pack('<III',0x46546c67,2,12+len(out))+out)
patch_factors(glb)
raw_glb=QA/'light-cruiser-uncompressed.glb';raw_glb.write_bytes(glb.read_bytes())
bpy.ops.export_scene.gltf(filepath=str(glb),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_cameras=False,export_lights=False,export_extras=True,export_texcoords=True,export_normals=True,export_tangents=True,export_image_format='AUTO',export_vertex_color='ACTIVE',export_draco_mesh_compression_enable=True,export_draco_mesh_compression_level=6,export_draco_position_quantization=18,export_draco_normal_quantization=12,export_draco_texcoord_quantization=16,export_draco_color_quantization=8)
patch_factors(glb)
assert glb.stat().st_size<=12_000_000,glb.stat().st_size
for name,node in COLOR_NODES.items():
 nt=bpy.data.materials[name].node_tree;nt.links.new(RENDER_OUTPUTS[name],nt.nodes.get('Principled BSDF').inputs['Base Color'])
# Bounds of render meshes (sockets outside hull are intentionally excluded).
bpy.context.view_layer.update();pts=[o.matrix_world@Vector(c) for o in ASSET if o.type=='MESH' for c in o.bound_box]
lo=[min(G(p)[i] for p in pts) for i in range(3)];hi=[max(G(p)[i] for p in pts) for i in range(3)]
PAINTS=[('Naval grey',[.60,.61,.62]),('Gunmetal',[.20,.24,.29]),('Olive drab',[.25,.29,.18]),('Desert sand',[.63,.48,.30]),('Arctic white',[.86,.89,.90]),('Deep navy',[.07,.12,.22])]
manifest={'units':'metres','forward':[0,0,1],'up':[0,1,0],'left':[1,0,0],'bounding_box':{'min':lo,'max':hi,'size':[hi[i]-lo[i] for i in range(3)]},'triangles':triangles,'materials':[m.name for m in MATS],'nodes':{},'axes':{},'paints':[{'name':n,'linear_rgb':v} for n,v in PAINTS]}
for o in ASSET:
 manifest['nodes'][o.name]={'world_position':G(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type}
 if o.name.startswith('Turret_'):manifest['axes'][o.name]=[0,1,0]
 if o.name.startswith('Gun_'):manifest['axes'][o.name]=[1,0,0]
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
# Soft daylight in a near-black blue-gradient void. No stage or ground.
world=bpy.data.worlds.new('Deep space');SC.world=world;world.use_nodes=True;nt=world.node_tree;nt.nodes.clear()
tex=nt.nodes.new('ShaderNodeTexCoord');sep=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(tex.outputs['Normal'],sep.inputs[0]);ramp=nt.nodes.new('ShaderNodeValToRGB');nt.links.new(sep.outputs[2],ramp.inputs[0]);ramp.color_ramp.elements[0].color=(.004,.006,.012,1);ramp.color_ramp.elements[1].color=(.021,.034,.065,1)
bg=nt.nodes.new('ShaderNodeBackground');nt.links.new(ramp.outputs[0],bg.inputs['Color']);bg.inputs['Strength'].default_value=.45;out=nt.nodes.new('ShaderNodeOutputWorld');ambient=nt.nodes.new('ShaderNodeBackground');ambient.inputs['Color'].default_value=(.10,.13,.17,1);ambient.inputs['Strength'].default_value=.55
lp=nt.nodes.new('ShaderNodeLightPath');mix=nt.nodes.new('ShaderNodeMixShader');nt.links.new(lp.outputs['Is Camera Ray'],mix.inputs[0]);nt.links.new(ambient.outputs[0],mix.inputs[1]);nt.links.new(bg.outputs[0],mix.inputs[2]);nt.links.new(mix.outputs[0],out.inputs[0])
def light(name,p,energy,col,size):
 d=bpy.data.lights.new(name,'AREA');d.energy=energy;d.color=col;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);SC.collection.objects.link(o);o.location=B(p);o.rotation_euler=(-o.location).to_track_quat('-Z','Y').to_euler()
light('Key',(100,170,130),460000,(1,.98,.95),72)
light('Cool fill',(150,15,100),115000,(.62,.76,1),180)
light('Stern rim',(40,100,-200),470000,(.68,.81,1),110)
light('Under bounce',(40,-110,70),190000,(.54,.66,1),170)
camdata=bpy.data.cameras.new('Render camera');cam=bpy.data.objects.new('Render camera',camdata);SC.collection.objects.link(cam);SC.camera=cam;camdata.type='ORTHO';camdata.lens=52

def camera(p,target=(0,4,0),ortho=360):
 cam.location=B(p);cam.rotation_euler=(B(target)-cam.location).to_track_quat('-Z','Y').to_euler();camdata.ortho_scale=ortho;camdata.clip_end=3000

def render(path,w=1920,h=1080,samples=24):
 SC.render.resolution_x=w;SC.render.resolution_y=h;SC.render.resolution_percentage=100;SC.cycles.samples=samples;SC.render.filepath=str(path);bpy.ops.render.render(write_still=True)
 print('RENDERED',path.name,round(time.time()-START,1),flush=True)
# Camera almost broadside, bow left, sufficient top view to see both dorsal turrets.
camera((255,158,230),ortho=347)
# Save clean scene for recovery and further inspection, outside delivery files.
bpy.ops.wm.save_as_mainfile(filepath=str(QA/'light-cruiser.blend'))
# Plain clay proof uses a view-layer override, then restores original materials.
clay=bpy.data.materials.new('QA_Clay');clay.use_nodes=True;cb=clay.node_tree.nodes.get('Principled BSDF');cb.inputs['Base Color'].default_value=(.40,.40,.40,1);cb.inputs['Roughness'].default_value=.68
SC.view_layers[0].material_override=clay
render(ROOT/'clay.png',1920,1080,20)
SC.view_layers[0].material_override=None
render(ROOT/'final.png',1920,1080,40)
camera((330,0,0),target=(0,6,0),ortho=350);render(ROOT/'side.png',1920,1080,24)
camera((210,100,-280),target=(0,2,-30),ortho=335);render(QA/'rear.png',1440,900,20)
camera((255,-145,200),target=(0,-2,0),ortho=347);render(QA/'underside.png',1440,900,20)
camera((102,47,118),target=(12,-1,80),ortho=92);render(QA/'close.png',1440,900,28)
# Verify every articulated transform with an actual 30-degree pose render.
camera((255,158,230),ortho=347)
for a in ASSEMBLIES[1:]:
 if a.name.startswith('Turret'):a.o.rotation_euler.z=math.radians(30)
 else:a.o.rotation_euler.x=math.radians(-30 if a.pivot.y>0 else 30)
bpy.context.view_layer.update()
for a in ASSEMBLIES[1:]:
 if a.name.startswith('Turret'):assert (a.o.matrix_world.translation-B(a.pivot)).length<1e-5
render(QA/'articulation_30deg.png',1440,900,20)
for a in ASSEMBLIES[1:]:a.o.rotation_euler=(0,0,0)
# Six same-view colourways. Compose with Blender image pixels, no external dependency.
frames=[]
for i,(name,col) in enumerate(PAINTS):
 COLOR_NODES['Paint'].inputs[2].default_value=(*col,1)
 render(QA/f'paint_{i}.png',640,360,12)
 im=bpy.data.images.load(str(QA/f'paint_{i}.png'),check_existing=False);arr=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(arr);frames.append(arr.reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,arr in enumerate(frames):
 row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=arr
im=bpy.data.images.new('Six paints',1920,720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
COLOR_NODES['Paint'].inputs[2].default_value=(*PAINTS[0][1],1)
notes=[
'Original 320m space-opera light cruiser: slab hull, recessed hangars, command tower, four articulated twin turrets, point defence and four deep engine bells.',
f'{triangles:,} triangles; {glb.stat().st_size/1e6:.2f} MB GLB; moving pivots tested at 30 degrees; Y-up, +Z nose, metre units.',
'Materials (7): Paint, Titanium, DarkMetal, Scorch, Glass, Lamps, Engines; neutral Paint texture with default factor [0.60, 0.61, 0.62].',
'Cycles GPU bakes: hull/metal colour+normal 1024², heat 512², shared signal/engine/optical trims 64–256²; ORM ≤512²; world-scale UVs; Draco 18-bit position.',
'Poly Haven CC0 sources: blue_metal_plate, metal_plate_02, rusty_metal_02; https://polyhaven.com/license ; source ids browsed through https://api.polyhaven.com/assets?t=textures .',
'Differences: original stepped bridge and blunt reinforced prow; enlarged centreline dorsal/ventral gunhouses; fixed point-defence mounts and added service bays.'
]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('COMPLETE',round(time.time()-START,1),'seconds',triangles,'triangles',glb.stat().st_size,'bytes',manifest['bounding_box'],flush=True)
