"""Original capital ship. Run with Blender 5.2 --background --python-exit-code 1.
Clean build, CC0 downloads, Cycles tiled material bakes, pivot checks and glTF export.
Authoring frame: X left, -Y forward, Z up. glTF frame: X left, Y up, Z forward.
"""
import bpy, bmesh, math, os, sys, json, struct, random, subprocess, time
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(7261)
START=time.time()
PREVIEW='--preview' in sys.argv
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene
S.unit_settings.system='METRIC'; S.unit_settings.scale_length=1
S.render.engine='CYCLES'; S.cycles.samples=24; S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=d.type!='CPU'
S.cycles.device='GPU'
S.cycles.max_bounces=6; S.cycles.diffuse_bounces=3; S.cycles.glossy_bounces=3
S.render.image_settings.file_format='PNG'; S.render.film_transparent=False
S.view_settings.view_transform='AgX'; S.view_settings.look='AgX - Medium High Contrast'
S.view_settings.exposure=-0.10
print('DEVICE',[(d.name,d.use) for d in prefs.devices],flush=True)
MATS=[]; MAT={}; FACTORS={}
PAINTS=[('Naval grey',[.30,.32,.34]),('Olive drab',[.20,.25,.15]),('Desert sand',[.51,.39,.24]),('Arctic white',[.78,.82,.83]),('Deep navy',[.075,.14,.23]),('Oxide crimson',[.34,.075,.055])]

def download(asset,kind):
    p=TEX/f'{asset}_{kind}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
        subprocess.run(['curl','--fail','-L','--retry','2','-A','Mozilla/5.0','-sS',url,'-o',str(p)],check=True)
    return p

def image_node(nt,path,linear=False):
    im=bpy.data.images.load(str(path),check_existing=True)
    if linear: im.colorspace_settings.name='Non-Color'
    n=nt.nodes.new('ShaderNodeTexImage');n.image=im;n.extension='REPEAT'
    return n

def mathn(nt,op,a=None,b=None):
    n=nt.nodes.new('ShaderNodeMath');n.operation=op
    for idx,v in enumerate([a,b]):
        if v is None:continue
        if isinstance(v,(float,int)):n.inputs[idx].default_value=v
        else:nt.links.new(v,n.inputs[idx])
    return n.outputs[0]

