"""Original fleet carrier. Blender 5.2, Cycles GPU, metres, deterministic seed.
Run: blender -b --python-exit-code 1 --python build_carrier-mothership.py
Rebuilds from an empty scene; obtains only CC0 Poly Haven photographic maps;
bakes reusable tile materials, builds articulated meshes, validates and exports.
"""
import bpy, bmesh, math, random, os, sys, json, struct, urllib.request, time, ctypes
# Blender distributions may omit their bundled Draco directory from the loader path.
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
ROOT=Path(__file__).resolve().parent
_draco=Path(bpy.app.binary_path).resolve().parent/'lib/libdraco.so.9'
if _draco.exists():ctypes.CDLL(str(_draco), mode=ctypes.RTLD_GLOBAL)
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
BAKE=ROOT/'baked'; BAKE.mkdir(exist_ok=True)
QA=ROOT/'qa'; QA.mkdir(exist_ok=True)
random.seed(74)
START=time.time()
IDS=['metal_plate','rusty_metal_02','painted_metal_shutter']
PAINTS=[('Naval grey',[.55,.55,.52]),('Gunmetal',[.155,.19,.22]),('Olive drab',[.25,.285,.155]),('Desert sand',[.61,.43,.245]),('Arctic white',[.86,.89,.9]),('Deep navy',[.075,.145,.245])]
MATS=[]; SURF={}; objects=[]; sockets=[]; moving=[]

def log(s):print('CARRIER: '+s,flush=True)
def setup():
 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
 pr=bpy.context.preferences.addons['cycles'].preferences
 try:pr.compute_device_type='OPTIX';pr.get_devices()
 except:pr.compute_device_type='CUDA';pr.get_devices()
 for d in pr.devices:d.use=d.type in {'OPTIX','CUDA'}
 assert any(d.use for d in pr.devices),'GPU unavailable'
 sc.cycles.device='GPU';sc.cycles.max_bounces=6;sc.cycles.diffuse_bounces=3;sc.cycles.glossy_bounces=3
 sc.render.image_settings.file_format='PNG';sc.render.image_settings.color_mode='RGB'
 sc.view_settings.view_transform='AgX';sc.view_settings.look='AgX - Medium High Contrast';sc.view_settings.exposure=.25
 sc.render.film_transparent=False
 return sc
SC=setup()

def fetch():
 for ident in IDS:
  for kind in ['diff','nor_gl','arm']:
   p=TEX/f'{ident}_{kind}_1k.jpg'
   if not p.exists():
    url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{kind}_1k.jpg'
    req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0 (Forge asset author)'})
    p.write_bytes(urllib.request.urlopen(req,timeout=60).read())
 log('CC0 photographic inputs ready')

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

def mathn(nt,op,a,b=None):
 n=node(nt,'ShaderNodeMath',operation=op)
 if hasattr(a,'node'):nt.links.new(a,n.inputs[0])
 else:n.inputs[0].default_value=a
 if b is not None:
  if hasattr(b,'node'):nt.links.new(b,n.inputs[1])
  else:n.inputs[1].default_value=b
 return n.outputs[0]

def ramp(nt,fac,stops):
 n=node(nt,'ShaderNodeValToRGB')
 for e in list(n.color_ramp.elements)[2:]:n.color_ramp.elements.remove(e)
 for i,(pos,col) in enumerate(stops):
  e=n.color_ramp.elements[i] if i<2 else n.color_ramp.elements.new(pos)
  e.position=pos;e.color=(*col[:3],col[3] if len(col)>3 else 1)
 nt.links.new(fac,n.inputs[0]);return n.outputs[0]

def source_material(name,ident,base,metal,rough,tile,mode):
 m=bpy.data.materials.new(name+'_source');m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs['Surface'])
 uv=node(nt,'ShaderNodeTexCoord');tex={}
 for kind in ['diff','nor_gl','arm']:
  im=bpy.data.images.load(str(TEX/f'{ident}_{kind}_1k.jpg'),check_existing=True)
  if kind!='diff':im.colorspace_settings.name='Non-Color'
  t=node(nt,'ShaderNodeTexImage',image=im);nt.links.new(uv.outputs['UV'],t.inputs[0]);tex[kind]=t.outputs['Color']
 gray=node(nt,'ShaderNodeRGBToBW');nt.links.new(tex['diff'],gray.inputs[0])
 noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=15;noise.inputs['Detail'].default_value=4
 nt.links.new(uv.outputs['UV'],noise.inputs[0])
 # A large-scale desaturated scan underlaid with directional dirt and fine pits.
 mp=node(nt,'ShaderNodeVectorMath',operation='MULTIPLY');mp.inputs[1].default_value=(13,1.5,1)
 nt.links.new(uv.outputs['UV'],mp.inputs[0]);streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=5;streak.inputs['Detail'].default_value=3;nt.links.new(mp.outputs[0],streak.inputs[0])
 shade=mathn(nt,'MULTIPLY_ADD',gray.outputs[0],.42);shade.node.inputs[2].default_value=.51
 shade=mathn(nt,'MULTIPLY',shade,mathn(nt,'ADD',mathn(nt,'MULTIPLY',streak.outputs['Fac'],.28),.72))
 col=node(nt,'ShaderNodeMixRGB',blend_type='MULTIPLY');col.inputs[0].default_value=1;col.inputs[1].default_value=(*base,1);nt.links.new(shade,col.inputs[2])
 color=col.outputs[0]
 # Panelized tile: four rectangular sheets, recessed perimeter, subtle edge wear.
 sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],sep.inputs[0])
 edge=[]
 for ax,scale in [('X',1),('Y',1)]:
  f=mathn(nt,'FRACT',mathn(nt,'MULTIPLY',sep.outputs[ax],scale))
  edge.append(mathn(nt,'MINIMUM',f,mathn(nt,'SUBTRACT',1,f)))
 dist=mathn(nt,'MINIMUM',edge[0],edge[1]);seam=mathn(nt,'LESS_THAN',dist,.0015 if mode=='paint' else .002)
 if mode=='paint':
  mix=node(nt,'ShaderNodeMixRGB');nt.links.new(seam,mix.inputs[0]);nt.links.new(color,mix.inputs[1]);mix.inputs[2].default_value=(.52,.53,.54,1);color=mix.outputs[0]
 if mode=='paint':
  # Fine pitting, uneven salt deposits and edge chips stay neutral for faction tinting.
  fine=node(nt,'ShaderNodeTexNoise');fine.inputs['Scale'].default_value=150;fine.inputs['Detail'].default_value=2;nt.links.new(uv.outputs['UV'],fine.inputs[0])
  dirt=mathn(nt,'MULTIPLY',mathn(nt,'LESS_THAN',dist,.035),mathn(nt,'GREATER_THAN',streak.outputs['Fac'],.52))
  pit=mathn(nt,'GREATER_THAN',fine.outputs['Fac'],.72)
  weather=mathn(nt,'MAXIMUM',mathn(nt,'MULTIPLY',dirt,.35),mathn(nt,'MULTIPLY',pit,.45))
  mix=node(nt,'ShaderNodeMixRGB');nt.links.new(weather,mix.inputs[0]);nt.links.new(color,mix.inputs[1]);mix.inputs[2].default_value=(.22,.23,.24,1);color=mix.outputs[0]
 nt.links.new(color,bs.inputs['Base Color']);bs.inputs['Metallic'].default_value=metal
 ar=node(nt,'ShaderNodeSeparateColor');nt.links.new(tex['arm'],ar.inputs[0])
 rough_socket=mathn(nt,'ADD',mathn(nt,'MULTIPLY',noise.outputs['Fac'],.12),rough-.06)
 rough_socket=mathn(nt,'ADD',mathn(nt,'MULTIPLY',rough_socket,.8),mathn(nt,'MULTIPLY',ar.outputs[1],.2));nt.links.new(rough_socket,bs.inputs['Roughness'])
 nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.35 if mode=='paint' else .32;nt.links.new(tex['nor_gl'],nm.inputs['Color'])
 bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.3;bump.inputs['Distance'].default_value=.06
 height=mathn(nt,'SUBTRACT',mathn(nt,'MULTIPLY',noise.outputs['Fac'],.04),mathn(nt,'MULTIPLY',seam,.25 if mode=='paint' else .03))
 nt.links.new(height,bump.inputs['Height']);nt.links.new(nm.outputs['Normal'],bump.inputs['Normal']);nt.links.new(bump.outputs[0],bs.inputs['Normal'])
 orm=node(nt,'ShaderNodeCombineColor');orm.mode='RGB';orm.inputs[0].default_value=1;nt.links.new(rough_socket,orm.inputs[1]);orm.inputs[2].default_value=metal
 nt.links.new(mathn(nt,'MULTIPLY',ar.outputs[0],mathn(nt,'SUBTRACT',1,mathn(nt,'MULTIPLY',seam,.25))),orm.inputs[0])
 return m,bs,out,color,orm.outputs[0],tile

