#!/usr/bin/env python3
"""
build_troop-dropship.py
Builds the armoured military troop dropship 3D asset from an empty Blender scene,
downloads/loads CC0 Poly Haven textures, bakes PBR textures with Cycles (<=1024px,
ORM <=512px), exports troop-dropship.glb and nodes.json, and renders final.png,
side.png, clay.png, and paints.png.
"""

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

# ---------------------------------------------------------------------------
# Paths & Configuration
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = SCRIPT_DIR
TMP_DIR = "/tmp/troop-dropship-work"
TEX_SRC_DIR = os.path.join(TMP_DIR, "polyhaven_1k")
BAKED_DIR = os.path.join(TMP_DIR, "baked")
os.makedirs(TEX_SRC_DIR, exist_ok=True)
os.makedirs(BAKED_DIR, exist_ok=True)

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

PAINTS = [
    {"name": "Olive drab", "linear_rgb": [0.24, 0.27, 0.20]},
    {"name": "Naval grey", "linear_rgb": [0.36, 0.41, 0.46]},
    {"name": "Gunmetal", "linear_rgb": [0.15, 0.17, 0.19]},
    {"name": "Desert sand", "linear_rgb": [0.54, 0.43, 0.28]},
    {"name": "Arctic white", "linear_rgb": [0.76, 0.80, 0.83]},
    {"name": "Deep navy", "linear_rgb": [0.05, 0.09, 0.16]},
]

MOVING_AXES = {
    "Canopy": [1, 0, 0],
    "Ramp": [1, 0, 0],
    "Door_L": [0, 0, -1],
    "Door_R": [0, 0, -1],
    "Fan_L": [0, 1, 0],
    "Fan_R": [0, 1, 0],
    "Turret_1": [0, 1, 0],
    "Gun_1": [1, 0, 0],
}


# ---------------------------------------------------------------------------
# Coordinate Frame Helpers
# ---------------------------------------------------------------------------
# Export contract (glTF Y-up):
#   +Z = Nose (Forward), -Z = Rear (Aft)   [L = 28m: z in -14..+14]
#   +Y = Up, -Y = Down                     [H = 7m:  y in -3.5..+3.5]
#   +X = Left (Port), -X = Right (Stbd)    [W = 22m: x in -11..+11]
# Blender with export_yup=True maps Blender (x_b, y_b, z_b) -> glTF (x_b, z_b, -y_b).
# Therefore, glTF (x, y, z) corresponds to Blender (x, -z, y).
def G(x, y, z):
    """Convert glTF (x=left, y=up, z=forward) to Blender (X, Y, Z)."""
    return Vector((float(x), -float(z), float(y)))


def B_to_G(vec):
    """Convert Blender Vector (x_b, y_b, z_b) back to glTF [x, y, z]."""
    return [round(float(vec.x), 6), round(float(vec.z), 6), round(float(-vec.y), 6)]


# ---------------------------------------------------------------------------
# Poly Haven Texture Loader
# ---------------------------------------------------------------------------
def ensure_polyhaven_textures():
    search_dirs = [
        TEX_SRC_DIR,
        "/root/fal-author/abrams-tank-v1/textures",
        "/root/fal-author/heavy-bomber-opera-v1/textures",
        "/root/fal-author/t14-armata-tank-v1/textures",
    ]
    paths = {}
    for ph_id in POLYHAVEN_IDS:
        paths[ph_id] = {}
        for map_type in ("diff", "nor_gl", "arm"):
            fname = f"{ph_id}_{map_type}_1k.jpg"
            dest = os.path.join(TEX_SRC_DIR, fname)
            if not os.path.exists(dest) or os.path.getsize(dest) < 1000:
                found_local = False
                for sdir in search_dirs:
                    cand = os.path.join(sdir, fname)
                    if os.path.exists(cand) and os.path.getsize(cand) > 1000 and cand != dest:
                        shutil.copyfile(cand, dest)
                        found_local = True
                        break
                if not found_local:
                    url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{fname}"
                    print(f"Downloading {url} -> {dest}")
                    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
                    with urllib.request.urlopen(req, timeout=30) as resp, open(dest, "wb") as f:
                        f.write(resp.read())
            paths[ph_id][map_type] = dest
    return paths


# ---------------------------------------------------------------------------
# Scene & GPU Setup
# ---------------------------------------------------------------------------
def reset_scene():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    scene = bpy.context.scene
    scene.unit_settings.system = 'METRIC'
    scene.unit_settings.scale_length = 1.0
    return scene


def configure_cycles_gpu(samples=64):
    scene = bpy.context.scene
    scene.render.engine = 'CYCLES'
    prefs = bpy.context.preferences
    cprefs = prefs.addons['cycles'].preferences
    used_gpu = False
    for dev_type in ('OPTIX', 'CUDA'):
        try:
            cprefs.compute_device_type = dev_type
            cprefs.get_devices()
            for d in cprefs.devices:
                d.use = True
                if d.type in ('OPTIX', 'CUDA'):
                    used_gpu = True
            if used_gpu:
                break
        except Exception:
            pass
    scene.cycles.device = 'GPU' if used_gpu else 'CPU'
    scene.cycles.samples = samples
    scene.cycles.use_denoising = True
    return scene


# ---------------------------------------------------------------------------
# BMesh Construction Primitives (in glTF Coordinates!)
# ---------------------------------------------------------------------------
def add_lofted_sections(bm, sections, mat_idx=0, cap_start=True, cap_end=True, smooth=False, bevel_width=0.0, bevel_segs=1):
    """
    Loft a sequence of closed 3D loops defined in glTF coordinates.
    Each entry in sections is a list of (x, y, z) tuples with the same vertex count.
    """
    rings = []
    for sec in sections:
        ring = [bm.verts.new(G(p[0], p[1], p[2])) for p in sec]
        rings.append(ring)
    created_faces = []
    n_pts = len(rings[0])
    for i in range(len(rings) - 1):
        r0 = rings[i]
        r1 = rings[i + 1]
        for j in range(n_pts):
            jn = (j + 1) % n_pts
            v0, v1, v2, v3 = r0[j], r0[jn], r1[jn], r1[j]
            try:
                f = bm.faces.new((v0, v1, v2, v3))
                f.material_index = mat_idx
                f.smooth = smooth
                created_faces.append(f)
            except ValueError:
                pass
    if cap_start and len(rings[0]) >= 3:
        try:
            f = bm.faces.new(list(reversed(rings[0])))
            f.material_index = mat_idx
            f.smooth = False
            created_faces.append(f)
        except ValueError:
            pass
    if cap_end and len(rings[-1]) >= 3:
        try:
            f = bm.faces.new(rings[-1])
            f.material_index = mat_idx
            f.smooth = False
            created_faces.append(f)
        except ValueError:
            pass
    bmesh.ops.recalc_face_normals(bm, faces=created_faces)
    if bevel_width > 0.0 and created_faces:
        edges = list({e for f in created_faces for e in f.edges})
        res = bmesh.ops.bevel(
            bm,
            geom=edges,
            offset=bevel_width,
            offset_type='OFFSET',
            segments=bevel_segs,
            profile=0.5,
            affect='EDGES',
            clamp_overlap=True,
        )
        for f in res.get("faces", []):
            f.material_index = mat_idx
            f.smooth = smooth
    return created_faces


def add_bevel_box(bm, cx, cy, cz, sx, sy, sz, mat_idx=0, bevel=0.035, segs=2, rot_deg=(0, 0, 0), smooth=False):
    """
    Create a bevelled box in glTF coordinates centered at (cx, cy, cz)
    with full dimensions (sx, sy, sz), rotated in glTF space.
    """
    hx, hy, hz = sx * 0.5, sy * 0.5, sz * 0.5
    rx, ry, rz = [math.radians(a) for a in rot_deg]
    rot_mat = Euler((rx, ry, rz), 'XYZ').to_matrix()

    def tf(lx, ly, lz):
        v = rot_mat @ Vector((lx, ly, lz))
        return (cx + v.x, cy + v.y, cz + v.z)

    sec0 = [tf(-hx, -hy, -hz), tf(hx, -hy, -hz), tf(hx, hy, -hz), tf(-hx, hy, -hz)]
    sec1 = [tf(-hx, -hy, hz), tf(hx, -hy, hz), tf(hx, hy, hz), tf(-hx, hy, hz)]
    return add_lofted_sections(
        bm, [sec0, sec1], mat_idx=mat_idx, cap_start=True, cap_end=True,
        smooth=smooth, bevel_width=bevel, bevel_segs=segs
    )


def add_plate_quad(bm, p0, p1, p2, p3, normal_g, thickness=0.04, mat_idx=1, bevel=0.015, segs=2):
    """
    Create a flush bevelled armour plate from 4 corners (p0, p1, p2, p3) extruded along normal_g.
    """
    vn = Vector(normal_g).normalized()
    v_corners = [Vector(p0), Vector(p1), Vector(p2), Vector(p3)]
    sec0 = [(v - vn * (thickness * 0.3)).to_tuple() for v in v_corners]
    sec1 = [(v + vn * (thickness * 0.7)).to_tuple() for v in v_corners]
    return add_lofted_sections(
        bm, [sec0, sec1], mat_idx=mat_idx, cap_start=True, cap_end=True,
        smooth=False, bevel_width=bevel, bevel_segs=segs
    )


def add_cylinder_g(bm, p0, p1, r0, r1=None, segs=24, mat_idx=0, cap0=True, cap1=True, smooth=True, bevel=0.0):
    """
    Create a cylinder/frustum between glTF points p0 and p1 with radii r0, r1.
    """
    if r1 is None:
        r1 = r0
    v0 = Vector(p0)
    v1 = Vector(p1)
    axis = v1 - v0
    if axis.length < 1e-6:
        return []
    d = axis.normalized()
    up = Vector((0, 1, 0)) if abs(d.y) < 0.9 else Vector((1, 0, 0))
    u = d.cross(up).normalized()
    w = d.cross(u).normalized()

    sec0, sec1 = [], []
    for i in range(segs):
        ang = (2.0 * math.pi * i) / segs
        ca, sa = math.cos(ang), math.sin(ang)
        pt0 = v0 + (u * ca + w * sa) * r0
        pt1 = v1 + (u * ca + w * sa) * r1
        sec0.append((pt0.x, pt0.y, pt0.z))
        sec1.append((pt1.x, pt1.y, pt1.z))
    return add_lofted_sections(
        bm, [sec0, sec1], mat_idx=mat_idx, cap_start=cap0, cap_end=cap1,
        smooth=smooth, bevel_width=bevel, bevel_segs=1
    )


def add_ring_tube(bm, p0, p1, r_out0, r_in0, r_out1=None, r_in1=None, segs=36, mat_idx=0, smooth=True):
    """Create a thick-walled hollow tube/bezel between glTF points p0 and p1."""
    if r_out1 is None:
        r_out1 = r_out0
    if r_in1 is None:
        r_in1 = r_in0
    v0 = Vector(p0)
    v1 = Vector(p1)
    d = (v1 - v0).normalized()
    up = Vector((0, 1, 0)) if abs(d.y) < 0.9 else Vector((1, 0, 0))
    u = d.cross(up).normalized()
    w = d.cross(u).normalized()

    out0, out1, in0, in1 = [], [], [], []
    for i in range(segs):
        ang = (2.0 * math.pi * i) / segs
        radial = u * math.cos(ang) + w * math.sin(ang)
        p_o0 = v0 + radial * r_out0
        p_o1 = v1 + radial * r_out1
        p_i0 = v0 + radial * r_in0
        p_i1 = v1 + radial * r_in1
        out0.append(bm.verts.new(G(p_o0.x, p_o0.y, p_o0.z)))
        out1.append(bm.verts.new(G(p_o1.x, p_o1.y, p_o1.z)))
        in0.append(bm.verts.new(G(p_i0.x, p_i0.y, p_i0.z)))
        in1.append(bm.verts.new(G(p_i1.x, p_i1.y, p_i1.z)))

    faces = []
    for i in range(segs):
        inext = (i + 1) % segs
        f1 = bm.faces.new((out0[i], out0[inext], out1[inext], out1[i]))
        f1.smooth = smooth
        f2 = bm.faces.new((in1[i], in1[inext], in0[inext], in0[i]))
        f2.smooth = smooth
        f3 = bm.faces.new((in0[i], in0[inext], out0[inext], out0[i]))
        f3.smooth = False
        f4 = bm.faces.new((out1[i], out1[inext], in1[inext], in1[i]))
        f4.smooth = False
        for f in (f1, f2, f3, f4):
            f.material_index = mat_idx
            faces.append(f)
    bmesh.ops.recalc_face_normals(bm, faces=faces)
    return faces


def add_bolt_row(bm, p_start, p_end, normal_g, count, radius=0.028, height=0.018, mat_idx=2):
    """Add a line of hex bolts along p_start..p_end protruding along normal_g."""
    va = Vector(p_start)
    vb = Vector(p_end)
    vn = Vector(normal_g).normalized()
    for i in range(count):
        t = (i + 0.5) / max(count, 1) if count > 1 else 0.5
        p = va.lerp(vb, t)
        p0 = p - vn * 0.008
        p1 = p + vn * height
        add_cylinder_g(bm, p0, p1, radius, radius * 0.86, segs=6, mat_idx=mat_idx, cap0=False, cap1=True, smooth=False)


