#!/usr/bin/env python3
"""Original naval heavy cruiser. Run with Blender 5.2 --background --python-exit-code 1.
Clean deterministic build: CC0 downloads -> Cycles tile bakes -> geometry -> GLB -> renders.
Blender authoring frame X=port, -Y=nose, Z=up; glTF X=port,Y=up,Z=nose.
"""
import bpy, bmesh, math, os, sys, json, random, urllib.request, struct, time
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin, cos, pi
import numpy as np
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
QA=ROOT/'qa'; QA.mkdir(exist_ok=True)
random.seed(27)
START=time.time()
PAINTS=[('Naval grey',[.32,.34,.36]),('Gunmetal',[.105,.13,.155]),('Olive drab',[.23,.265,.14]),('Desert sand',[.57,.43,.265]),('Arctic white',[.79,.83,.86]),('Deep navy',[.055,.105,.20])]
MATS=[]; ASSEMBLIES={}; SOCKETS=[]; MOVING={}

def log(s): print('CRUISER',round(time.time()-START,1),s,flush=True)
def reset():
 bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
 for d in list(bpy.data.materials): bpy.data.materials.remove(d)
 sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24; sc.cycles.use_denoising=True
 prefs=bpy.context.preferences.addons['cycles'].preferences
 for mode in ['OPTIX','CUDA']:
  try:
   prefs.compute_device_type=mode; prefs.get_devices()
   if any(d.type==mode for d in prefs.devices):
    for d in prefs.devices: d.use=(d.type==mode)
    sc.cycles.device='GPU'; log('GPU '+mode); break
  except Exception: pass
 else: raise RuntimeError('GPU required')
 sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
 sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
 sc.view_settings.view_transform='AgX'; sc.view_settings.exposure=-.85
 sc.render.resolution_percentage=100
 sc.world=bpy.data.worlds.new('Space')
 return sc
SC=reset()

def download():
 for ident in ['metal_plate','rusty_metal_04']:
  for typ in ['diff','nor_gl','arm']:
   p=CACHE/f'{ident}_{typ}_1k.jpg'
   if not p.exists():
    url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{typ}_1k.jpg'
    log('Download '+url)
    urllib.request.urlretrieve(url,p)

def node(nt,kind,**kw):
 n=nt.nodes.new(kind)
 for k,v in kw.items(): setattr(n,k,v)
 return n