def bake_material(name,source,res=1024):
 m,bs,out,color,orm,tile=source;nt=m.node_tree
 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
 maps={};em=node(nt,'ShaderNodeEmission')
 for kind,size in [('base',res),('normal',res),('orm',min(res,512))]:
  im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
  if kind!='base':im.colorspace_settings.name='Non-Color'
  target=node(nt,'ShaderNodeTexImage',image=im);nt.nodes.active=target
  if kind=='normal':
   nt.links.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   nt.links.new(color if kind=='base' else orm,em.inputs[0]);nt.links.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(BAKE/f'{name}_{kind}.png');im.file_format='PNG';im.save();maps[kind]=im
 bpy.data.objects.remove(plane,do_unlink=True);bpy.data.materials.remove(m)
 mat=bpy.data.materials.new(name);mat.use_nodes=True;nt=mat.node_tree;bs=nt.nodes.get('Principled BSDF')
 for kind,im in maps.items():
  t=node(nt,'ShaderNodeTexImage',image=im);t.label='Cycles baked '+kind
  if kind=='base':nt.links.new(t.outputs['Color'],bs.inputs['Base Color']);base_tex=t
  elif kind=='normal':
   n=node(nt,'ShaderNodeNormalMap');nt.links.new(t.outputs['Color'],n.inputs['Color']);nt.links.new(n.outputs[0],bs.inputs['Normal'])
  else:
   n=node(nt,'ShaderNodeSeparateColor');nt.links.new(t.outputs[0],n.inputs[0]);nt.links.new(n.outputs[1],bs.inputs['Roughness']);nt.links.new(n.outputs[2],bs.inputs['Metallic'])
   g=bpy.data.node_groups.get('glTF Material Output')
   if not g:
    g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=node(nt,'ShaderNodeGroup',node_tree=g);nt.links.new(n.outputs[0],gn.inputs['Occlusion'])
 if name=='Paint':
  attr=node(nt,'ShaderNodeVertexColor',layer_name='FabricationTone')
  mod=node(nt,'ShaderNodeMixRGB',blend_type='MULTIPLY');mod.inputs[0].default_value=1;nt.links.new(base_tex.outputs[0],mod.inputs[1]);nt.links.new(attr.outputs[0],mod.inputs[2])
  mul=node(nt,'ShaderNodeMixRGB',blend_type='MULTIPLY');mul.name='FactionColour';mul.inputs[0].default_value=1;nt.links.new(mod.outputs[0],mul.inputs[1]);mul.inputs[2].default_value=(*PAINTS[0][1],1);nt.links.new(mul.outputs[0],bs.inputs['Base Color'])
 mat.diffuse_color=(*PAINTS[0][1],1) if name=='Paint' else (.25,.27,.28,1)
 SURF[name]={'tile':tile,'base_tex':base_tex,'bs':bs,'maps':maps};MATS.append(mat)
 log('Baked material '+name)
 return mat

def special(name,kind):
 # Small shared trim atlas, also baked through Cycles, including emissive colour.
 m=bpy.data.materials.new(name+'_source');m.use_nodes=True;nt=m.node_tree;bs=nt.nodes.get('Principled BSDF');out=nt.nodes.get('Material Output')
 uv=node(nt,'ShaderNodeTexCoord');sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],sep.inputs[0])
 if name=='Lamps':
  color=ramp(nt,sep.outputs['X'],[(0,(.24,.72,1)),(.249,(.24,.72,1)),(.25,(1,.36,.07)),(.499,(1,.36,.07)),(.5,(1,.045,.016)),(.749,(1,.045,.016)),(.75,(.045,1,.21)),(1,(.045,1,.21))]);color.node.color_ramp.interpolation='CONSTANT'
 elif name=='Engines':
  color=ramp(nt,sep.outputs['X'],[(0,(.08,.28,1)),(.45,(.2,.65,1)),(.8,(.63,.88,1)),(1,(1,.37,.075))])
 else:
  n=node(nt,'ShaderNodeRGB');n.outputs[0].default_value=(.055,.12,.16,1);color=n.outputs[0]
 nt.links.new(color,bs.inputs['Base Color']);orm=node(nt,'ShaderNodeCombineColor');orm.inputs[0].default_value=1;orm.inputs[1].default_value=.22 if name=='Glass' else .42;orm.inputs[2].default_value=.35 if name=='Glass' else .1
 result=bake_material(name,(m,bs,out,color,orm.outputs[0],8),64)
 bs=SURF[name]['bs']
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.72;bs.inputs['Metallic'].default_value=.38;bs.inputs['Roughness'].default_value=.18
  result.surface_render_method='DITHERED'
 else:
  result.node_tree.links.new(SURF[name]['base_tex'].outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=2 if name=='Lamps' else 2.4
 return result

# ---------- Parametric, chamfered, joined construction geometry ----------
CACHE={}
class Part:
 def __init__(self,name):self.name=name;self.v=[];self.f=[];self.mi=[];self.smooth=[];self.uvs=[];self.tones=[]
 def add(self,verts,faces,mat='Paint',smooth=False,uv=None):
  k=len(self.v);shade=random.uniform(.72,1.0) if mat=='Paint' else 1.0;self.tones.extend([shade]*len(verts));self.v.extend(tuple(v) for v in verts);self.f.extend(tuple(k+i for i in f) for f in faces);self.mi.extend([mat]*len(faces));self.smooth.extend([smooth]*len(faces));self.uvs.extend([uv]*len(faces))
 def mesh(self,pivot=(0,0,0),parent=None):
  me=bpy.data.meshes.new(self.name);me.from_pydata([tuple(Vector(v)-Vector(pivot)) for v in self.v],[],self.f);me.update()
  ob=bpy.data.objects.new(self.name,me);bpy.context.collection.objects.link(ob);ob.location=pivot
  for m in MATS:me.materials.append(m)
  uv=me.uv_layers.new(name='UVMap')
  vc=me.color_attributes.new(name='FabricationTone',type='BYTE_COLOR',domain='CORNER')
  for poly in me.polygons:
   for li in poly.loop_indices:
    t=self.tones[me.loops[li].vertex_index];vc.data[li].color=(t,t,t,1)
  for p,mat,sm,custom in zip(me.polygons,self.mi,self.smooth,self.uvs):
   p.material_index=[m.name for m in MATS].index(mat);p.use_smooth=sm
   axis=max(range(3),key=lambda k:abs(p.normal[k]));dims=[i for i in range(3) if i!=axis];scale=SURF[mat]['tile']
   for li in p.loop_indices:
    vv=self.v[me.loops[li].vertex_index]
    uv.data[li].uv=custom if custom else (vv[dims[0]]/scale,vv[dims[1]]/scale)
  if parent:
   ob.parent=parent;ob.location=Vector(pivot)-parent.matrix_world.translation
  objects.append(ob);return ob

def box(P,c,d,mat='Paint',b=.6,seg=2,rot=None,uv=None):
 key=(tuple(d),b,seg)
 if key not in CACHE:
  bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
  for v in bm.verts:v.co=Vector((v.co.x*d[0],v.co.y*d[1],v.co.z*d[2]))
  if b>0:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(b,min(d)*.24),segments=seg,affect='EDGES')
  bm.verts.ensure_lookup_table();bm.verts.index_update();CACHE[key]=([v.co.copy() for v in bm.verts],[[v.index for v in f.verts] for f in bm.faces]);bm.free()
 vs,fs=CACHE[key];R=rot if rot else Matrix.Identity(3);P.add([R@v+Vector(c) for v in vs],fs,mat,uv=uv)