def uv_box_project(bm, scale=0.20):
    """Assign clean box-projected UVs in glTF space so baked tiling/trim sheets never stretch."""
    uv_layer = bm.loops.layers.uv.verify()
    for f in bm.faces:
        n = f.normal
        nx, ny, nz = abs(n.x), abs(n.z), abs(n.y)
        for loop in f.loops:
            co = loop.vert.co
            gx, gy, gz = co.x, co.z, -co.y
            if nx >= ny and nx >= nz:
                u, v = gz * scale, gy * scale
            elif ny >= nx and ny >= nz:
                u, v = gx * scale, gz * scale
            else:
                u, v = gx * scale, gy * scale
            loop[uv_layer].uv = (u, v)


def finalize_mesh_object(name, bm, materials, pivot_g=(0.0, 0.0, 0.0), parent=None, parent_pivot_g=(0.0, 0.0, 0.0)):
    """
    Convert a bmesh (authored in world glTF coordinates) into a Blender Mesh Object
    whose origin is at pivot_g (in glTF coordinates), without applying location on moving parts!
    """
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0004)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    uv_box_project(bm, scale=0.20)

    pivot_b = G(*pivot_g)
    if pivot_b.length > 1e-6:
        bmesh.ops.translate(bm, verts=bm.verts, vec=-pivot_b)

    mesh = bpy.data.meshes.new(name)
    bm.to_mesh(mesh)
    bm.free()

    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)

    for mat in materials:
        obj.data.materials.append(mat)

    if parent is not None:
        parent_b = G(*parent_pivot_g)
        obj.parent = parent
        obj.matrix_parent_inverse = Matrix.Identity(4)
        obj.location = pivot_b - parent_b
    else:
        obj.location = pivot_b

    bpy.context.view_layer.update()
    return obj


# ---------------------------------------------------------------------------
# Material Indices
# ---------------------------------------------------------------------------
MAT_PAINT = 0
MAT_LIGHT = 1
MAT_BARE = 2
MAT_SCORCH = 3
MAT_WEAPONS = 4
MAT_COCKPIT = 5
MAT_GLASS = 6
MAT_ENGINES = 7
MAT_LAMPS = 8

MAT_NAMES = [
    "Paint",
    "Armour_Light",
    "Metal_Bare",
    "Metal_Scorched",
    "Weapons",
    "Cockpit",
    "Glass",
    "Engines",
    "Lamps",
]