def bake_material(name,kind):
 m=bpy.data.materials.new(name); m.use_nodes=True; nt=m.node_tree; nt.nodes.clear(); L=nt.links.new
 out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled'); L(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=node(nt,'ShaderNodeTexCoord')
 def tex(filename,non=False):
  t=node(nt,'ShaderNodeTexImage'); t.image=bpy.data.images.load(str(CACHE/filename),check_existing=True)
  if non:t.image.colorspace_settings.name='Non-Color'
  L(uv.outputs['UV'],t.inputs['Vector']); return t
 def mathn(op,a,b=None):
  n=node(nt,'ShaderNodeMath',operation=op)
  if hasattr(a,'node'): L(a,n.inputs[0])
  else:n.inputs[0].default_value=a
  if b is not None:
   if hasattr(b,'node'):L(b,n.inputs[1])
   else:n.inputs[1].default_value=b
  return n.outputs[0]
 def mix(a,b,factor,blend='MIX'):
  n=node(nt,'ShaderNodeMixRGB',blend_type=blend); n.inputs[0].default_value=factor
  if hasattr(a,'node'):L(a,n.inputs[1])
  else:n.inputs[1].default_value=(*a,1)
  if hasattr(b,'node'):L(b,n.inputs[2])
  else:n.inputs[2].default_value=(*b,1)
  return n.outputs[0]
 rough=.47; metal=.7
 if kind in ['paint','metal','dark','weapon','deck','trim']:
  photo=tex('metal_plate_diff_1k.jpg'); nor=tex('metal_plate_nor_gl_1k.jpg',True); arm=tex('metal_plate_arm_1k.jpg',True)
  rust=tex('rusty_metal_04_diff_1k.jpg')
  bw=node(nt,'ShaderNodeRGBToBW'); L(photo.outputs['Color'],bw.inputs[0])
  normalize=mathn('MULTIPLY_ADD',bw.outputs[0],.10); normalize.node.inputs[2].default_value=.9
  brick=node(nt,'ShaderNodeTexBrick'); L(uv.outputs['UV'],brick.inputs['Vector'])
  brick.inputs['Scale'].default_value=1; brick.inputs['Mortar Size'].default_value=.0017
  brick.inputs['Mortar Smooth'].default_value=.002; brick.inputs['Brick Width'].default_value=1.0; brick.inputs['Row Height'].default_value=.25
  brick.inputs['Color1'].default_value=(.72,.72,.72,1); brick.inputs['Color2'].default_value=(1,1,1,1); brick.inputs['Mortar'].default_value=(.28,.28,.28,1)
  noise=node(nt,'ShaderNodeTexNoise'); L(uv.outputs['UV'],noise.inputs['Vector']); noise.inputs['Scale'].default_value=145; noise.inputs['Detail'].default_value=2
  base=normalize
  if kind=='paint':
   base=mix(normalize,brick.outputs['Color'],.45,'MULTIPLY'); rough=.48; metal=.45
  elif kind=='metal':
   base=mix(photo.outputs['Color'],(.36,.40,.44),.72); rough=.34; metal=.88
  elif kind=='dark':
   base=mix(photo.outputs['Color'],(.034,.042,.051),.91); rough=.57; metal=.75
  elif kind=='weapon':
   base=mix(photo.outputs['Color'],(.18,.20,.22),.72); rough=.4; metal=.82
  elif kind=='trim':
   base=mix(photo.outputs['Color'],(.25,.28,.31),.45); rough=.42; metal=.85
  else:
   base=mix(normalize,brick.outputs['Color'],.65,'MULTIPLY'); base=mix(base,(.10,.12,.135),.82); rough=.67; metal=.55
  # elongated grime and fine impact pitting, baked into the shared tile
  mapping=node(nt,'ShaderNodeVectorMath',operation='MULTIPLY'); L(uv.outputs['UV'],mapping.inputs[0]); mapping.inputs[1].default_value=(12,1.2,1)
  grime=node(nt,'ShaderNodeTexNoise'); L(mapping.outputs[0],grime.inputs['Vector']); grime.inputs['Scale'].default_value=6; grime.inputs['Detail'].default_value=3
  grimecol=node(nt,'ShaderNodeValToRGB'); L(grime.outputs['Fac'],grimecol.inputs[0]); grimecol.color_ramp.elements[0].color=(.58,.58,.58,1); grimecol.color_ramp.elements[1].color=(1,1,1,1)
  base=mix(base,grimecol.outputs[0],.52,'MULTIPLY')
  if kind not in ['paint','deck']:base=mix(base,rust.outputs['Color'],.075)
  # Neutral weathering from the second CC0 scan: broad oxidation, impact specks and polish.
  if kind=='paint':
   rustbw=node(nt,'ShaderNodeRGBToBW');L(rust.outputs[0],rustbw.inputs[0])
   wear=node(nt,'ShaderNodeValToRGB');L(rustbw.outputs[0],wear.inputs[0])
   wear.color_ramp.elements[0].position=.10;wear.color_ramp.elements[0].color=(.38,.38,.38,1)
   wear.color_ramp.elements[1].position=.6;wear.color_ramp.elements[1].color=(1,1,1,1)
   base=mix(base,wear.outputs[0],.68,'MULTIPLY')
  if kind=='trim':
   # A tiled vent/armour trim: 4 metres across, 1 metre tall; no unique hull atlas.
   wave=node(nt,'ShaderNodeTexWave');wave.bands_direction='Y';L(uv.outputs['UV'],wave.inputs['Vector']);wave.inputs['Scale'].default_value=5
   crest=mathn('GREATER_THAN',wave.outputs['Fac'],.35)
   trimcol=node(nt,'ShaderNodeMixRGB');L(crest,trimcol.inputs[0]);trimcol.inputs[1].default_value=(.008,.012,.017,1);L(base,trimcol.inputs[2]);base=trimcol.outputs[0]
  L(base,bs.inputs['Base Color'])
  nm=node(nt,'ShaderNodeNormalMap'); nm.inputs['Strength'].default_value=.08; L(nor.outputs['Color'],nm.inputs['Color'])
  bump=node(nt,'ShaderNodeBump'); bump.inputs['Strength'].default_value=.15; bump.inputs['Distance'].default_value=.008
  L(nm.outputs[0],bump.inputs['Normal']); L(noise.outputs['Fac'],bump.inputs['Height']); L(bump.outputs[0],bs.inputs['Normal'])
  if kind in ['paint','deck']:
   b2=node(nt,'ShaderNodeBump'); b2.inputs['Strength'].default_value=.5; b2.inputs['Distance'].default_value=.055
   L(bump.outputs[0],b2.inputs['Normal']); L(brick.outputs['Fac'],b2.inputs['Height']); b2.invert=True; L(b2.outputs[0],bs.inputs['Normal'])
  if kind=='trim':
   bt=node(nt,'ShaderNodeBump');bt.inputs['Strength'].default_value=.6;bt.inputs['Distance'].default_value=.06;L(wave.outputs['Fac'],bt.inputs['Height']);L(bump.outputs[0],bt.inputs['Normal']);L(bt.outputs[0],bs.inputs['Normal'])
  sep=node(nt,'ShaderNodeSeparateColor'); L(arm.outputs['Color'],sep.inputs[0])
  roughout=mathn('MULTIPLY_ADD',grime.outputs['Fac'],.32); roughout.node.inputs[2].default_value=rough-.16
  aoout=mathn('MULTIPLY_ADD',sep.outputs['Red'],.22); aoout.node.inputs[2].default_value=.78
 else:
  colors={'glass':(.075,.16,.20),'engine':(.12,.72,1.0),'lamp':(.6,.8,1)}
  base=colors[kind]; bs.inputs['Base Color'].default_value=(*base,1); rough=.18 if kind=='glass' else .38; metal=.15
  roughout=None; aoout=None
  if kind=='engine':
   offset=node(nt,'ShaderNodeVectorMath',operation='DISTANCE');L(uv.outputs['UV'],offset.inputs[0]);offset.inputs[1].default_value=(.5,.5,0)
   plasma=node(nt,'ShaderNodeValToRGB');plasma.color_ramp.elements[0].position=.08;plasma.color_ramp.elements[0].color=(.8,.95,1,1)
   plasma.color_ramp.elements[1].position=.52;plasma.color_ramp.elements[1].color=(.015,.26,1,1)
   e=plasma.color_ramp.elements.new(.34);e.color=(.08,.72,1,1);L(offset.outputs['Value'],plasma.inputs[0]);base=plasma.outputs[0];L(base,bs.inputs['Base Color'])
  if kind=='lamp':
   sx=node(nt,'ShaderNodeSeparateXYZ');L(uv.outputs['UV'],sx.inputs[0])
   palette=node(nt,'ShaderNodeValToRGB');palette.color_ramp.interpolation='CONSTANT'
   palette.color_ramp.elements[0].color=(.7,.85,1,1)
   palette.color_ramp.elements[1].position=.667;palette.color_ramp.elements[1].color=(.015,1,.08,1)
   e=palette.color_ramp.elements.new(.333);e.color=(1,.018,.008,1)
   L(sx.outputs[0],palette.inputs[0]);base=palette.outputs['Color']
   L(base,bs.inputs['Base Color'])
 bs.inputs['Metallic'].default_value=metal; bs.inputs['Roughness'].default_value=rough
 # A real Cycles bake on a UV tile, used at 8 m, 4 m or 2 m intervals throughout.
 bpy.ops.mesh.primitive_plane_add(size=2); plane=bpy.context.object; plane.name='BakeTile'; plane.data.materials.append(m)
 SC.cycles.samples=8; SC.render.bake.margin=8; SC.render.bake.use_clear=True
 baked={}
 for channel,res in [('Base',1024 if kind in ['paint','metal','dark','weapon','deck','trim'] else 32),('Normal',1024 if kind in ['paint','metal','dark','weapon','deck','trim'] else 32),('ORM',512 if kind in ['paint','metal','dark','weapon','deck','trim'] else 32)]:
  im=bpy.data.images.new(name+'_'+channel,width=res,height=res,alpha=False)
  if channel!='Base':im.colorspace_settings.name='Non-Color'
  target=node(nt,'ShaderNodeTexImage'); target.image=im; nt.nodes.active=target
  if channel=='Base':
   emit=node(nt,'ShaderNodeEmission')
   if hasattr(base,'node'):L(base,emit.inputs[0])
   else:emit.inputs[0].default_value=(*base,1)
   L(emit.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='EMIT'); nt.nodes.remove(emit)
  elif channel=='Normal':
   L(bs.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='NORMAL')
  else:
   comb=node(nt,'ShaderNodeCombineColor'); comb.inputs['Red'].default_value=1; comb.inputs['Green'].default_value=rough; comb.inputs['Blue'].default_value=metal
   if aoout:L(aoout,comb.inputs['Red'])
   if roughout:L(roughout,comb.inputs['Green'])
   emit=node(nt,'ShaderNodeEmission'); L(comb.outputs[0],emit.inputs[0]); L(emit.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='EMIT'); nt.nodes.remove(emit)
  im.filepath_raw=str(CACHE/f'{name}_{channel}.png'); im.file_format='PNG'; im.save(); baked[channel]=im
  nt.nodes.remove(target)
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear(); out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled'); L(bs.outputs[0],out.inputs[0])
 for ch in ['Base','Normal','ORM']:
  t=node(nt,'ShaderNodeTexImage',name=ch); t.image=baked[ch]
  if ch=='Base':
   if name=='Paint':
    mult=node(nt,'ShaderNodeMixRGB',name='FactionTint',blend_type='MULTIPLY'); mult.inputs[0].default_value=1; mult.inputs[2].default_value=(*PAINTS[0][1],1); L(t.outputs[0],mult.inputs[1]); L(mult.outputs[0],bs.inputs['Base Color'])
   else:L(t.outputs[0],bs.inputs['Base Color'])
  elif ch=='Normal':
   nm=node(nt,'ShaderNodeNormalMap'); L(t.outputs[0],nm.inputs['Color']); L(nm.outputs[0],bs.inputs['Normal'])
  else:
   sp=node(nt,'ShaderNodeSeparateColor'); L(t.outputs[0],sp.inputs[0]); L(sp.outputs['Green'],bs.inputs['Roughness']); L(sp.outputs['Blue'],bs.inputs['Metallic'])
   group=bpy.data.node_groups.get('glTF Material Output')
   if group is None:
    group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   occ=node(nt,'ShaderNodeGroup'); occ.node_tree=group; L(sp.outputs['Red'],occ.inputs['Occlusion'])
 if kind in ['engine','lamp']:
  L(nt.nodes['Base'].outputs[0],bs.inputs['Emission Color']); bs.inputs['Emission Strength'].default_value=4.0 if kind=='engine' else 1.4
 if kind=='glass':
  bs.inputs['Alpha'].default_value=.62; bs.inputs['Metallic'].default_value=.25
  m.surface_render_method='DITHERED'; m.diffuse_color=(.075,.16,.20,.62)
 MATS.append(m); log('Baked '+name)
 return len(MATS)-1