def loft(P,sections,mat='Paint',x=0):
 vs=[]
 for y,w,low,high,ch in sections:
  a=w/2;vs.extend([(x-a+ch,y,low),(x+a-ch,y,low),(x+a,y,low+ch),(x+a,y,high-ch),(x+a-ch,y,high),(x-a+ch,y,high),(x-a,y,high-ch),(x-a,y,low+ch)])
 fs=[tuple(reversed(range(8)))]
 for k in range(len(sections)-1):
  for j in range(8):fs.append((k*8+j,k*8+(j+1)%8,(k+1)*8+(j+1)%8,(k+1)*8+j))
 fs.append(tuple(range(len(vs)-8,len(vs))))
 bm=bmesh.new();vv=[bm.verts.new(v) for v in vs]
 for f in fs:
  try:bm.faces.new([vv[i] for i in f])
  except ValueError:pass
 bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.00001)
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
 edges=[e for e in bm.edges if e.is_manifold and e.calc_face_angle()>.12]
 try:bmesh.ops.bevel(bm,geom=edges,offset=.65 if P.name.startswith('Turret') else 1.25,segments=3,affect='EDGES',clamp_overlap=True)
 except:pass
 bm.verts.ensure_lookup_table();bm.verts.index_update();P.add([v.co.copy() for v in bm.verts],[[v.index for v in f.verts] for f in bm.faces],mat);bm.free()

def cyl(P,a,b,r,mat='Steel',n=16,r2=None,cap=True):
 a,b=Vector(a),Vector(b);direction=b-a
 if direction.length<1e-5:return
 q=direction.to_track_quat('Z','Y');r2=r if r2 is None else r2
 vs=[tuple(q@Vector((rad*math.cos(i*2*math.pi/n),rad*math.sin(i*2*math.pi/n),z))+a) for z,rad in [(0,r),(direction.length,r2)] for i in range(n)]
 fs=[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)];P.add(vs,fs,mat,True,uv=(.32,.5) if mat=='Engines' else None)
 if cap:P.add(vs,[tuple(reversed(range(n))),tuple(range(n,2*n))],mat,uv=(.32,.5) if mat=='Engines' else None)

def tube(P,center,profile,mat='Steel',n=48,uv=None):
 x,z=center;vs=[(x+r*math.cos(i*2*math.pi/n),y,z+r*math.sin(i*2*math.pi/n)) for y,r in profile for i in range(n)]
 fs=[(k*n+i,k*n+(i+1)%n,(k+1)*n+(i+1)%n,(k+1)*n+i) for k in range(len(profile)-1) for i in range(n)]
 P.add(vs,fs,mat,True,uv=uv or ((.45,.5) if mat=='Engines' else None))

def plate(P,outline,z,th=1.2,mat='Paint'):
 vs=[(x,y,z+zz) for zz in [0,th] for x,y in outline];n=len(outline)
 P.add(vs,[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],mat)

def socket(name,loc,parent=None,direction=None):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.empty_display_type='PLAIN_AXES';o.empty_display_size=3;o.location=loc
 if parent:o.parent=parent;o.location=Vector(loc)-parent.matrix_world.translation
 if direction:o.rotation_euler=Vector(direction).to_track_quat('-Y','Z').to_euler()
 sockets.append(o);return o

HULL_SECTIONS=[(-622,167,-51,40,18),(-425,220,-62,43,22),(-360,242,-64,44,24),(310,242,-64,44,24),(390,258,-68,43,25),(580,240,-66,37,23),(625,210,-53,27,20)]
SHOULDER_SECTIONS=[(355,112,-66,55,18),(375,112,-66,55,18),(556,102,-62,51,17),(592,73,-49,33,13)]
def profile(y,sections=HULL_SECTIONS):
 for A,B in zip(sections,sections[1:]):
  if A[0]<=y<=B[0]:
   t=(y-A[0])/(B[0]-A[0]);return tuple(a+(b-a)*t for a,b in zip(A[1:],B[1:]))
 return sections[0][1:] if y<sections[0][0] else sections[-1][1:]
def deck(y):return profile(y)[2]
def compress_hangar(P,start):
 for i in range(start,len(P.v)):
  x,y,z=P.v[i]
  if abs(x)>120:x=math.copysign(120+(abs(x)-120)*.6,x)
  P.v[i]=(x,y,z)

def portal(P,x,front,back,outer,inner,zc,mat='Paint'):
 # Connected octagonal annulus, with real deep inner return walls.
 def ring(y,w,h,c):
  return [(x-w/2+c,y,zc-h/2),(x+w/2-c,y,zc-h/2),(x+w/2,y,zc-h/2+c),(x+w/2,y,zc+h/2-c),(x+w/2-c,y,zc+h/2),(x-w/2+c,y,zc+h/2),(x-w/2,y,zc+h/2-c),(x-w/2,y,zc-h/2+c)]
 vs=ring(front,*outer)+ring(back,*outer)+ring(front,*inner)+ring(back,*inner)
 fs=[]
 for i in range(8):
  j=(i+1)%8
  fs.extend([(i,j,j+8,i+8),(i+16,i+24,j+24,j+16),(i,i+16,j+16,j),(i+8,j+8,j+24,i+24)])
 bm=bmesh.new();vv=[bm.verts.new(v) for v in vs]
 for f in fs:bm.faces.new([vv[i] for i in f])
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
 bmesh.ops.bevel(bm,geom=list(bm.edges),offset=.6,segments=3,affect='EDGES',clamp_overlap=True)
 bm.verts.ensure_lookup_table();bm.verts.index_update();P.add([v.co.copy() for v in bm.verts],[[v.index for v in f.verts] for f in bm.faces],mat);bm.free()