# ---------------------------------------------------------------------------
# Geometry Builders
# ---------------------------------------------------------------------------
def build_hull_bmesh():
    bm = bmesh.new()

    # 1. MAIN FUSELAGE MULTI-STATION LOFT (z = +13.45 down to -11.80)
    def make_hull_ring(z, w_bot, w_low, w_mid, w_shld, w_top, y_bot, y_low, y_mid, y_shld, y_top, y_deck_ctr):
        return [
            (0.0, y_bot, z),
            (w_bot, y_bot, z),
            (w_low, y_low, z),
            (w_mid, y_mid, z),
            (w_mid * 0.99, y_mid + (y_shld - y_mid) * 0.58, z),
            (w_shld, y_shld, z),
            (w_top, y_top, z),
            (w_top * 0.52, y_deck_ctr, z),
            (0.0, y_deck_ctr + 0.03, z),
            (-w_top * 0.52, y_deck_ctr, z),
            (-w_top, y_top, z),
            (-w_shld, y_shld, z),
            (-w_mid * 0.99, y_mid + (y_shld - y_mid) * 0.58, z),
            (-w_mid, y_mid, z),
            (-w_low, y_low, z),
            (-w_bot, y_bot, z),
        ]

    hull_stations = [
        # (z,     w_bot, w_low, w_mid, w_shld, w_top, y_bot, y_low, y_mid, y_shld, y_top, y_deck_ctr)
        (13.45,   0.48,  0.82,  0.96,  0.82,   0.56, -1.18, -1.06, -0.68, -0.38, -0.22, -0.20),
        (13.05,   0.78,  1.36,  1.56,  1.32,   0.88, -1.42, -1.25, -0.64, -0.20, -0.08, -0.06),
        (12.35,   1.02,  1.72,  1.98,  1.58,   1.02, -1.62, -1.42, -0.60, -0.06, -0.04, -0.65),
        (11.10,   1.22,  2.02,  2.32,  1.82,   1.22, -1.72, -1.48, -0.48,  0.18,  0.18, -0.65),
        (9.70,    1.38,  2.22,  2.58,  2.02,   1.32, -1.80, -1.52, -0.35,  0.48,  0.46, -0.42),
        (8.40,    1.50,  2.38,  2.76,  2.18,   1.30, -1.86, -1.56, -0.22,  0.80,  0.76, -0.15),
        (7.42,    1.62,  2.52,  2.92,  2.32,   1.26, -1.90, -1.60, -0.12,  1.12,  1.05,  0.85),
        # Step up behind canopy into raised dorsal spine & brow intake fairing
        (7.10,    1.72,  2.72,  3.12,  2.55,   1.55, -1.94, -1.64, -0.08,  1.62,  2.52,  2.65),
        (5.90,    1.95,  3.15,  3.52,  2.85,   1.68, -1.98, -1.68, -0.04,  1.74,  2.78,  2.92),
        (4.40,    2.18,  3.55,  3.82,  3.05,   1.72, -2.02, -1.72,  0.00,  1.80,  2.86,  3.00),
        (1.45,    2.22,  3.62,  3.85,  3.08,   1.70, -2.05, -1.74,  0.00,  1.78,  2.80,  2.92),
        # Recessed troop bay doorway pocket (z = +1.25 to -3.85)
        (1.22,    2.18,  3.48,  3.32,  2.96,   1.66, -2.05, -1.74,  0.00,  1.75,  2.76,  2.88),
        (-1.30,   2.18,  3.48,  3.32,  2.94,   1.62, -2.05, -1.74,  0.00,  1.72,  2.68,  2.78),
        (-3.82,   2.18,  3.48,  3.32,  2.92,   1.58, -2.05, -1.74,  0.00,  1.68,  2.60,  2.70),
        # Aft sponson & upswept rear ramp bay
        (-4.08,   2.15,  3.56,  3.80,  2.98,   1.56, -2.05, -1.74,  0.00,  1.66,  2.56,  2.66),
        (-4.62,   2.05,  3.45,  3.70,  2.90,   1.52, -2.02, -1.70,  0.04,  1.62,  2.50,  2.60),
        (-6.80,   1.85,  3.05,  3.38,  2.72,   1.44, -1.16, -0.88,  0.32,  1.56,  2.38,  2.46),
        (-9.20,   1.65,  2.60,  2.95,  2.45,   1.32, -0.22,  0.02,  0.68,  1.50,  2.24,  2.32),
        (-10.65,  1.45,  2.22,  2.52,  2.12,   1.18,  0.32,  0.48,  0.90,  1.45,  2.10,  2.16),
        (-11.85,  1.10,  1.72,  1.96,  1.68,   0.94,  0.52,  0.65,  1.05,  1.40,  1.95,  2.00),
    ]
    sections = [make_hull_ring(*st) for st in hull_stations]
    add_lofted_sections(bm, sections, mat_idx=MAT_PAINT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.035, bevel_segs=2)

    # 2. INTEGRATED NOSE PROW, SENSOR PODS & FLUSH TWO-TONE CHEEK ARMOUR
    # Contoured nose bumper & upper glacis panel following exact nose taper
    nose_c0 = [(-0.46, -1.12, 13.52), (0.46, -1.12, 13.52), (0.54, -0.32, 13.48), (-0.54, -0.32, 13.48)]
    nose_c1 = [(-0.78, -1.28, 13.02), (0.78, -1.28, 13.02), (0.86, -0.10, 12.95), (-0.86, -0.10, 12.95)]
    add_lofted_sections(bm, [nose_c0, nose_c1], mat_idx=MAT_LIGHT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.035, bevel_segs=2)
    add_bevel_box(bm, 0.0, -1.26, 13.12, 1.48, 0.24, 0.52, mat_idx=MAT_BARE, bevel=0.025, segs=2)

    for sx in (-1.0, 1.0):
        # Flush upper cheek armor panel flanking the cockpit sill (offset cleanly outside hull wall)
        add_plate_quad(
            bm,
            (sx * 1.62, -0.05, 12.20),
            (sx * 2.06,  0.46,  9.70),
            (sx * 1.36,  0.46,  9.70),
            (sx * 1.06, -0.04, 12.20),
            (sx * 0.45, 0.85, 0.15),
            thickness=0.04, mat_idx=MAT_LIGHT, bevel=0.015, segs=2
        )
        add_plate_quad(
            bm,
            (sx * 2.08,  0.48, 9.55),
            (sx * 2.34,  1.10, 7.50),
            (sx * 1.30,  1.05, 7.50),
            (sx * 1.36,  0.48, 9.55),
            (sx * 0.45, 0.85, 0.12),
            thickness=0.04, mat_idx=MAT_LIGHT, bevel=0.015, segs=2
        )
        # Recessed nose cheek sensor / light blister (flush with lower cheek chine at x=2.18)
        add_bevel_box(bm, sx * 2.16, -0.68, 11.15, 0.18, 0.20, 0.65, mat_idx=MAT_BARE, bevel=0.02, segs=2, rot_deg=(0, sx * -14, 0))
        add_bevel_box(bm, sx * 2.22, -0.68, 11.18, 0.10, 0.11, 0.46, mat_idx=MAT_LAMPS, bevel=0.008, segs=1, rot_deg=(0, sx * -14, 0))
        # Pitot static probes
        add_cylinder_g(bm, (sx * 1.72, -0.92, 12.10), (sx * 1.92, -0.92, 12.10), 0.022, 0.018, segs=10, mat_idx=MAT_BARE)
        add_cylinder_g(bm, (sx * 1.90, -0.92, 12.05), (sx * 1.90, -0.92, 12.60), 0.016, 0.010, segs=10, mat_idx=MAT_BARE)
        add_bolt_row(bm, (sx * 1.58, -0.02, 12.10), (sx * 2.30, 1.08, 7.60), (sx * 0.5, 0.8, 0.1), 14, mat_idx=MAT_BARE)

    # 3. ROUNDED VENTRAL CHIN POD / RADOME UNDER COCKPIT (z = +6.3 to +10.6, y down to -3.50)
    chin_cx, chin_cy, chin_cz = 0.0, -1.78, 8.45
    chin_rx, chin_ry, chin_rz = 1.62, 1.72, 2.22
    n_rings, n_segs = 16, 36
    chin_sections = []
    for ir in range(n_rings):
        phi = (math.pi * 0.52) * (ir / (n_rings - 1))
        sp = math.sin(phi)
        cp = math.cos(phi)
        ring = []
        for j in range(n_segs):
            th = (2.0 * math.pi * j) / n_segs
            x = chin_cx + chin_rx * sp * math.cos(th)
            z = chin_cz + chin_rz * sp * math.sin(th)
            y = chin_cy - chin_ry * cp
            ring.append((x, max(y, -3.50), z))
        chin_sections.append(ring)
    add_lofted_sections(bm, chin_sections, mat_idx=MAT_LIGHT, cap_start=True, cap_end=True, smooth=True)
    add_ring_tube(bm, (0.0, -1.72, 8.45), (0.0, -1.98, 8.45), 1.72, 1.48, 1.65, 1.44, segs=36, mat_idx=MAT_BARE, smooth=True)
    add_ring_tube(bm, (0.0, -2.55, 8.45), (0.0, -2.68, 8.45), 1.46, 1.32, 1.40, 1.28, segs=36, mat_idx=MAT_PAINT, smooth=True)
    for bi in range(24):
        bang = (2.0 * math.pi * bi) / 24.0
        add_cylinder_g(
            bm,
            (math.cos(bang) * 1.66, -1.94, 8.45 + math.sin(bang) * 2.15),
            (math.cos(bang) * 1.70, -1.98, 8.45 + math.sin(bang) * 2.20),
            0.026, 0.022, segs=6, mat_idx=MAT_BARE, smooth=False
        )

    # 4. COCKPIT TUB INTERIOR (Strictly inside the recessed tub y = -0.65..+0.85)
    add_bevel_box(bm, 0.0, -0.68, 9.85, 1.72, 0.16, 4.75, mat_idx=MAT_COCKPIT, bevel=0.02, segs=1)
    for sx in (-1.0, 1.0):
        add_bevel_box(bm, sx * 0.72, -0.38, 9.85, 0.28, 0.36, 4.40, mat_idx=MAT_COCKPIT, bevel=0.02, segs=1)
        add_bevel_box(bm, sx * 0.70, -0.19, 10.35, 0.16, 0.03, 1.30, mat_idx=MAT_LAMPS, bevel=0.005, segs=1)
        add_bevel_box(bm, sx * 0.70,  0.05, 8.55,  0.16, 0.03, 1.20, mat_idx=MAT_LAMPS, bevel=0.005, segs=1)

    for (sz, sy) in [(10.35, -0.58), (8.55, -0.18)]:
        add_bevel_box(bm, 0.0, sy + 0.08, sz, 0.54, 0.12, 0.54, mat_idx=MAT_COCKPIT, bevel=0.02, segs=1)
        add_bevel_box(bm, 0.0, sy + 0.42, sz - 0.22, 0.52, 0.64, 0.12, mat_idx=MAT_COCKPIT, bevel=0.02, segs=1, rot_deg=(-12, 0, 0))
        add_bevel_box(bm, 0.0, sy + 0.78, sz - 0.30, 0.36, 0.20, 0.12, mat_idx=MAT_BARE, bevel=0.02, segs=1)
        add_bevel_box(bm, 0.0, sy + 0.28, sz + 0.76, 0.92, 0.34, 0.38, mat_idx=MAT_COCKPIT, bevel=0.03, segs=2, rot_deg=(12, 0, 0))
        for mx in (-0.26, 0.0, 0.26):
            add_bevel_box(bm, mx, sy + 0.34, sz + 0.56, 0.20, 0.15, 0.04, mat_idx=MAT_LAMPS, bevel=0.008, segs=1, rot_deg=(-18, 0, 0))
        add_cylinder_g(bm, (0.0, sy + 0.10, sz + 0.38), (0.0, sy + 0.38, sz + 0.42), 0.028, 0.022, segs=10, mat_idx=MAT_BARE)
        add_bevel_box(bm, 0.0, sy + 0.40, sz + 0.42, 0.06, 0.09, 0.07, mat_idx=MAT_WEAPONS, bevel=0.01, segs=1)
    add_bevel_box(bm, 0.0, 0.02, 11.10, 0.36, 0.22, 0.02, mat_idx=MAT_GLASS, bevel=0.004, segs=1, rot_deg=(-25, 0, 0))

    # 5. RAISED DORSAL SPINE, RECESSED BROW INTAKES & VISIBLE COOLING LOUVRE BANKS
    brow_sec0 = [(-1.35, 1.68, 7.35), (1.35, 1.68, 7.35), (1.12, 2.58, 7.20), (-1.12, 2.58, 7.20)]
    brow_sec1 = [(-1.62, 1.82, 5.60), (1.62, 1.82, 5.60), (1.38, 2.95, 5.60), (-1.38, 2.95, 5.60)]
    add_lofted_sections(bm, [brow_sec0, brow_sec1], mat_idx=MAT_LIGHT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.04, bevel_segs=2)

    add_bevel_box(bm, 0.0, 2.94, 3.20, 2.25, 0.18, 4.60, mat_idx=MAT_LIGHT, bevel=0.035, segs=2)
    add_bevel_box(bm, 0.0, 2.78, -2.20, 2.10, 0.18, 6.20, mat_idx=MAT_LIGHT, bevel=0.035, segs=2, rot_deg=(-1.8, 0, 0))
    add_bevel_box(bm, 0.0, 2.48, -7.40, 1.85, 0.18, 4.20, mat_idx=MAT_PAINT, bevel=0.035, segs=2, rot_deg=(-3.2, 0, 0))

    for sx in (-1.0, 1.0):
        # Recessed Brow Air Intake Hoods (outer frame in MAT_LIGHT protruding to z=7.42, dark throat recessed inside at z=7.28!)
        add_bevel_box(bm, sx * 0.62, 2.44, 7.34, 0.90, 0.09, 0.28, mat_idx=MAT_LIGHT, bevel=0.015, segs=2)
        add_bevel_box(bm, sx * 0.62, 1.94, 7.38, 0.90, 0.09, 0.28, mat_idx=MAT_LIGHT, bevel=0.015, segs=2)
        add_bevel_box(bm, sx * 1.03, 2.19, 7.36, 0.09, 0.56, 0.28, mat_idx=MAT_LIGHT, bevel=0.015, segs=2)
        add_bevel_box(bm, sx * 0.21, 2.19, 7.36, 0.09, 0.56, 0.28, mat_idx=MAT_LIGHT, bevel=0.015, segs=2)
        add_bevel_box(bm, sx * 0.62, 2.19, 7.24, 0.78, 0.42, 0.12, mat_idx=MAT_COCKPIT, bevel=0.01, segs=1)
        for iv in range(4):
            vy = 2.04 + iv * 0.095
            add_bevel_box(bm, sx * 0.62, vy, 7.35, 0.76, 0.022, 0.18, mat_idx=MAT_BARE, bevel=0.004, segs=1)

        # 6 Vertical Cooling Louvre Slots on upper hull shoulder (z = +0.5 to +2.7, x = ±2.32, y = +2.25)
        # Positioned right above the wing root so all 6 slots read clearly in side.png and final.png!
        add_bevel_box(bm, sx * 2.28, 2.24, 1.65, 0.32, 0.58, 2.55, mat_idx=MAT_LIGHT, bevel=0.03, segs=2, rot_deg=(0, 0, sx * -28))
        for slot_i in range(6):
            sz = 2.62 - slot_i * 0.38
            add_bevel_box(bm, sx * 2.38, 2.26, sz, 0.18, 0.40, 0.22, mat_idx=MAT_COCKPIT, bevel=0.01, segs=1, rot_deg=(0, 0, sx * -28))
            add_bevel_box(bm, sx * 2.43, 2.27, sz, 0.06, 0.38, 0.06, mat_idx=MAT_BARE, bevel=0.006, segs=1, rot_deg=(0, 15, sx * -28))

    # 6. INTEGRAL SCULPTED FORWARD SIDE CHEEK INTAKES & SPONSONS (z = +6.2 to -6.8)
    for sx in (-1.0, 1.0):
        # Flush angular armour panel on forward hull shoulder between cockpit & cheek intake (z = 5.2..7.1)
        add_plate_quad(
            bm,
            (sx * 2.90,  0.15, 6.95),
            (sx * 3.35,  0.20, 5.25),
            (sx * 2.75,  1.58, 5.25),
            (sx * 2.38,  1.42, 6.95),
            (sx * 0.88, 0.45, 0.12), thickness=0.04, mat_idx=MAT_PAINT, bevel=0.018, segs=2
        )
        add_plate_quad(
            bm,
            (sx * 2.72,  1.62, 6.85),
            (sx * 2.98,  1.72, 4.85),
            (sx * 1.72,  2.68, 4.85),
            (sx * 1.58,  2.48, 6.85),
            (sx * 0.65, 0.75, 0.10), thickness=0.04, mat_idx=MAT_LIGHT, bevel=0.018, segs=2
        )
        add_bolt_row(bm, (sx * 2.86, 0.25, 6.80), (sx * 2.42, 1.35, 6.80), (sx * 0.85, 0.45, 0.1), 8, mat_idx=MAT_BARE)

        # Outer sculpted cheek intake body (z = 5.95 at top, sloping back to z = 4.85 at bottom, back to z = 3.85)
        in_s0 = [(sx * 2.55, -1.55, 4.75), (sx * 3.78, -1.55, 4.65), (sx * 3.86, 0.95, 5.75), (sx * 2.55, 0.95, 5.88)]
        in_s1 = [(sx * 2.65, -1.65, 3.85), (sx * 3.88, -1.65, 3.85), (sx * 3.92, 0.95, 3.85), (sx * 2.65, 0.95, 3.85)]
        if sx < 0:
            in_s0, in_s1 = list(reversed(in_s0)), list(reversed(in_s1))
        add_lofted_sections(bm, [in_s0, in_s1], mat_idx=MAT_LIGHT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.04, bevel_segs=2)

        # Protruding 4-wall bevelled intake cowl lip around a recessed dark plenum throat (so vanes sit INSIDE the hood!)
        # Top, bottom, outer, and inner lip walls protruding +0.28m forward from in_s0
        add_plate_quad(
            bm,
            (sx * 2.58,  0.88, 5.82), (sx * 3.84,  0.88, 5.72),
            (sx * 3.84,  0.88, 6.02), (sx * 2.58,  0.88, 6.12),
            (0.0, 1.0, 0.0), thickness=0.12, mat_idx=MAT_LIGHT, bevel=0.02, segs=2
        )
        add_plate_quad(
            bm,
            (sx * 2.58, -1.50, 4.72), (sx * 3.76, -1.50, 4.62),
            (sx * 3.76, -1.50, 4.92), (sx * 2.58, -1.50, 5.02),
            (0.0, -1.0, 0.0), thickness=0.12, mat_idx=MAT_LIGHT, bevel=0.02, segs=2
        )
        add_plate_quad(
            bm,
            (sx * 3.76, -1.52, 4.62), (sx * 3.84,  0.90, 5.72),
            (sx * 3.84,  0.90, 6.02), (sx * 3.76, -1.52, 4.92),
            (sx, 0.0, 0.0), thickness=0.12, mat_idx=MAT_LIGHT, bevel=0.02, segs=2
        )
        # Dark recessed backplate inside the cowl lip
        add_plate_quad(
            bm,
            (sx * 2.68, -1.42, 4.76), (sx * 3.68, -1.42, 4.68),
            (sx * 3.74,  0.80, 5.74), (sx * 2.68,  0.80, 5.84),
            (0.0, -0.40, 0.92), thickness=0.06, mat_idx=MAT_COCKPIT, bevel=0.01, segs=1
        )
        # 8 Horizontal metallic intake splitter vanes recessed cleanly INSIDE the lip hood
        for iv in range(8):
            t = (iv + 0.5) / 8.0
            vy = -1.32 + t * 2.04
            vz = 4.76 + t * 1.02
            add_bevel_box(bm, sx * 3.22, vy, vz, 0.92, 0.028, 0.16, mat_idx=MAT_BARE, bevel=0.005, segs=1, rot_deg=(0, sx * -5, 0))

        # Sponson forward door-gun housing (z = +1.35 to +3.85) & aft door pillar (z = -3.85 to -6.8)
        add_bevel_box(bm, sx * 3.60, -0.38, 2.55, 0.62, 2.48, 2.55, mat_idx=MAT_PAINT, bevel=0.04, segs=2)
        add_plate_quad(
            bm,
            (sx * 3.92, -1.45, 3.65),
            (sx * 3.92, -1.45, 1.45),
            (sx * 3.92,  0.65, 1.45),
            (sx * 3.92,  0.65, 3.65),
            (sx, 0.0, 0.0), thickness=0.04, mat_idx=MAT_LIGHT, bevel=0.015, segs=2
        )
        add_bevel_box(bm, sx * 3.58, -0.38, -4.45, 0.62, 2.42, 1.45, mat_idx=MAT_PAINT, bevel=0.04, segs=2)
        aft_p0 = (sx * 3.46, -1.25, -5.15)
        aft_p1 = (sx * 2.86, -0.15, -8.85)
        aft_p2 = (sx * 2.66,  1.15, -8.85)
        aft_p3 = (sx * 3.16,  1.15, -5.15)
        add_plate_quad(bm, aft_p0, aft_p1, aft_p2, aft_p3, (sx, -0.1, 0.0), thickness=0.05, mat_idx=MAT_LIGHT, bevel=0.02, segs=2)
        add_bolt_row(bm, (sx * 3.44, -1.10, -5.30), (sx * 2.86, -0.05, -8.70), (sx, 0.0, 0.0), 12, mat_idx=MAT_BARE)

        # Lower & upper sliding door rail tracks
        add_bevel_box(bm, sx * 3.66, -1.82, -1.55, 0.46, 0.32, 6.10, mat_idx=MAT_BARE, bevel=0.03, segs=2)
        add_bevel_box(bm, sx * 3.74, -1.82, -1.55, 0.16, 0.12, 5.85, mat_idx=MAT_COCKPIT, bevel=0.01, segs=1)
        add_bevel_box(bm, sx * 3.54,  0.92, -1.55, 0.36, 0.20, 5.95, mat_idx=MAT_BARE, bevel=0.02, segs=2)

        # Interior troop bay bulkhead & floor behind sliding door pocket
        add_bevel_box(bm, sx * 3.08, -0.35, -1.25, 0.18, 2.45, 5.10, mat_idx=MAT_COCKPIT, bevel=0.02, segs=1)
        add_bevel_box(bm, sx * 3.20,  0.72, -1.25, 0.10, 0.08, 4.40, mat_idx=MAT_LAMPS, bevel=0.01, segs=1)

        # 7. SIDE DOOR GUN SPONSON MOUNT & ROTARY DOOR GUN (z = +1.75 to +4.35)
        add_bevel_box(bm, sx * 3.68, -0.15, 2.38, 0.48, 0.92, 1.28, mat_idx=MAT_COCKPIT, bevel=0.025, segs=1)
        add_bevel_box(bm, sx * 3.84, -0.15, 2.08, 0.28, 0.80, 0.68, mat_idx=MAT_BARE, bevel=0.03, segs=2)
        add_cylinder_g(bm, (sx * 3.80, -0.52, 2.38), (sx * 3.80, 0.20, 2.38), 0.12, 0.12, segs=16, mat_idx=MAT_BARE)
        add_bevel_box(bm, sx * 3.88, -0.15, 2.55, 0.32, 0.34, 0.72, mat_idx=MAT_WEAPONS, bevel=0.025, segs=2)
        add_cylinder_g(bm, (sx * 3.92, -0.15, 2.85), (sx * 3.94, -0.15, 3.62), 0.15, 0.14, segs=18, mat_idx=MAT_WEAPONS)
        for bi in range(6):
            bang = (2.0 * math.pi * bi) / 6.0
            bx = sx * 3.94 + math.cos(bang) * 0.082
            by = -0.15 + math.sin(bang) * 0.082
            add_cylinder_g(bm, (bx, by, 2.90), (bx + sx * 0.01, by, 4.35), 0.025, 0.023, segs=12, mat_idx=MAT_WEAPONS)
        for clamp_z in (3.35, 3.85, 4.22):
            add_cylinder_g(bm, (sx * 3.95, -0.15, clamp_z - 0.04), (sx * 3.95, -0.15, clamp_z + 0.04), 0.13, 0.13, segs=18, mat_idx=MAT_BARE)

    # 8. HIGH-MOUNTED STUB WINGS WITH TRUE OPEN VERTICAL LIFT-FAN TUNNELS
    for sx in (-1.0, 1.0):
        def make_wing_sec(x_abs, z_le, z_te, y_mid, thick):
            x = sx * x_abs
            L = z_le - z_te
            return [
                (x, y_mid, z_le),
                (x, y_mid + thick * 0.42, z_le - L * 0.12),
                (x, y_mid + thick * 0.54, z_le - L * 0.38),
                (x, y_mid + thick * 0.36, z_le - L * 0.75),
                (x, y_mid + thick * 0.09, z_te),
                (x, y_mid - thick * 0.09, z_te),
                (x, y_mid - thick * 0.32, z_le - L * 0.72),
                (x, y_mid - thick * 0.44, z_le - L * 0.35),
                (x, y_mid - thick * 0.32, z_le - L * 0.10),
            ]

        def outer_wing_top_y(x_abs, z_val):
            """Exact upper surface Y of out_wing at (x_abs, z_val) so armor plates sit uniformly +0.035m proud!"""
            t_x = max(0.0, min(1.0, (x_abs - 6.85) / (10.55 - 6.85)))
            z_le = 3.08 * (1.0 - t_x) + 0.55 * t_x
            z_te = -2.04 * (1.0 - t_x) + (-1.42) * t_x
            y_mid = 1.46 * (1.0 - t_x) + 1.38 * t_x
            thick = 0.75 * (1.0 - t_x) + 0.34 * t_x
            c = max(0.0, min(1.0, (z_le - z_val) / max(0.1, z_le - z_te)))
            if c <= 0.38:
                k = (c / 0.38) * 0.54
            elif c <= 0.75:
                u = (c - 0.38) / (0.75 - 0.38)
                k = 0.54 * (1.0 - u) + 0.36 * u
            else:
                u = (c - 0.75) / (1.0 - 0.75)
                k = 0.36 * (1.0 - u) + 0.09 * u
            return y_mid + thick * k

        in_wing = [
            make_wing_sec(2.75, 4.65, -2.55, 1.54, 0.88),
            make_wing_sec(3.85, 4.35, -2.40, 1.51, 0.85),
        ]
        fwd_spar = [
            make_wing_sec(3.80, 4.35,  2.15, 1.51, 0.82),
            make_wing_sec(5.35, 3.72,  2.25, 1.48, 0.78),
            make_wing_sec(6.95, 3.05,  1.85, 1.46, 0.74),
        ]
        aft_spar = [
            make_wing_sec(3.80, -0.85, -2.40, 1.51, 0.78),
            make_wing_sec(5.35, -0.95, -2.22, 1.48, 0.74),
            make_wing_sec(6.95, -0.65, -2.02, 1.46, 0.70),
        ]
        out_wing = [
            make_wing_sec(6.85, 3.08, -2.04, 1.46, 0.75),
            make_wing_sec(8.65, 1.85, -1.72, 1.42, 0.52),
            make_wing_sec(10.55, 0.55, -1.42, 1.38, 0.34),
            make_wing_sec(10.96, 0.20, -1.32, 1.38, 0.24),
        ]
        for wpart in (in_wing, fwd_spar, aft_spar, out_wing):
            secs = list(reversed(wpart)) if sx < 0 else wpart
            add_lofted_sections(bm, secs, mat_idx=MAT_PAINT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.028, bevel_segs=2)

        # Outer Wing Two-Tone Angular Armour Panels (exact +0.032m proud of local wing surface -> ZERO z-fighting!)
        p1_pts = [(7.25, 1.85), (9.25, 0.75), (9.25, -0.50), (7.25, -0.25)]
        add_plate_quad(
            bm,
            *[(sx * xa, outer_wing_top_y(xa, zv) + 0.032, zv) for (xa, zv) in p1_pts],
            (0.0, 1.0, 0.0), thickness=0.036, mat_idx=MAT_LIGHT, bevel=0.012, segs=2
        )
        p2_pts = [(7.15, -0.75), (10.35, -0.82), (10.35, -1.36), (7.15, -1.92)]
        add_plate_quad(
            bm,
            *[(sx * xa, outer_wing_top_y(xa, zv) + 0.032, zv) for (xa, zv) in p2_pts],
            (0.0, 1.0, 0.0), thickness=0.036, mat_idx=MAT_LIGHT, bevel=0.012, segs=2
        )
        # Leading-edge titanium erosion strip along outer wing
        p3_pts = [(6.95, 2.95), (10.50, 0.55), (10.50, 0.25), (6.95, 2.55)]
        add_plate_quad(
            bm,
            *[(sx * xa, outer_wing_top_y(xa, zv) + 0.028, zv) for (xa, zv) in p3_pts],
            (0.0, 0.85, 0.52), thickness=0.032, mat_idx=MAT_BARE, bevel=0.010, segs=2
        )

        # Wingtip fairing pod at exact x = ±11.00 + Navigation lights
        add_bevel_box(bm, sx * 10.82, 1.38, -0.56, 0.36, 0.28, 1.85, mat_idx=MAT_BARE, bevel=0.035, segs=2)
        add_bevel_box(bm, sx * 10.95, 1.38, -0.35, 0.10, 0.14, 0.62, mat_idx=MAT_LAMPS, bevel=0.012, segs=1)
        add_bevel_box(bm, sx * 10.97, 1.38, -0.35, 0.06, 0.16, 0.66, mat_idx=MAT_GLASS, bevel=0.008, segs=1)

        # Integral Sleek Wing-Root Leading Edge Slatted Intake (with recessed dark throat & 10 vanes inside lip!)
        wr_s0 = [(sx * 3.05, 1.24, 4.48), (sx * 4.75, 1.24, 4.02), (sx * 4.75, 1.82, 4.02), (sx * 3.05, 1.86, 4.48)]
        wr_s1 = [(sx * 3.05, 1.18, 3.55), (sx * 4.85, 1.18, 3.25), (sx * 4.85, 1.92, 3.25), (sx * 3.05, 1.95, 3.55)]
        if sx < 0:
            wr_s0, wr_s1 = list(reversed(wr_s0)), list(reversed(wr_s1))
        add_lofted_sections(bm, [wr_s0, wr_s1], mat_idx=MAT_LIGHT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.035, bevel_segs=2)

        # Protruding top & bottom lip bars on wing-root intake so the dark plenum & vanes are genuinely recessed!
        add_plate_quad(
            bm,
            (sx * 3.08, 1.82, 4.45), (sx * 4.72, 1.78, 4.00),
            (sx * 4.72, 1.78, 4.24), (sx * 3.08, 1.82, 4.68),
            (0.0, 1.0, 0.0), thickness=0.08, mat_idx=MAT_LIGHT, bevel=0.015, segs=2
        )
        add_plate_quad(
            bm,
            (sx * 3.08, 1.28, 4.45), (sx * 4.72, 1.28, 4.00),
            (sx * 4.72, 1.28, 4.24), (sx * 3.08, 1.28, 4.68),
            (0.0, -1.0, 0.0), thickness=0.08, mat_idx=MAT_LIGHT, bevel=0.015, segs=2
        )
        add_plate_quad(
            bm,
            (sx * 3.16, 1.32, 4.46), (sx * 4.64, 1.32, 4.04),
            (sx * 4.64, 1.74, 4.04), (sx * 3.16, 1.76, 4.46),
            (sx * 0.25, 0.0, 0.96), thickness=0.05, mat_idx=MAT_COCKPIT, bevel=0.01, segs=1
        )
        for iv in range(10):
            t = (iv + 0.5) / 10.0
            vx = sx * (3.24 + t * 1.32)
            vz = 4.48 - t * 0.38
            add_bevel_box(bm, vx, 1.54, vz, 0.024, 0.38, 0.16, mat_idx=MAT_BARE, bevel=0.004, segs=1, rot_deg=(0, sx * 15, 0))

        # BURIED LIFT FAN DUCT & TOP/BOTTOM GRILLES (TRUE OPEN VERTICAL TUNNEL at x = sx * 5.35, z = +0.65)
        fx, fz = sx * 5.35, 0.65
        add_ring_tube(bm, (fx, 1.06, fz), (fx, 1.88, fz), 2.38, 1.80, 2.38, 1.80, segs=64, mat_idx=MAT_PAINT, smooth=True)
        add_ring_tube(bm, (fx, 1.05, fz), (fx, 1.90, fz), 1.81, 1.75, 1.81, 1.75, segs=64, mat_idx=MAT_COCKPIT, smooth=True)
        add_ring_tube(bm, (fx, 1.84, fz), (fx, 1.95, fz), 2.14, 1.75, 2.02, 1.75, segs=64, mat_idx=MAT_LIGHT, smooth=True)
        add_ring_tube(bm, (fx, 1.92, fz), (fx, 1.97, fz), 1.92, 1.74, 1.88, 1.74, segs=64, mat_idx=MAT_BARE, smooth=True)
        add_ring_tube(bm, (fx, 1.01, fz), (fx, 1.12, fz), 2.02, 1.75, 2.14, 1.75, segs=64, mat_idx=MAT_LIGHT, smooth=True)
        add_ring_tube(bm, (fx, 0.98, fz), (fx, 1.04, fz), 1.88, 1.74, 1.92, 1.74, segs=64, mat_idx=MAT_BARE, smooth=True)

        for bi in range(36):
            bang = (2.0 * math.pi * bi) / 36.0
            bx = fx + math.cos(bang) * 1.96
            bz = fz + math.sin(bang) * 1.96
            add_cylinder_g(bm, (bx, 1.93, bz), (bx, 1.98, bz), 0.028, 0.024, segs=8, mat_idx=MAT_BARE, smooth=False)
            add_cylinder_g(bm, (bx, 1.02, bz), (bx, 0.97, bz), 0.028, 0.024, segs=8, mat_idx=MAT_BARE, smooth=False)

        for gy in (1.94, 1.02):
            for si in range(8):
                sang = (2.0 * math.pi * si) / 8.0
                p_in = (fx + math.cos(sang) * 0.44, gy, fz + math.sin(sang) * 0.44)
                p_out = (fx + math.cos(sang) * 1.75, gy, fz + math.sin(sang) * 1.75)
                add_cylinder_g(bm, p_in, p_out, 0.024, 0.024, segs=10, mat_idx=MAT_BARE, cap0=False, cap1=False)
            for gr in (0.78, 1.28, 1.66):
                add_ring_tube(bm, (fx, gy - 0.015, fz), (fx, gy + 0.015, fz), gr + 0.018, gr - 0.018, segs=48, mat_idx=MAT_BARE)

        # Under-wing Ordnance Pylons (Hardpoint_1..4) with sway braces & launch rails
        for (px, pz) in [(sx * 5.20, 0.40), (sx * 7.80, -0.20)]:
            add_bevel_box(bm, px, 0.90, pz, 0.22, 0.48, 1.65, mat_idx=MAT_WEAPONS, bevel=0.025, segs=2)
            add_bevel_box(bm, px, 0.65, pz, 0.30, 0.14, 1.95, mat_idx=MAT_BARE, bevel=0.02, segs=2)
            for sz_off in (-0.55, 0.55):
                add_bevel_box(bm, px, 0.54, pz + sz_off, 0.36, 0.10, 0.18, mat_idx=MAT_WEAPONS, bevel=0.015, segs=2)

    # 9. TWIN REAR MAIN THRUSTER NACELLES, NOZZLES & TAIL FINS (z = -4.0 to -14.0)
    for sx in (-1.0, 1.0):
        nx, ny = sx * 2.65, 1.25
        add_cylinder_g(bm, (nx, ny, -3.95), (nx, ny, -4.60), 1.15, 1.36, segs=48, mat_idx=MAT_PAINT, smooth=True)
        add_cylinder_g(bm, (nx, ny, -4.60), (nx, ny, -11.95), 1.36, 1.24, segs=48, mat_idx=MAT_PAINT, smooth=True)
        # Sleek flush two-tone nacelle armour bands (only 1.5cm proud of nacelle skin)
        add_ring_tube(bm, (nx, ny, -4.65), (nx, ny, -6.35), 1.375, 1.30, 1.355, 1.28, segs=48, mat_idx=MAT_LIGHT, smooth=True)
        add_ring_tube(bm, (nx, ny, -8.40), (nx, ny, -11.40), 1.325, 1.22, 1.275, 1.18, segs=48, mat_idx=MAT_LIGHT, smooth=True)
        add_ring_tube(bm, (nx, ny, -11.40), (nx, ny, -12.08), 1.285, 1.16, 1.245, 1.12, segs=48, mat_idx=MAT_BARE, smooth=True)
        for rz in (-6.40, -8.35):
            add_ring_tube(bm, (nx, ny, rz - 0.05), (nx, ny, rz + 0.05), 1.36, 1.28, 1.36, 1.28, segs=48, mat_idx=MAT_BARE, smooth=True)

        for pang_deg in (15, -25):
            pang = math.radians(pang_deg)
            px = nx + sx * math.cos(pang) * 1.34
            py = ny + math.sin(pang) * 1.34
            add_cylinder_g(bm, (px, py, -4.75), (px, py, -11.85), 0.032, 0.032, segs=12, mat_idx=MAT_BARE)

        # HEAT-SCORCHED VARIABLE THRUSTER NOZZLE (z = -12.00 to exact -14.00)
        add_ring_tube(bm, (nx, ny, -12.00), (nx, ny, -13.15), 1.22, 1.04, 1.14, 0.96, segs=48, mat_idx=MAT_SCORCH, smooth=True)
        add_ring_tube(bm, (nx, ny, -13.10), (nx, ny, -14.00), 1.14, 0.94, 0.98, 0.82, segs=48, mat_idx=MAT_SCORCH, smooth=True)
        for pi in range(28):
            pang = (2.0 * math.pi * pi) / 28.0
            ca, sa = math.cos(pang), math.sin(pang)
            p_a = (nx + ca * 1.24, ny + sa * 1.24, -11.95)
            p_b = (nx + ca * 1.15, ny + sa * 1.15, -13.15)
            p_c = (nx + ca * 1.00, ny + sa * 1.00, -13.98)
            add_cylinder_g(bm, p_a, p_b, 0.036, 0.030, segs=10, mat_idx=MAT_BARE)
            add_cylinder_g(bm, p_b, p_c, 0.042, 0.028, segs=10, mat_idx=MAT_SCORCH)

        # Glowing interior Engine core cone (material "Engines") + afterburner flameholder vanes
        add_cylinder_g(bm, (nx, ny, -12.15), (nx, ny, -13.75), 0.88, 0.78, segs=36, mat_idx=MAT_ENGINES, cap0=True, cap1=True, smooth=True)
        add_ring_tube(bm, (nx, ny, -13.45), (nx, ny, -13.85), 0.76, 0.52, 0.74, 0.48, segs=36, mat_idx=MAT_SCORCH, smooth=True)
        for fi in range(16):
            fang = (2.0 * math.pi * fi) / 16.0
            add_cylinder_g(
                bm,
                (nx + math.cos(fang) * 0.22, ny + math.sin(fang) * 0.22, -13.68),
                (nx + math.cos(fang) * 0.78, ny + math.sin(fang) * 0.78, -13.68),
                0.022, 0.022, segs=8, mat_idx=MAT_SCORCH
            )

        # CANTED UPPER VERTICAL TAIL FIN (reaches exact y = +3.50 top height bound!)
        fin_root = [
            (nx - sx * 0.12, 2.05, -7.80),
            (nx + sx * 0.14, 2.05, -7.80),
            (nx + sx * 0.12, 2.00, -11.95),
            (nx - sx * 0.12, 2.00, -11.95),
        ]
        fin_mid = [
            (nx + sx * 0.42, 2.85, -8.85),
            (nx + sx * 0.62, 2.82, -8.85),
            (nx + sx * 0.58, 2.82, -12.25),
            (nx + sx * 0.40, 2.85, -12.25),
        ]
        fin_tip = [
            (nx + sx * 0.82, 3.50, -9.65),
            (nx + sx * 0.96, 3.48, -9.65),
            (nx + sx * 0.94, 3.48, -12.45),
            (nx + sx * 0.80, 3.50, -12.45),
        ]
        if sx < 0:
            fin_root = list(reversed(fin_root))
            fin_mid = list(reversed(fin_mid))
            fin_tip = list(reversed(fin_tip))
        add_lofted_sections(bm, [fin_root, fin_mid, fin_tip], mat_idx=MAT_PAINT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.025, bevel_segs=2)
        add_bevel_box(bm, nx + sx * 0.55, 2.78, -11.65, 0.18, 1.25, 1.05, mat_idx=MAT_LIGHT, bevel=0.02, segs=2, rot_deg=(0, 0, sx * -26))

        # CANTED LOWER VENTRAL STABILIZER FIN
        vfin_root = [
            (nx + sx * 1.10, 1.32, -8.10),
            (nx + sx * 1.18, 1.08, -8.10),
            (nx + sx * 1.15, 1.08, -11.90),
            (nx + sx * 1.08, 1.32, -11.90),
        ]
        vfin_tip = [
            (nx + sx * 2.55, 0.18, -9.45),
            (nx + sx * 2.62, 0.02, -9.45),
            (nx + sx * 2.60, 0.02, -12.15),
            (nx + sx * 2.52, 0.18, -12.15),
        ]
        if sx > 0:
            vfin_root = list(reversed(vfin_root))
            vfin_tip = list(reversed(vfin_tip))
        add_lofted_sections(bm, [vfin_root, vfin_tip], mat_idx=MAT_LIGHT, cap_start=True, cap_end=True, smooth=False, bevel_width=0.025, bevel_segs=2)

    # 10. REAR RAMP PORTAL FRAME, HYDRAULIC STRUTS & WARNING LIGHTS
    add_bevel_box(bm, 0.0, 0.52, -10.55, 3.65, 0.36, 0.62, mat_idx=MAT_BARE, bevel=0.03, segs=2)
    for sx in (-1.0, 1.0):
        add_bevel_box(bm, sx * 1.82, -0.82, -7.55, 0.34, 0.48, 6.20, mat_idx=MAT_BARE, bevel=0.03, segs=2, rot_deg=(21.5, 0, 0))
        add_cylinder_g(bm, (sx * 1.48, 0.42, -9.65), (sx * 1.48, -0.35, -8.25), 0.09, 0.09, segs=16, mat_idx=MAT_BARE)
        add_cylinder_g(bm, (sx * 1.48, -0.30, -8.35), (sx * 1.48, -0.92, -7.20), 0.06, 0.06, segs=14, mat_idx=MAT_BARE)
        add_bevel_box(bm, sx * 0.95, 0.52, -10.88, 0.62, 0.12, 0.08, mat_idx=MAT_LAMPS, bevel=0.01, segs=1)

    # 11. DORSAL MAINTENANCE HATCHES, ECS EXHAUST LOUVRES, ANTENNAS & BELLY DETAIL
    for hz in (4.80, 3.20, 1.60, -0.40, -2.40, -4.40, -6.40, -8.20):
        hy = 2.98 if hz > 0 else 2.82 + hz * 0.045
        add_bevel_box(bm, 0.0, hy, hz, 1.35, 0.08, 1.15, mat_idx=MAT_LIGHT if int(abs(hz)) % 2 == 0 else MAT_PAINT, bevel=0.02, segs=2)
        add_bolt_row(bm, (-0.58, hy + 0.04, hz - 0.45), (-0.58, hy + 0.04, hz + 0.45), (0, 1, 0), 6, mat_idx=MAT_BARE)
        add_bolt_row(bm, (0.58, hy + 0.04, hz - 0.45), (0.58, hy + 0.04, hz + 0.45), (0, 1, 0), 6, mat_idx=MAT_BARE)

    add_cylinder_g(bm, (0.0, 2.95, 2.50), (0.0, 3.08, 2.50), 0.18, 0.14, segs=18, mat_idx=MAT_BARE)
    add_cylinder_g(bm, (0.0, 3.08, 2.50), (0.0, 3.18, 2.50), 0.12, 0.08, segs=18, mat_idx=MAT_LAMPS)
    add_bevel_box(bm, -0.65, 2.98, 4.85, 0.06, 0.48, 0.32, mat_idx=MAT_BARE, bevel=0.01, segs=2, rot_deg=(24, 0, 0))
    for (gx, gz, gw, gl) in [(0.0, 4.50, 1.45, 2.20), (-2.15, -1.20, 1.35, 2.65), (2.15, -1.20, 1.35, 2.65)]:
        add_bevel_box(bm, gx, -2.06, gz, gw, 0.06, gl, mat_idx=MAT_BARE, bevel=0.02, segs=2)
        add_bevel_box(bm, gx, -2.08, gz, gw * 0.88, 0.05, gl * 0.90, mat_idx=MAT_LIGHT, bevel=0.015, segs=2)
        add_bolt_row(bm, (gx - gw * 0.42, -2.10, gz - gl * 0.42), (gx - gw * 0.42, -2.10, gz + gl * 0.42), (0, -1, 0), 10, mat_idx=MAT_BARE)
        add_bolt_row(bm, (gx + gw * 0.42, -2.10, gz - gl * 0.42), (gx + gw * 0.42, -2.10, gz + gl * 0.42), (0, -1, 0), 10, mat_idx=MAT_BARE)

    return bm


