#!/usr/bin/env python3
"""
Build script for: variable-fighter-anime (vehicle.space.fighter/variable-fighter-anime)
Contract: spacecraft/v1
Builds an original anime-style variable-sweep space fighter from an empty Blender 5.2 scene,
loads CC0 Poly Haven textures, bakes PBR textures (BaseColor 1024px, Normal 1024px, ORM 512px)
with Cycles, verifies moving part pivots, exports variable-fighter-anime.glb and nodes.json,
and renders final.png, clay.png, side.png, and paints.png.
"""

import json
import math
import os
import struct
import urllib.request
from pathlib import Path

import bmesh
import bpy
from mathutils import Euler, Matrix, Vector

ROOT = Path(__file__).resolve().parent
TEX_DIR = ROOT / "textures"
BAKED_DIR = ROOT / "baked"
TEX_DIR.mkdir(parents=True, exist_ok=True)
BAKED_DIR.mkdir(parents=True, exist_ok=True)

# Poly Haven CC0 textures used
POLYHAVEN_IDS = [
    "blue_metal_plate",
    "metal_plate_02",
    "metal_plate",
    "rusty_metal_03",
]

PAINTS = [
    ("Warm White", [0.85, 0.85, 0.88]),
    ("Interceptor Grey", [0.42, 0.45, 0.49]),
    ("Valkyrie Crimson", [0.62, 0.08, 0.07]),
    ("Deep Space Navy", [0.07, 0.13, 0.28]),
    ("Tactical Olive", [0.22, 0.27, 0.15]),
    ("Stealth Obsidian", [0.06, 0.06, 0.07]),
]


def ensure_polyhaven_textures():
    """Download CC0 Poly Haven 1k jpg maps if not already cached in textures/."""
    for ph_id in POLYHAVEN_IDS:
        for map_type in ("diff", "nor_gl", "arm"):
            fname = f"{ph_id}_{map_type}_1k.jpg"
            fpath = TEX_DIR / fname
            if fpath.exists() and fpath.stat().st_size > 1000:
                continue
            url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{fname}"
            print(f"Downloading {url} ...", flush=True)
            req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=30) as resp:
                fpath.write_bytes(resp.read())


# ---------------------------------------------------------------------------
# Scene & GPU Initialization
# ---------------------------------------------------------------------------
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", "NONE"):
        try:
            cprefs.compute_device_type = dev_type
            cprefs.get_devices()
            if dev_type != "NONE" and any(d.type == dev_type for d in cprefs.devices):
                for d in cprefs.devices:
                    d.use = d.type == dev_type
                scene.cycles.device = "GPU"
                print(f"Cycles GPU enabled: {dev_type}", flush=True)
                break
        except Exception:
            continue
    scene.cycles.use_adaptive_sampling = True
    scene.cycles.use_denoising = True


# ---------------------------------------------------------------------------
# Geometry Construction Helpers
# ---------------------------------------------------------------------------
def make_mesh_obj(name, verts, faces, mat=None, collection=None, smooth=True):
    mesh = bpy.data.meshes.new(name)
    mesh.from_pydata(verts, [], faces)
    mesh.update()
    bm = bmesh.new()
    bm.from_mesh(mesh)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    for f in bm.faces:
        f.smooth = smooth
    bm.to_mesh(mesh)
    bm.free()
    if mat is not None:
        mesh.materials.append(mat)
    obj = bpy.data.objects.new(name, mesh)
    col = collection or bpy.context.scene.collection
    col.objects.link(obj)
    return obj


def add_bevel(obj, width=0.006, segments=2, angle_limit=math.radians(28)):
    bev = obj.modifiers.new(name="Bevel", type="BEVEL")
    bev.width = width
    bev.segments = segments
    bev.limit_method = "ANGLE"
    bev.angle_limit = angle_limit
    bev.miter_outer = "MITER_ARC"
    bev.harden_normals = False
    return bev


def loft_sections(name, sections, mat, closed_loop=True, cap_ends=True, smooth=True):
    """
    Loft a list of cross-sections. Each section is a list of (x, y, z) tuples
    of identical length N.
    """
    verts = []
    faces = []
    n_pts = len(sections[0])
    n_sec = len(sections)
    for sec in sections:
        assert len(sec) == n_pts
        verts.extend(sec)
    for i in range(n_sec - 1):
        base0 = i * n_pts
        base1 = (i + 1) * n_pts
        limit = n_pts if closed_loop else (n_pts - 1)
        for j in range(limit):
            jn = (j + 1) % n_pts
            faces.append((base0 + j, base0 + jn, base1 + jn, base1 + j))
    if cap_ends and closed_loop and n_pts >= 3:
        faces.append(tuple(range(n_pts - 1, -1, -1)))
        last_base = (n_sec - 1) * n_pts
        faces.append(tuple(range(last_base, last_base + n_pts)))
    return make_mesh_obj(name, verts, faces, mat=mat, smooth=smooth)


def make_box(name, center, size, mat, bevel=0.006, segments=2, rot_euler=(0, 0, 0)):
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=1.0)
    bmesh.ops.scale(bm, vec=size, verts=bm.verts)
    if any(abs(a) > 1e-6 for a in rot_euler):
        rmat = Euler(rot_euler, "XYZ").to_matrix()
        bmesh.ops.rotate(bm, matrix=rmat, verts=bm.verts)
    bmesh.ops.translate(bm, vec=center, verts=bm.verts)
    for f in bm.faces:
        f.smooth = True
    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()
    if mat:
        mesh.materials.append(mat)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.scene.collection.objects.link(obj)
    if bevel > 0:
        add_bevel(obj, width=bevel, segments=segments)
    return obj


def make_cylinder(
    name,
    p0,
    p1,
    r0,
    r1=None,
    segments=24,
    mat=None,
    cap_ends=True,
    smooth=True,
    bevel=0.003,
):
    if r1 is None:
        r1 = r0
    v0 = Vector(p0)
    v1 = Vector(p1)
    axis = v1 - v0
    direction = axis.normalized()
    up = Vector((0, 0, 1)) if abs(direction.z) < 0.9 else Vector((1, 0, 0))
    tangent = direction.cross(up).normalized()
    bitangent = direction.cross(tangent).normalized()

    sec0 = []
    sec1 = []
    for i in range(segments):
        ang = 2.0 * math.pi * i / segments
        ca, sa = math.cos(ang), math.sin(ang)
        radial = tangent * ca + bitangent * sa
        sec0.append(tuple(v0 + radial * r0))
        sec1.append(tuple(v1 + radial * r1))
    obj = loft_sections(name, [sec0, sec1], mat, closed_loop=True, cap_ends=cap_ends, smooth=smooth)
    if bevel > 0:
        add_bevel(obj, width=bevel, segments=2, angle_limit=math.radians(35))
    return obj


def mirror_x_copy(obj, name_suffix="_R"):
    """Create a mirrored copy across X=0 with flipped normals."""
    new_mesh = obj.data.copy()
    bm = bmesh.new()
    bm.from_mesh(new_mesh)
    for v in bm.verts:
        v.co.x = -v.co.x
    bmesh.ops.reverse_faces(bm, faces=bm.faces)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(new_mesh)
    bm.free()
    new_obj = bpy.data.objects.new(obj.name + name_suffix, new_mesh)
    new_obj.location = Vector((-obj.location.x, obj.location.y, obj.location.z))
    for m in obj.modifiers:
        nm = new_obj.modifiers.new(name=m.name, type=m.type)
        if m.type == "BEVEL":
            nm.width = m.width
            nm.segments = m.segments
            nm.limit_method = m.limit_method
            nm.angle_limit = m.angle_limit
            nm.miter_outer = m.miter_outer
    bpy.context.scene.collection.objects.link(new_obj)
    return new_obj


def apply_all_modifiers_and_join(objs, target_name, pivot=(0.0, 0.0, 0.0)):
    """
    Apply all modifiers on each object in `objs`, join them into a single triangulated
    mesh object named `target_name`, and set its object origin to `pivot` in world space
    WITHOUT applying the final translation so moving parts retain their pivot!
    """
    depsgraph = bpy.context.evaluated_depsgraph_get()
    combined_bm = bmesh.new()
    mat_list = []
    mat_index_map = {}

    for o in objs:
        if o is None:
            continue
        eval_obj = o.evaluated_get(depsgraph)
        temp_mesh = bpy.data.meshes.new_from_object(eval_obj)
        temp_bm = bmesh.new()
        temp_bm.from_mesh(temp_mesh)
        bmesh.ops.transform(temp_bm, matrix=o.matrix_world, verts=temp_bm.verts)

        local_map = {}
        for idx, m in enumerate(temp_mesh.materials):
            if m is None:
                continue
            if m.name not in mat_index_map:
                mat_index_map[m.name] = len(mat_list)
                mat_list.append(m)
            local_map[idx] = mat_index_map[m.name]

        for f in temp_bm.faces:
            f.material_index = local_map.get(f.material_index, 0)

        temp_bm.to_mesh(temp_mesh)
        temp_bm.free()
        combined_bm.from_mesh(temp_mesh)
        bpy.data.meshes.remove(temp_mesh)

    piv_vec = Vector(pivot)
    bmesh.ops.translate(combined_bm, vec=-piv_vec, verts=combined_bm.verts)
    bmesh.ops.recalc_face_normals(combined_bm, faces=combined_bm.faces)

    bmesh.ops.triangulate(combined_bm, faces=combined_bm.faces, quad_method="BEAUTY", ngon_method="BEAUTY")

    for e in combined_bm.edges:
        if len(e.link_faces) == 2:
            ang = e.link_faces[0].normal.angle(e.link_faces[1].normal, 0.0)
            e.smooth = ang < math.radians(38.0)
        else:
            e.smooth = False
    for f in combined_bm.faces:
        f.smooth = True

    final_mesh = bpy.data.meshes.new(target_name)
    combined_bm.to_mesh(final_mesh)
    combined_bm.free()

    for m in mat_list:
        final_mesh.materials.append(m)

    for o in objs:
        if o is not None and o.name in bpy.data.objects:
            mdata = o.data
            bpy.data.objects.remove(o, do_unlink=True)
            if mdata and mdata.users == 0:
                bpy.data.meshes.remove(mdata)

    final_obj = bpy.data.objects.new(target_name, final_mesh)
    final_obj.location = piv_vec
    bpy.context.scene.collection.objects.link(final_obj)
    return final_obj


def unwrap_box_project(objs, scale=0.14, nozzle_mat_names=("Scorched", "Engines")):
    """
    Create clean non-degenerate UVs on all mesh objects:
    - Standard materials get clean tri-planar projection aligned with aircraft axes.
    - Nozzle materials ('Scorched', 'Engines') get cylindrical/axial UV mapping where
      U wraps around the nozzle circumference and V runs from 0.0 at Y=+6.45 (forward collar)
      to 1.0 at Y=+7.90 (aft exit lip), driving the blue/orange thermal gradient!
    """
    for obj in objs:
        mesh = obj.data
        if not mesh.uv_layers:
            mesh.uv_layers.new(name="UVMap")
        uv_layer = mesh.uv_layers.active.data
        world_mat = obj.matrix_world

        nozzle_indices = {
            i for i, m in enumerate(mesh.materials) if m and m.name in nozzle_mat_names
        }

        for poly in mesh.polygons:
            n = (world_mat.to_3x3() @ poly.normal).normalized()
            is_nozzle = poly.material_index in nozzle_indices
            for loop_idx in poly.loop_indices:
                v_idx = mesh.loops[loop_idx].vertex_index
                co = world_mat @ mesh.vertices[v_idx].co
                if is_nozzle:
                    cx = 1.18 if co.x >= 0 else -1.18
                    cz = 0.05
                    ang = math.atan2(co.z - cz, co.x - cx)
                    u = (ang / (2.0 * math.pi)) % 1.0
                    v = max(0.03, min(0.97, (co.y - 6.50) / 1.40))
                    uv_layer[loop_idx].uv = (0.02 + 0.96 * u, v)
                else:
                    ax, ay, az = abs(n.x), abs(n.y), abs(n.z)
                    if az >= ax and az >= ay:
                        u = (co.x * scale + 0.5) % 1.0
                        v = (co.y * scale + 0.5) % 1.0
                    elif ax >= ay and ax >= az:
                        u = (co.y * scale + 0.5) % 1.0
                        v = (co.z * scale + 0.5) % 1.0
                    else:
                        u = (co.x * scale + 0.5) % 1.0
                        v = (co.z * scale + 0.5) % 1.0
                    uv_layer[loop_idx].uv = (0.02 + 0.96 * u, 0.02 + 0.96 * v)


# ---------------------------------------------------------------------------
# Fighter Geometry Construction
# ---------------------------------------------------------------------------
def build_fuselage_section_ring(
    y,
    half_w_top,
    half_w_chine,
    half_w_bot,
    z_top,
    z_shoulder,
    z_chine,
    z_belly_side,
    z_keel,
    cockpit_cut=0.0,
):
    z_center_top = z_top - cockpit_cut
    w_tub = half_w_top * 0.62 if cockpit_cut > 0 else half_w_top * 0.45

    right_side = [
        (0.0, y, z_center_top),
        (-w_tub, y, z_center_top),
        (-half_w_top, y, z_top),
        (-half_w_chine * 0.90, y, z_shoulder),
        (-half_w_chine, y, z_chine),
        (-half_w_chine * 0.94, y, z_chine - 0.035),
        (-half_w_bot, y, z_belly_side),
        (-half_w_bot * 0.45, y, z_keel),
        (0.0, y, z_keel),
    ]
    left_side = [
        (half_w_bot * 0.45, y, z_keel),
        (half_w_bot, y, z_belly_side),
        (half_w_chine * 0.94, y, z_chine - 0.035),
        (half_w_chine, y, z_chine),
        (half_w_chine * 0.90, y, z_shoulder),
        (half_w_top, y, z_top),
        (w_tub, y, z_center_top),
    ]
    return right_side + left_side


