#!/usr/bin/env python3
"""
build_hardsf-cruiser.py
Builds the Hard Sci-Fi Military Cruiser (hardsf-cruiser.glb) from an empty scene in Blender 5.2.
Dimensions: 260 m long (+Z nose, -Z tail), 110 m wide (+X port/left, -X starboard/right), 40 m high (+Y up).
"""

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

# ---------------------------------------------------------------------------
# Configuration & Paths
# ---------------------------------------------------------------------------
OUT_DIR = os.path.dirname(os.path.abspath(__file__))
TEX_CACHE = "/tmp/hardsf_cruiser_tex"
BAKE_DIR = "/tmp/hardsf_cruiser_baked"
os.makedirs(TEX_CACHE, exist_ok=True)
os.makedirs(BAKE_DIR, exist_ok=True)

POLYHAVEN_IDS = [
    "metal_plate",
    "metal_plate_02",
    "blue_metal_plate",
    "rusty_metal_03",
]

PAINTS = [
    {"name": "Naval grey",   "linear_rgb": [0.70, 0.70, 0.68], "rgb": [0.70, 0.70, 0.68]},
    {"name": "Gunmetal",     "linear_rgb": [0.22, 0.24, 0.27], "rgb": [0.22, 0.24, 0.27]},
    {"name": "Olive drab",   "linear_rgb": [0.26, 0.30, 0.17], "rgb": [0.26, 0.30, 0.17]},
    {"name": "Desert sand",  "linear_rgb": [0.62, 0.50, 0.33], "rgb": [0.62, 0.50, 0.33]},
    {"name": "Deep navy",    "linear_rgb": [0.09, 0.15, 0.28], "rgb": [0.09, 0.15, 0.28]},
    {"name": "Crimson",      "linear_rgb": [0.52, 0.09, 0.09], "rgb": [0.52, 0.09, 0.09]},
]


# ---------------------------------------------------------------------------
# Scene Reset & GPU Setup
# ---------------------------------------------------------------------------
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()
            used = False
            for d in cprefs.devices:
                if d.type != 'CPU':
                    d.use = True
                    used = True
                else:
                    d.use = False
            if used:
                scene.cycles.device = 'GPU'
                print(f"[GPU] Activated Cycles backend: {dev_type}")
                break
        except Exception:
            continue
    scene.cycles.samples = 64
    scene.cycles.use_denoising = True


# ---------------------------------------------------------------------------
# Poly Haven Texture Downloader
# ---------------------------------------------------------------------------
def ensure_polyhaven_textures():
    maps = ["diff", "nor_gl", "arm"]
    paths = {}
    for ph_id in POLYHAVEN_IDS:
        paths[ph_id] = {}
        for m in maps:
            fname = f"{ph_id}_{m}_1k.jpg"
            fpath = os.path.join(TEX_CACHE, 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"[Download] Fetching {url} -> {fpath}")
                req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
                with urllib.request.urlopen(req, timeout=30) as resp, open(fpath, "wb") as out:
                    shutil.copyfileobj(resp, out)
            paths[ph_id][m] = fpath
    return paths


# ---------------------------------------------------------------------------
# Cycles Material Baking (Poly Haven + Node Detail -> 1024 Base/Normal, 512 ORM)
# ---------------------------------------------------------------------------
def load_image_cached(path, colorspace='sRGB'):
    img = bpy.data.images.load(path, check_existing=True)
    img.colorspace_settings.name = colorspace
    return img