def build_canopy_bmesh():
    """
    Build the armored multi-pane cockpit canopy node (Canopy).
    Origin on its top-rear hinge at (0.0, 1.72, 7.45), rotates about X [1, 0, 0].
    Starts flush at the nose deck (z = 12.65) with a closed sloped front windscreen!
    """
    bm = bmesh.new()

    canopy_stations = [
        # (z, w_sill, w_top, y_sill, y_top)
        (12.68, 0.82, 0.50, -0.10, -0.06),
        (11.95, 1.04, 0.64,  0.02,  0.48),
        (10.85, 1.22, 0.76,  0.22,  0.96),
        (9.65,  1.30, 0.82,  0.48,  1.38),
        (8.45,  1.28, 0.84,  0.76,  1.62),
        (7.45,  1.24, 0.84,  1.02,  1.74),
    ]

    for i in range(len(canopy_stations)):
        z, ws, wt, ys, yt = canopy_stations[i]
        if i > 0:
            add_cylinder_g(bm, (-ws, ys, z), (-wt, yt, z), 0.056, 0.056, segs=12, mat_idx=MAT_LIGHT, bevel=0.006)
            add_cylinder_g(bm, (-wt, yt, z), (wt, yt, z), 0.060, 0.060, segs=12, mat_idx=MAT_LIGHT, bevel=0.006)
            add_cylinder_g(bm, (wt, yt, z), (ws, ys, z), 0.056, 0.056, segs=12, mat_idx=MAT_LIGHT, bevel=0.006)
        else:
            # Front lower windshield sill bar at z = 12.68
            add_cylinder_g(bm, (-ws, ys, z), (ws, ys, z), 0.065, 0.065, segs=12, mat_idx=MAT_LIGHT, bevel=0.008)

    for i in range(len(canopy_stations) - 1):
        z0, ws0, wt0, ys0, yt0 = canopy_stations[i]
        z1, ws1, wt1, ys1, yt1 = canopy_stations[i + 1]
        for sx in (-1.0, 1.0):
            add_cylinder_g(bm, (sx * ws0, ys0, z0), (sx * ws1, ys1, z1), 0.066, 0.066, segs=12, mat_idx=MAT_PAINT, bevel=0.008)
            add_cylinder_g(bm, (sx * wt0, yt0, z0), (sx * wt1, yt1, z1), 0.056, 0.056, segs=12, mat_idx=MAT_LIGHT, bevel=0.008)

            # Side glass pane
            side_sec0 = [(sx * (ws0 - 0.02), ys0 + 0.02, z0), (sx * (wt0 - 0.02), yt0 - 0.01, z0),
                         (sx * (wt0 - 0.05), yt0 - 0.01, z0), (sx * (ws0 - 0.05), ys0 + 0.02, z0)]
            side_sec1 = [(sx * (ws1 - 0.02), ys1 + 0.02, z1), (sx * (wt1 - 0.02), yt1 - 0.02, z1),
                         (sx * (wt1 - 0.05), yt1 - 0.02, z1), (sx * (ws1 - 0.05), ys1 + 0.02, z1)]
            add_lofted_sections(bm, [side_sec0, side_sec1], mat_idx=MAT_GLASS, cap_start=True, cap_end=True, smooth=False)

        # Center/top windscreen glass pane
        top_sec0 = [(-wt0 + 0.02, yt0 - 0.01, z0), (wt0 - 0.02, yt0 - 0.01, z0),
                    (wt0 - 0.02, yt0 - 0.04, z0), (-wt0 + 0.02, yt0 - 0.04, z0)]
        top_sec1 = [(-wt1 + 0.02, yt1 - 0.01, z1), (wt1 - 0.02, yt1 - 0.01, z1),
                    (wt1 - 0.02, yt1 - 0.04, z1), (-wt1 + 0.02, yt1 - 0.04, z1)]
        add_lofted_sections(bm, [top_sec0, top_sec1], mat_idx=MAT_GLASS, cap_start=True, cap_end=True, smooth=False)

    # Rear hinge knuckle assembly at (0.0, 1.72, 7.45)
    add_bevel_box(bm, 0.0, 1.70, 7.50, 1.55, 0.14, 0.26, mat_idx=MAT_BARE, bevel=0.025, segs=2)
    return bm