def construct():
 P=Part('Hull')
 # The pressure hull: blunt parallel forebody, stepped shoulders, chamfered keel.
 loft(P,HULL_SECTIONS)
 loft(P,[(-560,106,-60,-44,7),(-360,158,-73,-47,10),(290,160,-74,-48,10),(415,176,-80,-48,12),(575,134,-70,-42,10)],'Steel')
 # A deep bow recess sits within a continuous rounded collar and brow.
 portal(P,0,-650,-620,(166,91,18),(120,64,10),-5,'Paint')
 portal(P,0,-652,-644,(129,73,12),(116,60,9),-5,'Steel')
 box(P,(0,-623,-5),(118,3,63),'Dark',3)
 box(P,(0,-626,-8),(93,3,52),'Steel',3)
 for x in [-22,22]:box(P,(x,-628,-8),(39,2,44),'Dark',2)
 box(P,(0,-629,-8),(2,2,47),'Steel',.4)
 for x in [-36,36]:
  box(P,(x,-630,-11),(7,5,30),'Steel',1)
  box(P,(x,-634,-11),(4,4,17),'Dark',.5)
 for x in [-52,52]:
  for z in [-23,13]:box(P,(x,-641,z),(5,14,5),'Steel',1)
 for x in [-59,59]:
  loft(P,[(-644,12,-57,-45,3),(-623,23,-66,-45,4),(-548,23,-64,-48,4),(-530,10,-55,-48,3)],'Steel',x)
 box(P,(0,-633,40),(118,25,3),'Paint',1)
 for s in [-1,1]:
  cyl(P,(s*58,-642,-35),(s*58,-681,-35),3,'Steel',16,r2=2)
  cyl(P,(s*58,-681,-35),(s*58,-700,-35),1.2,'Steel',12,r2=.6)
  for j in range(4):cyl(P,(s*58,-658-j*7,-35),(s*58,-660-j*7,-35),3.5,'Dark',16)
 # Flush fitted deck plating with nonuniform plate sizes and tiny weld beads.
 for row,y in enumerate(range(-596,589,44)):
  w,lo,high,ch=profile(y);width=w/2-ch-4
  for side in [-1,1]:
   if -25<y<145 and side==1:continue
   cols=[(width*.3,width*.55),(width*.79,width*.36)] if row%3==0 else [(width*.5,width-.8)]
   for cx,ww in cols:
    # Follow axial camber instead of hovering over the bow's falling deck.
    yy0=y-20.9;yy1=y+20.9
    outline=[(side*(cx-ww/2),yy0),(side*(cx+ww/2),yy0),(side*(cx+ww/2),yy1),(side*(cx-ww/2),yy1)]
    vs=[(x,yy,deck(yy)+dz) for dz in [-.2,.65] for x,yy in outline]
    P.add(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)])
   if row%4==0:
    box(P,(side*(width-8),y+7,high+1.1),(9,13,1.2),'Steel',.4)
  if row%3==0:box(P,(0,y,high+.7),(2.2,38,.8),'Steel',.3)
 # Raised seam spines and removable pressure panels break up the large deck.
 for yy,ll in [(-554,60),(-463,67),(-297,81),(-114,65),(205,55)]:
  zz=deck(yy)
  loft(P,[(yy-ll/2,15,zz+.4,zz+1.6,1),(yy-ll/2+6,23,zz+.4,zz+3.6,2),(yy+ll/2-7,23,zz+.4,zz+3.6,2),(yy+ll/2,15,zz+.4,zz+1.6,1)],'Paint')
  for xx in [-8,8]:
   box(P,(xx,yy,zz+3.8),(1.3,ll-18,.6),'Steel',.2,1)
  box(P,(0,yy+ll/2-11,zz+4),(7,6,.8),'Dark',.25)
 for side in [-1,1]:
  for ii,yy in enumerate([-572,-518,-448,-310,-246,-112,177,231]):
   zz=deck(yy);ww=profile(yy)[0]/2-profile(yy)[3];xx=side*(ww*.6)
   box(P,(xx,yy+4,zz+.9),(22,27,.7),'Steel',.3)
   box(P,(xx,yy+4,zz+1.4),(19.8,24.8,.6),'Paint',.25)
   # Small coaming and inset handle sit on the individual panel.
   box(P,(xx+side*5,yy+12,zz+1.85),(5,1.8,.6),'Dark',.2)
   for dx,dy in [(-8,-6),(8,-6),(-8,15),(8,15)]:
    cyl(P,(xx+dx,yy+dy,zz+1.5),(xx+dx,yy+dy,zz+2.05),.35,'Steel',8)
   if ii in [1,4,6]:
    box(P,(xx,yy-14,zz+1.6),(16,6,2),'Dark',.5)
    for dx in [-5,-2,1,4]:box(P,(xx+dx,yy-14,zz+2.7),(1,5,.7),'Steel',.25,1)
 # Twin sunken navigation scanner housings, protected by low chamfered coamings.
 for side in [-1,1]:
  xx=side*42;yy=-585;zz=deck(yy)
  cyl(P,(xx,yy,zz),(xx,yy,zz+2),8,'Dark',32)
  cyl(P,(xx,yy,zz+1),(xx,yy,zz+3),6.8,'Steel',32,r2=5.6)
  cyl(P,(xx,yy,zz+3),(xx,yy,zz+3.4),4.5,'Glass',32)
 # Bow armour belts, service channels, inset ribs, scalloped shoulder plating.
 for s in [-1,1]:
  for i,y in enumerate(range(-601,-219,33)):
   w,low,high,ch=profile(y);x=w/2
   slope=(profile(y+1)[0]-profile(y-1)[0])/4
   R=Matrix.Rotation(-s*math.atan(slope),3,'Z')
   # Armour surrounds a recessed longitudinal service channel.
   box(P,(s*(x+.5),y,-7),(1.7,31,9),'Dark',.6,rot=R)
   for z in [-12,-1.5]:box(P,(s*(x+1.5),y,z),(2,31,1),'Steel',.35,rot=R)
   for z,h in [(10,20),(-29,26)]:
    box(P,(s*(x+1.1),y,z),(2.5,31,h),'Paint',.8,rot=R)
   if i%3==0:
    box(P,(s*(x+2.5),y,9),(2,12,11),'Steel',.7,rot=R)
    box(P,(s*(x+3.5),y,9),(1,9,8),'Dark',.6,rot=R)
   for yy in [y-9,y+9]:
    box(P,(s*(x+1.7),yy,-7),(2,2,5),'Steel',.5,rot=R)
   # Service conduit is physically retained in the recessed belt.
   cyl(P,(s*(x+1.7-slope*13),y-13,-6),(s*(x+1.7+slope*13),y+13,-6),.48,'Steel',8)
  hangar_start=len(P.v)
  # Long side hangar: structural roof, deck, REAL open volume between.
  loft(P,[(-300,36,-38,24,8),(-251,67,-43,36,9),(-215,72,-43,38,9)],'Paint',s*141)
  loft(P,[(-226,78,20,42,7),(-195,89,20,45,7),(283,89,20,45,7),(321,76,20,40,7)],'Paint',s*143)
  loft(P,[(-226,78,-48,-27,6),(-195,89,-50,-27,7),(283,89,-50,-27,7),(321,76,-43,-27,6)],'Paint',s*143)
  box(P,(s*128,47,-4),(10,537,50),'Dark',3)
  box(P,(s*157,45,-26.7),(55,520,.7),'Steel',.15)
  for yy in [-138,-35,68,171]:
   box(P,(s*181,yy,-3),(9,7,43),'Steel',1.2)
   box(P,(s*185,yy,-3),(2,4,37),'Paint',.6)
  # Opening mouth trims and narrow protective lips.
  for z in [-25,20]:
   box(P,(s*184,45,z),(5,511,4),'Steel',1.1)
   box(P,(s*185,45,z+(2.7 if z>0 else -2.7)),(4,515,1.8),'Paint',.5)
  for y in [-216,306]:
   box(P,(s*178,y,-2),(19,12,48),'Steel',3)
   box(P,(s*183,y,-2),(10,5,40),'Paint',1)
  for j,y in enumerate(range(-177,294,78)):
   # Lit ceiling beams, deep segmented back wall doors and maintenance frames.
   box(P,(s*151,y,18),(48,3,3),'Steel',.5)
   box(P,(s*164,y,16),(24,1.1,1.1),'Lamps',.25,1,uv=(.1,.5))
   box(P,(s*135,y,-3),(2,65,34),'Steel',1)
   box(P,(s*137,y,-3),(2,58,27),'Dark',.8)
   box(P,(s*139,y,11.5),(1.5,44,1.5),'Lamps',.4,uv=(.1,.5))
   for yy in [-29,29]:box(P,(s*139,y+yy,-2),(1,1.5,22),'Steel',.4)
   for zz in [-10,-5,0,5]:box(P,(s*138.2,y,zz),(1,55,.6),'Steel',.15,1)
   for yy in [y-33,y+33]:
    box(P,(s*144,yy,-3),(15,3,42),'Steel',1)
    cyl(P,(s*151,yy,-24),(s*151,yy,16),.7,'Dark',8)
   for yy in [y-24,y+24]:
    box(P,(s*164,yy,-26),(43,.35,.4),'Lamps',.1,1,uv=(.1,.5))
   for q in range(5):box(P,(s*186,y-25+q*12,-23),(1,3,.8),'Lamps',.15,1,uv=(.34,.5))
   # Flat cargo pallets and closed machinery lockers, human-scale in a vast bay.
   for q in range(3):box(P,(s*143,y-17+q*11,-23),(8,8,5),'Steel',.5)
   if j in [0,2,4]:socket('Hangar_'+str(len([o for o in sockets if o.name.startswith('Hangar')])+1),(s*160.8,y,-15),direction=(s,0,0))
  # Angled armour shingles on the hangar roof; panel ribs have a readable thickness.
  for y in range(-190,300,49):
   box(P,(s*143,y,45 if y<283 else 43),(70,47.8,.8),'Paint',.35,2)
   box(P,(s*178,y,39),(3,43,1.5),'Steel',.5)
  # Fore-shoulder recess with inset radiators (no disconnected ornament).
  box(P,(s*153,-270,-3),(42,33,27),'Dark',3)
  for y in range(-283,-252,5):box(P,(s*174,y,-2),(2,2,22),'Steel',.4,1)
  # Recessed optical tracker at the leading hangar shoulder.
  box(P,(s*176,-270,-2),(3,25,17),'Steel',2)
  box(P,(s*178,-270,-2),(2,21,13),'Dark',1.5)
  for yy,zz,rad in [(-276,1,3.2),(-269,-4,2.3),(-263,1,2.8)]:
   cyl(P,(s*179,yy,zz),(s*180.5,yy,zz),rad,'Steel',20)
   cyl(P,(s*180.5,yy,zz),(s*181,yy,zz),rad*.65,'Glass',20)
  compress_hangar(P,hangar_start)
 # Lower hangar armour carries service panels, deep braces and bundled conduits.
 for side in [-1,1]:
  hangar_start=len(P.v)
  for j,y in enumerate(range(-180,290,47)):
   box(P,(side*187.5,y,-37),(1.7,44,10),'Paint',.6)
   if j%3==1:
    box(P,(side*189,y,-37),(1.1,17,6),'Steel',.5)
    for yy in range(-6,7,3):box(P,(side*189.7,y+yy,-37),(.5,1.3,4),'Dark',.2,1)
   for yy in [-17,17]:cyl(P,(side*188,y+yy,-37),(side*189.4,y+yy,-37),.6,'Steel',8)
  for y in [-166,-25,116,257]:
   loft(P,[(y-28,38,-59,-47,5),(y-18,48,-68,-47,6),(y+23,46,-67,-47,6),(y+28,32,-57,-47,4)],'Paint',side*137)
   box(P,(side*158,y,-57),(2,26,7),'Dark',.5)
   for z in [-58,-55]:cyl(P,(side*160,y-9,z),(side*160,y+9,z),.65,'Steel',8)
  # Framed access panels on the curved upper shoulder, ladders and retention hoops.
  for y in [-166,-25,116,257]:
   box(P,(side*155,y,45.8),(21,27,1.4),'Steel',.6)
   box(P,(side*155,y,46.7),(17,23,.8),'Paint',.4)
   for yy in [-8,8]:
    cyl(P,(side*155-5,y+yy,47),(side*155-5,y+yy,49),.4,'Steel',8)
    cyl(P,(side*155+5,y+yy,47),(side*155+5,y+yy,49),.4,'Steel',8)
    cyl(P,(side*155-5,y+yy,49),(side*155+5,y+yy,49),.4,'Steel',8)
  compress_hangar(P,hangar_start)
 # Engine shoulders, segmented armoured casing and deep vertical radiator mouths.
 for s in [-1,1]:
  loft(P,SHOULDER_SECTIONS,'Paint',s*132)
  # Deep forward-facing intake with load-bearing armored octagonal returns.
  portal(P,s*148,302,361,(84,115,17),(61,82,11),-5,'Paint')
  portal(P,s*148,300,307,(68,90,12),(59,79,10),-5,'Steel')
  box(P,(s*148,354,-5),(60,4,82),'Dark',4)
  for xx in range(-23,24,6):box(P,(s*148+xx,348,-5),(2.5,8,65),'Steel',.5)
  for zz in range(-34,30,8):box(P,(s*171,326,zz),(2.5,28,2),'Lamps',.4,uv=(.34,.5))
  box(P,(s*145,321,51),(58,33,3),'Paint',1)
  for xx in [-15,15]:box(P,(s*148+xx,322,53),(5,27,2),'Steel',.6)
  # Massive vent cheek forward of the reactor housing.
  box(P,(s*183,367,-4),(8,57,80),'Dark',6)
  for y in range(343,393,4):box(P,(s*188,y,-4),(3,1.5,65),'Steel',.5,1)
  for z in [-42,34]:box(P,(s*186,368,z),(6,62,5),'Steel',1.4)
  box(P,(s*188,335,-4),(4,5,76),'Paint',1.3)
  for z in range(-32,30,5):box(P,(s*189,338,z),(1,3,2),'Lamps',.3,1,uv=(.34,.5))
  for j,y in enumerate([414,466,518,563]):
   ww,ll,hh,cc=profile(y,SHOULDER_SECTIONS)
   box(P,(s*132,y,hh+.1),(ww-2*cc-2,45,1.2),'Paint',.5)
   box(P,(s*132,y,hh+.9),(ww-2*cc-9,1.1,.8),'Steel',.3,1)
  # Exposed pump bed flanked by circular heat-exchanger covers.
  box(P,(s*183,476,-4),(7,122,33),'Steel',3)
  box(P,(s*187,476,-4),(3,110,26),'Dark',2)
  for y in [433,518]:
   cyl(P,(s*186,y,-4),(s*190,y,-4),11,'Steel',32)
   for a in range(8):
    yy=y+8*math.cos(a*math.tau/8);zz=-4+8*math.sin(a*math.tau/8)
    cyl(P,(s*189,yy,zz),(s*190,yy,zz),.8,'Steel',8)
  for q in range(8):box(P,(s*189,452+q*5,-4+(q%3-1)*6),(2,3,8),'Steel',.4,1)
  # Exhaust feed cylinders nest into the rear transverse pressure bulkhead.
  for z in [-28,28]:
   cyl(P,(s*72,568,z),(s*72,637,z),32,'Paint',48,r2=30)
   for y in [576,613,632]:tube(P,(s*72,z),[(y,31),(y+3,33),(y+6,31)],'Steel',48)
 for side in [-1,1]:
  for y in [415,462,509,550]:
   ww,ll,hh,ch=profile(y,SHOULDER_SECTIONS)
   x=side*(132+ww/2-1)
   box(P,(x,y,-8),(3,2,65),'Steel',.7)
   for z in [-33,17]:cyl(P,(x,y,z),(x+side*2,y,z),.65,'Steel',8)
  # Long rear maintenance shield at the base of the radiator bank.
  for y in [430,482,530]:
   box(P,(side*169,y,-48),(9,38,14),'Paint',2)
   box(P,(side*174,y,-48),(2,27,6),'Dark',.7)
 # Armoured maintenance covers, expansion joints, lifting rings and side conduits.
 for side in [-1,1]:
  for ii,yy in enumerate([407,452,499,548]):
   ww,ll,hh,ch=profile(yy,SHOULDER_SECTIONS);xx=side*132
   box(P,(xx,yy,hh+1.3),(ww-2*ch-15,29,1.7),'Steel',.6)
   box(P,(xx,yy,hh+2.2),(ww-2*ch-18,26,1.3),'Paint',.5)
   for dx in [-11,11]:
    cyl(P,(xx+dx,yy-7,hh+2.3),(xx+dx,yy-7,hh+4),.45,'Steel',8)
    cyl(P,(xx+dx,yy+7,hh+2.3),(xx+dx,yy+7,hh+4),.45,'Steel',8)
    cyl(P,(xx+dx,yy-7,hh+4),(xx+dx,yy+7,hh+4),.45,'Steel',8)
   ox=side*(132+ww/2-.2)
   for zz in [-24,15]:
    cyl(P,(ox,yy-13,zz),(ox,yy+13,zz),.65,'Steel',10)
    for dy in [-10,10]:box(P,(ox,yy+dy,zz),(2.5,2,3.5),'Paint',.5)
  # A recessed strip of human-scale crew/service windows above the armour belt.
  for yy in range(406,549,8):
   ww,ll,hh,ch=profile(yy,SHOULDER_SECTIONS);xx=side*(132+ww/2+.2)
   box(P,(xx,yy,22),(1.3,5.2,2.6),'Dark',.35,1)
   box(P,(xx+side*.7,yy,22),(.5,3.3,1.1),'Lamps',.2,1,uv=(.1,.5))
 # Different reactor service modules interrupt the regular heat-rejection grilles.
 for yy in [392,496]:
  loft(P,[(yy-14,35,45,47,2),(yy-8,43,45,57,3),(yy+12,43,45,57,3),(yy+19,32,45,48,2)],'Paint')
  box(P,(0,yy,57.5),(28,16,1.5),'Steel',.6)
  for xx in [-10,10]:
   cyl(P,(xx,yy-5,58),(xx,yy+5,58),1,'Heat',12)
 # Reactor spine and sunken heat-rejection grilles.
 for y in [340,392,444,496,548]:
  box(P,(0,y,45),(98,39,4),'Steel',2)
  box(P,(0,y,47),(84,28,2),'Dark',1)
  for x in range(-39,40,6):box(P,(x,y,49),(2,26,2),'Steel',.5,1)
 for x in [-55,55]:
  cyl(P,(x,300,48),(x,562,48),2.5,'Steel',16)
  for y in range(310,560,26):box(P,(x,y,48),(8,3,7),'Paint',.8)
 box(P,(0,611,-4),(207,33,117),'Steel',10)
 # Four main bell nozzles plus four compact verniers; every exit gets a socket.
 nozzles=[(-72,-29,29),(-72,29,29),(72,-29,29),(72,29,29),(-24,-23,14),(-24,23,14),(24,-23,14),(24,23,14)]
 for i,(x,z,r) in enumerate(nozzles,1):
  end=700 if r>20 else 668;start=623
  tube(P,(x,z),[(start,r*.92),(start+12,r),(end-8,r*.93),(end,r*.86),(end,r*.75),(end-12,r*.73),(start+16,r*.52)],'Heat',64 if r>20 else 32)
  tube(P,(x,z),[(end-5,r*.94),(end,r*.91),(end+0 if r>20 else end+1,r*.83),(end-3,r*.78)],'Steel',64 if r>20 else 32)
  if r>20:
   tube(P,(x,z),[(609,33.5),(615,35),(638,35),(666,32),(671,30.8),(671,29.8),(665,29.5)],'Paint',64)
   tube(P,(x,z),[(658,32.6),(660,33),(665,32.6)],'Steel',64)
  tube(P,(x,z),[(end-23,r*.705),(end-10,r*.745)],'Engines',64 if r>20 else 32)
  # Deep recessed emitter, not a flat disc pasted at the mouth.
  depth=22 if r>20 else 12
  cyl(P,(x,end-depth-1,z),(x,end-depth,z),r*.71,'Engines',48,r2=r*.71)
  tube(P,(x,z),[(end-8,r*.76),(end-6,r*.77)],'Engines',48,uv=(.96,.5))
  for a in range(12 if r>20 else 8):
   theta=a*math.tau/(12 if r>20 else 8)
   xx=x+math.cos(theta)*r*.94;zz=z+math.sin(theta)*r*.94
   cyl(P,(xx,start+12,zz),(x+math.cos(theta)*r*.87,end-5,z+math.sin(theta)*r*.87),.85,'Steel',6)
  tube(P,(x,z),[(start+21,r*.57),(start+23,r*.57)],'Engines',48)
  socket('Thruster_'+str(i),(x,end,z))
 tower_start=len(P.v)
 # Tiered command island with three distinct bridge decks.
 loft(P,[(-22,61,43,52,5),(-4,83,43,68,9),(139,83,43,68,9),(159,62,43,52,5)],'Paint',22)
 loft(P,[(21,49,55,91,7),(36,64,55,91,8),(113,64,55,91,8),(137,48,55,82,7)],'Paint',22)
 loft(P,[(27,72,84,98,6),(38,88,84,98,6),(105,88,84,98,6),(120,69,84,98,6)],'Steel',22)
 loft(P,[(22,70,96,104,4),(36,91,96,104,5),(111,91,96,104,5),(123,68,96,104,4)],'Paint',22)
 # Wrap-around bridge windows, framing and visible inset consoles.
 box(P,(22,24,92),(59,2,7),'Glass',1)
 for s in [-1,1]:
  box(P,(22+s*43,72,92),(1.5,66,7),'Glass',.8)
  for y in range(43,104,10):box(P,(22+s*44,y,92),(2.2,1,8),'Steel',.2,1)
  box(P,(22+s*33,78,72),(1.8,76,6),'Glass',.6)
  for y in range(44,118,7):
   box(P,(22+s*34,y,72),(1.6,.8,7),'Steel',.2,1)
   box(P,(22+s*34,y+2,70.5),(1,1.6,.7),'Lamps',.2,1,uv=(.1,.5))
 for x in range(-3,52,9):box(P,(x,22,92),(1,2,8),'Steel',.2,1)
 for x in range(2,48,9):box(P,(x,30,88),(6,5,3),'Dark',.6)
 # Bridge buttresses, inset equipment racks and communication apertures.
 for side in [-1,1]:
  for y in [48,73,98,121]:
   box(P,(22+side*34,y,78),(3,2,17),'Steel',.5)
  for y in [47,101]:
   box(P,(22+side*44,y,58),(3,15,10),'Dark',.7)
   for yy in [-5,0,5]:box(P,(22+side*46,y+yy,58),(2,2,6),'Steel',.4)
 box(P,(22,125,98),(45,7,3),'Steel',.8)
 for x in [-3,9,21,33,45]:box(P,(x,128,98),(6,3,1.5),'Dark',.4)
 # Satellite platforms and service walkway around the island.
 for s in [-1,1]:
  box(P,(22+s*54,84,50),(14,99,3),'Steel',.8)
  for y in range(40,130,6):
   cyl(P,(22+s*59,y,50),(22+s*59,y,53),.24,'Steel',6)
  for z in [51.5,53]:cyl(P,(22+s*59,40,z),(22+s*59,124,z),.22,'Steel',6)
 for vi in range(tower_start,len(P.v)):
  x,y,z=P.v[vi];P.v[vi]=(x,y,44+(z-44)*1.167)
 # Antenna forest: braced stepped masts, radar blades, dishes, feeder cable runs.
 antennas=[(49,95,140,2.2),(-5,91,128,1.7),(38,52,120,1.1),(2,64,118,1),(47,116,129,1.4),(13,119,121,1.1),(66,140,112,1.2),(-37,117,105,1.2)]
 for i,(x,y,top,r) in enumerate(antennas):
  base=114 if -20<x<60 and y<122 else 72
  top=max(top,base+9)
  box(P,(x,y,base),(6,6,2),'Steel',.7)
  cyl(P,(x,y,base),(x,y,top-7),r,'Steel',12,r2=r*.38)
  cyl(P,(x,y,top-9),(x,y,top),r*.32,'Steel',8,r2=.18)
  if i in [0,1,4]:
   box(P,(x,y,top-17),(15,2.5,5),'Paint',.6)
   for xx in [-5,0,5]:cyl(P,(x+xx,y,top-19),(x+xx,y,top-14),.3,'Steel',6)
  for xx in [-3,3]:cyl(P,(x+xx,y,base),(x,y,base+11),.25,'Steel',6)
 # Angled receivers and two braced aperture radar assemblies.
 for x,y,z,side in [(1,92,122,-1),(50,113,123,1)]:
  cyl(P,(x,y,113),(x,y,z),1.4,'Steel',12)
  cyl(P,(x,y,z),(x+side*5,y,z+3),1.6,'Steel',12)
  cyl(P,(x+side*5,y,z+3),(x+side*8,y,z+5),5.5,'Steel',24,r2=8.5)
  cyl(P,(x+side*8,y,z+5),(x+side*8.3,y,z+5.2),7.5,'Dark',24)
  cyl(P,(x+side*8,y,z+5),(x+side*12,y,z+8),.45,'Steel',8)
 for xx,yy,zz in [(49,95,132),(-5,91,122)]:
  cyl(P,(xx-10,yy,zz),(xx+10,yy,zz),.45,'Steel',8)
  for dx in [-8,-4,4,8]:cyl(P,(xx+dx,yy,zz),(xx+dx,yy,zz+4),.22,'Steel',6)
 for y in [-140,-65,220,281,563]:
  for s in [-1,1]:
   x=s*82;zz=deck(y);cyl(P,(x,y,zz),(x,y,zz+11),1.2,'Steel',12,r2=.7);cyl(P,(x,y,zz+11),(x,y,zz+23),.32,'Steel',8)
 # Deck service equipment: zoned vents, armoured hatches, cable trays, hand rails.
 for s in [-1,1]:
  for y in [-486,-330,-80,188,278]:
   x=s*min(77,profile(y)[0]/2-profile(y)[3]-15);z=deck(y)
   box(P,(x,y,z+1.4),(26,37,3),'Steel',1)
   box(P,(x,y,z+3),(21,31,1.5),'Dark',.7)
   for yy in range(-12,15,4):box(P,(x,y+yy,z+4),(19,1.3,1.5),'Steel',.4,1)
  for y in range(-430,245,45):
   x=s*min(105,profile(y)[0]/2-profile(y)[3]-6);z=deck(y)
   box(P,(x,y,z+.4),(7,43,1),'Dark',.4)
   for q in [-1,1]:cyl(P,(x+q*1.7,y-20,z+1.1),(x+q*1.7,y+20,z+1.1),.48,'Steel',8)
   for yy in [-16,16]:box(P,(x,y+yy,z+1.5),(9,1.5,1.5),'Paint',.4)
  # Numerous small recessed utility windows establish true human scale.
  for y in range(-100,281,6):
   box(P,(s*122,y,8),(1.2,3,1.8),'Dark',.25,1)
   if y%5:box(P,(s*122.8,y,8),(1,2.1,1),'Lamps',.15,1,uv=(.1,.5))
  # Belly closed service doors; landing gear is absent on this deep-space ship.
  for y in [-360,-120,120,350,510]:
   box(P,(s*79,y,-59),(32,55,4),'Steel',2)
   for xx in [-6,6]:box(P,(s*79+xx,y,-61),(1,48,1),'Dark',.2,1)
 # Hundreds of small fasteners along structural, accessible seams, not random noise.
 for s in [-1,1]:
  for y in range(-558,594,16):
   w,lo,hi,ch=profile(y);x=s*(w/2-ch-5)
   cyl(P,(x,y,hi+.55),(x,y,hi+1.2),.55,'Steel',8)
  for y in range(-200,302,13):
   for z in [-30,26]:cyl(P,(s*159,y,z),(s*160.2,y,z),.52,'Steel',8)
 # Repair patches, metal piping and recessed bay fittings use real geometry.
 for side in [-1,1]:
  for ii,yy in enumerate([-580,-504,-426,-351,-278]):
   ww,ll,hh,ch=profile(yy);xx=side*(ww/2+2)
   box(P,(xx,yy+7,-25),(1.5,18,15),'Steel',.6)
   box(P,(xx+side*.7,yy+7,-25),(1,15,12),'Paint',.5)
   for zz in [-29,-22]:
    cyl(P,(xx+side*1.3,yy+3,zz),(xx+side*1.3,yy+11,zz),.4,'Steel',8)
   for dy in [-7,21]:box(P,(xx,yy+dy,16),(1.5,8,3),'Dark',.4)
  for yy in [-165,-25,116,257]:
   xx=side*148
   box(P,(xx,yy,-49),(2,29,5),'Dark',.7)
   for zz in [-50,-47]:
    cyl(P,(xx+side,yy-12,zz),(xx+side,yy+12,zz),.65,'Steel',10)
   for dy in [-10,10]:box(P,(xx+side,yy+dy,-49),(3,2,6),'Paint',.5)
 # A compact aft communications platform carries a clustered antenna grove.
 loft(P,[(155,32,44,48,3),(163,46,44,62,5),(190,46,44,62,5),(199,32,44,48,3)],'Paint',22)
 for ii,(xx,yy,top) in enumerate([(5,167,103),(18,172,93),(35,167,99),(40,186,86),(8,188,89)]):
  cyl(P,(xx,yy,60),(xx,yy,top-8),.9,'Steel',10,r2=.45)
  cyl(P,(xx,yy,top-8),(xx,yy,top),.22,'Steel',8)
  box(P,(xx,yy,64),(4,4,3),'Dark',.5)
  if ii%2==0:box(P,(xx,yy,top-11),(7,1.2,3),'Steel',.4)
 # Eight original paired naval rail turrets. Fixed barbette belongs to the Hull.
 turret_positions=[(-57,-393,47),(57,-393,47),(-64,-176,47),(64,-176,47),(-76,265,47),(76,265,47),(-65,545,50),(65,545,50)]
 hull=P.mesh();bpy.context.view_layer.update()
 for idx,(x,y,z) in enumerate(turret_positions,1):
  z=deck(y)+1
  B=Part('Barbette');cyl(B,(x,y,z-2),(x,y,z+3),14,'Steel',40);cyl(B,(x,y,z+3),(x,y,z+5),12.5,'Dark',40)
  # Add fixed part into the hull data by final join, not another exported node.
  ob=B.mesh();ob.name='fixed_barbette'
  T=Part('Turret_'+str(idx));loft(T,[(y-12,19,z+4,z+14,4),(y-6,25,z+4,z+18,5),(y+9,26,z+4,z+18,5),(y+15,20,z+4,z+12,4)],'Steel',x)
  box(T,(x,y+7,z+18),(16,9,1.4),'Steel',.6)
  for ss in [-1,1]:cyl(T,(x+ss*12,y-3,z+10),(x+ss*14,y-3,z+10),3.1,'Dark',20)
  turret=T.mesh((x,y,z+4),hull);bpy.context.view_layer.update();moving.append((turret,[0,1,0]))
  G=Part('Gun_'+str(idx));pivot=(x,y-4,z+11)
  box(G,(x,y-4,z+11),(15,10,8),'Dark',1.5)
  for ss in [-1,1]:
   xx=x+ss*4.7
   cyl(G,(xx,y-8,z+11),(xx,y-24,z+11),2.2,'Steel',20,r2=1.5)
   cyl(G,(xx,y-24,z+11),(xx,y-42,z+11),1.25,'Heat',16,r2=.9)
   cyl(G,(xx,y-11,z+11),(xx,y-15,z+11),2.6,'Steel',20)
   cyl(G,(xx,y-40,z+11),(xx,y-44,z+11),1.5,'Steel',16,r2=1.3)
   cyl(G,(xx,y-44.02,z+11),(xx,y-44.06,z+11),.8,'Dark',16)
  gun=G.mesh(pivot,turret);bpy.context.view_layer.update();moving.append((gun,[1,0,0]))
  for ss in [-1,1]:socket('Muzzle_'+str((idx-1)*2+(1 if ss==-1 else 2)),(x+ss*4.7,y-44.1,z+11),gun)
 # Navigation emitters mounted on the outer shoulders; matching game sockets.
 N=Part('Navigation')
 for s,name,u in [(1,'Light_Nav_L',.61),(-1,'Light_Nav_R',.86)]:
  box(N,(s*188,420,24),(3,7,4),'Steel',1);box(N,(s*189,420,25),(1.5,3,2),'Lamps',.5,uv=(u,.5));socket(name,(s*190,420,25))
 cyl(N,(49,95,135),(49,95,137),1.2,'Lamps',12);socket('Light_Beacon',(49,95,137))
 N.mesh()
 for s in [-1,1]:
  for j,y in enumerate([-460,-280,409]):socket('Hardpoint_'+str((0 if s==-1 else 3)+j+1),(s*88,y,-52))
 socket('Camera_Chase',(0,1100,390))
 # Consolidate all static meshes. Apply no moving-node transforms.
 statics=[o for o in objects if not o.name.startswith(('Turret_','Gun_'))]
 bpy.ops.object.select_all(action='DESELECT')
 for o in statics:o.select_set(True)
 bpy.context.view_layer.objects.active=hull;bpy.ops.object.join();objects[:]=[hull]+[o for o,axis in moving]
 for o in sockets:
  if not o.parent:
   o.parent=hull
 bpy.context.view_layer.update()
 # Ensure consistently outward normals, remove true zero-area faces, triangulate.
 for ob in objects:
  bm=bmesh.new();bm.from_mesh(ob.data)
  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=bm.faces)
  bmesh.ops.triangulate(bm,faces=bm.faces)
  bad=[f for f in bm.faces if f.calc_area()<1e-7]
  if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
  for v in bm.verts:
   if ob.name=='Hull' and v.co.z < -80:v.co.z=-80
  bm.to_mesh(ob.data);bm.free();ob.data.update()
 return hull