def build_and_bake_materials(ph_paths):
    """
    Creates the 8 materials required by the contract:
      1. Paint       (neutral near-white baked detail texture + baseColorFactor [0.70, 0.70, 0.68])
      2. Metal       (bare brushed/machined steel & alloy for trusses, spherical tanks, radiator frames, pipes)
      3. Weapons     (dark oiled gunmetal for spinal railgun, turret barrels, drone cells, recessed bays)
      4. HeatMetal   (heat-scorched bronze/amber/steel for magnetic nozzle & spherical tank bands)
      5. Radiator    (dark slate-blue photovoltaic/radiator fin panels with micro-louvers)
      6. Engines     (emissive warm orange-white fusion plasma core & thermal louvers)
      7. Lamps       (emissive warm crew windows, running lights, beacons)
      8. Glass       (alpha BLEND tinted armored bridge glazing)
    """
    scene = bpy.context.scene
    orig_samples = scene.cycles.samples
    scene.cycles.samples = 16
    scene.cycles.use_denoising = False

    bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, -500.0))
    bake_obj = bpy.context.active_object
    bake_obj.name = "_BakeProxy"

    def make_source_and_bake(mat_name, setup_fn):
        src_mat = bpy.data.materials.new(name=f"_src_{mat_name}")
        nodes = src_mat.node_tree.nodes
        links = src_mat.node_tree.links
        nodes.clear()

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

        base_sock, orm_sock, nor_sock = setup_fn(nodes, links, bsdf)

        bake_obj.data.materials.clear()
        bake_obj.data.materials.append(src_mat)
        bpy.ops.object.select_all(action='DESELECT')
        bake_obj.select_set(True)
        bpy.context.view_layer.objects.active = bake_obj

        emit_node = nodes.new("ShaderNodeEmission")

        # 1) Bake BaseColor (1024x1024)
        bc_img = bpy.data.images.new(f"{mat_name}_basecolor", width=1024, height=1024, alpha=False)
        bc_img.colorspace_settings.name = 'sRGB'
        links.new(base_sock, emit_node.inputs["Color"])
        links.new(emit_node.outputs["Emission"], out_node.inputs["Surface"])
        img_node = nodes.new("ShaderNodeTexImage")
        img_node.image = bc_img
        for n in nodes:
            n.select = False
        img_node.select = True
        nodes.active = img_node
        bpy.ops.object.bake(type='EMIT')
        bc_path = os.path.join(BAKE_DIR, f"{mat_name}_basecolor.jpg")
        bc_img.filepath_raw = bc_path
        bc_img.file_format = 'JPEG'
        bc_img.save()

        # 2) Bake ORM (512x512, Non-Color: R=Occlusion, G=Roughness, B=Metallic)
        orm_img = bpy.data.images.new(f"{mat_name}_orm", width=512, height=512, alpha=False)
        orm_img.colorspace_settings.name = 'Non-Color'
        links.new(orm_sock, emit_node.inputs["Color"])
        links.new(emit_node.outputs["Emission"], out_node.inputs["Surface"])
        img_node.image = orm_img
        bpy.ops.object.bake(type='EMIT')
        orm_path = os.path.join(BAKE_DIR, f"{mat_name}_orm.jpg")
        orm_img.filepath_raw = orm_path
        orm_img.file_format = 'JPEG'
        orm_img.save()

        # 3) Bake Normal (1024x1024, Non-Color)
        nor_img = bpy.data.images.new(f"{mat_name}_normal", width=1024, height=1024, alpha=False)
        nor_img.colorspace_settings.name = 'Non-Color'
        links.new(bsdf.outputs["BSDF"], out_node.inputs["Surface"])
        if nor_sock is not None:
            links.new(nor_sock, bsdf.inputs["Normal"])
        img_node.image = nor_img
        bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT')
        nor_path = os.path.join(BAKE_DIR, f"{mat_name}_normal.jpg")
        nor_img.filepath_raw = nor_path
        nor_img.file_format = 'JPEG'
        nor_img.save()

        bpy.data.materials.remove(src_mat)
        return (
            load_image_cached(bc_path, 'sRGB'),
            load_image_cached(orm_path, 'Non-Color'),
            load_image_cached(nor_path, 'Non-Color'),
        )

    def add_ph_set(nodes, links, ph_id, scale=(2.0, 2.0, 2.0)):
        tex_coord = nodes.new("ShaderNodeTexCoord")
        mapping = nodes.new("ShaderNodeMapping")
        if isinstance(scale, (int, float)):
            scale = (scale, scale, scale)
        mapping.inputs["Scale"].default_value = scale
        links.new(tex_coord.outputs["UV"], mapping.inputs["Vector"])

        diff_n = nodes.new("ShaderNodeTexImage")
        diff_n.image = load_image_cached(ph_paths[ph_id]["diff"], 'sRGB')
        links.new(mapping.outputs["Vector"], diff_n.inputs["Vector"])

        arm_n = nodes.new("ShaderNodeTexImage")
        arm_n.image = load_image_cached(ph_paths[ph_id]["arm"], 'Non-Color')
        links.new(mapping.outputs["Vector"], arm_n.inputs["Vector"])

        nor_n = nodes.new("ShaderNodeTexImage")
        nor_n.image = load_image_cached(ph_paths[ph_id]["nor_gl"], 'Non-Color')
        links.new(mapping.outputs["Vector"], nor_n.inputs["Vector"])

        return tex_coord, mapping, diff_n, arm_n, nor_n

    # --- 1. PAINT (Neutral near-white military hull plate texture with flush seams & weathering streaks) ---
    def setup_paint(nodes, links, bsdf):
        tc, mp, diff1, arm1, nor1 = add_ph_set(nodes, links, "metal_plate", scale=(1.0, 1.0, 1.0))
        _, _, diff2, arm2, _ = add_ph_set(nodes, links, "rusty_metal_03", scale=(3.0, 1.0, 1.0))

        # Large non-staggered spacecraft hull plates with crisp dark seam lines
        brick1 = nodes.new("ShaderNodeTexBrick")
        brick1.offset = 0.0
        brick1.inputs["Scale"].default_value = 2.0
        brick1.inputs["Brick Width"].default_value = 0.80
        brick1.inputs["Row Height"].default_value = 0.45
        brick1.inputs["Mortar Size"].default_value = 0.012
        brick1.inputs["Mortar Smooth"].default_value = 0.20
        brick1.inputs["Color1"].default_value = (0.94, 0.94, 0.94, 1.0)
        brick1.inputs["Color2"].default_value = (0.85, 0.85, 0.85, 1.0)
        brick1.inputs["Mortar"].default_value = (0.58, 0.58, 0.58, 1.0)
        links.new(tc.outputs["UV"], brick1.inputs["Vector"])

        rgb2bw = nodes.new("ShaderNodeRGBToBW")
        links.new(diff1.outputs["Color"], rgb2bw.inputs["Color"])

        ramp = nodes.new("ShaderNodeValToRGB")
        ramp.color_ramp.elements[0].position = 0.0
        ramp.color_ramp.elements[0].color = (0.74, 0.74, 0.74, 1.0)
        ramp.color_ramp.elements[1].position = 1.0
        ramp.color_ramp.elements[1].color = (0.97, 0.97, 0.97, 1.0)
        links.new(rgb2bw.outputs["Val"], ramp.inputs["Fac"])

        bw_grime = nodes.new("ShaderNodeRGBToBW")
        links.new(diff2.outputs["Color"], bw_grime.inputs["Color"])

        mix_panel = nodes.new("ShaderNodeMixRGB")
        mix_panel.blend_type = 'MULTIPLY'
        mix_panel.inputs["Fac"].default_value = 0.72
        links.new(ramp.outputs["Color"], mix_panel.inputs["Color1"])
        links.new(brick1.outputs["Color"], mix_panel.inputs["Color2"])

        mix_grime = nodes.new("ShaderNodeMixRGB")
        mix_grime.blend_type = 'MULTIPLY'
        mix_grime.inputs["Fac"].default_value = 0.25
        links.new(mix_panel.outputs["Color"], mix_grime.inputs["Color1"])
        links.new(bw_grime.outputs["Val"], mix_grime.inputs["Color2"])

        sep_arm = nodes.new("ShaderNodeSeparateColor")
        links.new(arm1.outputs["Color"], sep_arm.inputs["Color"])

        rough_math = nodes.new("ShaderNodeMath")
        rough_math.operation = 'MULTIPLY_ADD'
        links.new(sep_arm.outputs["Green"], rough_math.inputs[0])
        rough_math.inputs[1].default_value = 0.30
        rough_math.inputs[2].default_value = 0.38

        comb_orm = nodes.new("ShaderNodeCombineColor")
        links.new(sep_arm.outputs["Red"], comb_orm.inputs["Red"])
        links.new(rough_math.outputs["Value"], comb_orm.inputs["Green"])
        comb_orm.inputs["Blue"].default_value = 0.22

        bump = nodes.new("ShaderNodeBump")
        bump.inputs["Strength"].default_value = 0.25
        bump.inputs["Distance"].default_value = 0.015
        links.new(brick1.outputs["Fac"], bump.inputs["Height"])

        nmap = nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.28
        links.new(nor1.outputs["Color"], nmap.inputs["Color"])
        links.new(nmap.outputs["Normal"], bump.inputs["Normal"])

        return mix_grime.outputs["Color"], comb_orm.outputs["Color"], bump.outputs["Normal"]

    # --- 2. METAL (Bare brushed steel/titanium alloy for trusses, spherical tanks, frames, pipes) ---
    def setup_metal(nodes, links, bsdf):
        tc, mp, diff1, arm1, nor1 = add_ph_set(nodes, links, "metal_plate_02", scale=2.0)
        _, _, diff2, arm2, _ = add_ph_set(nodes, links, "metal_plate", scale=2.5)

        mix_c = nodes.new("ShaderNodeMixRGB")
        mix_c.blend_type = 'MIX'
        mix_c.inputs["Fac"].default_value = 0.45
        links.new(diff1.outputs["Color"], mix_c.inputs["Color1"])
        links.new(diff2.outputs["Color"], mix_c.inputs["Color2"])

        bright = nodes.new("ShaderNodeBrightContrast")
        bright.inputs["Bright"].default_value = 0.10
        bright.inputs["Contrast"].default_value = 0.14
        links.new(mix_c.outputs["Color"], bright.inputs["Color"])

        sep_arm = nodes.new("ShaderNodeSeparateColor")
        links.new(arm1.outputs["Color"], sep_arm.inputs["Color"])

        rough_m = nodes.new("ShaderNodeMath")
        rough_m.operation = 'MULTIPLY_ADD'
        links.new(sep_arm.outputs["Green"], rough_m.inputs[0])
        rough_m.inputs[1].default_value = 0.28
        rough_m.inputs[2].default_value = 0.22

        comb_orm = nodes.new("ShaderNodeCombineColor")
        links.new(sep_arm.outputs["Red"], comb_orm.inputs["Red"])
        links.new(rough_m.outputs["Value"], comb_orm.inputs["Green"])
        comb_orm.inputs["Blue"].default_value = 0.85

        nmap = nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.55
        links.new(nor1.outputs["Color"], nmap.inputs["Color"])

        return bright.outputs["Color"], comb_orm.outputs["Color"], nmap.outputs["Normal"]

    # --- 3. WEAPONS (Dark oiled gunmetal for spinal railgun, barrels, drone launch racks) ---
    def setup_weapons(nodes, links, bsdf):
        tc, mp, diff1, arm1, nor1 = add_ph_set(nodes, links, "metal_plate", scale=2.5)
        _, _, diff2, _, _ = add_ph_set(nodes, links, "rusty_metal_03", scale=2.0)

        mix_w = nodes.new("ShaderNodeMixRGB")
        mix_w.blend_type = 'MULTIPLY'
        mix_w.inputs["Fac"].default_value = 0.35
        links.new(diff1.outputs["Color"], mix_w.inputs["Color1"])
        links.new(diff2.outputs["Color"], mix_w.inputs["Color2"])

        tint = nodes.new("ShaderNodeMixRGB")
        tint.blend_type = 'MULTIPLY'
        tint.inputs["Fac"].default_value = 0.85
        links.new(mix_w.outputs["Color"], tint.inputs["Color1"])
        tint.inputs["Color2"].default_value = (0.23, 0.25, 0.28, 1.0)

        sep_arm = nodes.new("ShaderNodeSeparateColor")
        links.new(arm1.outputs["Color"], sep_arm.inputs["Color"])

        rough_w = nodes.new("ShaderNodeMath")
        rough_w.operation = 'MULTIPLY_ADD'
        links.new(sep_arm.outputs["Green"], rough_w.inputs[0])
        rough_w.inputs[1].default_value = 0.28
        rough_w.inputs[2].default_value = 0.24

        comb_orm = nodes.new("ShaderNodeCombineColor")
        links.new(sep_arm.outputs["Red"], comb_orm.inputs["Red"])
        links.new(rough_w.outputs["Value"], comb_orm.inputs["Green"])
        comb_orm.inputs["Blue"].default_value = 0.84

        nmap = nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.70
        links.new(nor1.outputs["Color"], nmap.inputs["Color"])

        return tint.outputs["Color"], comb_orm.outputs["Color"], nmap.outputs["Normal"]

    # --- 4. HEATMETAL (Heat-scorched bronze/amber/steel for magnetic nozzle & tank bands) ---
    def setup_heatmetal(nodes, links, bsdf):
        tc, mp, diff1, arm1, nor1 = add_ph_set(nodes, links, "rusty_metal_03", scale=2.0)
        _, _, diff2, _, _ = add_ph_set(nodes, links, "metal_plate_02", scale=2.0)

        sep_xyz = nodes.new("ShaderNodeSeparateXYZ")
        links.new(tc.outputs["UV"], sep_xyz.inputs["Vector"])

        grad_ramp = nodes.new("ShaderNodeValToRGB")
        grad_ramp.color_ramp.elements[0].position = 0.0
        grad_ramp.color_ramp.elements[0].color = (1.00, 0.65, 0.24, 1.0)  # Hot glowing amber throat
        e1 = grad_ramp.color_ramp.elements.new(0.35)
        e1.color = (0.76, 0.48, 0.26, 1.0)  # Warm bronze-copper tank band & mid-nozzle
        e2 = grad_ramp.color_ramp.elements.new(0.68)
        e2.color = (0.38, 0.31, 0.26, 1.0)  # Heat-tinted brown-steel
        grad_ramp.color_ramp.elements[-1].position = 1.0
        grad_ramp.color_ramp.elements[-1].color = (0.28, 0.30, 0.33, 1.0)  # Brushed steel lip
        links.new(sep_xyz.outputs["Y"], grad_ramp.inputs["Fac"])

        mix_tex = nodes.new("ShaderNodeMixRGB")
        mix_tex.blend_type = 'OVERLAY'
        mix_tex.inputs["Fac"].default_value = 0.45
        links.new(grad_ramp.outputs["Color"], mix_tex.inputs["Color1"])
        links.new(diff2.outputs["Color"], mix_tex.inputs["Color2"])

        sep_arm = nodes.new("ShaderNodeSeparateColor")
        links.new(arm1.outputs["Color"], sep_arm.inputs["Color"])

        rough_h = nodes.new("ShaderNodeMath")
        rough_h.operation = 'MULTIPLY_ADD'
        links.new(sep_arm.outputs["Green"], rough_h.inputs[0])
        rough_h.inputs[1].default_value = 0.26
        rough_h.inputs[2].default_value = 0.20

        comb_orm = nodes.new("ShaderNodeCombineColor")
        links.new(sep_arm.outputs["Red"], comb_orm.inputs["Red"])
        links.new(rough_h.outputs["Value"], comb_orm.inputs["Green"])
        comb_orm.inputs["Blue"].default_value = 0.86

        nmap = nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.65
        links.new(nor1.outputs["Color"], nmap.inputs["Color"])

        return mix_tex.outputs["Color"], comb_orm.outputs["Color"], nmap.outputs["Normal"]

    # --- 5. RADIATOR (Deep slate-blue radiator wing panels with crisp silver cooling grid) ---
    def setup_radiator(nodes, links, bsdf):
        tc, mp, diff1, arm1, nor1 = add_ph_set(nodes, links, "blue_metal_plate", scale=2.0)

        brick = nodes.new("ShaderNodeTexBrick")
        brick.offset = 0.0
        brick.inputs["Scale"].default_value = 8.0
        brick.inputs["Brick Width"].default_value = 0.45
        brick.inputs["Row Height"].default_value = 0.45
        brick.inputs["Mortar Size"].default_value = 0.04
        brick.inputs["Mortar Smooth"].default_value = 0.05
        brick.inputs["Color1"].default_value = (0.14, 0.22, 0.35, 1.0)
        brick.inputs["Color2"].default_value = (0.18, 0.27, 0.41, 1.0)
        brick.inputs["Mortar"].default_value = (0.65, 0.70, 0.75, 1.0)
        links.new(tc.outputs["UV"], brick.inputs["Vector"])

        mix_r = nodes.new("ShaderNodeMixRGB")
        mix_r.blend_type = 'OVERLAY'
        mix_r.inputs["Fac"].default_value = 0.35
        links.new(brick.outputs["Color"], mix_r.inputs["Color1"])
        links.new(diff1.outputs["Color"], mix_r.inputs["Color2"])

        sep_arm = nodes.new("ShaderNodeSeparateColor")
        links.new(arm1.outputs["Color"], sep_arm.inputs["Color"])

        comb_orm = nodes.new("ShaderNodeCombineColor")
        links.new(sep_arm.outputs["Red"], comb_orm.inputs["Red"])
        comb_orm.inputs["Green"].default_value = 0.25
        comb_orm.inputs["Blue"].default_value = 0.62

        bump = nodes.new("ShaderNodeBump")
        bump.inputs["Strength"].default_value = 0.35
        links.new(brick.outputs["Fac"], bump.inputs["Height"])

        nmap = nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.55
        links.new(nor1.outputs["Color"], nmap.inputs["Color"])
        links.new(nmap.outputs["Normal"], bump.inputs["Normal"])

        return mix_r.outputs["Color"], comb_orm.outputs["Color"], bump.outputs["Normal"]

    baked_maps = {}
    for mname, fn in [
        ("Paint", setup_paint),
        ("Metal", setup_metal),
        ("Weapons", setup_weapons),
        ("HeatMetal", setup_heatmetal),
        ("Radiator", setup_radiator),
    ]:
        print(f"[Bake] Baking {mname} (1024 Base/Normal, 512 ORM)...")
        baked_maps[mname] = make_source_and_bake(mname, fn)

    bpy.data.objects.remove(bake_obj, do_unlink=True)
    scene.cycles.samples = orig_samples
    scene.cycles.use_denoising = True

    mats = {}
    for mname, (bc_img, orm_img, nor_img) in baked_maps.items():
        mat = bpy.data.materials.new(name=mname)
        nodes = mat.node_tree.nodes
        links = mat.node_tree.links
        nodes.clear()

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

        bc_n = nodes.new("ShaderNodeTexImage")
        bc_n.image = bc_img
        bc_n.name = "BaseColorTex"

        if mname == "Paint":
            mul_n = nodes.new("ShaderNodeMixRGB")
            mul_n.name = "PaintFactorMix"
            mul_n.blend_type = 'MULTIPLY'
            mul_n.inputs["Fac"].default_value = 1.0
            mul_n.inputs["Color2"].default_value = (0.70, 0.70, 0.68, 1.0)
            links.new(bc_n.outputs["Color"], mul_n.inputs["Color1"])
            links.new(mul_n.outputs["Color"], bsdf.inputs["Base Color"])
        else:
            links.new(bc_n.outputs["Color"], bsdf.inputs["Base Color"])

        orm_n = nodes.new("ShaderNodeTexImage")
        orm_n.image = orm_img
        sep_n = nodes.new("ShaderNodeSeparateColor")
        links.new(orm_n.outputs["Color"], sep_n.inputs["Color"])
        links.new(sep_n.outputs["Green"], bsdf.inputs["Roughness"])
        links.new(sep_n.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("Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')
        gltf_n = nodes.new("ShaderNodeGroup")
        gltf_n.node_tree = gltf_group
        links.new(sep_n.outputs["Red"], gltf_n.inputs["Occlusion"])

        nor_t = nodes.new("ShaderNodeTexImage")
        nor_t.image = nor_img
        nmap = nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.65
        links.new(nor_t.outputs["Color"], nmap.inputs["Color"])
        links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])

        mats[mname] = mat

    # --- 6. ENGINES (Emissive inside of magnetic nozzle & thermal louver slats) ---
    mat_eng = bpy.data.materials.new(name="Engines")
    en_nodes = mat_eng.node_tree.nodes
    en_bsdf = en_nodes.get("Principled BSDF")
    en_bsdf.inputs["Base Color"].default_value = (1.0, 0.56, 0.20, 1.0)
    en_bsdf.inputs["Roughness"].default_value = 0.22
    en_bsdf.inputs["Emission Color"].default_value = (1.0, 0.52, 0.16, 1.0)
    en_bsdf.inputs["Emission Strength"].default_value = 3.6
    mats["Engines"] = mat_eng

    # --- 7. LAMPS (Emissive lit crew windows, nav lights & glowing details) ---
    mat_lmp = bpy.data.materials.new(name="Lamps")
    lm_nodes = mat_lmp.node_tree.nodes
    lm_bsdf = lm_nodes.get("Principled BSDF")
    lm_bsdf.inputs["Base Color"].default_value = (0.98, 0.84, 0.55, 1.0)
    lm_bsdf.inputs["Roughness"].default_value = 0.15
    lm_bsdf.inputs["Emission Color"].default_value = (1.0, 0.82, 0.48, 1.0)
    lm_bsdf.inputs["Emission Strength"].default_value = 2.6
    mats["Lamps"] = mat_lmp

    # --- 8. GLASS (Tinted armored bridge glazing, alpha BLEND) ---
    mat_gls = bpy.data.materials.new(name="Glass")
    mat_gls.surface_render_method = 'BLENDED'
    gl_nodes = mat_gls.node_tree.nodes
    gl_bsdf = gl_nodes.get("Principled BSDF")
    gl_bsdf.inputs["Base Color"].default_value = (0.34, 0.45, 0.54, 1.0)
    gl_bsdf.inputs["Metallic"].default_value = 0.25
    gl_bsdf.inputs["Roughness"].default_value = 0.08
    gl_bsdf.inputs["Alpha"].default_value = 0.68
    mats["Glass"] = mat_gls

    return mats