def baked_material(name,asset=None,tint=(1,1,1),rough=.55,metal=.7,emission=0,alpha=1):
    m=bpy.data.materials.new(name);m.use_nodes=True;m.use_backface_culling=True;nt=m.node_tree;nt.nodes.clear()
    out=nt.nodes.new('ShaderNodeOutputMaterial');bs=nt.nodes.new('ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs['BSDF'],out.inputs['Surface'])
    uv=nt.nodes.new('ShaderNodeTexCoord')
    noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=115;noise.inputs['Detail'].default_value=3
    nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
    if asset:
        diffuse=image_node(nt,download(asset,'diff'));normal=image_node(nt,download(asset,'nor_gl'),True);arm=image_node(nt,download(asset,'arm'),True)
        for n in [diffuse,normal,arm]:nt.links.new(uv.outputs['UV'],n.inputs['Vector'])
        bw=nt.nodes.new('ShaderNodeRGBToBW');nt.links.new(diffuse.outputs['Color'],bw.inputs[0])
        if name=='Paint':
            value=mathn(nt,'MINIMUM',mathn(nt,'ADD',mathn(nt,'MULTIPLY',bw.outputs[0],5.0),.38),.96)
            uvsplit=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],uvsplit.inputs[0])
            sx=mathn(nt,'LESS_THAN',mathn(nt,'PINGPONG',mathn(nt,'MULTIPLY',uvsplit.outputs[0],2),1),.008)
            sy=mathn(nt,'LESS_THAN',mathn(nt,'PINGPONG',mathn(nt,'MULTIPLY',uvsplit.outputs[1],4),1),.009)
            seam=mathn(nt,'MAXIMUM',sx,sy)
            value=mathn(nt,'MULTIPLY',value,mathn(nt,'SUBTRACT',1,mathn(nt,'MULTIPLY',seam,.48)))
            streak=nt.nodes.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=1;streak.inputs['Detail'].default_value=4
            stretch=nt.nodes.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(72,3,1)
            nt.links.new(uv.outputs['UV'],stretch.inputs[0]);nt.links.new(stretch.outputs[0],streak.inputs['Vector'])
            grime=mathn(nt,'MULTIPLY',mathn(nt,'MAXIMUM',mathn(nt,'SUBTRACT',streak.outputs['Fac'],.48),0),.65)
            value=mathn(nt,'MULTIPLY',value,mathn(nt,'SUBTRACT',1,grime))
            pock=nt.nodes.new('ShaderNodeTexVoronoi');pock.inputs['Scale'].default_value=92
            nt.links.new(uv.outputs['UV'],pock.inputs['Vector'])
            pits=mathn(nt,'LESS_THAN',pock.outputs['Distance'],.07)
            value=mathn(nt,'MULTIPLY',value,mathn(nt,'SUBTRACT',1,mathn(nt,'MULTIPLY',pits,.55)))
            col=nt.nodes.new('ShaderNodeCombineXYZ')
            for j in range(3):nt.links.new(value,col.inputs[j])
            base=col.outputs[0]
        else:
            mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*tint,1)
            nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',bw.outputs[0],.80),.28),mix.inputs[1]);base=mix.outputs[0]
            if name=='Trim':
                uvs=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],uvs.inputs[0])
                stripes=mathn(nt,'GREATER_THAN',mathn(nt,'PINGPONG',mathn(nt,'MULTIPLY',uvs.outputs['Y'],16),1),.28)
                trimheight=mathn(nt,'MULTIPLY',stripes,.72)
                trimcol=nt.nodes.new('ShaderNodeMixRGB');trimcol.blend_type='MULTIPLY';trimcol.inputs[0].default_value=1
                nt.links.new(base,trimcol.inputs[1]);nt.links.new(mathn(nt,'ADD',mathn(nt,'MULTIPLY',stripes,.62),.25),trimcol.inputs[2]);base=trimcol.outputs[0]
        split=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(arm.outputs['Color'],split.inputs[0])
        ao=mathn(nt,'ADD',mathn(nt,'MULTIPLY',split.outputs[0],.25),.75)
        rr=mathn(nt,'ADD',mathn(nt,'MULTIPLY',split.outputs[1],.25),rough-.12)
        nm=nt.nodes.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.85 if name=='Paint' else .65;nt.links.new(normal.outputs['Color'],nm.inputs['Color'])
        bump=nt.nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.26;bump.inputs['Distance'].default_value=.008
        nt.links.new(trimheight if name=='Trim' else noise.outputs['Fac'],bump.inputs['Height']);
        if name=='Trim':bump.inputs['Strength'].default_value=.8;bump.inputs['Distance'].default_value=.04
        nt.links.new(nm.outputs['Normal'],bump.inputs['Normal']);nt.links.new(bump.outputs['Normal'],bs.inputs['Normal'])
    else:
        col=nt.nodes.new('ShaderNodeRGB');col.outputs[0].default_value=(*tint,1);base=col.outputs[0]
        if name=='Engines':
            sub=nt.nodes.new('ShaderNodeVectorMath');sub.operation='SUBTRACT';sub.inputs[1].default_value=(.5,.5,0)
            nt.links.new(uv.outputs['UV'],sub.inputs[0]);distance=nt.nodes.new('ShaderNodeVectorMath');distance.operation='LENGTH';nt.links.new(sub.outputs[0],distance.inputs[0])
            radial=nt.nodes.new('ShaderNodeValToRGB');radial.color_ramp.interpolation='EASE'
            palette=[(0,(.80,.95,1,1)),(.12,(.40,.83,1,1)),(.20,(.015,.22,.48,1)),(.29,(.09,.68,1,1)),(.34,(.005,.09,.18,1)),(.42,(.06,.51,1,1)),(.49,(.015,.07,.14,1))]
            radial.color_ramp.elements.remove(radial.color_ramp.elements[1]);radial.color_ramp.elements[0].position=palette[0][0];radial.color_ramp.elements[0].color=palette[0][1]
            for pos,color in palette[1:]:e=radial.color_ramp.elements.new(pos);e.color=color
            nt.links.new(distance.outputs['Value'],radial.inputs[0]);base=radial.outputs['Color']
        if name=='Lamps':
            uvx=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],uvx.inputs[0])
            lampstrip=nt.nodes.new('ShaderNodeValToRGB');lampstrip.color_ramp.interpolation='CONSTANT'
            lampstrip.color_ramp.elements[0].position=0;lampstrip.color_ramp.elements[0].color=(1,.015,.01,1)
            lampstrip.color_ramp.elements[1].position=.25;lampstrip.color_ramp.elements[1].color=(.015,1,.10,1)
            e=lampstrip.color_ramp.elements.new(.5);e.color=(.28,.66,1,1)
            e=lampstrip.color_ramp.elements.new(.75);e.color=(.8,.9,1,1)
            nt.links.new(uvx.outputs[0],lampstrip.inputs[0]);base=lampstrip.outputs[0]
        ao=mathn(nt,'ADD',1,0);rr=mathn(nt,'ADD',rough,0)
    nt.links.new(base,bs.inputs['Base Color']);nt.links.new(rr,bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
    # One square tile is baked, then reused at a fixed metre density on the ship.
    bpy.ops.mesh.primitive_plane_add(size=2)
    plane=bpy.context.object;plane.name='_bake_tile';plane.data.materials.append(m)
    S.cycles.samples=8;S.render.bake.margin=8;S.render.bake.use_clear=True
    bake_images={}
    emit=nt.nodes.new('ShaderNodeEmission')
    pack=nt.nodes.new('ShaderNodeCombineColor');pack.mode='RGB';nt.links.new(ao,pack.inputs[0]);nt.links.new(rr,pack.inputs[1]);pack.inputs[2].default_value=metal
    for kind,res in [('base',(512 if name=='Trim' else 1024) if asset else (256 if name=='Engines' else 32)),('normal',(512 if name=='Trim' else 1024) if asset else 32),('orm',(256 if name=='Trim' else 512) if asset else 32)]:
        im=bpy.data.images.new(f'{name}_{kind}',res,res,alpha=False,float_buffer=False)
        if kind!='base':im.colorspace_settings.name='Non-Color'
        target=nt.nodes.new('ShaderNodeTexImage');target.image=im;nt.nodes.active=target
        if kind=='normal':
            nt.links.new(bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
        else:
            nt.links.new(base if kind=='base' else pack.outputs[0],emit.inputs[0]);nt.links.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
        im.filepath_raw=str(TEX/f'{name}_{kind}.jpg');im.file_format='JPEG';im.save();bake_images[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    nt.nodes.clear();out=nt.nodes.new('ShaderNodeOutputMaterial');bs=nt.nodes.new('ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
    tex={}
    for k,im in bake_images.items():
        n=nt.nodes.new('ShaderNodeTexImage');n.image=im;n.label=k;n.name=k;n.extension='REPEAT';tex[k]=n
    nt.links.new(tex['base'].outputs['Color'],bs.inputs['Base Color'])
    sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(tex['orm'].outputs['Color'],sep.inputs[0]);nt.links.new(sep.outputs[1],bs.inputs['Roughness']);nt.links.new(sep.outputs[2],bs.inputs['Metallic'])
    norm=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(tex['normal'].outputs['Color'],norm.inputs['Color']);nt.links.new(norm.outputs[0],bs.inputs['Normal'])
    # glTF exporter convention for a packed occlusion texture.
    group=bpy.data.node_groups.get('glTF Material Output')
    if not group:
        group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=nt.nodes.new('ShaderNodeGroup');gn.node_tree=group;nt.links.new(sep.outputs[0],gn.inputs['Occlusion'])
    if emission:
        nt.links.new(tex['base'].outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=emission
    if alpha<1:
        bs.inputs['Alpha'].default_value=alpha;bs.inputs['Transmission Weight'].default_value=0;m.surface_render_method='DITHERED';m.diffuse_color=(*tint,alpha)
    if name=='Paint':
        mul=nt.nodes.new('ShaderNodeMixRGB');mul.name='Faction';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*PAINTS[0][1],1)
        nt.links.new(tex['base'].outputs[0],mul.inputs[1]);nt.links.new(mul.outputs[0],bs.inputs['Base Color'])
    MATS.append(m);MAT[name]=len(MATS)-1
    print('BAKED',name,flush=True)
    return m

for args in [
 ('Paint','blue_metal_plate', (1,1,1),.66,.45),
 ('Armour','metal_plate_02',(.30,.34,.38),.48,.72),
 ('Metal','metal_plate_02',(.80,.84,.88),.29,.88),
 ('DarkMetal','metal_plate_02',(.20,.23,.27),.60,.62),
 ('HeatMetal','rusty_metal_03',(.48,.30,.17),.48,.80),
 ('Trim','metal_plate_02',(.25,.29,.34),.67,.70),
 ('Engines',None,(.015,.46,1.0),.32,.15,2),
 ('Lamps',None,(.28,.66,1.0),.40,.05,1.5),
 ('Glass',None,(.09,.21,.26),.17,.25,0,.7),
]:baked_material(*args)
S.cycles.samples=24

class Geo:
    def __init__(self):self.v=[];self.f=[];self.mi=[];self.sm=[]
    def add(self,verts,faces,mat='Paint',smooth=None):
        off=len(self.v);self.v.extend(tuple(v) for v in verts);self.f.extend(tuple(off+i for i in f) for f in faces);self.mi.extend([MAT[mat]]*len(faces));self.sm.extend(smooth or [False]*len(faces))
    def box(self,c,size,mat='Paint',rot=None):
        x,y,z=size;verts=[Vector((a*x/2,b*y/2,d*z/2)) for a,b,d 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:verts=[rot@v for v in verts]
        self.add([v+Vector(c) for v in verts],[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)],mat)
    def prism(self,pts,z0,z1,mat='Paint'):
        n=len(pts);self.add([(x,y,z) for z in [z0,z1] for x,y in pts],[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 loft(self,sections,mat='Paint'):
        verts=[]
        for y,w,h,z in sections:
            a=w/2;b=h/2;cx=min(w*.15,16);cz=min(h*.24,14)
            ring=[(-a+cx,-b), (a-cx,-b),(a,-b+cz),(a,b-cz),(a-cx,b),(-a+cx,b),(-a,b-cz),(-a,-b+cz)]
            verts.extend((x,y,zz+z) for x,zz in ring)
        fs=[tuple(reversed(range(8))),tuple(range(len(verts)-8,len(verts)))]
        for k in range(len(sections)-1):
            for i in range(8):fs.append((k*8+i,k*8+(i+1)%8,(k+1)*8+(i+1)%8,(k+1)*8+i))
        self.add(verts,fs,mat)
    def tube(self,a,b,r1,r2=None,mat='Metal',n=16,inner=0,cap=True):
        a=Vector(a);b=Vector(b);d=(b-a).normalized();u=d.cross(Vector((0,0,1)))
        if u.length<.1:u=d.cross(Vector((0,1,0)))
        u.normalize();v=d.cross(u);r2=r1 if r2 is None else r2
        rings=[(a,r1),(b,r2)]
        if inner:rings.extend([(b,r2-inner),(a,r1-inner)])
        vs=[c+r*(math.cos(i*math.tau/n)*u+math.sin(i*math.tau/n)*v) for c,r in rings for i in range(n)]
        fs=[];sm=[]
        for k in range(len(rings)-1):
            for i in range(n):fs.append((k*n+i,k*n+(i+1)%n,(k+1)*n+(i+1)%n,(k+1)*n+i));sm.append(k!=1)
        if inner:
            for i in range(n):fs.append((3*n+i,3*n+(i+1)%n,(i+1)%n,i));sm.append(False)
        elif cap:fs.extend([tuple(reversed(range(n))),tuple(range(n,2*n))]);sm.extend([False,False])
        self.add(vs,fs,mat,sm)
    def beam(self,a,b,w,h,mat='Metal'):
        a=Vector(a);b=Vector(b);q=(b-a).to_track_quat('Z','Y');self.box((a+b)/2,(w,h,(b-a).length),mat,q.to_matrix())
    def build(self,name,loc=(0,0,0),parent=None,bevel=.22):
        mesh=bpy.data.meshes.new(name);mesh.from_pydata(self.v,[],self.f);mesh.update()
        ob=bpy.data.objects.new(name,mesh);S.collection.objects.link(ob)
        for m in MATS:mesh.materials.append(m)
        for p,idx,sm in zip(mesh.polygons,self.mi,self.sm):p.material_index=idx;p.use_smooth=sm
        bm=bmesh.new();bm.from_mesh(mesh);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(mesh);bm.free()
        bpy.context.view_layer.objects.active=ob;ob.select_set(True)
        if bevel:
            mod=ob.modifiers.new('Machined edge radii','BEVEL');mod.width=bevel;mod.segments=1;mod.limit_method='ANGLE';mod.angle_limit=1.1;mod.material=MAT['Armour']
            bpy.ops.object.modifier_apply(modifier=mod.name)
        # Actual metre-scaled planar UVs; each 16 m tile remains crisp on a capital hull.
        uv=mesh.uv_layers.new(name='WorldMetres')
        for p in mesh.polygons:
            n=p.normal;axis=max(range(3),key=lambda k:abs(n[k]));axes=[k for k in range(3) if k!=axis]
            scale=16 if p.material_index==MAT['Paint'] else (4 if p.material_index==MAT['Trim'] else 9)
            for li in p.loop_indices:
                co=mesh.vertices[mesh.loops[li].vertex_index].co
                uv.data[li].uv=(co[axes[0]]/scale,co[axes[1]]/scale)
                if p.material_index==MAT['Lamps']:
                    color_u=.625
                    if abs(co.x)>95 and 73<co.y<77 and 19<co.z<23:color_u=.125 if co.x>0 else .375
                    if co.z>151:color_u=.875
                    uv.data[li].uv=(color_u,.5)
                if p.material_index==MAT['Engines']:
                    cx=min([-54,-18,18,54],key=lambda x:abs(x-co.x));cz=min([-18,18],key=lambda z:abs(z-co.z))
                    uv.data[li].uv=((co.x-cx)/28+.5,(co.z-cz)/28+.5)
        # Bevel surfaces smooth, large planes remain planar.
        ob.location=loc;ob.parent=parent;ob.select_set(False)
        return ob

H=Geo();SOCKETS=[];MOVING=[];HEAVY=[];MUZZLE=0

def socket(name,loc,parent=None):
    o=bpy.data.objects.new(name,None);o.empty_display_type='ARROWS';o.empty_display_size=2;S.collection.objects.link(o);o.location=loc;o.parent=parent;SOCKETS.append(o);return o

def bolt(g,c,axis=(0,0,1),r=.45,mat='Metal'):
    c=Vector(c);d=Vector(axis)*.35;g.tube(c,c+d,r,mat=mat,n=6)

def panel(g,c,size,mat='Paint',rot=None,rivets=True):
    g.box(c,size,mat,rot)
    if rivets and rot is None:
        x,y,z=c;w,l,h=size
        for sx in [-1,1]:
            for sy in [-1,1]:bolt(g,(x+sx*(w/2-1.5),y+sy*(l/2-1.5),z+h/2))

# Core volume: blunt, shouldered octagonal hull; armour plates are separate closed solids.
SECTIONS=[(-428,86,54,-1),(-390,100,60,-1),(-270,110,64,-1),(-185,122,66,-1),(-135,144,70,-1),(-75,160,72,-1),(145,164,72,-1),(245,150,69,-1),(325,140,65,-1),(386,126,60,-1)]
H.loft(SECTIONS,'DarkMetal')
# Deep longitudinal machinery channels and armoured dorsal / ventral belts.
for sign in [-1,1]:
    H.loft([(y,w-4,21,z+sign*(h/2-11)) for y,w,h,z in SECTIONS],'Paint')
    # Midbody flared cheek armour makes the characteristic 180 m beam.
    pts=[(sign*x,y) for x,y in [(52,-155),(72,-115),(90,-55),(90,135),(80,225),(63,290),(49,272),(65,145),(64,-65),(46,-120)]]
    if sign<0:pts.reverse()
    H.prism(pts,12,27,'Paint');H.prism(pts,-30,-20,'Paint')
    # Structural side ribs bridge the recessed equatorial band.
    for y,x in [(-382,49),(-310,53),(-235,57),(-172,63),(-117,74),(-50,80),(25,82),(102,82),(175,80),(248,73),(310,68)]:
        H.box((sign*x,y,-1),(5,9,42),'Armour')
        H.box((sign*(x+2.8),y,0),(1.5,5,24),'Metal')
    # Equatorial conduits read through the armour break.
    for z in [-12,-6,3,8]:
        for y0,y1,x in [(-382,-188,54),(-140,215,76),(240,360,64)]:
            H.tube((sign*x,y0,z),(sign*x,y1,z),.65,mat='Metal',n=10)
            for yy in range(y0+8,y1,24):H.box((sign*x,yy,z),(2.1,1.3,2.1),'DarkMetal')
# Individually shaped deck plates with bevels, proper gaps backed by pressure hull.
for y0,y1,w0,w1,h0,h1 in [(-423,-390,41,48,26,29),(-386,-272,48,53,29,31),(-268,-188,53,59,31,32),(-182,-138,59,70,32,34),(-132,-78,70,78,34,35),(-73,140,78,80,35,35),(145,242,80,73,35,33.5),(247,321,73,68,33.5,31.5),(326,379,68,61,31.5,29)]:
    count=max(1,int((y1-y0)/40))
    for k in range(count):
        a=y0+(y1-y0)*k/count+1;b=y0+(y1-y0)*(k+1)/count-1
        for side in [-1,1]:
            pts=[(side*3,a),(side*(w0-10),a),(side*(w1-5),b-5),(side*(w1-12),b),(side*3,b)]
            if side<0:pts.reverse()
            H.prism(pts,(h0+h1)/2-2.0,(h0+h1)/2+2.0,'Paint')
            # inset service panel, perimeter latches, pale edge catches
            if (b-a)>25:
                panel(H,(side*(w0*.57),(a+b)/2,(h0+h1)/2+2.4),(w0*.45,(b-a)*.55,.9),rivets=True)
                H.box((side*(w0*.57),a+5,(h0+h1)/2+3.1),(w0*.3,.5,.3),'Metal')
# Angular side armour cassettes, with diagonal buttresses and visible layered edges.
for side in [-1,1]:
    for y,x,w in [(-365,46,62),(-292,51,71),(-215,57,61),(-135,67,52),(-52,82,76),(40,86,99),(143,83,79),(229,75,63),(299,67,61),(352,62,32)]:
        ang=side*math.radians(29)
        rot=Matrix.Rotation(ang,3,'Y')
        # Trapezoidal plates have clipped corners and staggered seams, with real thickness.
        shield=Geo();shield.prism([(-9,-w/2),(6,-w/2),(11,-w/2+8),(10,w/2-7),(2,w/2),(-9,w/2)],-3,3,'Paint')
        off=len(H.v);H.v.extend(tuple(rot@Vector(v)+Vector((side*x,y,25))) for v in shield.v)
        H.f.extend(tuple(off+i for i in f) for f in shield.f);H.mi.extend(shield.mi);H.sm.extend(shield.sm)
        H.box((side*(x+3),y,19),(11,w-3,2),'Metal',rot)
        panel(H,(side*(x+1),y,-27),(18,w,5),'Paint',Matrix.Rotation(-ang,3,'Y'),False)
        for yy in [y-w/2+4,y+w/2-4]:
            H.beam((side*(x-7),yy,34),(side*(x+10),yy,13),1.3,2,'Armour')
        # Recessed side maintenance hatch with a frame and handle.
        for yy in [y-4,y+4]:
            H.box((side*(x+6),yy,1),(.6,4.8,5.8),'Armour')
            H.box((side*(x+6.4),yy,1),(.3,3.8,4.7),'Trim')
            H.box((side*(x+6.6),yy+1,1),(.25,.25,.7),'Metal')
            for z in [-.5,2.5]:bolt(H,(side*(x+6.6),yy-1.5,z),axis=(side,0,0),r=.16)
# Broad lateral armour covers the pressure hull, leaving a recessed machinery belt.
for side in [-1,1]:
    for cy,x,w in [(-370,48,49),(-310,52,52),(-248,56,50),(-190,60,44),(-110,73,43),(-46,84,49),(22,87,49),(90,87,49),(158,83,49),(221,76,44),(280,70,39),(333,66,34)]:
        for zz in [-22,18]:
            H.box((side*x,cy,zz),(4,w,15),'Paint')
            H.box((side*(x+2.15),cy,zz),(1,w-5,10),'Paint')
            for yy in [cy-w/2+4,cy+w/2-4]:
                for z in [zz-4,zz+4]:bolt(H,(side*(x+2.8),yy,z),axis=(side,0,0),r=.45)
        H.box((side*(x+3),cy,9),(1,w-3,.8),'Metal')
# Raised armoured citadel interrupts the deck and gives the broad midbody weight.
H.loft([(-122,98,12,38),(-88,126,22,40),(170,126,22,40),(224,98,12,38)],'Paint')
for side in [-1,1]:
    for cy in [-75,-30,15,60,105,150]:
        H.box((side*48,cy,49),(22,32,5),'Paint')
        for yy in [cy-13,cy+13]:H.box((side*48,yy,51.7),(19,.6,.45),'Metal')
# Citadel side plating: large overlapping shields over a dark service trench.
for side in [-1,1]:
    for cy,ln in [(-71,61),(6,79),(95,87),(173,48)]:
        H.box((side*63,cy,39),(2.6,ln,15),'Paint')
        H.box((side*64.5,cy,36.5),(.45,ln-9,3),'DarkMetal')
        H.box((side*64.8,cy,44),(.4,ln-7,.55),'Metal')
        for yy in [cy-ln/2+5,cy+ln/2-5]:
            H.box((side*65,yy,39),(.7,1.4,10),'Armour')
            for zz in [35,42]:bolt(H,(side*65.4,yy,zz),axis=(side,0,0),r=.30)
# Bow: broad split armour prow with four deep launch apertures per side.
for side in [-1,1]:
    H.prism([(side*x,y) for x,y in [(6,-438),(36,-438),(49,-405),(46,-360),(8,-366)]],20,30,'Paint')
    H.prism([(side*x,y) for x,y in [(6,-438),(36,-438),(49,-405),(46,-360),(8,-366)]],-30,-21,'Paint')
    H.beam((side*40,-432,-22),(side*48,-406,20),3,4,'Armour')
    H.beam((side*8,-438,-22),(side*8,-438,22),3,5,'Metal')
    H.tube((side*43,-422,-23),(side*43,-450,-23),1.8,1.15,'Armour',16,inner=.45)
T=Geo()
for idx,(x,z) in enumerate([(x,z) for x in [-28,-12,12,28] for z in [-9,9]],1):
    T.tube((x,-418,z),(x,-438,z),5.6,5.6,'Armour',20,inner=1.25)
    T.tube((x,-416,z),(x,-433,z),4.25,4.25,'Metal',20,inner=.38)
    T.tube((x,-418,z),(x,-418.4,z),3.95,mat='DarkMetal',n=20)
    for a in range(4):
        theta=a*math.tau/4;T.box((x+6*math.cos(theta),-439,z+6*math.sin(theta)),(1.0,1.0,1.0),'Metal')
    socket(f'Hardpoint_{idx}',(x,-440,z))
# Spine with service covers and cable trunks.
for a,b in [(-380,-300),(-260,-190),(-95,-45),(45,67),(198,239),(310,362)]:
    H.box((0,(a+b)/2,35),(13,b-a,2.6),'Armour')
    for yy in range(a+4,b,9):H.box((0,yy,37),(10,1.3,1.5),'Metal')
# Hangars: deep open slits under the broad shoulders, true frame / floor / internal walls.
for side in [-1,1]:
    for cy in [-34,102]:
        x=side*83.0
        H.box((side*76,cy,-1),(2,77,14),'DarkMetal')
        H.box((side*83,cy,-9),(15,83,2.6),'Armour')
        H.box((side*83,cy,8),(15,83,2.6),'Paint')
        for yy in [cy-41,cy+41]:H.box((side*83,yy,-.5),(15,3.5,19),'Paint')
        for yy in [cy-27,cy-9,cy+9,cy+27]:
            H.box((side*78,yy,-.5),(5,1.4,15),'Metal')
            H.box((side*84,yy,-6),(8,7,.35),'Metal')
            H.box((side*88,yy,6.1),(.6,5,.7),'Lamps')
        for j in range(8):H.box((side*90,cy-34+j*9,-6.9),(.4,3,.5),'Lamps')
# Engine yoke and eight recessed bell nozzles, 4 x 2 bank.
E=Geo()
E.loft([(323,140,63,-1),(365,150,72,-1),(398,150,72,-1),(411,142,67,-1)],'Armour')
for x in [-54,-18,18,54]:
    for z in [-18,18]:
        E.tube((x,378,z),(x,411,z),16,15.5,'Armour',48)
        E.tube((x,410,z),(x,448,z),15,13.5,'HeatMetal',48,inner=2)
        E.tube((x,444,z),(x,450,z),14.1,14.1,'Metal',48,inner=2.5)
        E.tube((x,424,z),(x,445,z),10.5,11.5,'DarkMetal',48,inner=1)
        E.tube((x,431,z),(x,432,z),10.4,mat='Engines',n=48)
        E.tube((x,442,z),(x,443,z),11.7,11.7,'Engines',48,inner=.5)
        E.tube((x,433,z),(x,434,z),5.2,mat='Metal',n=24,inner=1)
        for j in range(12):
            t=j*math.tau/12;xx=x+14.7*math.cos(t);zz=z+14.7*math.sin(t)
            E.beam((xx,413,zz),(x+13.9*math.cos(t),442,z+13.9*math.sin(t)),.8,1.0,'Metal')
        for j in [-1,1]:E.box((x+j*7,401,z+14),(3,7,.8),'Lamps')
        socket(f'Thruster_{len([o for o in SOCKETS if o.name.startswith("Thruster")])+1}',(x,450,z))
# Horizontal armoured divider between the upper and lower engine rows.
E.box((0,418,0),(139,38,3.0),'HeatMetal')
E.box((0,437,0),(136,2.2,4.5),'Armour')
for x in [-54,-18,18,54]:
    for z in [-18,18]:
        E.tube((x,437,z),(x,440,z),8.2,mat='Metal',n=48,inner=.8)
        for k in range(8):
            t=k*math.tau/8
            E.beam((x+5*math.cos(t),436,z+5*math.sin(t)),(x+9*math.cos(t),439,z+9*math.sin(t)),.5,.6,'HeatMetal')
# Engine plate fins and heat sinks.
for side in [-1,1]:
    for z in [-28,28]:
        E.prism([(side*54,300),(side*75,323),(side*73,406),(side*60,416)],z-2,z+2,'Paint')
    for y in range(328,393,5):E.box((side*71,y,0),(4,2,25),'HeatMetal')
# Segmented engine armour collars, cooling ribs and upper reactor access deck.
for side in [-1,1]:
    E.prism([(side*49,358),(side*74,366),(side*77,405),(side*68,429),(side*58,423)],29,35,'Paint')
    E.prism([(side*49,358),(side*74,366),(side*77,405),(side*68,429),(side*58,423)],-35,-29,'Paint')
    for cy in [350,373,396]:
        E.box((side*75,cy,0),(2.5,18,39),'Paint')
        E.box((side*76.5,cy,0),(1,14,28),'Trim')
        for zz in range(-11,12,4):E.box((side*77.2,cy,zz),(.7,12,1.2),'Metal')
for cy in [338,365,392]:
    E.box((0,cy,37),(74,20,3),'Paint')
    E.box((0,cy,39),(55,14,.9),'Trim')
    for xx in range(-24,25,4):E.box((xx,cy,39.6),(1.3,12,.7),'Metal')
# Bridge: flared battleship tower, armoured base and continuous inset glazing.
B=Geo()
B.loft([(64,71,12,43),(85,75,14,43),(159,75,14,43),(182,61,12,42)],'Paint')
# Main tower tapers in cross section through height, as a custom eight-sided stack.
def tower_ring(z,w,d,cy):
    a=w/2;b=d/2;c=4
    return [(-a+c,cy-b,z),(a-c,cy-b,z),(a,cy-b+c,z),(a,cy+b-c,z),(a-c,cy+b,z),(-a+c,cy+b,z),(-a,cy+b-c,z),(-a,cy-b+c,z)]
rings=[tower_ring(49,58,68,120),tower_ring(63,48,53,124),tower_ring(87,39,43,128),tower_ring(94,43,48,128)]
verts=sum(rings,[]);faces=[tuple(reversed(range(8))),tuple(range(24,32))]
for k in range(3):
    for j in range(8):faces.append((8*k+j,8*k+(j+1)%8,8*(k+1)+(j+1)%8,8*(k+1)+j))
B.add(verts,faces,'Paint')
for z,w,d,cy in [(54,62,71,120),(66,50,56,124),(88,45,49,128),(95,53,57,127)]:
    B.prism([(x,y) for x,y,_ in tower_ring(0,w,d,cy)],z,z+1.3,'Armour')
# Forward command gallery with metal mullions, light behind glass.
B.box((0,99,91),(46,3,5.2),'DarkMetal')
for x in range(-21,22,3):
    B.box((x,97.3,91),(2.1,.5,2.7),'Lamps');B.box((x,96.9,91),(2.5,.3,3.1),'Glass')
    B.box((x+1.3,96.5,91),(.25,.6,4.2),'Metal')
for side in [-1,1]:
    for y in range(105,152,4):
        B.box((side*22.0,y,91),(.4,2.7,2.1),'Lamps');B.box((side*22.4,y,91),(.3,3,2.7),'Glass')
    for z in [60,70,78]:
        for j in range(7):B.box((side*(27-(z-60)*.20),106+j*5,z),(.3,1.9,.8),'Lamps')
    # Visible side armour plate tiers, attached conduits and access ladder.
    for z in [58,68,78]:
        B.box((side*(26-(z-58)*.22),130,z),(1,22,7),'Paint')
    B.beam((side*20,150,55),(side*20,150,86),.12,.16,'Metal')
    B.beam((side*21,150,55),(side*21,150,86),.12,.16,'Metal')
    for k in range(73):
        z=55.4+k*.42;B.beam((side*20,150,z),(side*21,150,z),.10,.10,'Metal')
# Upper sensor crown and restrained antenna forest.
B.box((0,128,98),(21,25,5),'Paint');B.box((0,129,101.5),(14,18,3),'Armour')
for x,y,z in [(-17,137,110),(17,139,107),(-10,148,106),(8,144,108),(0,134,110)]:
    B.tube((x,y,96),(x,y,z),.55,.16,'Metal',10)
    B.box((x,y,99),(1.8,2,2),'DarkMetal')
B.box((0,127,105),(9,7,4),'Armour')
# Tower foot forward armour fins and personnel-scale catwalk railings.
for side in [-1,1]:
    B.beam((side*28,68,36),(side*22,94,64),5,8,'Paint')


B.prism([(x,y) for x,y,_ in tower_ring(0,58,56,124)],65.3,66.3,'Armour')
# Gallery buttresses, recessed service slots, sensor vanes and railings.
for side in [-1,1]:
    for yy in [101,118,135]:
        B.box((side*24.5,yy,73),(1.5,10,12),'Paint')
        B.box((side*25.4,yy,73),(.5,7,7),'Trim')
        for zz in [70,72,74,76]:B.box((side*25.8,yy,zz),(.3,6,.45),'Metal')
    B.beam((side*26,97,65),(side*20,104,87),1.8,2.5,'Armour')
    for yy in range(105,149,5):B.tube((side*27,yy,66),(side*27,yy,67.2),.10,mat='Metal',n=6)
    B.beam((side*27,105,67.2),(side*27,145,67.2),.12,.12,'Metal')
B.tube((-13,142,97),(-13,142,119),.55,.12,'Metal',10)
for zz in [106,111,116]:B.beam((-15,142,zz),(-11,142,zz),.14,.14,'Metal')
# Upper fire-control tier and faceted radome within the original mast height envelope.
B.loft([(118,19,12,105),(132,23,12,105),(140,17,10,105)],'Armour')
B.box((0,128,111),(22,26,1.5),'Paint')
B.tube((0,128,112),(0,128,115.5),5.5,5.7,'Armour',24)
B.tube((0,128,115.5),(0,128,118.8),5.7,2.8,'Armour',24)
B.tube((0,128,118.8),(0,128,119.3),.65,mat='Lamps',n=12)
B.box((0,116,107),(15,.5,2),'DarkMetal')
for xx in [-5,-2,2,5]:B.box((xx,115.7,107),(1.5,.3,.7),'Glass')
# Auxiliary fixed radar director on the aft bridge foundation.
B.tube((0,170,50),(0,170,58),9,mat='Armour',n=24)
B.loft([(162,15,9,60),(175,18,9,60),(180,11,7,60)],'Armour')
B.beam((0,175,63),(0,189,67),1.2,1.8,'Metal')
# Rear sensor deck, receiver cabinets, dish and a few fine antenna branches.
for side in [-1,1]:
    for yy in [114,139]:
        B.box((side*17,yy,99),(5,7,3),'Armour')
        B.tube((side*17,yy,100),(side*17,yy,111),.28,.10,'Metal',8)
    for yy in [112,119,126,133,140]:
        B.box((side*22,yy,81),(.7,4,6),'Paint')
        bolt(B,(side*22.5,yy,83),axis=(side,0,0),r=.2)
B.tube((9,136,103),(9,136,107),2.0,mat='Metal',n=16)
B.tube((9,136,107),(9,139,109),3.2,4.5,'Armour',20,inner=.45)
for yy in [115,123,131,139]:
    B.box((0,155,69+(yy-115)*.16),(12,1.5,.8),'DarkMetal')
B.v=[(x*(.80 if z>=88 else 1),128+(y-128)*(.88 if z>=88 else 1),z) for x,y,z in B.v]
bridge_offset=len(H.v);H.v.extend((x,y,z+8+max(0,z-50)*.40) for x,y,z in B.v);H.f.extend(tuple(bridge_offset+i for i in f) for f in B.f);H.mi.extend(B.mi);H.sm.extend(B.sm)
# Rows of human-scale illuminated portholes recessed along hull terraces.
for side in [-1,1]:
    for cy,x,w in [(-310,52,36),(-190,60,30),(-46,84,36),(158,83,36),(280,70,28)]:
        H.box((side*(x+2.9),cy,18),(.5,w,1.5),'DarkMetal')
        for j in range(int(w/3.2)-1):
            yy=cy-w/2+3.2+j*3.2
            H.box((side*(x+3.22),yy,18),(.20,1.15,.55),'Lamps')
# Secondary services: vent banks, conduits, hatches, gasket seams and latches.
G=Geo()
for side in [-1,1]:
    for cy in [-345,-220,-78,15,208,300]:
        xx=side*(37 if cy<-160 else 53)
        G.box((xx,cy,35),(13,20,1.2),'Trim')
        for yy in range(-8,9,2):G.box((xx,cy+yy,35.8),(11,.65,.5),'Metal')
    for cy in [-180,5,205,310]:
        xx=side*(39 if cy<-100 else 48)
        for k in range(3):
            G.tube((xx+k*side*2.3,cy-15,37),(xx+k*side*2.3,cy+13,37),.65,mat='Metal',n=10)
        for yy in [cy-12,cy+10]:G.box((xx+side*2.3,yy,36),(8,1.2,3.2),'Armour')
    for yy in range(-360,345,18):
        xx=side*(43 if yy<-180 else 59)
        # Flush tie-downs, panels and paired fasteners give metre-scale cues.
        G.box((xx,yy,34.7),(3.1,4.5,.6),'Armour')
        for dx in [-.85,.85]:bolt(H,(xx+dx,yy,34.3),r=.27)
def top_surface(x,y,sections):
    for (a,wa,ha,za),(b,wb,hb,zb) in zip(sections,sections[1:]):
        if a<=y<=b:
            t=(y-a)/(b-a);w=wa+(wb-wa)*t;h=ha+(hb-ha)*t;z=za+(zb-za)*t
            if abs(x)>w/2:return -1e3
            bevelx=min(w*.15,16);bevelz=min(h*.24,14)
            return z+h/2-max(0,abs(x)-(w/2-bevelx))/bevelx*bevelz
    return -1e3
def deck_top(x,y):
    core=top_surface(x,y,SECTIONS)+2.5
    citadel=top_surface(x,y,[(-122,98,12,38),(-88,126,22,40),(170,126,22,40),(224,98,12,38)])
    return max(core,citadel)
service_offset=len(H.v);H.v.extend((x,y,deck_top(x,y)+z-34.4) for x,y,z in G.v);H.f.extend(tuple(service_offset+i for i in f) for f in G.f);H.mi.extend(G.mi);H.sm.extend(G.sm)
# Thin layered lower keel; no landing appendages.
H.loft([(-330,30,8,-34),(-210,43,11,-36),(-100,62,44,-44),(125,69,44,-44),(237,44,10,-36),(330,29,8,-34)],'Paint')
for yy in range(-75,130,31):
    H.box((0,yy,-65),(39,26,2.8),'Paint')
    H.box((0,yy,-66.5),(29,19,.7),'Armour')
    for side in [-1,1]:
        H.beam((side*30,yy-12,-53),(side*19,yy-12,-65),1.2,1.6,'Metal')

# Fixed gun foundations: deep barbettes meet deck/keel and the moving traverse race.
for cy,z,ven in [(-288,34,False),(-141,39,False),(-8,52,False),(275,50,False),(-135,-43,True),(220,-43,True)]:
    H.tube((0,cy,z+(8 if ven else -8)),(0,cy,z+(-.3 if ven else .3)),20.5,19.4,'Armour',32)
    H.tube((0,cy,z+(-.4 if ven else -.5)),(0,cy,z+(.4 if not ven else .5)),20.0,mat='Metal',n=32)
for side in [-1,1]:
    for y,x,z in [(-354,31,33),(-225,37,35),(-91,68,38),(-28,73,37),(34,73,37),(101,72,37),(168,70,37),(227,51,35),(322,44,32)]:
        H.tube((side*x,y,z-7),(side*x,y,z+.3),5.0,4.5,'Armour',16)
    for y,x,z in [(-300,36,-31),(-204,43,-32),(-54,63,-34),(50,65,-34),(145,62,-34),(305,43,-31)]:
        H.tube((side*x,y,z+6),(side*x,y,z-.3),5.0,4.5,'Armour',16)
for side in [-1,1]:
    H.box((side*96.1,75,21),(1.6,4,2.6),'Armour')
    H.box((side*96.9,75,21),(.6,2.4,1.2),'Lamps')
hull=H.build('Hull',bevel=.28)
engine_fixed=E.build('_Engine fixed',parent=hull,bevel=.25)
bow_fixed=T.build('_Bow fixed',parent=hull,bevel=.18)
for o in SOCKETS:o.parent=hull

def turret(idx,loc,heavy=True,ventral=False,aft=False):
    global MUZZLE
    g=Geo();sign=-1 if ventral else 1
    if heavy:
        g.tube((0,0,-sign*1),(0,0,sign*3),19,mat='Armour',n=48)
        g.tube((0,0,sign*2.7),(0,0,sign*4.2),17.5,mat='Metal',n=48)
        # Cast angular turret housing with sloped cheeks and a low commander's blister.
        rings=[]
        pts=[(-21,-18),(21,-18),(25,-10),(24,13),(16,19),(-16,19),(-24,13),(-25,-10)]
        rings=[[(x,y,sign*3) for x,y in pts],[(x*.87,y*.89+2,sign*17) for x,y in pts]]
        fs=[tuple(reversed(range(8))),tuple(range(8,16))]+[(i,(i+1)%8,(i+1)%8+8,i+8) for i in range(8)]
        g.add(sum(rings,[]),fs,'Armour')
        for side in [-1,1]:
            g.box((side*20,2,sign*10),(2.1,22,8),'Metal')
            g.box((side*15,8,sign*17),(9,10,2),'Armour')
            for y in [-10,0,10]:bolt(g,(side*19,y,sign*16),axis=(0,0,sign),r=.5)
        g.box((0,8,sign*17),(11,10,1.6),'DarkMetal')
        for x in [-3,0,3]:g.box((x,8,sign*18),(1,7,.5),'Metal')
        for x in [-12,0,12]:g.tube((x,-14,sign*10),(x,-21,sign*10),4.6,mat='DarkMetal',n=24)
        # Articulated cast breech shield, rangefinder ears, rear cooling louvers.
        for side in [-1,1]:
            g.box((side*23.5,-5,sign*11),(4.5,7,5.5),'Armour')
            g.tube((side*23.5,-9,sign*11),(side*23.5,-9.7,sign*11),1.4,mat='Glass',n=16)
            g.box((side*17,-12,sign*16.8),(7,7,1.3),'Armour')
            g.beam((side*15,-14,sign*18),(side*15,-10,sign*18),.35,.35,'Metal')
            for yy in [-7,7]:
                g.box((side*23.2,yy,sign*9),(1,4,4),'DarkMetal')
                for zoff in [-1,1]:g.box((side*23.8,yy,sign*(9+zoff)),(.3,3,.4),'Metal')
        g.box((0,18,sign*9),(24,1.6,7),'Trim')
        for xx in range(-10,11,3):g.box((xx,19,sign*9),(1,1,5),'Metal')
        g.tube((0,4,sign*17.3),(0,4,sign*18.2),4.1,mat='Armour',n=20)
        g.tube((0,4,sign*18.2),(0,4,sign*18.5),3.5,mat='DarkMetal',n=20,inner=.35)
        for xx in [-4,4]:g.box((xx,4,sign*18.7),(1,2,.7),'Metal')
        pivot=(0,-18,sign*10);xs=[-12,0,12];length=62;radius=2.5
    else:
        g.tube((0,0,0),(0,0,sign*1.4),4.5,mat='Armour',n=20)
        g.loft([(-3,6.8,5,sign*3),(2.5,8,5,sign*3),(4,5,4,sign*3)],'Armour')
        g.box((0,1,sign*5.8),(3.8,3,1.1),'Metal')
        g.tube((3,0,sign*4),(4,0,sign*4),1.1,mat='Glass',n=12)
        pivot=(0,-3,sign*3.5);xs=[-1.6,1.6];length=12;radius=.65
    body=g.build(f'Turret_{idx}',loc,parent=hull,bevel=.48 if heavy else .11)
    if aft:body.rotation_euler.z=math.pi
    MOVING.append((body,[0,1,0]));HEAVY.append(body) if heavy else None
    gun=Geo()
    for x in xs:
        gun.tube((x,3,0),(x,-4,0),radius*1.8,mat='Metal',n=24 if heavy else 12)
        gun.tube((x,-2,0),(x,-length*.47,0),radius*1.35,radius,'Armour',24 if heavy else 12)
        gun.tube((x,-length*.43,0),(x,-length+2,0),radius,radius*.70,'Metal',24 if heavy else 12)
        gun.tube((x,-length+4,0),(x,-length,0),radius*.91,radius*.91,'DarkMetal',24 if heavy else 12,inner=radius*.28)
        for y in ([-8,-19,-29] if heavy else [-4]):
            gun.tube((x,y,0),(x,y-1.5,0),radius*1.48,mat='Metal',n=24 if heavy else 12)
        if heavy:
            gun.tube((x+3,-1,-2.4),(x+3,-20,-2.4),.75,mat='Metal',n=12)
    gunob=gun.build(f'Gun_{idx}',pivot,parent=body,bevel=.12 if heavy else .055);MOVING.append((gunob,[1,0,0]))
    for x in xs:
        MUZZLE+=1;socket(f'Muzzle_{MUZZLE}',(x,-length,0),gunob)
    return body

for i,(p,ven,aft) in enumerate([((0,-288,34),False,False),((0,-141,39),False,False),((0,-8,52),False,False),((0,275,50),False,True),((0,-135,-43),True,False),((0,220,-43),True,True)],1):turret(i,p,True,ven,aft)
idx=7
for side in [-1,1]:
    for y,x,z in [(-354,31,33),(-225,37,35),(-91,68,38),(-28,73,37),(34,73,37),(101,72,37),(168,70,37),(227,51,35),(322,44,32)]:
        turret(idx,(side*x,y,z),False,False,y>180);idx+=1
    for y,x,z in [(-300,36,-31),(-204,43,-32),(-54,63,-34),(50,65,-34),(145,62,-34),(305,43,-31)]:
        turret(idx,(side*x,y,z),False,True,y>180);idx+=1
# The paired fixed bow guns also expose their barrel-tip launch sockets.
for side in [-1,1]:
    MUZZLE+=1;socket(f'Muzzle_{MUZZLE}',(side*43,-450,-23),hull)
socket('Camera_Chase',(0,1050,310),hull)
socket('Light_Nav_L',(97.2,75,21),hull);socket('Light_Nav_R',(-97.2,75,21),hull);socket('Light_Beacon',(0,128,154.3),hull)
# Explicit lamp colour is supplied by socket metadata in the game; all lamp meshes share Lamps.

# Broader, deeper central citadel and a lower forward hull. Every attachment follows it.
def hull_depth(y):
    knots=[(-450,.83),(-270,.86),(-190,.92),(-105,1.10),(200,1.10),(325,1.0),(450,1.0)]
    for (a,va),(b,vb) in zip(knots,knots[1:]):
        if a<=y<=b:return va+(vb-va)*(y-a)/(b-a)
    return 1.0
for v in hull.data.vertices:
    v.co.z*=hull_depth(v.co.y)
for ob,axis in MOVING:
    if ob.name.startswith('Turret'):
        f=hull_depth(ob.location.y);ob.location.z*=f
        gun=next(c for c in ob.children if c.name.startswith('Gun'))
for ob in SOCKETS:
    if ob.parent==hull and not ob.name.startswith(('Thruster_','Hardpoint_')):ob.location.z*=hull_depth(ob.location.y)
# Bounds normalization is geometry-only, preserving moving-node transforms and exact pivot hierarchy.
bpy.context.view_layer.update()
asset=[o for o in S.objects if o.type in {'MESH','EMPTY'}]
meshes=[o for o in asset if o.type=='MESH']
pts=[o.matrix_world@Vector(c) for o in meshes for c in o.bound_box]
lo=Vector(tuple(min(p[i] for p in pts) for i in range(3)));hi=Vector(tuple(max(p[i] for p in pts) for i in range(3)))
# Solve height around rigid circular hardware, rather than crushing engine bells and barrels.
# Hull plates can vary in depth; weapon local shapes and nozzle cross sections stay round.
scale=Vector((180/(hi.x-lo.x),900/(hi.y-lo.y),1))
fixed_round={engine_fixed,bow_fixed}
def z_extent(sz):
    values=[]
    for ob in meshes:
        if ob==hull:values.extend(c[2]*sz for c in ob.bound_box)
        elif ob in fixed_round:values.extend(c[2]*scale.x for c in ob.bound_box)
        elif ob.name.startswith('Turret_'):values.extend(ob.location.z*sz+c[2]*scale.x for c in ob.bound_box)
        elif ob.name.startswith('Gun_'):values.extend(ob.parent.location.z*sz+(ob.location.z+c[2])*scale.x for c in ob.bound_box)
    return max(values)-min(values)
low,high=.1,2.0
for _ in range(48):
    mid=(low+high)/2
    if z_extent(mid)<160:low=mid
    else:high=mid
scale.z=(low+high)/2
for ob in meshes:
    zs=scale.z if ob==hull else scale.x
    ys=scale.y if ob==hull or ob in fixed_round else scale.x
    for v in ob.data.vertices:v.co=Vector((v.co.x*scale.x,v.co.y*ys,v.co.z*zs))
for ob in asset:
    rigid_local=(ob.name.startswith(('Gun_','Thruster_','Hardpoint_')) or (ob.name.startswith('Muzzle_') and ob.parent!=hull) or ob in fixed_round)
    zs=scale.x if rigid_local else scale.z
    ys=scale.x if (ob.name.startswith('Gun_') or (ob.name.startswith('Muzzle_') and ob.parent!=hull)) else scale.y
    ob.location=Vector((ob.location.x*scale.x,ob.location.y*ys,ob.location.z*zs))
# All static geometry is exported as the single Hull mesh node.
for ob in S.objects:ob.select_set(False)
for ob in [hull,engine_fixed,bow_fixed]:ob.select_set(True)
bpy.context.view_layer.objects.active=hull;bpy.ops.object.join()
asset=[o for o in S.objects if o.type in {'MESH','EMPTY'}]
meshes=[o for o in asset if o.type=='MESH']
for ob in asset:ob.select_set(False)
bpy.context.view_layer.update()
# Keep evaluated geometry inside the scene triangle budget before linking instances.
raw_tri=sum(len(p.vertices)-2 for ob in meshes for p in ob.data.polygons)
if raw_tri>177000:
    ratio=176000/raw_tri
    for ob in meshes:
        bpy.context.view_layer.objects.active=ob
        dec=ob.modifiers.new('Game budget reduction','DECIMATE');dec.ratio=ratio;dec.use_collapse_triangulate=True
        bpy.ops.object.modifier_apply(modifier=dec.name)
# Remove zero-area fragments left by edge bevel intersections and reduction.
for ob in meshes:
    bm=bmesh.new();bm.from_mesh(ob.data)
    bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=0.00001)
    bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=0.000001)
    bmesh.ops.triangulate(bm,faces=list(bm.faces))
    bad=[f for f in bm.faces if f.calc_area()<1e-10]
    if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
    bm.to_mesh(ob.data);bm.free();ob.data.update()
# Reduction can trim an antenna extremity by centimetres. Restore the exact
# 160 m height in geometry and pivot positions, without applying node transforms.
bpy.context.view_layer.update()
extrema=[(o.matrix_world@Vector(c)).z for o in meshes for c in o.bound_box]
height_correction=160.0/(max(extrema)-min(extrema))
for ob in meshes:
    for v in ob.data.vertices:v.co.z*=height_correction
    ob.data.update()
for ob in asset:ob.location.z*=height_correction
bpy.context.view_layer.update()
# Reuse identical static local mesh data across movable mounts (separate pivots).
import hashlib
mesh_cache={}
for ob in meshes:
    me=ob.data
    digest=hashlib.sha256()
    for v in me.vertices:digest.update(struct.pack('<3f',*v.co))
    for p in me.polygons:digest.update(struct.pack('<I',p.material_index)+struct.pack('<'+'I'*len(p.vertices),*p.vertices))
    key=digest.digest()
    if key in mesh_cache:
        ob.data=mesh_cache[key]
        if me.users==0:bpy.data.meshes.remove(me)
    else:mesh_cache[key]=me
# Six heavy turrets and 30 independently articulated point defence guns.
triangles=sum(len(p.vertices)-2 for o in meshes for p in o.data.polygons)
print('GEOMETRY',len(meshes),'meshes',triangles,'triangles',flush=True)

# Dark space gradient. Lighting is separate from camera-visible background.
world=bpy.data.worlds.new('Deep space');S.world=world;world.use_nodes=True;nt=world.node_tree;nt.nodes.clear()
out=nt.nodes.new('ShaderNodeOutputWorld');mix=nt.nodes.new('ShaderNodeMixShader');path=nt.nodes.new('ShaderNodeLightPath');nt.links.new(path.outputs['Is Camera Ray'],mix.inputs[0]);nt.links.new(mix.outputs[0],out.inputs[0])
ambient=nt.nodes.new('ShaderNodeBackground');ambient.inputs[0].default_value=(.13,.18,.26,1);ambient.inputs[1].default_value=.45;nt.links.new(ambient.outputs[0],mix.inputs[1])
bg=nt.nodes.new('ShaderNodeBackground');bg.inputs[1].default_value=.30
coord=nt.nodes.new('ShaderNodeTexCoord');sep=nt.nodes.new('ShaderNodeSeparateXYZ');nt.links.new(coord.outputs['Normal'],sep.inputs[0]);ramp=nt.nodes.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.1;ramp.color_ramp.elements[0].color=(.003,.006,.012,1);ramp.color_ramp.elements[1].position=.85;ramp.color_ramp.elements[1].color=(.025,.047,.085,1);nt.links.new(sep.outputs['Z'],ramp.inputs[0]);nt.links.new(ramp.outputs[0],bg.inputs[0]);nt.links.new(bg.outputs[0],mix.inputs[2])
def light(name,typ,loc,energy,color,size=500):
    d=bpy.data.lights.new(name,typ);o=bpy.data.objects.new(name,d);S.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,0))-o.location).to_track_quat('-Z','Y').to_euler();d.energy=energy;d.color=color
    if typ=='AREA':d.shape='DISK';d.size=size
    else:d.angle=math.radians(8)
    return o
light('White key','SUN',(400,-600,900),3.1,(1.0,.96,.90))
light('Cool fill','SUN',(-500,-200,300),1.15,(.53,.69,1))
light('Stern rim','SUN',(100,700,400),1.65,(.68,.79,1))
light('Lower bounce','SUN',(200,-100,-500),.65,(.54,.65,.82))
camd=bpy.data.cameras.new('Review camera');cam=bpy.data.objects.new('Review camera',camd);S.collection.objects.link(cam);S.camera=cam;camd.type='ORTHO';camd.clip_end=10000

def camera(direction=(1,-1.65,.82),target=(0,-10,12),scale=1020):
    target=Vector(target);cam.location=target+Vector(direction).normalized()*1700;cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=scale

def render(name,w=1920,h=1080,samples=24):
    S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)