# Component builders accumulate geometry directly; no thousands of scene nodes.
class Part:
 def __init__(self,name,pivot=(0,0,0),parent=None,rot=(0,0,0)):
  self.name=name; self.pivot=Vector(pivot); self.parent=parent; self.rot=rot; self.v=[]; self.f=[]; self.mi=[]; self.sm=[]
  ASSEMBLIES[name]=self
 def add(self,v,f,mat=0,bevel=0,smooth=False):
  bm=bmesh.new(); vv=[bm.verts.new(p) for p in v]
  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 bevel:
   bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bevel,segments=2,affect='EDGES',profile=.5)
   bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
  bm.verts.ensure_lookup_table(); bm.verts.index_update(); off=len(self.v)
  self.v += [tuple(x.co) for x in bm.verts]
  for face in bm.faces:
   self.f.append(tuple(off+x.index for x in face.verts)); self.mi.append(mat); self.sm.append(smooth and len(face.verts)==4)
  bm.free()
 def box(self,c,d,mat=0,b=.12,rot=None):
  v=[Vector((x*d[0]/2,y*d[1]/2,z*d[2]/2)) for x,y,z in [(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1),(-1,-1,1),(1,-1,1),(1,1,1),(-1,1,1)]]
  if rot:v=[rot@p for p in v]
  v=[tuple(p+Vector(c)) for p in v]
  self.add(v,[(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,min(b,min(d)*.22))
 def loft(self,sections,mat=0,b=.2):
  # z-oriented chamfered rectangle rings: z, halfwidth, halfdepth, ycenter, chamfer
  v=[]
  for z,w,l,y,c in sections:
   v.extend([(w-c,y-l,z),(w,y-l+c,z),(w,y+l-c,z),(w-c,y+l,z),(-w+c,y+l,z),(-w,y+l-c,z),(-w,y-l+c,z),(-w+c,y-l,z)])
  f=[tuple(reversed(range(8))),tuple(range(len(v)-8,len(v)))]
  for j in range(len(sections)-1):
   for i in range(8):f.append((j*8+i,j*8+(i+1)%8,(j+1)*8+(i+1)%8,(j+1)*8+i))
  self.add(v,f,mat,b)
 def cyl(self,a,b,r,mat=1,n=12,r2=None):
  a=Vector(a); b=Vector(b); d=b-a; q=d.to_track_quat('Z','Y'); r2=r if r2 is None else r2
  # profiled bevel at each end
  h=d.length; e=min(.12,r*.11,h*.14); pr=[(0,max(.01,r-e)),(e,r),(h-e,r2),(h,max(.01,r2-e))]
  v=[tuple(a+q@Vector((rr*cos(i*2*pi/n),rr*sin(i*2*pi/n),z))) for z,rr in pr for i in range(n)]
  f=[tuple(reversed(range(n))),tuple(range(3*n,4*n))]
  for j in range(3):
   for i in range(n):f.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
  self.add(v,f,mat,0,True)
 def tube(self,c,profile,mat=1,n=48,axis='Y'):
  # closed lathed sectional profile; explicit inner wall gives real nozzle / muzzle depth
  v=[]
  for along,r in profile:
   for i in range(n):
    a=2*pi*i/n; v.append((c[0]+r*cos(a),c[1]+along,c[2]+r*sin(a)) if axis=='Y' else (c[0]+r*cos(a),c[1]+r*sin(a),c[2]+along))
  f=[]
  for j in range(len(profile)):
   j2=(j+1)%len(profile)
   for i in range(n):f.append((j*n+i,j*n+(i+1)%n,j2*n+(i+1)%n,j2*n+i))
  self.add(v,f,mat,0,True)
 def create(self):
  mesh=bpy.data.meshes.new(self.name); mesh.from_pydata(self.v,[],self.f); mesh.update()
  ob=bpy.data.objects.new(self.name,mesh); bpy.context.collection.objects.link(ob)
  for m in MATS:mesh.materials.append(m)
  for p,mi,sm in zip(mesh.polygons,self.mi,self.sm):p.material_index=mi;p.use_smooth=sm
  bm=bmesh.new();bm.from_mesh(mesh);bmesh.ops.dissolve_degenerate(bm,dist=1e-6,edges=list(bm.edges))
  dead=[f for f in bm.faces if f.calc_area()<1e-9]
  if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
  bm.to_mesh(mesh);bm.free();mesh.update()
  uv=mesh.uv_layers.new(name='UVMap')
  for p in mesh.polygons:
   axis=max(range(3),key=lambda i:abs(p.normal[i])); scale=12 if p.material_index==0 else (8 if p.material_index==4 else 3.2)
   for li in p.loop_indices:
    co=mesh.vertices[mesh.loops[li].vertex_index].co
    a,b=(co.y,co.z) if axis==0 else ((co.x,co.z) if axis==1 else (co.x,co.y))
    uv.data[li].uv=(a/scale,b/scale)
    if p.material_index==8:
     uv.data[li].uv=(a/4,b)
    if p.material_index==6:
     cx,cz=min([(-12,11),(12,11),(-21,-3),(21,-3),(-12,-17),(12,-17)],key=lambda xz:(co.x-xz[0])**2+(co.z-xz[1])**2)
     uv.data[li].uv=((co.x-cx)/10.6+.5,(co.z-cz)/10.6+.5)
    if p.material_index==7:
     avgx=sum(mesh.vertices[i].co.x for i in p.vertices)/len(p.vertices)
     uv.data[li].uv=((.5 if avgx>33 else .84) if abs(avgx)>33 else .16,.5)
  ob.location=self.pivot; ob.rotation_euler=self.rot
  if self.parent:ob.parent=ASSEMBLIES[self.parent].ob
  self.ob=ob; return ob

def socket(name,p,parent='Hull',rot=(0,0,0),**meta):SOCKETS.append((name,p,parent,rot,meta))
def pipe(p,points,r=.18,mat=1):
 for a,b in zip(points,points[1:]):p.cyl(a,b,r,mat,8)
def rail(p,points):
 for pos in points:p.cyl(pos,(pos[0],pos[1],pos[2]+1.1),.055,1,8)
 for h in [.45,1.1]:pipe(p,[(x,y,z+h) for x,y,z in points],.045)
def bolts(p,c,dx,dy,z,mat=1):
 for x in [-dx,dx]:
  for y in [-dy,dy]:p.cyl((c[0]+x,c[1]+y,z),(c[0]+x,c[1]+y,z+.12),.14,mat,6)

def build_geometry():
 H=Part('Hull')
 # Eight-sided naval hull with asymmetric height and a long flared, raked ram.
 stations=[(-225,4.4,-19,13),(-207,8,-21,15),(-176,15,-22,16),(-133,23,-23,17),(-82,27,-24,18),(20,29,-24,18),(102,28,-23,18),(160,25,-20,16),(184,21,-17,14)]
 def cross(y,w,bot,top):
  rake=max(0,(-y-190)/35)
  return [(0,y+18*rake,top),(w*.77,y+18*rake,top),(w,y+14*rake,top-11),(w,y+8*rake,bot+17),(w*.72,y+8*rake,bot),(0,y+8*rake,bot),(-w*.72,y+8*rake,bot),(-w,y+8*rake,bot+17),(-w,y+14*rake,top-11),(-w*.77,y+18*rake,top)]
 verts=[]
 for s in stations:verts+=cross(*s)
 faces=[tuple(reversed(range(10))),tuple(range(len(verts)-10,len(verts)))]
 for j in range(len(stations)-1):
  for k in range(10):faces.append((j*10+k,j*10+(k+1)%10,(j+1)*10+(k+1)%10,(j+1)*10+k))
 H.add(verts,faces,0,.45)
 # Flared bow cheeks: distinct swept plates with a recessed central spine.
 for sign in [-1,1]:
  rings=[(-225,4.8,-19,-22),(-207,8.8,14,-22),(-176,16,16.5,-22),(-133,24.2,16,-22.5),(-82,28.3,11,-20),(-58,25.6,4,-15)]
  vs=[]
  for y,w,top,bot in rings:
   vs += [(sign*(w-1.6),y,top),(sign*w,y,top-3),(sign*(w+1.9),y,(top+bot)*.5),(sign*(w-1.6),y,bot),(sign*(w-2.3),y,bot+1),(sign*(w-2.3),y,top-1)]
  fs=[tuple(reversed(range(6))),tuple(range(len(vs)-6,len(vs)))]
  for j in range(len(rings)-1):
   for k in range(6):fs.append((j*6+k,j*6+(k+1)%6,(j+1)*6+(k+1)%6,(j+1)*6+k))
  H.add(vs,fs,0,.24)
  pipe(H,[(sign*(w+1.91),y,(top+bot)*.5) for y,w,top,bot in rings[1:]],.10,1)
  # Real inset armour strakes, fitted to the curved bow rather than pasted rectangles.
  def cheek(y,t,lower=False,offset=.14):
   for aa,bb in zip(rings,rings[1:]):
    if aa[0]<=y<=bb[0]:
     f=(y-aa[0])/(bb[0]-aa[0]);w=aa[1]*(1-f)+bb[1]*f;top=aa[2]*(1-f)+bb[2]*f;bot=aa[3]*(1-f)+bb[3]*f
     mid=(top+bot)*.5
     x=(w+1.9)*(1-t)+(w-1.6)*t if lower else w*(1-t)+(w+1.9)*t
     z=mid*(1-t)+bot*t if lower else (top-3)*(1-t)+mid*t
     return (sign*(x+offset),y,z)
  for aa,bb in zip(rings[1:4],rings[2:5]):
   steps=math.ceil((bb[0]-aa[0])/27)
   for k in range(steps):
    a=aa[0]+(bb[0]-aa[0])*k/steps+.35;b=aa[0]+(bb[0]-aa[0])*(k+1)/steps-.35
    for lower in [False,True]:
     pts=[cheek(a,.13,lower),cheek(b,.13,lower),cheek(b,.87,lower),cheek(a,.87,lower)]
     vs=pts+[(x-sign*.28,y,z) for x,y,z in pts]
     H.add(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)],0,.06)
  # layered lower armour rail following the curve of the hull
  for a,b in zip(stations[1:-1],stations[2:]):
   y,w,bot,top=a; yy,ww,bb,tt=b
   v=[(sign*(w*.84+.12),y,bot+7),(sign*(ww*.84+.12),yy,bb+7),(sign*(ww*.84+.12),yy,bb+8.25),(sign*(w*.84+.12),y,bot+8.25),(sign*(w*.84-.4),y,bot+7),(sign*(ww*.84-.4),yy,bb+7),(sign*(ww*.84-.4),yy,bb+8.25),(sign*(w*.84-.4),y,bot+8.25)]
   H.add(v,[(0,1,2,3),(4,7,6,5),(0,4,5,1),(3,2,6,7),(0,3,7,4),(1,5,6,2)],2,.12)
 # Inset bow observation/sensor spine, framed and barred.
 H.box((0,-211,0),(6.9,1.3,22),2,.2)
 for z in range(-9,11):H.box((0,-211.8,z),(6.7,.35,.25),1,.05)
 for s in [-1,1]:H.box((s*3.6,-211.9,0),(.45,.65,22.8),1,.08)
 # upper deck inset: longitudinal loft rather than a slab
 H.loft([(17.3,20,107,-40,5),(18.2,20,107,-40,5)],4,.18)
 H.loft([(17,23.5,62,105,6),(18.4,22,61,105,5)],4,.18)
 # raised forecastle and citadel continuous battered walls
 H.loft([(17,22,48,10,6),(26,19,43,12,5),(28,17.8,40,14,4)],0,.55)
 # Continuous raised forecastle carries the superfiring B turret into the citadel.
 H.loft([(17.5,19,35,-40,4),(26.7,16.5,29,-39,3),(28.1,15.4,27,-39,3)],0,.3)
 for sign in [-1,1]:
  H.box((sign*16.7,-42,23),(.5,35,4),2,.12)
  for y in [-55,-46,-37,-28]:H.box((sign*17.05,y,23),(.4,.7,3.8),1,.1)
 H.loft([(27.5,16,29,14,3),(32.5,14.6,24,16,2.5)],0,.23)
 H.loft([(32.3,13.9,21.8,19,2.6),(39,12.5,20,20,2.5)],0,.25)
 H.box((0,-8.3,30.5),(20,.7,1.15),2,.13)
 H.box((0,-8.72,30.6),(16,.16,.36),5,.035)
 H.box((0,-3.5,34.4),(14,.65,.8),2,.12)
 H.box((0,-3.88,34.5),(11,.16,.25),5,.025)
 H.box((0,-7.6,32.5),(5,3.2,1.1),3,.18)
 for x in [-9,9]:
  H.box((x,-.6,35.8),(.7,1.1,6.1),1,.12,Matrix.Rotation(-.28,3,'X'))
 # angled bridge: overlapping armored brow, dark continuous glazing, roof and wings
 H.loft([(38.4,13.3,17,18,2.8),(42.6,14.7,18,18,2.8)],2,.25)
 H.loft([(40,13.4,16.9,17.6,2.7),(44.5,14.0,17.4,17.6,2.5)],5,.15)
 H.loft([(44.4,15.4,19,18,2.8),(46.1,15.2,18.7,18,2.5)],0,.27)
 for s in [-1,1]:
  for y in [5,12,19,26,33]:H.box((s*14.05,y,42.5),(.4,.45,4.5),1,.08)
  H.loft([(35,s*0+18,7,23,2),(37,17.7,7,23,2)],0,.25)
 # front bridge mullions & interior consoles glimpsed through glazing
 for x in [-10,-5,0,5,10]:H.box((x,.1,42.2),(.4,.7,4),1,.08)
 H.box((0,5,40),(20,3,1.5),2,.12)
 for x in [-8,-4,0,4,8]:H.box((x,3.35,40.9),(2,.2,.65),7,.04)
 H.loft([(46,7,8,24,2),(52,5.4,6,25,1.5)],0,.25)
 H.box((0,25,52.5),(12,10,1.2),1,.16)
 H.cyl((0,25,53),(0,25,62),.14,1,12)
 H.cyl((3,27,52.5),(3,27,58),.09,1,10)
 H.cyl((-4,26,52.5),(-4,26,56),.1,1,10)
 H.box((0,25,54),(8,.5,.45),2,.1)
 # Low aft machinery island and gun pedestal.
 H.loft([(17,23,34,116,5),(25,19,30,116,4)],0,.5)
 H.loft([(16,22,20,168,5),(27,18,16,171,4)],0,.4)
 def width_at(y):
  for a,b in zip(stations,stations[1:]):
   if a[0]<=y<=b[0]:return a[1]+(b[1]-a[1])*(y-a[0])/(b[0]-a[0])
  return stations[-1][1]
 for s in [-1,1]:
  # flank longitudinal recesses, framed machinery bays and discrete armor belt panels
  for y in [-85,-52,-19,14,47,80,113,146]:
   x=s*(width_at(y)+.15)
   H.box((x,y,-3),(1.6,28,13.5),2,.25)
   H.box((x+s*.8,y,3.8),(.9,29,.8),1,.12)
   H.box((x+s*.8,y,-10),(.9,29,.8),1,.12)
   for yy in [y-14,y+14]:H.box((x+s*.9,yy,-3),(.8,.7,13),0,.16)
   for zz in [-5.5,-3,-.5]:
    H.cyl((x+s*1.4,y-11,zz),(x+s*1.4,y+11,zz),.26,1,10)
   for yy in [y-8,y+8]:H.box((x+s*1.45,yy,-3),(1.1,1.7,8),2,.13)
   H.box((x+s*.95,y,2.0),(1.2,26,3.5),0,.16)
  for y in [-112,-78,-44,-10,24,58,92,126,157]:
   w=24 if y<-90 else (27 if y<130 else 24)
   # sloping upper shoulder panels
   w=width_at(y);ang=Matrix.Rotation(s*math.radians(58),3,'Y')
   H.box((s*(w*.885),y,12.4),(8,29,.55),0,.17,ang)
   H.box((s*(w*.885),y+13.2,12.85),(7.7,.38,.16),1,.04,ang)
  # human-scale inset lit porthole bands near citadel / aft quarters
  for y in list(range(-28,53,4))+list(range(103,150,4)):
   x=s*(20 if y<60 else 21)
   H.box((x,y,25 if y<60 else 22),(.45,2.05,.75),2,.08)
   H.box((x+s*.25,y,25 if y<60 else 22),(.12,1.45,.35),7,.025)
  # machinery modules and side docking airlocks between sponsons
  for y in [-7,75]:
   H.box((s*30,y,-1),(4,16,20),0,.55)
   H.box((s*32.12,y,-1),(.4,12.4,15.5),2,.2)
   H.box((s*32.4,y,-1),(.4,10.8,13.6),1,.2)
   H.box((s*32.65,y-2,-3),(.25,2.3,3.5),2,.07)
   H.box((s*32.81,y-2,-3),(.12,1.6,2.8),1,.04)
   H.box((s*32.9,y-2,-2.35),(.09,.75,.45),5,.02)
   for yy in [-4,4]:
    for zz in [-5,3]:H.box((s*32.8,y+yy,zz),(.25,.55,.24),2,.045)
  for y in [-7,75]:
   H.box((s*32.7,y,-6.5),(.12,7.4,1.4),8,.025)
  for y in [-41,18,104,141]:
   H.box((s*(width_at(y)+1.02),y,5.2),(.18,10.5,1.5),8,.04)
  # cooling louvres on bridge sides
  for y in [-4,37]:
   H.box((s*17,y,30),(1,14,7),2,.15)
   for j in range(12):H.box((s*17.6,y-6+j*1.05,30),(.7,.25,6.3),1,.04)
  # deck handrails at service routes, broken only around weapons
  for a,b in [(-151,-118),(-16,37),(66,85),(139,165)]:
   rail(H,[(s*(15 if a<-100 else 22),y,18.5) for y in range(a,b+1,4)])
  # conduits, service hatches and compact deck fittings
  pipe(H,[(s*17,-125,18.5),(s*18,-85,18.5),(s*20,-76,18.5)],.3)
  pipe(H,[(s*24,56,19),(s*24,146,19),(s*19,158,22)],.34)
  for y in [-142,-126,-108,61,78,140,156]:
   H.box((s*13,y,18.7),(4,6,.55),2,.16); H.box((s*13,y,19.05),(3.6,5.5,.22),0,.1)
   bolts(H,(s*13,y),1.45,2.25,19.18)
  for y in [-172,-158]:
   H.cyl((s*7,y,16),(s*7,y,18.5),.4,1,12); H.box((s*7,y,18.6),(3,.8,.55),1,.1)
 # bottom keel spine + armored ventral plinth
 H.loft([(-24,16,76,25,4),(-21,20,82,25,5)],2,.4)
 H.loft([(-25,14,20,-29,3),(-21,17,23,-29,3)],0,.3)
 # main turrets: two forward superfiring, one stern, one ventral
 main=[((0,-105,18.5),0,False),((0,-53,28.2),0,False),((0,113,25.5),pi,False),((0,-29,-24.5),pi,True)]
 gunid=0; muzzle=0
 def turret(pivot,yaw,size,barrels,under=False):
  nonlocal gunid,muzzle
  gunid+=1; idx=gunid; name=f'Turret_{idx}'; gn=f'Gun_{idx}'
  ro=(pi if under else 0,0,yaw)
  # stationary barbette ring attached to Hull; gun house itself independent.
  radius=8.8*size
  z=pivot[2]; direction=-1 if under else 1
  H.cyl((pivot[0],pivot[1],z-direction*.8),(pivot[0],pivot[1],z+direction*.35),radius*1.06,2,24)
  T=Part(name,pivot,'Hull',ro)
  T.cyl((0,0,-.2),(0,0,1.2*size),radius,1,24)
  T.loft([(1*size,9.4*size,10.8*size,1*size,2.2*size),(6.8*size,8.5*size,9.5*size,1.7*size,1.6*size),(8.1*size,7.8*size,8*size,2.2*size,1.3*size)],3,.28*size)
  T.box((0,5.5*size,8.2*size),(7*size,5*size,.5*size),1,.12*size)
  T.box((5.5*size,3*size,8.7*size),(3*size,3.3*size,1.1*size),3,.18*size)
  for side in [-1,1]:
   T.box((side*8.6*size,2*size,4.3*size),(.65*size,8*size,4*size),2,.16*size)
   for yy in [-.5,2,4.5]:T.box((side*8.99*size,yy*size,4.5*size),(.2*size,1.6*size,2.3*size),1,.05*size)
   T.cyl((side*8.1*size,-6*size,4*size),(side*8.1*size,-4.5*size,4*size),.8*size,1,16)
  G=Part(gn,(0,-7.4*size,4.6*size),name)
  G.cyl((-7.5*size,0,0),(7.5*size,0,0),1.9*size,2,20)
  for j in range(barrels):
   xx=(j-(barrels-1)/2)*4.7*size
   G.box((xx,-.5*size,0),(3.5*size,3.4*size,3.3*size),3,.36*size)
   # continuous stepped recoil sleeve and hollow muzzle, no end cap across bore
   G.tube((xx,0,0),[(0,1.45*size),(-4*size,1.45*size),(-5.5*size,1.14*size),(-8.5*size,1.14*size),(-9*size,.88*size),(-22*size,.68*size),(-23*size,.68*size),(-23*size,.48*size),(-20.3*size,.48*size),(-20.3*size,.55*size),(-1*size,.6*size)],1,24)
   for yy in [-3.8,-8.7]:G.tube((xx,yy*size,0),[(-.25*size,1.5*size),(.25*size,1.5*size),(.25*size,1.1*size),(-.25*size,1.1*size)],2,20)
   muzzle+=1; socket(f'Muzzle_{muzzle}',(xx,-23*size,0),gn)
  MOVING[name]={'axis':[0,-1 if under else 1,0],'role':'main turret' if barrels==3 else 'secondary turret'}
  MOVING[gn]={'axis':[1,0,0],'role':'gun elevation'}
 for pos,yaw,under in main:turret(pos,yaw,1,3,under)
 for sign in [-1,1]:
  for y in [-67,32,124]:
   start=len(H.v)
   H.loft([(-6,2.5,10,y,1),(1.2,5.8,11,y,2),(7,5,10,y,2)],0,.3)
   H.v[start:]=[(x+sign*28.5,yy,z) for x,yy,z in H.v[start:]]
   turret((sign*29.2,y,7.1),0 if y<100 else pi,.67,2)
 # missile cells in paired inclined racks; each has visible collar and launch hardpoint
 hard=0
 for sign in [-1,1]:
  for cy in [61,151]:
   H.box((sign*20,cy,20),(12,17,3),2,.35)
   for row in range(2):
    for col in range(4):
     x=sign*(16+col*2.65); y=cy; zz=22+row*2.5
     H.cyl((x,y+7,zz),(x,y-7,zz),1.15,1,16)
     H.cyl((x,y-6.9,zz),(x,y-7.35,zz),1.29,2,16)
     H.cyl((x,y-7.36,zz),(x,y-7.48,zz),.89,3,16)
     hard+=1;socket(f'Hardpoint_{hard}',(x,y-7.5,zz))
   for y in [cy-6,cy+6]:H.box((sign*20,y,21.3),(12.3,.65,2.8),0,.15)
   # Protective pod cheeks and transverse straps surround the exposed launcher tubes.
   for x in [13.6,26.5]:H.box((sign*x,cy,22.6),(.65,17.8,5),0,.15)
   H.box((sign*20,cy+8.2,22.4),(13,.75,5.1),0,.16)
   for row in [-1,1]:H.box((sign*20,cy+row*4.5,25.8),(13,.7,.5),2,.12)
   H.box((sign*20,cy+2,26.1),(13,12.5,.55),0,.15)
 # engine mounting bulkhead and six nozzle pods, arranged in a hexagon
 H.loft([(-17,23,14,179,5),(17,23,14,179,5)],2,.7)
 positions=[(-12,11),(12,11),(-21,-3),(21,-3),(-12,-17),(12,-17)]
 for i,(x,z) in enumerate(positions,1):
  H.tube((x,178,z),[(0,6.3),(3,7.8),(13,7.8),(17,6.9),(28,7.2),(39,8),(46.5,7.8),(47,7.3),(46,6.2),(38,5.3),(35,4.6),(32,4.6),(32,5.1),(20,5.1),(0,5.9)],1,48)
  for yy in [183,190,201,214,222]:H.tube((x,yy,z),[(-.45,7.8),(.45,7.8),(.45,7.35),(-.45,7.35)],2,32)
  # heat shield petals, longitudinal nozzle ribs and fasteners
  for j in range(12):
   a=2*pi*j/12
   px=x+7.45*cos(a); pz=z+7.45*sin(a)
   H.cyl((px,196,pz),(px,222,pz),.24,2,8)
   H.cyl((x+7.7*cos(a),219,z+7.7*sin(a)),(x+7.7*cos(a),220,z+7.7*sin(a)),.26,1,8)
  # Eight curved refractory petals, with open seams and backed mechanical flanges.
  for j in range(8):
   vv=[];half=pi/8-.028
   profile=[(211,8.02),(224,8.02),(224,8.25),(223.92,8.32),(211.08,8.32),(211,8.25)]
   for yy,rr in profile:
    for k in range(5):
     aa=2*pi*j/8-half+2*half*k/4;vv.append((x+rr*cos(aa),yy,z+rr*sin(aa)))
   ff=[]
   for q in range(len(profile)):
    qq=(q+1)%len(profile)
    for k in range(4):ff.append((q*5+k,q*5+k+1,qq*5+k+1,qq*5+k))
   ff += [tuple(q*5 for q in range(len(profile))),tuple(q*5+4 for q in reversed(range(len(profile))))]
   H.add(vv,ff,2,0,True)
  H.cyl((x,211,z),(x,212,z),4.55,6,48)
  H.tube((x,214,z),[(0,5.3),(.5,5.3),(.5,4.8),(0,4.8)],6,48)
  H.tube((x,223.7,z),[(-.22,8.08),(.22,8.08),(.22,7.86),(-.22,7.86)],6,32)
  H.cyl((x,212.1,z),(x,213,z),1.15,2,24)
  socket(f'Thruster_{i}',(x,225,z))
 # Stepped stern cowling encloses the engine roots, leaving each complete nozzle exit exposed.
 for sign in [-1,1]:
  H.box((sign*16.3,185,18.8),(19,20,2.7),0,.4,Matrix.Rotation(sign*.13,3,'Y'))
  H.box((sign*27.2,185,-2),(2.2,22,22),0,.35)
  H.box((sign*14,185,-24),(19,18,2),2,.3)
 # stern plumbing exposed between armored shoulders
 for s in [-1,1]:
  for z in [-8,0,8]:pipe(H,[(s*20,160,z),(s*24,172,z),(s*24,185,z)],.5)
 # Human-scale antennas, radomes and nav-light cages.
 for x,y,z in [(-11,41,40),(11,41,40),(-19,139,26),(19,139,26)]:
  H.cyl((x,y,z),(x,y,z+1.8),.8,2,16); H.cyl((x,y,z+1.8),(x,y,z+3.7),.6,1,16,r2=.15)
 for s in [-1,1]:
  H.box((s*33.6,32,8),(2.8,1.6,.5),2,.1)
  H.box((s*34.5,32,8.5),(.6,.8,.5),7,.09)
 socket('Light_Nav_L',(35,32,8.7),color=[1,0,0]); socket('Light_Nav_R',(-35,32,8.7),color=[0,1,0])
 socket('Light_Beacon',(0,25,53.5));socket('Camera_Chase',(0,310,95))
 # Layered armor plating, broad shallow recesses and service trunks interrupt large blank masses.
 for sign in [-1,1]:
  # Upper shoulder access-panel strips and armored brow with narrow steel exposed edges.
  for y in [-86,-52,-18,16,50,84,118,150]:
   for row in [-1,1]:
    x=sign*(23.2+row*.9)
    H.box((x,y,17.5),(1.3,20,.25),1,.07)
  # Angled citadel reinforcement ribs: larger structural rhythm than the tiled seams.
  for y in [-21,-7,7,21,35,48]:
   H.box((sign*19.2,y,23),(.7,.8,7),1,.11,Matrix.Rotation(-sign*.23,3,'Y'))
  for y in [94,107,120,133]:
   H.box((sign*20.5,y,21.4),(.65,.8,6.4),1,.1,Matrix.Rotation(-sign*.35,3,'Y'))
  # Stern heat-exchanger banks: real inset beds, louvers, braced pipework.
  H.box((sign*10,171,27.3),(12,24,.7),2,.2)
  for k in range(15):H.box((sign*10,160+k*1.5,27.8),(10.5,.45,.8),1,.09)
  H.box((sign*20.4,173,20),(.6,21,9),2,.18)
  for k in range(12):H.box((sign*20.8,164+k*1.5,20),(.4,.4,7.6),1,.07)
  # Paired auxiliary sensor/funnel housings behind the bridge, adding a stepped aft silhouette.
  if sign==1:H.loft([(28,4.4,9,44,1.2),(33.5,3.4,7,45,1),(35,3.2,6.6,45,1)],2,.22)
  pipe(H,[(sign*13.5,38,36),(sign*13.5,44,36),(sign*13.5,44,32)],.4)
  # low catwalk shields, recessed side strip; no spurious text or faction marks
  for a,b in [(-151,-120),(-13,34),(68,85),(141,164)]:
   H.box((sign*(15.6 if a<-100 else 22.7),(a+b)/2,18.8),(.3,b-a,.6),2,.08)
 # Bridge roof equipment and a proper open mast with stays.
 H.box((0,14,46.5),(15,7,.45),2,.14)
 for x in [-5,0,5]:H.box((x,14,46.8),(3.2,5.8,.35),1,.1)
 for sign in [-1,1]:
  pipe(H,[(sign*2.6,25,53),(0,25,59)],.09)
  H.cyl((sign*9,23,46),(sign*9,23,48.2),1.2,2,20)
  H.cyl((sign*9,23,48.2),(sign*9,23,49.7),1.2,1,20,r2=.3)
 # Recessed forward armor apron and maintenance access on the bridge face.
 H.box((0,-4.0,33),(9,.4,4),2,.15)
 for x in [-3.4,-1.7,0,1.7,3.4]:H.box((x,-4.28,33),(.7,.25,3),1,.07)
 # normalize exact mesh dimensions before creating pivoted objects; transforms remain untouched.
 # width is determined by broadside gun tips (~75 m): compress only lateral world geometry.
 for p in ASSEMBLIES.values():p.create()
 for name,pos,parent,rot,meta in SOCKETS:
  ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.empty_display_size=1
  ob.parent=ASSEMBLIES[parent].ob;ob.location=pos;ob.rotation_euler=rot
  for k,v in meta.items():ob[k]=v
 bpy.context.view_layer.update()
 # Apply affine dimensions in WORLD then back into each mesh's local frame, preserving all pivots.
 objects=[p.ob for p in ASSEMBLIES.values()]
 pts=[o.matrix_world@Vector(c) for o in objects for c 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)])
 factors=Vector((70/(hi.x-lo.x),450/(hi.y-lo.y),90/(hi.z-lo.z)))
 old={o.name:o.matrix_world.copy() for o in list(objects)+[bpy.data.objects[s[0]] for s in SOCKETS]}
 worldScale=Matrix.Diagonal((*factors,1))
 for o in objects:
  oldmat=old[o.name]; newloc=worldScale@oldmat.translation; rot=oldmat.to_quaternion().to_matrix().to_4x4(); rot.translation=newloc
  deform=rot.inverted()@worldScale@oldmat
  for v in o.data.vertices:v.co=deform@v.co
  o.matrix_world=rot
 bpy.context.view_layer.update()
 for name,*_ in SOCKETS:
  ob=bpy.data.objects[name]; w=old[name].copy();w.translation=worldScale@w.translation;ob.matrix_world=w
 bpy.context.view_layer.update()
 cache={}
 for o in objects:
  signature=(tuple(tuple(round(c,5) for c in v.co) for v in o.data.vertices),tuple((tuple(p.vertices),p.material_index) for p in o.data.polygons))
  if signature in cache:o.data=cache[signature]
  else:cache[signature]=o.data
 log('Geometry components '+str(len(H.f))+' static faces')