# ---------------------------------------------------------------------------
# Procedural BMesh Geometry Builders (Beveled, Watertight, Detailed)
# ---------------------------------------------------------------------------
class PartCollector:
    """Collects mesh parts per material and joins them into a single clean Mesh Object."""
    def __init__(self, mats):
        self.mats = mats
        self.objs = []

    def add_obj(self, obj, mat_name="Paint", bevel_width=0.0, bevel_segs=2, shade_smooth=False, uv_mode="BOX", uv_scale=0.05):
        mat = self.mats[mat_name]
        if len(obj.data.materials) == 0:
            obj.data.materials.append(mat)
        else:
            obj.data.materials[0] = mat

        if bevel_width > 0.0:
            bev = obj.modifiers.new(name="Bevel", type='BEVEL')
            bev.width = bevel_width
            bev.segments = bevel_segs
            bev.limit_method = 'ANGLE'
            bev.angle_limit = math.radians(28.0)
            bev.harden_normals = False

        bpy.context.view_layer.objects.active = obj
        for mod in list(obj.modifiers):
            try:
                bpy.ops.object.modifier_apply(modifier=mod.name)
            except Exception:
                pass

        me = obj.data
        if not me.uv_layers:
            me.uv_layers.new(name="UVMap")
        bm = bmesh.new()
        bm.from_mesh(me)
        uv_lay = bm.loops.layers.uv.verify()
        mat_world = obj.matrix_world

        for f in bm.faces:
            f.smooth = shade_smooth
            n = (mat_world.to_3x3() @ f.normal).normalized()
            ax, ay, az = abs(n.x), abs(n.y), abs(n.z)
            for loop in f.loops:
                co = mat_world @ loop.vert.co
                if uv_mode == "NOZZLE":
                    ang = math.atan2(co.y, co.x) / (2.0 * math.pi) + 0.5
                    v = max(0.0, min(1.0, (-108.0 - co.z) / 22.0))
                    loop[uv_lay].uv = (ang * 4.0, v)
                elif uv_mode == "TANK_BAND":
                    ang = math.atan2(co.z, co.x) / (2.0 * math.pi) + 0.5
                    loop[uv_lay].uv = (ang * 3.0, 0.35)
                else:
                    if ax >= ay and ax >= az:
                        u, v = co.z * uv_scale, co.y * uv_scale
                    elif ay >= ax and ay >= az:
                        u, v = co.z * uv_scale, co.x * uv_scale
                    else:
                        u, v = co.x * uv_scale, co.y * uv_scale
                    loop[uv_lay].uv = (u, v)

        bm.to_mesh(me)
        bm.free()
        self.objs.append(obj)
        return obj

    def add_box(self, cx, cy, cz, sx, sy, sz, mat_name="Paint", bevel=0.16, segs=2, rot=(0, 0, 0), uv_scale=0.05):
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(cx, cy, cz), rotation=rot)
        obj = bpy.context.active_object
        obj.scale = (sx, sy, sz)
        bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
        return self.add_obj(obj, mat_name, bevel_width=bevel, bevel_segs=segs, shade_smooth=False, uv_scale=uv_scale)

    def add_chamfer_hull_section(self, z0, z1, x_bot0, x_top0, y_bot0, y_top0,
                                 x_bot1, x_top1, y_bot1, y_top1,
                                 chamfer0=1.6, chamfer1=1.6, mat_name="Paint", bevel=0.16):
        """Creates an 8-sided (octagonal chamfered) lofted hull block from z0 to z1."""
        bm = bmesh.new()

        def ring_verts(z, xb, xt, yb, yt, ch):
            c = min(ch, abs(yt - yb) * 0.28, abs(xb) * 0.32, abs(xt) * 0.32)
            pts = [
                (-xt + c, yt, z),
                ( xt - c, yt, z),
                ( xt,     yt - c, z),
                ( xb,     yb + c, z),
                ( xb - c, yb, z),
                (-xb + c, yb, z),
                (-xb,     yb + c, z),
                (-xt,     yt - c, z),
            ]
            return [bm.verts.new(p) for p in pts]

        r0 = ring_verts(z0, x_bot0, x_top0, y_bot0, y_top0, chamfer0)
        r1 = ring_verts(z1, x_bot1, x_top1, y_bot1, y_top1, chamfer1)
        n = len(r0)
        for i in range(n):
            ni = (i + 1) % n
            bm.faces.new([r0[i], r0[ni], r1[ni], r1[i]])
        bm.faces.new(list(reversed(r0)))
        bm.faces.new(r1)
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)

        me = bpy.data.meshes.new("ChamferHull")
        bm.to_mesh(me)
        bm.free()
        obj = bpy.data.objects.new("ChamferHull", me)
        bpy.context.collection.objects.link(obj)
        return self.add_obj(obj, mat_name, bevel_width=bevel, bevel_segs=2, shade_smooth=False)

    def add_cylinder(self, cx, cy, cz, r1, r2, depth, verts=28, rot=(0, 0, 0),
                     mat_name="Metal", bevel=0.07, shade_smooth=True, uv_mode="BOX", uv_scale=0.08):
        bpy.ops.mesh.primitive_cone_add(
            vertices=verts, radius1=r1, radius2=r2, depth=depth,
            location=(cx, cy, cz), rotation=rot
        )
        obj = bpy.context.active_object
        return self.add_obj(obj, mat_name, bevel_width=bevel, bevel_segs=2,
                            shade_smooth=shade_smooth, uv_mode=uv_mode, uv_scale=uv_scale)

    def add_pipe_segment(self, p0, p1, radius=0.32, verts=12, mat_name="Metal", shade_smooth=True):
        v0 = Vector(p0)
        v1 = Vector(p1)
        dv = v1 - v0
        length = dv.length
        if length < 1e-4:
            return None
        mid = (v0 + v1) * 0.5
        quat = dv.normalized().to_track_quat('Z', 'Y')
        bpy.ops.mesh.primitive_cylinder_add(
            vertices=verts, radius=radius, depth=length,
            location=mid, rotation=quat.to_euler()
        )
        obj = bpy.context.active_object
        return self.add_obj(obj, mat_name, bevel_width=0.0, shade_smooth=shade_smooth, uv_scale=0.12)