def build_fighter_geometry(mats):
    hull_objs = []
    canopy_objs = []
    wing_l_objs = []
    wing_r_objs = []
    turret_objs = []
    gun_objs = []

    m_paint = mats["Paint"]
    m_stripes = mats["Stripes"]
    m_steel = mats["Steel"]
    m_scorched = mats["Scorched"]
    m_cockpit = mats["Cockpit"]
    m_glass = mats["Glass"]
    m_engines = mats["Engines"]
    m_lamps = mats["Lamps"]

    # =========================================================================
    # 1. FORWARD CHISEL NOSE & CENTRAL FUSELAGE (Y = -7.90 to +6.40)
    # =========================================================================
    nose_stations = [
        (-7.90, 0.012, 0.022, 0.012, -0.14, -0.148, -0.155, -0.162, -0.168, 0.0),
        (-7.50, 0.068, 0.135, 0.078, -0.05, -0.095, -0.135, -0.185, -0.205, 0.0),
        (-7.05, 0.135, 0.255, 0.155, 0.06, -0.02, -0.11, -0.225, -0.255, 0.0),
    ]
    radome_rings = [build_fuselage_section_ring(*st) for st in nose_stations]
    radome_obj = loft_sections("Radome_Cap", radome_rings, m_paint)
    add_bevel(radome_obj, width=0.004, segments=2)
    hull_objs.append(radome_obj)

    tip_cone_rings = [
        build_fuselage_section_ring(-7.90, 0.012, 0.022, 0.012, -0.14, -0.148, -0.155, -0.162, -0.168),
        build_fuselage_section_ring(-7.62, 0.052, 0.105, 0.060, -0.075, -0.112, -0.142, -0.175, -0.192),
    ]
    tip_cone = loft_sections("Radome_Tip_Steel", tip_cone_rings, m_steel)
    hull_objs.append(tip_cone)

    collar_rings = [
        build_fuselage_section_ring(-7.08, 0.136, 0.258, 0.157, 0.062, -0.018, -0.11, -0.227, -0.257),
        build_fuselage_section_ring(-7.00, 0.148, 0.276, 0.168, 0.080, -0.008, -0.106, -0.236, -0.268),
    ]
    collar_obj = loft_sections("Radome_Collar", collar_rings, m_steel)
    hull_objs.append(collar_obj)

    fuse_stations = [
        (-7.05, 0.135, 0.255, 0.155, 0.06, -0.02, -0.11, -0.225, -0.255, 0.0),
        (-6.35, 0.225, 0.395, 0.250, 0.23, 0.10, -0.07, -0.285, -0.335, 0.0),
        (-5.65, 0.315, 0.520, 0.345, 0.40, 0.22, -0.03, -0.345, -0.405, 0.0),
        (-5.15, 0.365, 0.585, 0.400, 0.48, 0.28, -0.01, -0.380, -0.445, 0.26),
        (-4.35, 0.425, 0.655, 0.455, 0.56, 0.35, 0.01, -0.415, -0.480, 0.42),
        (-3.35, 0.460, 0.700, 0.490, 0.62, 0.40, 0.03, -0.440, -0.505, 0.46),
        (-2.35, 0.465, 0.715, 0.500, 0.70, 0.46, 0.05, -0.445, -0.510, 0.32),
        (-1.80, 0.445, 0.720, 0.500, 0.94, 0.62, 0.06, -0.440, -0.505, 0.0),
        (-1.00, 0.440, 0.725, 0.510, 0.91, 0.61, 0.06, -0.430, -0.490, 0.0),
        (0.00, 0.415, 0.690, 0.490, 0.85, 0.57, 0.05, -0.410, -0.470, 0.0),
        (1.10, 0.385, 0.610, 0.450, 0.79, 0.53, 0.05, -0.370, -0.430, 0.0),
        (2.50, 0.355, 0.510, 0.390, 0.72, 0.47, 0.06, -0.330, -0.380, 0.0),
        (4.00, 0.335, 0.470, 0.350, 0.65, 0.43, 0.06, -0.290, -0.340, 0.0),
        (5.30, 0.295, 0.420, 0.310, 0.54, 0.35, 0.05, -0.230, -0.270, 0.0),
        (6.35, 0.235, 0.330, 0.250, 0.35, 0.21, 0.04, -0.140, -0.180, 0.0),
    ]
    fuse_rings = [build_fuselage_section_ring(*st) for st in fuse_stations]
    fuse_obj = loft_sections("Main_Fuselage", fuse_rings, m_paint)
    add_bevel(fuse_obj, width=0.006, segments=2)
    hull_objs.append(fuse_obj)

    # Segmented dorsal spine armor plates & panel line relief
    for y0, y1, w0, w1, z0, z1 in [
        (-6.90, -6.10, 0.14, 0.22, 0.095, 0.285),
        (-6.02, -5.66, 0.225, 0.28, 0.300, 0.398),
        (-1.75, -0.65, 0.34, 0.33, 0.942, 0.885),
        (-0.55, 0.85, 0.32, 0.29, 0.878, 0.800),
        (0.95, 2.45, 0.28, 0.26, 0.792, 0.722),
        (2.55, 4.15, 0.25, 0.23, 0.715, 0.635),
        (4.25, 6.10, 0.22, 0.18, 0.625, 0.390),
    ]:
        sec_a = [
            (-w0, y0, z0 - 0.015),
            (w0, y0, z0 - 0.015),
            (w0 * 0.88, y0, z0 + 0.008),
            (-w0 * 0.88, y0, z0 + 0.008),
        ]
        sec_b = [
            (-w1, y1, z1 - 0.015),
            (w1, y1, z1 - 0.015),
            (w1 * 0.88, y1, z1 + 0.008),
            (-w1 * 0.88, y1, z1 + 0.008),
        ]
        plate = loft_sections(f"Dorsal_Plate_{y0:.1f}", [sec_a, sec_b], m_paint)
        add_bevel(plate, width=0.0025, segments=2)
        hull_objs.append(plate)

    for side in (-1, 1):
        pitot = make_cylinder(
            f"Pitot_{side}",
            (side * 0.19, -7.15, -0.09),
            (side * 0.19, -7.68, -0.12),
            0.008,
            0.003,
            segments=10,
            mat=m_steel,
        )
        hull_objs.append(pitot)
        rcs_bezel = make_box(
            f"Nose_RCS_Bezel_{side}",
            (side * 0.40, -6.12, 0.06),
            (0.03, 0.26, 0.075),
            m_steel,
            bevel=0.003,
            rot_euler=(0.08, side * 0.32, -side * 0.12),
        )
        rcs_port = make_box(
            f"Nose_RCS_Port_{side}",
            (side * 0.41, -6.12, 0.06),
            (0.02, 0.20, 0.045),
            m_cockpit,
            bevel=0.002,
            rot_euler=(0.08, side * 0.32, -side * 0.12),
        )
        hull_objs.extend([rcs_bezel, rcs_port])

    # Under-nose chin electro-optical sensor fairing & ventral blade antenna
    chin_pod_sec = [
        [(-0.07, -5.45, -0.39), (0.07, -5.45, -0.39), (0.04, -5.45, -0.47), (-0.04, -5.45, -0.47)],
        [(-0.10, -5.00, -0.42), (0.10, -5.00, -0.42), (0.07, -5.00, -0.55), (-0.07, -5.00, -0.55)],
        [(-0.09, -4.35, -0.45), (0.09, -4.35, -0.45), (0.06, -4.35, -0.54), (-0.06, -4.35, -0.54)],
        [(-0.05, -4.05, -0.47), (0.05, -4.05, -0.47), (0.03, -4.05, -0.49), (-0.03, -4.05, -0.49)],
    ]
    chin_pod = loft_sections("Chin_Sensor_Pod", chin_pod_sec, m_paint)
    add_bevel(chin_pod, width=0.004, segments=2)
    chin_lens = make_box(
        "Chin_Sensor_Lens", (0.0, -5.15, -0.50), (0.09, 0.20, 0.05), m_lamps, bevel=0.002
    )
    chin_blade_sec = [
        [(-0.012, -4.22, -0.48), (0.012, -4.22, -0.48), (0.012, -3.96, -0.48), (-0.012, -3.96, -0.48)],
        [(-0.007, -4.12, -0.66), (0.007, -4.12, -0.66), (0.007, -3.94, -0.66), (-0.007, -3.94, -0.66)],
    ]
    chin_blade = loft_sections("Chin_Blade_Antenna", chin_blade_sec, m_paint)
    add_bevel(chin_blade, width=0.002, segments=2)
    hull_objs.extend([chin_pod, chin_lens, chin_blade])

    # Dorsal swept blade antenna behind cockpit (at Y = -0.35 in sheet.jpg)
    dorsal_blade_sec = [
        [(-0.016, -0.58, 0.85), (0.016, -0.58, 0.85), (0.016, -0.15, 0.83), (-0.016, -0.15, 0.83)],
        [(-0.009, -0.38, 1.13), (0.009, -0.38, 1.13), (0.009, -0.08, 1.11), (-0.009, -0.08, 1.11)],
    ]
    dorsal_blade = loft_sections("Dorsal_Blade_Antenna", dorsal_blade_sec, m_paint)
    add_bevel(dorsal_blade, width=0.002, segments=2)
    beacon_lens = make_box(
        "Dorsal_Beacon_Lens", (0.0, -0.23, 1.13), (0.02, 0.10, 0.022), m_lamps, bevel=0.002
    )
    hull_objs.extend([dorsal_blade, beacon_lens])

    # =========================================================================
    # 2. COCKPIT INTERIOR (Seat, Instrument Coaming, HUD, Stick, Consoles)
    # =========================================================================
    tub_box = make_box("Cockpit_Tub_Floor", (0.0, -3.65, 0.16), (0.72, 2.75, 0.05), m_cockpit, bevel=0.005)
    rear_bulkhead = make_box(
        "Cockpit_Rear_Bulkhead",
        (0.0, -2.25, 0.54),
        (0.76, 0.12, 0.66),
        m_cockpit,
        bevel=0.006,
        rot_euler=(math.radians(14), 0, 0),
    )
    hull_objs.extend([tub_box, rear_bulkhead])

    for side in (-1, 1):
        con = make_box(
            f"Cockpit_Side_Console_{side}",
            (side * 0.32, -3.70, 0.34),
            (0.16, 2.10, 0.22),
            m_cockpit,
            bevel=0.005,
        )
        hull_objs.append(con)
        for k in range(5):
            btn = make_box(
                f"Console_Switch_{side}_{k}",
                (side * 0.31, -4.30 + k * 0.28, 0.46),
                (0.045, 0.075, 0.018),
                m_lamps if k % 2 == 0 else m_steel,
                bevel=0.002,
            )
            hull_objs.append(btn)

    throttle_arm = make_cylinder(
        "Throttle_Arm", (-0.31, -3.80, 0.44), (-0.31, -3.86, 0.55), 0.012, 0.012, segments=10, mat=m_steel
    )
    throttle_grip = make_box(
        "Throttle_Grip", (-0.31, -3.86, 0.56), (0.08, 0.045, 0.04), m_cockpit, bevel=0.003
    )
    hull_objs.extend([throttle_arm, throttle_grip])

    coaming = make_box(
        "Cockpit_Glare_Shield",
        (0.0, -4.75, 0.48),
        (0.54, 0.64, 0.15),
        m_cockpit,
        bevel=0.010,
        rot_euler=(math.radians(-14), 0, 0),
    )
    inst_panel = make_box(
        "Cockpit_Instrument_Panel",
        (0.0, -4.38, 0.42),
        (0.58, 0.12, 0.32),
        m_cockpit,
        bevel=0.005,
        rot_euler=(math.radians(18), 0, 0),
    )
    hull_objs.extend([coaming, inst_panel])

    for mx, mz, mw, mh in [
        (0.0, 0.47, 0.16, 0.12),
        (-0.19, 0.45, 0.13, 0.10),
        (0.19, 0.45, 0.13, 0.10),
    ]:
        mfd_bezel = make_box(
            f"MFD_Bezel_{mx:.2f}",
            (mx, -4.31, mz),
            (mw + 0.02, 0.025, mh + 0.02),
            m_steel,
            bevel=0.002,
            rot_euler=(math.radians(18), 0, 0),
        )
        mfd_screen = make_box(
            f"MFD_Screen_{mx:.2f}",
            (mx, -4.295, mz),
            (mw, 0.018, mh),
            m_lamps,
            bevel=0.002,
            rot_euler=(math.radians(18), 0, 0),
        )
        hull_objs.extend([mfd_bezel, mfd_screen])

    hud_base = make_box("HUD_Base", (0.0, -4.52, 0.57), (0.18, 0.20, 0.04), m_steel, bevel=0.003)
    hud_glass = make_box(
        "HUD_Glass",
        (0.0, -4.42, 0.65),
        (0.15, 0.010, 0.12),
        m_lamps,
        bevel=0.002,
        rot_euler=(math.radians(38), 0, 0),
    )
    hull_objs.extend([hud_base, hud_glass])

    seat_pan = make_box(
        "Seat_Pan",
        (0.0, -3.45, 0.28),
        (0.42, 0.46, 0.10),
        m_cockpit,
        bevel=0.010,
        rot_euler=(math.radians(-6), 0, 0),
    )
    seat_back = make_box(
        "Seat_Back",
        (0.0, -3.18, 0.60),
        (0.40, 0.11, 0.62),
        m_cockpit,
        bevel=0.010,
        rot_euler=(math.radians(16), 0, 0),
    )
    seat_headrest = make_box(
        "Seat_Headrest",
        (0.0, -3.02, 0.94),
        (0.26, 0.14, 0.20),
        m_stripes,
        bevel=0.008,
        rot_euler=(math.radians(14), 0, 0),
    )
    hull_objs.extend([seat_pan, seat_back, seat_headrest])
    for side in (-1, 1):
        rail = make_cylinder(
            f"Seat_Rail_{side}",
            (side * 0.20, -3.25, 0.30),
            (side * 0.20, -3.00, 1.00),
            0.016,
            0.014,
            segments=10,
            mat=m_steel,
        )
        bolster = make_box(
            f"Seat_Bolster_{side}",
            (side * 0.19, -3.25, 0.54),
            (0.055, 0.18, 0.42),
            m_cockpit,
            bevel=0.006,
            rot_euler=(math.radians(16), 0, 0),
        )
        hull_objs.extend([rail, bolster])

    stick_boot = make_cylinder(
        "Stick_Boot", (0.0, -3.92, 0.19), (0.0, -3.90, 0.36), 0.032, 0.016, segments=12, mat=m_cockpit
    )
    stick_grip = make_cylinder(
        "Stick_Grip", (0.0, -3.90, 0.36), (0.0, -3.87, 0.49), 0.018, 0.022, segments=12, mat=m_steel
    )
    hull_objs.extend([stick_boot, stick_grip])

    # =========================================================================
    # 3. LONG CANOPY (Moving Node `Canopy`, hinged at rear Y = -1.85, Z = +0.94)
    # =========================================================================
    def canopy_arch_pts(y, w_base, z_base, z_crown, inset=0.0):
        wb = max(0.02, w_base - inset)
        zc = max(z_base + 0.012, z_crown - inset)
        zb = z_base
        return [
            (-wb, y, zb),
            (-wb * 0.93, y, zb + (zc - zb) * 0.46),
            (-wb * 0.66, y, zb + (zc - zb) * 0.82),
            (-wb * 0.26, y, zc),
            (wb * 0.26, y, zc),
            (wb * 0.66, y, zb + (zc - zb) * 0.82),
            (wb * 0.93, y, zb + (zc - zb) * 0.46),
            (wb, y, zb),
        ]

    canopy_stations = [
        (-5.65, 0.28, 0.40, 0.415),
        (-5.20, 0.35, 0.47, 0.64),
        (-4.70, 0.40, 0.52, 0.85),
        (-4.20, 0.43, 0.56, 1.03),
        (-3.65, 0.45, 0.60, 1.15),
        (-3.10, 0.46, 0.63, 1.20),
        (-2.60, 0.46, 0.67, 1.16),
        (-2.15, 0.45, 0.75, 1.06),
        (-1.80, 0.43, 0.88, 0.94),
    ]

    def interp_canopy(y_q):
        if y_q <= canopy_stations[0][0]:
            return canopy_stations[0][1:]
        for i in range(len(canopy_stations) - 1):
            y0, w0, zb0, zc0 = canopy_stations[i]
            y1, w1, zb1, zc1 = canopy_stations[i + 1]
            if y0 <= y_q <= y1:
                t = (y_q - y0) / (y1 - y0)
                return (
                    w0 + t * (w1 - w0),
                    zb0 + t * (zb1 - zb0),
                    zc0 + t * (zc1 - zc0),
                )
        return canopy_stations[-1][1:]

    # Tinted Glass shell (from Y = -5.50 to -2.02)
    glass_ys = [-5.50, -5.10, -4.65, -4.20, -3.65, -3.10, -2.60, -2.15, -2.02]
    glass_rings = []
    for gy in glass_ys:
        gw, gzb, gzc = interp_canopy(gy)
        go = canopy_arch_pts(gy, gw, gzb, gzc, inset=0.003)
        gi = canopy_arch_pts(gy, gw, gzb, gzc, inset=0.018)
        glass_rings.append(go + list(reversed(gi)))
    canopy_glass = loft_sections("Canopy_Glass_Shell", glass_rings, m_glass, closed_loop=True, cap_ends=True)
    canopy_objs.append(canopy_glass)

    # Flush Canopy Frame Sills (dark frame at windscreen Y=-5.65..-4.18, Paint frame aft Y=-4.18..-1.80)
    for side in (-1, 1):
        sill_fwd_rings = []
        for y in (-5.65, -5.20, -4.70, -4.18):
            w, zb, zc = interp_canopy(y)
            x_out = side * (w + 0.005)
            x_in = side * (w - 0.025)
            sill_fwd_rings.append([
                (x_in, y, zb - 0.010),
                (x_out, y, zb - 0.010),
                (x_out * 0.96, y, zb + 0.042),
                (x_in * 0.96, y, zb + 0.042),
            ])
        sill_fwd = loft_sections(f"Canopy_Sill_Fwd_{side}", sill_fwd_rings, m_cockpit)
        add_bevel(sill_fwd, width=0.002, segments=2)
        canopy_objs.append(sill_fwd)

        sill_aft_rings = []
        for y in (-4.18, -3.65, -3.10, -2.60, -2.15, -1.80):
            w, zb, zc = interp_canopy(y)
            x_out = side * (w + 0.006)
            x_in = side * (w - 0.028)
            sill_aft_rings.append([
                (x_in, y, zb - 0.012),
                (x_out, y, zb - 0.012),
                (x_out * 0.96, y, zb + 0.048),
                (x_in * 0.96, y, zb + 0.048),
            ])
        sill_aft = loft_sections(f"Canopy_Sill_Aft_{side}", sill_aft_rings, m_paint)
        add_bevel(sill_aft, width=0.0025, segments=2)
        canopy_objs.append(sill_aft)

    # Flush Canopy Transverse Frame Arches (strictly within [-5.64, -1.80] so zero step!)
    for idx, (ya, yb, mat_bow) in enumerate([
        (-5.64, -5.38, m_cockpit),  # Front windscreen apex collar (flush with nose deck!)
        (-4.26, -4.12, m_cockpit),  # Forward windscreen arch bow
        (-2.82, -2.66, m_paint),    # Rear canopy arch bow
        (-2.08, -1.80, m_paint),    # Aft solid canopy spine fairing
    ]):
        w0, zb0, zc0 = interp_canopy(ya)
        w1, zb1, zc1 = interp_canopy(yb)
        outer0 = canopy_arch_pts(ya, w0 + 0.005, zb0, zc0 + 0.005, inset=0.0)
        inner0 = canopy_arch_pts(ya, w0 + 0.005, zb0, zc0 + 0.005, inset=0.028)
        outer1 = canopy_arch_pts(yb, w1 + 0.005, zb1, zc1 + 0.005, inset=0.0)
        inner1 = canopy_arch_pts(yb, w1 + 0.005, zb1, zc1 + 0.005, inset=0.028)
        bow = loft_sections(
            f"Canopy_Arch_{idx}",
            [outer0 + list(reversed(inner0)), outer1 + list(reversed(inner1))],
            mat_bow,
        )
        add_bevel(bow, width=0.002, segments=2)
        canopy_objs.append(bow)

    # Sleek windscreen side frame struts (flush with windscreen curvature from Y=-5.45 to Y=-4.18)
    for side in (-1, 1):
        strut_secs = []
        for sy in (-5.45, -5.00, -4.60, -4.18):
            sw, szb, szc = interp_canopy(sy)
            ang_x = side * sw * 0.56
            ang_z = szb + (szc - szb) * 0.80
            sec = [
                (ang_x - 0.020, sy, ang_z - 0.016),
                (ang_x + 0.020, sy, ang_z - 0.016),
                (ang_x + 0.016, sy, ang_z + 0.006),
                (ang_x - 0.016, sy, ang_z + 0.006),
            ]
            strut_secs.append(sec)
        ws_strut = loft_sections(f"Windscreen_Strut_{side}", strut_secs, m_cockpit)
        add_bevel(ws_strut, width=0.002, segments=2)
        canopy_objs.append(ws_strut)

    hinge_cyl = make_cylinder(
        "Canopy_Hinge_Pin",
        (-0.22, -1.85, 0.90),
        (0.22, -1.85, 0.90),
        0.024,
        0.024,
        segments=12,
        mat=m_steel,
    )
    canopy_objs.append(hinge_cyl)

    # =========================================================================
    # 4. CONTRAST STRIPES ON FUSELAGE (Geometry assigned to `Stripes`)
    # =========================================================================
    def hull_profile_at_y(y_val):
        for i in range(len(fuse_stations) - 1):
            st0 = fuse_stations[i]
            st1 = fuse_stations[i + 1]
            if st0[0] <= y_val <= st1[0]:
                t = (y_val - st0[0]) / (st1[0] - st0[0])
                return [st0[k] + t * (st1[k] - st0[k]) for k in range(1, 8)]
        return list(fuse_stations[5][1:8])

    for side in (-1, 1):
        for band_idx, y_shift in enumerate((0.0, 0.56)):
            y_stations = [
                (-2.05 + y_shift, -1.72 + y_shift, "spine"),
                (-2.35 + y_shift, -2.02 + y_shift, "top"),
                (-2.68 + y_shift, -2.35 + y_shift, "shoulder"),
                (-3.05 + y_shift, -2.72 + y_shift, "chine"),
                (-3.38 + y_shift, -3.05 + y_shift, "belly"),
            ]
            diag_secs = []
            for y0, y1, zone in y_stations:
                wt0, wc0, wb0, zt0, zs0, zc0, zb0 = hull_profile_at_y(y0)
                wt1, wc1, wb1, zt1, zs1, zc1, zb1 = hull_profile_at_y(y1)
                pad = 0.005
                in_d = 0.018
                if zone == "spine":
                    x0, z0 = side * 0.02, zt0 + pad
                    x1, z1 = side * 0.02, zt1 + pad
                    nx, nz = 0.0, -in_d
                elif zone == "top":
                    x0, z0 = side * (wt0 + pad), zt0 + pad
                    x1, z1 = side * (wt1 + pad), zt1 + pad
                    nx, nz = -side * in_d * 0.7, -in_d * 0.7
                elif zone == "shoulder":
                    x0, z0 = side * (wc0 * 0.90 + pad), zs0 + pad * 0.5
                    x1, z1 = side * (wc1 * 0.90 + pad), zs1 + pad * 0.5
                    nx, nz = -side * in_d, -in_d * 0.3
                elif zone == "chine":
                    x0, z0 = side * (wc0 + pad), zc0
                    x1, z1 = side * (wc1 + pad), zc1
                    nx, nz = -side * in_d, 0.0
                else:
                    x0, z0 = side * (wb0 + pad), zb0 - pad * 0.5
                    x1, z1 = side * (wb1 + pad), zb1 - pad * 0.5
                    nx, nz = -side * in_d, in_d * 0.4

                sec = [
                    (x0 + nx, y0, z0 + nz),
                    (x0, y0, z0),
                    (x1, y1, z1),
                    (x1 + nx, y1, z1 + nz),
                ]
                if side < 0:
                    sec.reverse()
                diag_secs.append(sec)

            diag_obj = loft_sections(f"Fuse_Diag_Stripe_{side}_{band_idx}", diag_secs, m_stripes)
            add_bevel(diag_obj, width=0.002, segments=2)
            hull_objs.append(diag_obj)

        # Horizontal lower nose chine stripe (Y = -5.50 to -2.75)
        chine_stripe_secs = []
        for y_s in (-5.50, -4.80, -4.00, -3.20, -2.75):
            wt_s, wc_s, wb_s, zt_s, zs_s, zc_s, zb_s = hull_profile_at_y(y_s)
            x_out = side * (wc_s + 0.005)
            x_in = side * (wc_s - 0.022)
            sec = [
                (x_in, y_s, zc_s - 0.075),
                (x_out * 0.96, y_s, zc_s - 0.075),
                (x_out, y_s, zc_s + 0.025),
                (x_in, y_s, zc_s + 0.025),
            ]
            if side < 0:
                sec.reverse()
            chine_stripe_secs.append(sec)
        c_stripe = loft_sections(f"Nose_Chine_Stripe_{side}", chine_stripe_secs, m_stripes)
        add_bevel(c_stripe, width=0.002, segments=2)
        hull_objs.append(c_stripe)

        # Triangular rescue chevron marker below front canopy
        tri_secs = [
            [(side * 0.62, -3.72, 0.24), (side * 0.665, -3.72, 0.24), (side * 0.665, -3.50, 0.24), (side * 0.62, -3.50, 0.24)],
            [(side * 0.64, -3.62, 0.14), (side * 0.675, -3.62, 0.14), (side * 0.675, -3.59, 0.14), (side * 0.64, -3.59, 0.14)],
        ]
        if side < 0:
            for s in tri_secs:
                s.reverse()
        tri_mark = loft_sections(f"Warning_Tri_{side}", tri_secs, m_stripes)
        hull_objs.append(tri_mark)

    # =========================================================================
    # 5. TWIN SHOULDER INTAKES, LERX GLOVES, & MAIN ENGINE NACELLES
    # =========================================================================
    sym_left_objs = []

    # 5a. Angular Shoulder Wedge Intake Cowl (matching sheet.jpg Side, Front, & 3/4 views!)
    # Look at sheet.jpg Side View:
    # A sleek tapered forward LERX / wedge overhang starts at a sharp point (Y = -2.65, Z = -0.02)
    # and rises smoothly back to (Y = -0.60, Z = +0.44), with a recessed dark boundary-layer/aux scoop
    # directly beneath its side lip (Y = -1.90 to -1.10, Z = -0.22 to -0.02) and the main wide intake
    # mouth opening below!
    def intake_ring(y_top_in, y_top_out, y_bot_out, y_bot_in, x_in, x_out, x_bot_out, z_top, z_bot, wall=0.055):
        outer = [
            (x_in, y_top_in, z_top),
            (x_out, y_top_out, z_top - 0.04),
            (x_out + 0.03, (y_top_out + y_bot_out) * 0.5, (z_top + z_bot) * 0.52),
            (x_bot_out, y_bot_out, z_bot),
            (x_in, y_bot_in, z_bot + 0.03),
        ]
        inner = [
            (x_in + wall, y_top_in, z_top - wall),
            (x_out - wall, y_top_out, z_top - 0.04 - wall),
            (x_out + 0.03 - wall, (y_top_out + y_bot_out) * 0.5, (z_top + z_bot) * 0.52),
            (x_bot_out - wall, y_bot_out, z_bot + wall),
            (x_in + wall, y_bot_in, z_bot + 0.03 + wall),
        ]
        return outer + list(reversed(inner))

    intake_cowl_secs = [
        intake_ring(-1.75, -1.95, -1.12, -1.02, 0.66, 1.86, 1.54, 0.32, -0.64, wall=0.045),
        intake_ring(-1.15, -1.35, -0.78, -0.72, 0.64, 1.96, 1.64, 0.40, -0.70, wall=0.060),
        intake_ring(-0.20, -0.30, -0.15, -0.10, 0.60, 2.00, 1.70, 0.46, -0.70, wall=0.070),
        intake_ring(0.55, 0.55, 0.55, 0.55, 0.54, 1.90, 1.66, 0.52, -0.65, wall=0.075),
    ]
    intake_cowl = loft_sections("Intake_Cowl_L", intake_cowl_secs, m_paint)
    add_bevel(intake_cowl, width=0.005, segments=2)
    sym_left_objs.append(intake_cowl)

    # Smooth linearly-sloped forward LERX / intake wedge hood (Y = -2.65 to -0.95)
    wedge_hood_secs = [
        [(0.68, -2.65, -0.02), (1.05, -2.65, -0.02), (1.03, -2.65, 0.04), (0.68, -2.65, 0.06)],
        [(0.66, -1.90, 0.00), (1.84, -1.90, -0.02), (1.82, -1.90, 0.24), (0.66, -1.90, 0.28)],
        [(0.64, -1.05, 0.04), (1.96, -1.05, 0.02), (1.94, -1.05, 0.39), (0.64, -1.05, 0.43)],
    ]
    wedge_hood = loft_sections("Intake_Wedge_Hood_L", wedge_hood_secs, m_paint)
    add_bevel(wedge_hood, width=0.005, segments=2)
    sym_left_objs.append(wedge_hood)

    # Dark recessed side intake mouth directly under the forward wedge overhang (visible in sheet.jpg Side View!)
    side_scoop_slot = make_box(
        "Side_Intake_Slot_L",
        (1.62, -1.62, -0.14),
        (0.46, 0.68, 0.22),
        m_cockpit,
        bevel=0.004,
        rot_euler=(0.0, 0.0, math.radians(-14)),
    )
    sym_left_objs.append(side_scoop_slot)

    # Red-orange `Stripes` trim band along the outer edge of the intake wedge hood
    hood_stripe_secs = [
        [(0.88, -2.62, -0.022), (1.06, -2.62, -0.022), (1.04, -2.62, 0.045), (0.88, -2.62, 0.05)],
        [(1.42, -1.90, -0.022), (1.85, -1.90, -0.022), (1.83, -1.90, 0.248), (1.42, -1.90, 0.26)],
        [(1.50, -1.10, 0.018), (1.97, -1.10, 0.018), (1.95, -1.10, 0.398), (1.50, -1.10, 0.41)],
    ]
    hood_stripe = loft_sections("Intake_Hood_Stripe_L", hood_stripe_secs, m_stripes)
    add_bevel(hood_stripe, width=0.002, segments=2)
    sym_left_objs.append(hood_stripe)

    # Two parallel diagonal red-orange stripes on the angled lower cheek of the intake cowl (Y = -1.05 to -0.15)
    for s_idx, y_sh in enumerate((0.0, 0.30)):
        cheek_secs = [
            [
                (1.88, -1.05 + y_sh, -0.16),
                (1.95, -1.05 + y_sh, -0.16),
                (1.95, -0.87 + y_sh, -0.16),
                (1.88, -0.87 + y_sh, -0.16),
            ],
            [
                (1.58, -0.62 + y_sh, -0.68),
                (1.66, -0.62 + y_sh, -0.68),
                (1.66, -0.44 + y_sh, -0.68),
                (1.58, -0.44 + y_sh, -0.68),
            ],
        ]
        s_bar = loft_sections(f"Intake_Cheek_Stripe_L_{s_idx}", cheek_secs, m_stripes)
        add_bevel(s_bar, width=0.002, segments=2)
        sym_left_objs.append(s_bar)

    # Horizontal splitter vane inside intake mouth + dark duct + 18-blade axial compressor fan
    splitter_vane = make_box(
        "Intake_Splitter_Vane_L",
        (1.26, -1.35, -0.22),
        (1.10, 0.75, 0.03),
        m_steel,
        bevel=0.003,
        rot_euler=(0.0, math.radians(-4), math.radians(-12)),
    )
    duct_back = make_box(
        "Intake_Duct_Back_L", (1.24, 0.38, -0.10), (1.16, 0.32, 0.96), m_cockpit, bevel=0.005
    )
    fan_hub = make_cylinder(
        "Compressor_Hub_L", (1.22, -0.05, -0.12), (1.22, 0.28, -0.12), 0.14, 0.18, segments=16, mat=m_steel
    )
    sym_left_objs.extend([splitter_vane, duct_back, fan_hub])
    for b_idx in range(18):
        ang = 2.0 * math.pi * b_idx / 18.0
        bx = 1.22 + 0.28 * math.cos(ang)
        bz = -0.12 + 0.28 * math.sin(ang)
        blade = make_box(
            f"Fan_Blade_L_{b_idx}",
            (bx, 0.12, bz),
            (0.30, 0.014, 0.06),
            m_steel,
            bevel=0.002,
            rot_euler=(0.35 * math.sin(ang), ang + math.pi * 0.5, 0.35 * math.cos(ang)),
        )
        sym_left_objs.append(blade)

    # 5b. Wing-Root Glove / LERX Shoulder Fairing & Integrated Wing-Root Micro-Missile Bay
    def glove_ring(y_in, y_out, x_in, x_out, z_top_in, z_top_out, z_bot):
        return [
            (x_in, y_in, z_top_in),
            (x_out * 0.94, y_out, z_top_out),
            (x_out, y_out, (z_top_out + z_bot) * 0.52),
            (x_out * 0.95, y_out, z_bot),
            (x_in, y_in, z_bot - 0.03),
        ]

    glove_secs = [
        glove_ring(-1.50, -1.80, 1.25, 1.85, 0.40, 0.32, 0.14),
        glove_ring(-0.75, -0.85, 1.35, 2.38, 0.46, 0.32, 0.06),
        glove_ring(0.20, 0.20, 1.40, 2.46, 0.50, 0.34, 0.02),
        glove_ring(1.35, 1.40, 1.42, 2.42, 0.48, 0.32, 0.04),
        glove_ring(2.35, 2.35, 1.40, 2.05, 0.42, 0.28, 0.08),
    ]
    glove_obj = loft_sections("Wing_Glove_L", glove_secs, m_paint)
    add_bevel(glove_obj, width=0.005, segments=2)
    sym_left_objs.append(glove_obj)

    slot_seal = make_box(
        "Wing_Pivot_Seal_L", (2.15, 0.85, 0.18), (0.56, 2.10, 0.12), m_cockpit, bevel=0.004
    )
    pivot_cap = make_cylinder(
        "Wing_Pivot_Cap_L", (1.85, 0.35, 0.04), (1.85, 0.35, 0.37), 0.34, 0.30, segments=20, mat=m_steel
    )
    # Recessed wing-root micro-missile launcher bay fairing on outer glove shoulder
    root_launcher_pod = make_box(
        "Wing_Root_Launcher_Box_L",
        (2.12, -0.42, 0.28),
        (0.32, 0.68, 0.14),
        m_paint,
        bevel=0.004,
        rot_euler=(0.0, math.radians(-10), math.radians(-14)),
    )
    root_launcher_face = make_box(
        "Wing_Root_Launcher_Tubes_L",
        (2.12, -0.75, 0.28),
        (0.26, 0.04, 0.09),
        m_steel,
        bevel=0.002,
        rot_euler=(0.0, math.radians(-10), math.radians(-14)),
    )
    sym_left_objs.extend([slot_seal, pivot_cap, root_launcher_pod, root_launcher_face])

    # 5c. Main Engine Nacelle (Y = +0.40 to +6.45, centered at X = +1.18, Z = +0.02)
    def nacelle_oct_ring(y, cx, cz, hw, hh, ch_top=0.20, ch_bot=0.22):
        return [
            (cx - hw + ch_top, y, cz + hh),
            (cx + hw - ch_top, y, cz + hh),
            (cx + hw, y, cz + hh - ch_top),
            (cx + hw, y, cz - hh + ch_bot),
            (cx + hw - ch_bot, y, cz - hh),
            (cx - hw + ch_bot, y, cz - hh),
            (cx - hw, y, cz - hh + ch_bot),
            (cx - hw, y, cz + hh - ch_top),
        ]

    nac_stations = [
        (0.40, 1.20, -0.04, 0.72, 0.65, 0.18, 0.22),
        (1.60, 1.19, -0.02, 0.74, 0.67, 0.19, 0.21),
        (3.10, 1.18, 0.01, 0.73, 0.66, 0.19, 0.21),
        (4.60, 1.18, 0.03, 0.70, 0.64, 0.18, 0.19),
        (5.90, 1.18, 0.05, 0.67, 0.61, 0.17, 0.17),
    ]
    nac_rings = [nacelle_oct_ring(*st) for st in nac_stations]
    nacelle_obj = loft_sections("Engine_Nacelle_L", nac_rings, m_paint)
    add_bevel(nacelle_obj, width=0.006, segments=2)
    sym_left_objs.append(nacelle_obj)

    # Low-profile sleek auxiliary dorsal intake vent on top of each nacelle (Y = +1.85 to +3.25)
    aux_hood_secs = [
        [(0.82, 1.85, 0.65), (1.50, 1.85, 0.65), (1.44, 1.85, 0.73), (0.88, 1.85, 0.73)],
        [(0.80, 2.35, 0.65), (1.52, 2.35, 0.65), (1.46, 2.35, 0.74), (0.86, 2.35, 0.74)],
        [(0.84, 3.25, 0.66), (1.48, 3.25, 0.66), (1.42, 3.25, 0.68), (0.90, 3.25, 0.68)],
    ]
    aux_hood = loft_sections("Dorsal_Aux_Intake_L", aux_hood_secs, m_paint)
    add_bevel(aux_hood, width=0.004, segments=2)
    aux_mouth = make_box(
        "Dorsal_Aux_Mouth_L", (1.16, 1.92, 0.69), (0.52, 0.18, 0.07), m_cockpit, bevel=0.002
    )
    aux_stripe = loft_sections(
        "Dorsal_Aux_Stripe_L",
        [
            [(0.81, 1.83, 0.65), (1.51, 1.83, 0.65), (1.45, 1.83, 0.738), (0.87, 1.83, 0.738)],
            [(0.80, 2.22, 0.65), (1.52, 2.22, 0.65), (1.46, 2.22, 0.745), (0.86, 2.22, 0.745)],
        ],
        m_stripes,
    )
    add_bevel(aux_stripe, width=0.002, segments=2)
    sym_left_objs.extend([aux_hood, aux_mouth, aux_stripe])

    # Aft dorsal nacelle red-orange L-stripe trim (visible in sheet.jpg Top View at Y = +4.2 to +5.7!)
    aft_deck_stripe_1 = make_box(
        "Aft_Deck_Stripe_Long_L",
        (0.76, 4.95, 0.65),
        (0.14, 1.45, 0.016),
        m_stripes,
        bevel=0.002,
    )
    aft_deck_stripe_2 = make_box(
        "Aft_Deck_Stripe_Cross_L",
        (1.06, 5.62, 0.645),
        (0.68, 0.18, 0.016),
        m_stripes,
        bevel=0.002,
    )
    sym_left_objs.extend([aft_deck_stripe_1, aft_deck_stripe_2])

    # Nacelle side armor relief panels & aft recessed bare-metal panel
    for py, pz, sy, sz, pmat in [
        (2.35, 0.02, 1.10, 0.52, m_paint),
        (3.75, 0.03, 1.10, 0.50, m_paint),
        (5.25, 0.04, 0.88, 0.44, m_steel),
    ]:
        side_panel = make_box(
            f"Nacelle_Side_Panel_L_{py:.1f}",
            (1.88, py, pz),
            (0.025, sy, sz),
            pmat,
            bevel=0.003,
        )
        sym_left_objs.append(side_panel)

    # Bare-Metal (`Steel`) Aft Rectangular-to-Octagonal Nozzle Shroud Collar (Y = +5.85 to +6.55)
    collar_nac_secs = [
        nacelle_oct_ring(5.85, 1.18, 0.05, 0.68, 0.62, 0.17, 0.17),
        nacelle_oct_ring(6.22, 1.18, 0.05, 0.66, 0.60, 0.18, 0.18),
        nacelle_oct_ring(6.55, 1.18, 0.05, 0.63, 0.58, 0.20, 0.20),
    ]
    nozzle_collar = loft_sections("Nozzle_Collar_L", collar_nac_secs, m_steel)
    add_bevel(nozzle_collar, width=0.005, segments=2)
    sym_left_objs.append(nozzle_collar)

    # =========================================================================
    # 6. SQUARE-TO-ROUND HEAT-SCORCHED NOZZLES & GLOWING ENGINE CORES (Y = +6.55 to +7.90)
    # =========================================================================
    def superellipse_ring(y, cx, cz, rx, rz, power=3.2, n_pts=24):
        pts = []
        for i in range(n_pts):
            ang = 2.0 * math.pi * i / n_pts
            ca, sa = math.cos(ang), math.sin(ang)
            x = cx + rx * (abs(ca) ** (2.0 / power)) * (1.0 if ca >= 0 else -1.0)
            z = cz + rz * (abs(sa) ** (2.0 / power)) * (1.0 if sa >= 0 else -1.0)
            petal_mod = 1.0 + 0.015 * math.cos(12.0 * ang)
            x = cx + (x - cx) * petal_mod
            z = cz + (z - cz) * petal_mod
            pts.append((x, y, z))
        return pts

    noz_outer_0 = superellipse_ring(6.52, 1.18, 0.05, 0.62, 0.57, power=4.2, n_pts=24)
    noz_outer_1 = superellipse_ring(7.05, 1.18, 0.05, 0.59, 0.54, power=3.0, n_pts=24)
    noz_outer_2 = superellipse_ring(7.52, 1.18, 0.05, 0.55, 0.51, power=2.3, n_pts=24)
    noz_outer_3 = superellipse_ring(7.90, 1.18, 0.05, 0.50, 0.47, power=2.0, n_pts=24)
    noz_inner_3 = superellipse_ring(7.90, 1.18, 0.05, 0.44, 0.41, power=2.0, n_pts=24)

    nozzle_ext = loft_sections(
        "Nozzle_Scorched_L",
        [noz_outer_0, noz_outer_1, noz_outer_2, noz_outer_3, noz_inner_3],
        m_scorched,
        closed_loop=True,
        cap_ends=False,
    )
    add_bevel(nozzle_ext, width=0.003, segments=2)
    sym_left_objs.append(nozzle_ext)

    for p_idx in range(12):
        ang = 2.0 * math.pi * p_idx / 12.0
        ca, sa = math.cos(ang), math.sin(ang)
        p0 = (1.18 + 0.61 * ca, 6.48, 0.05 + 0.56 * sa)
        p1 = (1.18 + 0.50 * ca, 7.86, 0.05 + 0.47 * sa)
        rib = make_cylinder(f"Nozzle_Rib_L_{p_idx}", p0, p1, 0.012, 0.009, segments=8, mat=m_steel)
        sym_left_objs.append(rib)

    glow_liner = loft_sections(
        "Engine_Glow_Liner_L",
        [
            superellipse_ring(6.42, 1.18, 0.05, 0.12, 0.12, power=2.0, n_pts=24),
            superellipse_ring(6.48, 1.18, 0.05, 0.48, 0.45, power=2.2, n_pts=24),
            superellipse_ring(7.20, 1.18, 0.05, 0.46, 0.43, power=2.0, n_pts=24),
            noz_inner_3,
        ],
        m_engines,
        closed_loop=True,
        cap_ends=False,
    )
    turb_cone = make_cylinder(
        "Engine_Core_Cone_L",
        (1.18, 6.40, 0.05),
        (1.18, 7.45, 0.05),
        0.22,
        0.03,
        segments=18,
        mat=m_engines,
    )
    sym_left_objs.extend([glow_liner, turb_cone])

    # =========================================================================
    # 7. TWIN CANTED VERTICAL TAILS & TWIN VENTRAL FINS (Exact Z in [-1.35, +2.65])
    # =========================================================================
    def airfoil_ring(x, y_le, y_te, z, thick, cant_angle=math.radians(27)):
        chord = y_te - y_le
        nx = math.cos(cant_angle) * thick * 0.5
        nz = -math.sin(cant_angle) * thick * 0.5
        return [
            (x, y_le, z),
            (x + nx * 0.65, y_le + chord * 0.16, z + nz * 0.65),
            (x + nx, y_le + chord * 0.38, z + nz),
            (x + nx * 0.55, y_le + chord * 0.75, z + nz * 0.55),
            (x + nx * 0.15, y_te, z + nz * 0.15),
            (x - nx * 0.15, y_te, z - nz * 0.15),
            (x - nx * 0.55, y_le + chord * 0.75, z - nz * 0.55),
            (x - nx, y_le + chord * 0.38, z - nz),
            (x - nx * 0.65, y_le + chord * 0.16, z - nz * 0.65),
        ]

    vtail_secs = [
        airfoil_ring(1.45, 3.10, 6.22, 0.56, 0.145),
        airfoil_ring(1.82, 3.92, 6.48, 1.25, 0.115),
        airfoil_ring(2.22, 4.82, 6.75, 2.02, 0.088),
        airfoil_ring(2.55, 5.58, 6.98, 2.642, 0.062),
    ]
    vtail_obj = loft_sections("Vertical_Tail_L", vtail_secs, m_paint)
    add_bevel(vtail_obj, width=0.004, segments=2)
    sym_left_objs.append(vtail_obj)

    vtail_le_secs = [
        airfoil_ring(1.48, 3.07, 3.60, 0.62, 0.152),
        airfoil_ring(1.82, 3.89, 4.38, 1.25, 0.122),
        airfoil_ring(2.22, 4.79, 5.24, 2.02, 0.095),
        airfoil_ring(2.54, 5.55, 5.96, 2.63, 0.068),
    ]
    vtail_stripe = loft_sections("Vertical_Tail_Stripe_L", vtail_le_secs, m_stripes)
    add_bevel(vtail_stripe, width=0.002, segments=2)
    sym_left_objs.append(vtail_stripe)

    vtail_rudder_secs = [
        airfoil_ring(1.56, 5.25, 6.26, 0.78, 0.105),
        airfoil_ring(2.20, 5.95, 6.72, 1.98, 0.072),
    ]
    vtail_rudder = loft_sections("Vertical_Tail_Rudder_L", vtail_rudder_secs, m_paint)
    add_bevel(vtail_rudder, width=0.003, segments=2)
    vtail_tip_light = make_box(
        "VTail_Strobe_L", (2.55, 6.55, 2.635), (0.035, 0.26, 0.022), m_lamps, bevel=0.002
    )
    sym_left_objs.extend([vtail_rudder, vtail_tip_light])

    vfin_secs = [
        airfoil_ring(1.22, 3.85, 6.38, -0.55, 0.105, cant_angle=math.radians(-26)),
        airfoil_ring(1.38, 4.42, 6.28, -0.95, 0.080, cant_angle=math.radians(-26)),
        airfoil_ring(1.55, 5.02, 6.18, -1.342, 0.056, cant_angle=math.radians(-26)),
    ]
    vfin_obj = loft_sections("Ventral_Fin_L", vfin_secs, m_paint)
    add_bevel(vfin_obj, width=0.004, segments=2)
    sym_left_objs.append(vfin_obj)

    vfin_le_secs = [
        airfoil_ring(1.23, 3.82, 4.42, -0.58, 0.112, cant_angle=math.radians(-26)),
        airfoil_ring(1.38, 4.39, 4.95, -0.95, 0.088, cant_angle=math.radians(-26)),
        airfoil_ring(1.54, 4.99, 5.48, -1.335, 0.062, cant_angle=math.radians(-26)),
    ]
    vfin_stripe = loft_sections("Ventral_Fin_Stripe_L", vfin_le_secs, m_stripes)
    add_bevel(vfin_stripe, width=0.002, segments=2)
    sym_left_objs.append(vfin_stripe)

    sym_right_objs = [mirror_x_copy(o, "_R") for o in sym_left_objs]
    hull_objs.extend(sym_left_objs)
    hull_objs.extend(sym_right_objs)

    # =========================================================================
    # 8. SWING WINGS AT MID SWEEP & ALIGNED MISSILE LAUNCHERS (`Wing_L`, `Wing_R`)
    # =========================================================================
    def build_missile_and_pylon(prefix, x_pos, y_nose, z_pylon_top, z_missile, length=2.55, radius=0.085):
        parts = []
        pylon_len = length * 0.58
        y_pylon_0 = y_nose + length * 0.22
        y_pylon_1 = y_pylon_0 + pylon_len
        pylon_secs = [
            [
                (x_pos - 0.038, y_pylon_0 - 0.12, z_pylon_top),
                (x_pos + 0.038, y_pylon_0 - 0.12, z_pylon_top),
                (x_pos + 0.038, y_pylon_1 + 0.08, z_pylon_top),
                (x_pos - 0.038, y_pylon_1 + 0.08, z_pylon_top),
            ],
            [
                (x_pos - 0.028, y_pylon_0, z_missile + radius * 0.9),
                (x_pos + 0.028, y_pylon_0, z_missile + radius * 0.9),
                (x_pos + 0.028, y_pylon_1, z_missile + radius * 0.9),
                (x_pos - 0.028, y_pylon_1, z_missile + radius * 0.9),
            ],
        ]
        pylon = loft_sections(f"{prefix}_Pylon", pylon_secs, m_paint)
        add_bevel(pylon, width=0.003, segments=2)
        rail = make_box(
            f"{prefix}_Rail",
            (x_pos, (y_pylon_0 + y_pylon_1) * 0.5, z_missile + radius + 0.020),
            (0.072, pylon_len + 0.30, 0.042),
            m_steel,
            bevel=0.003,
        )
        parts.extend([pylon, rail])

        m_nose = make_cylinder(
            f"{prefix}_Nose",
            (x_pos, y_nose, z_missile),
            (x_pos, y_nose + 0.38, z_missile),
            0.010,
            radius,
            segments=18,
            mat=m_paint,
        )
        m_band1 = make_cylinder(
            f"{prefix}_Stripe_Fwd",
            (x_pos, y_nose + 0.38, z_missile),
            (x_pos, y_nose + 0.58, z_missile),
            radius * 1.015,
            radius * 1.015,
            segments=18,
            mat=m_stripes,
        )
        m_body = make_cylinder(
            f"{prefix}_Body",
            (x_pos, y_nose + 0.58, z_missile),
            (x_pos, y_nose + length - 0.34, z_missile),
            radius,
            radius,
            segments=18,
            mat=m_paint,
        )
        m_band2 = make_cylinder(
            f"{prefix}_Stripe_Aft",
            (x_pos, y_nose + length - 0.34, z_missile),
            (x_pos, y_nose + length - 0.14, z_missile),
            radius * 1.015,
            radius * 1.015,
            segments=18,
            mat=m_stripes,
        )
        m_nozzle = make_cylinder(
            f"{prefix}_Nozzle",
            (x_pos, y_nose + length - 0.14, z_missile),
            (x_pos, y_nose + length, z_missile),
            radius,
            radius * 0.68,
            segments=18,
            mat=m_scorched,
        )
        parts.extend([m_nose, m_band1, m_body, m_band2, m_nozzle])

        for f_ang in (math.pi * 0.25, math.pi * 0.75, math.pi * 1.25, math.pi * 1.75):
            ca, sa = math.cos(f_ang), math.sin(f_ang)
            for f_name, fy, flen, fspan in [
                ("MidFin", y_nose + length * 0.48, 0.28, radius * 2.1),
                ("TailFin", y_nose + length * 0.88, 0.24, radius * 2.4),
            ]:
                fin = make_box(
                    f"{prefix}_{f_name}_{f_ang:.2f}",
                    (x_pos + ca * fspan * 0.65, fy, z_missile + sa * fspan * 0.65),
                    (fspan, flen, 0.012),
                    m_steel,
                    bevel=0.002,
                    rot_euler=(0.0, f_ang, 0.0),
                )
                parts.append(fin)
        return parts

    def wing_airfoil_section(x, y_le, y_te, z_center, thick):
        chord = y_te - y_le
        ht = thick * 0.5
        return [
            (x, y_le, z_center),
            (x, y_le + chord * 0.12, z_center + ht * 0.78),
            (x, y_le + chord * 0.32, z_center + ht),
            (x, y_le + chord * 0.68, z_center + ht * 0.62),
            (x, y_te, z_center + 0.007),
            (x, y_te, z_center - 0.007),
            (x, y_le + chord * 0.68, z_center - ht * 0.48),
            (x, y_le + chord * 0.32, z_center - ht * 0.72),
            (x, y_le + chord * 0.12, z_center - ht * 0.58),
        ]

    wing_secs = [
        wing_airfoil_section(1.72, -0.05, 1.95, 0.18, 0.155),
        wing_airfoil_section(2.38, -0.02, 2.08, 0.18, 0.150),
        wing_airfoil_section(2.48, -0.14, 2.12, 0.18, 0.144),
        wing_airfoil_section(3.85, 0.78, 2.58, 0.17, 0.112),
        wing_airfoil_section(5.25, 1.72, 3.05, 0.16, 0.080),
        wing_airfoil_section(6.44, 2.52, 3.45, 0.15, 0.050),
        wing_airfoil_section(6.496, 2.62, 3.42, 0.15, 0.034),
    ]
    wing_blade_l = loft_sections("Wing_Blade_L", wing_secs, m_paint)
    add_bevel(wing_blade_l, width=0.004, segments=2)
    wing_l_objs.append(wing_blade_l)

    wing_knuckle_l = make_cylinder(
        "Wing_Knuckle_L", (1.85, 0.35, 0.07), (1.85, 0.35, 0.29), 0.28, 0.28, segments=20, mat=m_steel
    )
    wing_l_objs.append(wing_knuckle_l)

    for fx, fy in [(3.05, 2.05), (4.25, 2.48), (5.45, 2.88)]:
        flap_pod = make_cylinder(
            f"Flap_Track_L_{fx:.1f}",
            (fx, fy - 0.35, 0.11),
            (fx, fy + 0.22, 0.10),
            0.028,
            0.014,
            segments=10,
            mat=m_steel,
        )
        wing_l_objs.append(flap_pod)

    # Connected L-chevron contrast stripe on Swing Wing (leading edge + wingtip chord, zero gap!)
    wing_le_stripe_secs = [
        wing_airfoil_section(2.50, -0.05, 0.35, 0.18, 0.150),
        wing_airfoil_section(3.85, 0.85, 1.22, 0.17, 0.118),
        wing_airfoil_section(5.25, 1.78, 2.10, 0.16, 0.086),
        wing_airfoil_section(6.22, 2.42, 2.72, 0.152, 0.058),
    ]
    wing_le_stripe = loft_sections("Wing_LE_Stripe_L", wing_le_stripe_secs, m_stripes)
    add_bevel(wing_le_stripe, width=0.002, segments=2)
    wing_l_objs.append(wing_le_stripe)

    wing_tip_stripe_secs = [
        wing_airfoil_section(5.88, 2.20, 3.25, 0.155, 0.068),
        wing_airfoil_section(6.22, 2.42, 3.36, 0.152, 0.058),
    ]
    wing_tip_stripe = loft_sections("Wing_Tip_Stripe_L", wing_tip_stripe_secs, m_stripes)
    add_bevel(wing_tip_stripe, width=0.002, segments=2)
    wing_l_objs.append(wing_tip_stripe)

    nav_light_l = make_box(
        "Wing_Nav_Lens_L", (6.48, 2.78, 0.15), (0.025, 0.20, 0.028), m_lamps, bevel=0.002
    )
    wing_l_objs.append(nav_light_l)

    # Two under-wing missiles per wing at the SAME Z = -0.32 and aligned Y = +0.05..+2.65
    # so Front/3/4 views show twin missiles under each wing and Side view shows ONE clean missile profile!
    wing_l_objs.extend(
        build_missile_and_pylon(
            "Wing_Missile_In_L", x_pos=2.58, y_nose=0.05, z_pylon_top=0.14, z_missile=-0.30, length=2.60, radius=0.088
        )
    )
    wing_l_objs.extend(
        build_missile_and_pylon(
            "Wing_Missile_Out_L", x_pos=3.42, y_nose=0.12, z_pylon_top=0.13, z_missile=-0.30, length=2.50, radius=0.084
        )
    )

    wing_r_objs = [mirror_x_copy(o, "_R") for o in wing_l_objs]

    # =========================================================================
    # 9. VENTRAL CENTERLINE GUN POD (`Turret_1` -> `Gun_1`)
    # =========================================================================
    # Look at sheet.jpg Side View: sleek streamlined gun pod tucked tightly under the belly
    turret_collar = make_cylinder(
        "GunPod_Turret_Collar",
        (0.0, 0.40, -0.46),
        (0.0, 0.40, -0.66),
        0.22,
        0.19,
        segments=20,
        mat=m_steel,
    )
    turret_fairing = make_box(
        "GunPod_Turret_Fairing",
        (0.0, 0.42, -0.60),
        (0.30, 1.30, 0.14),
        m_paint,
        bevel=0.006,
    )
    for ty in (-0.05, 0.85):
        fork = make_box(
            f"GunPod_Trunnion_Fork_{ty:.2f}",
            (0.0, ty, -0.72),
            (0.28, 0.20, 0.18),
            m_steel,
            bevel=0.005,
        )
        turret_objs.append(fork)
    turret_objs.extend([turret_collar, turret_fairing])

    # `Gun_1` (pivot at X = 0.0, Y = +0.40, Z = -0.82, elevates about X)
    pod_housing_secs = [
        nacelle_oct_ring(-0.62, 0.0, -0.82, 0.16, 0.14, 0.05, 0.05),
        nacelle_oct_ring(-0.35, 0.0, -0.82, 0.21, 0.17, 0.06, 0.06),
        nacelle_oct_ring(1.55, 0.0, -0.82, 0.21, 0.17, 0.06, 0.06),
        nacelle_oct_ring(2.05, 0.0, -0.80, 0.14, 0.12, 0.04, 0.04),
        nacelle_oct_ring(2.25, 0.0, -0.78, 0.07, 0.06, 0.02, 0.02),
    ]
    pod_housing = loft_sections("GunPod_Housing", pod_housing_secs, m_steel)
    add_bevel(pod_housing, width=0.005, segments=2)
    gun_objs.append(pod_housing)

    # Flush side access panels on the gun pod housing (no protruding belly straps!)
    for side in (-1, 1):
        gp_panel = make_box(
            f"GunPod_Side_Panel_{side}",
            (side * 0.208, 0.60, -0.82),
            (0.016, 1.45, 0.18),
            m_cockpit,
            bevel=0.003,
        )
        gun_objs.append(gp_panel)

    barrel_shroud = make_cylinder(
        "GunPod_Shroud",
        (0.0, -1.25, -0.82),
        (0.0, -0.58, -0.82),
        0.115,
        0.135,
        segments=20,
        mat=m_steel,
    )
    gun_objs.append(barrel_shroud)
    for c_y in (-2.05, -1.65, -1.25):
        clamp = make_cylinder(
            f"GunPod_Barrel_Clamp_{c_y:.2f}",
            (0.0, c_y - 0.035, -0.82),
            (0.0, c_y + 0.035, -0.82),
            0.098,
            0.098,
            segments=18,
            mat=m_steel,
        )
        gun_objs.append(clamp)

    for b_i in range(6):
        ang = 2.0 * math.pi * b_i / 6.0
        bx = 0.058 * math.cos(ang)
        bz = -0.82 + 0.058 * math.sin(ang)
        brl = make_cylinder(
            f"Gatling_Barrel_{b_i}",
            (bx, -2.12, bz),
            (bx, -0.58, bz),
            0.019,
            0.021,
            segments=12,
            mat=m_steel,
        )
        gun_objs.append(brl)

    laser_pod = make_box(
        "GunPod_Laser", (0.0, -0.92, -0.96), (0.10, 0.58, 0.065), m_cockpit, bevel=0.003
    )
    laser_lens = make_box(
        "GunPod_Laser_Lens", (0.0, -1.21, -0.96), (0.06, 0.02, 0.04), m_lamps, bevel=0.002
    )
    gun_objs.extend([laser_pod, laser_lens])

    # =========================================================================
    # 10. JOIN INTO NAMED GAMEPLAY NODES WITH EXACT PIVOTS
    # =========================================================================
    pivots = {
        "Hull": (0.0, 0.0, 0.0),
        "Canopy": (0.0, -1.85, 0.90),
        "Wing_L": (1.85, 0.35, 0.18),
        "Wing_R": (-1.85, 0.35, 0.18),
        "Turret_1": (0.0, 0.40, -0.60),
        "Gun_1": (0.0, 0.40, -0.82),
    }

    hull_node = apply_all_modifiers_and_join(hull_objs, "Hull", pivot=pivots["Hull"])
    canopy_node = apply_all_modifiers_and_join(canopy_objs, "Canopy", pivot=pivots["Canopy"])
    wing_l_node = apply_all_modifiers_and_join(wing_l_objs, "Wing_L", pivot=pivots["Wing_L"])
    wing_r_node = apply_all_modifiers_and_join(wing_r_objs, "Wing_R", pivot=pivots["Wing_R"])
    turret_node = apply_all_modifiers_and_join(turret_objs, "Turret_1", pivot=pivots["Turret_1"])
    gun_node = apply_all_modifiers_and_join(gun_objs, "Gun_1", pivot=pivots["Gun_1"])

    for child, parent in [
        (canopy_node, hull_node),
        (wing_l_node, hull_node),
        (wing_r_node, hull_node),
        (turret_node, hull_node),
        (gun_node, turret_node),
    ]:
        world_loc = child.location.copy()
        child.parent = parent
        child.matrix_parent_inverse = Matrix.Identity(4)
        child.location = world_loc - parent.matrix_world.translation

    mesh_nodes = [hull_node, canopy_node, wing_l_node, wing_r_node, turret_node, gun_node]

    # Exact normalisation to 13.000 m wingspan (X), 15.800 m length (Y), 4.000 m height (Z)
    bpy.context.view_layer.update()
    all_pts = [o.matrix_world @ v.co for o in mesh_nodes for v in o.data.vertices]
    min_x, max_x = min(p.x for p in all_pts), max(p.x for p in all_pts)
    min_y, max_y = min(p.y for p in all_pts), max(p.y for p in all_pts)
    min_z, max_z = min(p.z for p in all_pts), max(p.z for p in all_pts)
    sx = 13.0 / (max_x - min_x)
    sy = 15.8 / (max_y - min_y)
    sz = 4.0 / (max_z - min_z)
    cx = 0.5 * (min_x + max_x)
    cy = 0.5 * (min_y + max_y)
    for o in mesh_nodes:
        wmat = o.matrix_world.copy()
        inv_w = wmat.inverted()
        for v in o.data.vertices:
            wp = wmat @ v.co
            wp.x = (wp.x - cx) * sx
            wp.y = (wp.y - cy) * sy
            wp.z = wp.z * sz
            v.co = inv_w @ wp
        o.data.update()

    bpy.context.view_layer.update()
    all_pts = [o.matrix_world @ v.co for o in mesh_nodes for v in o.data.vertices]
    min_x, max_x = min(p.x for p in all_pts), max(p.x for p in all_pts)
    min_y, max_y = min(p.y for p in all_pts), max(p.y for p in all_pts)
    min_z, max_z = min(p.z for p in all_pts), max(p.z for p in all_pts)
    print(
        f"Exact bounds: X=[{min_x:.4f}, {max_x:.4f}] ({max_x - min_x:.4f}m), "
        f"Y=[{min_y:.4f}, {max_y:.4f}] ({max_y - min_y:.4f}m), "
        f"Z=[{min_z:.4f}, {max_z:.4f}] ({max_z - min_z:.4f}m)",
        flush=True,
    )

    empty_specs = [
        ("Thruster_1", hull_node, (1.18, 7.90, 0.05)),
        ("Thruster_2", hull_node, (-1.18, 7.90, 0.05)),
        ("Muzzle_1", gun_node, (0.0, -2.12, -0.82)),
        ("Hardpoint_1", hull_node, (2.12, -0.78, 0.28)),
        ("Hardpoint_2", hull_node, (-2.12, -0.78, 0.28)),
        ("Hardpoint_3", wing_l_node, (2.58, 1.15, -0.30)),
        ("Hardpoint_4", wing_r_node, (-2.58, 1.15, -0.30)),
        ("Hardpoint_5", wing_l_node, (3.42, 1.25, -0.30)),
        ("Hardpoint_6", wing_r_node, (-3.42, 1.25, -0.30)),
        ("Seat_Pilot", hull_node, (0.0, -3.35, 0.52)),
        ("Camera_Chase", hull_node, (0.0, 14.50, 3.80)),
        ("Light_Nav_L", wing_l_node, (6.48, 2.78, 0.15)),
        ("Light_Nav_R", wing_r_node, (-6.48, 2.78, 0.15)),
        ("Light_Beacon", hull_node, (0.0, -0.23, 1.13)),
    ]
    empty_nodes = []
    for ename, eparent, eworld in empty_specs:
        emp = bpy.data.objects.new(ename, None)
        emp.empty_display_type = "PLAIN_AXES"
        emp.empty_display_size = 0.25
        bpy.context.scene.collection.objects.link(emp)
        emp.parent = eparent
        emp.matrix_parent_inverse = Matrix.Identity(4)
        emp.location = Vector(eworld) - eparent.matrix_world.translation
        empty_nodes.append(emp)

    unwrap_box_project(mesh_nodes)
    return mesh_nodes, empty_nodes