def build_turret_bmesh():
    """
    Build Chin Cannon Turret housing (Turret_1).
    Origin on its vertical traverse axis at (0.0, -2.05, 10.65), turns about Y [0, 1, 0].
    """
    bm = bmesh.new()
    tx, ty, tz = 0.0, -2.05, 10.65
    add_cylinder_g(bm, (tx, ty + 0.35, tz), (tx, ty - 0.12, tz), 0.72, 0.66, segs=32, mat_idx=MAT_BARE, smooth=True)
    add_bevel_box(bm, tx, ty - 0.20, tz + 0.18, 1.15, 0.56, 1.35, mat_idx=MAT_WEAPONS, bevel=0.05, segs=2)
    for sx in (-1.0, 1.0):
        add_bevel_box(bm, sx * 0.48, ty - 0.22, tz + 0.38, 0.28, 0.50, 1.15, mat_idx=MAT_LIGHT, bevel=0.04, segs=2)
        add_bolt_row(bm, (sx * 0.63, ty - 0.05, tz - 0.10), (sx * 0.63, ty - 0.05, tz + 0.85), (sx, 0, 0), 8, mat_idx=MAT_BARE)
    add_bevel_box(bm, -0.62, ty - 0.18, tz + 0.42, 0.32, 0.34, 0.58, mat_idx=MAT_BARE, bevel=0.03, segs=2)
    add_cylinder_g(bm, (-0.62, ty - 0.18, tz + 0.68), (-0.62, ty - 0.18, tz + 0.74), 0.11, 0.10, segs=18, mat_idx=MAT_GLASS)
    return bm