# ---------------------------------------------------------------------------
# Hull Section Builders (Matching sheet.jpg Silhouette & Proportions)
# ---------------------------------------------------------------------------
def build_forward_command_and_casemate(col):
    """
    Builds:
      1) Forward Command & Weapons Section (Z = +76m to +116.5m) with deep shadowed Spinal Railgun Nose Bay,
         stepped armored command bridge with Glass glazing & visible cockpit/bridge interior,
         side armor cheek plates, panel grooves, lit crew windows, and lower chin mount.
      2) Forward Main Armored Casemate & Side Sponsons (Z = +36m to +76m) + Lower Deep Keel (Z = +18m to +76m).
    """
    # --- A. Forward Command Upper Block (Z = +76.0 to +112.0, Y = +0.4 to +10.2, X = ±8.5) ---
    col.add_chamfer_hull_section(
        z0=75.0, z1=77.2,
        x_bot0=9.4, x_top0=8.6, y_bot0=-8.5, y_top0=10.2,
        x_bot1=8.6, x_top1=7.8, y_bot1=-8.2, y_top1=9.8,
        chamfer0=1.6, chamfer1=1.5, mat_name="Weapons", bevel=0.12
    )
    col.add_chamfer_hull_section(
        z0=76.5, z1=96.0,
        x_bot0=8.8, x_top0=7.8, y_bot0=0.4, y_top0=10.2,
        x_bot1=8.5, x_top1=7.4, y_bot1=0.4, y_top1=9.8,
        chamfer0=1.6, chamfer1=1.5, mat_name="Paint", bevel=0.20
    )
    col.add_box(0.0, 5.0, 96.1, 15.8, 8.2, 0.8, mat_name="Weapons", bevel=0.06)
    col.add_chamfer_hull_section(
        z0=96.4, z1=111.5,
        x_bot0=8.4, x_top0=7.3, y_bot0=0.4, y_top0=9.6,
        x_bot1=7.5, x_top1=6.2, y_bot1=0.6, y_top1=8.4,
        chamfer0=1.5, chamfer1=1.3, mat_name="Paint", bevel=0.20
    )

    # Deep Recessed Nose Armor Hood (Z = +111.2 to +116.5) framing the dark Spinal Railgun Muzzle Bay!
    # Top Cowl Roof
    col.add_chamfer_hull_section(
        z0=111.2, z1=116.5,
        x_bot0=7.4, x_top0=6.2, y_bot0=5.2, y_top0=8.3,
        x_bot1=6.6, x_top1=5.2, y_bot1=4.8, y_top1=7.2,
        chamfer0=1.1, chamfer1=0.9, mat_name="Paint", bevel=0.16
    )
    # Left & Right Nose Cheek Cowls (Paint exterior, Weapons dark inner lining)
    for sx in (-1.0, 1.0):
        col.add_box(sx * 6.3, 1.4, 113.8, 2.2, 7.2, 5.2, mat_name="Paint", bevel=0.16)
        col.add_box(sx * 5.0, 1.4, 113.5, 0.8, 6.2, 4.4, mat_name="Weapons", bevel=0.08)
        col.add_box(sx * 8.0, 3.2, 103.5, 1.4, 3.6, 13.5, mat_name="Paint", bevel=0.14)
        col.add_box(sx * 8.5, 3.8, 86.5, 1.5, 4.2, 16.0, mat_name="Paint", bevel=0.14)
        col.add_box(sx * 7.8, 1.8, 106.5, 1.6, 1.3, 6.8, mat_name="Weapons", bevel=0.08)

    # Dark mechanical recessed nose bay bulkhead & Recessed Spinal Railgun Muzzle (Weapons dark gunmetal!)
    col.add_box(0.0, 1.2, 111.0, 9.6, 7.2, 2.0, mat_name="Weapons", bevel=0.10)
    col.add_cylinder(0.0, 1.2, 112.5, 3.3, 3.0, 3.2, verts=32, mat_name="Weapons", bevel=0.10)
    col.add_cylinder(0.0, 1.2, 113.8, 2.6, 2.3, 2.0, verts=32, mat_name="Weapons", bevel=0.08)
    # Deep dark inner bore of the spinal railgun at the nose
    col.add_cylinder(0.0, 1.2, 114.2, 1.5, 1.5, 1.8, verts=28, mat_name="Weapons", bevel=0.04)
    for k in range(8):
        ang = k * (math.pi / 4.0) + math.pi / 8.0
        rx = math.cos(ang) * 2.55
        ry = 1.2 + math.sin(ang) * 2.55
        col.add_box(rx, ry, 113.5, 0.48, 0.48, 2.4, mat_name="Metal", bevel=0.04, rot=(0, 0, ang))

    # --- B. Forward Command Bridge Glazing & Visible Interior (Z = +104 to +110, Y = +7.0 to +9.2) ---
    col.add_box(0.0, 7.6, 106.5, 6.2, 1.5, 4.4, mat_name="Weapons", bevel=0.06)
    col.add_box(0.0, 7.3, 107.8, 4.4, 0.7, 1.0, mat_name="Metal", bevel=0.04)
    col.add_box(0.0, 7.7, 107.6, 3.8, 0.28, 0.5, mat_name="Lamps", bevel=0.02)
    for sx in (-1.3, 0.0, 1.3):
        col.add_box(sx, 7.4, 106.2, 0.7, 0.9, 0.7, mat_name="Metal", bevel=0.04)
    col.add_box(0.0, 7.9, 109.2, 5.8, 0.95, 0.30, mat_name="Glass", bevel=0.02, rot=(math.radians(18), 0, 0))
    for sx in (-1.0, 1.0):
        col.add_box(sx * 3.5, 7.9, 107.0, 0.30, 0.85, 3.8, mat_name="Glass", bevel=0.02, rot=(0, sx * math.radians(14), 0))

    # --- C. Forward Lower Jaw / Chin Hull Section (Z = +76.0 to +115.5, Y = -9.2 to +0.2) ---
    col.add_box(0.0, 0.1, 95.5, 14.8, 1.2, 37.0, mat_name="Weapons", bevel=0.08)
    col.add_chamfer_hull_section(
        z0=76.5, z1=93.0,
        x_bot0=7.6, x_top0=8.6, y_bot0=-9.2, y_top0=-0.3,
        x_bot1=7.4, x_top1=8.4, y_bot1=-8.9, y_top1=-0.3,
        chamfer0=1.5, chamfer1=1.4, mat_name="Paint", bevel=0.20
    )
    col.add_box(0.0, -4.6, 93.1, 14.6, 7.6, 0.8, mat_name="Weapons", bevel=0.06)
    col.add_chamfer_hull_section(
        z0=93.3, z1=106.0,
        x_bot0=7.3, x_top0=8.3, y_bot0=-8.8, y_top0=-0.3,
        x_bot1=6.8, x_top1=7.8, y_bot1=-8.2, y_top1=-0.3,
        chamfer0=1.4, chamfer1=1.3, mat_name="Paint", bevel=0.20
    )
    col.add_chamfer_hull_section(
        z0=106.2, z1=115.5,
        x_bot0=6.6, x_top0=7.6, y_bot0=-8.0, y_top0=-0.3,
        x_bot1=5.4, x_top1=6.4, y_bot1=-6.8, y_top1=-1.8,
        chamfer0=1.2, chamfer1=1.0, mat_name="Paint", bevel=0.18
    )
    col.add_chamfer_hull_section(
        z0=90.0, z1=112.5,
        x_bot0=4.4, x_top0=5.6, y_bot0=-10.2, y_top0=-7.8,
        x_bot1=4.0, x_top1=5.0, y_bot1=-9.5,  y_top1=-7.2,
        chamfer0=0.9, chamfer1=0.8, mat_name="Weapons", bevel=0.12
    )

    # --- D. Forward Main Armored Casemate (Z = +36.0 to +76.0, Y = -3.0 to +11.6, X = ±11.2) ---
    col.add_chamfer_hull_section(
        z0=36.0, z1=55.5,
        x_bot0=11.2, x_top0=9.8, y_bot0=-3.0, y_top0=11.4,
        x_bot1=11.4, x_top1=10.0, y_bot1=-3.0, y_top1=11.6,
        chamfer0=2.0, chamfer1=2.0, mat_name="Paint", bevel=0.22
    )
    col.add_box(0.0, 4.3, 55.6, 20.6, 13.0, 0.9, mat_name="Weapons", bevel=0.08)
    col.add_chamfer_hull_section(
        z0=55.8, z1=76.0,
        x_bot0=11.4, x_top0=10.0, y_bot0=-3.0, y_top0=11.6,
        x_bot1=10.4, x_top1=9.0,  y_bot1=-2.6, y_top1=10.8,
        chamfer0=2.0, chamfer1=1.8, mat_name="Paint", bevel=0.22
    )
    col.add_chamfer_hull_section(
        z0=40.0, z1=72.0,
        x_bot0=6.8, x_top0=5.4, y_bot0=11.2, y_top0=12.3,
        x_bot1=6.4, x_top1=5.0, y_bot1=11.0, y_top1=12.1,
        chamfer0=1.0, chamfer1=1.0, mat_name="Paint", bevel=0.14
    )

    # Prominent Side Armor Cheek Sponsons (X = ±10.5 to ±14.8, Y = -2.0 to +5.8, Z = +36.5 to +74)
    for sx in (-1.0, 1.0):
        col.add_chamfer_hull_section(
            z0=46.5, z1=74.0,
            x_bot0=2.6, x_top0=2.6, y_bot0=-1.8, y_top0=5.8,
            x_bot1=2.3, x_top1=2.3, y_bot1=-1.5, y_top1=5.5,
            chamfer0=0.7, chamfer1=0.7, mat_name="Paint", bevel=0.18
        )
        sponson_obj = col.objs[-1]
        sponson_obj.location.x = sx * 12.2
        bpy.context.view_layer.objects.active = sponson_obj
        bpy.ops.object.transform_apply(location=True, rotation=False, scale=False)

        col.add_box(sx * 14.3, 2.0, 59.8, 1.0, 4.4, 18.0, mat_name="Paint", bevel=0.14)
        col.add_box(sx * 12.3, 2.0, 73.9, 3.4, 5.2, 1.2, mat_name="Weapons", bevel=0.06)
        for vy in (-0.6, 0.7, 2.0, 3.3, 4.5):
            col.add_box(sx * 12.5, vy, 74.4, 2.8, 0.35, 0.45, mat_name="Metal", bevel=0.03)

        # Two stacked square modular pods at aft end of sponson (Z = +36.5 to +45.5)
        col.add_box(sx * 12.2, 3.9, 41.0, 4.2, 3.3, 8.4, mat_name="Paint", bevel=0.16)
        col.add_box(sx * 12.2, 0.0, 41.0, 4.2, 3.3, 8.4, mat_name="Paint", bevel=0.16)
        col.add_box(sx * 14.1, 3.9, 41.0, 0.7, 2.1, 5.4, mat_name="Metal", bevel=0.06)
        col.add_box(sx * 14.1, 0.0, 41.0, 0.7, 2.1, 5.4, mat_name="Metal", bevel=0.06)

    # --- E. Lower Deep Keel & Overhanging Ventral Bay (Z = +18.0 to +76.0, Y = -13.2 to -2.6) ---
    col.add_chamfer_hull_section(
        z0=42.0, z1=75.8,
        x_bot0=8.4, x_top0=10.6, y_bot0=-13.2, y_top0=-2.6,
        x_bot1=7.6, x_top1=9.4,  y_bot1=-11.8, y_top1=-2.6,
        chamfer0=1.8, chamfer1=1.6, mat_name="Paint", bevel=0.22
    )
    col.add_box(0.0, -7.8, 41.9, 18.2, 9.2, 0.9, mat_name="Weapons", bevel=0.08)
    col.add_chamfer_hull_section(
        z0=18.0, z1=41.7,
        x_bot0=7.4, x_top0=9.2,  y_bot0=-12.2, y_top0=-4.2,
        x_bot1=8.3, x_top1=10.4, y_bot1=-13.2, y_top1=-2.6,
        chamfer0=1.6, chamfer1=1.8, mat_name="Paint", bevel=0.22
    )
    for sx in (-1.0, 1.0):
        col.add_box(sx * 9.3, -8.2, 29.8, 0.8, 5.2, 19.5, mat_name="Paint", bevel=0.16)
        col.add_box(sx * 9.5, -8.2, 58.5, 0.8, 5.6, 26.0, mat_name="Paint", bevel=0.16)

    # --- F. Human-Scale Lit Crew Windows (Lamps) on Forward Command & Casemate ---
    for sx in (-1.0, 1.0):
        for zw in (82.0, 84.5, 87.0, 99.0, 101.5, 104.0):
            col.add_box(sx * 8.25, 6.4, zw, 0.28, 0.24, 0.85, mat_name="Lamps", bevel=0.0)
        for zw in (42.0, 45.0, 48.0, 60.0, 63.0, 66.0):
            col.add_box(sx * 10.75, 8.6, zw, 0.28, 0.26, 0.95, mat_name="Lamps", bevel=0.0)


