#!/usr/bin/env python3
"""
build_railgun-battleship.py
Standalone Blender 5.2 generator for the 1100m Spinal Railgun Battleship (railgun-battleship).
Builds complete high-detail hard-surface geometry matching sheet.jpg, downloads/loads Poly Haven
CC0 textures, mixes procedural weathering/panel/trim nodes and bakes them with Cycles GPU to
game-weight textures (<=1024px base/normal, <=512px ORM), verifies 30-deg moving-part articulations,
exports railgun-battleship.glb + nodes.json, and renders final.png, side.png, clay.png, paints.png.
"""

import os
import sys
import json
import math
import struct
import urllib.request
import bpy
import bmesh
from mathutils import Vector, Matrix, Euler

# ---------------------------------------------------------------------------
# Paths & Configuration
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
TEX_PH_DIR = os.path.join(SCRIPT_DIR, "textures_ph")
TEX_BAKE_DIR = os.path.join(SCRIPT_DIR, "textures_baked")
os.makedirs(TEX_PH_DIR, exist_ok=True)
os.makedirs(TEX_BAKE_DIR, exist_ok=True)

POLYHAVEN_IDS = [
    "metal_plate",
    "metal_plate_02",
    "rusty_metal_02",
    "corrugated_iron_02",
]

PAINTS = [
    {"name": "Bone White (Default)", "color": [0.70, 0.68, 0.62]},
    {"name": "Naval Grey",           "color": [0.46, 0.50, 0.54]},
    {"name": "Gunmetal",             "color": [0.24, 0.26, 0.29]},
    {"name": "Olive Drab",           "color": [0.33, 0.37, 0.26]},
    {"name": "Desert Sand",          "color": [0.68, 0.58, 0.44]},
    {"name": "Deep Navy",            "color": [0.16, 0.22, 0.34]},
]


# ---------------------------------------------------------------------------
# Coordinate Conversion Helpers
# Export Contract (glTF Y-up, metres):
#   Nose = +Z (Z in [-550.0, +550.0], length 1100.0 m)
#   Up   = +Y (Y in [ -45.0,  +95.0], height  140.0 m)
#   Left = +X (X in [ -80.0,  +80.0], width   160.0 m)
# Blender native Z-up with export_yup=True maps:
#   X_gltf =  X_bl
#   Y_gltf =  Z_bl
#   Z_gltf = -Y_bl
# Therefore: (X_bl, Y_bl, Z_bl) = (x_gltf, -z_gltf, y_gltf)
# ---------------------------------------------------------------------------
def g2b(x, y, z):
    return Vector((float(x), float(-z), float(y)))


def b2g_tuple(v):
    return [round(float(v[0]), 4), round(float(v[2]), 4), round(float(-v[1]), 4)]


# ---------------------------------------------------------------------------
# Step 1: Download / Verify Poly Haven CC0 Textures
# ---------------------------------------------------------------------------
def ensure_polyhaven_textures():
    for ph_id in POLYHAVEN_IDS:
        for m in ("diff", "nor_gl", "arm"):
            fname = f"{ph_id}_{m}_1k.jpg"
            fpath = os.path.join(TEX_PH_DIR, fname)
            if not os.path.exists(fpath) or os.path.getsize(fpath) < 1000:
                url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{fname}"
                print(f"[PolyHaven] Downloading {url} ...")
                urllib.request.urlretrieve(url, fpath)
    print("[PolyHaven] All CC0 source maps ready.")


# ---------------------------------------------------------------------------
# Step 2: Scene Reset & GPU Cycles Configuration
# ---------------------------------------------------------------------------
def reset_scene():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    scene = bpy.context.scene
    scene.unit_settings.system = 'METRIC'
    scene.unit_settings.scale_length = 1.0

    scene.render.engine = 'CYCLES'
    prefs = bpy.context.preferences
    cprefs = prefs.addons['cycles'].preferences
    for dev_type in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
        try:
            cprefs.compute_device_type = dev_type
            cprefs.get_devices()
            devs = [d for d in cprefs.devices if d.type != 'CPU']
            if devs:
                for d in cprefs.devices:
                    d.use = (d.type != 'CPU')
                scene.cycles.device = 'GPU'
                print(f"[Cycles] Enabled GPU device backend: {dev_type} ({[d.name for d in devs]})")
                break
        except Exception:
            pass
    scene.cycles.use_adaptive_sampling = True
    scene.cycles.adaptive_threshold = 0.02
    return scene