# Geometry-first diagnostic, before shipping any texture result.
camera((1,-.88,.55),scale=1020)
clay=bpy.data.materials.new('_Clay diagnostic');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.30,.32,.34,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.65
S.view_layers[0].material_override=clay
render('clay.png',1920,1080,20)
S.view_layers[0].material_override=None

# Check each pivot independently before export; its own origin cannot translate.
for ob,axis in MOVING:
    before=ob.matrix_world.translation.copy();saved=ob.rotation_euler.copy()
    component=2 if ob.name.startswith('Turret_') else 0
    ob.rotation_euler[component]+=math.radians(30);bpy.context.view_layer.update()
    assert (ob.matrix_world.translation-before).length<1e-5,ob.name
    ob.rotation_euler=saved;bpy.context.view_layer.update()
print('PIVOT CHECK',len(MOVING),'independent rotations passed',flush=True)
# Thirty degree pose test. Both turret positions and elevation trunnion points remain fixed.
base_positions={o.name:o.matrix_world.translation.copy() for o,_ in MOVING}
for o,axis in MOVING:
    if o.name.startswith('Turret'):o.rotation_euler.z+=math.radians(30)
    else:o.rotation_euler.x+=math.radians(30)
bpy.context.view_layer.update()
for o,axis in MOVING:
    if o.name.startswith('Turret'):assert (o.matrix_world.translation-base_positions[o.name]).length<1e-5
    assert all(abs(v-1)<1e-6 for v in o.scale),o.name
    assert len(o.modifiers)==0,o.name