def build_spinal_railgun_and_trusses(col):
    """
    Builds the central open spine section (Z = +36m to -36m):
      1) Exposed Spinal Railgun Barrel (Y = +1.0m):
         - Forward breech drum at Z = +30..+36m
         - Long grooved electromagnetic rail barrel from Z = -4m to +30m
         - Stepped aft accelerator drums at Z = -4..-18m and Z = -18..-36m with heavy flanged collars
      2) Upper Open Box Warren-Truss (Z = +36m to -36m, Y = +7.5m to +10.8m, X = ±5.8m).
      3) Lower Open Box Warren-Truss (Z = +18m to -36m, Y = -6.5m to -9.8m, X = ±5.5m).
    """
    y_gun = 1.0

    col.add_cylinder(0.0, y_gun, 33.25, 3.6, 3.6, 6.5, verts=32, mat_name="Weapons", bevel=0.12)
    col.add_cylinder(0.0, y_gun, 29.8, 3.9, 3.9, 1.3, verts=32, mat_name="Metal", bevel=0.08)

    col.add_cylinder(0.0, y_gun, 13.0, 2.35, 2.35, 34.5, verts=32, mat_name="Weapons", bevel=0.08)
    for dx, dy, sx, sy in [(0, 2.15, 1.85, 0.90), (0, -2.15, 1.85, 0.90), (2.25, 0, 0.85, 1.4), (-2.25, 0, 0.85, 1.4)]:
        col.add_box(dx, y_gun + dy, 13.0, sx, sy, 34.0, mat_name="Metal", bevel=0.06)

    col.add_cylinder(0.0, y_gun, -4.2, 3.65, 3.65, 1.6, verts=32, mat_name="Metal", bevel=0.09)
    col.add_cylinder(0.0, y_gun, -10.75, 3.15, 3.15, 13.5, verts=32, mat_name="Weapons", bevel=0.08)
    col.add_cylinder(0.0, y_gun, -17.7, 4.25, 4.25, 1.8, verts=32, mat_name="Metal", bevel=0.09)
    col.add_cylinder(0.0, y_gun, -22.75, 3.70, 3.70, 10.5, verts=32, mat_name="Weapons", bevel=0.08)
    col.add_cylinder(0.0, y_gun, -28.2, 4.65, 4.65, 1.7, verts=32, mat_name="Metal", bevel=0.09)
    col.add_cylinder(0.0, y_gun, -32.25, 4.15, 4.15, 8.5, verts=32, mat_name="Weapons", bevel=0.08)
    for k in range(8):
        ang = k * (math.pi / 4.0)
        rx = math.cos(ang) * 3.95
        ry = y_gun + math.sin(ang) * 3.95
        col.add_pipe_segment((rx, ry, -36.0), (rx * 0.80, y_gun + math.sin(ang) * 3.15, -4.5), radius=0.26, mat_name="Metal")

    def build_warren_truss(z_start, z_end, x_half, y_bot, y_top, num_bays):
        length = z_start - z_end
        mid_z = 0.5 * (z_start + z_end)
        for sx in (-x_half, x_half):
            for cy in (y_bot, y_top):
                col.add_box(sx, cy, mid_z, 0.78, 0.78, length, mat_name="Metal", bevel=0.05)
        col.add_box(0.0, y_top if y_top > 0 else y_bot, mid_z, x_half * 1.95, 0.55, length, mat_name="Paint", bevel=0.06)

        dz = length / float(num_bays)
        for i in range(num_bays + 1):
            zi = z_start - i * dz
            for sx in (-x_half, x_half):
                col.add_box(sx, 0.5 * (y_bot + y_top), zi, 0.55, y_top - y_bot, 0.55, mat_name="Metal", bevel=0.04)
            for cy in (y_bot, y_top):
                col.add_box(0.0, cy, zi, x_half * 2.0, 0.55, 0.55, mat_name="Metal", bevel=0.04)

            if i < num_bays:
                z_next = zi - dz
                z_mid = 0.5 * (zi + z_next)
                for sx in (-x_half, x_half):
                    col.add_pipe_segment((sx, y_bot, zi), (sx, y_top, z_mid), radius=0.32, verts=8, mat_name="Metal", shade_smooth=False)
                    col.add_pipe_segment((sx, y_top, z_mid), (sx, y_bot, z_next), radius=0.32, verts=8, mat_name="Metal", shade_smooth=False)
                col.add_pipe_segment((-x_half, y_top, zi), (x_half, y_top, z_next), radius=0.24, verts=8, mat_name="Metal", shade_smooth=False)
                col.add_pipe_segment((x_half, y_top, zi), (-x_half, y_top, z_next), radius=0.24, verts=8, mat_name="Metal", shade_smooth=False)

    build_warren_truss(z_start=36.0, z_end=-36.0, x_half=5.8, y_bot=7.5, y_top=10.8, num_bays=9)
    build_warren_truss(z_start=18.0, z_end=-36.0, x_half=5.5, y_bot=-9.8, y_top=-6.6, num_bays=7)


def build_aft_hull_tanks_and_drone_racks(col):
    """
    Builds the Aft Engineering, Propellant, Drone Launch Rack & Reactor Section (Z = -36m to -108m):
      1) Forward Aft-Hull Vertical Bulkhead Collar (Z = -36m to -45m, Y = -11.0m to +11.6m).
      2) Central Aft Engineering Core & Dorsal Superstructure (Z = -45m to -92m).
      3) 8 Spherical Propellant Tanks (Z = -48.5m and -62.5m, Y = +5.2m and -6.2m, X = ±12.2m, radius = 5.4m)
         crafted in brushed cryo-alloy Metal with HeatMetal bronze equatorial bands!
      4) Ventral & Lateral Drone Launch Racks (cellular launch bays with frames).
      5) Aft Fusion Drive Collar & Thermal Exchanger Louver Bays (Z = -92m to -108m).
    """
    col.add_chamfer_hull_section(
        z0=-45.0, z1=-36.0,
        x_bot0=10.8, x_top0=9.8, y_bot0=-11.0, y_top0=11.6,
        x_bot1=10.4, x_top1=9.4, y_bot1=-10.6, y_top1=11.4,
        chamfer0=1.8, chamfer1=1.8, mat_name="Paint", bevel=0.22
    )
    col.add_box(0.0, 0.2, -45.1, 19.8, 20.8, 0.9, mat_name="Weapons", bevel=0.08)

    col.add_chamfer_hull_section(
        z0=-45.0, z1=-36.2,
        x_bot0=4.4, x_top0=5.6, y_bot0=-12.4, y_top0=-10.5,
        x_bot1=3.8, x_top1=5.0, y_bot1=-12.1, y_top1=-10.5,
        chamfer0=0.8, chamfer1=0.8, mat_name="Paint", bevel=0.14
    )

    col.add_chamfer_hull_section(
        z0=-92.0, z1=-45.2,
        x_bot0=10.4, x_top0=9.6, y_bot0=-10.6, y_top0=11.6,
        x_bot1=10.6, x_top1=9.8, y_bot1=-10.8, y_top1=11.6,
        chamfer0=1.8, chamfer1=1.8, mat_name="Paint", bevel=0.22
    )
    col.add_chamfer_hull_section(
        z0=-90.0, z1=-45.5,
        x_bot0=7.2, x_top0=5.8, y_bot0=11.3, y_top0=12.8,
        x_bot1=7.2, x_top1=5.8, y_bot1=11.3, y_top1=12.6,
        chamfer0=1.0, chamfer1=1.0, mat_name="Paint", bevel=0.16
    )
    col.add_chamfer_hull_section(
        z0=-83.5, z1=-69.5,
        x_bot0=5.2, x_top0=4.0, y_bot0=12.5, y_top0=13.8,
        x_bot1=5.2, x_top1=4.0, y_bot1=12.5, y_top1=13.8,
        chamfer0=0.8, chamfer1=0.8, mat_name="Paint", bevel=0.14
    )
    for sx in (-1.0, 1.0):
        col.add_box(sx * 9.2, 8.8, -78.0, 2.8, 2.6, 20.0, mat_name="Paint", bevel=0.14,
                    rot=(0, 0, -sx * math.radians(38)))

    # --- 3. Eight Spherical Propellant Tanks (Z = -48.5 and -62.0, Y = +5.2 and -6.2, X = ±12.2, radius = 5.45m) ---
    # Using brushed Metal for the cryo-tank hemispheres + HeatMetal bronze equatorial bands (matches sheet.jpg!)
    tank_z_coords = (-48.5, -62.0)
    tank_y_coords = (5.2, -6.2)
    for sx in (-1.0, 1.0):
        for tz in tank_z_coords:
            for ty in tank_y_coords:
                tx = sx * 12.2
                bpy.ops.mesh.primitive_uv_sphere_add(
                    segments=36, ring_count=24, radius=5.45, location=(tx, ty, tz)
                )
                sph = bpy.context.active_object
                col.add_obj(sph, mat_name="Metal", bevel_width=0.0, shade_smooth=True, uv_scale=0.09)

                col.add_cylinder(
                    tx, ty, tz, r1=5.62, r2=5.62, depth=1.50, verts=36,
                    rot=(math.radians(90), 0, 0), mat_name="HeatMetal",
                    bevel=0.08, shade_smooth=True, uv_mode="TANK_BAND"
                )
                for dy_strap in (-0.88, 0.88):
                    col.add_cylinder(
                        tx, ty + dy_strap, tz, r1=5.54, r2=5.54, depth=0.20, verts=36,
                        rot=(math.radians(90), 0, 0), mat_name="Weapons",
                        bevel=0.03, shade_smooth=True
                    )
                col.add_cylinder(
                    tx + sx * 5.1, ty, tz, r1=1.3, r2=1.0, depth=0.8, verts=20,
                    rot=(0, sx * math.radians(90), 0), mat_name="Weapons", bevel=0.05, shade_smooth=True
                )

    # --- 4. Drone Launch Racks (Ventral & Lower-Side Cellular Racks at Z = -46 to -88) ---
    col.add_chamfer_hull_section(
        z0=-89.5, z1=-45.2,
        x_bot0=7.6, x_top0=9.6, y_bot0=-12.0, y_top0=-9.8,
        x_bot1=7.6, x_top1=9.6, y_bot1=-12.0, y_top1=-9.8,
        chamfer0=1.2, chamfer1=1.2, mat_name="Weapons", bevel=0.14
    )
    for sx in (-1.0, 1.0):
        for z_cell in range(-86, -46, 5):
            col.add_box(sx * 4.2, -11.9, float(z_cell), 2.8, 0.7, 3.6, mat_name="Metal", bevel=0.06)
            col.add_box(sx * 4.2, -12.1, float(z_cell), 2.2, 0.4, 2.8, mat_name="Weapons", bevel=0.04)
            col.add_box(sx * 9.6, -10.8, float(z_cell), 1.8, 1.5, 3.4, mat_name="Metal", bevel=0.06)

    # --- 5. Aft Fusion Drive Collar & Thermal Louver Bays (Z = -108.0 to -92.0) ---
    col.add_box(0.0, 0.2, -92.1, 19.6, 20.4, 0.9, mat_name="Weapons", bevel=0.08)
    col.add_chamfer_hull_section(
        z0=-108.0, z1=-92.2,
        x_bot0=11.0, x_top0=10.2, y_bot0=-10.0, y_top0=10.4,
        x_bot1=12.0, x_top1=11.2, y_bot1=-10.6, y_top1=11.2,
        chamfer0=2.4, chamfer1=2.2, mat_name="Paint", bevel=0.22
    )
    # Port & Starboard Aft Side Cooling Louver Bays (dark metallic louvers on side, glowing orange facing -Z aft!)
    for sx in (-1.0, 1.0):
        col.add_box(sx * 12.2, 0.2, -99.5, 2.8, 6.2, 11.0, mat_name="Weapons", bevel=0.14)
        col.add_box(sx * 12.7, 0.2, -99.5, 2.2, 5.0, 9.4, mat_name="Weapons", bevel=0.10)
        for ly in (-1.6, -0.8, 0.0, 0.8, 1.6):
            col.add_box(sx * 13.4, 0.2 + ly, -99.2, 0.8, 0.36, 7.6, mat_name="Metal", bevel=0.03)
            col.add_box(sx * 12.2, 0.2 + ly, -104.8, 1.8, 0.40, 0.5, mat_name="Engines", bevel=0.03)

    # Ventral Aft Point-Defence / Thermal Louver Pod at Z = -99.5, Y = -11.0 (glowing orange slats as in sheet.jpg!)
    col.add_box(0.0, -10.8, -99.5, 8.8, 2.8, 7.8, mat_name="Weapons", bevel=0.14)
    col.add_box(0.0, -11.0, -99.5, 7.4, 2.2, 6.4, mat_name="Metal", bevel=0.10)
    for ly in (-11.6, -10.9, -10.2):
        col.add_box(0.0, ly, -99.5, 6.8, 0.32, 5.2, mat_name="Engines", bevel=0.03)
    for lx in (-2.4, -1.2, 0.0, 1.2, 2.4):
        col.add_box(lx, -11.0, -103.2, 0.6, 1.5, 0.5, mat_name="Engines", bevel=0.03)

    for sx in (-1.0, 1.0):
        col.add_cylinder(sx * 8.2, -7.6, -105.0, 2.0, 1.8, 5.5, verts=24, mat_name="Weapons", bevel=0.08)
        col.add_cylinder(sx * 8.2, -7.6, -107.8, 1.4, 1.2, 0.6, verts=20, mat_name="HeatMetal", bevel=0.05)

    for sx in (-1.0, 1.0):
        for zw in (-86.0, -83.0, -60.0, -57.0, -54.0):
            col.add_box(sx * 9.95, 10.2, zw, 0.28, 0.26, 0.95, mat_name="Lamps", bevel=0.0)