def world_contract():
 bpy.context.view_layer.update();pts=[o.matrix_world@Vector(v) for o in objects for v in o.bound_box]
 lo=[min(p[k] for p in pts) for k in range(3)];hi=[max(p[k] for p in pts) for k in range(3)]
 def game(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
 dims=[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]
 tri=sum(len(o.data.polygons) for o in objects)
 d={'asset':'carrier-mothership','units':'metres','forward':[0,0,1],'up':[0,1,0],'left':[1,0,0],'origin':'nominal centre of mass, level flight','bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]],'size':dims},'triangles':tri,'materials':[m.name for m in MATS],'nodes':{},'axes':{o.name:a for o,a in moving},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
 for o in objects+sockets:
  d['nodes'][o.name]={'world_position':game(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty'}
  if o.name.startswith('Thruster'):d['nodes'][o.name]['exhaust_direction']=[0,0,-1]
  if o.name.startswith('Hangar'):d['nodes'][o.name]['launch_direction']=[1 if o.matrix_world.translation.x>0 else -1,0,0]
 (ROOT/'nodes.json').write_text(json.dumps(d,indent=2))
 log(f'Geometry {tri:,} triangles; dimensions {dims}')
 return d

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

def export():
 paint=MATS[0];nt=paint.node_tree;nt.links.new(SURF['Paint']['base_tex'].outputs[0],SURF['Paint']['bs'].inputs['Base Color'])
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects+sockets:o.select_set(True)
 kw=dict(export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_cameras=False,export_lights=False,export_extras=True,export_image_format='AUTO',export_animations=False,export_vertex_color='ACTIVE',export_all_vertex_colors=False,export_tangents=True)
 bpy.ops.export_scene.gltf(filepath=str(QA/'uncompressed.glb'),**kw);patch_glb(QA/'uncompressed.glb')
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'carrier-mothership.glb'),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,**kw);patch_glb(ROOT/'carrier-mothership.glb')
 nt.links.new(nt.nodes['FactionColour'].outputs[0],SURF['Paint']['bs'].inputs['Base Color'])
 log('GLB exported: %.2f MB'%((ROOT/'carrier-mothership.glb').stat().st_size/1e6))

def aim(ob,target):ob.rotation_euler=(Vector(target)-ob.location).to_track_quat('-Z','Y').to_euler()
def lights_camera():
 w=bpy.data.worlds.new('Deep space');SC.world=w;w.use_nodes=True;nt=w.node_tree;bg=nt.nodes.get('Background');bg.inputs['Strength'].default_value=.22
 tc=node(nt,'ShaderNodeTexCoord');sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(tc.outputs['Normal'],sep.inputs[0]);r=ramp(nt,sep.outputs['Z'],[(0,(.002,.005,.013)),(1,(.016,.028,.055))]);nt.links.new(r,bg.inputs[0])
 def sun(name,loc,energy,color,angle):
  da=bpy.data.lights.new(name,'SUN');ob=bpy.data.objects.new(name,da);bpy.context.collection.objects.link(ob);ob.location=loc;aim(ob,(0,0,0));da.energy=energy;da.color=color;da.angle=angle
 sun('White key',(600,-900,1100),3.1,(1,.96,.9),.12)
 sun('Cool fill',(-700,-100,550),1.15,(.52,.7,1),.5)
 sun('Engine rim',(100,1000,400),1.8,(.65,.78,1),.25)
 sun('Belly bounce',(300,-200,-600),.3,(.5,.65,1),.7)
 for s in [-1,1]:
  for y in [-165,-60,45,150,255]:
   da=bpy.data.lights.new('Hangar practical','AREA');ob=bpy.data.objects.new('Hangar practical',da);bpy.context.collection.objects.link(ob);ob.location=(s*149,y,13);aim(ob,(s*129,y,-15));da.energy=6500;da.color=(.35,.65,1);da.shape='RECTANGLE';da.size=55;da.size_y=15
 da=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',da);bpy.context.collection.objects.link(cam);SC.camera=cam;da.type='ORTHO';da.clip_end=10000
 return cam

def render(path,cam,loc,target,scale,w=1920,h=1080,samples=32):
 cam.location=loc;aim(cam,target);cam.data.ortho_scale=scale;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)

def paint_grid(cam):
 # Tile the six real Cycles renders without external image dependencies.
 files=[]
 for idx,(name,col) in enumerate(PAINTS):
  MATS[0].node_tree.nodes['FactionColour'].inputs[2].default_value=(*col,1)
  p=QA/f'paint_{idx}.png';render(p,cam,(1450,-1900,930),(0,20,3),1540,640,360,12);files.append(p)
 arr=np.zeros((720,1920,4),dtype=np.float32)
 for idx,p in enumerate(files):
  im=bpy.data.images.load(str(p),check_existing=False);pixels=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
  row=1-idx//3;col=idx%3;arr[row*360:(row+1)*360,col*640:(col+1)*640]=pixels;bpy.data.images.remove(im)
 im=bpy.data.images.new('Paint contact sheet',1920,720,alpha=True);im.pixels.foreach_set(arr.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
 MATS[0].node_tree.nodes['FactionColour'].inputs[2].default_value=(*PAINTS[0][1],1)

def run():
 fetch()
 bake_material('Paint',source_material('Paint','rusty_metal_02',(.98,.98,.98),.4,.43,23,'paint'))
 bake_material('Steel',source_material('Steel','painted_metal_shutter',(.29,.32,.34),.78,.4,8,'steel'))
 bake_material('Dark',source_material('Dark','metal_plate',(.075,.09,.105),.65,.55,5,'dark'))
 bake_material('Heat',source_material('Heat','rusty_metal_02',(.19,.16,.13),.8,.53,8,'heat'))
 special('Glass','glass');special('Lamps','lamps');special('Engines','engines')
 construct();meta=world_contract();export()
 assert 100000<=meta['triangles']<=220000,meta['triangles']
 assert len(MATS)<=9 and (ROOT/'carrier-mothership.glb').stat().st_size<=12000000
 assert all(abs(a-b)<.005 for a,b in zip(meta['bounding_box']['size'],[380,220,1400]))
 assert all(max(im.size)<= (512 if kind=='orm' else 1024) for d in SURF.values() for kind,im in d['maps'].items())
 cam=lights_camera()
 # Clay gate is first, while the geometry is still easy to criticize.
 clay=bpy.data.materials.new('QA clay');clay.diffuse_color=(.44,.44,.44,1);clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.44,.44,.44,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.62
 SC.view_layers[0].material_override=clay
 render(ROOT/'clay.png',cam,(1450,-1900,930),(0,20,3),1540,samples=24)
 SC.view_layers[0].material_override=None
 render(ROOT/'final.png',cam,(1450,-1900,930),(0,20,3),1540,samples=40)
 render(ROOT/'side.png',cam,(1900,0,10),(0,0,10),1530,samples=24)
 render(QA/'rear.png',cam,(1050,1700,850),(0,100,0),1510,w=1280,h=720,samples=20)
 render(QA/'surface-detail.png',cam,(165,-450,111),(65,-390,47),153,w=1280,h=720,samples=32)
 render(QA/'hangar-close.png',cam,(430,-230,145),(145,0,0),530,w=1280,h=720,samples=24)
 # Physically rotate ALL turrets and elevation assemblies, around their actual pivots.
 before={o.name:o.matrix_world.translation.copy() for o,a in moving if o.name.startswith('Turret_')}
 for o,a in moving:o.rotation_euler.z=math.radians(30) if o.name.startswith('Turret_') else 0;o.rotation_euler.x=math.radians(30) if o.name.startswith('Gun_') else 0
 bpy.context.view_layer.update()
 for o,a in moving:
  if o.name.startswith('Turret_'):assert (o.matrix_world.translation-before[o.name]).length<1e-5
 render(QA/'articulation-30deg.png',cam,(1450,-1900,930),(0,20,3),1540,w=1280,h=720,samples=16)
 for o,a in moving:o.rotation_euler=(0,0,0)
 bpy.context.view_layer.update();paint_grid(cam)
 # Save a convenient local checkpoint for scrutiny; delivery remains the one source script + GLB.
 bpy.ops.wm.save_as_mainfile(filepath=str(QA/'carrier-source.blend'))
 notes=[
 'Original 1,400 m fleet carrier: true recessed bilateral hangars, command island, antenna arrays, eight articulated twin turrets and eight deep engine bells; level flight, no landing gear.',
 f"Triangles: {meta['triangles']:,}; GLB {(ROOT/'carrier-mothership.glb').stat().st_size/1e6:.2f} MB; dimensions {meta['bounding_box']['size']} m (X/Y/Z); Draco-compressed geometry.",
 'Materials (7): Paint, Steel, Dark, Heat, Glass, Lamps, Engines; Paint baseColorFactor defaults to linear [0.55, 0.55, 0.52]; six palettes in nodes.json, paints.png in row order.',
 'Textures: four shared Cycles-baked 1024px base/normal + 512px ORM sets; Glass/Lamps/Engines use 64px baked sets; world-scale UV tiling at 5–23 m, actual panel seams and frames in geometry.',
 'Poly Haven CC0 IDs: metal_plate, rusty_metal_02, painted_metal_shutter; downloaded diff/nor_gl/arm from dl.polyhaven.org; licence https://polyhaven.com/license; sources https://polyhaven.com/a/metal_plate https://polyhaven.com/a/rusty_metal_02 https://polyhaven.com/a/painted_metal_shutter.',
 'Reference differences: original bow hatch, paired rail turrets and eight-engine cluster; weathering is restrained neutral grime rather than heavy rust; small secondary reference fittings are interpreted, with no text or faction marks.'
 ];(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
 log('COMPLETE in %.1f minutes'%((time.time()-START)/60))
if __name__=='__main__':run()