render('pivots.png',1280,720,16)
for o,axis in MOVING:
    if o.name.startswith('Turret'):o.rotation_euler.z-=math.radians(30)
    else:o.rotation_euler.x-=math.radians(30)
bpy.context.view_layer.update()

# Export baked maps only, then serialize the paint factor explicitly.
paint=MATS[MAT['Paint']];pnt=paint.node_tree;pbs=pnt.nodes.get('Principled BSDF')
if pbs is None:pbs=next(n for n in pnt.nodes if n.type=='BSDF_PRINCIPLED')
pnt.links.new(pnt.nodes['base'].outputs[0],pbs.inputs['Base Color'])
for o in S.objects:o.select_set(False)
for o in asset:o.select_set(True)
bpy.context.view_layer.objects.active=hull
GLB=ROOT/'dreadnought.glb'
bpy.ops.export_scene.gltf(filepath=str(GLB),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='JPEG',export_jpeg_quality=83,export_cameras=False,export_lights=False,export_extras=True)
data=GLB.read_bytes();jslen=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jslen]);binary=data[20+jslen:]
for m in doc['materials']:
    if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
    if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=False
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4);GLB.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+binary)
pnt.links.new(pnt.nodes['Faction'].outputs[0],pbs.inputs['Base Color'])
print('EXPORTED',GLB.stat().st_size,flush=True)
assert GLB.stat().st_size<=12000000,'GLB exceeds 12 MB'
assert 100000<=triangles<=220000
assert len(doc['materials'])<=9
# Export frame metadata and precise bounds.
def gl(v):return [round(float(v.x),5),round(float(v.z),5),round(float(-v.y),5)]
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in meshes for c in o.bound_box];lo=Vector(tuple(min(p[i] for p in pts) for i in range(3)));hi=Vector(tuple(max(p[i] for p in pts) for i in range(3)))
meta={'units':'metres','forward':'+Z','up':'+Y','left':'+X','bbox':{'min':[lo.x,lo.z,-hi.y],'max':[hi.x,hi.z,-lo.y],'size':[hi.x-lo.x,hi.z-lo.z,hi.y-lo.y]},'triangle_count':triangles,'materials':[m.name for m in MATS],'nodes':{},'axes':{o.name:ax for o,ax in MOVING},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS], 'lights':{'Light_Nav_L':'red','Light_Nav_R':'green','Light_Beacon':'white'},'counts':{'heavy_triple_turrets':6,'point_defence_turrets':30,'main_engines':8,'torpedo_launchers':8,'fixed_bow_guns':2}}
for o in asset:meta['nodes'][o.name]={'position':gl(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
notes=["Original 900 × 180 × 160 m armoured dreadnought; six heavy triple mounts, 30 twin point-defence mounts, eight engines, eight bow torpedo tubes, four hangars.",f"Triangles: {triangles:,}; GLB: {GLB.stat().st_size/1e6:.2f} MB; metres, Y up, nose +Z; pivot-preserving articulated gun hierarchy.","Materials (9): Paint, Armour, Metal, DarkMetal, HeatMetal, Trim, Engines, Lamps, Glass; neutral Paint bake with default linear factor [0.30, 0.32, 0.34].","Textures: shared Cycles-baked tiling base colour / tangent normal at 1024 px; packed ORM 512 px; baked vent trims 512 px / ORM 256 px; engine emission 256 px, uniform light/glass sets 32 px; UV tile scales 16 m hull / 9 m metal.","Poly Haven CC0 ids: blue_metal_plate, metal_plate_02, rusty_metal_03; https://polyhaven.com/license ; source JPG diff / nor_gl / arm maps downloaded from dl.polyhaven.org.","Reference differences: original split armoured torpedo prow, eight engines instead of six pictured, extra fourth dorsal turret and 30 point-defence mounts; no markings or interior bridge room."]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
# Save a recoverable authoring checkpoint, not part of game export.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'dreadnought.blend'))
render('final.png',1920,1080,32)
camera((1,0,.015),target=(0,0,25),scale=1040);render('side.png',1920,1080,24)
camera((1,-.88,.55),scale=1020)
# Same view and illumination for the six faction paint variants.
arr=[]
for i,(name,color) in enumerate(PAINTS):
    pnt.nodes['Faction'].inputs[2].default_value=(*color,1)
    render(f'paint_{i}.png',640,360,12)
    im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'),check_existing=False)
    pix=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(pix);arr.append(pix.reshape(360,640,4));bpy.data.images.remove(im)
grid=np.zeros((720,1920,4),np.float32)
for i,pix in enumerate(arr):
    row=1-i//3;col=i%3;grid[row*360:(row+1)*360,col*640:(col+1)*640]=pix
im=bpy.data.images.new('Faction paint grid',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
pnt.nodes['Faction'].inputs[2].default_value=(*PAINTS[0][1],1)
camera((1,1.5,.65),target=(0,30,0),scale=970);render('rear.png',1280,720,20)
camera((1,-1.6,.65),target=(25,75,35),scale=310);render('detail.png',1280,720,24)
camera((1,-1.1,-.6),target=(0,0,-12),scale=1020);render('ventral.png',1280,720,24)
camera((0,1,.02),target=(0,420,0),scale=210);render('engines.png',1280,720,24)
print('COMPLETE',round(time.time()-START,1),'seconds',flush=True)