def build_diamond_radiator_wings(col):
    """
    Builds the Big Diamond-Shaped Radiator Wings (Z = -20.0m to -94.0m, centered at Z = -57.0m):
      - Overall length along Z: 74.0 m
      - Overall height along Y: Y = -20.0m to +20.0m (40.0 m)
      - Overall width along X:  X = -55.0m to +55.0m (110.0 m)
      - Straight horizontal root boom at |X| = 18.6m, Y = -0.5m (right between the upper & lower spherical tanks!).
      - Upper apex at (|X| = 29.5m, Y = +19.6m, Z = -57.0m) and Lower apex at (|X| = 54.6m, Y = -19.6m, Z = -57.0m)
        so both the Upper and Lower triangular radiator wings present wide, balanced faces in the 3/4 view!
    """
    z_front = -20.0
    z_mid = -57.0
    z_back = -94.0

    for sx in (-1.0, 1.0):
        x_root = sx * 18.6

        # 1. Straight Horizontal Manifold Boom between the Upper & Lower Spherical Tanks (Z = -20 to -94, Y = -0.5)
        col.add_box(x_root, -0.5, z_mid, 2.4, 1.8, 74.0, mat_name="Metal", bevel=0.12)

        # 6 Rectangular Heat-Exchanger Manifold Blocks along the central hinge line
        for idx in range(6):
            zb = -34.5 - idx * 9.0
            col.add_box(x_root + sx * 0.6, -0.5, zb, 2.4, 2.2, 6.8, mat_name="Paint", bevel=0.10)
            col.add_box(x_root + sx * 1.3, -0.5, zb, 1.4, 1.6, 5.4, mat_name="Weapons", bevel=0.06)

        for zs in (-38.0, -55.5, -73.0, -88.0):
            col.add_pipe_segment((sx * 9.6, -0.5, zs), (x_root, -0.5, zs), radius=0.78, verts=14, mat_name="Metal")

        # 2. Upper (+1) and Lower (-1) Flat Triangular Radiator Wing Panels
        for sy in (-1.0, 1.0):
            y_base = -0.5 + sy * 0.95
            p_front = Vector((x_root, y_base, z_front))
            p_back  = Vector((x_root, y_base, z_back))
            p_base_mid = Vector((x_root, y_base, z_mid))
            # Upper apex at |X| = 29.5m, Y = +19.6m (wide open to 3/4 camera!); Lower apex at |X| = 54.6m, Y = -19.6m (110m span!)
            x_apex = sx * (29.5 if sy > 0 else 54.6)
            p_apex = Vector((x_apex, sy * 19.6, z_mid))

            n_wing = (p_front - p_base_mid).cross(p_apex - p_base_mid).normalized()
            half_t = 0.22
            bm = bmesh.new()
            top_v = [bm.verts.new(v + n_wing * half_t) for v in (p_front, p_back, p_apex)]
            bot_v = [bm.verts.new(v - n_wing * half_t) for v in (p_front, p_back, p_apex)]
            bm.faces.new(top_v)
            bm.faces.new(list(reversed(bot_v)))
            for i in range(3):
                ni = (i + 1) % 3
                bm.faces.new([top_v[i], bot_v[i], bot_v[ni], top_v[ni]])
            bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
            me = bpy.data.meshes.new("WingPanelTri")
            bm.to_mesh(me)
            bm.free()
            obj = bpy.data.objects.new("WingPanelTri", me)
            bpy.context.collection.objects.link(obj)
            col.add_obj(obj, mat_name="Radiator", bevel_width=0.04, bevel_segs=1, shade_smooth=False, uv_scale=0.14)

            # Heavy Beveled Metallic Perimeter Frame & Central Radial Rib (Metal)
            col.add_pipe_segment(p_front, p_apex, radius=0.56, verts=8, mat_name="Metal", shade_smooth=False)
            col.add_pipe_segment(p_back,  p_apex, radius=0.56, verts=8, mat_name="Metal", shade_smooth=False)
            col.add_pipe_segment(p_front, p_back, radius=0.60, verts=8, mat_name="Metal", shade_smooth=False)
            col.add_pipe_segment(p_base_mid, p_apex, radius=0.54, verts=8, mat_name="Metal", shade_smooth=False)

            for frac in (0.34, 0.67):
                p_edge_lead  = p_front.lerp(p_apex, frac)
                p_edge_trail = p_back.lerp(p_apex, frac)
                col.add_pipe_segment(p_base_mid, p_edge_lead,  radius=0.38, verts=8, mat_name="Metal", shade_smooth=False)
                col.add_pipe_segment(p_base_mid, p_edge_trail, radius=0.38, verts=8, mat_name="Metal", shade_smooth=False)

            for k in range(1, 8):
                t = k / 8.5
                pa = p_front.lerp(p_apex, t)
                pb = p_back.lerp(p_apex, t)
                col.add_pipe_segment(pa, pb, radius=0.13, verts=6, mat_name="Metal", shade_smooth=False)


def build_fusion_drive_and_nozzle(col):
    """
    Builds the Aft Fusion Drive & Large Magnetic Nozzle (Z = -107.0m to -130.0m):
      - Throat actuator ring & 16 axial cooling/magnetic field struts (Z = -107.0 to -111.0)
      - Glowing plasma throat collar band & smooth flared HeatMetal magnetic nozzle cone (Z = -109.5 to -130.0m)
        with brushed Metal aft rim rings and fine longitudinal flutes (matching sheet.jpg!).
    """
    col.add_cylinder(0.0, 0.0, -108.0, 7.8, 7.4, 2.4, verts=40, mat_name="Weapons", bevel=0.10)
    col.add_cylinder(0.0, 0.0, -110.8, 7.75, 7.35, 3.8, verts=48, mat_name="Engines", bevel=0.0, shade_smooth=True)

    for k in range(16):
        ang = k * (2.0 * math.pi / 16.0)
        rx = math.cos(ang) * 7.65
        ry = math.sin(ang) * 7.65
        col.add_pipe_segment((rx * 1.04, ry * 1.04, -106.8), (rx, ry, -112.5), radius=0.28, verts=10, mat_name="Metal")

    nozzle_segs = [
        (-111.5, -116.0, 7.70,  8.55),
        (-116.0, -120.8, 8.55,  9.30),
        (-120.8, -125.5, 9.30,  9.95),
        (-125.5, -130.0, 9.95, 10.50),
    ]
    for z0, z1, r0, r1 in nozzle_segs:
        zc = 0.5 * (z0 + z1)
        dz = abs(z0 - z1)
        col.add_cylinder(
            0.0, 0.0, zc, r1=r1, r2=r0, depth=dz, verts=64,
            mat_name="HeatMetal", bevel=0.04, shade_smooth=True, uv_mode="NOZZLE"
        )

    ring_specs = [
        (-112.0, 7.90,  0.65, "HeatMetal"),
        (-124.2, 9.85,  0.75, "Metal"),
        (-129.2, 10.62, 1.45, "Metal"),
    ]
    for zr, rr, dr, mname in ring_specs:
        col.add_cylinder(0.0, 0.0, zr, r1=rr, r2=rr, depth=dr, verts=64, mat_name=mname, bevel=0.06, shade_smooth=True)

    for k in range(24):
        ang = k * (2.0 * math.pi / 24.0)
        p0 = (math.cos(ang) * 7.72,  math.sin(ang) * 7.72,  -111.5)
        p1 = (math.cos(ang) * 10.52, math.sin(ang) * 10.52, -129.8)
        col.add_pipe_segment(p0, p1, radius=0.11, verts=6, mat_name="HeatMetal")

    bm = bmesh.new()
    verts_n = 48
    ring_defs = [
        (-110.5, 0.0),
        (-111.0, 6.0),
        (-116.0, 7.6),
        (-123.0, 8.9),
        (-129.6, 10.0),
        (-130.0, 10.50),
    ]
    rings = []
    for zr, rr in ring_defs:
        if rr == 0.0:
            rings.append([bm.verts.new((0.0, 0.0, zr))])
        else:
            r_list = []
            for i in range(verts_n):
                ang = i * (2.0 * math.pi / verts_n)
                r_list.append(bm.verts.new((math.cos(ang) * rr, math.sin(ang) * rr, zr)))
            rings.append(r_list)

    c_vert = rings[0][0]
    for i in range(verts_n):
        ni = (i + 1) % verts_n
        bm.faces.new([c_vert, rings[1][ni], rings[1][i]])

    for r_idx in range(1, len(rings) - 1):
        ra = rings[r_idx]
        rb = rings[r_idx + 1]
        for i in range(verts_n):
            ni = (i + 1) % verts_n
            bm.faces.new([ra[i], ra[ni], rb[ni], rb[i]])

    me = bpy.data.meshes.new("NozzleInnerGlow")
    bm.to_mesh(me)
    bm.free()
    glow_obj = bpy.data.objects.new("NozzleInnerGlow", me)
    bpy.context.collection.objects.link(glow_obj)
    col.add_obj(glow_obj, mat_name="Engines", bevel_width=0.0, shade_smooth=True)

    for zp, rp in [(-113.0, 6.3), (-117.5, 7.2), (-122.0, 8.1)]:
        bpy.ops.mesh.primitive_torus_add(
            major_radius=rp, minor_radius=0.32, major_segments=36, minor_segments=12,
            location=(0.0, 0.0, zp)
        )
        tor = bpy.context.active_object
        col.add_obj(tor, mat_name="Engines", bevel_width=0.0, shade_smooth=True)


def build_surface_greebles_and_pipes(col):
    """
    Adds fine close-up and mid-scale architectural details across the 260m hull.
    """
    for sx in (-1.0, 1.0):
        col.add_pipe_segment((sx * 4.8, 9.8, 77.0), (sx * 4.0, 8.6, 110.0), radius=0.22, verts=10, mat_name="Metal")
        col.add_pipe_segment((sx * 8.6, 11.7, 37.0), (sx * 8.0, 11.0, 75.0), radius=0.26, verts=10, mat_name="Metal")
        col.add_pipe_segment((sx * 6.4, 11.8, -89.0), (sx * 6.4, 11.8, -37.0), radius=0.28, verts=10, mat_name="Metal")
        col.add_pipe_segment((sx * 3.4, -7.2, 18.5), (sx * 3.4, -7.2, -36.5), radius=0.34, verts=12, mat_name="HeatMetal")
        col.add_pipe_segment((sx * 2.1, -7.2, 18.5), (sx * 2.1, -7.2, -36.5), radius=0.26, verts=12, mat_name="Metal")

        col.add_cylinder(
            sx * 9.6, -6.8, 60.0, r1=1.6, r2=1.4, depth=0.8, verts=24,
            rot=(0, sx * math.radians(90), 0), mat_name="Metal", bevel=0.06
        )
        col.add_cylinder(
            sx * 9.8, -6.8, 60.0, r1=1.0, r2=1.0, depth=0.4, verts=20,
            rot=(0, sx * math.radians(90), 0), mat_name="Weapons", bevel=0.04
        )

    for zm, ym, sy_dir in [(98.0, 9.2, 1.0), (66.0, 12.1, 1.0), (-50.0, 12.7, 1.0), (22.0, -12.2, -1.0)]:
        for sx in (-2.0, 2.0):
            col.add_pipe_segment((sx, ym, zm), (sx, ym + sy_dir * 2.2, zm - 0.5), radius=0.12, verts=8, mat_name="Metal")
            col.add_box(sx, ym + sy_dir * 1.3, zm - 0.25, 0.30, 0.30, 1.4, mat_name="Weapons", bevel=0.03)