def setup_lighting():
 w=SC.world;w.use_nodes=True;n=w.node_tree.nodes;n.clear();L=w.node_tree.links.new
 tc=node(w.node_tree,'ShaderNodeTexCoord');sep=node(w.node_tree,'ShaderNodeSeparateXYZ');L(tc.outputs['Normal'],sep.inputs[0])
 ramp=node(w.node_tree,'ShaderNodeValToRGB');L(sep.outputs['Z'],ramp.inputs[0]);ramp.color_ramp.elements[0].color=(.004,.006,.012,1);ramp.color_ramp.elements[1].color=(.015,.027,.046,1)
 bg=node(w.node_tree,'ShaderNodeBackground');bg.inputs['Strength'].default_value=.36;L(ramp.outputs[0],bg.inputs[0]);out=node(w.node_tree,'ShaderNodeOutputWorld');ambient=node(w.node_tree,'ShaderNodeBackground');ambient.inputs['Color'].default_value=(.11,.14,.19,1);ambient.inputs['Strength'].default_value=.7
 lp=node(w.node_tree,'ShaderNodeLightPath');mixworld=node(w.node_tree,'ShaderNodeMixShader');L(lp.outputs['Is Camera Ray'],mixworld.inputs[0]);L(ambient.outputs[0],mixworld.inputs[1]);L(bg.outputs[0],mixworld.inputs[2]);L(mixworld.outputs[0],out.inputs[0])
 def sun(name,loc,energy,color,angle):
  d=bpy.data.lights.new(name,'SUN');d.energy=energy;d.color=color;d.angle=angle;o=bpy.data.objects.new(name,d);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(-Vector(loc)).to_track_quat('-Z','Y').to_euler()
 sun('Key',(170,-240,330),4.1,(1,.95,.88),.10)
 sun('CoolFill',(-200,-70,90),1.15,(.64,.78,1),.28)
 sun('Rim',(10,200,140),2.0,(.65,.8,1),.14)
 d=bpy.data.lights.new('Broad daylight reflection','AREA');d.energy=2500000;d.shape='RECTANGLE';d.size=190;d.size_y=280;o=bpy.data.objects.new('Broad daylight reflection',d);bpy.context.collection.objects.link(o);o.location=(160,-70,240);o.rotation_euler=(-o.location).to_track_quat('-Z','Y').to_euler()
 sun('LowerBounce',(-50,-10,-180),.36,(.59,.7,1),.3)
 camd=bpy.data.cameras.new('RenderCamera');cam=bpy.data.objects.new('RenderCamera',camd);bpy.context.collection.objects.link(cam);SC.camera=cam
 # Blender 5 compositor socket API; glow is restrained and only affects emissive highlights.
 comp=bpy.data.node_groups.new('Cruiser_Compositing','CompositorNodeTree');SC.compositing_node_group=comp
 comp.interface.new_socket(name='Image',in_out='OUTPUT',socket_type='NodeSocketColor')
 rl=comp.nodes.new('CompositorNodeRLayers');glare=comp.nodes.new('CompositorNodeGlare')
 glare.inputs['Type'].default_value='Fog Glow';glare.inputs['Quality'].default_value='High';glare.inputs['Threshold'].default_value=1.3;glare.inputs['Strength'].default_value=.16;glare.inputs['Size'].default_value=.2
 output=comp.nodes.new('NodeGroupOutput');comp.links.new(rl.outputs['Image'],glare.inputs['Image']);comp.links.new(glare.outputs['Image'],output.inputs['Image'])
 camd.type='ORTHO';camd.clip_end=3000
 return cam