def build_gun_bmesh():
    """
    Build Chin Cannon barrels & cooling shroud (Gun_1, child of Turret_1).
    Origin on its elevation trunnion axis at (0.0, -2.25, 11.15), elevates about X [1, 0, 0].
    Reaches exact z = +14.00 (front bounding box limit!).
    """
    bm = bmesh.new()
    gx, gy, gz = 0.0, -2.25, 11.15
    add_cylinder_g(bm, (-0.42, gy, gz), (0.42, gy, gz), 0.18, 0.18, segs=24, mat_idx=MAT_BARE, smooth=True)
    add_bevel_box(bm, gx, gy, gz + 0.32, 0.52, 0.42, 0.95, mat_idx=MAT_WEAPONS, bevel=0.03, segs=2)
    add_ring_tube(bm, (gx, gy, 11.65), (gx, gy, 12.85), 0.22, 0.18, 0.20, 0.16, segs=28, mat_idx=MAT_WEAPONS, smooth=True)
    for bi in range(6):
        ang = (2.0 * math.pi * bi) / 6.0
        bx = gx + math.cos(ang) * 0.115
        by = gy + math.sin(ang) * 0.115
        add_cylinder_g(bm, (bx, by, 11.55), (bx, by, 13.92), 0.034, 0.030, segs=16, mat_idx=MAT_WEAPONS, smooth=True)
    for cz in (12.85, 13.38, 13.78):
        add_cylinder_g(bm, (gx, gy, cz - 0.045), (gx, gy, cz + 0.045), 0.175, 0.175, segs=24, mat_idx=MAT_BARE, smooth=True)
    add_ring_tube(bm, (gx, gy, 13.82), (gx, gy, 14.00), 0.18, 0.11, 0.16, 0.10, segs=24, mat_idx=MAT_SCORCH, smooth=True)
    return bm


def build_ramp_bmesh():
    """
    Build Rear Loading Ramp (Ramp, child of Hull).
    Origin on its lower hinge at (0.0, -2.02, -4.60), closed flush against the rear upsweep
    (+21.5 deg slope up-back to z = -10.50, y = +0.28), rotates about X [1, 0, 0].
    """
    bm = bmesh.new()
    # Notice rot_deg=(+21.5, 0, 0) slopes UP toward the rear (-Z) so the closed ramp seals the rear bay!
    add_bevel_box(bm, 0.0, -0.86, -7.55, 3.28, 0.22, 6.32, mat_idx=MAT_PAINT, bevel=0.04, segs=2, rot_deg=(21.5, 0, 0))
    add_bevel_box(bm, 0.0, -0.92, -7.55, 1.45, 0.10, 5.85, mat_idx=MAT_LIGHT, bevel=0.03, segs=2, rot_deg=(21.5, 0, 0))
    for rx in (-1.28, -0.58, 0.58, 1.28):
        add_bevel_box(bm, rx, -0.94, -7.50, 0.16, 0.12, 5.95, mat_idx=MAT_BARE, bevel=0.02, segs=2, rot_deg=(21.5, 0, 0))
    # Interior non-slip troop tread bars on top surface of ramp
    for ti in range(12):
        t = (ti + 0.5) / 12.0
        tz = -4.90 - t * 5.20
        ty = -1.78 + t * 2.04
        add_bevel_box(bm, 0.0, ty, tz, 2.95, 0.05, 0.16, mat_idx=MAT_COCKPIT, bevel=0.01, segs=1, rot_deg=(21.5, 0, 0))
    # Heavy hinge knuckles right on the origin axis (0.0, -2.02, -4.60)
    for hx in (-1.35, -0.45, 0.45, 1.35):
        add_cylinder_g(bm, (hx - 0.14, -2.02, -4.60), (hx + 0.14, -2.02, -4.60), 0.14, 0.14, segs=18, mat_idx=MAT_BARE)
    return bm


def build_door_bmesh(side_sign):
    """
    Build Sliding Side Door (Door_L for +1, Door_R for -1).
    Origin on its slide line at (side_sign * 3.66, -0.35, -1.20), closed, slides along Z [0, 0, -1].
    """
    bm = bmesh.new()
    sx = float(side_sign)
    dx = sx * 3.60
    add_bevel_box(bm, dx, -0.40, -1.30, 0.26, 2.42, 4.92, mat_idx=MAT_PAINT, bevel=0.04, segs=2)
    add_bevel_box(bm, dx + sx * 0.05, -0.15, -0.35, 0.20, 1.65, 2.05, mat_idx=MAT_LIGHT, bevel=0.03, segs=2)
    add_bevel_box(bm, dx + sx * 0.05, -0.55, -2.55, 0.20, 1.45, 1.95, mat_idx=MAT_LIGHT, bevel=0.03, segs=2)
    add_bevel_box(bm, dx + sx * 0.13, 0.35, -0.25, 0.08, 0.28, 0.65, mat_idx=MAT_BARE, bevel=0.015, segs=2)
    add_bevel_box(bm, dx + sx * 0.15, 0.35, -0.25, 0.06, 0.20, 0.55, mat_idx=MAT_GLASS, bevel=0.008, segs=1)
    for rz in (0.85, -1.30, -3.40):
        add_bevel_box(bm, dx + sx * 0.05, -1.72, rz, 0.24, 0.20, 0.42, mat_idx=MAT_BARE, bevel=0.02, segs=2)
        add_bevel_box(bm, dx + sx * 0.04, 0.86, rz, 0.22, 0.15, 0.36, mat_idx=MAT_BARE, bevel=0.02, segs=2)
    for gz in (0.92, -3.48):
        add_cylinder_g(bm, (dx + sx * 0.18, -0.95, gz), (dx + sx * 0.18, 0.15, gz), 0.028, 0.028, segs=12, mat_idx=MAT_BARE)
    add_bolt_row(bm, (dx + sx * 0.16, 0.62, 0.60), (dx + sx * 0.16, 0.62, -3.30), (sx, 0, 0), 14, mat_idx=MAT_BARE)
    return bm


def build_fan_bmesh(side_sign):
    """
    Build Ducted Lift Fan Rotor (Fan_L for +1, Fan_R for -1).
    Origin on its vertical shaft axis at (side_sign * 5.35, 1.45, 0.65), spins about Y [0, 1, 0].
    """
    bm = bmesh.new()
    sx = float(side_sign)
    fx, fy, fz = sx * 5.35, 1.45, 0.65
    add_cylinder_g(bm, (fx, fy - 0.24, fz), (fx, fy + 0.20, fz), 0.46, 0.44, segs=32, mat_idx=MAT_BARE, smooth=True)
    add_cylinder_g(bm, (fx, fy + 0.20, fz), (fx, fy + 0.38, fz), 0.44, 0.12, segs=28, mat_idx=MAT_LIGHT, smooth=True)
    add_cylinder_g(bm, (fx, fy - 0.24, fz), (fx, fy - 0.36, fz), 0.44, 0.18, segs=28, mat_idx=MAT_BARE, smooth=True)
    add_ring_tube(bm, (fx, fy - 0.15, fz), (fx, fy + 0.15, fz), 1.71, 1.64, 1.71, 1.64, segs=44, mat_idx=MAT_BARE, smooth=True)
    n_blades = 22
    for bi in range(n_blades):
        ang = (2.0 * math.pi * bi) / n_blades
        ca, sa = math.cos(ang), math.sin(ang)
        tx, tz = -sa, ca
        r_in, r_mid, r_out = 0.44, 1.05, 1.66
        w_in, w_mid, w_out = 0.20, 0.25, 0.22
        h_in, h_mid, h_out = 0.10, 0.08, 0.06
        th_deg = 0.028

        def blade_sec(r, w, h):
            cx, cz = fx + ca * r, fz + sa * r
            return [
                (cx + tx * w, fy + h + th_deg, cz + tz * w),
                (cx - tx * w, fy - h + th_deg, cz - tz * w),
                (cx - tx * w, fy - h - th_deg, cz - tz * w),
                (cx + tx * w, fy + h - th_deg, cz + tz * w),
            ]

        add_lofted_sections(
            bm, [blade_sec(r_in, w_in, h_in), blade_sec(r_mid, w_mid, h_mid), blade_sec(r_out, w_out, h_out)],
            mat_idx=MAT_BARE, cap_start=True, cap_end=True, smooth=False, bevel_width=0.007, bevel_segs=1
        )
    return bm