# ---------------------------------------------------------------------------
# Moving Turret & Gun Node Builders (Origins on exact Pivot Axes!)
# ---------------------------------------------------------------------------
def finalize_mesh_hierarchy_object(objs, name, pivot_world):
    bpy.ops.object.select_all(action='DESELECT')
    for o in objs:
        o.select_set(True)
    bpy.context.view_layer.objects.active = objs[0]
    if len(objs) > 1:
        bpy.ops.object.join()
    merged = bpy.context.active_object
    merged.name = name
    merged.data.name = f"{name}_Mesh"

    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
    pw = Vector(pivot_world)
    me = merged.data
    bm = bmesh.new()
    bm.from_mesh(me)
    bmesh.ops.translate(bm, verts=bm.verts, vec=-pw)
    bm.to_mesh(me)
    bm.free()
    merged.location = pw
    merged.rotation_euler = (0.0, 0.0, 0.0)
    merged.scale = (1.0, 1.0, 1.0)
    return merged


def build_turrets_and_nodes(mats, hull_obj):
    axes_map = {}

    def make_empty(name, world_pos, parent_obj):
        emp = bpy.data.objects.new(name, None)
        emp.empty_display_type = 'PLAIN_AXES'
        emp.empty_display_size = 1.5
        bpy.context.collection.objects.link(emp)
        emp.parent = parent_obj
        emp.location = Vector(world_pos) - parent_obj.matrix_world.translation
        return emp

    # --- Turret_1 & Gun_1: Forward Chin Twin Cannon / Sensor Array (reaches Z = +130.0m!) ---
    t1_piv = Vector((0.0, -8.8, 111.0))
    t1_col = PartCollector(mats)
    t1_col.add_cylinder(0.0, -8.6, 111.0, 3.0, 2.6, 1.4, verts=24, rot=(math.radians(90), 0, 0), mat_name="Weapons", bevel=0.08)
    t1_col.add_chamfer_hull_section(
        z0=107.5, z1=114.2,
        x_bot0=3.6, x_top0=4.2, y_bot0=-9.2, y_top0=-7.0,
        x_bot1=3.2, x_top1=3.8, y_bot1=-8.8, y_top1=-6.8,
        chamfer0=0.7, chamfer1=0.7, mat_name="Weapons", bevel=0.12
    )
    turret_1 = finalize_mesh_hierarchy_object(t1_col.objs, "Turret_1", t1_piv)
    turret_1.parent = hull_obj
    turret_1.location = t1_piv
    axes_map["Turret_1"] = [0, 1, 0]

    g1_piv = Vector((0.0, -8.0, 113.5))
    g1_col = PartCollector(mats)
    g1_col.add_box(0.0, -8.0, 113.8, 6.2, 1.2, 2.4, mat_name="Weapons", bevel=0.08)
    for sx in (-1.0, 1.0):
        bx = sx * 2.5
        g1_col.add_cylinder(bx, -8.0, 117.25, 0.56, 0.48, 7.5, verts=20, mat_name="Weapons", bevel=0.04)
        g1_col.add_cylinder(bx, -8.0, 124.25, 0.35, 0.31, 6.5, verts=18, mat_name="Metal", bevel=0.03)
        g1_col.add_cylinder(bx, -8.0, 128.75, 0.40, 0.36, 2.5, verts=18, mat_name="Weapons", bevel=0.03)
        g1_col.add_cylinder(bx * 0.65, -8.65, 118.5, 0.25, 0.18, 9.0, verts=14, mat_name="Metal", bevel=0.02)

    gun_1 = finalize_mesh_hierarchy_object(g1_col.objs, "Gun_1", g1_piv)
    gun_1.parent = turret_1
    gun_1.location = g1_piv - t1_piv
    axes_map["Gun_1"] = [1, 0, 0]

    make_empty("Muzzle_1", ( 2.5, -8.0, 130.0), gun_1)
    make_empty("Muzzle_2", (-2.5, -8.0, 130.0), gun_1)

    def make_pd_turret(idx, muzzle_idx, t_pos, y_sign=1.0, barrel_len=9.5, scale_f=1.0):
        tp = Vector(t_pos)
        t_col = PartCollector(mats)
        t_col.add_cylinder(
            tp.x, tp.y + y_sign * 0.32 * scale_f, tp.z,
            r1=2.9 * scale_f, r2=2.6 * scale_f, depth=0.75 * scale_f, verts=24,
            rot=(math.radians(90), 0, 0), mat_name="Metal", bevel=0.07 * scale_f
        )
        y0 = tp.y + (0.35 * scale_f if y_sign > 0 else -2.1 * scale_f)
        y1 = tp.y + (2.1 * scale_f if y_sign > 0 else -0.35 * scale_f)
        t_col.add_chamfer_hull_section(
            z0=tp.z - 3.4 * scale_f, z1=tp.z + 3.0 * scale_f,
            x_bot0=2.5 * scale_f, x_top0=1.9 * scale_f, y_bot0=y0, y_top0=y1,
            x_bot1=2.1 * scale_f, x_top1=1.5 * scale_f, y_bot1=y0, y_top1=y1,
            chamfer0=0.50 * scale_f, chamfer1=0.40 * scale_f, mat_name="Paint", bevel=0.09 * scale_f
        )
        t_col.add_box(tp.x, tp.y + y_sign * 2.25 * scale_f, tp.z - 0.4 * scale_f,
                      1.4 * scale_f, 0.50 * scale_f, 2.0 * scale_f, mat_name="Weapons", bevel=0.04 * scale_f)

        t_name = f"Turret_{idx}"
        t_obj = finalize_mesh_hierarchy_object(t_col.objs, t_name, tp)
        t_obj.parent = hull_obj
        t_obj.location = tp
        axes_map[t_name] = [0, 1, 0]

        gp = Vector((tp.x, tp.y + y_sign * 1.35 * scale_f, tp.z + 2.0 * scale_f))
        g_col = PartCollector(mats)
        g_col.add_box(gp.x, gp.y, gp.z, 2.3 * scale_f, 0.85 * scale_f, 1.6 * scale_f, mat_name="Weapons", bevel=0.05 * scale_f)
        for sx in (-0.75 * scale_f, 0.75 * scale_f):
            g_col.add_cylinder(gp.x + sx, gp.y, gp.z + 2.0 * scale_f,
                               0.34 * scale_f, 0.30 * scale_f, 3.4 * scale_f, verts=16, mat_name="Weapons", bevel=0.03 * scale_f)
            g_col.add_cylinder(gp.x + sx, gp.y, gp.z + 0.5 * barrel_len,
                               0.21 * scale_f, 0.17 * scale_f, barrel_len, verts=16, mat_name="Metal", bevel=0.02 * scale_f)

        g_name = f"Gun_{idx}"
        g_obj = finalize_mesh_hierarchy_object(g_col.objs, g_name, gp)
        g_obj.parent = t_obj
        g_obj.location = gp - tp
        axes_map[g_name] = [1, 0, 0]

        make_empty(f"Muzzle_{muzzle_idx}", (gp.x, gp.y, gp.z + barrel_len), g_obj)

    make_pd_turret(2, 3, (0.0, 12.2, 57.0), y_sign=1.0, barrel_len=7.0, scale_f=0.95)
    make_pd_turret(3, 4, (0.0, 11.6, -40.5), y_sign=1.0, barrel_len=11.5, scale_f=1.05)
    make_pd_turret(4, 5, (0.0, -12.2, -40.5), y_sign=-1.0, barrel_len=10.5, scale_f=0.95)
    make_pd_turret(5, 6, (0.0, 13.7, -76.5), y_sign=1.0, barrel_len=9.0, scale_f=1.00)
    make_pd_turret(6, 7, ( 9.4, 9.6, -81.0), y_sign=1.0, barrel_len=6.0, scale_f=0.72)
    make_pd_turret(7, 8, (-9.4, 9.6, -81.0), y_sign=1.0, barrel_len=6.0, scale_f=0.72)
    make_pd_turret(8, 9, (0.0, -11.8, -89.5), y_sign=-1.0, barrel_len=6.5, scale_f=0.80)

    make_empty("Muzzle_10", (0.0, 1.2, 115.0), hull_obj)

    make_empty("Thruster_1", ( 0.0,   0.0, -130.0), hull_obj)
    make_empty("Thruster_2", ( 12.6,  0.2, -104.8), hull_obj)
    make_empty("Thruster_3", (-12.6,  0.2, -104.8), hull_obj)
    make_empty("Thruster_4", ( 0.0, -11.0, -103.2), hull_obj)

    hp_coords = [
        ( 12.2,  3.9,  45.0),
        (-12.2,  3.9,  45.0),
        ( 12.2,  0.0,  45.0),
        (-12.2,  0.0,  45.0),
        (  4.2, -12.1, -55.0),
        ( -4.2, -12.1, -55.0),
        (  4.2, -12.1, -75.0),
        ( -4.2, -12.1, -75.0),
    ]
    for i, hpos in enumerate(hp_coords, start=1):
        make_empty(f"Hardpoint_{i}", hpos, hull_obj)

    make_empty("Seat_Pilot",   (  0.0,   7.4,  106.2), hull_obj)
    make_empty("Camera_Chase", (  0.0,  42.0, -195.0), hull_obj)
    make_empty("Light_Nav_L",  ( 54.6, -19.6,  -57.0), hull_obj)
    make_empty("Light_Nav_R",  (-54.6, -19.6,  -57.0), hull_obj)
    make_empty("Light_Beacon", (  0.0,  14.2,   57.0), hull_obj)

    return axes_map


# ---------------------------------------------------------------------------
# GLB Post-Patcher: Guarantees Paint baseColorFactor = [0.70, 0.70, 0.68, 1.0]
# ---------------------------------------------------------------------------
def patch_glb_paint_factor(glb_path):
    with open(glb_path, "rb") as f:
        data = f.read()
    magic, version, total_len = struct.unpack_from("<III", data, 0)
    json_len, json_type = struct.unpack_from("<II", data, 12)
    json_bytes = data[20:20 + json_len]
    bin_rest = data[20 + json_len:]

    gltf = json.loads(json_bytes.decode("utf-8"))
    for mat in gltf.get("materials", []):
        if mat.get("name") == "Paint":
            pbr = mat.setdefault("pbrMetallicRoughness", {})
            pbr["baseColorFactor"] = [0.70, 0.70, 0.68, 1.0]
            print("[GLB] Verified Paint.pbrMetallicRoughness.baseColorFactor = [0.70, 0.70, 0.68, 1.0]")
        elif mat.get("name") == "Glass":
            mat["alphaMode"] = "BLEND"
            mat["doubleSided"] = True

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

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