# ---------------------------------------------------------------------------
# Material Creation & Cycles Baking (GAME WEIGHT: 1024px Base/Normal, 512px ORM)
# ---------------------------------------------------------------------------
def load_ph_image(ph_id, map_type, non_color=False):
    fpath = TEX_DIR / f"{ph_id}_{map_type}_1k.jpg"
    img = bpy.data.images.load(str(fpath), check_existing=True)
    if non_color:
        img.colorspace_settings.name = "Non-Color"
    return img


def get_gltf_occlusion_group():
    group = bpy.data.node_groups.get("glTF Material Output")
    if not group:
        group = bpy.data.node_groups.new("glTF Material Output", "ShaderNodeTree")
        group.interface.new_socket(name="Occlusion", in_out="INPUT", socket_type="NodeSocketFloat")
    return group


def build_and_bake_materials():
    mats = {}
    for mname in ("Paint", "Stripes", "Steel", "Scorched", "Cockpit", "Engines", "Glass", "Lamps"):
        m = bpy.data.materials.new(mname)
        m.use_nodes = True
        mats[mname] = m

    bm = bmesh.new()
    bmesh.ops.create_grid(bm, x_segments=64, y_segments=64, size=1.0)
    bake_mesh = bpy.data.meshes.new("_BakeProxy")
    bm.to_mesh(bake_mesh)
    bm.free()
    bake_uv = bake_mesh.uv_layers.new(name="UVMap")
    for poly in bake_mesh.polygons:
        for li in poly.loop_indices:
            vi = bake_mesh.loops[li].vertex_index
            co = bake_mesh.vertices[vi].co
            bake_uv.data[li].uv = (co.x * 0.5 + 0.5, co.y * 0.5 + 0.5)
    bake_obj = bpy.data.objects.new("_BakeProxy", bake_mesh)
    bpy.context.scene.collection.objects.link(bake_obj)

    scene = bpy.context.scene
    orig_samples = scene.cycles.samples
    scene.cycles.samples = 16

    def bake_pbr_set(mat, ph_main, ph_wear, mode="paint"):
        nt = mat.node_tree
        nt.nodes.clear()
        out = nt.nodes.new("ShaderNodeOutputMaterial")
        bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
        nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

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

        diff_main = nt.nodes.new("ShaderNodeTexImage")
        diff_main.image = load_ph_image(ph_main, "diff", non_color=False)
        nor_main = nt.nodes.new("ShaderNodeTexImage")
        nor_main.image = load_ph_image(ph_main, "nor_gl", non_color=True)
        arm_main = nt.nodes.new("ShaderNodeTexImage")
        arm_main.image = load_ph_image(ph_main, "arm", non_color=True)

        diff_wear = nt.nodes.new("ShaderNodeTexImage")
        diff_wear.image = load_ph_image(ph_wear, "diff", non_color=False)
        arm_wear = nt.nodes.new("ShaderNodeTexImage")
        arm_wear.image = load_ph_image(ph_wear, "arm", non_color=True)

        for tn in (diff_main, nor_main, arm_main, diff_wear, arm_wear):
            nt.links.new(mapping.outputs["Vector"], tn.inputs["Vector"])

        panel_grid = nt.nodes.new("ShaderNodeTexBrick")
        panel_grid.offset = 0.0
        panel_grid.inputs["Scale"].default_value = 3.0
        panel_grid.inputs["Brick Width"].default_value = 1.5
        panel_grid.inputs["Row Height"].default_value = 1.0
        panel_grid.inputs["Mortar Size"].default_value = 0.006
        panel_grid.inputs["Mortar Smooth"].default_value = 0.15
        panel_grid.inputs["Color1"].default_value = (1.0, 1.0, 1.0, 1.0)
        panel_grid.inputs["Color2"].default_value = (0.96, 0.96, 0.98, 1.0)
        panel_grid.inputs["Mortar"].default_value = (0.58, 0.60, 0.63, 1.0)
        nt.links.new(tex_coord.outputs["UV"], panel_grid.inputs["Vector"])

        noise = nt.nodes.new("ShaderNodeTexNoise")
        noise.inputs["Scale"].default_value = 12.0
        noise.inputs["Detail"].default_value = 6.0
        noise.inputs["Roughness"].default_value = 0.6
        nt.links.new(tex_coord.outputs["UV"], noise.inputs["Vector"])

        sep_arm = nt.nodes.new("ShaderNodeSeparateColor")
        nt.links.new(arm_main.outputs["Color"], sep_arm.inputs["Color"])
        sep_wear = nt.nodes.new("ShaderNodeSeparateColor")
        nt.links.new(arm_wear.outputs["Color"], sep_wear.inputs["Color"])

        emit_bake = nt.nodes.new("ShaderNodeEmission")

        if mode == "paint":
            ramp_base = nt.nodes.new("ShaderNodeValToRGB")
            ramp_base.color_ramp.elements[0].position = 0.10
            ramp_base.color_ramp.elements[0].color = (0.86, 0.87, 0.89, 1.0)
            ramp_base.color_ramp.elements[1].position = 0.90
            ramp_base.color_ramp.elements[1].color = (0.99, 0.99, 1.00, 1.0)
            nt.links.new(sep_arm.outputs["Green"], ramp_base.inputs["Fac"])

            mix_panel = nt.nodes.new("ShaderNodeMix")
            mix_panel.data_type = "RGBA"
            mix_panel.blend_type = "MULTIPLY"
            mix_panel.inputs["Factor"].default_value = 0.45
            nt.links.new(ramp_base.outputs["Color"], mix_panel.inputs[6])
            nt.links.new(panel_grid.outputs["Color"], mix_panel.inputs[7])

            mix_grime = nt.nodes.new("ShaderNodeMix")
            mix_grime.data_type = "RGBA"
            mix_grime.blend_type = "MULTIPLY"
            mix_grime.inputs["Factor"].default_value = 0.06
            nt.links.new(mix_panel.outputs[2], mix_grime.inputs[6])
            nt.links.new(sep_wear.outputs["Red"], mix_grime.inputs[7])

            base_col_socket = mix_grime.outputs[2]
            met_val, rough_base = 0.15, 0.36

        elif mode == "stripes":
            ramp_red = nt.nodes.new("ShaderNodeValToRGB")
            ramp_red.color_ramp.elements[0].position = 0.15
            ramp_red.color_ramp.elements[0].color = (0.52, 0.055, 0.032, 1.0)
            ramp_red.color_ramp.elements[1].position = 0.85
            ramp_red.color_ramp.elements[1].color = (0.74, 0.095, 0.048, 1.0)
            nt.links.new(sep_arm.outputs["Green"], ramp_red.inputs["Fac"])

            mix_panel = nt.nodes.new("ShaderNodeMix")
            mix_panel.data_type = "RGBA"
            mix_panel.blend_type = "MULTIPLY"
            mix_panel.inputs["Factor"].default_value = 0.25
            nt.links.new(ramp_red.outputs["Color"], mix_panel.inputs[6])
            nt.links.new(panel_grid.outputs["Color"], mix_panel.inputs[7])
            base_col_socket = mix_panel.outputs[2]
            met_val, rough_base = 0.22, 0.32

        elif mode == "steel":
            ramp_steel = nt.nodes.new("ShaderNodeValToRGB")
            ramp_steel.color_ramp.elements[0].position = 0.10
            ramp_steel.color_ramp.elements[0].color = (0.36, 0.39, 0.43, 1.0)
            ramp_steel.color_ramp.elements[1].position = 0.90
            ramp_steel.color_ramp.elements[1].color = (0.66, 0.69, 0.74, 1.0)
            nt.links.new(sep_arm.outputs["Green"], ramp_steel.inputs["Fac"])

            mix_panel = nt.nodes.new("ShaderNodeMix")
            mix_panel.data_type = "RGBA"
            mix_panel.blend_type = "MULTIPLY"
            mix_panel.inputs["Factor"].default_value = 0.28
            nt.links.new(ramp_steel.outputs["Color"], mix_panel.inputs[6])
            nt.links.new(panel_grid.outputs["Color"], mix_panel.inputs[7])
            base_col_socket = mix_panel.outputs[2]
            met_val, rough_base = 0.88, 0.26

        elif mode == "scorched":
            sep_uv = nt.nodes.new("ShaderNodeSeparateXYZ")
            nt.links.new(tex_coord.outputs["UV"], sep_uv.inputs["Vector"])

            math_add = nt.nodes.new("ShaderNodeMath")
            math_add.operation = "ADD"
            nt.links.new(sep_uv.outputs["Y"], math_add.inputs[0])
            math_mul = nt.nodes.new("ShaderNodeMath")
            math_mul.operation = "MULTIPLY"
            math_mul.inputs[1].default_value = 0.06
            nt.links.new(noise.outputs["Fac"], math_mul.inputs[0])
            nt.links.new(math_mul.outputs["Value"], math_add.inputs[1])

            ramp_heat = nt.nodes.new("ShaderNodeValToRGB")
            cr = ramp_heat.color_ramp
            cr.elements[0].position = 0.08
            cr.elements[0].color = (0.78, 0.46, 0.14, 1.0)
            cr.elements[1].position = 0.45
            cr.elements[1].color = (0.86, 0.42, 0.10, 1.0)
            e2 = cr.elements.new(0.66)
            e2.color = (0.38, 0.24, 0.58, 1.0)
            e3 = cr.elements.new(0.88)
            e3.color = (0.12, 0.42, 0.86, 1.0)
            nt.links.new(math_add.outputs["Value"], ramp_heat.inputs["Fac"])

            ramp_grain = nt.nodes.new("ShaderNodeValToRGB")
            ramp_grain.color_ramp.elements[0].position = 0.0
            ramp_grain.color_ramp.elements[0].color = (0.78, 0.78, 0.80, 1.0)
            ramp_grain.color_ramp.elements[1].position = 1.0
            ramp_grain.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
            nt.links.new(sep_arm.outputs["Green"], ramp_grain.inputs["Fac"])

            mix_metal = nt.nodes.new("ShaderNodeMix")
            mix_metal.data_type = "RGBA"
            mix_metal.blend_type = "MULTIPLY"
            mix_metal.inputs["Factor"].default_value = 0.85
            nt.links.new(ramp_heat.outputs["Color"], mix_metal.inputs[6])
            nt.links.new(ramp_grain.outputs["Color"], mix_metal.inputs[7])
            base_col_socket = mix_metal.outputs[2]
            met_val, rough_base = 0.88, 0.22

        else:  # cockpit
            ramp_dark = nt.nodes.new("ShaderNodeValToRGB")
            ramp_dark.color_ramp.elements[0].position = 0.1
            ramp_dark.color_ramp.elements[0].color = (0.045, 0.052, 0.060, 1.0)
            ramp_dark.color_ramp.elements[1].position = 0.9
            ramp_dark.color_ramp.elements[1].color = (0.115, 0.128, 0.145, 1.0)
            nt.links.new(sep_arm.outputs["Green"], ramp_dark.inputs["Fac"])
            base_col_socket = ramp_dark.outputs["Color"]
            met_val, rough_base = 0.35, 0.48

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

        # 1. Bake BaseColor (1024x1024)
        img_bc = bpy.data.images.new(f"{mat.name}_BaseColor", width=1024, height=1024, alpha=False)
        img_bc.colorspace_settings.name = "sRGB"
        node_bc = nt.nodes.new("ShaderNodeTexImage")
        node_bc.name = "Baked_BaseColor"
        node_bc.image = img_bc
        nt.links.new(base_col_socket, emit_bake.inputs["Color"])
        nt.links.new(emit_bake.outputs["Emission"], out.inputs["Surface"])
        for n in nt.nodes:
            n.select = False
        node_bc.select = True
        nt.nodes.active = node_bc
        bpy.ops.object.bake(type="EMIT")
        bc_path = BAKED_DIR / f"{mat.name}_BaseColor.jpg"
        img_bc.filepath_raw = str(bc_path)
        img_bc.file_format = "JPEG"
        img_bc.save()

        # 2. Bake ORM (512x512, Non-Color: R=Occlusion, G=Roughness, B=Metallic)
        comb_orm = nt.nodes.new("ShaderNodeCombineColor")
        nt.links.new(sep_arm.outputs["Red"], comb_orm.inputs["Red"])
        math_rough = nt.nodes.new("ShaderNodeMath")
        math_rough.operation = "MULTIPLY_ADD"
        nt.links.new(sep_arm.outputs["Green"], math_rough.inputs[0])
        math_rough.inputs[1].default_value = 0.18
        math_rough.inputs[2].default_value = rough_base
        nt.links.new(math_rough.outputs["Value"], comb_orm.inputs["Green"])
        comb_orm.inputs["Blue"].default_value = met_val

        img_orm = bpy.data.images.new(f"{mat.name}_ORM", width=512, height=512, alpha=False)
        img_orm.colorspace_settings.name = "Non-Color"
        node_orm = nt.nodes.new("ShaderNodeTexImage")
        node_orm.name = "Baked_ORM"
        node_orm.image = img_orm
        nt.links.new(comb_orm.outputs["Color"], emit_bake.inputs["Color"])
        for n in nt.nodes:
            n.select = False
        node_orm.select = True
        nt.nodes.active = node_orm
        bpy.ops.object.bake(type="EMIT")
        orm_path = BAKED_DIR / f"{mat.name}_ORM.jpg"
        img_orm.filepath_raw = str(orm_path)
        img_orm.file_format = "JPEG"
        img_orm.save()

        # 3. Bake Normal Map (1024x1024, Non-Color)
        nmap_in = nt.nodes.new("ShaderNodeNormalMap")
        nmap_in.inputs["Strength"].default_value = 0.65
        nt.links.new(nor_main.outputs["Color"], nmap_in.inputs["Color"])
        bump = nt.nodes.new("ShaderNodeBump")
        bump.inputs["Strength"].default_value = 0.15
        bump.inputs["Distance"].default_value = 0.005
        nt.links.new(panel_grid.outputs["Fac"], bump.inputs["Height"])
        nt.links.new(nmap_in.outputs["Normal"], bump.inputs["Normal"])
        nt.links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
        nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

        img_nor = bpy.data.images.new(f"{mat.name}_Normal", width=1024, height=1024, alpha=False)
        img_nor.colorspace_settings.name = "Non-Color"
        node_nor = nt.nodes.new("ShaderNodeTexImage")
        node_nor.name = "Baked_Normal"
        node_nor.image = img_nor
        for n in nt.nodes:
            n.select = False
        node_nor.select = True
        nt.nodes.active = node_nor
        bpy.ops.object.bake(type="NORMAL")
        nor_path = BAKED_DIR / f"{mat.name}_Normal.jpg"
        img_nor.filepath_raw = str(nor_path)
        img_nor.file_format = "JPEG"
        img_nor.save()

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

        tex_bc = nt.nodes.new("ShaderNodeTexImage")
        tex_bc.name = "Baked_BaseColor"
        tex_bc.image = bpy.data.images.load(str(bc_path), check_existing=True)

        if mat.name == "Paint":
            mix_fac = nt.nodes.new("ShaderNodeMix")
            mix_fac.name = "PaintFactor"
            mix_fac.data_type = "RGBA"
            mix_fac.blend_type = "MULTIPLY"
            mix_fac.inputs["Factor"].default_value = 1.0
            r, g, b = PAINTS[0][1]
            mix_fac.inputs[7].default_value = (r, g, b, 1.0)
            nt.links.new(tex_bc.outputs["Color"], mix_fac.inputs[6])
            nt.links.new(mix_fac.outputs[2], bsdf.inputs["Base Color"])
        else:
            nt.links.new(tex_bc.outputs["Color"], bsdf.inputs["Base Color"])

        tex_orm = nt.nodes.new("ShaderNodeTexImage")
        tex_orm.name = "Baked_ORM"
        tex_orm.image = bpy.data.images.load(str(orm_path), check_existing=True)
        tex_orm.image.colorspace_settings.name = "Non-Color"
        sep = nt.nodes.new("ShaderNodeSeparateColor")
        nt.links.new(tex_orm.outputs["Color"], sep.inputs["Color"])
        nt.links.new(sep.outputs["Green"], bsdf.inputs["Roughness"])
        nt.links.new(sep.outputs["Blue"], bsdf.inputs["Metallic"])

        occ_group = nt.nodes.new("ShaderNodeGroup")
        occ_group.node_tree = get_gltf_occlusion_group()
        nt.links.new(sep.outputs["Red"], occ_group.inputs["Occlusion"])

        tex_nor = nt.nodes.new("ShaderNodeTexImage")
        tex_nor.name = "Baked_Normal"
        tex_nor.image = bpy.data.images.load(str(nor_path), check_existing=True)
        tex_nor.image.colorspace_settings.name = "Non-Color"
        nmap = nt.nodes.new("ShaderNodeNormalMap")
        nt.links.new(tex_nor.outputs["Color"], nmap.inputs["Color"])
        nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
        print(f"Baked PBR material: {mat.name}", flush=True)

    bake_pbr_set(mats["Paint"], "blue_metal_plate", "rusty_metal_03", mode="paint")
    bake_pbr_set(mats["Stripes"], "metal_plate_02", "rusty_metal_03", mode="stripes")
    bake_pbr_set(mats["Steel"], "metal_plate_02", "blue_metal_plate", mode="steel")
    bake_pbr_set(mats["Scorched"], "metal_plate_02", "rusty_metal_03", mode="scorched")
    bake_pbr_set(mats["Cockpit"], "metal_plate_02", "rusty_metal_03", mode="cockpit")

    # Bake `Engines` blue-to-orange thermal glow texture (512x512)
    m_eng = mats["Engines"]
    nt = m_eng.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    emit = nt.nodes.new("ShaderNodeEmission")
    tc = nt.nodes.new("ShaderNodeTexCoord")
    sep_uv = nt.nodes.new("ShaderNodeSeparateXYZ")
    nt.links.new(tc.outputs["UV"], sep_uv.inputs["Vector"])
    ramp_glow = nt.nodes.new("ShaderNodeValToRGB")
    cr = ramp_glow.color_ramp
    cr.elements[0].position = 0.12
    cr.elements[0].color = (1.0, 0.48, 0.08, 1.0)
    cr.elements[1].position = 0.85
    cr.elements[1].color = (0.14, 0.58, 1.0, 1.0)
    nt.links.new(sep_uv.outputs["Y"], ramp_glow.inputs["Fac"])
    nt.links.new(ramp_glow.outputs["Color"], emit.inputs["Color"])
    nt.links.new(emit.outputs["Emission"], out.inputs["Surface"])

    bake_obj.data.materials.clear()
    bake_obj.data.materials.append(m_eng)
    img_eng = bpy.data.images.new("Engines_Glow", width=512, height=512, alpha=False)
    img_eng.colorspace_settings.name = "sRGB"
    node_eng = nt.nodes.new("ShaderNodeTexImage")
    node_eng.image = img_eng
    for n in nt.nodes:
        n.select = False
    node_eng.select = True
    nt.nodes.active = node_eng
    bpy.ops.object.bake(type="EMIT")
    eng_path = BAKED_DIR / "Engines_Glow.jpg"
    img_eng.filepath_raw = str(eng_path)
    img_eng.file_format = "JPEG"
    img_eng.save()

    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    tex_eng = nt.nodes.new("ShaderNodeTexImage")
    tex_eng.image = bpy.data.images.load(str(eng_path), check_existing=True)
    nt.links.new(tex_eng.outputs["Color"], bsdf.inputs["Base Color"])
    nt.links.new(tex_eng.outputs["Color"], bsdf.inputs["Emission Color"])
    bsdf.inputs["Emission Strength"].default_value = 4.5
    bsdf.inputs["Roughness"].default_value = 0.20
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    # Configure `Glass` (alpha BLEND tinted canopy glass)
    m_glass = mats["Glass"]
    m_glass.blend_method = "BLEND" if hasattr(m_glass, "blend_method") else "HASHED"
    nt = m_glass.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.inputs["Base Color"].default_value = (0.09, 0.16, 0.24, 1.0)
    bsdf.inputs["Metallic"].default_value = 0.15
    bsdf.inputs["Roughness"].default_value = 0.04
    bsdf.inputs["IOR"].default_value = 1.52
    bsdf.inputs["Alpha"].default_value = 0.34
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    # Configure `Lamps` (emissive running lights, HUD, MFD displays)
    m_lamps = mats["Lamps"]
    nt = m_lamps.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.inputs["Base Color"].default_value = (0.25, 0.86, 1.0, 1.0)
    bsdf.inputs["Emission Color"].default_value = (0.25, 0.86, 1.0, 1.0)
    bsdf.inputs["Emission Strength"].default_value = 3.0
    bsdf.inputs["Roughness"].default_value = 0.15
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])

    bpy.data.objects.remove(bake_obj, do_unlink=True)
    bpy.data.meshes.remove(bake_mesh)
    scene.cycles.samples = orig_samples
    return mats