# ---------------------------------------------------------------------------
# Cycles PBR Material Baking (Poly Haven + Node Detail -> <=1024px, ORM <=512px)
# ---------------------------------------------------------------------------
def bake_materials_with_cycles(ph_paths):
    """
    Build procedural + Poly Haven shader setups on a temporary bake plane,
    bake BaseColor (1024x1024), Normal (1024x1024), and ORM (512x512) with Cycles,
    and return the 9 glTF-ready Blender Materials.
    """
    scene = configure_cycles_gpu(samples=16)
    scene.cycles.bake_type = 'EMIT'

    bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0.0, 0.0, -50.0))
    bake_obj = bpy.context.active_object
    bake_obj.name = "_TempBakePlane"

    ph_imgs = {}
    for ph_id, maps in ph_paths.items():
        ph_imgs[ph_id] = {}
        for mtype, fpath in maps.items():
            img = bpy.data.images.load(fpath, check_existing=True)
            img.colorspace_settings.name = 'sRGB' if mtype == 'diff' else 'Non-Color'
            ph_imgs[ph_id][mtype] = img

    if "glTF Material Output" not in bpy.data.node_groups:
        ng = bpy.data.node_groups.new("glTF Material Output", "ShaderNodeTree")
        ng.interface.new_socket(name="Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')

    # Note: Use smooth rolled steel (metal_plate_02) for hull armor so there is no diamond-plate artifact!
    # Reserve diamond tread plate (metal_plate) for Cockpit/ramp walkways.
    # (mat_name, ph_primary, ph_weath, base_rgb, grime_rgb, weath_amt, rough_bias, metal_val, nor_str, is_paint, is_scorch)
    specs = [
        ("Paint",          "metal_plate_02", "rusty_metal_03", (0.92, 0.93, 0.92), (0.72, 0.73, 0.72), 0.18, 0.48, 0.18, 0.25, True,  False),
        ("Armour_Light",   "metal_plate_02", "rusty_metal_03", (0.48, 0.52, 0.56), (0.34, 0.37, 0.40), 0.16, 0.44, 0.28, 0.25, False, False),
        ("Metal_Bare",     "metal_plate_02", "metal_plate",    (0.62, 0.65, 0.68), (0.38, 0.40, 0.42), 0.18, 0.30, 0.90, 0.35, False, False),
        ("Metal_Scorched", "metal_plate_02", "rusty_metal_03", (0.44, 0.35, 0.26), (0.22, 0.24, 0.35), 0.48, 0.36, 0.86, 0.40, False, True),
        ("Weapons",        "metal_plate_02", "rusty_metal_03", (0.12, 0.13, 0.15), (0.20, 0.21, 0.22), 0.15, 0.38, 0.82, 0.30, False, False),
        ("Cockpit",        "rubberized_track","metal_plate",   (0.10, 0.11, 0.12), (0.16, 0.17, 0.18), 0.15, 0.65, 0.15, 0.45, False, False),
    ]

    baked_sets = {}

    for (mname, ph1, ph2, base_rgb, grime_rgb, weath_amt, rough_bias, metal_val, nor_str, is_paint, is_scorch) in specs:
        mat_tmp = bpy.data.materials.new(name=f"_Bake_{mname}")
        mat_tmp.use_nodes = True
        nt = mat_tmp.node_tree
        nt.nodes.clear()

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

        out_node = nt.nodes.new("ShaderNodeOutputMaterial")
        emit_node = nt.nodes.new("ShaderNodeEmission")
        bsdf_node = nt.nodes.new("ShaderNodeBsdfPrincipled")

        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"])

        n_diff1 = nt.nodes.new("ShaderNodeTexImage")
        n_diff1.image = ph_imgs[ph1]["diff"]
        n_nor1 = nt.nodes.new("ShaderNodeTexImage")
        n_nor1.image = ph_imgs[ph1]["nor_gl"]
        n_arm1 = nt.nodes.new("ShaderNodeTexImage")
        n_arm1.image = ph_imgs[ph1]["arm"]

        n_diff2 = nt.nodes.new("ShaderNodeTexImage")
        n_diff2.image = ph_imgs[ph2]["diff"]

        for tn in (n_diff1, n_nor1, n_arm1, n_diff2):
            nt.links.new(mapping.outputs["Vector"], tn.inputs["Vector"])

        # Procedural aircraft panel grid & bevel seams
        brick = nt.nodes.new("ShaderNodeTexBrick")
        brick.offset = 0.5
        brick.inputs["Scale"].default_value = 3.0
        brick.inputs["Mortar Size"].default_value = 0.008
        brick.inputs["Mortar Smooth"].default_value = 0.25
        brick.inputs["Color1"].default_value = (0.98, 0.98, 0.98, 1.0)
        brick.inputs["Color2"].default_value = (0.92, 0.93, 0.94, 1.0)
        brick.inputs["Mortar"].default_value = (0.52, 0.53, 0.54, 1.0)
        nt.links.new(mapping.outputs["Vector"], brick.inputs["Vector"])

        noise = nt.nodes.new("ShaderNodeTexNoise")
        noise.inputs["Scale"].default_value = 8.0
        noise.inputs["Detail"].default_value = 8.0
        noise.inputs["Roughness"].default_value = 0.60
        nt.links.new(mapping.outputs["Vector"], noise.inputs["Vector"])

        # Multiply noise by weath_amt so weathering stays subtle and controlled!
        math_wfac = nt.nodes.new("ShaderNodeMath")
        math_wfac.operation = 'MULTIPLY'
        nt.links.new(noise.outputs["Fac"], math_wfac.inputs[0])
        math_wfac.inputs[1].default_value = weath_amt

        # Mix Poly Haven micro-detail into base_rgb
        mix_base = nt.nodes.new("ShaderNodeMix")
        mix_base.data_type = 'RGBA'
        mix_base.blend_type = 'MIX'
        mix_base.inputs["Factor"].default_value = 0.18 if is_paint else 0.25
        mix_base.inputs[6].default_value = (*base_rgb, 1.0)
        nt.links.new(n_diff1.outputs["Color"], mix_base.inputs[7])

        # Multiply panel lines
        mix_panels = nt.nodes.new("ShaderNodeMix")
        mix_panels.data_type = 'RGBA'
        mix_panels.blend_type = 'MULTIPLY'
        mix_panels.inputs["Factor"].default_value = 0.22
        nt.links.new(mix_base.outputs[2], mix_panels.inputs[6])
        nt.links.new(brick.outputs["Color"], mix_panels.inputs[7])

        # Controlled grime / rust / heat-scorch blend
        mix_weath = nt.nodes.new("ShaderNodeMix")
        mix_weath.data_type = 'RGBA'
        mix_weath.blend_type = 'MIX'
        nt.links.new(math_wfac.outputs["Value"], mix_weath.inputs["Factor"])
        nt.links.new(mix_panels.outputs[2], mix_weath.inputs[6])
        if is_scorch:
            scorch_ramp = nt.nodes.new("ShaderNodeValToRGB")
            elts = scorch_ramp.color_ramp.elements
            elts[0].position = 0.15
            elts[0].color = (0.55, 0.38, 0.20, 1.0)
            elts[1].position = 0.55
            elts[1].color = (0.30, 0.18, 0.38, 1.0)
            e2 = scorch_ramp.color_ramp.elements.new(0.85)
            e2.color = (0.14, 0.25, 0.42, 1.0)
            nt.links.new(noise.outputs["Fac"], scorch_ramp.inputs["Fac"])
            nt.links.new(scorch_ramp.outputs["Color"], mix_weath.inputs[7])
        else:
            # Blend subtle rusty_metal_03 with neutral grime_rgb (only 15% rust influence)
            mix_grime_rust = nt.nodes.new("ShaderNodeMix")
            mix_grime_rust.data_type = 'RGBA'
            mix_grime_rust.blend_type = 'MIX'
            mix_grime_rust.inputs["Factor"].default_value = 0.08 if is_paint else 0.15
            mix_grime_rust.inputs[6].default_value = (*grime_rgb, 1.0)
            nt.links.new(n_diff2.outputs["Color"], mix_grime_rust.inputs[7])
            nt.links.new(mix_grime_rust.outputs[2], mix_weath.inputs[7])

        # 1) BAKE BASE COLOR (1024x1024)
        nt.links.new(mix_weath.outputs[2], emit_node.inputs["Color"])
        nt.links.new(emit_node.outputs["Emission"], out_node.inputs["Surface"])

        img_bc = bpy.data.images.new(f"{mname}_basecolor", width=1024, height=1024, alpha=False)
        img_bc.colorspace_settings.name = 'sRGB'
        bake_target = nt.nodes.new("ShaderNodeTexImage")
        bake_target.image = img_bc
        for n in nt.nodes:
            n.select = False
        bake_target.select = True
        nt.nodes.active = bake_target

        bpy.context.view_layer.objects.active = bake_obj
        bake_obj.select_set(True)
        bpy.ops.object.bake(type='EMIT')
        bc_path = os.path.join(BAKED_DIR, f"{mname}_basecolor.jpg")
        img_bc.filepath_raw = bc_path
        img_bc.file_format = 'JPEG'
        img_bc.save()

        # 2) BAKE ORM (512x512: R=Occlusion, G=Roughness, B=Metallic)
        sep_arm = nt.nodes.new("ShaderNodeSeparateColor")
        nt.links.new(n_arm1.outputs["Color"], sep_arm.inputs["Color"])

        math_ao = nt.nodes.new("ShaderNodeMath")
        math_ao.operation = 'MULTIPLY'
        nt.links.new(sep_arm.outputs["Red"], math_ao.inputs[0])
        nt.links.new(brick.outputs["Fac"], math_ao.inputs[1])

        math_rough = nt.nodes.new("ShaderNodeMath")
        math_rough.operation = 'ADD'
        nt.links.new(sep_arm.outputs["Green"], math_rough.inputs[0])
        math_rough.inputs[1].default_value = rough_bias - 0.45
        math_rough.use_clamp = True

        comb_orm = nt.nodes.new("ShaderNodeCombineColor")
        nt.links.new(math_ao.outputs["Value"], comb_orm.inputs["Red"])
        nt.links.new(math_rough.outputs["Value"], comb_orm.inputs["Green"])
        comb_orm.inputs["Blue"].default_value = metal_val

        nt.links.new(comb_orm.outputs["Color"], emit_node.inputs["Color"])

        img_orm = bpy.data.images.new(f"{mname}_orm", width=512, height=512, alpha=False)
        img_orm.colorspace_settings.name = 'Non-Color'
        bake_target.image = img_orm
        nt.nodes.active = bake_target
        bpy.ops.object.bake(type='EMIT')
        orm_path = os.path.join(BAKED_DIR, f"{mname}_orm.jpg")
        img_orm.filepath_raw = orm_path
        img_orm.file_format = 'JPEG'
        img_orm.save()

        # 3) BAKE NORMAL MAP (1024x1024: Poly Haven nor_gl + panel Bump)
        nor_map_node = nt.nodes.new("ShaderNodeNormalMap")
        nor_map_node.inputs["Strength"].default_value = nor_str
        nt.links.new(n_nor1.outputs["Color"], nor_map_node.inputs["Color"])

        bump_node = nt.nodes.new("ShaderNodeBump")
        bump_node.inputs["Strength"].default_value = 0.25
        bump_node.inputs["Distance"].default_value = 0.015
        nt.links.new(brick.outputs["Fac"], bump_node.inputs["Height"])
        nt.links.new(nor_map_node.outputs["Normal"], bump_node.inputs["Normal"])

        nt.links.new(bump_node.outputs["Normal"], bsdf_node.inputs["Normal"])
        nt.links.new(bsdf_node.outputs["BSDF"], out_node.inputs["Surface"])

        img_nor = bpy.data.images.new(f"{mname}_normal", width=1024, height=1024, alpha=False)
        img_nor.colorspace_settings.name = 'Non-Color'
        bake_target.image = img_nor
        nt.nodes.active = bake_target
        bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT')
        nor_path = os.path.join(BAKED_DIR, f"{mname}_normal.png")
        img_nor.filepath_raw = nor_path
        img_nor.file_format = 'PNG'
        img_nor.save()

        baked_sets[mname] = (bc_path, orm_path, nor_path)
        bpy.data.materials.remove(mat_tmp)

    bpy.data.objects.remove(bake_obj, do_unlink=True)

    final_mats = []
    for (mname, _, _, _, _, _, _, _, _, is_paint, _) in specs:
        bc_path, orm_path, nor_path = baked_sets[mname]
        mat = bpy.data.materials.new(name=mname)
        mat.use_nodes = True
        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_bc = nt.nodes.new("ShaderNodeTexImage")
        tex_bc.image = bpy.data.images.load(bc_path, check_existing=True)
        tex_bc.image.colorspace_settings.name = 'sRGB'

        tex_orm = nt.nodes.new("ShaderNodeTexImage")
        tex_orm.image = bpy.data.images.load(orm_path, check_existing=True)
        tex_orm.image.colorspace_settings.name = 'Non-Color'

        tex_nor = nt.nodes.new("ShaderNodeTexImage")
        tex_nor.image = bpy.data.images.load(nor_path, check_existing=True)
        tex_nor.image.colorspace_settings.name = 'Non-Color'

        if is_paint:
            mul = nt.nodes.new("ShaderNodeMix")
            mul.name = "PaintFactorMix"
            mul.data_type = 'RGBA'
            mul.blend_type = 'MULTIPLY'
            mul.inputs["Factor"].default_value = 1.0
            mul.inputs[7].default_value = (0.24, 0.27, 0.20, 1.0)
            nt.links.new(tex_bc.outputs["Color"], mul.inputs[6])
            nt.links.new(mul.outputs[2], bsdf.inputs["Base Color"])
            mat.diffuse_color = (0.24, 0.27, 0.20, 1.0)
        else:
            nt.links.new(tex_bc.outputs["Color"], bsdf.inputs["Base 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"])

        gltf_out = nt.nodes.new("ShaderNodeGroup")
        gltf_out.node_tree = bpy.data.node_groups["glTF Material Output"]
        nt.links.new(sep.outputs["Red"], gltf_out.inputs["Occlusion"])

        nmap = nt.nodes.new("ShaderNodeNormalMap")
        nmap.inputs["Strength"].default_value = 0.65
        nt.links.new(tex_nor.outputs["Color"], nmap.inputs["Color"])
        nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])

        final_mats.append(mat)

    # 7. Glass Material (alpha BLEND, tinted blue-grey reflective cockpit glazing)
    mat_glass = bpy.data.materials.new(name="Glass")
    mat_glass.use_nodes = True
    if hasattr(mat_glass, "blend_method"):
        mat_glass.blend_method = 'BLEND'
    if hasattr(mat_glass, "surface_render_method"):
        mat_glass.surface_render_method = 'BLENDED'
    bsdf_g = mat_glass.node_tree.nodes.get("Principled BSDF")
    bsdf_g.inputs["Base Color"].default_value = (0.09, 0.16, 0.22, 1.0)
    bsdf_g.inputs["Metallic"].default_value = 0.30
    bsdf_g.inputs["Roughness"].default_value = 0.06
    bsdf_g.inputs["Alpha"].default_value = 0.55
    final_mats.append(mat_glass)

    # 8. Engines Material (emissive warm orange-amber nozzle interior)
    mat_eng = bpy.data.materials.new(name="Engines")
    mat_eng.use_nodes = True
    bsdf_e = mat_eng.node_tree.nodes.get("Principled BSDF")
    bsdf_e.inputs["Base Color"].default_value = (1.0, 0.38, 0.06, 1.0)
    bsdf_e.inputs["Roughness"].default_value = 0.30
    bsdf_e.inputs["Emission Color"].default_value = (1.0, 0.42, 0.08, 1.0)
    bsdf_e.inputs["Emission Strength"].default_value = 5.5
    final_mats.append(mat_eng)

    # 9. Lamps Material (emissive running lights, displays, warning beacons)
    mat_lamps = bpy.data.materials.new(name="Lamps")
    mat_lamps.use_nodes = True
    bsdf_l = mat_lamps.node_tree.nodes.get("Principled BSDF")
    bsdf_l.inputs["Base Color"].default_value = (0.85, 0.92, 1.0, 1.0)
    bsdf_l.inputs["Roughness"].default_value = 0.15
    bsdf_l.inputs["Emission Color"].default_value = (0.80, 0.90, 1.0, 1.0)
    bsdf_l.inputs["Emission Strength"].default_value = 3.5
    final_mats.append(mat_lamps)

    return final_mats


# ---------------------------------------------------------------------------
# Patch GLB Binary to Guarantee Paint baseColorFactor + baseColorTexture
# ---------------------------------------------------------------------------
def ensure_glb_paint_contract(glb_path):
    with open(glb_path, "rb") as f:
        data = f.read()
    magic, version, total_len = struct.unpack_from("<III", data, 0)
    if magic != 0x46546C67:
        return
    json_len, json_type = struct.unpack_from("<II", data, 12)
    json_bytes = data[20:20 + json_len]
    bin_rest = data[20 + json_len:]

    gltf = json.loads(json_bytes.decode("utf-8"))
    for mat in gltf.get("materials", []):
        if mat.get("name") == "Paint":
            pbr = mat.setdefault("pbrMetallicRoughness", {})
            pbr["baseColorFactor"] = [0.24, 0.27, 0.20, 1.0]
        elif mat.get("name") == "Glass":
            mat["alphaMode"] = "BLEND"

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

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