# ---------------------------------------------------------------------------
# Articulation Test, Bounding Box & nodes.json Writer
# ---------------------------------------------------------------------------
def verify_moving_parts_and_write_nodes_json(axes_map, glb_path):
    bpy.context.view_layer.update()

    for node_name, axis in axes_map.items():
        obj = bpy.data.objects.get(node_name)
        if obj is None:
            continue
        orig_world = obj.matrix_world.translation.copy()
        orig_rot = obj.rotation_euler.copy()
        ax = Vector(axis)
        obj.rotation_euler = Euler((ax.x * math.radians(30), ax.y * math.radians(30), ax.z * math.radians(30)), 'XYZ')
        bpy.context.view_layer.update()
        new_world = obj.matrix_world.translation.copy()
        drift = (new_world - orig_world).length
        assert drift < 1e-4, f"Moving node {node_name} drifted by {drift}m!"
        obj.rotation_euler = orig_rot
        bpy.context.view_layer.update()
    print("[Verify] All moving parts rotated 30 degrees cleanly with 0.0m pivot drift.")

    min_v = Vector(( 1e9,  1e9,  1e9))
    max_v = Vector((-1e9, -1e9, -1e9))
    total_tris = 0

    for obj in bpy.context.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:
                wc = mw @ v.co
                min_v.x = min(min_v.x, wc.x)
                min_v.y = min(min_v.y, wc.y)
                min_v.z = min(min_v.z, wc.z)
                max_v.x = max(max_v.x, wc.x)
                max_v.y = max(max_v.y, wc.y)
                max_v.z = max(max_v.z, wc.z)

    dims = max_v - min_v
    print(f"[Stats] BBox min={[round(c, 2) for c in min_v]}, max={[round(c, 2) for c in max_v]}")
    print(f"[Stats] Dimensions (X, Y, Z) = ({dims.x:.2f}m, {dims.y:.2f}m, {dims.z:.2f}m), Triangles = {total_tris}")

    nodes_dict = {}
    for obj in sorted(bpy.context.scene.objects, key=lambda o: o.name):
        if obj.type in ('MESH', 'EMPTY'):
            wp = obj.matrix_world.translation
            nodes_dict[obj.name] = {
                "world_position": [round(wp.x, 4), round(wp.y, 4), round(wp.z, 4)],
                "parent": obj.parent.name if obj.parent else None,
                "type": obj.type.lower(),
            }

    out_data = {
        "asset": "hardsf-cruiser",
        "units": "metres",
        "forward": "+Z",
        "up": "+Y",
        "left": "+X",
        "bounding_box": {
            "min": [round(min_v.x, 3), round(min_v.y, 3), round(min_v.z, 3)],
            "max": [round(max_v.x, 3), round(max_v.y, 3), round(max_v.z, 3)],
            "dimensions": [round(dims.x, 3), round(dims.y, 3), round(dims.z, 3)],
        },
        "triangles": total_tris,
        "materials": ["Paint", "Metal", "Weapons", "HeatMetal", "Radiator", "Engines", "Lamps", "Glass"],
        "file_bytes": os.path.getsize(glb_path),
        "axes": axes_map,
        "paints": PAINTS,
        "nodes": nodes_dict,
    }
    nodes_path = os.path.join(OUT_DIR, "nodes.json")
    with open(nodes_path, "w") as f:
        json.dump(out_data, f, indent=2)
    print(f"[Write] Saved {nodes_path}")
    return total_tris, dims


# ---------------------------------------------------------------------------
# Studio Space Backdrop, Lighting & Renders (final.png, side.png, clay.png, paints.png)
# ---------------------------------------------------------------------------
def aim_object_yup(obj, loc, look_at, up_vec=(0.0, 1.0, 0.0)):
    """Aims a Blender camera or light at `look_at` from `loc` with `up_vec = +Y` as world UP."""
    obj.location = Vector(loc)
    forward = (Vector(look_at) - Vector(loc)).normalized()
    up = Vector(up_vec).normalized()
    right = forward.cross(up).normalized()
    cam_up = right.cross(forward).normalized()
    rot_mat = Matrix((right, cam_up, -forward)).transposed()
    obj.rotation_euler = rot_mat.to_euler()


def render_deliverables(mats):
    scene = bpy.context.scene
    scene.render.image_settings.file_format = 'PNG'
    scene.render.resolution_x = 1920
    scene.render.resolution_y = 1080
    scene.render.resolution_percentage = 100

    world = bpy.data.worlds.new("SpaceWorld")
    scene.world = world
    wnodes = world.node_tree.nodes
    wlinks = world.node_tree.links
    wnodes.clear()

    w_out = wnodes.new("ShaderNodeOutputWorld")
    w_bg = wnodes.new("ShaderNodeBackground")
    w_tc = wnodes.new("ShaderNodeTexCoord")
    w_sep = wnodes.new("ShaderNodeSeparateXYZ")
    w_ramp = wnodes.new("ShaderNodeValToRGB")
    w_ramp.color_ramp.elements[0].position = 0.0
    w_ramp.color_ramp.elements[0].color = (0.005, 0.008, 0.015, 1.0)
    w_ramp.color_ramp.elements[1].position = 1.0
    w_ramp.color_ramp.elements[1].color = (0.016, 0.028, 0.052, 1.0)
    w_bg.inputs["Strength"].default_value = 1.0
    wlinks.new(w_tc.outputs["Window"], w_sep.inputs["Vector"])
    wlinks.new(w_sep.outputs["Y"], w_ramp.inputs["Fac"])
    wlinks.new(w_ramp.outputs["Color"], w_bg.inputs["Color"])
    wlinks.new(w_bg.outputs["Background"], w_out.inputs["Surface"])

    def add_sun(name, energy, color, from_pos, angle_deg=3.5):
        ldata = bpy.data.lights.new(name=name, type='SUN')
        ldata.energy = energy
        ldata.color = color
        ldata.angle = math.radians(angle_deg)
        lobj = bpy.data.objects.new(name=name, object_data=ldata)
        bpy.context.collection.objects.link(lobj)
        aim_object_yup(lobj, from_pos, (0.0, 0.0, 0.0))
        return lobj

    # Key light from upper front-left (+X, +Y, +Z) so nose, port sponsons & radiator wings gleam
    add_sun("KeyLight",  4.6, (1.00, 0.98, 0.95), ( 190.0, 145.0, 175.0), angle_deg=3.5)
    # Soft cool fill from lower front-right (-X, -Y, +Z)
    add_sun("FillLight", 1.9, (0.72, 0.85, 1.00), (-160.0, -70.0, 120.0), angle_deg=12.0)
    # Rim light from aft-above (+X, +Y, -Z) so magnetic nozzle, spherical tanks & radiator wing frames pop
    add_sun("RimLight",  2.2, (0.90, 0.94, 1.00), ( 110.0, 130.0, -190.0), angle_deg=6.0)

    # 1. 3/4 Front Camera on Port-Forward-Upper (+X, +Y, +Z) -> Nose on lower-left, Tail on upper-right (matching sheet.jpg!)
    cam_34_data = bpy.data.cameras.new("Cam34")
    cam_34_data.lens = 40.0
    cam_34_data.clip_start = 0.5
    cam_34_data.clip_end = 2000.0
    cam_34 = bpy.data.objects.new("Cam34", cam_34_data)
    bpy.context.collection.objects.link(cam_34)
    aim_object_yup(cam_34, (210.0, 62.0, 220.0), (0.0, -1.0, 5.0))

    # 2. Orthographic Side Camera on Port (+X) side -> Nose (+Z) on Left of image, Nozzle (-Z) on Right (matching sheet.jpg!)
    cam_side_data = bpy.data.cameras.new("CamSide")
    cam_side_data.type = 'ORTHO'
    cam_side_data.ortho_scale = 276.0
    cam_side_data.clip_start = 0.5
    cam_side_data.clip_end = 2000.0
    cam_side = bpy.data.objects.new("CamSide", cam_side_data)
    bpy.context.collection.objects.link(cam_side)
    aim_object_yup(cam_side, (220.0, 0.0, 0.0), (0.0, 0.0, 0.0))

    # Render final.png
    scene.camera = cam_34
    scene.cycles.samples = 96
    scene.render.filepath = os.path.join(OUT_DIR, "final.png")
    print("[Render] Rendering final.png (1920x1080 Cycles)...")
    bpy.ops.render.render(write_still=True)

    # Render side.png
    scene.camera = cam_side
    scene.cycles.samples = 64
    scene.render.filepath = os.path.join(OUT_DIR, "side.png")
    print("[Render] Rendering side.png (1920x1080 Ortho Side)...")
    bpy.ops.render.render(write_still=True)

    # Render clay.png (same view as final.png, plain neutral grey clay material override)
    clay_mat = bpy.data.materials.new(name="_ClayOverride")
    clay_bsdf = clay_mat.node_tree.nodes.get("Principled BSDF")
    clay_bsdf.inputs["Base Color"].default_value = (0.58, 0.59, 0.60, 1.0)
    clay_bsdf.inputs["Roughness"].default_value = 0.50
    clay_bsdf.inputs["Metallic"].default_value = 0.05

    scene.camera = cam_34
    scene.cycles.samples = 64
    bpy.context.view_layer.material_override = clay_mat
    scene.render.filepath = os.path.join(OUT_DIR, "clay.png")
    print("[Render] Rendering clay.png (1920x1080 Clay)...")
    bpy.ops.render.render(write_still=True)
    bpy.context.view_layer.material_override = None
    bpy.data.materials.remove(clay_mat)

    # Render paints.png (3x2 grid of the 6 faction paints in nodes.json)
    scene.camera = cam_34
    scene.render.resolution_x = 640
    scene.render.resolution_y = 540
    scene.cycles.samples = 32

    paint_mat = mats["Paint"]
    mul_node = paint_mat.node_tree.nodes.get("PaintFactorMix")
    tile_paths = []
    for idx, pinfo in enumerate(PAINTS):
        rgb = pinfo["linear_rgb"]
        mul_node.inputs["Color2"].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
        tpath = os.path.join(BAKE_DIR, f"paint_tile_{idx}.png")
        scene.render.filepath = tpath
        print(f"[Render] Rendering paint tile {idx + 1}/6 ({pinfo['name']})...")
        bpy.ops.render.render(write_still=True)
        tile_paths.append(tpath)

    mul_node.inputs["Color2"].default_value = (0.70, 0.70, 0.68, 1.0)

    import numpy as np
    grid_w, grid_h = 1920, 1080
    tw, th = 640, 540
    grid_arr = np.zeros((grid_h, grid_w, 4), dtype=np.float32)

    for idx, tpath in enumerate(tile_paths):
        timg = bpy.data.images.load(tpath)
        px = np.array(timg.pixels[:], dtype=np.float32).reshape((th, tw, 4))
        col_i = idx % 3
        row_i = 1 - (idx // 3)
        y0, y1 = row_i * th, (row_i + 1) * th
        x0, x1 = col_i * tw, (col_i + 1) * tw
        grid_arr[y0:y1, x0:x1, :] = px
        bpy.data.images.remove(timg)

    paints_img = bpy.data.images.new("PaintsGrid", width=grid_w, height=grid_h, alpha=True)
    paints_img.pixels = grid_arr.flatten().tolist()
    paints_img.filepath_raw = os.path.join(OUT_DIR, "paints.png")
    paints_img.file_format = 'PNG'
    paints_img.save()
    print("[Render] Saved paints.png (1920x1080 3x2 grid)")


# ---------------------------------------------------------------------------
# Main Build Entrypoint
# ---------------------------------------------------------------------------
def main():
    reset_scene()
    ph_paths = ensure_polyhaven_textures()
    mats = build_and_bake_materials(ph_paths)

    hull_col = PartCollector(mats)
    build_forward_command_and_casemate(hull_col)
    build_spinal_railgun_and_trusses(hull_col)
    build_aft_hull_tanks_and_drone_racks(hull_col)
    build_diamond_radiator_wings(hull_col)
    build_fusion_drive_and_nozzle(hull_col)
    build_surface_greebles_and_pipes(hull_col)

    hull_obj = finalize_mesh_hierarchy_object(hull_col.objs, "Hull", (0.0, 0.0, 0.0))

    axes_map = build_turrets_and_nodes(mats, hull_obj)

    glb_path = os.path.join(OUT_DIR, "hardsf-cruiser.glb")
    bpy.ops.export_scene.gltf(
        filepath=glb_path,
        export_format='GLB',
        export_yup=False,
        export_apply=False,
        export_cameras=False,
        export_lights=False,
        export_image_format='JPEG',
        export_jpeg_quality=88,
    )
    patch_glb_paint_factor(glb_path)
    verify_moving_parts_and_write_nodes_json(axes_map, glb_path)

    render_deliverables(mats)
    print("[Done] Build and renders complete.")


if __name__ == "__main__":
    main()