# ---------------------------------------------------------------------------
# Lighting, Space Backdrop, & Rendering Helpers
# ---------------------------------------------------------------------------
def setup_space_world_and_lights():
    scene = bpy.context.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.015, 0.028, 0.052, 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"])

    lights = []
    for lname, ltype, loc, rot, energy, color, size in [
        ("Key_Light", "AREA", (12.0, -13.5, 10.0), (math.radians(52), 0, math.radians(40)), 3800.0, (1.0, 0.98, 0.95), 7.0),
        ("Fill_Light", "AREA", (-12.5, -10.0, 4.5), (math.radians(70), 0, math.radians(-48)), 1350.0, (0.74, 0.86, 1.0), 9.0),
        ("Rim_Light", "AREA", (5.5, 12.5, 6.5), (math.radians(-58), 0, math.radians(152)), 1800.0, (0.86, 0.92, 1.0), 7.0),
        ("Ventral_Fill", "AREA", (8.5, -2.5, -6.5), (math.radians(125), 0, math.radians(72)), 950.0, (0.78, 0.86, 0.98), 8.0),
    ]:
        ldata = bpy.data.lights.new(lname, type=ltype)
        ldata.energy = energy
        ldata.color = color
        ldata.size = size
        lobj = bpy.data.objects.new(lname, ldata)
        lobj.location = loc
        lobj.rotation_euler = rot
        scene.collection.objects.link(lobj)
        lights.append(lobj)
    return lights