# ---------------------------------------------------------------------------
# Lighting, Backdrop & Rendering Helpers
# ---------------------------------------------------------------------------
def setup_space_lighting():
    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")
    tex_coord = 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.007, 0.014, 1.0)
    ramp.color_ramp.elements[1].position = 1.0
    ramp.color_ramp.elements[1].color = (0.016, 0.028, 0.052, 1.0)
    nt.links.new(tex_coord.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 = []
    key_data = bpy.data.lights.new("KeyLight", type='SUN')
    key_data.energy = 5.4
    key_data.color = (1.0, 0.98, 0.95)
    key_data.angle = math.radians(4.0)
    key_obj = bpy.data.objects.new("KeyLight", key_data)
    bpy.context.collection.objects.link(key_obj)
    key_obj.location = G(22.0, 24.0, 26.0)
    direction = (G(0.0, 0.0, 0.0) - key_obj.location).normalized()
    key_obj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
    lights.append(key_obj)

    fill_data = bpy.data.lights.new("FillLight", type='SUN')
    fill_data.energy = 2.1
    fill_data.color = (0.72, 0.84, 1.0)
    fill_data.angle = math.radians(15.0)
    fill_obj = bpy.data.objects.new("FillLight", fill_data)
    bpy.context.collection.objects.link(fill_obj)
    fill_obj.location = G(-25.0, 10.0, 18.0)
    fdir = (G(0.0, 0.0, 0.0) - fill_obj.location).normalized()
    fill_obj.rotation_euler = fdir.to_track_quat('-Z', 'Y').to_euler()
    lights.append(fill_obj)

    rim_data = bpy.data.lights.new("RimLight", type='SUN')
    rim_data.energy = 2.4
    rim_data.color = (0.88, 0.92, 1.0)
    rim_data.angle = math.radians(8.0)
    rim_obj = bpy.data.objects.new("RimLight", rim_data)
    bpy.context.collection.objects.link(rim_obj)
    rim_obj.location = G(-14.0, 18.0, -26.0)
    rdir = (G(0.0, 0.0, 0.0) - rim_obj.location).normalized()
    rim_obj.rotation_euler = rdir.to_track_quat('-Z', 'Y').to_euler()
    lights.append(rim_obj)

    belly_data = bpy.data.lights.new("BellyFill", type='SUN')
    belly_data.energy = 0.90
    belly_data.color = (0.68, 0.78, 0.95)
    belly_data.angle = math.radians(25.0)
    belly_obj = bpy.data.objects.new("BellyFill", belly_data)
    bpy.context.collection.objects.link(belly_obj)
    belly_obj.location = G(8.0, -18.0, 15.0)
    bdir = (G(0.0, 0.0, 0.0) - belly_obj.location).normalized()
    belly_obj.rotation_euler = bdir.to_track_quat('-Z', 'Y').to_euler()
    lights.append(belly_obj)

    return lights


def make_camera(name, loc_g, target_g, lens=50.0, ortho_scale=None):
    cam_data = bpy.data.cameras.new(name)
    if ortho_scale is not None:
        cam_data.type = 'ORTHO'
        cam_data.ortho_scale = ortho_scale
    else:
        cam_data.type = 'PERSP'
        cam_data.lens = lens
    cam_data.clip_start = 0.1
    cam_data.clip_end = 500.0
    cam_obj = bpy.data.objects.new(name, cam_data)
    bpy.context.collection.objects.link(cam_obj)
    cam_obj.location = G(*loc_g)
    direction = (G(*target_g) - cam_obj.location).normalized()
    cam_obj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
    return cam_obj


def render_image(cam_obj, filepath, res_x=1920, res_y=1080, samples=64):
    scene = bpy.context.scene
    scene.camera = cam_obj
    scene.render.resolution_x = res_x
    scene.render.resolution_y = res_y
    scene.render.resolution_percentage = 100
    scene.render.image_settings.file_format = 'PNG'
    scene.render.filepath = filepath
    scene.cycles.samples = samples
    bpy.ops.render.render(write_still=True)


def set_paint_factor(paint_mat, rgb):
    paint_mat.diffuse_color = (rgb[0], rgb[1], rgb[2], 1.0)
    mix = paint_mat.node_tree.nodes.get("PaintFactorMix")
    if mix is not None:
        mix.inputs[7].default_value = (rgb[0], rgb[1], rgb[2], 1.0)


def stitch_paints_grid(tile_paths, out_path, tile_w=640, tile_h=540):
    grid_w, grid_h = tile_w * 3, tile_h * 2
    out_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):
        col = idx % 3
        row = 1 - (idx // 3)
        timg = bpy.data.images.load(tpath, check_existing=False)
        tpix = list(timg.pixels)
        for y in range(tile_h):
            dst_y = row * tile_h + y
            src_row_start = y * tile_w * 4
            dst_row_start = (dst_y * grid_w + col * tile_w) * 4
            grid_pixels[dst_row_start:dst_row_start + tile_w * 4] = tpix[src_row_start:src_row_start + tile_w * 4]
        bpy.data.images.remove(timg)

    out_img.pixels[:] = grid_pixels
    out_img.filepath_raw = out_path
    out_img.file_format = 'PNG'
    out_img.save()
    bpy.data.images.remove(out_img)


# ---------------------------------------------------------------------------
# Main Build Pipeline
# ---------------------------------------------------------------------------
def main():
    reset_scene()
    ph_paths = ensure_polyhaven_textures()
    materials = bake_materials_with_cycles(ph_paths)

    # 1. Build Mesh Nodes (with exact parent_pivot_g so child pivots never double-offset!)
    hull_obj = finalize_mesh_object("Hull", build_hull_bmesh(), materials, pivot_g=(0.0, 0.0, 0.0), parent=None)
    canopy_obj = finalize_mesh_object("Canopy", build_canopy_bmesh(), materials, pivot_g=(0.0, 1.72, 7.45), parent=hull_obj, parent_pivot_g=(0.0, 0.0, 0.0))
    turret_obj = finalize_mesh_object("Turret_1", build_turret_bmesh(), materials, pivot_g=(0.0, -2.05, 10.65), parent=hull_obj, parent_pivot_g=(0.0, 0.0, 0.0))
    gun_obj = finalize_mesh_object("Gun_1", build_gun_bmesh(), materials, pivot_g=(0.0, -2.25, 11.15), parent=turret_obj, parent_pivot_g=(0.0, -2.05, 10.65))
    ramp_obj = finalize_mesh_object("Ramp", build_ramp_bmesh(), materials, pivot_g=(0.0, -2.02, -4.60), parent=hull_obj, parent_pivot_g=(0.0, 0.0, 0.0))
    door_l_obj = finalize_mesh_object("Door_L", build_door_bmesh(+1), materials, pivot_g=(3.66, -0.35, -1.20), parent=hull_obj, parent_pivot_g=(0.0, 0.0, 0.0))
    door_r_obj = finalize_mesh_object("Door_R", build_door_bmesh(-1), materials, pivot_g=(-3.66, -0.35, -1.20), parent=hull_obj, parent_pivot_g=(0.0, 0.0, 0.0))
    fan_l_obj = finalize_mesh_object("Fan_L", build_fan_bmesh(+1), materials, pivot_g=(5.35, 1.45, 0.65), parent=hull_obj, parent_pivot_g=(0.0, 0.0, 0.0))
    fan_r_obj = finalize_mesh_object("Fan_R", build_fan_bmesh(-1), materials, pivot_g=(-5.35, 1.45, 0.65), parent=hull_obj, parent_pivot_g=(0.0, 0.0, 0.0))

    mesh_objs = [hull_obj, canopy_obj, turret_obj, gun_obj, ramp_obj, door_l_obj, door_r_obj, fan_l_obj, fan_r_obj]

    # 2. Build Empty Nodes
    empties_spec = [
        ("Thruster_1", (2.65, 1.25, -14.0), hull_obj, (0.0, 0.0, 0.0)),
        ("Thruster_2", (-2.65, 1.25, -14.0), hull_obj, (0.0, 0.0, 0.0)),
        ("Muzzle_1",   (0.0, -2.25, 14.0),   gun_obj,  (0.0, -2.25, 11.15)),
        ("Muzzle_2",   (3.95, -0.15, 4.35),  hull_obj, (0.0, 0.0, 0.0)),
        ("Muzzle_3",   (-3.95, -0.15, 4.35), hull_obj, (0.0, 0.0, 0.0)),
        ("Hardpoint_1",(5.20, 0.65, 0.40),   hull_obj, (0.0, 0.0, 0.0)),
        ("Hardpoint_2",(7.80, 0.65, -0.20),  hull_obj, (0.0, 0.0, 0.0)),
        ("Hardpoint_3",(-5.20, 0.65, 0.40),  hull_obj, (0.0, 0.0, 0.0)),
        ("Hardpoint_4",(-7.80, 0.65, -0.20), hull_obj, (0.0, 0.0, 0.0)),
        ("Seat_Pilot", (0.0, -0.50, 10.35),  hull_obj, (0.0, 0.0, 0.0)),
        ("Camera_Chase",(0.0, 7.50, -24.0),  hull_obj, (0.0, 0.0, 0.0)),
        ("Light_Nav_L",(11.0, 1.38, -0.35),  hull_obj, (0.0, 0.0, 0.0)),
        ("Light_Nav_R",(-11.0, 1.38, -0.35), hull_obj, (0.0, 0.0, 0.0)),
        ("Light_Beacon",(0.0, 3.12, 2.50),   hull_obj, (0.0, 0.0, 0.0)),
    ]
    empty_objs = []
    for ename, eloc_g, eparent, ep_piv_g in empties_spec:
        eobj = bpy.data.objects.new(ename, None)
        eobj.empty_display_type = 'PLAIN_AXES'
        eobj.empty_display_size = 0.5
        bpy.context.collection.objects.link(eobj)
        eobj.parent = eparent
        eobj.matrix_parent_inverse = Matrix.Identity(4)
        eobj.location = G(*eloc_g) - G(*ep_piv_g)
        empty_objs.append(eobj)

    bpy.context.view_layer.update()

    # 3. Compute exact Bounding Box & Triangle Count across all mesh nodes
    min_g = [1e9, 1e9, 1e9]
    max_g = [-1e9, -1e9, -1e9]
    total_tris = 0
    for mobj in mesh_objs:
        mw = mobj.matrix_world
        me = mobj.data
        me.calc_loop_triangles()
        total_tris += len(me.loop_triangles)
        for v in me.vertices:
            wv = mw @ v.co
            gx, gy, gz = B_to_G(wv)
            min_g[0] = min(min_g[0], gx)
            min_g[1] = min(min_g[1], gy)
            min_g[2] = min(min_g[2], gz)
            max_g[0] = max(max_g[0], gx)
            max_g[1] = max(max_g[1], gy)
            max_g[2] = max(max_g[2], gz)

    dims_g = [round(max_g[i] - min_g[i], 4) for i in range(3)]
    print(f"Total Triangles: {total_tris}")
    print(f"Bounding Box (glTF): min={min_g}, max={max_g}, dimensions={dims_g}")

    # 4. Export Clean GLB
    glb_path = os.path.join(OUT_DIR, "troop-dropship.glb")
    bpy.ops.export_scene.gltf(
        filepath=glb_path,
        export_format='GLB',
        export_yup=True,
        export_apply=False,
        export_texcoords=True,
        export_normals=True,
        export_materials='EXPORT',
        export_cameras=False,
        export_lights=False,
    )
    ensure_glb_paint_contract(glb_path)
    glb_bytes = os.path.getsize(glb_path)
    print(f"Exported {glb_path} ({glb_bytes} bytes)")

    # 5. Write nodes.json
    nodes_dict = {}
    for obj in mesh_objs + empty_objs:
        wpos = B_to_G(obj.matrix_world.translation)
        nodes_dict[obj.name] = {
            "world_position": wpos,
            "parent": obj.parent.name if obj.parent else None,
            "type": "mesh" if obj.type == 'MESH' else "empty",
        }

    nodes_json = {
        "asset": "troop-dropship",
        "contract": "spacecraft/v1",
        "units": "metres",
        "forward": "+Z",
        "up": "+Y",
        "left": "+X",
        "bounding_box": {
            "min": [round(x, 4) for x in min_g],
            "max": [round(x, 4) for x in max_g],
            "dimensions": dims_g,
        },
        "triangles": total_tris,
        "materials": MAT_NAMES,
        "file_bytes": glb_bytes,
        "axes": MOVING_AXES,
        "paints": PAINTS,
        "nodes": nodes_dict,
    }
    nodes_json_path = os.path.join(OUT_DIR, "nodes.json")
    with open(nodes_json_path, "w", encoding="utf-8") as f:
        json.dump(nodes_json, f, indent=2)

    # 6. Setup Space Backdrop, Lights & Cameras for Renders
    setup_space_lighting()
    cam_34 = make_camera("Cam_34", loc_g=(23.5, 9.2, 24.5), target_g=(0.0, 0.2, 0.5), lens=45.0)
    cam_side = make_camera("Cam_Side", loc_g=(38.0, 0.0, 0.0), target_g=(0.0, 0.0, 0.0), ortho_scale=31.0)

    # Render final.png (3/4 front view, default Olive Drab paint)
    set_paint_factor(materials[MAT_PAINT], PAINTS[0]["linear_rgb"])
    render_image(cam_34, os.path.join(OUT_DIR, "final.png"), res_x=1920, res_y=1080, samples=64)

    # Render side.png (orthographic side view)
    render_image(cam_side, os.path.join(OUT_DIR, "side.png"), res_x=1920, res_y=1080, samples=48)

    # Render paints.png (3x2 grid of the 6 faction paints in the same 3/4 view)
    tile_paths = []
    for idx, pinfo in enumerate(PAINTS):
        set_paint_factor(materials[MAT_PAINT], pinfo["linear_rgb"])
        tpath = os.path.join(TMP_DIR, f"paint_tile_{idx}.png")
        render_image(cam_34, tpath, res_x=640, res_y=540, samples=28)
        tile_paths.append(tpath)
    stitch_paints_grid(tile_paths, os.path.join(OUT_DIR, "paints.png"), tile_w=640, tile_h=540)
    set_paint_factor(materials[MAT_PAINT], PAINTS[0]["linear_rgb"])

    # Render 30-degree moving-parts verification test
    rot30 = math.radians(30.0)
    canopy_obj.rotation_euler.x = -rot30
    ramp_obj.rotation_euler.x = -rot30
    turret_obj.rotation_euler.z = rot30
    gun_obj.rotation_euler.x = rot30 * 0.5
    fan_l_obj.rotation_euler.z = rot30
    fan_r_obj.rotation_euler.z = rot30
    door_l_obj.rotation_euler.z = rot30 * 0.25
    door_r_obj.rotation_euler.z = -rot30 * 0.25
    render_image(cam_34, os.path.join(TMP_DIR, "moving_test.png"), res_x=1280, res_y=720, samples=24)
    for mobj in (canopy_obj, ramp_obj, turret_obj, gun_obj, fan_l_obj, fan_r_obj, door_l_obj, door_r_obj):
        mobj.rotation_euler = (0.0, 0.0, 0.0)

    # Render clay.png (same 3/4 view as final, plain grey clay material override)
    clay_mat = bpy.data.materials.new(name="ClayOverride")
    clay_mat.use_nodes = True
    bsdf_c = clay_mat.node_tree.nodes.get("Principled BSDF")
    bsdf_c.inputs["Base Color"].default_value = (0.55, 0.56, 0.57, 1.0)
    bsdf_c.inputs["Roughness"].default_value = 0.62
    bsdf_c.inputs["Metallic"].default_value = 0.0
    bpy.context.view_layer.material_override = clay_mat
    render_image(cam_34, os.path.join(OUT_DIR, "clay.png"), res_x=1920, res_y=1080, samples=48)
    bpy.context.view_layer.material_override = None


if __name__ == "__main__":
    main()