def camera_view(cam,mode='hero'):
 if mode=='side':cam.location=(600,0,20);target=Vector((0,0,12));cam.data.ortho_scale=490
 elif mode=='rear':cam.location=(340,540,230);target=Vector((0,55,8));cam.data.ortho_scale=440
 elif mode=='close':cam.location=(110,-122,90);target=Vector((0,-45,27));cam.data.ortho_scale=143
 else:cam.location=(370,-490,245);target=Vector((0,-10,6));cam.data.ortho_scale=455
 cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()

def render(path,w=1920,h=1080,samples=64):
 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);log('Rendered '+str(path.name))

def patch_glb(path):
 data=path.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);blob=data[20+jl:]
 for m in doc.get('materials',[]):
  m['doubleSided']=m['name']=='Glass'
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[*PAINTS[0][1],1]
  if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
 raw=json.dumps(doc,separators=(',',':')).encode();raw+=b' '*((-len(raw))%4)
 path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(raw)+len(blob))+struct.pack('<I4s',len(raw),b'JSON')+raw+blob)
 return doc

def export():
 bpy.ops.object.select_all(action='DESELECT')
 for o in SC.objects:
  if o.type in ['MESH','EMPTY']:o.select_set(True)
 path=ROOT/'heavy-cruiser-naval.glb'
 bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_image_format='JPEG',export_jpeg_quality=82,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_animations=False)
 doc=patch_glb(path)
 if path.stat().st_size>12_000_000:raise RuntimeError('GLB over budget: '+str(path.stat().st_size))
 log('GLB bytes '+str(path.stat().st_size))
 # Metadata is exported in glTF coordinates, matching the actual world placement.
 def cv(v):return [round(v[0],5),round(v[2],5),round(-v[1],5)]
 meshobs=[o for o in SC.objects if o.type=='MESH'];pts=[o.matrix_world@Vector(c) for o in meshobs for c in o.bound_box]
 pp=[cv(p) for p in pts];lo=[min(p[i] for p in pp) for i in range(3)];hi=[max(p[i] for p in pp) for i in range(3)]
 tris=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in meshobs)
 nd={}
 for o in SC.objects:
  if o.type not in ['MESH','EMPTY']:continue
  nd[o.name]={'world_position':cv(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'socket'}
  if o.name in MOVING:nd[o.name].update(MOVING[o.name])
 meta={'asset':'heavy-cruiser-naval','units':'metres','origin':'Approximate centre of mass, middle of the main hull','texture_repeat_metres':{'Paint':12,'Metal':3.2,'Deck':8,'Trim':[4,1]},'coordinate_system':{'up':'+Y','nose':'+Z','left':'+X'},'bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],4) for i in range(3)]},'triangles':tris,'materials':[m.name for m in MATS],'nodes':nd,'axes':{k:v['axis'] for k,v in MOVING.items()},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
 (ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
 (ROOT/'NOTES.md').write_text('\n'.join([
 'Original 450 × 70 × 90 m naval space cruiser: four triple main turrets, six twin flank turrets, 32 missile cells, six engines; pivoted weapons and game sockets.',
 f'{tris:,} triangles; {len(meshobs)} mesh nodes; GLB {path.stat().st_size/1048576:.2f} MiB; Y up, +Z nose, +X port, metre units.',
 '9 materials: Paint, Metal, Dark, Weapons, Deck, Glass (BLEND), Engines, Lamps, Trim; neutral Paint bake with default linear factor [0.32, 0.34, 0.36].',
 'Cycles GPU tile bakes: Base/Normal 1024² and ORM 512² (Glass/Engines/Lamps 32²); shared world-scale UVs and 4 × 1 m vent trims; JPEG embedded in GLB; no external runtime files.',
 'Poly Haven CC0 ids: metal_plate, rusty_metal_04; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/rusty_metal_04 ; licence https://polyhaven.com/license .',
 'Reference differences: original faceted ram and bridge detailing, six hex-arranged nozzles; restrained patina for vacuum service, no markings; no interior beyond bridge console silhouettes.'
 ])+'\n')
 assert 80000<=tris<=200000, ('Triangle budget',tris)
 return meta

def main():
 download()
 for name,kind in [('Paint','paint'),('Metal','metal'),('Dark','dark'),('Weapons','weapon'),('Deck','deck'),('Glass','glass'),('Engines','engine'),('Lamps','lamp'),('Trim','trim')]:bake_material(name,kind)
 build_geometry();cam=setup_lighting();camera_view(cam)
 clay=bpy.data.materials.new('QA_Clay');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.35,.35,.35,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.7
 SC.view_layers[0].material_override=clay;render(ROOT/'clay.png',1920,1080,24);SC.view_layers[0].material_override=None
 # Prove pivots: every turret +30 degrees traverse, every barrel +30 elevation.
 original={name:tuple(bpy.data.objects[name].rotation_euler) for name in MOVING}
 local_positions={name:bpy.data.objects[name].location.copy() for name in MOVING}
 for name in MOVING:
  ob=bpy.data.objects[name];local_axis='Z' if name.startswith('Turret') else 'X'
  ob.rotation_euler=(ob.rotation_euler.to_matrix() @ Matrix.Rotation(math.radians(30),3,local_axis)).to_euler()
  assert (ob.location-local_positions[name]).length<1e-7
 bpy.context.view_layer.update();render(QA/'motion-test.png',1280,720,20)
 for name,e in original.items():bpy.data.objects[name].rotation_euler=e
 bpy.context.view_layer.update();meta=export();camera_view(cam);render(ROOT/'final.png')
 camera_view(cam,'side');render(ROOT/'side.png',1920,1080,28)
 camera_view(cam,'rear');render(QA/'rear.png',1280,720,24)
 camera_view(cam,'close');render(QA/'close.png',1280,720,24)
 camera_view(cam)
 tint=MATS[0].node_tree.nodes['FactionTint'];imgs=[]
 for i,(name,c) in enumerate(PAINTS):
  tint.inputs[2].default_value=(*c,1);p=QA/f'paint-{i}.png';render(p,640,360,16)
  im=bpy.data.images.load(str(p));arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);imgs.append(arr)
 tint.inputs[2].default_value=(*PAINTS[0][1],1)
 grid=np.ones((720,1920,4),np.float32)
 for i,ar in enumerate(imgs):
  row=1-i//3;col=i%3;grid[row*360:(row+1)*360,col*640:(col+1)*640]=ar
 im=bpy.data.images.new('SixPaints',width=1920,height=720);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
 # Keep a source checkpoint remotely, while the self-contained script is the portable source of truth.
 bpy.ops.wm.save_as_mainfile(filepath=str(QA/'cruiser.blend'))
 (QA/'build-report.json').write_text(json.dumps({'elapsed_seconds':time.time()-START,'device':SC.cycles.device,'triangles':meta['triangles'],'moving_nodes_tested':list(MOVING),'bake_engine':'Cycles','bake_samples':8,'source_ids':['metal_plate','rusty_metal_04']},indent=2))
 log('COMPLETE')
if __name__=='__main__':main()