def point_camera_at(cam_obj, loc, target, lens=50.0, ortho_scale=None):
    cam_obj.location = Vector(loc)
    direction = Vector(target) - Vector(loc)
    cam_obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
    if ortho_scale is not None:
        cam_obj.data.type = "ORTHO"
        cam_obj.data.ortho_scale = ortho_scale
    else:
        cam_obj.data.type = "PERSP"
        cam_obj.data.lens = lens


def render_views(mesh_nodes, mats):
    scene = bpy.context.scene
    cam_data = bpy.data.cameras.new("RenderCam")
    cam_obj = bpy.data.objects.new("RenderCam", cam_data)
    scene.collection.objects.link(cam_obj)
    scene.camera = cam_obj

    cam_34_loc = (12.2, -13.8, 6.4)
    cam_34_target = (0.15, 0.25, 0.05)

    # 1. Verify moving parts by rotating 30 degrees and rendering a diagnostic proof first
    mov_rotations = {
        "Canopy": (math.radians(-30), 0.0, 0.0),
        "Wing_L": (0.0, 0.0, math.radians(30)),
        "Wing_R": (0.0, 0.0, math.radians(-30)),
        "Turret_1": (0.0, 0.0, math.radians(30)),
        "Gun_1": (math.radians(-20), 0.0, 0.0),
    }
    node_by_name = {o.name: o for o in mesh_nodes}
    for mname, rot in mov_rotations.items():
        node_by_name[mname].rotation_euler = rot
    bpy.context.view_layer.update()
    point_camera_at(cam_obj, cam_34_loc, cam_34_target, lens=42.0)
    scene.render.resolution_x = 960
    scene.render.resolution_y = 540
    scene.render.image_settings.file_format = "PNG"
    scene.cycles.samples = 24
    scene.render.filepath = str(BAKED_DIR / "pivot_check.png")
    bpy.ops.render.render(write_still=True)

    # Restore to rest pose (0,0,0)
    for mname in mov_rotations:
        node_by_name[mname].rotation_euler = (0.0, 0.0, 0.0)
    bpy.context.view_layer.update()

    # Render `final.png` (1920x1080 Cycles)
    point_camera_at(cam_obj, cam_34_loc, cam_34_target, lens=42.0)
    scene.render.resolution_x = 1920
    scene.render.resolution_y = 1080
    scene.cycles.samples = 112
    scene.render.filepath = str(ROOT / "final.png")
    bpy.ops.render.render(write_still=True)
    print("Rendered final.png", flush=True)

    # Render `clay.png` (same view as final, plain grey clay material override)
    clay_mat = bpy.data.materials.new("ClayOverride")
    clay_mat.use_nodes = True
    clay_bsdf = clay_mat.node_tree.nodes["Principled BSDF"]
    clay_bsdf.inputs["Base Color"].default_value = (0.55, 0.56, 0.58, 1.0)
    clay_bsdf.inputs["Roughness"].default_value = 0.42
    clay_bsdf.inputs["Metallic"].default_value = 0.0
    scene.view_layers[0].material_override = clay_mat
    scene.cycles.samples = 64
    scene.render.filepath = str(ROOT / "clay.png")
    bpy.ops.render.render(write_still=True)
    scene.view_layers[0].material_override = None
    bpy.data.materials.remove(clay_mat)
    print("Rendered clay.png", flush=True)

    # Render `side.png` (orthographic left side view matching top profile of sheet.jpg)
    point_camera_at(cam_obj, (18.0, 0.0, 0.55), (0.0, 0.0, 0.55), ortho_scale=17.4)
    scene.render.resolution_x = 1920
    scene.render.resolution_y = 800
    scene.cycles.samples = 64
    scene.render.filepath = str(ROOT / "side.png")
    bpy.ops.render.render(write_still=True)
    print("Rendered side.png", flush=True)

    # Render `paints.png` (3x2 grid of the 6 faction paints in the same 3/4 view)
    point_camera_at(cam_obj, cam_34_loc, cam_34_target, lens=42.0)
    tile_w, tile_h = 640, 360
    scene.render.resolution_x = tile_w
    scene.render.resolution_y = tile_h
    scene.cycles.samples = 36

    paint_nt = mats["Paint"].node_tree
    paint_fac_node = paint_nt.nodes["PaintFactor"]
    tile_paths = []
    for idx, (pname, rgb) in enumerate(PAINTS):
        paint_fac_node.inputs[7].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
        tpath = BAKED_DIR / f"paint_tile_{idx}.png"
        scene.render.filepath = str(tpath)
        bpy.ops.render.render(write_still=True)
        tile_paths.append(tpath)

    r0, g0, b0 = PAINTS[0][1]
    paint_fac_node.inputs[7].default_value = (r0, g0, b0, 1.0)

    grid_w, grid_h = tile_w * 3, tile_h * 2
    grid_img = bpy.data.images.new("PaintsGrid", width=grid_w, height=grid_h, alpha=True)
    grid_pixels = [0.0] * (grid_w * grid_h * 4)

    for idx, tpath in enumerate(tile_paths):
        timg = bpy.data.images.load(str(tpath))
        tpix = list(timg.pixels)
        col = idx % 3
        row = 1 - (idx // 3)
        x0 = col * tile_w
        y0 = row * tile_h
        for y in range(tile_h):
            src_start = y * tile_w * 4
            dst_start = ((y0 + y) * grid_w + x0) * 4
            grid_pixels[dst_start : dst_start + tile_w * 4] = tpix[src_start : src_start + tile_w * 4]
        bpy.data.images.remove(timg)

    grid_img.pixels[:] = grid_pixels
    grid_img.filepath_raw = str(ROOT / "paints.png")
    grid_img.file_format = "PNG"
    grid_img.save()
    bpy.data.images.remove(grid_img)
    print("Rendered paints.png (3x2 grid)", flush=True)

    bpy.data.objects.remove(cam_obj, do_unlink=True)
    bpy.data.cameras.remove(cam_data)


# ---------------------------------------------------------------------------
# Export GLB & Write nodes.json
# ---------------------------------------------------------------------------
def export_glb_and_metadata(mesh_nodes, empty_nodes, mats):
    all_export_objs = mesh_nodes + empty_nodes

    paint_nt = mats["Paint"].node_tree
    paint_bsdf = next(n for n in paint_nt.nodes if n.type == "BSDF_PRINCIPLED")
    paint_tex = paint_nt.nodes["Baked_BaseColor"]
    paint_fac = paint_nt.nodes["PaintFactor"]
    paint_nt.links.new(paint_tex.outputs["Color"], paint_bsdf.inputs["Base Color"])

    bpy.ops.object.select_all(action="DESELECT")
    for o in all_export_objs:
        o.select_set(True)

    glb_path = ROOT / "variable-fighter-anime.glb"
    bpy.ops.export_scene.gltf(
        filepath=str(glb_path),
        export_format="GLB",
        use_selection=True,
        export_yup=True,
        export_apply=False,
        export_animations=False,
        export_cameras=False,
        export_lights=False,
        export_texcoords=True,
        export_normals=True,
        export_tangents=True,
        export_materials="EXPORT",
        export_image_format="AUTO",
    )

    raw = glb_path.read_bytes()
    jlen = struct.unpack_from("<I", raw, 12)[0]
    doc = json.loads(raw[20 : 20 + jlen])
    bin_chunk = raw[20 + jlen :]

    for m in doc.get("materials", []):
        pbr = m.setdefault("pbrMetallicRoughness", {})
        if m.get("name") == "Paint":
            pbr["baseColorFactor"] = [0.85, 0.85, 0.88, 1.0]
        elif m.get("name") == "Glass":
            m["alphaMode"] = "BLEND"
            m["doubleSided"] = True

    jbytes = json.dumps(doc, separators=(",", ":")).encode("utf-8")
    jbytes += b" " * ((-len(jbytes)) % 4)
    total_len = 12 + 8 + len(jbytes) + len(bin_chunk)
    new_glb = (
        struct.pack("<III", 0x46546C67, 2, total_len)
        + struct.pack("<II", len(jbytes), 0x4E4F534A)
        + jbytes
        + bin_chunk
    )
    glb_path.write_bytes(new_glb)

    paint_nt.links.new(paint_fac.outputs[2], paint_bsdf.inputs["Base Color"])

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

    bpy.context.view_layer.update()
    tris = sum(len(p.vertices) - 2 for o in mesh_nodes for p in o.data.polygons)
    pts_gltf = [to_gltf_vec(o.matrix_world @ v.co) for o in mesh_nodes for v in o.data.vertices]
    bb_min = [round(min(p[i] for p in pts_gltf), 4) for i in range(3)]
    bb_max = [round(max(p[i] for p in pts_gltf), 4) for i in range(3)]
    bb_size = [round(bb_max[i] - bb_min[i], 4) for i in range(3)]

    axes_map = {
        "Canopy": [-1.0, 0.0, 0.0],
        "Wing_L": [0.0, 1.0, 0.0],
        "Wing_R": [0.0, 1.0, 0.0],
        "Turret_1": [0.0, 1.0, 0.0],
        "Gun_1": [1.0, 0.0, 0.0],
    }

    nodes_list = []
    for o in all_export_objs:
        wpos = to_gltf_vec(o.matrix_world.translation)
        nodes_list.append(
            {
                "name": o.name,
                "type": "MESH" if o.type == "MESH" else "EMPTY",
                "parent": o.parent.name if o.parent else None,
                "world_position": wpos,
                "position": wpos,
                "axis": axes_map.get(o.name),
            }
        )

    nodes_json = {
        "asset": "variable-fighter-anime",
        "contract": "spacecraft/v1",
        "units": "metres",
        "frame": {"forward": "+Z", "up": "+Y", "left": "+X"},
        "bounding_box": {
            "min": bb_min,
            "max": bb_max,
            "size": bb_size,
            "dimensions": bb_size,
        },
        "triangles": tris,
        "materials": [m.get("name") for m in doc.get("materials", [])],
        "file_bytes": glb_path.stat().st_size,
        "axes": axes_map,
        "paints": [{"name": n, "linear_rgb": rgb} for n, rgb in PAINTS],
        "nodes": nodes_list,
        "source_textures": [
            {"id": ph_id, "license": "CC0-1.0", "url": f"https://polyhaven.com/a/{ph_id}"}
            for ph_id in POLYHAVEN_IDS
        ],
    }
    (ROOT / "nodes.json").write_text(json.dumps(nodes_json, indent=2) + "\n")
    print(
        f"Exported {glb_path.name}: {tris} tris, {glb_path.stat().st_size} bytes, "
        f"bounds={bb_size}",
        flush=True,
    )


def main():
    ensure_polyhaven_textures()
    reset_scene()
    mats = build_and_bake_materials()
    mesh_nodes, empty_nodes = build_fighter_geometry(mats)
    lights = setup_space_world_and_lights()
    export_glb_and_metadata(mesh_nodes, empty_nodes, mats)
    render_views(mesh_nodes, mats)
    for lobj in lights:
        bpy.data.objects.remove(lobj, do_unlink=True)
    print("BUILD COMPLETE", flush=True)


if __name__ == "__main__":
    main()