# ---------------------------------------------------------------------------
# Step 3: Cycles Texture Baking (Poly Haven + Procedural Nodes -> Baked Maps)
# Strictly obeys GAME WEIGHT:
#   - BaseColor & Normal maps <= 1024x1024 (saved as JPEG so GLB stays < 10 MB)
#   - ORM (Occlusion/Roughness/Metallic) maps <= 512x512
#   - Paint baseColor texture holds ONLY light neutral detail (near-white 0.82..0.97
#     with panel lines, grey grime, engine scorch, micro-meteorite pitting & chipped edges)
#     while Paint.baseColorFactor = [0.70, 0.68, 0.62].
# ---------------------------------------------------------------------------
def build_and_bake_textures(scene):
    print("[Bake] Constructing Cycles procedural + Poly Haven materials for baking...")

    ph_imgs = {}
    for ph_id in POLYHAVEN_IDS:
        ph_imgs[ph_id] = {}
        for m in ("diff", "nor_gl", "arm"):
            p = os.path.join(TEX_PH_DIR, f"{ph_id}_{m}_1k.jpg")
            img = bpy.data.images.load(p, check_existing=True)
            img.colorspace_settings.name = 'sRGB' if m == 'diff' else 'Non-Color'
            ph_imgs[ph_id][m] = img

    bm = bmesh.new()
    v0 = bm.verts.new((-1, -1, 0))
    v1 = bm.verts.new(( 1, -1, 0))
    v2 = bm.verts.new(( 1,  1, 0))
    v3 = bm.verts.new((-1,  1, 0))
    f = bm.faces.new((v0, v1, v2, v3))
    uv_layer = bm.loops.layers.uv.new("UVMap")
    for loop, uv in zip(f.loops, [(0, 0), (1, 0), (1, 1), (0, 1)]):
        loop[uv_layer].uv = uv

    bake_mesh = bpy.data.meshes.new("_BakePlaneMesh")
    bm.to_mesh(bake_mesh)
    bm.free()
    bake_obj = bpy.data.objects.new("_BakePlane", bake_mesh)
    scene.collection.objects.link(bake_obj)
    bpy.context.view_layer.objects.active = bake_obj
    bake_obj.select_set(True)

    old_samples = scene.cycles.samples
    scene.cycles.samples = 16
    scene.render.bake.margin = 4

    def bake_pass_to_file(mat, bake_type, res, out_path, is_color=True):
        img = bpy.data.images.new("_BakeTarget", width=res, height=res, alpha=False)
        img.colorspace_settings.name = 'sRGB' if is_color else 'Non-Color'
        nodes = mat.node_tree.nodes
        tnode = nodes.new("ShaderNodeTexImage")
        tnode.image = img
        for n in nodes:
            n.select = False
        tnode.select = True
        nodes.active = tnode

        bake_obj.data.materials.clear()
        bake_obj.data.materials.append(mat)

        if bake_type == 'DIFFUSE':
            scene.render.bake.use_pass_direct = False
            scene.render.bake.use_pass_indirect = False
            scene.render.bake.use_pass_color = True
        bpy.ops.object.bake(type=bake_type)

        scene.render.image_settings.file_format = 'JPEG'
        scene.render.image_settings.quality = 94
        img.filepath_raw = out_path
        img.file_format = 'JPEG'
        img.save()
        nodes.remove(tnode)
        bpy.data.images.remove(img)
        loaded = bpy.data.images.load(out_path, check_existing=True)
        loaded.colorspace_settings.name = 'sRGB' if is_color else 'Non-Color'
        return loaded

    def create_source_bake_mat(name, ph_main, ph_alt, mode="paint"):
        mat = bpy.data.materials.new(name)
        mat.use_nodes = True
        nt = mat.node_tree
        nodes = nt.nodes
        links = nt.links
        nodes.clear()

        out = nodes.new("ShaderNodeOutputMaterial")
        bsdf = nodes.new("ShaderNodeBsdfPrincipled")
        links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

        tex_coord = nodes.new("ShaderNodeTexCoord")
        mapping = nodes.new("ShaderNodeMapping")
        mapping.inputs["Scale"].default_value = (1.5, 1.5, 1.5)
        links.new(tex_coord.outputs["UV"], mapping.inputs["Vector"])

        t_diff1 = nodes.new("ShaderNodeTexImage")
        t_diff1.image = ph_imgs[ph_main]["diff"]
        t_norm1 = nodes.new("ShaderNodeTexImage")
        t_norm1.image = ph_imgs[ph_main]["nor_gl"]
        t_arm1 = nodes.new("ShaderNodeTexImage")
        t_arm1.image = ph_imgs[ph_main]["arm"]
        for t in (t_diff1, t_norm1, t_arm1):
            links.new(mapping.outputs["Vector"], t.inputs["Vector"])

        t_diff2 = nodes.new("ShaderNodeTexImage")
        t_diff2.image = ph_imgs[ph_alt]["diff"]
        t_arm2 = nodes.new("ShaderNodeTexImage")
        t_arm2.image = ph_imgs[ph_alt]["arm"]
        links.new(mapping.outputs["Vector"], t_diff2.inputs["Vector"])
        links.new(mapping.outputs["Vector"], t_arm2.inputs["Vector"])

        # Primary hull armor plate panels with subtle tonal variation & crisp dark seams
        brick = nodes.new("ShaderNodeTexBrick")
        brick.offset = 0.5
        brick.squash = 1.8
        brick.inputs["Scale"].default_value = 3.5
        brick.inputs["Mortar Size"].default_value = 0.010
        brick.inputs["Mortar Smooth"].default_value = 0.25
        brick.inputs["Color1"].default_value = (0.98, 0.98, 0.98, 1.0)
        brick.inputs["Color2"].default_value = (0.88, 0.88, 0.90, 1.0)
        brick.inputs["Mortar"].default_value = (0.38, 0.39, 0.41, 1.0)
        links.new(tex_coord.outputs["UV"], brick.inputs["Vector"])

        # Secondary technical sub-panel lines & access hatches
        brick2 = nodes.new("ShaderNodeTexBrick")
        brick2.offset = 0.25
        brick2.squash = 1.2
        brick2.inputs["Scale"].default_value = 10.0
        brick2.inputs["Mortar Size"].default_value = 0.006
        brick2.inputs["Mortar Smooth"].default_value = 0.20
        brick2.inputs["Color1"].default_value = (0.99, 0.99, 0.99, 1.0)
        brick2.inputs["Color2"].default_value = (0.93, 0.93, 0.94, 1.0)
        brick2.inputs["Mortar"].default_value = (0.62, 0.63, 0.65, 1.0)
        links.new(tex_coord.outputs["UV"], brick2.inputs["Vector"])

        # Weathering / scorch / micro-meteorite pitting noise
        noise = nodes.new("ShaderNodeTexNoise")
        noise.inputs["Scale"].default_value = 5.5
        noise.inputs["Detail"].default_value = 12.0
        noise.inputs["Roughness"].default_value = 0.65
        links.new(tex_coord.outputs["UV"], noise.inputs["Vector"])

        mix_bricks = nodes.new("ShaderNodeMixRGB")
        mix_bricks.blend_type = 'MULTIPLY'
        mix_bricks.inputs["Fac"].default_value = 0.68
        links.new(brick.outputs["Color"], mix_bricks.inputs["Color1"])
        links.new(brick2.outputs["Color"], mix_bricks.inputs["Color2"])

        hsv = nodes.new("ShaderNodeHueSaturation")
        if mode == "paint":
            hsv.inputs["Saturation"].default_value = 0.06
            hsv.inputs["Value"].default_value = 1.75
        elif mode == "weapon":
            hsv.inputs["Saturation"].default_value = 0.08
            hsv.inputs["Value"].default_value = 1.52
        elif mode == "bare":
            hsv.inputs["Saturation"].default_value = 0.14
            hsv.inputs["Value"].default_value = 1.38
        elif mode == "scorch":
            hsv.inputs["Saturation"].default_value = 0.48
            hsv.inputs["Value"].default_value = 0.46
        links.new(t_diff1.outputs["Color"], hsv.inputs["Color"])

        hsv2 = nodes.new("ShaderNodeHueSaturation")
        if mode == "paint":
            hsv2.inputs["Saturation"].default_value = 0.22
            hsv2.inputs["Value"].default_value = 1.18
        elif mode == "weapon":
            hsv2.inputs["Saturation"].default_value = 0.14
            hsv2.inputs["Value"].default_value = 1.15
        elif mode == "scorch":
            hsv2.inputs["Saturation"].default_value = 0.58
            hsv2.inputs["Value"].default_value = 0.38
        else:
            hsv2.inputs["Saturation"].default_value = 0.20
            hsv2.inputs["Value"].default_value = 1.05
        links.new(t_diff2.outputs["Color"], hsv2.inputs["Color"])

        mix_ph = nodes.new("ShaderNodeMixRGB")
        mix_ph.blend_type = 'MIX'
        links.new(noise.outputs["Fac"], mix_ph.inputs["Fac"])
        links.new(hsv.outputs["Color"], mix_ph.inputs["Color1"])
        links.new(hsv2.outputs["Color"], mix_ph.inputs["Color2"])

        final_col = nodes.new("ShaderNodeMixRGB")
        final_col.blend_type = 'MULTIPLY'
        final_col.inputs["Fac"].default_value = 0.76 if mode == "paint" else 0.65
        links.new(mix_ph.outputs["Color"], final_col.inputs["Color1"])
        links.new(mix_bricks.outputs["Color"], final_col.inputs["Color2"])

        if mode == "paint":
            bright = nodes.new("ShaderNodeBrightContrast")
            bright.inputs["Bright"].default_value = 0.15
            bright.inputs["Contrast"].default_value = 0.15
            links.new(final_col.outputs["Color"], bright.inputs["Color"])
            links.new(bright.outputs["Color"], bsdf.inputs["Base Color"])
        elif mode == "weapon":
            bright = nodes.new("ShaderNodeBrightContrast")
            bright.inputs["Bright"].default_value = 0.08
            bright.inputs["Contrast"].default_value = 0.12
            links.new(final_col.outputs["Color"], bright.inputs["Color"])
            links.new(bright.outputs["Color"], bsdf.inputs["Base Color"])
        else:
            links.new(final_col.outputs["Color"], bsdf.inputs["Base Color"])

        norm_map = nodes.new("ShaderNodeNormalMap")
        norm_map.inputs["Strength"].default_value = 0.85
        links.new(t_norm1.outputs["Color"], norm_map.inputs["Color"])

        bump = nodes.new("ShaderNodeBump")
        bump.inputs["Strength"].default_value = 0.42
        bump.inputs["Distance"].default_value = 0.020
        links.new(mix_bricks.outputs["Color"], bump.inputs["Height"])
        links.new(norm_map.outputs["Normal"], bump.inputs["Normal"])
        links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])

        sep1 = nodes.new("ShaderNodeSeparateColor")
        links.new(t_arm1.outputs["Color"], sep1.inputs["Color"])

        ao_mul = nodes.new("ShaderNodeMath")
        ao_mul.operation = 'MULTIPLY'
        links.new(sep1.outputs["Red"], ao_mul.inputs[0])
        links.new(mix_bricks.outputs["Color"], ao_mul.inputs[1])

        rough_map = nodes.new("ShaderNodeMapRange")
        if mode == "paint":
            rough_map.inputs["To Min"].default_value = 0.36
            rough_map.inputs["To Max"].default_value = 0.65
            metal_val = 0.22
        elif mode == "weapon":
            rough_map.inputs["To Min"].default_value = 0.34
            rough_map.inputs["To Max"].default_value = 0.58
            metal_val = 0.35
        elif mode == "bare":
            rough_map.inputs["To Min"].default_value = 0.32
            rough_map.inputs["To Max"].default_value = 0.56
            metal_val = 0.58
        else:  # scorch
            rough_map.inputs["To Min"].default_value = 0.28
            rough_map.inputs["To Max"].default_value = 0.62
            metal_val = 0.78
        links.new(sep1.outputs["Green"], rough_map.inputs["Value"])

        comb_orm = nodes.new("ShaderNodeCombineColor")
        links.new(ao_mul.outputs["Value"], comb_orm.inputs["Red"])
        links.new(rough_map.outputs["Result"], comb_orm.inputs["Green"])
        comb_orm.inputs["Blue"].default_value = metal_val

        mat["_orm_out_node"] = comb_orm.name
        return mat

    def bake_complete_pbr_set(prefix, ph_main, ph_alt, mode):
        src_mat = create_source_bake_mat(f"_Src_{prefix}", ph_main, ph_alt, mode=mode)
        base_path = os.path.join(TEX_BAKE_DIR, f"{prefix}_base_1024.jpg")
        norm_path = os.path.join(TEX_BAKE_DIR, f"{prefix}_norm_1024.jpg")
        orm_path  = os.path.join(TEX_BAKE_DIR, f"{prefix}_orm_512.jpg")

        bsdf = [n for n in src_mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'][0]
        bsdf.inputs["Metallic"].default_value = 0.0
        img_base = bake_pass_to_file(src_mat, 'DIFFUSE', 1024, base_path, is_color=True)
        img_norm = bake_pass_to_file(src_mat, 'NORMAL',  1024, norm_path, is_color=False)

        comb_orm = src_mat.node_tree.nodes[src_mat["_orm_out_node"]]
        emit = src_mat.node_tree.nodes.new("ShaderNodeEmission")
        out = [n for n in src_mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL'][0]
        src_mat.node_tree.links.new(comb_orm.outputs["Color"], emit.inputs["Color"])
        src_mat.node_tree.links.new(emit.outputs["Emission"], out.inputs["Surface"])
        img_orm = bake_pass_to_file(src_mat, 'EMIT', 512, orm_path, is_color=False)

        bpy.data.materials.remove(src_mat)
        return img_base, img_norm, img_orm

    baked = {}
    baked["Paint"]         = bake_complete_pbr_set("paint",  "metal_plate",      "rusty_metal_02", "paint")
    baked["BareMetal"]     = bake_complete_pbr_set("bare",   "metal_plate_02",   "corrugated_iron_02", "bare")
    baked["ScorchedMetal"] = bake_complete_pbr_set("scorch", "rusty_metal_02",   "metal_plate", "scorch")
    baked["WeaponArmor"]   = bake_complete_pbr_set("weapon", "metal_plate",      "metal_plate_02", "weapon")

    # Bake Emissive texture for Engines (Rich electric blue plasma core + warm orange-gold rim)
    eng_src = bpy.data.materials.new("_Src_Engines")
    eng_src.use_nodes = True
    nt = eng_src.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    emit = nt.nodes.new("ShaderNodeEmission")
    nt.links.new(emit.outputs["Emission"], out.inputs["Surface"])
    tc = nt.nodes.new("ShaderNodeTexCoord")
    sep_uv = nt.nodes.new("ShaderNodeSeparateXYZ")
    nt.links.new(tc.outputs["UV"], sep_uv.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.0
    ramp.color_ramp.elements[0].color = (0.04, 0.52, 1.0, 1.0)
    e_mid = ramp.color_ramp.elements.new(0.55)
    e_mid.color = (0.16, 0.76, 1.0, 1.0)
    ramp.color_ramp.elements[1].position = 0.90
    ramp.color_ramp.elements[1].color = (1.0, 0.45, 0.10, 1.0)
    nt.links.new(sep_uv.outputs["Y"], ramp.inputs["Fac"])
    nt.links.new(ramp.outputs["Color"], emit.inputs["Color"])
    eng_path = os.path.join(TEX_BAKE_DIR, "engines_emit_512.jpg")
    baked["Engines_Emit"] = bake_pass_to_file(eng_src, 'EMIT', 512, eng_path, is_color=True)
    bpy.data.materials.remove(eng_src)

    # Bake Emissive texture for Lamps (Deep saturated electric cyan-blue capacitor glow)
    lamp_src = bpy.data.materials.new("_Src_Lamps")
    lamp_src.use_nodes = True
    nt = lamp_src.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    emit = nt.nodes.new("ShaderNodeEmission")
    nt.links.new(emit.outputs["Emission"], out.inputs["Surface"])
    tc = nt.nodes.new("ShaderNodeTexCoord")
    brick = nt.nodes.new("ShaderNodeTexBrick")
    brick.inputs["Scale"].default_value = 4.0
    brick.inputs["Mortar Size"].default_value = 0.025
    brick.inputs["Color1"].default_value = (0.02, 0.40, 1.0, 1.0)
    brick.inputs["Color2"].default_value = (0.06, 0.56, 1.0, 1.0)
    brick.inputs["Mortar"].default_value = (0.01, 0.22, 0.72, 1.0)
    nt.links.new(tc.outputs["UV"], brick.inputs["Vector"])
    nt.links.new(brick.outputs["Color"], emit.inputs["Color"])
    lamp_path = os.path.join(TEX_BAKE_DIR, "lamps_emit_512.jpg")
    baked["Lamps_Emit"] = bake_pass_to_file(lamp_src, 'EMIT', 512, lamp_path, is_color=True)
    bpy.data.materials.remove(lamp_src)

    bpy.data.objects.remove(bake_obj, do_unlink=True)
    bpy.data.meshes.remove(bake_mesh, do_unlink=True)
    scene.cycles.samples = old_samples
    scene.render.image_settings.file_format = 'PNG'
    print("[Bake] All game-weight textures baked as JPEG (1024px base/norm, 512px ORM/emit).")
    return baked


# ---------------------------------------------------------------------------
# Step 4: Final glTF Exportable Materials Setup (7 Materials total, budget <= 9)
# ---------------------------------------------------------------------------
def build_export_materials(baked):
    mats = {}

    def make_gltf_pbr_mat(name, imgs, factor=None):
        mat = bpy.data.materials.new(name)
        mat.use_nodes = True
        nt = mat.node_tree
        nodes = nt.nodes
        links = nt.links
        nodes.clear()

        out = nodes.new("ShaderNodeOutputMaterial")
        bsdf = nodes.new("ShaderNodeBsdfPrincipled")
        links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

        img_base, img_norm, img_orm = imgs
        t_base = nodes.new("ShaderNodeTexImage")
        t_base.image = img_base
        t_base.name = "BaseColorTexNode"

        if factor is not None:
            mix = nodes.new("ShaderNodeMixRGB")
            mix.name = "PaintFactorMix"
            mix.blend_type = 'MULTIPLY'
            mix.inputs["Fac"].default_value = 1.0
            mix.inputs["Color2"].default_value = (factor[0], factor[1], factor[2], 1.0)
            links.new(t_base.outputs["Color"], mix.inputs["Color1"])
            links.new(mix.outputs["Color"], bsdf.inputs["Base Color"])
            bsdf.inputs["Base Color"].default_value = (factor[0], factor[1], factor[2], 1.0)
        else:
            links.new(t_base.outputs["Color"], bsdf.inputs["Base Color"])

        t_orm = nodes.new("ShaderNodeTexImage")
        t_orm.image = img_orm
        sep = nodes.new("ShaderNodeSeparateColor")
        links.new(t_orm.outputs["Color"], sep.inputs["Color"])
        links.new(sep.outputs["Green"], bsdf.inputs["Roughness"])
        links.new(sep.outputs["Blue"], bsdf.inputs["Metallic"])

        gltf_group = bpy.data.node_groups.get("glTF Material Output")
        if gltf_group is None:
            gltf_group = bpy.data.node_groups.new("glTF Material Output", "ShaderNodeTree")
            gltf_group.interface.new_socket(name="Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')
        gnode = nodes.new("ShaderNodeGroup")
        gnode.node_tree = gltf_group
        links.new(sep.outputs["Red"], gnode.inputs["Occlusion"])

        t_norm = nodes.new("ShaderNodeTexImage")
        t_norm.image = img_norm
        nmap = nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.85
        links.new(t_norm.outputs["Color"], nmap.inputs["Color"])
        links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])

        return mat

    mats["Paint"]         = make_gltf_pbr_mat("Paint",         baked["Paint"],         factor=PAINTS[0]["color"])
    mats["BareMetal"]     = make_gltf_pbr_mat("BareMetal",     baked["BareMetal"],     factor=None)
    mats["ScorchedMetal"] = make_gltf_pbr_mat("ScorchedMetal", baked["ScorchedMetal"], factor=None)
    mats["WeaponArmor"]   = make_gltf_pbr_mat("WeaponArmor",   baked["WeaponArmor"],   factor=None)

    # Engines (Emissive)
    m_eng = bpy.data.materials.new("Engines")
    m_eng.use_nodes = True
    nt = m_eng.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    t_eng = nt.nodes.new("ShaderNodeTexImage")
    t_eng.image = baked["Engines_Emit"]
    nt.links.new(t_eng.outputs["Color"], bsdf.inputs["Base Color"])
    nt.links.new(t_eng.outputs["Color"], bsdf.inputs["Emission Color"])
    bsdf.inputs["Emission Strength"].default_value = 6.5
    bsdf.inputs["Roughness"].default_value = 0.15
    mats["Engines"] = m_eng

    # Lamps (Emissive cyan-blue capacitor banks, lit windows, running lights)
    m_lamp = bpy.data.materials.new("Lamps")
    m_lamp.use_nodes = True
    nt = m_lamp.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    t_lamp = nt.nodes.new("ShaderNodeTexImage")
    t_lamp.image = baked["Lamps_Emit"]
    nt.links.new(t_lamp.outputs["Color"], bsdf.inputs["Base Color"])
    nt.links.new(t_lamp.outputs["Color"], bsdf.inputs["Emission Color"])
    bsdf.inputs["Emission Strength"].default_value = 2.6
    bsdf.inputs["Roughness"].default_value = 0.10
    mats["Lamps"] = m_lamp

    # Glass (Bridge glazing, alpha BLEND, tinted cyan-blue)
    m_glass = bpy.data.materials.new("Glass")
    m_glass.use_nodes = True
    m_glass.surface_render_method = 'BLENDED'
    nt = m_glass.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    bsdf.inputs["Base Color"].default_value = (0.18, 0.58, 0.82, 1.0)
    bsdf.inputs["Metallic"].default_value = 0.15
    bsdf.inputs["Roughness"].default_value = 0.05
    bsdf.inputs["Alpha"].default_value = 0.45
    bsdf.inputs["Emission Color"].default_value = (0.10, 0.55, 0.92, 1.0)
    bsdf.inputs["Emission Strength"].default_value = 1.6
    mats["Glass"] = m_glass

    return mats


# ---------------------------------------------------------------------------
# Step 5: High-Detail Hard-Surface BMesh Primitives
# ---------------------------------------------------------------------------
MAT_IDX = {
    "Paint": 0,
    "BareMetal": 1,
    "ScorchedMetal": 2,
    "WeaponArmor": 3,
    "Engines": 4,
    "Lamps": 5,
    "Glass": 6,
}


def add_oct_loft(bm, z0, z1, wx0, hy0, chx0, chy0, wx1, hy1, chx1, chy1,
                 y_off0=0.0, y_off1=0.0, x_off0=0.0, x_off1=0.0,
                 mat_idx=0, cap_start=True, cap_end=True, bevel_width=0.45,
                 bevel_segs=2, segs_z=1):
    def profile_pts(z, wx, hy, chx, chy, xo, yo):
        return [
            g2b(xo + wx,       yo + hy - chy, z),
            g2b(xo + wx - chx, yo + hy,       z),
            g2b(xo - wx + chx, yo + hy,       z),
            g2b(xo - wx,       yo + hy - chy, z),
            g2b(xo - wx,       yo - hy + chy, z),
            g2b(xo - wx + chx, yo - hy,       z),
            g2b(xo + wx - chx, yo - hy,       z),
            g2b(xo + wx,       yo - hy + chy, z),
        ]

    rings = []
    for s in range(segs_z + 1):
        t = s / float(segs_z)
        z  = z0 + (z1 - z0) * t
        wx = wx0 + (wx1 - wx0) * t
        hy = hy0 + (hy1 - hy0) * t
        cx = chx0 + (chx1 - chx0) * t
        cy = chy0 + (chy1 - chy0) * t
        xo = x_off0 + (x_off1 - x_off0) * t
        yo = y_off0 + (y_off1 - y_off0) * t
        pts = profile_pts(z, wx, hy, cx, cy, xo, yo)
        rings.append([bm.verts.new(p) for p in pts])

    created_faces = []
    n = 8
    for s in range(segs_z):
        rA = rings[s]
        rB = rings[s + 1]
        for i in range(n):
            j = (i + 1) % n
            f = bm.faces.new((rA[i], rA[j], rB[j], rB[i]))
            f.material_index = mat_idx
            created_faces.append(f)

    if cap_start:
        f = bm.faces.new(list(reversed(rings[0])))
        f.material_index = mat_idx
        created_faces.append(f)
    if cap_end:
        f = bm.faces.new(rings[-1])
        f.material_index = mat_idx
        created_faces.append(f)

    bmesh.ops.recalc_face_normals(bm, faces=created_faces)

    if bevel_width > 0.0:
        edges = list({e for f in created_faces for e in f.edges})
        res = bmesh.ops.bevel(
            bm, geom=edges, offset=bevel_width, offset_type='OFFSET',
            segments=bevel_segs, profile=0.5, affect='EDGES'
        )
        for f in res.get("faces", []):
            f.material_index = mat_idx
            f.smooth = (bevel_segs > 1)
    return created_faces


def add_half_oct_collar(bm, side, z0, z1, x_inner, wx0, hy0, chx0, chy0, belt_y0,
                        wx1=None, hy1=None, chx1=None, chy1=None, belt_y1=None,
                        mat_idx=0, bevel_width=0.55, bevel_segs=3):
    if wx1 is None: wx1 = wx0
    if hy1 is None: hy1 = hy0
    if chx1 is None: chx1 = chx0
    if chy1 is None: chy1 = chy0
    if belt_y1 is None: belt_y1 = belt_y0

    sx = float(side)

    def make_ring(z, wx, hy, chx, belt_y):
        pts = [
            g2b(sx * x_inner,        hy,     z),
            g2b(sx * (wx - chx),     hy,     z),
            g2b(sx * wx,             belt_y, z),
            g2b(sx * wx,            -belt_y, z),
            g2b(sx * (wx - chx),    -hy,     z),
            g2b(sx * x_inner,       -hy,     z),
        ]
        if side < 0:
            pts.reverse()
        return [bm.verts.new(p) for p in pts]

    r0 = make_ring(z0, wx0, hy0, chx0, belt_y0)
    r1 = make_ring(z1, wx1, hy1, chx1, belt_y1)
    n = len(r0)
    faces = []
    for i in range(n):
        j = (i + 1) % n
        f = bm.faces.new((r0[i], r0[j], r1[j], r1[i]))
        f.material_index = mat_idx
        faces.append(f)
    f_start = bm.faces.new(list(reversed(r0)))
    f_start.material_index = mat_idx
    f_end = bm.faces.new(r1)
    f_end.material_index = mat_idx
    faces.extend([f_start, f_end])

    bmesh.ops.recalc_face_normals(bm, faces=faces)
    if bevel_width > 0.0:
        edges = list({e for f in faces for e in f.edges})
        res = bmesh.ops.bevel(
            bm, geom=edges, offset=bevel_width, offset_type='OFFSET',
            segments=bevel_segs, profile=0.5, affect='EDGES'
        )
        for f in res.get("faces", []):
            f.material_index = mat_idx
            f.smooth = (bevel_segs > 1)
    return faces


def add_jaw_octagon(bm, side, vdir, z0, z1, x_in, wx0, y_in0, hy0, chx0, chy0,
                    wx1, y_in1, hy1, chx1, chy1, mat_idx=0, bevel_width=0.50, bevel_segs=3):
    sx = float(side)
    vy = float(vdir)

    def make_ring(z, wx, y_in, hy, chx, chy):
        pts = [
            g2b(sx * x_in,           vy * y_in,         z),
            g2b(sx * (wx - chx*0.4), vy * y_in,         z),
            g2b(sx * wx,             vy * (y_in + 3.5), z),
            g2b(sx * wx,             vy * (hy - chy),   z),
            g2b(sx * (wx - chx),     vy * hy,           z),
            g2b(sx * x_in,           vy * hy,           z),
        ]
        if (side * vdir) < 0:
            pts.reverse()
        return [bm.verts.new(p) for p in pts]

    r0 = make_ring(z0, wx0, y_in0, hy0, chx0, chy0)
    r1 = make_ring(z1, wx1, y_in1, hy1, chx1, chy1)
    n = len(r0)
    faces = []
    for i in range(n):
        j = (i + 1) % n
        f = bm.faces.new((r0[i], r0[j], r1[j], r1[i]))
        f.material_index = mat_idx
        faces.append(f)
    faces.append(bm.faces.new(list(reversed(r0))))
    faces.append(bm.faces.new(r1))
    for f in faces[-2:]:
        f.material_index = mat_idx

    bmesh.ops.recalc_face_normals(bm, faces=faces)
    if bevel_width > 0.0:
        edges = list({e for f in faces for e in f.edges})
        res = bmesh.ops.bevel(
            bm, geom=edges, offset=bevel_width, offset_type='OFFSET',
            segments=bevel_segs, profile=0.5, affect='EDGES'
        )
        for f in res.get("faces", []):
            f.material_index = mat_idx
            f.smooth = (bevel_segs > 1)
    return faces


def add_beveled_box(bm, x0, x1, y0, y1, z0, z1, mat_idx=0, bevel=0.25, bevel_segs=2,
                    taper_top_x=0.0, taper_top_z=0.0, taper_front_y=0.0, taper_front_x=0.0):
    pts = [
        g2b(x0,                 y0, z0),
        g2b(x1,                 y0, z0),
        g2b(x1 - taper_front_x, y0 + taper_front_y, z1),
        g2b(x0 + taper_front_x, y0 + taper_front_y, z1),
        g2b(x0 + taper_top_x,                 y1, z0 + taper_top_z),
        g2b(x1 - taper_top_x,                 y1, z0 + taper_top_z),
        g2b(x1 - taper_top_x - taper_front_x, y1 - taper_front_y, z1 - taper_top_z),
        g2b(x0 + taper_top_x + taper_front_x, y1 - taper_front_y, z1 - taper_top_z),
    ]
    v = [bm.verts.new(p) for p in pts]
    quads = [
        (v[0], v[3], v[2], v[1]),
        (v[4], v[5], v[6], v[7]),
        (v[0], v[1], v[5], v[4]),
        (v[2], v[3], v[7], v[6]),
        (v[1], v[2], v[6], v[5]),
        (v[3], v[0], v[4], v[7]),
    ]
    faces = []
    for q in quads:
        f = bm.faces.new(q)
        f.material_index = mat_idx
        faces.append(f)
    bmesh.ops.recalc_face_normals(bm, faces=faces)

    if bevel > 0.0:
        edges = list({e for f in faces for e in f.edges})
        res = bmesh.ops.bevel(
            bm, geom=edges, offset=bevel, offset_type='OFFSET',
            segments=bevel_segs, profile=0.5, affect='EDGES'
        )
        for f in res.get("faces", []):
            f.material_index = mat_idx
            f.smooth = (bevel_segs > 1)
    return faces


def add_cylinder_axis(bm, p0_gltf, p1_gltf, r0, r1, segs=24, mat_idx=1,
                      cap0=True, cap1=True, smooth=True):
    p0 = g2b(*p0_gltf)
    p1 = g2b(*p1_gltf)
    axis = p1 - p0
    length = axis.length
    if length < 1e-5:
        return []
    d = axis.normalized()
    up = Vector((0, 0, 1)) if abs(d.z) < 0.9 else Vector((1, 0, 0))
    u = d.cross(up).normalized()
    v = d.cross(u).normalized()

    ring0 = []
    ring1 = []
    for i in range(segs):
        ang = 2.0 * math.pi * i / segs
        ca, sa = math.cos(ang), math.sin(ang)
        nvec = u * ca + v * sa
        ring0.append(bm.verts.new(p0 + nvec * r0))
        ring1.append(bm.verts.new(p1 + nvec * r1))

    faces = []
    for i in range(segs):
        j = (i + 1) % segs
        f = bm.faces.new((ring0[i], ring0[j], ring1[j], ring1[i]))
        f.material_index = mat_idx
        f.smooth = smooth
        faces.append(f)

    if cap0 and r0 > 1e-4:
        f0 = bm.faces.new(list(reversed(ring0)))
        f0.material_index = mat_idx
        f0.smooth = False
        faces.append(f0)
    if cap1 and r1 > 1e-4:
        f1 = bm.faces.new(ring1)
        f1.material_index = mat_idx
        f1.smooth = False
        faces.append(f1)

    bmesh.ops.recalc_face_normals(bm, faces=faces)
    return faces


# ---------------------------------------------------------------------------
# Step 6: Build the Fixed Ship Hull (1100.0 m x 160.0 m x 140.0 m)
# Exact Bounding Box in glTF Y-up:
#   X: [-80.0,  +80.0] -> 160.0 m wide
#   Y: [-45.0,  +95.0] -> 140.0 m high (Ventral Keel -45.0m to Mast Tip +95.0m)
#   Z: [-550.0, +550.0] -> 1100.0 m long (Engine Bell Exit -550.0m to Bow Tip +550.0m)
# ---------------------------------------------------------------------------
def build_hull_mesh(mats):
    bm = bmesh.new()

    M_PAINT  = MAT_IDX["Paint"]
    M_BARE   = MAT_IDX["BareMetal"]
    M_SCORCH = MAT_IDX["ScorchedMetal"]
    M_WEAPON = MAT_IDX["WeaponArmor"]
    M_ENG    = MAT_IDX["Engines"]
    M_LAMP   = MAT_IDX["Lamps"]
    M_GLASS  = MAT_IDX["Glass"]

    # =========================================================================
    # 1. BOW RAILGUN APERTURE & TWIN SPINAL JAWS (Z = +410.0 to +550.0)
    # =========================================================================
    add_oct_loft(bm, 265.0, 458.0, 36.5, 34.5, 12.0, 12.0, 34.5, 29.0, 10.0, 10.0,
                 mat_idx=M_SCORCH, bevel_width=0.45, bevel_segs=2, segs_z=3)

    # Deep recessed inner bore side-shroud inside the bow aperture (Z = +452 to +522)
    # Creates the deep dark octagonal chamber seen in the 3/4 Hero View & Front View!
    for side in (-1, 1):
        sx = float(side)
        bx0 = 21.5 if side > 0 else -25.5
        bx1 = 25.5 if side > 0 else -21.5
        add_beveled_box(bm, bx0, bx1, -14.5, 14.5, 452.0, 518.0,
                        mat_idx=M_SCORCH, bevel=0.35, bevel_segs=2, taper_front_y=2.0)
        for z_rib in range(458, 516, 8):
            add_beveled_box(bm, bx0 - 0.6 * sx, bx1 - 0.2 * sx, -13.0, 13.0,
                            z_rib - 1.2, z_rib + 1.2, mat_idx=M_BARE, bevel=0.15, bevel_segs=1)

    for side in (-1, 1):
        sx = float(side)
        # Base jaw transition collar (Z = +410 to +456)
        add_half_oct_collar(
            bm, side, 410.0, 456.0, x_inner=5.0,
            wx0=41.5, hy0=39.5, chx0=13.5, chy0=13.5, belt_y0=15.5,
            wx1=39.0, hy1=37.0, chx1=12.5, chy1=12.5, belt_y1=14.5,
            mat_idx=M_PAINT, bevel_width=0.65, bevel_segs=3
        )

        for vdir in (-1, 1):
            vy = float(vdir)
            # Inner C-jaw angled gusset at the back of the mouth (Z = +450 to +476)
            add_jaw_octagon(
                bm, side, vdir, 450.0, 476.0, x_in=5.0,
                wx0=39.0, y_in0=10.5, hy0=37.0, chx0=12.5, chy0=12.5,
                wx1=37.5, y_in1=15.0, hy1=35.5, chx1=11.8, chy1=11.8,
                mat_idx=M_PAINT, bevel_width=0.55, bevel_segs=3
            )
            # Main Forward Jaw Prong (Z = +474 to +530)
            add_jaw_octagon(
                bm, side, vdir, 474.0, 530.0, x_in=5.0,
                wx0=37.5, y_in0=15.0, hy0=35.5, chx0=11.8, chy0=11.8,
                wx1=34.5, y_in1=16.2, hy1=32.0, chx1=10.5, chy1=10.5,
                mat_idx=M_PAINT, bevel_width=0.60, bevel_segs=3
            )
            # Forward Nose Tip & Muzzle Tooth (Z = +528 to +546.0)
            # Notice how y_in steps inward from 16.2 to 11.8 to form the iconic notched jaw tooth!
            add_jaw_octagon(
                bm, side, vdir, 528.0, 546.0, x_in=5.2,
                wx0=34.5, y_in0=16.2, hy0=32.0, chx0=10.5, chy0=10.5,
                wx1=31.5, y_in1=11.8, hy1=28.5, chx1=9.5,  chy1=9.5,
                mat_idx=M_PAINT, bevel_width=0.50, bevel_segs=3
            )
            # Heavy Heat-Scorched Muzzle Emitter Shoe at the exact tip Z = +544.5 to +550.0
            add_jaw_octagon(
                bm, side, vdir, 544.5, 550.0, x_in=5.5,
                wx0=31.0, y_in0=11.8, hy0=28.0, chx0=9.0, chy0=9.0,
                wx1=28.0, y_in1=13.0, hy1=25.5, chx1=8.0, chy1=8.0,
                mat_idx=M_SCORCH, bevel_width=0.40, bevel_segs=2
            )

            # Inner Electromagnetic Rail Track & Ribbed Accelerator Shoes along the Jaw Mouth
            ry0 = 11.2 if vdir > 0 else -15.2
            ry1 = 15.2 if vdir > 0 else -11.2
            rx0 = 5.5 if side > 0 else -27.5
            rx1 = 27.5 if side > 0 else -5.5
            add_beveled_box(bm, rx0, rx1, ry0, ry1, 452.0, 546.0,
                            mat_idx=M_SCORCH, bevel=0.30, bevel_segs=2)

            # 28 Ribbed Electromagnetic Accelerator Teeth lining each jaw inner surface
            for k in range(28):
                zk = 454.0 + k * 3.3
                ty0 = 10.0 if vdir > 0 else -13.6
                ty1 = 13.6 if vdir > 0 else -10.0
                tx0 = 6.2 if side > 0 else -24.5
                tx1 = 24.5 if side > 0 else -6.2
                add_beveled_box(bm, tx0, tx1, ty0, ty1, zk, zk + 1.9,
                                mat_idx=M_BARE if k % 2 == 0 else M_SCORCH,
                                bevel=0.18, bevel_segs=2)

    # =========================================================================
    # 2. FORWARD HEAVY ARMOR SHROUD (Z = +265 to +410) & NECK (Z = +195 to +265)
    # Flush dorsal profile; clean spacecraft armor panels on the sides (no dark squares!)
    # =========================================================================
    for (z0, z1) in [(339.0, 409.0), (266.0, 336.0)]:
        for side in (-1, 1):
            sx = float(side)
            add_half_oct_collar(
                bm, side, z0, z1, x_inner=5.0,
                wx0=43.5, hy0=41.5, chx0=14.5, chy0=14.5, belt_y0=16.0,
                mat_idx=M_PAINT, bevel_width=0.75, bevel_segs=3
            )
            # Clean flush-framed armor belt panels in Paint with subtle thin seam frames
            for zm in (z0 + 18.0, z0 + 52.0):
                hx0 = 42.8 if side > 0 else -44.2
                hx1 = 44.2 if side > 0 else -42.8
                add_beveled_box(bm, hx0, hx1, -12.5, 12.5, zm - 13.5, zm + 13.5,
                                mat_idx=M_PAINT, bevel=0.40, bevel_segs=2)
                add_beveled_box(bm, hx0 + 0.2 * sx, hx1 + 0.15 * sx, -6.5, 6.5, zm - 7.5, zm + 7.5,
                                mat_idx=M_PAINT, bevel=0.25, bevel_segs=2)

    # Structural Neck Section (Z = +196 to +264)
    add_oct_loft(bm, 194.0, 266.0, 37.5, 35.5, 11.5, 11.5,
                 37.5, 35.5, 11.5, 11.5, mat_idx=M_SCORCH, bevel_width=0.40, bevel_segs=2)
    for (z0, z1) in [(197.5, 228.5), (232.5, 263.5)]:
        for side in (-1, 1):
            sx = float(side)
            add_half_oct_collar(
                bm, side, z0, z1, x_inner=5.0,
                wx0=39.5, hy0=38.0, chx0=12.5, chy0=12.5, belt_y0=14.0,
                mat_idx=M_PAINT, bevel_width=0.60, bevel_segs=3
            )
            nx0 = 38.5 if side > 0 else -40.0
            nx1 = 40.0 if side > 0 else -38.5
            add_beveled_box(bm, nx0, nx1, -9.0, 9.0, z0 + 3.5, z1 - 3.5,
                            mat_idx=M_BARE, bevel=0.25, bevel_segs=2)
            for y_cond in (-5.0, -1.7, 1.7, 5.0):
                add_cylinder_axis(bm, (sx * 39.8, y_cond, z0 + 2.0), (sx * 39.8, y_cond, z1 - 2.0),
                                  0.65, 0.65, segs=12, mat_idx=M_SCORCH)

    # =========================================================================
    # 3. CAPACITOR BANK COLLAR (Z = -35.0 to +195.0, 5 Massive Octagonal Armor Bays)
    # Full wrap-around Paint octagonal rings with framed side pockets and 8 distinct
    # glowing cyan-blue plasma capacitor cells (2 rows x 4 columns) per bay!
    # =========================================================================
    add_oct_loft(bm, -36.0, 196.0, 52.0, 40.5, 15.0, 15.0,
                 52.0, 40.5, 15.0, 15.0, mat_idx=M_SCORCH, bevel_width=0.45, bevel_segs=2, segs_z=5)

    cap_bay_bounds = [
        (-34.0,   9.5),
        ( 12.0,  55.5),
        ( 58.0, 101.5),
        (104.0, 147.5),
        (150.0, 193.5),
    ]
    for bay_idx, (z0, z1) in enumerate(cap_bay_bounds):
        for side in (-1, 1):
            sx = float(side)
            x_in = 5.0
            wx = 56.5
            hy = 43.5
            chx = 15.0
            sh_y = 26.5     # 45-deg chamfer shoulder ends at Y = ±26.5
            belt_y = 10.0   # Vertical Paint armor wall continues down to Y = ±10.0!

            def make_upper_cap_ring(z):
                pts = [
                    g2b(sx * x_in,       hy,     z),
                    g2b(sx * (wx - chx), hy,     z),
                    g2b(sx * wx,         sh_y,   z),
                    g2b(sx * wx,         belt_y, z),
                    g2b(sx * (wx - 5.5), belt_y, z),
                    g2b(sx * x_in,       belt_y, z),
                ]
                if side < 0:
                    pts.reverse()
                return [bm.verts.new(p) for p in pts]

            ru0, ru1 = make_upper_cap_ring(z0), make_upper_cap_ring(z1)
            f_up = []
            for i in range(6):
                j = (i + 1) % 6
                f = bm.faces.new((ru0[i], ru0[j], ru1[j], ru1[i]))
                f.material_index = M_PAINT
                f_up.append(f)
            f_up.append(bm.faces.new(list(reversed(ru0))))
            f_up.append(bm.faces.new(ru1))
            f_up[-2].material_index = M_PAINT
            f_up[-1].material_index = M_PAINT
            bmesh.ops.recalc_face_normals(bm, faces=f_up)
            edges_u = list({e for f in f_up for e in f.edges})
            res_u = bmesh.ops.bevel(bm, geom=edges_u, offset=0.65, offset_type='OFFSET',
                                    segments=3, profile=0.5, affect='EDGES')
            for f in res_u.get("faces", []):
                f.material_index = M_PAINT
                f.smooth = True

            def make_lower_cap_ring(z):
                pts = [
                    g2b(sx * x_in,       -belt_y, z),
                    g2b(sx * (wx - 5.5), -belt_y, z),
                    g2b(sx * wx,         -belt_y, z),
                    g2b(sx * wx,         -sh_y,   z),
                    g2b(sx * (wx - chx), -hy,     z),
                    g2b(sx * x_in,       -hy,     z),
                ]
                if side < 0:
                    pts.reverse()
                return [bm.verts.new(p) for p in pts]

            rl0, rl1 = make_lower_cap_ring(z0), make_lower_cap_ring(z1)
            f_lo = []
            for i in range(6):
                j = (i + 1) % 6
                f = bm.faces.new((rl0[i], rl0[j], rl1[j], rl1[i]))
                f.material_index = M_PAINT
                f_lo.append(f)
            f_lo.append(bm.faces.new(list(reversed(rl0))))
            f_lo.append(bm.faces.new(rl1))
            f_lo[-2].material_index = M_PAINT
            f_lo[-1].material_index = M_PAINT
            bmesh.ops.recalc_face_normals(bm, faces=f_lo)
            edges_l = list({e for f in f_lo for e in f.edges})
            res_l = bmesh.ops.bevel(bm, geom=edges_l, offset=0.65, offset_type='OFFSET',
                                    segments=3, profile=0.5, affect='EDGES')
            for f in res_l.get("faces", []):
                f.material_index = M_PAINT
                f.smooth = True

            # Full-height Paint side-wall end pillars at z0 and z1 so each of the 5 segments
            # forms a closed octagonal armor ring around its recessed capacitor pocket!
            z_mid = 0.5 * (z0 + z1)
            for zp in (z0 + 2.2, z1 - 2.2):
                vx0 = 51.0 if side > 0 else -56.5
                vx1 = 56.5 if side > 0 else -51.0
                add_beveled_box(bm, vx0, vx1, -11.5, 11.5, zp - 2.2, zp + 2.2,
                                mat_idx=M_PAINT, bevel=0.45, bevel_segs=2)

            # Dark ScorchedMetal recessed backplate inside the capacitor pocket
            bx_b0 = 50.8 if side > 0 else -52.8
            bx_b1 = 52.8 if side > 0 else -50.8
            add_beveled_box(bm, bx_b0, bx_b1, -10.2, 10.2, z0 + 4.0, z1 - 4.0,
                            mat_idx=M_SCORCH, bevel=0.20, bevel_segs=1)

            # Central BareMetal divider pillar separating the 2 pairs in each bay
            vx0 = 51.5 if side > 0 else -56.2
            vx1 = 56.2 if side > 0 else -51.5
            add_beveled_box(bm, vx0, vx1, -10.5, 10.5, z_mid - 1.5, z_mid + 1.5,
                            mat_idx=M_BARE, bevel=0.25, bevel_segs=2)

            # Horizontal BareMetal central clamping busbar across Y = -0.85..+0.85
            # Separates each vertical column into Upper & Lower glowing blue capacitor cells!
            hx_b0 = 52.2 if side > 0 else -56.0
            hx_b1 = 56.0 if side > 0 else -52.2
            add_beveled_box(bm, hx_b0, hx_b1, -0.85, 0.85, z0 + 4.2, z1 - 4.2,
                            mat_idx=M_BARE, bevel=0.18, bevel_segs=2)

            # 2 Sub-bays per Armor Block -> 4 columns x 2 rows = 8 glowing blue cells per bay
            for sub in (0, 1):
                zs0 = (z0 + 4.8) if sub == 0 else (z_mid + 1.8)
                zs1 = (z_mid - 1.8) if sub == 0 else (z1 - 4.8)
                z_sub_mid = 0.5 * (zs0 + zs1)

                # Vertical BareMetal rib between the 2 columns of each sub-bay
                rx_b0 = 52.0 if side > 0 else -55.8
                rx_b1 = 55.8 if side > 0 else -52.0
                add_beveled_box(bm, rx_b0, rx_b1, -10.0, 10.0, z_sub_mid - 0.65, z_sub_mid + 0.65,
                                mat_idx=M_BARE, bevel=0.15, bevel_segs=1)

                for col_z in (0.5 * (zs0 + z_sub_mid - 0.65), 0.5 * (z_sub_mid + 0.65 + zs1)):
                    # Upper and Lower glowing cyan-blue plasma capacitor cells
                    for (cy0, cy1) in [(1.05, 8.55), (-8.55, -1.05)]:
                        add_cylinder_axis(bm, (sx * 53.8, cy0, col_z), (sx * 53.8, cy1, col_z),
                                          2.65, 2.65, segs=24, mat_idx=M_LAMP)
                    # Metallic end-caps at top, center, and bottom
                    for y_cap in (-8.85, 0.0, 8.85):
                        add_cylinder_axis(bm, (sx * 53.8, y_cap - 0.40, col_z),
                                          (sx * 53.8, y_cap + 0.40, col_z),
                                          2.85, 2.85, segs=20, mat_idx=M_BARE)

                # Thin protective horizontal guard rods at Y = ±4.8
                for y_bar in (-4.8, 4.8):
                    add_cylinder_axis(bm, (sx * 56.2, y_bar, zs0 - 0.3),
                                      (sx * 56.2, y_bar, zs1 + 0.3),
                                      0.28, 0.28, segs=12, mat_idx=M_BARE)

    # =========================================================================
    # 4. TWIN SPINAL RAILGUN DORSAL & VENTRAL RAILS (Z = -135 to +410)
    # Recessed guide rails inside the central slot (never sticking above forward shroud!)
    # =========================================================================
    for side in (-1, 1):
        sx = float(side)
        rx_in  = 4.8 if side > 0 else -23.5
        rx_out = 23.5 if side > 0 else -4.8
        # Raised Dorsal Twin Railgun Housing along the Capacitor Collar (Z = -35 to +195)
        add_beveled_box(bm, rx_in, rx_out, 42.5, 47.2, -35.0, 195.0,
                        mat_idx=M_PAINT, bevel=0.55, bevel_segs=2,
                        taper_top_x=2.5, taper_front_y=1.5)
        # Sloped Forward Dorsal Spine Cowling ahead of Turret_1 (Z = -135 to -35)
        add_beveled_box(bm, rx_in, rx_out, 39.0, 46.8, -135.0, -35.0,
                        mat_idx=M_PAINT, bevel=0.55, bevel_segs=2, taper_top_x=3.0)

        # Exposed metallic electromagnetic guide rail recessed INSIDE the central spinal slot
        gx_in  = 3.6 if side > 0 else -7.0
        gx_out = 7.0 if side > 0 else -3.6
        add_beveled_box(bm, gx_in, gx_out, 31.0, 38.2, -120.0, 450.0,
                        mat_idx=M_BARE, bevel=0.25, bevel_segs=2)

        # Ventral Twin Rail Keel Beams (flush with bottom of Capacitor Collar)
        add_beveled_box(bm, rx_in, rx_out, -44.2, -39.0, -35.0, 195.0,
                        mat_idx=M_PAINT, bevel=0.50, bevel_segs=2)

    # 48 Recessed Transverse Electromagnetic Clamp Bridges INSIDE the spinal slot
    for z_clamp in range(-110, 445, 12):
        add_beveled_box(bm, -6.5, 6.5, 32.0, 36.8, z_clamp - 1.5, z_clamp + 1.5,
                        mat_idx=M_SCORCH, bevel=0.20, bevel_segs=2)
        add_beveled_box(bm, -6.5, 6.5, -36.8, -32.0, z_clamp - 1.5, z_clamp + 1.5,
                        mat_idx=M_SCORCH, bevel=0.20, bevel_segs=2)

    # =========================================================================
    # 5. MID-AFT MAIN COMBAT HULL & BROADSIDE SPONSON DECKS (Z = -290 to -35)
    # =========================================================================
    add_oct_loft(bm, -295.0, -35.0, 50.0, 39.5, 13.0, 13.0,
                 52.0, 41.5, 14.0, 14.0, mat_idx=M_PAINT, bevel_width=0.70, bevel_segs=3, segs_z=4)

    add_beveled_box(bm, -34.0, 34.0, 38.0, 47.0, -175.0, -35.0,
                    mat_idx=M_PAINT, bevel=0.70, bevel_segs=3, taper_top_x=6.0, taper_front_x=5.0)

    for side in (-1, 1):
        sx = float(side)
        ux0 = 42.0 if side > 0 else -66.5
        ux1 = 66.5 if side > 0 else -42.0

        # Upper Broadside Sponson Deck (Y = +9.0 to +24.2, Z = -275 to -40)
        add_beveled_box(bm, ux0, ux1, 9.0, 24.2, -275.0, -40.0,
                        mat_idx=M_PAINT, bevel=0.75, bevel_segs=3,
                        taper_top_x=3.0, taper_front_x=4.5)

        # Lower Broadside Sponson Deck (Y = -24.2 to -9.0, Z = -275 to -40)
        add_beveled_box(bm, ux0, ux1, -24.2, -9.0, -275.0, -40.0,
                        mat_idx=M_PAINT, bevel=0.75, bevel_segs=3,
                        taper_top_x=3.0, taper_front_x=4.5)

        # Recessed Mid-Hull Industrial Trench Backplate (Y = -9.5 to +9.5, X = ±53)
        tx0 = 45.0 if side > 0 else -54.5
        tx1 = 54.5 if side > 0 else -45.0
        add_beveled_box(bm, tx0, tx1, -9.5, 9.5, -272.0, -42.0,
                        mat_idx=M_SCORCH, bevel=0.30, bevel_segs=2)

        # 8 Longitudinal Coolant & Power Pipelines inside the Mid-Hull Trench
        for y_pipe in (-7.0, -5.0, -3.0, -1.0, 1.0, 3.0, 5.0, 7.0):
            add_cylinder_axis(bm, (sx * 55.4, y_pipe, -270.0), (sx * 55.4, y_pipe, -44.0),
                              0.68, 0.68, segs=14, mat_idx=M_BARE)

        # 28 Vertical Structural Stanchion Ribs & Radiator Louver Blocks in the Trench
        for z_rib in range(-266, -42, 8):
            rx0 = 52.5 if side > 0 else -58.4
            rx1 = 58.4 if side > 0 else -52.5
            add_beveled_box(bm, rx0, rx1, -9.2, 9.2, z_rib - 1.1, z_rib + 1.1,
                            mat_idx=M_BARE if (z_rib // 8) % 2 == 0 else M_SCORCH,
                            bevel=0.18, bevel_segs=2)

        # Sleek Metallic Armor Rubbing Strakes along the Sponson Flanks (replaces neon blue bars!)
        for z_strake in (-248.0, -145.0, -52.0):
            wx0 = 64.8 if side > 0 else -66.8
            wx1 = 66.8 if side > 0 else -64.8
            add_beveled_box(bm, wx0, wx1, 15.2, 17.5, z_strake - 12.0, z_strake + 12.0,
                            mat_idx=M_BARE, bevel=0.15, bevel_segs=2)
            add_beveled_box(bm, wx0, wx1, -17.5, -15.2, z_strake - 12.0, z_strake + 12.0,
                            mat_idx=M_BARE, bevel=0.15, bevel_segs=2)

        # Cantilevered Octagonal Turret Sponson Casemates at Z = -95 and Z = -195
        for z_tur in (-95.0, -195.0):
            bx0 = 48.0 if side > 0 else -71.5
            bx1 = 71.5 if side > 0 else -48.0
            add_beveled_box(bm, bx0, bx1, 9.5, 25.0, z_tur - 24.0, z_tur + 24.0,
                            mat_idx=M_PAINT, bevel=0.65, bevel_segs=3, taper_front_x=4.5)
            add_beveled_box(bm, bx0, bx1, -25.0, -9.5, z_tur - 24.0, z_tur + 24.0,
                            mat_idx=M_PAINT, bevel=0.65, bevel_segs=3, taper_front_x=4.5)

    # =========================================================================
    # 6. COMMAND TOWER SUPERSTRUCTURE (Z = -345 to -155, Y = +40 to +95.0 m)
    # =========================================================================
    # Tier 1: Citadel Base (Z = -345 to -155, Y = +39 to +55.5, X = -35 to +35)
    add_beveled_box(bm, -35.0, 35.0, 39.0, 55.5, -345.0, -155.0,
                    mat_idx=M_PAINT, bevel=0.75, bevel_segs=3,
                    taper_top_x=5.0, taper_top_z=10.0, taper_front_x=4.0)

    for side in (-1, 1):
        wx0 = 24.0 if side > 0 else -41.0
        wx1 = 41.0 if side > 0 else -24.0
        add_beveled_box(bm, wx0, wx1, 38.0, 48.5, -248.0, -172.0,
                        mat_idx=M_PAINT, bevel=0.55, bevel_segs=3, taper_front_x=2.5)
        for zb in (-325.0, -302.0, -279.0, -256.0):
            rx0 = 30.5 if side > 0 else -35.2
            rx1 = 35.2 if side > 0 else -30.5
            add_beveled_box(bm, rx0, rx1, 40.0, 54.5, zb - 2.8, zb + 2.8,
                            mat_idx=M_PAINT, bevel=0.30, bevel_segs=2, taper_top_x=1.2)

    # Tier 2: Steep Conning Tower Neck (Z = -295 to -172, Y = +55 to +67.5)
    add_beveled_box(bm, -26.5, 26.5, 55.0, 67.5, -295.0, -172.0,
                    mat_idx=M_PAINT, bevel=0.65, bevel_segs=3,
                    taper_top_x=3.5, taper_top_z=6.0, taper_front_x=3.0)
    # Stepped Aft Funnel / Defense Deck behind the Bridge Tower (Z = -338 to -290, Y = +55 to +68.0)
    add_beveled_box(bm, -21.0, 21.0, 55.0, 68.0, -338.0, -290.0,
                    mat_idx=M_PAINT, bevel=0.55, bevel_segs=3, taper_top_x=3.0, taper_top_z=4.0)

    # Forward sloped glacis ahead of the bridge (Z = -176 to -152)
    add_beveled_box(bm, -18.5, 18.5, 54.5, 63.0, -176.0, -152.0,
                    mat_idx=M_BARE, bevel=0.45, bevel_segs=2, taper_front_x=4.0, taper_front_y=2.5)

    # Tier 3: Armored Command Bridge Lower Collar & Aft Bunker (Z = -282 to -184, Y = +67 to +72.5)
    add_beveled_box(bm, -23.0, 23.0, 67.0, 72.5, -282.0, -184.0,
                    mat_idx=M_PAINT, bevel=0.55, bevel_segs=3, taper_front_x=3.5)
    add_beveled_box(bm, -20.0, 20.0, 72.0, 78.5, -280.0, -226.0,
                    mat_idx=M_PAINT, bevel=0.50, bevel_segs=3, taper_top_x=2.2)

    # VISIBLE BRIDGE INTERIOR behind the Glass (Z = -226 to -188, Y = +72.0 to +77.8, X = -17 to +17)
    add_beveled_box(bm, -16.8, 16.8, 71.8, 73.0, -226.0, -188.0,
                    mat_idx=M_BARE, bevel=0.15, bevel_segs=1)
    add_beveled_box(bm, -6.0, 6.0, 73.0, 75.0, -214.0, -200.0,
                    mat_idx=M_LAMP, bevel=0.15, bevel_segs=1)
    for cx in (-11.5, -5.5, 0.0, 5.5, 11.5):
        add_beveled_box(bm, cx - 1.8, cx + 1.8, 73.0, 74.8, -196.0, -192.0,
                        mat_idx=M_BARE, bevel=0.10, bevel_segs=1)
        add_beveled_box(bm, cx - 1.5, cx + 1.5, 74.5, 75.1, -195.5, -192.5,
                        mat_idx=M_LAMP, bevel=0.05, bevel_segs=1)

    # Wrap-Around Bridge Glazing ("Glass" material, alpha BLEND) + Structural Steel Mullions
    add_beveled_box(bm, -17.0, 17.0, 72.5, 77.5, -189.5, -187.0,
                    mat_idx=M_GLASS, bevel=0.08, bevel_segs=1, taper_top_x=1.2)
    for side in (-1, 1):
        gx0 = 16.8 if side > 0 else -18.8
        gx1 = 18.8 if side > 0 else -16.8
        add_beveled_box(bm, gx0, gx1, 72.5, 77.5, -226.0, -188.0,
                        mat_idx=M_GLASS, bevel=0.08, bevel_segs=1)
    for mx in (-17.2, -10.5, -3.5, 3.5, 10.5, 17.2):
        add_beveled_box(bm, mx - 0.45, mx + 0.45, 72.2, 77.8, -189.8, -186.6,
                        mat_idx=M_BARE, bevel=0.08, bevel_segs=1)

    # Bridge Roof Brow & Overhang Visor (Y = +77.2 to +81.2, Z = -278 to -180)
    add_beveled_box(bm, -21.5, 21.5, 77.2, 81.2, -278.0, -180.0,
                    mat_idx=M_PAINT, bevel=0.50, bevel_segs=3, taper_top_x=2.8, taper_front_x=4.0)

    # Forward Bridge Roof Point-Defense Gun Cupola (visible on brow in Side View at Z = -194)
    add_cylinder_axis(bm, (0.0, 81.0, -194.0), (0.0, 84.5, -194.0), 4.8, 3.8, segs=24, mat_idx=M_WEAPON)
    add_cylinder_axis(bm, (-1.4, 83.0, -194.0), (-1.4, 83.0, -176.0), 0.55, 0.45, segs=14, mat_idx=M_BARE)
    add_cylinder_axis(bm, ( 1.4, 83.0, -194.0), ( 1.4, 83.0, -176.0), 0.55, 0.45, segs=14, mat_idx=M_BARE)

    # Tier 4: Tall Main Radar & Fire-Control Tower Mast (Z = -266 to -228, Y = +81.0 to +95.0 m)
    add_beveled_box(bm, -11.0, 11.0, 81.0, 88.0, -264.0, -228.0,
                    mat_idx=M_PAINT, bevel=0.45, bevel_segs=3, taper_top_x=2.2, taper_top_z=2.5)
    add_beveled_box(bm, -7.5, 7.5, 88.0, 92.5, -254.0, -234.0,
                    mat_idx=M_BARE, bevel=0.35, bevel_segs=2)
    add_beveled_box(bm, -5.0, 5.0, 92.0, 94.0, -250.0, -238.0,
                    mat_idx=M_SCORCH, bevel=0.25, bevel_segs=2)
    add_beveled_box(bm, -21.0, 21.0, 86.5, 88.0, -246.5, -243.0,
                    mat_idx=M_BARE, bevel=0.20, bevel_segs=2)
    # Whip communication antennas reaching exact Y = +95.0 m (giving 140.0 m total height!)
    add_cylinder_axis(bm, (0.0, 93.5, -244.0), (0.0, 95.0, -244.0), 0.70, 0.25, segs=14, mat_idx=M_BARE)
    for ax in (-18.0, -12.0, 12.0, 18.0):
        add_cylinder_axis(bm, (ax, 87.5, -244.8), (ax, 94.6, -244.8), 0.45, 0.18, segs=12, mat_idx=M_BARE)
    add_cylinder_axis(bm, (0.0, 81.0, -214.0), (0.0, 92.5, -214.0), 0.55, 0.20, segs=12, mat_idx=M_BARE)
    add_cylinder_axis(bm, (0.0, 73.5, -276.0), (0.0, 91.0, -276.0), 0.55, 0.20, segs=12, mat_idx=M_BARE)

    # =========================================================================
    # 7. AFT OUTRIGGER ENGINE SPONSONS (Z = -450 to -275, X = -80.0 to +80.0 m)
    #    & VENTRAL KEEL SKEG (Y = -45.0 m)
    # =========================================================================
    add_oct_loft(bm, -464.0, -290.0, 48.0, 36.0, 12.0, 11.0,
                 50.0, 39.5, 13.0, 12.0, mat_idx=M_PAINT, bevel_width=0.70, bevel_segs=3, segs_z=3)

    add_beveled_box(bm, -30.0, 30.0, 35.0, 43.0, -440.0, -340.0,
                    mat_idx=M_PAINT, bevel=0.60, bevel_segs=3, taper_top_x=4.5)

    # Heavy Ventral Keel Skeg under the aft hull reaching exact Y = -45.0 m!
    add_beveled_box(bm, -26.0, 26.0, -45.0, -34.0, -445.0, -285.0,
                    mat_idx=M_PAINT, bevel=0.70, bevel_segs=3, taper_front_x=5.0, taper_front_y=3.5)
    add_beveled_box(bm, -18.0, 18.0, -44.6, -35.0, -458.0, -435.0,
                    mat_idx=M_SCORCH, bevel=0.45, bevel_segs=2)

    for side in (-1, 1):
        sx = float(side)
        add_half_oct_collar(
            bm, side, -438.0, -280.0, x_inner=44.0,
            wx0=80.0, hy0=24.0, chx0=10.0, chy0=8.0, belt_y0=16.0,
            wx1=78.5, hy1=23.0, chx1=10.0, chy1=8.0, belt_y1=15.0,
            mat_idx=M_PAINT, bevel_width=0.75, bevel_segs=3
        )

        # Forward Vertical Intake Mouth & Louver Grille at Z = -280
        ix_in  = 54.0 if side > 0 else -76.5
        ix_out = 76.5 if side > 0 else -54.0
        add_beveled_box(bm, ix_in, ix_out, -19.0, 17.0, -283.5, -276.0,
                        mat_idx=M_SCORCH, bevel=0.35, bevel_segs=2)
        for y_louv in range(-16, 16, 3):
            add_beveled_box(bm, ix_in + 1.5, ix_out - 1.5, y_louv - 0.5, y_louv + 0.5,
                            -281.5, -275.5, mat_idx=M_BARE, bevel=0.10, bevel_segs=1)

        # Recessed Flank Armor Panel Frame on the outer sponson wall
        px0 = 78.2 if side > 0 else -79.9
        px1 = 79.9 if side > 0 else -78.2
        add_beveled_box(bm, px0, px1, -13.5, 13.5, -422.0, -298.0,
                        mat_idx=M_PAINT, bevel=0.35, bevel_segs=2)
        for z_seam in (-382.0, -340.0):
            add_beveled_box(bm, px0 - 0.2 * sx, px1 + 0.05 * sx, -12.5, 12.5, z_seam - 1.0, z_seam + 1.0,
                            mat_idx=M_SCORCH, bevel=0.12, bevel_segs=1)

        # Upper Sponson Cooling & Sensor Pod (Z = -418 to -320, Y = +23.5 to +30.5)
        ux0 = 48.0 if side > 0 else -72.0
        ux1 = 72.0 if side > 0 else -48.0
        add_beveled_box(bm, ux0, ux1, 23.5, 30.5, -418.0, -320.0,
                        mat_idx=M_PAINT, bevel=0.50, bevel_segs=3, taper_top_x=3.0)

        # Rear Auxiliary Thruster Pod (X = ±48..±74, Z = -462 to -434)
        ax0 = 48.0 if side > 0 else -73.5
        ax1 = 73.5 if side > 0 else -48.0
        add_beveled_box(bm, ax0, ax1, -21.0, 19.0, -458.0, -434.0,
                        mat_idx=M_SCORCH, bevel=0.55, bevel_segs=3, taper_front_x=2.5)

        for y_aux in (10.5, -1.0, -12.5):
            add_cylinder_axis(bm, (sx * 61.0, y_aux, -454.0), (sx * 61.0, y_aux, -468.0),
                              5.0, 5.8, segs=28, mat_idx=M_SCORCH, cap0=True, cap1=False)
            add_cylinder_axis(bm, (sx * 61.0, y_aux, -467.5), (sx * 61.0, y_aux, -456.0),
                              5.3, 3.8, segs=28, mat_idx=M_ENG, cap0=False, cap1=True)

    # =========================================================================
    # 8. MAIN ENGINE CLUSTER (Z = -550.0 to -450.0, 4 Huge Nozzles in 2x2 Quad)
    # =========================================================================
    add_oct_loft(bm, -468.0, -454.0, 46.0, 36.5, 11.0, 10.0,
                 48.0, 37.5, 11.0, 10.0, mat_idx=M_SCORCH, bevel_width=0.50, bevel_segs=2)

    engine_centers = [
        ( 25.5,  19.0),  # Thruster_1: Upper Port
        (-25.5,  19.0),  # Thruster_2: Upper Starboard
        ( 25.5, -19.0),  # Thruster_3: Lower Port
        (-25.5, -19.0),  # Thruster_4: Lower Starboard
    ]
    for idx, (ex, ey) in enumerate(engine_centers):
        # 1. Thrust Mount Base Ring on Rear Bulkhead (Z = -456 to -468)
        add_cylinder_axis(bm, (ex, ey, -456.0), (ex, ey, -468.0),
                          15.8, 14.4, segs=44, mat_idx=M_BARE)

        # 2. Gimbal Neck & Exposed Turbopump Core (Z = -466 to -486)
        add_cylinder_axis(bm, (ex, ey, -466.0), (ex, ey, -486.0),
                          12.4, 13.8, segs=44, mat_idx=M_SCORCH)

        # 16 Hydraulic Gimbal Struts & Cryogenic Feed Pipes around the engine neck
        for k in range(16):
            ang = 2.0 * math.pi * k / 16.0
            ang2 = ang + (0.22 if k % 2 == 0 else -0.22)
            p_start = (ex + math.cos(ang) * 15.2,  ey + math.sin(ang) * 15.2,  -458.0)
            p_end   = (ex + math.cos(ang2) * 14.6, ey + math.sin(ang2) * 14.6, -488.0)
            add_cylinder_axis(bm, p_start, p_end, 0.95, 0.82, segs=12, mat_idx=M_BARE)

        # 3 Heavy Toroidal Fuel Manifold Rings around the neck
        for z_ring, r_ring in [(-471.0, 14.5), (-478.0, 14.9), (-484.0, 15.3)]:
            add_cylinder_axis(bm, (ex, ey, z_ring - 1.3), (ex, ey, z_ring + 1.3),
                              r_ring, r_ring, segs=44, mat_idx=M_BARE)

        # 3. Main Flared Conical Engine Bell (Z = -485 to -550.0, radius 13.8 -> 17.8 m)
        bell_stations = [
            (-485.0, -501.0, 13.8, 15.1),
            (-501.0, -517.0, 15.1, 16.2),
            (-517.0, -533.0, 16.2, 17.1),
            (-533.0, -549.2, 17.1, 17.8),
        ]
        for (zb0, zb1, rb0, rb1) in bell_stations:
            add_cylinder_axis(bm, (ex, ey, zb0), (ex, ey, zb1),
                              rb0, rb1, segs=48, mat_idx=M_SCORCH, cap0=False, cap1=False)
            add_cylinder_axis(bm, (ex, ey, zb0 - 1.2), (ex, ey, zb0 + 1.2),
                              rb0 + 0.65, rb0 + 0.65, segs=48, mat_idx=M_BARE, cap0=True, cap1=True)

        # Rear Nozzle Lip Stiffener Ring + Glowing Plasma Inner Rim right at Z = -550.0
        # Allows the electric blue/orange plasma glow to be visible on the nozzle exit lip!
        add_cylinder_axis(bm, (ex, ey, -547.2), (ex, ey, -549.4),
                          18.1, 17.7, segs=48, mat_idx=M_SCORCH, cap0=True, cap1=False)
        add_cylinder_axis(bm, (ex, ey, -549.0), (ex, ey, -550.0),
                          17.4, 16.6, segs=48, mat_idx=M_ENG, cap0=False, cap1=True)

        # 36 Longitudinal Cooling Ribs along the outside of each engine bell
        for k in range(36):
            ang = 2.0 * math.pi * k / 36.0
            ca, sa = math.cos(ang), math.sin(ang)
            p0_rib = (ex + ca * 14.0, ey + sa * 14.0, -486.0)
            p1_rib = (ex + ca * 17.8, ey + sa * 17.8, -548.5)
            add_cylinder_axis(bm, p0_rib, p1_rib, 0.38, 0.44, segs=10, mat_idx=M_BARE)

        # 4. Glowing Inner Nozzle Liner & Injector Plate ("Engines" emissive material!)
        add_cylinder_axis(bm, (ex, ey, -549.8), (ex, ey, -517.0),
                          17.0, 14.6, segs=48, mat_idx=M_ENG, cap0=False, cap1=False)
        add_cylinder_axis(bm, (ex, ey, -517.0), (ex, ey, -488.0),
                          14.6, 10.5, segs=48, mat_idx=M_ENG, cap0=False, cap1=True)
        for r_inj in (9.0, 6.0, 3.0):
            add_cylinder_axis(bm, (ex, ey, -490.0), (ex, ey, -498.0),
                              r_inj, r_inj * 0.88, segs=28, mat_idx=M_ENG, cap0=True, cap1=True)

    # =========================================================================
    # 9. BARBETTE RINGS FOR ALL 13 ARTICULATED TURRETS + VLS MISSILE BAYS
    # =========================================================================
    for tspec in get_turret_specs():
        tx, ty, tz = tspec["pos"]
        scale = tspec["scale"]
        inv = -1.0 if tspec.get("invert_y", False) else 1.0
        add_cylinder_axis(bm, (tx, ty - inv * 3.0 * scale, tz), (tx, ty + inv * 0.4 * scale, tz),
                          9.8 * scale, 9.0 * scale, segs=32, mat_idx=M_SCORCH)
        add_cylinder_axis(bm, (tx, ty - inv * 0.6 * scale, tz), (tx, ty + inv * 0.6 * scale, tz),
                          9.3 * scale, 8.6 * scale, segs=32, mat_idx=M_BARE)

    for hp_x in (-16.0, 16.0):
        for hp_z in (-52.0, -68.0, -84.0, -100.0):
            add_beveled_box(bm, hp_x - 4.5, hp_x + 4.5, 45.8, 47.4, hp_z - 5.5, hp_z + 5.5,
                            mat_idx=M_SCORCH, bevel=0.25, bevel_segs=2)
            add_beveled_box(bm, hp_x - 3.2, hp_x + 3.2, 46.2, 47.8, hp_z - 4.0, hp_z + 4.0,
                            mat_idx=M_BARE, bevel=0.18, bevel_segs=2)

    hull_mesh = bpy.data.meshes.new("Hull")
    bm.to_mesh(hull_mesh)
    bm.free()

    for mname in ("Paint", "BareMetal", "ScorchedMetal", "WeaponArmor", "Engines", "Lamps", "Glass"):
        hull_mesh.materials.append(mats[mname])

    apply_world_scale_box_uvs(hull_mesh, scale_m=42.0)

    hull_obj = bpy.data.objects.new("Hull", hull_mesh)
    bpy.context.scene.collection.objects.link(hull_obj)
    return hull_obj


# ---------------------------------------------------------------------------
# Step 7: Build the 13 Beefy Articulated Moving Turrets (Turret_1..13 + Gun_1..13)
# Includes both Upper and Lower Aft Defense Turrets behind the Command Tower!
# ---------------------------------------------------------------------------
def get_turret_specs():
    return [
        # 1. Forward Dorsal Heavy Twin Turret ahead of Command Bridge
        {"idx": 1,  "pos": (  0.0,  47.0, -120.0), "scale": 1.45, "yaw_deg":   0.0},
        # 2-3. Port (+X) Upper Broadside Turret Battery
        {"idx": 2,  "pos": ( 59.5,  25.0,  -95.0), "scale": 1.30, "yaw_deg":   0.0},
        {"idx": 3,  "pos": ( 59.5,  25.0, -195.0), "scale": 1.30, "yaw_deg":   0.0},
        # 4-5. Port (+X) Lower Broadside Turret Battery
        {"idx": 4,  "pos": ( 59.5, -25.0,  -95.0), "scale": 1.30, "yaw_deg":   0.0, "invert_y": True},
        {"idx": 5,  "pos": ( 59.5, -25.0, -195.0), "scale": 1.30, "yaw_deg":   0.0, "invert_y": True},
        # 6-7. Starboard (-X) Upper Broadside Turret Battery
        {"idx": 6,  "pos": (-59.5,  25.0,  -95.0), "scale": 1.30, "yaw_deg":   0.0},
        {"idx": 7,  "pos": (-59.5,  25.0, -195.0), "scale": 1.30, "yaw_deg":   0.0},
        # 8-9. Starboard (-X) Lower Broadside Turret Battery
        {"idx": 8,  "pos": (-59.5, -25.0,  -95.0), "scale": 1.30, "yaw_deg":   0.0, "invert_y": True},
        {"idx": 9,  "pos": (-59.5, -25.0, -195.0), "scale": 1.30, "yaw_deg":   0.0, "invert_y": True},
        # 10-11. Superstructure Flanking Medium Defense Turrets
        {"idx": 10, "pos": ( 32.5,  48.5, -208.0), "scale": 0.95, "yaw_deg":   0.0},
        {"idx": 11, "pos": (-32.5,  48.5, -208.0), "scale": 0.95, "yaw_deg":   0.0},
        # 12. Upper Stepped Aft Defense Turret right behind Command Tower
        {"idx": 12, "pos": (  0.0,  68.0, -314.0), "scale": 0.95, "yaw_deg": 180.0},
        # 13. Lower Aft Dorsal Defense Turret on Aft Deck
        {"idx": 13, "pos": (  0.0,  43.0, -385.0), "scale": 1.20, "yaw_deg": 180.0},
    ]


def build_turrets_and_empties(mats, hull_obj):
    M_BARE   = MAT_IDX["BareMetal"]
    M_SCORCH = MAT_IDX["ScorchedMetal"]
    M_WEAPON = MAT_IDX["WeaponArmor"]

    scene = bpy.context.scene
    turret_objs = []
    gun_objs = []
    muzzle_objs = []

    muz1 = bpy.data.objects.new("Muzzle_1", None)
    muz1.empty_display_type = 'PLAIN_AXES'
    muz1.empty_display_size = 8.0
    muz1.parent = hull_obj
    muz1.location = g2b(0.0, 0.0, 550.0)
    scene.collection.objects.link(muz1)
    muzzle_objs.append(muz1)

    muz_counter = 2
    for tspec in get_turret_specs():
        idx = tspec["idx"]
        tx, ty, tz = tspec["pos"]
        sc = tspec["scale"]
        inv = -1.0 if tspec.get("invert_y", False) else 1.0
        fdir = -1.0 if abs(tspec.get("yaw_deg", 0.0) - 180.0) < 1.0 else 1.0

        bm_t = bmesh.new()
        add_cylinder_axis(bm_t, (0.0, 0.0, 0.0), (0.0, inv * 1.8 * sc, 0.0),
                          8.2 * sc, 7.8 * sc, segs=32, mat_idx=M_BARE)
        y_lo = min(1.0 * sc * inv, 6.8 * sc * inv)
        y_hi = max(1.0 * sc * inv, 6.8 * sc * inv)
        z_back  = -10.5 * sc * fdir
        z_front =   8.5 * sc * fdir
        add_beveled_box(bm_t, -7.8 * sc, 7.8 * sc, y_lo, y_hi,
                        min(z_back, z_front), max(z_back, z_front),
                        mat_idx=M_WEAPON, bevel=0.55 * sc, bevel_segs=3,
                        taper_top_x=2.0 * sc, taper_front_x=1.8 * sc)
        y_roof_lo = min(6.2 * sc * inv, 7.8 * sc * inv)
        y_roof_hi = max(6.2 * sc * inv, 7.8 * sc * inv)
        add_beveled_box(bm_t, -5.5 * sc, 5.5 * sc, y_roof_lo, y_roof_hi,
                        min(-8.5 * sc * fdir, 5.0 * sc * fdir),
                        max(-8.5 * sc * fdir, 5.0 * sc * fdir),
                        mat_idx=M_WEAPON, bevel=0.35 * sc, bevel_segs=2,
                        taper_top_x=1.2 * sc)
        for s_cheek in (-1, 1):
            cx0 = (7.2 * sc) if s_cheek > 0 else (-9.6 * sc)
            cx1 = (9.6 * sc) if s_cheek > 0 else (-7.2 * sc)
            add_beveled_box(bm_t, cx0, cx1,
                            min(2.0 * sc * inv, 5.4 * sc * inv),
                            max(2.0 * sc * inv, 5.4 * sc * inv),
                            min(-7.0 * sc * fdir, 3.0 * sc * fdir),
                            max(-7.0 * sc * fdir, 3.0 * sc * fdir),
                            mat_idx=M_SCORCH, bevel=0.30 * sc, bevel_segs=2)
        add_beveled_box(bm_t, -6.2 * sc, 6.2 * sc,
                        min(1.6 * sc * inv, 5.8 * sc * inv),
                        max(1.6 * sc * inv, 5.8 * sc * inv),
                        min(-14.2 * sc * fdir, -9.8 * sc * fdir),
                        max(-14.2 * sc * fdir, -9.8 * sc * fdir),
                        mat_idx=M_WEAPON, bevel=0.35 * sc, bevel_segs=2)

        t_mesh = bpy.data.meshes.new(f"Turret_{idx}")
        bm_t.to_mesh(t_mesh)
        bm_t.free()
        for mname in ("Paint", "BareMetal", "ScorchedMetal", "WeaponArmor", "Engines", "Lamps", "Glass"):
            t_mesh.materials.append(mats[mname])
        apply_world_scale_box_uvs(t_mesh, scale_m=20.0)

        t_obj = bpy.data.objects.new(f"Turret_{idx}", t_mesh)
        t_obj.parent = hull_obj
        t_obj.location = g2b(tx, ty, tz)
        scene.collection.objects.link(t_obj)
        turret_objs.append(t_obj)

        trun_y = inv * 3.8 * sc
        trun_z = fdir * 5.8 * sc

        bm_g = bmesh.new()
        add_cylinder_axis(bm_g, (-5.2 * sc, 0.0, 0.0), (5.2 * sc, 0.0, 0.0),
                          2.0 * sc, 2.0 * sc, segs=28, mat_idx=M_SCORCH)

        barrel_len = 34.0 * sc
        for b_side in (-1, 1):
            bx = b_side * 2.8 * sc
            add_beveled_box(bm_g, bx - 1.35 * sc, bx + 1.35 * sc,
                            -1.35 * sc, 1.35 * sc,
                            min(0.0, fdir * 13.5 * sc), max(0.0, fdir * 13.5 * sc),
                            mat_idx=M_WEAPON, bevel=0.25 * sc, bevel_segs=2)
            add_cylinder_axis(bm_g, (bx, 1.45 * sc, 0.0), (bx, 1.45 * sc, fdir * 16.5 * sc),
                              0.48 * sc, 0.42 * sc, segs=16, mat_idx=M_BARE)
            add_cylinder_axis(bm_g, (bx, 0.0, fdir * 12.5 * sc), (bx, 0.0, fdir * barrel_len),
                              0.95 * sc, 0.76 * sc, segs=28, mat_idx=M_BARE)
            add_cylinder_axis(bm_g, (bx, 0.0, fdir * (barrel_len - 3.8 * sc)),
                              (bx, 0.0, fdir * barrel_len),
                              1.12 * sc, 1.02 * sc, segs=28, mat_idx=M_SCORCH)

        g_mesh = bpy.data.meshes.new(f"Gun_{idx}")
        bm_g.to_mesh(g_mesh)
        bm_g.free()
        for mname in ("Paint", "BareMetal", "ScorchedMetal", "WeaponArmor", "Engines", "Lamps", "Glass"):
            g_mesh.materials.append(mats[mname])
        apply_world_scale_box_uvs(g_mesh, scale_m=16.0)

        g_obj = bpy.data.objects.new(f"Gun_{idx}", g_mesh)
        g_obj.parent = t_obj
        g_obj.location = g2b(0.0, trun_y, trun_z)
        scene.collection.objects.link(g_obj)
        gun_objs.append(g_obj)

        for b_side in (-1, 1):
            bx = b_side * 2.8 * sc
            muz = bpy.data.objects.new(f"Muzzle_{muz_counter}", None)
            muz.empty_display_type = 'PLAIN_AXES'
            muz.empty_display_size = 2.0
            muz.parent = g_obj
            muz.location = g2b(bx, 0.0, fdir * barrel_len)
            scene.collection.objects.link(muz)
            muzzle_objs.append(muz)
            muz_counter += 1

    empty_nodes = []

    def make_empty(name, gx, gy, gz, parent=hull_obj):
        e = bpy.data.objects.new(name, None)
        e.empty_display_type = 'PLAIN_AXES'
        e.empty_display_size = 4.0
        e.parent = parent
        e.location = g2b(gx, gy, gz)
        scene.collection.objects.link(e)
        empty_nodes.append(e)
        return e

    make_empty("Thruster_1",  25.5,  19.0, -550.0)
    make_empty("Thruster_2", -25.5,  19.0, -550.0)
    make_empty("Thruster_3",  25.5, -19.0, -550.0)
    make_empty("Thruster_4", -25.5, -19.0, -550.0)

    hp_coords = [
        ( 16.0, 47.5,  -52.0), (-16.0, 47.5,  -52.0),
        ( 16.0, 47.5,  -68.0), (-16.0, 47.5,  -68.0),
        ( 16.0, 47.5,  -84.0), (-16.0, 47.5,  -84.0),
        ( 16.0, 47.5, -100.0), (-16.0, 47.5, -100.0),
    ]
    for i, (hx, hy, hz) in enumerate(hp_coords, start=1):
        make_empty(f"Hardpoint_{i}", hx, hy, hz)

    make_empty("Camera_Chase", 0.0, 130.0, -760.0)
    make_empty("Light_Nav_L",   80.0,  0.0, -358.0)
    make_empty("Light_Nav_R",  -80.0,  0.0, -358.0)
    make_empty("Light_Beacon",   0.0, 95.0, -244.0)

    return turret_objs, gun_objs, muzzle_objs, empty_nodes


def apply_world_scale_box_uvs(mesh, scale_m=36.0):
    bm = bmesh.new()
    bm.from_mesh(mesh)
    uv_layer = bm.loops.layers.uv.verify()
    for f in bm.faces:
        n = f.normal
        ax, ay, az = abs(n.x), abs(n.y), abs(n.z)
        for loop in f.loops:
            co = loop.vert.co
            if ax >= ay and ax >= az:
                u, v = co.y / scale_m, co.z / scale_m
            elif ay >= ax and ay >= az:
                u, v = co.x / scale_m, co.z / scale_m
            else:
                u, v = co.x / scale_m, co.y / scale_m
            loop[uv_layer].uv = (u, v)
    bm.to_mesh(mesh)
    bm.free()


# ---------------------------------------------------------------------------
# Step 8: Verify 30-Degree Articulation Test & Export GLB + nodes.json
# ---------------------------------------------------------------------------
def verify_articulation_and_export(scene, hull_obj, turret_objs, gun_objs, mats):
    bpy.context.view_layer.update()

    rad30 = math.radians(30.0)
    for t_obj in turret_objs:
        t_obj.rotation_euler = Euler((0.0, 0.0, rad30), 'XYZ')
    for g_obj in gun_objs:
        g_obj.rotation_euler = Euler((rad30, 0.0, 0.0), 'XYZ')
    bpy.context.view_layer.update()

    for t_obj in turret_objs:
        t_obj.rotation_euler = Euler((0.0, 0.0, 0.0), 'XYZ')
    for g_obj in gun_objs:
        g_obj.rotation_euler = Euler((0.0, 0.0, 0.0), 'XYZ')
    bpy.context.view_layer.update()
    print("[Verify] 30-degree traverse/elevation pivot check passed on all 26 moving nodes.")

    total_tris = 0
    min_g = [ 1e9,  1e9,  1e9]
    max_g = [-1e9, -1e9, -1e9]

    for obj in scene.objects:
        if obj.type == 'MESH':
            mw = obj.matrix_world
            me = obj.data
            me.calc_loop_triangles()
            total_tris += len(me.loop_triangles)
            for v in me.vertices:
                wv = mw @ v.co
                gx, gy, gz = b2g_tuple(wv)
                if gx < min_g[0]: min_g[0] = gx
                if gy < min_g[1]: min_g[1] = gy
                if gz < min_g[2]: min_g[2] = gz
                if gx > max_g[0]: max_g[0] = gx
                if gy > max_g[1]: max_g[1] = gy
                if gz > max_g[2]: max_g[2] = gz

    min_g = [round(v, 3) for v in min_g]
    max_g = [round(v, 3) for v in max_g]
    size_g = [round(max_g[i] - min_g[i], 3) for i in range(3)]
    print(f"[Stats] Total Triangles: {total_tris:,}")
    print(f"[Stats] Bounding Box (glTF Y-up): min={min_g}, max={max_g}, size={size_g}")

    paint_mat = mats["Paint"]
    nt = paint_mat.node_tree
    bsdf = [n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'][0]
    t_base = nt.nodes.get("BaseColorTexNode")
    mix_node = nt.nodes.get("PaintFactorMix")
    if t_base and bsdf:
        nt.links.new(t_base.outputs["Color"], bsdf.inputs["Base Color"])

    glb_path = os.path.join(SCRIPT_DIR, "railgun-battleship.glb")
    bpy.ops.export_scene.gltf(
        filepath=glb_path,
        export_format='GLB',
        export_yup=True,
        export_apply=False,
        export_texcoords=True,
        export_normals=True,
        export_materials='EXPORT',
        export_image_format='JPEG',
        export_jpeg_quality=92,
        export_cameras=False,
        export_lights=False,
    )

    if mix_node and bsdf:
        nt.links.new(mix_node.outputs["Color"], bsdf.inputs["Base Color"])

    enforce_paint_contract_in_glb(glb_path)

    nodes_dict = {}
    axes_dict = {}
    for obj in sorted(scene.objects, key=lambda o: o.name):
        wpos = b2g_tuple(obj.matrix_world.translation)
        entry = {
            "position": wpos,
            "parent": obj.parent.name if obj.parent else None,
            "type": obj.type,
        }
        if obj.type == 'MESH':
            bmin = [ 1e9,  1e9,  1e9]
            bmax = [-1e9, -1e9, -1e9]
            mw = obj.matrix_world
            for corner in obj.bound_box:
                gc = b2g_tuple(mw @ Vector(corner))
                for i in range(3):
                    bmin[i] = min(bmin[i], gc[i])
                    bmax[i] = max(bmax[i], gc[i])
            entry["bbox"] = {"min": [round(x, 3) for x in bmin], "max": [round(x, 3) for x in bmax]}
        nodes_dict[obj.name] = entry

        if obj.name.startswith("Turret_"):
            axes_dict[obj.name] = [0, 1, 0]
        elif obj.name.startswith("Gun_"):
            axes_dict[obj.name] = [1, 0, 0]

    bbox_obj = {
        "min": min_g,
        "max": max_g,
        "size": size_g,
    }
    nodes_payload = {
        "name": "railgun-battleship",
        "bbox": bbox_obj,
        "bounding_box": bbox_obj,
        "triangles": total_tris,
        "axes": axes_dict,
        "paints": PAINTS,
        "nodes": nodes_dict,
    }
    nodes_path = os.path.join(SCRIPT_DIR, "nodes.json")
    with open(nodes_path, "w", encoding="utf-8") as f:
        json.dump(nodes_payload, f, indent=2)
    print(f"[Export] Wrote {glb_path} ({os.path.getsize(glb_path)/1024/1024:.2f} MB) and {nodes_path}")
    return total_tris, size_g


def enforce_paint_contract_in_glb(glb_path):
    with open(glb_path, "rb") as f:
        data = f.read()
    magic, version, length = struct.unpack_from("<III", data, 0)
    json_len, json_type = struct.unpack_from("<II", data, 12)
    json_str = data[20:20 + json_len].decode("utf-8")
    bin_rest = data[20 + json_len:]

    gltf = json.loads(json_str)
    for mat in gltf.get("materials", []):
        if mat.get("name") == "Paint":
            pbr = mat.setdefault("pbrMetallicRoughness", {})
            pbr["baseColorFactor"] = [0.70, 0.68, 0.62, 1.0]

    new_json_bytes = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
    pad = (4 - (len(new_json_bytes) % 4)) % 4
    new_json_bytes += b" " * pad
    total_len = 12 + 8 + len(new_json_bytes) + len(bin_rest)

    with open(glb_path, "wb") as f:
        f.write(struct.pack("<III", magic, version, total_len))
        f.write(struct.pack("<II", len(new_json_bytes), json_type))
        f.write(new_json_bytes)
        f.write(bin_rest)


# ---------------------------------------------------------------------------
# Step 9: Lighting, Backdrop & Renders (final.png, side.png, clay.png, paints.png)
# ---------------------------------------------------------------------------
def setup_lighting_and_world(scene):
    world = bpy.data.worlds.new("SpaceWorld")
    scene.world = world
    world.use_nodes = True
    nt = world.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputWorld")
    bg = nt.nodes.new("ShaderNodeBackground")
    tc = nt.nodes.new("ShaderNodeTexCoord")
    sep = nt.nodes.new("ShaderNodeSeparateXYZ")
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.0
    ramp.color_ramp.elements[0].color = (0.004, 0.008, 0.018, 1.0)
    ramp.color_ramp.elements[1].position = 1.0
    ramp.color_ramp.elements[1].color = (0.016, 0.032, 0.068, 1.0)
    nt.links.new(tc.outputs["Window"], sep.inputs["Vector"])
    nt.links.new(sep.outputs["Y"], ramp.inputs["Fac"])
    nt.links.new(ramp.outputs["Color"], bg.inputs["Color"])
    bg.inputs["Strength"].default_value = 1.0
    nt.links.new(bg.outputs["Background"], out.inputs["Surface"])

    # Strong white key light from upper front left
    key_data = bpy.data.lights.new("KeyLight", type='SUN')
    key_data.energy = 5.0
    key_data.color = (1.0, 0.98, 0.95)
    key_data.angle = math.radians(2.5)
    key_obj = bpy.data.objects.new("KeyLight", key_data)
    scene.collection.objects.link(key_obj)
    key_obj.location = g2b(650.0, 520.0, 720.0)
    direction = g2b(0.0, 0.0, 40.0) - key_obj.location
    key_obj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()

    # Soft cool fill light
    fill_data = bpy.data.lights.new("FillLight", type='SUN')
    fill_data.energy = 1.9
    fill_data.color = (0.68, 0.82, 1.0)
    fill_data.angle = math.radians(12.0)
    fill_obj = bpy.data.objects.new("FillLight", fill_data)
    scene.collection.objects.link(fill_obj)
    fill_obj.location = g2b(-650.0, 180.0, 350.0)
    f_dir = g2b(0.0, 0.0, 0.0) - fill_obj.location
    fill_obj.rotation_euler = f_dir.to_track_quat('-Z', 'Y').to_euler()

    # Subtle rim light from upper rear
    rim_data = bpy.data.lights.new("RimLight", type='SUN')
    rim_data.energy = 1.65
    rim_data.color = (0.78, 0.88, 1.0)
    rim_data.angle = math.radians(6.0)
    rim_obj = bpy.data.objects.new("RimLight", rim_data)
    scene.collection.objects.link(rim_obj)
    rim_obj.location = g2b(-350.0, 550.0, -650.0)
    r_dir = g2b(0.0, 0.0, -100.0) - rim_obj.location
    rim_obj.rotation_euler = r_dir.to_track_quat('-Z', 'Y').to_euler()

    # Render-only Cyan Capacitor Spill Lights & Engine Plume Accent Lights
    # (Created AFTER GLB export so no lights exist in the GLB!)
    accent_lights = []
    for z_cap in (-12.0, 34.0, 80.0, 126.0, 172.0):
        for side in (-1, 1):
            p_light = bpy.data.lights.new(f"CapGlow_{side}_{int(z_cap)}", type='POINT')
            p_light.energy = 14000.0
            p_light.color = (0.02, 0.45, 1.0)
            p_light.shadow_soft_size = 7.5
            p_obj = bpy.data.objects.new(p_light.name, p_light)
            scene.collection.objects.link(p_obj)
            p_obj.location = g2b(side * 59.5, 0.0, z_cap)
            accent_lights.append(p_obj)

    for (ex, ey) in [(25.5, 19.0), (-25.5, 19.0), (25.5, -19.0), (-25.5, -19.0)]:
        e_light = bpy.data.lights.new(f"EngGlow_{int(ex)}_{int(ey)}", type='POINT')
        e_light.energy = 50000.0
        e_light.color = (0.14, 0.68, 1.0)
        e_light.shadow_soft_size = 12.0
        e_obj = bpy.data.objects.new(e_light.name, e_light)
        scene.collection.objects.link(e_obj)
        e_obj.location = g2b(ex, ey, -552.0)
        accent_lights.append(e_obj)

    return accent_lights


def point_camera_at(cam_obj, pos_gltf, target_gltf):
    cam_obj.location = g2b(*pos_gltf)
    target = g2b(*target_gltf)
    direction = target - cam_obj.location
    cam_obj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()


def render_all_views(scene, mats):
    accent_lights = setup_lighting_and_world(scene)

    cam_data = bpy.data.cameras.new("RenderCam")
    cam_data.clip_start = 1.0
    cam_data.clip_end = 6000.0
    cam_obj = bpy.data.objects.new("RenderCam", cam_data)
    scene.collection.objects.link(cam_obj)
    scene.camera = cam_obj

    scene.render.resolution_x = 1920
    scene.render.resolution_y = 1080
    scene.render.resolution_percentage = 100
    scene.render.image_settings.file_format = 'PNG'
    scene.cycles.samples = 56

    # 1. final.png - 3/4 Front Hero View framing the entire 1100m ship with comfortable margins!
    cam_data.type = 'PERSP'
    cam_data.lens = 36.0
    point_camera_at(cam_obj, (760.0, 235.0, 810.0), (0.0, 6.0, 95.0))
    scene.render.filepath = os.path.join(SCRIPT_DIR, "final.png")
    bpy.ops.render.render(write_still=True)
    print("[Render] Saved final.png")

    # 2. clay.png - Same 3/4 view as final.png with plain grey clay material override (accent lights off)
    for l_obj in accent_lights:
        l_obj.hide_render = True
    clay_mat = bpy.data.materials.new("ClayGrey")
    clay_mat.use_nodes = True
    bsdf = [n for n in clay_mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'][0]
    bsdf.inputs["Base Color"].default_value = (0.58, 0.59, 0.60, 1.0)
    bsdf.inputs["Roughness"].default_value = 0.55
    bsdf.inputs["Metallic"].default_value = 0.05
    scene.view_layers[0].material_override = clay_mat
    scene.render.filepath = os.path.join(SCRIPT_DIR, "clay.png")
    bpy.ops.render.render(write_still=True)
    scene.view_layers[0].material_override = None
    for l_obj in accent_lights:
        l_obj.hide_render = False
    print("[Render] Saved clay.png")

    # 3. side.png - Orthographic Side View matching Side View on sheet.jpg (nose left = +Z left)
    cam_data.type = 'ORTHO'
    cam_data.ortho_scale = 1180.0
    point_camera_at(cam_obj, (900.0, 18.0, 0.0), (0.0, 18.0, 0.0))
    scene.render.filepath = os.path.join(SCRIPT_DIR, "side.png")
    bpy.ops.render.render(write_still=True)
    print("[Render] Saved side.png")

    # 4. paints.png - 3x2 grid of the 3/4 view in all 6 faction paints from nodes.json
    cam_data.type = 'PERSP'
    cam_data.lens = 36.0
    point_camera_at(cam_obj, (760.0, 235.0, 810.0), (0.0, 6.0, 95.0))
    scene.render.resolution_x = 640
    scene.render.resolution_y = 540
    scene.cycles.samples = 24

    paint_mat = mats["Paint"]
    mix_node = paint_mat.node_tree.nodes.get("PaintFactorMix")
    tile_paths = []
    for idx, pinfo in enumerate(PAINTS):
        col = pinfo["color"]
        if mix_node is not None:
            mix_node.inputs["Color2"].default_value = (col[0], col[1], col[2], 1.0)
        tpath = os.path.join(TEX_BAKE_DIR, f"_paint_tile_{idx}.png")
        scene.render.filepath = tpath
        bpy.ops.render.render(write_still=True)
        tile_paths.append(tpath)

    def_col = PAINTS[0]["color"]
    if mix_node is not None:
        mix_node.inputs["Color2"].default_value = (def_col[0], def_col[1], def_col[2], 1.0)

    compose_paints_grid(tile_paths, os.path.join(SCRIPT_DIR, "paints.png"))
    print("[Render] Saved paints.png (3x2 grid of 6 paints)")


def compose_paints_grid(tile_paths, out_path):
    W, H = 1920, 1080
    tw, th = 640, 540
    grid_img = bpy.data.images.new("PaintsGrid", width=W, height=H, alpha=True)
    canvas = [0.0] * (W * H * 4)

    for idx, tpath in enumerate(tile_paths):
        col_idx = idx % 3
        row_idx = 1 - (idx // 3)
        timg = bpy.data.images.load(tpath)
        tpix = list(timg.pixels)
        x_off = col_idx * tw
        y_off = row_idx * th
        for y in range(th):
            src_row_start = y * tw * 4
            dst_row_start = ((y_off + y) * W + x_off) * 4
            canvas[dst_row_start:dst_row_start + tw * 4] = tpix[src_row_start:src_row_start + tw * 4]
        bpy.data.images.remove(timg)

    grid_img.pixels = canvas
    grid_img.filepath_raw = out_path
    grid_img.file_format = 'PNG'
    grid_img.save()
    bpy.data.images.remove(grid_img)


# ---------------------------------------------------------------------------
# Main Execution
# ---------------------------------------------------------------------------
def main():
    ensure_polyhaven_textures()
    scene = reset_scene()
    baked = build_and_bake_textures(scene)
    mats = build_export_materials(baked)
    hull_obj = build_hull_mesh(mats)
    turret_objs, gun_objs, muzzle_objs, empty_nodes = build_turrets_and_empties(mats, hull_obj)
    verify_articulation_and_export(scene, hull_obj, turret_objs, gun_objs, mats)
    render_all_views(scene, mats)
    print("[Done] Spinal Railgun Battleship asset pipeline complete.")


if __name__ == "__main__":
    main()
