#!/usr/bin/env python3
"""
build_starfighter-opera.py
Builds the original space-opera starfighter (starfighter-opera.glb) from an empty scene,
downloads/loads CC0 Poly Haven textures, mixes procedural weathering & panel lines,
bakes textures with Cycles (<=1024px Base/Normal, <=512px ORM), verifies moving parts,
exports starfighter-opera.glb and nodes.json, and renders final.png, side.png, clay.png, paints.png.
"""

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

# ---------------------------------------------------------------------------
# Paths & Configuration
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
TEX_CACHE_DIR = "/tmp/starfighter_opera_tex"
BAKE_DIR = "/tmp/starfighter_opera_baked"
os.makedirs(TEX_CACHE_DIR, exist_ok=True)
os.makedirs(BAKE_DIR, exist_ok=True)

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

PAINTS = [
    {"name": "warm white", "color": [0.75, 0.74, 0.70]},
    {"name": "imperial grey", "color": [0.42, 0.45, 0.48]},
    {"name": "rebel red", "color": [0.68, 0.14, 0.12]},
    {"name": "midnight blue", "color": [0.10, 0.18, 0.36]},
    {"name": "olive", "color": [0.28, 0.33, 0.22]},
    {"name": "gold", "color": [0.78, 0.58, 0.18]},
]


def ensure_polyhaven_textures():
    """Download CC0 Poly Haven 1k jpg maps if not already cached."""
    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"
            local_path = os.path.join(TEX_CACHE_DIR, fname)
            if not os.path.exists(local_path) or os.path.getsize(local_path) < 1000:
                url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{fname}"
                print(f"Downloading {url} -> {local_path}")
                req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
                with urllib.request.urlopen(req, timeout=30) as resp, open(local_path, "wb") as out:
                    shutil.copyfileobj(resp, out)
            paths[ph_id][map_type] = local_path
    return paths


def clear_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


def configure_cycles_gpu(samples=64):
    scene = bpy.context.scene
    scene.render.engine = 'CYCLES'
    prefs = bpy.context.preferences
    cprefs = prefs.addons['cycles'].preferences
    gpu_found = 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'):
                    gpu_found = True
            if gpu_found:
                break
        except Exception:
            pass
    scene.cycles.device = 'GPU' if gpu_found else 'CPU'
    scene.cycles.samples = samples
    scene.cycles.use_denoising = True
    return scene


# ---------------------------------------------------------------------------
# Procedural + Poly Haven Shader Builders (Pre-Bake)
# ---------------------------------------------------------------------------
def load_image_cached(filepath, colorspace='sRGB'):
    img = bpy.data.images.load(filepath, check_existing=True)
    img.colorspace_settings.name = colorspace
    return img


def add_polyhaven_box(nt, nodes, links, ph_files, scale=0.35):
    """Creates Object-space box-mapped Poly Haven diff, arm, nor_gl nodes for seamless 3D baking."""
    tex_coord = nodes.new('ShaderNodeTexCoord')
    mapping = nodes.new('ShaderNodeMapping')
    mapping.inputs['Scale'].default_value = (scale, scale, scale)
    links.new(tex_coord.outputs['Object'], mapping.inputs['Vector'])

    img_diff = nodes.new('ShaderNodeTexImage')
    img_diff.image = load_image_cached(ph_files['diff'], 'sRGB')
    img_diff.projection = 'BOX'
    img_diff.projection_blend = 0.25
    links.new(mapping.outputs['Vector'], img_diff.inputs['Vector'])

    img_arm = nodes.new('ShaderNodeTexImage')
    img_arm.image = load_image_cached(ph_files['arm'], 'Non-Color')
    img_arm.projection = 'BOX'
    img_arm.projection_blend = 0.25
    links.new(mapping.outputs['Vector'], img_arm.inputs['Vector'])

    img_nor = nodes.new('ShaderNodeTexImage')
    img_nor.image = load_image_cached(ph_files['nor_gl'], 'Non-Color')
    img_nor.projection = 'BOX'
    img_nor.projection_blend = 0.25
    links.new(mapping.outputs['Vector'], img_nor.inputs['Vector'])

    sep_arm = nodes.new('ShaderNodeSeparateColor')
    links.new(img_arm.outputs['Color'], sep_arm.inputs['Color'])

    return tex_coord, mapping, img_diff, sep_arm, img_nor


def add_polyhaven_uv(nt, nodes, links, ph_files, scale=2.5):
    """Creates UV-mapped Poly Haven diff, arm, nor_gl nodes for clean baking."""
    tex_coord = nodes.new('ShaderNodeTexCoord')
    mapping = nodes.new('ShaderNodeMapping')
    mapping.inputs['Scale'].default_value = (scale, scale, scale)
    links.new(tex_coord.outputs['UV'], mapping.inputs['Vector'])

    img_diff = nodes.new('ShaderNodeTexImage')
    img_diff.image = load_image_cached(ph_files['diff'], 'sRGB')
    links.new(mapping.outputs['Vector'], img_diff.inputs['Vector'])

    img_arm = nodes.new('ShaderNodeTexImage')
    img_arm.image = load_image_cached(ph_files['arm'], 'Non-Color')
    links.new(mapping.outputs['Vector'], img_arm.inputs['Vector'])

    img_nor = nodes.new('ShaderNodeTexImage')
    img_nor.image = load_image_cached(ph_files['nor_gl'], 'Non-Color')
    links.new(mapping.outputs['Vector'], img_nor.inputs['Vector'])

    sep_arm = nodes.new('ShaderNodeSeparateColor')
    links.new(img_arm.outputs['Color'], sep_arm.inputs['Color'])

    return tex_coord, mapping, img_diff, sep_arm, img_nor


def create_rich_materials(ph_paths):
    """
    Create the 8 rich materials combining Poly Haven CC0 textures with procedural
    panel lines, grime, engine scorch, micro-meteorite pitting, and edge wear.
    """
    mats = {}

    # 1. Paint (Neutral near-white baked detail; baseColorFactor [0.75, 0.74, 0.70] applied after bake)
    mat_paint = bpy.data.materials.new("Paint")
    mat_paint.use_nodes = True
    nt = mat_paint.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()

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

    tc, mapping, diff_mp, sep_mp, nor_mp = add_polyhaven_uv(
        nt, nodes, links, ph_paths['metal_plate_02'], scale=2.0
    )
    _, _, diff_mp1, sep_mp1, nor_mp1 = add_polyhaven_uv(
        nt, nodes, links, ph_paths['metal_plate'], scale=4.0
    )
    _, _, diff_rust, sep_rust, _ = add_polyhaven_uv(
        nt, nodes, links, ph_paths['rusty_metal_03'], scale=2.5
    )

    # Primary staggered aerospace hull plates (Brick texture 1: elongated aircraft skin plates)
    brick1 = nodes.new('ShaderNodeTexBrick')
    brick1.offset = 0.5
    brick1.squash = 2.8
    brick1.inputs['Scale'].default_value = 2.4
    brick1.inputs['Mortar Size'].default_value = 0.005
    brick1.inputs['Mortar Smooth'].default_value = 0.25
    brick1.inputs['Color1'].default_value = (0.99, 0.99, 0.99, 1.0)
    brick1.inputs['Color2'].default_value = (0.93, 0.93, 0.94, 1.0)
    brick1.inputs['Mortar'].default_value = (0.72, 0.72, 0.74, 1.0)
    links.new(tc.outputs['UV'], brick1.inputs['Vector'])

    # Secondary access hatches & fine panel seams (Brick texture 2)
    brick2 = nodes.new('ShaderNodeTexBrick')
    brick2.offset = 0.25
    brick2.squash = 1.8
    brick2.inputs['Scale'].default_value = 4.8
    brick2.inputs['Mortar Size'].default_value = 0.004
    brick2.inputs['Mortar Smooth'].default_value = 0.22
    brick2.inputs['Color1'].default_value = (1.0, 1.0, 1.0, 1.0)
    brick2.inputs['Color2'].default_value = (0.96, 0.96, 0.97, 1.0)
    brick2.inputs['Mortar'].default_value = (0.80, 0.80, 0.82, 1.0)
    links.new(tc.outputs['UV'], brick2.inputs['Vector'])

    mix_bricks = nodes.new('ShaderNodeMix')
    mix_bricks.data_type = 'RGBA'
    mix_bricks.blend_type = 'MULTIPLY'
    mix_bricks.inputs['Factor'].default_value = 0.55
    links.new(brick1.outputs['Color'], mix_bricks.inputs['A'])
    links.new(brick2.outputs['Color'], mix_bricks.inputs['B'])

    # Convert Poly Haven metal_plate_02 + metal_plate + rusty_metal_03 into neutral greyscale detail (0.68..0.99)
    mix_mp = nodes.new('ShaderNodeMix')
    mix_mp.data_type = 'RGBA'
    mix_mp.blend_type = 'MIX'
    mix_mp.inputs['Factor'].default_value = 0.30
    links.new(diff_mp.outputs['Color'], mix_mp.inputs['A'])
    links.new(diff_mp1.outputs['Color'], mix_mp.inputs['B'])

    bw_mp = nodes.new('ShaderNodeRGBToBW')
    links.new(mix_mp.outputs['Result'], bw_mp.inputs['Color'])
    ramp_mp = nodes.new('ShaderNodeValToRGB')
    ramp_mp.color_ramp.elements[0].position = 0.08
    ramp_mp.color_ramp.elements[0].color = (0.74, 0.74, 0.76, 1.0)
    ramp_mp.color_ramp.elements[1].position = 0.92
    ramp_mp.color_ramp.elements[1].color = (0.99, 0.99, 0.99, 1.0)
    links.new(bw_mp.outputs['Val'], ramp_mp.inputs['Fac'])

    bw_rust = nodes.new('ShaderNodeRGBToBW')
    links.new(diff_rust.outputs['Color'], bw_rust.inputs['Color'])
    ramp_grime = nodes.new('ShaderNodeValToRGB')
    ramp_grime.color_ramp.elements[0].position = 0.22
    ramp_grime.color_ramp.elements[0].color = (0.66, 0.66, 0.68, 1.0)
    ramp_grime.color_ramp.elements[1].position = 0.78
    ramp_grime.color_ramp.elements[1].color = (0.99, 0.99, 0.99, 1.0)
    links.new(bw_rust.outputs['Val'], ramp_grime.inputs['Fac'])

    mix_ph_neutral = nodes.new('ShaderNodeMix')
    mix_ph_neutral.data_type = 'RGBA'
    mix_ph_neutral.blend_type = 'MULTIPLY'
    mix_ph_neutral.inputs['Factor'].default_value = 0.55
    links.new(ramp_mp.outputs['Color'], mix_ph_neutral.inputs['A'])
    links.new(ramp_grime.outputs['Color'], mix_ph_neutral.inputs['B'])

    # Directional airflow streaks & engine scorch variation
    streak_map = nodes.new('ShaderNodeMapping')
    streak_map.inputs['Scale'].default_value = (3.2, 0.55, 1.0)
    links.new(tc.outputs['UV'], streak_map.inputs['Vector'])
    noise_streak = nodes.new('ShaderNodeTexNoise')
    noise_streak.inputs['Scale'].default_value = 4.5
    noise_streak.inputs['Detail'].default_value = 8.0
    noise_streak.inputs['Roughness'].default_value = 0.65
    links.new(streak_map.outputs['Vector'], noise_streak.inputs['Vector'])

    ramp_streak = nodes.new('ShaderNodeValToRGB')
    ramp_streak.color_ramp.elements[0].position = 0.25
    ramp_streak.color_ramp.elements[0].color = (0.76, 0.76, 0.78, 1.0)
    ramp_streak.color_ramp.elements[1].position = 0.75
    ramp_streak.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
    links.new(noise_streak.outputs['Fac'], ramp_streak.inputs['Fac'])

    mix_streak = nodes.new('ShaderNodeMix')
    mix_streak.data_type = 'RGBA'
    mix_streak.blend_type = 'MULTIPLY'
    mix_streak.inputs['Factor'].default_value = 0.50
    links.new(mix_ph_neutral.outputs['Result'], mix_streak.inputs['A'])
    links.new(ramp_streak.outputs['Color'], mix_streak.inputs['B'])

    # Micro-meteorite pitting / fastener dots
    vor_pit = nodes.new('ShaderNodeTexVoronoi')
    vor_pit.inputs['Scale'].default_value = 42.0
    links.new(tc.outputs['UV'], vor_pit.inputs['Vector'])
    ramp_pit = nodes.new('ShaderNodeValToRGB')
    ramp_pit.color_ramp.elements[0].position = 0.04
    ramp_pit.color_ramp.elements[0].color = (0.72, 0.72, 0.74, 1.0)
    ramp_pit.color_ramp.elements[1].position = 0.14
    ramp_pit.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
    links.new(vor_pit.outputs['Distance'], ramp_pit.inputs['Fac'])

    mix_pit = nodes.new('ShaderNodeMix')
    mix_pit.data_type = 'RGBA'
    mix_pit.blend_type = 'MULTIPLY'
    mix_pit.inputs['Factor'].default_value = 0.30
    links.new(mix_streak.outputs['Result'], mix_pit.inputs['A'])
    links.new(ramp_pit.outputs['Color'], mix_pit.inputs['B'])

    # Combine with staggered aerospace panels & seam grime
    mix_panels = nodes.new('ShaderNodeMix')
    mix_panels.data_type = 'RGBA'
    mix_panels.blend_type = 'MULTIPLY'
    mix_panels.inputs['Factor'].default_value = 0.48
    links.new(mix_pit.outputs['Result'], mix_panels.inputs['A'])
    links.new(mix_bricks.outputs['Result'], mix_panels.inputs['B'])

    links.new(mix_panels.outputs['Result'], bsdf.inputs['Base Color'])

    # Roughness & Metallic for Paint (with edge-chipped metallic along panel seams)
    math_rough = nodes.new('ShaderNodeMath')
    math_rough.operation = 'MULTIPLY_ADD'
    links.new(sep_mp.outputs['Green'], math_rough.inputs[0])
    math_rough.inputs[1].default_value = 0.32
    math_rough.inputs[2].default_value = 0.22
    links.new(math_rough.outputs['Value'], bsdf.inputs['Roughness'])

    math_met = nodes.new('ShaderNodeMath')
    math_met.operation = 'MULTIPLY_ADD'
    links.new(brick1.outputs['Fac'], math_met.inputs[0])
    math_met.inputs[1].default_value = 0.25
    math_met.inputs[2].default_value = 0.20
    links.new(math_met.outputs['Value'], bsdf.inputs['Metallic'])

    bump = nodes.new('ShaderNodeBump')
    bump.inputs['Strength'].default_value = 0.18
    bump.inputs['Distance'].default_value = 0.006
    links.new(mix_bricks.outputs['Result'], bump.inputs['Height'])

    nmap = nodes.new('ShaderNodeNormalMap')
    nmap.inputs['Strength'].default_value = 0.55
    links.new(nor_mp.outputs['Color'], nmap.inputs['Color'])
    links.new(nmap.outputs['Normal'], bump.inputs['Normal'])
    links.new(bump.outputs['Normal'], bsdf.inputs['Normal'])
    mats['Paint'] = mat_paint

    # 2. Metal (Smooth brushed aerospace titanium/steel)
    mat_metal = bpy.data.materials.new("Metal")
    mat_metal.use_nodes = True
    nt = mat_metal.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()
    out = nodes.new('ShaderNodeOutputMaterial')
    bsdf = nodes.new('ShaderNodeBsdfPrincipled')
    links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    tc, mapping, diff_m2, sep_m2, nor_m2 = add_polyhaven_uv(
        nt, nodes, links, ph_paths['metal_plate_02'], scale=2.2
    )
    _, _, diff_m1, _, _ = add_polyhaven_uv(
        nt, nodes, links, ph_paths['metal_plate'], scale=4.0
    )
    _, _, diff_r, _, _ = add_polyhaven_uv(
        nt, nodes, links, ph_paths['rusty_metal_03'], scale=2.8
    )
    mix_m12 = nodes.new('ShaderNodeMix')
    mix_m12.data_type = 'RGBA'
    mix_m12.blend_type = 'MIX'
    mix_m12.inputs['Factor'].default_value = 0.12
    links.new(diff_m2.outputs['Color'], mix_m12.inputs['A'])
    links.new(diff_m1.outputs['Color'], mix_m12.inputs['B'])

    mix_m = nodes.new('ShaderNodeMix')
    mix_m.data_type = 'RGBA'
    mix_m.blend_type = 'MULTIPLY'
    mix_m.inputs['Factor'].default_value = 0.28
    links.new(mix_m12.outputs['Result'], mix_m.inputs['A'])
    links.new(diff_r.outputs['Color'], mix_m.inputs['B'])

    tint_m = nodes.new('ShaderNodeMix')
    tint_m.data_type = 'RGBA'
    tint_m.blend_type = 'MIX'
    tint_m.inputs['Factor'].default_value = 0.45
    links.new(mix_m.outputs['Result'], tint_m.inputs['A'])
    tint_m.inputs['B'].default_value = (0.50, 0.53, 0.57, 1.0)
    links.new(tint_m.outputs['Result'], bsdf.inputs['Base Color'])

    bsdf.inputs['Metallic'].default_value = 0.90
    math_r = nodes.new('ShaderNodeMath')
    math_r.operation = 'MULTIPLY_ADD'
    links.new(sep_m2.outputs['Green'], math_r.inputs[0])
    math_r.inputs[1].default_value = 0.28
    math_r.inputs[2].default_value = 0.15
    links.new(math_r.outputs['Value'], bsdf.inputs['Roughness'])

    nmap = nodes.new('ShaderNodeNormalMap')
    nmap.inputs['Strength'].default_value = 0.65
    links.new(nor_m2.outputs['Color'], nmap.inputs['Color'])
    links.new(nmap.outputs['Normal'], bsdf.inputs['Normal'])
    mats['Metal'] = mat_metal

    # 3. Scorched (Heat-scorched bronze/amber/blued titanium on engine collars, intake bands, canopy trim)
    mat_scorch = bpy.data.materials.new("Scorched")
    mat_scorch.use_nodes = True
    nt = mat_scorch.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()
    out = nodes.new('ShaderNodeOutputMaterial')
    bsdf = nodes.new('ShaderNodeBsdfPrincipled')
    links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    tc, mapping, diff_s, sep_s, nor_s = add_polyhaven_uv(
        nt, nodes, links, ph_paths['rusty_metal_03'], scale=2.2
    )
    noise_s = nodes.new('ShaderNodeTexNoise')
    noise_s.inputs['Scale'].default_value = 4.0
    noise_s.inputs['Detail'].default_value = 6.0
    links.new(tc.outputs['UV'], noise_s.inputs['Vector'])

    ramp_s = nodes.new('ShaderNodeValToRGB')
    ramp_s.color_ramp.elements[0].position = 0.20
    ramp_s.color_ramp.elements[0].color = (0.64, 0.39, 0.18, 1.0)
    e1 = ramp_s.color_ramp.elements.new(0.60)
    e1.color = (0.80, 0.50, 0.22, 1.0)
    ramp_s.color_ramp.elements[2].position = 0.88
    ramp_s.color_ramp.elements[2].color = (0.30, 0.42, 0.62, 1.0)
    links.new(noise_s.outputs['Fac'], ramp_s.inputs['Fac'])

    mix_s = nodes.new('ShaderNodeMix')
    mix_s.data_type = 'RGBA'
    mix_s.blend_type = 'MULTIPLY'
    mix_s.inputs['Factor'].default_value = 0.50
    links.new(ramp_s.outputs['Color'], mix_s.inputs['A'])
    links.new(diff_s.outputs['Color'], mix_s.inputs['B'])
    links.new(mix_s.outputs['Result'], bsdf.inputs['Base Color'])

    bsdf.inputs['Metallic'].default_value = 0.85
    bsdf.inputs['Roughness'].default_value = 0.26
    nmap = nodes.new('ShaderNodeNormalMap')
    nmap.inputs['Strength'].default_value = 0.60
    links.new(nor_s.outputs['Color'], nmap.inputs['Color'])
    links.new(nmap.outputs['Normal'], bsdf.inputs['Normal'])
    mats['Scorched'] = mat_scorch

    # 4. Weapons (Dark gunmetal for radome nose cone, cannon receivers, nozzle petals, vents)
    mat_weap = bpy.data.materials.new("Weapons")
    mat_weap.use_nodes = True
    nt = mat_weap.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()
    out = nodes.new('ShaderNodeOutputMaterial')
    bsdf = nodes.new('ShaderNodeBsdfPrincipled')
    links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    tc, mapping, diff_w, sep_w, nor_w = add_polyhaven_uv(
        nt, nodes, links, ph_paths['metal_plate_02'], scale=2.8
    )
    tint_w = nodes.new('ShaderNodeMix')
    tint_w.data_type = 'RGBA'
    tint_w.blend_type = 'MIX'
    tint_w.inputs['Factor'].default_value = 0.72
    links.new(diff_w.outputs['Color'], tint_w.inputs['A'])
    tint_w.inputs['B'].default_value = (0.17, 0.19, 0.22, 1.0)
    links.new(tint_w.outputs['Result'], bsdf.inputs['Base Color'])

    bsdf.inputs['Metallic'].default_value = 0.76
    bsdf.inputs['Roughness'].default_value = 0.32
    nmap = nodes.new('ShaderNodeNormalMap')
    nmap.inputs['Strength'].default_value = 0.65
    links.new(nor_w.outputs['Color'], nmap.inputs['Color'])
    links.new(nmap.outputs['Normal'], bsdf.inputs['Normal'])
    mats['Weapons'] = mat_weap

    # 5. Cockpit (Dark upholstery + tactical composite interior)
    mat_cockpit = bpy.data.materials.new("Cockpit")
    mat_cockpit.use_nodes = True
    nt = mat_cockpit.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()
    out = nodes.new('ShaderNodeOutputMaterial')
    bsdf = nodes.new('ShaderNodeBsdfPrincipled')
    links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    tc, mapping, diff_c, sep_c, nor_c = add_polyhaven_uv(
        nt, nodes, links, ph_paths['rubberized_track'], scale=4.0
    )
    tint_c = nodes.new('ShaderNodeMix')
    tint_c.data_type = 'RGBA'
    tint_c.blend_type = 'MIX'
    tint_c.inputs['Factor'].default_value = 0.70
    links.new(diff_c.outputs['Color'], tint_c.inputs['A'])
    tint_c.inputs['B'].default_value = (0.18, 0.20, 0.22, 1.0)
    links.new(tint_c.outputs['Result'], bsdf.inputs['Base Color'])

    bsdf.inputs['Metallic'].default_value = 0.15
    bsdf.inputs['Roughness'].default_value = 0.56
    nmap = nodes.new('ShaderNodeNormalMap')
    nmap.inputs['Strength'].default_value = 0.5
    links.new(nor_c.outputs['Color'], nmap.inputs['Color'])
    links.new(nmap.outputs['Normal'], bsdf.inputs['Normal'])
    mats['Cockpit'] = mat_cockpit

    # 6. Glass (Tinted cockpit canopy glazing, alpha BLEND, visible interior behind it)
    mat_glass = bpy.data.materials.new("Glass")
    mat_glass.use_nodes = True
    mat_glass.blend_method = 'BLEND'
    nt = mat_glass.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()
    out = nodes.new('ShaderNodeOutputMaterial')
    bsdf = nodes.new('ShaderNodeBsdfPrincipled')
    bsdf.inputs['Base Color'].default_value = (0.48, 0.64, 0.74, 1.0)
    bsdf.inputs['Metallic'].default_value = 0.22
    bsdf.inputs['Roughness'].default_value = 0.04
    bsdf.inputs['Alpha'].default_value = 0.28
    links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
    mats['Glass'] = mat_glass

    # 7. Engines (Glowing electric cyan-blue inside of both engine nozzles, matching ENGINE GLOW swatch)
    mat_eng = bpy.data.materials.new("Engines")
    mat_eng.use_nodes = True
    nt = mat_eng.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()
    out = nodes.new('ShaderNodeOutputMaterial')
    bsdf = nodes.new('ShaderNodeBsdfPrincipled')

    tc_e = nodes.new('ShaderNodeTexCoord')
    grad_e = nodes.new('ShaderNodeTexGradient')
    grad_e.gradient_type = 'SPHERICAL'
    links.new(tc_e.outputs['UV'], grad_e.inputs['Vector'])
    ramp_e = nodes.new('ShaderNodeValToRGB')
    ramp_e.color_ramp.elements[0].position = 0.0
    ramp_e.color_ramp.elements[0].color = (0.04, 0.42, 1.0, 1.0)   # rich electric cobalt-cyan
    e_mid = ramp_e.color_ramp.elements.new(0.55)
    e_mid.color = (0.14, 0.72, 1.0, 1.0)                           # bright cyan-blue
    ramp_e.color_ramp.elements[2].position = 1.0
    ramp_e.color_ramp.elements[2].color = (0.38, 0.88, 1.0, 1.0)   # vivid cyan-blue core
    links.new(grad_e.outputs['Fac'], ramp_e.inputs['Fac'])

    links.new(ramp_e.outputs['Color'], bsdf.inputs['Base Color'])
    links.new(ramp_e.outputs['Color'], bsdf.inputs['Emission Color'])
    bsdf.inputs['Roughness'].default_value = 0.08
    bsdf.inputs['Metallic'].default_value = 0.05
    bsdf.inputs['Emission Strength'].default_value = 2.8
    links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
    mats['Engines'] = mat_eng

    # 8. Lamps (Emissive running lights, beacon, and cockpit MFD displays)
    mat_lamps = bpy.data.materials.new("Lamps")
    mat_lamps.use_nodes = True
    nt = mat_lamps.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()
    out = nodes.new('ShaderNodeOutputMaterial')
    bsdf = nodes.new('ShaderNodeBsdfPrincipled')
    bsdf.inputs['Base Color'].default_value = (0.35, 0.88, 1.0, 1.0)
    bsdf.inputs['Roughness'].default_value = 0.15
    bsdf.inputs['Emission Color'].default_value = (0.35, 0.88, 1.0, 1.0)
    bsdf.inputs['Emission Strength'].default_value = 3.5
    links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
    mats['Lamps'] = mat_lamps

    return mats


# ---------------------------------------------------------------------------
# Geometry Helpers
# ---------------------------------------------------------------------------
def add_bevel_and_smooth(obj, width=0.012, segments=2, angle_deg=30.0):
    bev = obj.modifiers.new(name="Bevel", type='BEVEL')
    bev.width = width
    bev.segments = segments
    bev.limit_method = 'ANGLE'
    bev.angle_limit = math.radians(angle_deg)
    bev.harden_normals = False
    for poly in obj.data.polygons:
        poly.use_smooth = True


def assign_material(obj, mat):
    if mat.name not in obj.data.materials:
        obj.data.materials.append(mat)
    idx = obj.data.materials.find(mat.name)
    for poly in obj.data.polygons:
        poly.material_index = idx


def make_lofted_mesh(name, stations, mat, closed_u=True, cap_ends=True):
    """stations: list of (z, list_of_xy_tuples)"""
    mesh = bpy.data.meshes.new(name)
    bm = bmesh.new()
    rings = []
    for z, pts in stations:
        ring = [bm.verts.new((x, y, z)) for (x, y) in pts]
        rings.append(ring)

    n_pts = len(stations[0][1])
    for i in range(len(rings) - 1):
        r0 = rings[i]
        r1 = rings[i + 1]
        limit = n_pts if closed_u else (n_pts - 1)
        for j in range(limit):
            jn = (j + 1) % n_pts
            v00, v01, v11, v10 = r0[j], r0[jn], r1[jn], r1[j]
            pts_unique = {(round(v.co.x, 5), round(v.co.y, 5), round(v.co.z, 5)) for v in (v00, v01, v11, v10)}
            if len(pts_unique) >= 3:
                try:
                    bm.faces.new((v00, v01, v11, v10))
                except ValueError:
                    pass

    if cap_ends and closed_u:
        for end_ring, flip in ((rings[0], True), (rings[-1], False)):
            pts_unique = {(round(v.co.x, 5), round(v.co.y, 5), round(v.co.z, 5)) for v in end_ring}
            if len(pts_unique) >= 3:
                try:
                    bm.faces.new(end_ring if not flip else list(reversed(end_ring)))
                except ValueError:
                    pass

    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0004)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(mesh)
    bm.free()

    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    for poly in obj.data.polygons:
        poly.use_smooth = True
    assign_material(obj, mat)
    return obj


def fuselage_section_points(w_top, w_chine, w_bot, y_top, y_chine, y_bot, n_per_quad=12):
    pts = []
    for i in range(n_per_quad):
        t = i / n_per_quad
        ang = t * (math.pi / 2.0)
        x = w_chine * (math.sin(ang) ** 0.84)
        y = y_chine + (y_top - y_chine) * (math.cos(ang) ** 1.16)
        pts.append((x, y))
    for i in range(n_per_quad):
        t = i / n_per_quad
        ang = t * (math.pi / 2.0)
        x = w_chine * (math.cos(ang) ** 1.04)
        y = y_chine + (y_bot - y_chine) * (math.sin(ang) ** 0.90)
        pts.append((x, y))
    for i in range(n_per_quad):
        t = i / n_per_quad
        ang = t * (math.pi / 2.0)
        x = -w_chine * (math.sin(ang) ** 1.04)
        y = y_chine + (y_bot - y_chine) * (math.cos(ang) ** 0.90)
        pts.append((x, y))
    for i in range(n_per_quad):
        t = i / n_per_quad
        ang = t * (math.pi / 2.0)
        x = -w_chine * (math.cos(ang) ** 0.84)
        y = y_chine + (y_top - y_chine) * (math.sin(ang) ** 1.16)
        pts.append((x, y))
    return pts


def build_airfoil_wing(name, root_z_le, root_z_te, tip_z_le, tip_z_te,
                       x_root, x_tip, y_root, y_tip, thick_root, thick_tip,
                       mat, n_chord=20, n_span=14):
    stations = []
    for s in range(n_span + 1):
        u = s / n_span
        x = x_root + (x_tip - x_root) * u
        y_c = y_root + (y_tip - y_root) * u
        z_le = root_z_le + (tip_z_le - root_z_le) * u
        z_te = root_z_te + (tip_z_te - root_z_te) * u
        chord = z_le - z_te
        thick = thick_root + (thick_tip - thick_root) * u

        ring_pts = []
        for c in range(n_chord):
            t = c / n_chord
            z = z_le - t * chord
            profile = 4.0 * (t ** 0.62) * ((1.0 - t) ** 0.86)
            half_t = 0.5 * thick * max(profile, 0.08)
            ring_pts.append((y_c + half_t, z))
        for c in range(n_chord):
            t = 1.0 - (c / n_chord)
            z = z_le - t * chord
            profile = 4.0 * (t ** 0.62) * ((1.0 - t) ** 0.86)
            half_t = 0.5 * thick * max(profile, 0.08)
            ring_pts.append((y_c - half_t, z))
        stations.append((x, ring_pts))

    mesh = bpy.data.meshes.new(name)
    bm = bmesh.new()
    rings = []
    for x, yz_pts in stations:
        ring = [bm.verts.new((x, y, z)) for (y, z) in yz_pts]
        rings.append(ring)
    n_pts = len(stations[0][1])
    for i in range(len(rings) - 1):
        r0, r1 = rings[i], rings[i + 1]
        for j in range(n_pts):
            jn = (j + 1) % n_pts
            try:
                bm.faces.new((r0[j], r0[jn], r1[jn], r1[j]))
            except ValueError:
                pass
    for end_ring, flip in ((rings[0], True), (rings[-1], False)):
        try:
            bm.faces.new(end_ring if not flip else list(reversed(end_ring)))
        except ValueError:
            pass
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0004)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(mesh)
    bm.free()

    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)
    for poly in obj.data.polygons:
        poly.use_smooth = True
    assign_material(obj, mat)
    return obj


# ---------------------------------------------------------------------------
# Starfighter Master Builder (Constructed in glTF coords: +X=Left, +Y=Up, +Z=Nose)
# ---------------------------------------------------------------------------
def build_starfighter(mats):
    hull_parts = []

    # -----------------------------------------------------------------------
    # 1. NOSE RADOME CONE (Z = +5.15 to +6.75) - Dark Gunmetal / Bare Metal
    # -----------------------------------------------------------------------
    radome_stations = [
        (6.75, fuselage_section_points(0.012, 0.015, 0.012, -0.05, -0.06, -0.07, 12)),
        (6.55, fuselage_section_points(0.09,  0.12,  0.09,   0.00, -0.05, -0.10, 12)),
        (6.25, fuselage_section_points(0.18,  0.24,  0.18,   0.07, -0.04, -0.16, 12)),
        (5.90, fuselage_section_points(0.28,  0.37,  0.28,   0.15, -0.03, -0.23, 12)),
        (5.52, fuselage_section_points(0.37,  0.48,  0.37,   0.23, -0.025, -0.31, 12)),
        (5.16, fuselage_section_points(0.44,  0.57,  0.44,   0.29, -0.02, -0.37, 12)),
    ]
    radome = make_lofted_mesh("Nose_Radome", radome_stations, mats['Weapons'])
    hull_parts.append(radome)

    collar_stations = [
        (5.18, fuselage_section_points(0.445, 0.575, 0.445, 0.296, -0.02, -0.376, 12)),
        (5.10, fuselage_section_points(0.462, 0.592, 0.462, 0.308, -0.02, -0.390, 12)),
    ]
    radome_collar = make_lofted_mesh("Radome_Collar", collar_stations, mats['Metal'])
    hull_parts.append(radome_collar)

    # -----------------------------------------------------------------------
    # 2. MAIN ARROWHEAD FUSELAGE (Z = +5.12 back to -5.75)
    # -----------------------------------------------------------------------
    fuse_stations = [
        (5.12,  fuselage_section_points(0.45, 0.58, 0.45,  0.30, -0.02, -0.38, 12)),
        (4.65,  fuselage_section_points(0.53, 0.69, 0.53,  0.37, -0.01, -0.45, 12)),
        (4.15,  fuselage_section_points(0.60, 0.79, 0.60,  0.43,  0.00, -0.52, 12)),
        (3.60,  fuselage_section_points(0.66, 0.88, 0.66,  0.47,  0.01, -0.59, 12)),
        (2.95,  fuselage_section_points(0.71, 0.95, 0.72,  0.50,  0.02, -0.66, 12)),
        (2.25,  fuselage_section_points(0.74, 1.01, 0.78,  0.52,  0.03, -0.76, 12)),
        (1.50,  fuselage_section_points(0.76, 1.05, 0.83,  0.54,  0.03, -0.89, 12)),
        (0.80,  fuselage_section_points(0.78, 1.09, 0.88,  0.66,  0.02, -1.01, 12)),
        (0.20,  fuselage_section_points(0.81, 1.13, 0.93,  0.88,  0.01, -1.09, 12)),
        (-0.60, fuselage_section_points(0.84, 1.16, 0.96,  0.92,  0.00, -1.15, 12)),
        (-1.50, fuselage_section_points(0.83, 1.15, 0.95,  0.91, -0.01, -1.15, 12)),
        (-2.40, fuselage_section_points(0.80, 1.12, 0.92,  0.89, -0.02, -1.12, 12)),
        (-3.30, fuselage_section_points(0.75, 1.06, 0.86,  0.84, -0.02, -1.02, 12)),
        (-4.20, fuselage_section_points(0.68, 0.95, 0.74,  0.76,  0.00, -0.76, 12)),
        (-5.10, fuselage_section_points(0.50, 0.66, 0.52,  0.56,  0.00, -0.52, 12)),
        (-5.80, fuselage_section_points(0.36, 0.44, 0.36,  0.42,  0.00, -0.40, 12)),
    ]
    main_fuse = make_lofted_mesh("Main_Fuselage", fuse_stations, mats['Paint'])

    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.58, 2.20))
    cutter = bpy.context.active_object
    cutter.scale = (0.94, 0.72, 3.25)
    bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
    bool_mod = main_fuse.modifiers.new(name="CockpitCut", type='BOOLEAN')
    bool_mod.operation = 'DIFFERENCE'
    bool_mod.solver = 'EXACT'
    bool_mod.object = cutter
    bpy.context.view_layer.objects.active = main_fuse
    bpy.ops.object.modifier_apply(modifier=bool_mod.name)
    bpy.data.objects.remove(cutter, do_unlink=True)
    hull_parts.append(main_fuse)

    # Raised Dorsal Spine Deck behind Canopy (Z = +0.50 to -5.95)
    spine_stations = [
        (0.55,  fuselage_section_points(0.42, 0.52, 0.48, 0.92, 0.68, 0.45, 8)),
        (-0.40, fuselage_section_points(0.45, 0.58, 0.52, 0.98, 0.72, 0.48, 8)),
        (-1.60, fuselage_section_points(0.42, 0.55, 0.50, 0.96, 0.70, 0.48, 8)),
        (-3.20, fuselage_section_points(0.36, 0.48, 0.44, 0.90, 0.66, 0.45, 8)),
        (-4.65, fuselage_section_points(0.30, 0.38, 0.36, 0.74, 0.55, 0.38, 8)),
        (-5.95, fuselage_section_points(0.28, 0.34, 0.32, 0.52, 0.38, 0.25, 8)),
    ]
    dorsal_spine = make_lofted_mesh("Dorsal_Spine", spine_stations, mats['Paint'])
    hull_parts.append(dorsal_spine)

    # -----------------------------------------------------------------------
    # 3. COCKPIT TUB & DETAILED INTERIOR (Seat, Stick, Console, MFDs, HUD)
    # -----------------------------------------------------------------------
    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.22, 2.20))
    tub = bpy.context.active_object
    tub.scale = (0.88, 0.12, 3.15)
    bpy.ops.object.transform_apply(scale=True)
    add_bevel_and_smooth(tub, 0.015, 2)
    assign_material(tub, mats['Cockpit'])
    hull_parts.append(tub)

    for sx in (-1.0, 1.0):
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 0.36, 0.40, 2.25))
        scon = bpy.context.active_object
        scon.scale = (0.18, 0.24, 2.50)
        bpy.ops.object.transform_apply(scale=True)
        add_bevel_and_smooth(scon, 0.012, 2)
        assign_material(scon, mats['Cockpit'])
        hull_parts.append(scon)

        # Flush Bronze/Amber Canopy Sill Accent Strip on outside of forward fuselage (matches sheet.jpg!)
        sill_stations = [
            (3.75, [(sx * 0.58, 0.36), (sx * 0.63, 0.33), (sx * 0.64, 0.25), (sx * 0.59, 0.28)]),
            (2.40, [(sx * 0.70, 0.42), (sx * 0.75, 0.39), (sx * 0.76, 0.30), (sx * 0.71, 0.33)]),
            (0.95, [(sx * 0.75, 0.44), (sx * 0.80, 0.41), (sx * 0.81, 0.32), (sx * 0.76, 0.35)]),
        ]
        if sx < 0:
            sill_stations = [(z, list(reversed(pts))) for (z, pts) in sill_stations]
        sill_strip = make_lofted_mesh(f"Canopy_Sill_Accent_{sx}", sill_stations, mats['Scorched'])
        hull_parts.append(sill_strip)

        # Long sculpted Forward Cheek Sensor/RCS Scoop along nose chine (Z = +3.30..+4.45, matches sheet.jpg!)
        scoop_stations = [
            (4.48, [(sx * 0.65, 0.07), (sx * 0.73, 0.04), (sx * 0.73, -0.06), (sx * 0.65, -0.03)]),
            (4.10, [(sx * 0.74, 0.09), (sx * 0.85, 0.06), (sx * 0.85, -0.07), (sx * 0.74, -0.04)]),
            (3.60, [(sx * 0.82, 0.10), (sx * 0.92, 0.07), (sx * 0.92, -0.06), (sx * 0.82, -0.03)]),
            (3.25, [(sx * 0.86, 0.09), (sx * 0.93, 0.06), (sx * 0.93, -0.04), (sx * 0.86, -0.02)]),
        ]
        if sx < 0:
            scoop_stations = [(z, list(reversed(pts))) for (z, pts) in scoop_stations]
        scoop_outer = make_lofted_mesh(f"Cheek_Scoop_{sx}", scoop_stations, mats['Metal'])
        hull_parts.append(scoop_outer)

        # Dark recessed interior slot inside the cheek scoop
        scoop_in_stations = [
            (4.49, [(sx * 0.66, 0.05), (sx * 0.72, 0.03), (sx * 0.72, -0.04), (sx * 0.66, -0.02)]),
            (3.70, [(sx * 0.80, 0.07), (sx * 0.90, 0.05), (sx * 0.90, -0.05), (sx * 0.80, -0.02)]),
        ]
        if sx < 0:
            scoop_in_stations = [(z, list(reversed(pts))) for (z, pts) in scoop_in_stations]
        scoop_in = make_lofted_mesh(f"Cheek_Scoop_In_{sx}", scoop_in_stations, mats['Weapons'])
        hull_parts.append(scoop_in)

    # Lower forward chin scoop (Z = +3.10..+4.20)
    for mat_sc, sc_scale, sc_y, sc_z in (
        (mats['Metal'],   (0.46, 0.14, 1.10), -0.50, 3.62),
        (mats['Weapons'], (0.36, 0.10, 0.92), -0.52, 3.70),
    ):
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, sc_y, sc_z))
        ch = bpy.context.active_object
        ch.scale = sc_scale
        ch.rotation_euler = (math.radians(-6.0), 0.0, 0.0)
        bpy.ops.object.transform_apply(rotation=True, scale=True)
        add_bevel_and_smooth(ch, 0.012, 2)
        assign_material(ch, mat_sc)
        hull_parts.append(ch)

    # Pilot Ejection Seat (Z = +1.40..+1.95)
    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.34, 1.78))
    seat_pan = bpy.context.active_object
    seat_pan.scale = (0.46, 0.12, 0.48)
    seat_pan.rotation_euler = (math.radians(-8.0), 0.0, 0.0)
    bpy.ops.object.transform_apply(rotation=True, scale=True)
    add_bevel_and_smooth(seat_pan, 0.02, 2)
    assign_material(seat_pan, mats['Cockpit'])
    hull_parts.append(seat_pan)

    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.66, 1.50))
    seat_back = bpy.context.active_object
    seat_back.scale = (0.44, 0.62, 0.14)
    seat_back.rotation_euler = (math.radians(-16.0), 0.0, 0.0)
    bpy.ops.object.transform_apply(rotation=True, scale=True)
    add_bevel_and_smooth(seat_back, 0.02, 2)
    assign_material(seat_back, mats['Cockpit'])
    hull_parts.append(seat_back)

    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 1.00, 1.38))
    headrest = bpy.context.active_object
    headrest.scale = (0.32, 0.22, 0.16)
    headrest.rotation_euler = (math.radians(-14.0), 0.0, 0.0)
    bpy.ops.object.transform_apply(rotation=True, scale=True)
    add_bevel_and_smooth(headrest, 0.02, 2)
    assign_material(headrest, mats['Metal'])
    hull_parts.append(headrest)

    for sx in (-1.0, 1.0):
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 0.12, 0.68, 1.56))
        strap = bpy.context.active_object
        strap.scale = (0.06, 0.52, 0.04)
        strap.rotation_euler = (math.radians(-16.0), 0.0, 0.0)
        bpy.ops.object.transform_apply(rotation=True, scale=True)
        add_bevel_and_smooth(strap, 0.005, 1)
        assign_material(strap, mats['Scorched'])
        hull_parts.append(strap)

    # Rear Avionics Bay behind seat (visible through rear of bubble canopy: Z = +0.70..+1.30)
    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.68, 1.00))
    rear_deck = bpy.context.active_object
    rear_deck.scale = (0.68, 0.26, 0.65)
    bpy.ops.object.transform_apply(scale=True)
    add_bevel_and_smooth(rear_deck, 0.015, 2)
    assign_material(rear_deck, mats['Weapons'])
    hull_parts.append(rear_deck)

    for sx in (-1.0, 1.0):
        bpy.ops.mesh.primitive_cylinder_add(vertices=18, radius=0.075, depth=0.48, location=(sx * 0.20, 0.84, 0.98))
        oxy = bpy.context.active_object
        add_bevel_and_smooth(oxy, 0.008, 1)
        assign_material(oxy, mats['Metal'])
        hull_parts.append(oxy)

    # Flight Stick & Side Throttle
    bpy.ops.mesh.primitive_cylinder_add(vertices=14, radius=0.024, depth=0.28, location=(0.0, 0.46, 2.25))
    stick = bpy.context.active_object
    stick.rotation_euler = (math.radians(78.0), 0.0, 0.0)
    bpy.ops.object.transform_apply(rotation=True, scale=True)
    assign_material(stick, mats['Metal'])
    hull_parts.append(stick)

    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.60, 2.28))
    grip = bpy.context.active_object
    grip.scale = (0.06, 0.11, 0.08)
    grip.rotation_euler = (math.radians(15.0), 0.0, 0.0)
    bpy.ops.object.transform_apply(rotation=True, scale=True)
    add_bevel_and_smooth(grip, 0.01, 2)
    assign_material(grip, mats['Cockpit'])
    hull_parts.append(grip)

    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.35, 0.56, 2.22))
    throttle = bpy.context.active_object
    throttle.scale = (0.10, 0.08, 0.14)
    bpy.ops.object.transform_apply(scale=True)
    add_bevel_and_smooth(throttle, 0.01, 2)
    assign_material(throttle, mats['Metal'])
    hull_parts.append(throttle)

    # Contoured Instrument Glare-Shield Coaming & Glowing MFD Screens
    dash_stations = [
        (3.55, fuselage_section_points(0.24, 0.30, 0.30, 0.56, 0.48, 0.40, 6)),
        (3.05, fuselage_section_points(0.32, 0.40, 0.40, 0.68, 0.54, 0.40, 6)),
        (2.58, fuselage_section_points(0.36, 0.44, 0.44, 0.72, 0.56, 0.40, 6)),
    ]
    dash = make_lofted_mesh("Instrument_Dash", dash_stations, mats['Cockpit'])
    hull_parts.append(dash)

    for mx, my, mz in ((0.0, 0.62, 2.56), (-0.18, 0.58, 2.57), (0.18, 0.58, 2.57)):
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(mx, my, mz))
        mfd = bpy.context.active_object
        mfd.scale = (0.13, 0.10, 0.02)
        mfd.rotation_euler = (math.radians(-18.0), 0.0, 0.0)
        bpy.ops.object.transform_apply(rotation=True, scale=True)
        assign_material(mfd, mats['Lamps'])
        hull_parts.append(mfd)

    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.78, 2.76))
    hud = bpy.context.active_object
    hud.scale = (0.20, 0.14, 0.012)
    hud.rotation_euler = (math.radians(-38.0), 0.0, 0.0)
    bpy.ops.object.transform_apply(rotation=True, scale=True)
    assign_material(hud, mats['Glass'])
    hull_parts.append(hud)

    # -----------------------------------------------------------------------
    # 4. TWO SMALL CANARDS (Z = +1.35 to +2.95, X = +-0.76 to +-1.92)
    # -----------------------------------------------------------------------
    for sx in (-1.0, 1.0):
        canard = build_airfoil_wing(
            f"Canard_{sx}",
            root_z_le=2.92, root_z_te=1.36,
            tip_z_le=1.88,  tip_z_te=1.42,
            x_root=sx * 0.72, x_tip=sx * 1.92,
            y_root=0.05, y_tip=0.03,
            thick_root=0.10, thick_tip=0.045,
            mat=mats['Paint'], n_chord=16, n_span=10
        )
        hull_parts.append(canard)

        le_strip = build_airfoil_wing(
            f"Canard_LE_{sx}",
            root_z_le=2.95, root_z_te=2.72,
            tip_z_le=1.90,  tip_z_te=1.78,
            x_root=sx * 0.74, x_tip=sx * 1.93,
            y_root=0.05, y_tip=0.03,
            thick_root=0.105, thick_tip=0.048,
            mat=mats['Scorched'], n_chord=8, n_span=8
        )
        hull_parts.append(le_strip)

        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 0.78, 0.05, 2.15))
        c_fair = bpy.context.active_object
        c_fair.scale = (0.14, 0.12, 1.72)
        bpy.ops.object.transform_apply(scale=True)
        add_bevel_and_smooth(c_fair, 0.02, 2)
        assign_material(c_fair, mats['Metal'])
        hull_parts.append(c_fair)

    # -----------------------------------------------------------------------
    # 5. TWIN SIDE RAM INTAKES & BLENDED ENGINE NACELLES (Z = +0.85 to -4.90)
    # -----------------------------------------------------------------------
    def intake_nacelle_ring(x_c, y_c, rx_in, rx_out, ry_top, ry_bot, n_pts=36):
        pts = []
        for i in range(n_pts):
            ang = (2.0 * math.pi * i) / n_pts
            ca, sa = math.cos(ang), math.sin(ang)
            rx = rx_out if ca >= 0 else rx_in
            ry = ry_top if sa >= 0 else ry_bot
            sx_val = math.copysign(abs(ca) ** 0.78, ca) * rx
            sy_val = math.copysign(abs(sa) ** 0.78, sa) * ry
            pts.append((x_c + sx_val, y_c + sy_val))
        return pts

    for sx in (-1.0, 1.0):
        # Smooth Nacelle Cowling
        nac_stations = [
            ( 0.62, intake_nacelle_ring(1.24, -0.14, 0.44, 0.72, 0.62, 0.66, 36)),
            ( 0.32, intake_nacelle_ring(1.25, -0.12, 0.46, 0.75, 0.66, 0.68, 36)),
            (-0.20, intake_nacelle_ring(1.24, -0.09, 0.49, 0.76, 0.71, 0.70, 36)),
            (-0.95, intake_nacelle_ring(1.21, -0.04, 0.54, 0.78, 0.76, 0.73, 36)),
            (-1.75, intake_nacelle_ring(1.18, -0.01, 0.60, 0.80, 0.80, 0.76, 36)),
            (-2.60, intake_nacelle_ring(1.16,  0.01, 0.67, 0.83, 0.83, 0.80, 36)),
            (-3.50, intake_nacelle_ring(1.15,  0.02, 0.75, 0.85, 0.85, 0.83, 36)),
            (-4.30, intake_nacelle_ring(1.15,  0.02, 0.83, 0.86, 0.86, 0.85, 36)),
            (-4.88, intake_nacelle_ring(1.15,  0.02, 0.84, 0.84, 0.84, 0.84, 36)),
        ]
        if sx < 0:
            nac_stations = [(z, [(-x, y) for (x, y) in reversed(pts)]) for (z, pts) in nac_stations]
        nac_obj = make_lofted_mesh(f"Engine_Nacelle_{sx}", nac_stations, mats['Paint'])
        hull_parts.append(nac_obj)

        # Flush Metallic Intake Lip Bezel Ring at Z = +0.44..+0.72 (+2cm proud of nacelle)
        lip_stations = [
            (0.72, intake_nacelle_ring(1.24, -0.14, 0.41, 0.68, 0.58, 0.62, 36)),
            (0.60, intake_nacelle_ring(1.24, -0.14, 0.46, 0.745, 0.645, 0.685, 36)),
            (0.44, intake_nacelle_ring(1.245, -0.13, 0.475, 0.765, 0.668, 0.700, 36)),
        ]
        if sx < 0:
            lip_stations = [(z, [(-x, y) for (x, y) in reversed(pts)]) for (z, pts) in lip_stations]
        lip_obj = make_lofted_mesh(f"Intake_Lip_{sx}", lip_stations, mats['Metal'])
        hull_parts.append(lip_obj)

        # Proud Heat-Scorched Bronze Collar Band behind Intake Lip at Z = +0.16..+0.42 (+2.2cm proud of nacelle)
        scorch_stations = [
            (0.42, intake_nacelle_ring(1.246, -0.13, 0.472, 0.764, 0.666, 0.696, 36)),
            (0.29, intake_nacelle_ring(1.250, -0.12, 0.480, 0.772, 0.682, 0.702, 36)),
            (0.16, intake_nacelle_ring(1.248, -0.11, 0.486, 0.776, 0.694, 0.706, 36)),
        ]
        if sx < 0:
            scorch_stations = [(z, [(-x, y) for (x, y) in reversed(pts)]) for (z, pts) in scorch_stations]
        sb_obj = make_lofted_mesh(f"Intake_Scorch_Band_{sx}", scorch_stations, mats['Scorched'])
        hull_parts.append(sb_obj)

        # Dark Interior Intake Duct Opening + 3 Horizontal Splitter Vanes
        duct_stations = [
            (0.73, intake_nacelle_ring(1.24, -0.14, 0.35, 0.58, 0.48, 0.52, 32)),
            (0.20, intake_nacelle_ring(1.24, -0.14, 0.35, 0.58, 0.48, 0.52, 32)),
        ]
        if sx < 0:
            duct_stations = [(z, [(-x, y) for (x, y) in reversed(pts)]) for (z, pts) in duct_stations]
        duct_dark = make_lofted_mesh(f"Intake_Duct_{sx}", duct_stations, mats['Weapons'])
        hull_parts.append(duct_dark)

        for vy, vz in ((0.12, 0.64), (-0.14, 0.64), (-0.40, 0.64)):
            bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 1.32, vy, vz))
            vane = bpy.context.active_object
            vane.scale = (0.86, 0.045, 0.36)
            vane.rotation_euler = (0.0, sx * math.radians(-6.0), 0.0)
            bpy.ops.object.transform_apply(rotation=True, scale=True)
            add_bevel_and_smooth(vane, 0.008, 2)
            assign_material(vane, mats['Metal'])
            hull_parts.append(vane)

        # Side Nacelle Auxiliary Vent Fairing above Wing Root (Z = -1.20..-2.35, X = +-1.86, Y = +0.18)
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 1.82, 0.18, -1.78))
        aux_pod = bpy.context.active_object
        aux_pod.scale = (0.34, 0.22, 1.15)
        bpy.ops.object.transform_apply(scale=True)
        add_bevel_and_smooth(aux_pod, 0.02, 2)
        assign_material(aux_pod, mats['Paint'])
        hull_parts.append(aux_pod)

        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 1.92, 0.18, -1.78))
        aux_slot = bpy.context.active_object
        aux_slot.scale = (0.18, 0.11, 0.85)
        bpy.ops.object.transform_apply(scale=True)
        add_bevel_and_smooth(aux_slot, 0.01, 1)
        assign_material(aux_slot, mats['Weapons'])
        hull_parts.append(aux_slot)

    # -----------------------------------------------------------------------
    # 6. SWEPT MAIN WINGS, UPPER VENTS, WINGTIP PODS & LASER CANNONS
    # -----------------------------------------------------------------------
    for sx in (-1.0, 1.0):
        strake = build_airfoil_wing(
            f"Wing_Strake_{sx}",
            root_z_le=0.15,  root_z_te=-1.20,
            tip_z_le=-0.55,  tip_z_te=-1.40,
            x_root=sx * 1.70, x_tip=sx * 2.35,
            y_root=-0.04, y_tip=-0.05,
            thick_root=0.22, thick_tip=0.18,
            mat=mats['Paint'], n_chord=16, n_span=10
        )
        hull_parts.append(strake)

        wing = build_airfoil_wing(
            f"Main_Wing_{sx}",
            root_z_le=-0.35, root_z_te=-3.88,
            tip_z_le=-2.28,  tip_z_te=-3.75,
            x_root=sx * 1.82, x_tip=sx * 5.26,
            y_root=-0.04, y_tip=-0.07,
            thick_root=0.24, thick_tip=0.11,
            mat=mats['Paint'], n_chord=26, n_span=18
        )
        hull_parts.append(wing)

        le_slat = build_airfoil_wing(
            f"Wing_LE_Slat_{sx}",
            root_z_le=-0.32, root_z_te=-0.65,
            tip_z_le=-2.25,  tip_z_te=-2.48,
            x_root=sx * 1.95, x_tip=sx * 5.22,
            y_root=-0.04, y_tip=-0.07,
            thick_root=0.18, thick_tip=0.09,
            mat=mats['Metal'], n_chord=12, n_span=12
        )
        hull_parts.append(le_slat)

        flap = build_airfoil_wing(
            f"Wing_Flap_{sx}",
            root_z_le=-3.35, root_z_te=-3.92,
            tip_z_le=-3.32,  tip_z_te=-3.84,
            x_root=sx * 1.95, x_tip=sx * 3.55,
            y_root=-0.04, y_tip=-0.055,
            thick_root=0.15, thick_tip=0.11,
            mat=mats['Metal'], n_chord=10, n_span=8
        )
        hull_parts.append(flap)

        aileron = build_airfoil_wing(
            f"Wing_Aileron_{sx}",
            root_z_le=-3.32, root_z_te=-3.82,
            tip_z_le=-3.35,  tip_z_te=-3.76,
            x_root=sx * 3.62, x_tip=sx * 5.18,
            y_root=-0.055, y_tip=-0.07,
            thick_root=0.11, thick_tip=0.08,
            mat=mats['Metal'], n_chord=10, n_span=8
        )
        hull_parts.append(aileron)

        # Upper Wing Root Louver Vent Box ("VENT" on sheet.jpg, X = +-2.38, Z = -1.85)
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 2.38, 0.07, -1.85))
        vent_box = bpy.context.active_object
        vent_box.scale = (0.48, 0.12, 0.96)
        vent_box.rotation_euler = (math.radians(2.5), sx * math.radians(-10.0), 0.0)
        bpy.ops.object.transform_apply(rotation=True, scale=True)
        add_bevel_and_smooth(vent_box, 0.015, 2)
        assign_material(vent_box, mats['Metal'])
        hull_parts.append(vent_box)

        for li in range(6):
            lz = -1.45 - li * 0.15
            bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 2.38, 0.125, lz))
            louver = bpy.context.active_object
            louver.scale = (0.36, 0.035, 0.08)
            louver.rotation_euler = (math.radians(22.0), sx * math.radians(-10.0), 0.0)
            bpy.ops.object.transform_apply(rotation=True, scale=True)
            assign_material(louver, mats['Weapons'])
            hull_parts.append(louver)

        # Wingtip Weapon Pod (X = +-5.32, Y = -0.07, Z = -1.45 to -4.38)
        pod_stations = [
            (-1.45, [(sx * 5.32 + 0.11 * math.cos(a), -0.07 + 0.11 * math.sin(a)) for a in [2 * math.pi * k / 28 for k in range(28)]]),
            (-1.75, [(sx * 5.32 + 0.17 * math.cos(a), -0.07 + 0.17 * math.sin(a)) for a in [2 * math.pi * k / 28 for k in range(28)]]),
            (-3.65, [(sx * 5.32 + 0.17 * math.cos(a), -0.07 + 0.17 * math.sin(a)) for a in [2 * math.pi * k / 28 for k in range(28)]]),
            (-4.15, [(sx * 5.32 + 0.11 * math.cos(a), -0.07 + 0.11 * math.sin(a)) for a in [2 * math.pi * k / 28 for k in range(28)]]),
            (-4.38, [(sx * 5.32 + 0.03 * math.cos(a), -0.07 + 0.03 * math.sin(a)) for a in [2 * math.pi * k / 28 for k in range(28)]]),
        ]
        if sx < 0:
            pod_stations = [(z, list(reversed(pts))) for (z, pts) in pod_stations]
        wt_pod = make_lofted_mesh(f"Wingtip_Pod_{sx}", pod_stations, mats['Paint'])
        hull_parts.append(wt_pod)

        # Wingtip Outer Navigation Light Housing & Emissive Lens (reaches exact X = +-5.50!)
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 5.47, -0.07, -3.10))
        nav_housing = bpy.context.active_object
        nav_housing.scale = (0.06, 0.07, 0.36)
        bpy.ops.object.transform_apply(scale=True)
        add_bevel_and_smooth(nav_housing, 0.008, 2)
        assign_material(nav_housing, mats['Lamps'])
        hull_parts.append(nav_housing)

        # Wingtip Laser Cannon Assembly (Z = -1.45 forward to +0.58)
        bpy.ops.mesh.primitive_cylinder_add(vertices=28, radius=0.115, depth=0.75, location=(sx * 5.32, -0.07, -1.10))
        c_rec = bpy.context.active_object
        add_bevel_and_smooth(c_rec, 0.008, 2)
        assign_material(c_rec, mats['Weapons'])
        hull_parts.append(c_rec)

        bpy.ops.mesh.primitive_cylinder_add(vertices=28, radius=0.092, depth=0.68, location=(sx * 5.32, -0.07, -0.42))
        c_shroud = bpy.context.active_object
        add_bevel_and_smooth(c_shroud, 0.006, 2)
        assign_material(c_shroud, mats['Metal'])
        hull_parts.append(c_shroud)

        for rz in (-0.68, -0.53, -0.38, -0.23):
            bpy.ops.mesh.primitive_cylinder_add(vertices=28, radius=0.108, depth=0.055, location=(sx * 5.32, -0.07, rz))
            c_ring = bpy.context.active_object
            add_bevel_and_smooth(c_ring, 0.004, 1)
            assign_material(c_ring, mats['Weapons'])
            hull_parts.append(c_ring)

        bpy.ops.mesh.primitive_cylinder_add(vertices=28, radius=0.058, depth=0.72, location=(sx * 5.32, -0.055, 0.22))
        c_barrel = bpy.context.active_object
        add_bevel_and_smooth(c_barrel, 0.005, 1)
        assign_material(c_barrel, mats['Metal'])
        hull_parts.append(c_barrel)

        bpy.ops.mesh.primitive_cylinder_add(vertices=18, radius=0.032, depth=0.55, location=(sx * 5.32, -0.125, 0.08))
        c_sub = bpy.context.active_object
        assign_material(c_sub, mats['Weapons'])
        hull_parts.append(c_sub)

        for brz in (0.05, 0.30):
            bpy.ops.mesh.primitive_cube_add(size=1.0, location=(sx * 5.32, -0.085, brz))
            b_clamp = bpy.context.active_object
            b_clamp.scale = (0.11, 0.15, 0.06)
            bpy.ops.object.transform_apply(scale=True)
            add_bevel_and_smooth(b_clamp, 0.006, 1)
            assign_material(b_clamp, mats['Weapons'])
            hull_parts.append(b_clamp)

        bpy.ops.mesh.primitive_cylinder_add(vertices=28, radius=0.072, depth=0.16, location=(sx * 5.32, -0.055, 0.50))
        c_muz = bpy.context.active_object
        add_bevel_and_smooth(c_muz, 0.006, 2)
        assign_material(c_muz, mats['Scorched'])
        hull_parts.append(c_muz)

    # -----------------------------------------------------------------------
    # 7. UNDERWING MISSILE HARDPOINTS & CONCUSSION MISSILES (4x)
    # -----------------------------------------------------------------------
    hardpoint_specs = [
        ( 2.75, -0.24, -1.85),
        (-2.75, -0.24, -1.85),
        ( 3.85, -0.25, -2.30),
        (-3.85, -0.25, -2.30),
    ]
    for idx, (hx, hy, hz) in enumerate(hardpoint_specs):
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(hx, hy, hz - 0.15))
        pylon = bpy.context.active_object
        pylon.scale = (0.08, 0.28, 0.95)
        bpy.ops.object.transform_apply(scale=True)
        add_bevel_and_smooth(pylon, 0.01, 2)
        assign_material(pylon, mats['Metal'])
        hull_parts.append(pylon)

        my = hy - 0.20
        bpy.ops.mesh.primitive_cylinder_add(vertices=28, radius=0.095, depth=1.45, location=(hx, my, hz - 0.10))
        m_body = bpy.context.active_object
        add_bevel_and_smooth(m_body, 0.006, 1)
        assign_material(m_body, mats['Paint'])
        hull_parts.append(m_body)

        bpy.ops.mesh.primitive_cone_add(vertices=28, radius1=0.095, radius2=0.008, depth=0.40, location=(hx, my, hz + 0.82))
        m_cone = bpy.context.active_object
        for poly in m_cone.data.polygons:
            poly.use_smooth = True
        assign_material(m_cone, mats['Scorched'])
        hull_parts.append(m_cone)

        for fang in (45.0, 135.0, 225.0, 315.0):
            rad = math.radians(fang)
            fx = hx + 0.125 * math.cos(rad)
            fy = my + 0.125 * math.sin(rad)
            bpy.ops.mesh.primitive_cube_add(size=1.0, location=(fx, fy, hz - 0.65))
            fin = bpy.context.active_object
            fin.scale = (0.15, 0.018, 0.30)
            fin.rotation_euler = (0.0, 0.0, rad)
            bpy.ops.object.transform_apply(rotation=True, scale=True)
            assign_material(fin, mats['Weapons'])
            hull_parts.append(fin)

    # -----------------------------------------------------------------------
    # 8. CENTRAL SWEPT DORSAL FIN (X = 0, Z = -1.35 to -4.48, Y up to +2.25 m)
    # -----------------------------------------------------------------------
    fin_levels = [
        (0.82, -1.35, -4.35, 0.14),
        (1.15, -1.85, -4.38, 0.11),
        (1.55, -2.45, -4.42, 0.085),
        (1.95, -3.05, -4.45, 0.065),
        (2.25, -3.48, -4.48, 0.045),
    ]
    mesh_fin = bpy.data.meshes.new("Dorsal_Fin")
    bm = bmesh.new()
    rings = []
    for y_val, zle, zte, hthick in fin_levels:
        chord = zle - zte
        ring = []
        n_c = 18
        for c in range(n_c):
            t = c / n_c
            z = zle - t * chord
            prof = 4.0 * (t ** 0.65) * ((1.0 - t) ** 0.85)
            x = hthick * max(prof, 0.12)
            ring.append(bm.verts.new((x, y_val, z)))
        for c in range(n_c):
            t = 1.0 - (c / n_c)
            z = zle - t * chord
            prof = 4.0 * (t ** 0.65) * ((1.0 - t) ** 0.85)
            x = -hthick * max(prof, 0.12)
            ring.append(bm.verts.new((x, y_val, z)))
        rings.append(ring)

    n_fp = len(rings[0])
    for i in range(len(rings) - 1):
        r0, r1 = rings[i], rings[i + 1]
        for j in range(n_fp):
            jn = (j + 1) % n_fp
            bm.faces.new((r0[j], r0[jn], r1[jn], r1[j]))
    bm.faces.new(list(reversed(rings[0])))
    bm.faces.new(rings[-1])
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(mesh_fin)
    bm.free()

    fin_obj = bpy.data.objects.new("Dorsal_Fin", mesh_fin)
    bpy.context.collection.objects.link(fin_obj)
    for poly in fin_obj.data.polygons:
        poly.use_smooth = True
    assign_material(fin_obj, mats['Paint'])
    hull_parts.append(fin_obj)

    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 2.23, -3.95))
    beacon_lens = bpy.context.active_object
    beacon_lens.scale = (0.07, 0.04, 0.28)
    bpy.ops.object.transform_apply(scale=True)
    add_bevel_and_smooth(beacon_lens, 0.008, 1)
    assign_material(beacon_lens, mats['Lamps'])
    hull_parts.append(beacon_lens)

    # -----------------------------------------------------------------------
    # 9. TWIN BIG ROUND ENGINES & GLOWING NOZZLES (X = +-1.15, Z = -4.50 to -6.75)
    # -----------------------------------------------------------------------
    for sx in (-1.0, 1.0):
        ex = sx * 1.15
        ey = 0.02

        # 1) Heat-Scorched Bronze Aft Nacelle Collar (Z = -4.55..-4.92)
        bpy.ops.mesh.primitive_cylinder_add(vertices=48, radius=0.85, depth=0.40, location=(ex, ey, -4.72))
        e_collar = bpy.context.active_object
        add_bevel_and_smooth(e_collar, 0.015, 2)
        assign_material(e_collar, mats['Scorched'])
        hull_parts.append(e_collar)

        # 2) Recessed Pre-Nozzle Mechanical Actuator Core (Z = -4.90..-5.48)
        bpy.ops.mesh.primitive_cylinder_add(vertices=48, radius=0.73, depth=0.62, location=(ex, ey, -5.18))
        e_neck = bpy.context.active_object
        for poly in e_neck.data.polygons:
            poly.use_smooth = True
        assign_material(e_neck, mats['Weapons'])
        hull_parts.append(e_neck)

        for rz in (-5.00, -5.16, -5.32):
            bpy.ops.mesh.primitive_cylinder_add(vertices=48, radius=0.77, depth=0.06, location=(ex, ey, rz))
            c_ring = bpy.context.active_object
            add_bevel_and_smooth(c_ring, 0.006, 1)
            assign_material(c_ring, mats['Metal'])
            hull_parts.append(c_ring)

        for k in range(20):
            ang = (2.0 * math.pi * k) / 20.0
            px = ex + 0.785 * math.cos(ang)
            py = ey + 0.785 * math.sin(ang)
            bpy.ops.mesh.primitive_cylinder_add(vertices=12, radius=0.030, depth=0.62, location=(px, py, -5.20))
            act = bpy.context.active_object
            for poly in act.data.polygons:
                poly.use_smooth = True
            assign_material(act, mats['Metal'])
            hull_parts.append(act)

        # 3) Tapered Convergent/Divergent Nozzle Core (Z = -5.42..-6.58)
        nozzle_stations = [
            (-5.42, [(ex + 0.82 * math.cos(a), ey + 0.82 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-5.78, [(ex + 0.83 * math.cos(a), ey + 0.83 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-6.22, [(ex + 0.76 * math.cos(a), ey + 0.76 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-6.58, [(ex + 0.67 * math.cos(a), ey + 0.67 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
        ]
        noz_outer = make_lofted_mesh(f"Nozzle_Outer_{sx}", nozzle_stations, mats['Weapons'], cap_ends=False)
        hull_parts.append(noz_outer)

        # 24 Tapered Turkey-Feather Petals + 24 Glowing Blue Plasma Slots between petals!
        for k in range(24):
            ang = (2.0 * math.pi * k) / 24.0
            ca, sa = math.cos(ang), math.sin(ang)
            px = ex + 0.765 * ca
            py = ey + 0.765 * sa
            bpy.ops.mesh.primitive_cube_add(size=1.0, location=(px, py, -6.02))
            petal = bpy.context.active_object
            petal.scale = (0.16, 0.042, 1.18)
            rot_z = Matrix.Rotation(ang - math.pi / 2.0, 4, 'Z')
            rot_x = Matrix.Rotation(math.radians(-6.8), 4, 'X')
            petal.rotation_euler = (rot_z @ rot_x).to_euler()
            bpy.ops.object.transform_apply(rotation=True, scale=True)
            add_bevel_and_smooth(petal, 0.008, 2)
            assign_material(petal, mats['Metal'] if k % 2 == 0 else mats['Weapons'])
            hull_parts.append(petal)

        # Circumferential Glowing Blue-Anodized Structural Ring around Nozzle Mid-Section (Z = -5.80..-5.98, matches sheet.jpg!)
        band_stations = [
            (-5.80, [(ex + 0.842 * math.cos(a), ey + 0.842 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-5.98, [(ex + 0.822 * math.cos(a), ey + 0.822 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
        ]
        noz_band = make_lofted_mesh(f"Nozzle_BlueBand_{sx}", band_stations, mats['Engines'])
        hull_parts.append(noz_band)

        # 4) GLOWING INNER ENGINE NOZZLE & REAR PLASMA CONE ("Engines" emissive material)
        # Extends from Z = -6.52 to -6.75 at the rear of each nozzle so rich cyan-blue glow is clearly visible in side.png & final.png!
        inner_glow_stations = [
            (-5.48, [(ex + 0.12 * math.cos(a), ey + 0.12 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-5.65, [(ex + 0.56 * math.cos(a), ey + 0.56 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-6.25, [(ex + 0.64 * math.cos(a), ey + 0.64 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-6.75, [(ex + 0.63 * math.cos(a), ey + 0.63 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
        ]
        inner_glow_stations_rev = [(z, list(reversed(pts))) for (z, pts) in inner_glow_stations]
        noz_glow = make_lofted_mesh(f"Nozzle_Inner_Glow_{sx}", inner_glow_stations_rev, mats['Engines'], cap_ends=False)
        hull_parts.append(noz_glow)

        # Outer-facing Glowing Plasma Exhaust Lip at Z = -6.48..-6.75 (gives the vivid blue nozzle tip in side.png & final.png!)
        exit_glow_stations = [
            (-6.48, [(ex + 0.695 * math.cos(a), ey + 0.695 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-6.62, [(ex + 0.665 * math.cos(a), ey + 0.665 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
            (-6.75, [(ex + 0.625 * math.cos(a), ey + 0.625 * math.sin(a)) for a in [2 * math.pi * k / 48 for k in range(48)]]),
        ]
        exit_glow = make_lofted_mesh(f"Nozzle_Exit_Glow_{sx}", exit_glow_stations, mats['Engines'], cap_ends=False)
        hull_parts.append(exit_glow)

        bpy.ops.mesh.primitive_torus_add(
            major_radius=0.63, minor_radius=0.045,
            major_segments=48, minor_segments=14,
            location=(ex, ey, -6.71)
        )
        glow_rim = bpy.context.active_object
        for poly in glow_rim.data.polygons:
            poly.use_smooth = True
        assign_material(glow_rim, mats['Engines'])
        hull_parts.append(glow_rim)

        bpy.ops.mesh.primitive_cylinder_add(vertices=36, radius=0.58, depth=0.08, location=(ex, ey, -5.85))
        core_disc = bpy.context.active_object
        assign_material(core_disc, mats['Engines'])
        hull_parts.append(core_disc)

        bpy.ops.mesh.primitive_cone_add(vertices=32, radius1=0.32, radius2=0.05, depth=0.60, location=(ex, ey, -6.15))
        core_cone = bpy.context.active_object
        core_cone.rotation_euler = (math.radians(180.0), 0.0, 0.0)
        bpy.ops.object.transform_apply(rotation=True, scale=True)
        for poly in core_cone.data.polygons:
            poly.use_smooth = True
        assign_material(core_cone, mats['Engines'])
        hull_parts.append(core_cone)

    # -----------------------------------------------------------------------
    # 10. CENTRAL INTER-ENGINE TAIL BOX & GREEBLES (Z = -4.20 to -6.05)
    # -----------------------------------------------------------------------
    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, 0.02, -5.15))
    tail_box = bpy.context.active_object
    tail_box.scale = (0.64, 0.72, 1.78)
    bpy.ops.object.transform_apply(scale=True)
    add_bevel_and_smooth(tail_box, 0.025, 2)
    assign_material(tail_box, mats['Weapons'])
    hull_parts.append(tail_box)

    for gz in (-1.0, -1.8, -2.6, -3.4, -4.2):
        bpy.ops.mesh.primitive_cube_add(size=1.0, location=(0.0, -1.11, gz + 0.9))
        v_plate = bpy.context.active_object
        v_plate.scale = (1.15, 0.05, 0.68)
        bpy.ops.object.transform_apply(scale=True)
        add_bevel_and_smooth(v_plate, 0.012, 2)
        assign_material(v_plate, mats['Metal'] if gz in (-1.8, -3.4) else mats['Paint'])
        hull_parts.append(v_plate)

    for sx in (-1.0, 1.0):
        for py_off in (-0.52, -0.68):
            bpy.ops.mesh.primitive_cylinder_add(vertices=14, radius=0.026, depth=3.6, location=(sx * 0.92, py_off, -2.2))
            pipe = bpy.context.active_object
            for poly in pipe.data.polygons:
                poly.use_smooth = True
            assign_material(pipe, mats['Metal'])
            hull_parts.append(pipe)

    # -----------------------------------------------------------------------
    # 11. JOIN ALL HULL PARTS INTO ONE NAMED MESH NODE: "Hull"
    # -----------------------------------------------------------------------
    bpy.ops.object.select_all(action='DESELECT')
    for obj in hull_parts:
        obj.select_set(True)
    for obj in list(bpy.context.selected_objects):
        bpy.context.view_layer.objects.active = obj
        for mod in list(obj.modifiers):
            try:
                bpy.ops.object.modifier_apply(modifier=mod.name)
            except Exception:
                pass
    bpy.context.view_layer.objects.active = hull_parts[0]
    bpy.ops.object.join()
    hull = bpy.context.active_object
    hull.name = "Hull"
    hull.data.name = "Hull"
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)

    # -----------------------------------------------------------------------
    # 12. MOVING NODE: "Canopy" (Bubble Canopy + Frame, Origin ON Rear Hinge)
    # -----------------------------------------------------------------------
    canopy_hinge = Vector((0.0, 0.90, 0.52))
    canopy_parts = []

    def canopy_arch_pts(w_half, y_base, y_peak, n_pts=28):
        pts = []
        for i in range(n_pts + 1):
            t = i / n_pts
            ang = t * math.pi
            x = w_half * math.cos(ang)
            y = y_base + (y_peak - y_base) * (math.sin(ang) ** 0.88)
            pts.append((x, y))
        for i in range(n_pts + 1):
            t = 1.0 - (i / n_pts)
            ang = t * math.pi
            x = (w_half - 0.028) * math.cos(ang)
            y = y_base + max(0.02, (y_peak - y_base - 0.028)) * (math.sin(ang) ** 0.88)
            pts.append((x, y))
        return pts

    canopy_glass_stations = [
        (4.10, canopy_arch_pts(0.24, 0.44, 0.50, 24)),
        (3.55, canopy_arch_pts(0.38, 0.47, 0.78, 24)),
        (2.85, canopy_arch_pts(0.49, 0.50, 1.06, 24)),
        (2.15, canopy_arch_pts(0.54, 0.52, 1.24, 24)),
        (1.45, canopy_arch_pts(0.52, 0.54, 1.22, 24)),
        (0.85, canopy_arch_pts(0.45, 0.62, 1.08, 24)),
        (0.52, canopy_arch_pts(0.38, 0.72, 0.94, 24)),
    ]
    canopy_glass = make_lofted_mesh("Canopy_Glass_Part", canopy_glass_stations, mats['Glass'])
    canopy_parts.append(canopy_glass)

    def canopy_sill_pts(w_half, y_base, y_peak, n_pts=24):
        pts = []
        for i in range(n_pts + 1):
            t = i / n_pts
            ang = t * math.pi
            x = (w_half + 0.018) * math.cos(ang)
            y = y_base + (y_peak - y_base + 0.016) * (math.sin(ang) ** 0.88)
            pts.append((x, y))
        for i in range(n_pts + 1):
            t = 1.0 - (i / n_pts)
            ang = t * math.pi
            x = (w_half - 0.035) * math.cos(ang)
            y = y_base + max(0.01, (y_peak - y_base - 0.035)) * (math.sin(ang) ** 0.88)
            pts.append((x, y))
        return pts

    front_bow_stations = [
        (4.16, canopy_sill_pts(0.25, 0.42, 0.50, 22)),
        (3.88, canopy_sill_pts(0.32, 0.44, 0.64, 22)),
    ]
    canopy_parts.append(make_lofted_mesh("Canopy_Front_Bow", front_bow_stations, mats['Paint']))

    mid_bow_stations = [
        (2.74, canopy_sill_pts(0.50, 0.50, 1.10, 24)),
        (2.50, canopy_sill_pts(0.52, 0.51, 1.18, 24)),
    ]
    canopy_parts.append(make_lofted_mesh("Canopy_Mid_Bow", mid_bow_stations, mats['Paint']))

    rear_bow_stations = [
        (0.84, canopy_sill_pts(0.45, 0.62, 1.09, 22)),
        (0.46, canopy_sill_pts(0.38, 0.72, 0.94, 22)),
    ]
    canopy_parts.append(make_lofted_mesh("Canopy_Rear_Bow", rear_bow_stations, mats['Paint']))

    for sx in (-1.0, 1.0):
        rail_stations = [
            (4.12, [(sx * 0.22, 0.48), (sx * 0.27, 0.48), (sx * 0.28, 0.40), (sx * 0.21, 0.40)]),
            (2.85, [(sx * 0.46, 0.56), (sx * 0.52, 0.56), (sx * 0.53, 0.45), (sx * 0.45, 0.45)]),
            (1.65, [(sx * 0.50, 0.58), (sx * 0.56, 0.58), (sx * 0.57, 0.47), (sx * 0.49, 0.47)]),
            (0.50, [(sx * 0.36, 0.76), (sx * 0.41, 0.76), (sx * 0.42, 0.66), (sx * 0.35, 0.66)]),
        ]
        if sx < 0:
            rail_stations = [(z, list(reversed(pts))) for (z, pts) in rail_stations]
        canopy_parts.append(make_lofted_mesh(f"Canopy_Rail_{sx}", rail_stations, mats['Paint']))

    bpy.ops.object.select_all(action='DESELECT')
    for cp in canopy_parts:
        cp.select_set(True)
    bpy.context.view_layer.objects.active = canopy_parts[0]
    bpy.ops.object.join()
    canopy = bpy.context.active_object
    canopy.name = "Canopy"
    canopy.data.name = "Canopy"

    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
    for v in canopy.data.vertices:
        v.co -= canopy_hinge
    canopy.location = canopy_hinge

    # -----------------------------------------------------------------------
    # 13. NORMALIZE EXACT DIMENSIONS TO 11.0m (X) x 3.4m (Y) x 13.5m (Z)
    # -----------------------------------------------------------------------
    all_world_pts = []
    for obj in (hull, canopy):
        mw = obj.matrix_world
        for v in obj.data.vertices:
            all_world_pts.append(mw @ v.co)

    min_x = min(p.x for p in all_world_pts)
    max_x = max(p.x for p in all_world_pts)
    min_y = min(p.y for p in all_world_pts)
    max_y = max(p.y for p in all_world_pts)
    min_z = min(p.z for p in all_world_pts)
    max_z = max(p.z for p in all_world_pts)

    cur_dx = max_x - min_x
    cur_dy = max_y - min_y
    cur_dz = max_z - min_z
    cx = 0.5 * (min_x + max_x)
    cz = 0.5 * (min_z + max_z)

    sx_norm = 11.0 / cur_dx
    sy_norm = 3.4 / cur_dy
    sz_norm = 13.5 / cur_dz

    for v in hull.data.vertices:
        v.co.x = (v.co.x - cx) * sx_norm
        v.co.y = v.co.y * sy_norm
        v.co.z = (v.co.z - cz) * sz_norm
    hull.data.update()

    new_hinge = Vector((
        (canopy.location.x - cx) * sx_norm,
        canopy.location.y * sy_norm,
        (canopy.location.z - cz) * sz_norm,
    ))
    for v in canopy.data.vertices:
        v.co.x *= sx_norm
        v.co.y *= sy_norm
        v.co.z *= sz_norm
    canopy.location = new_hinge
    canopy.data.update()

    # Controlled 1-segment bevel pass on Hull to hit the 40k-90k triangle budget (~74,000 tris)
    bpy.ops.object.select_all(action='DESELECT')
    hull.select_set(True)
    bpy.context.view_layer.objects.active = hull
    bev_fine = hull.modifiers.new(name="FineBevel", type='BEVEL')
    bev_fine.width = 0.007
    bev_fine.segments = 1
    bev_fine.limit_method = 'ANGLE'
    bev_fine.angle_limit = math.radians(24.0)
    bpy.ops.object.modifier_apply(modifier=bev_fine.name)

    # -----------------------------------------------------------------------
    # 14. CREATE REQUIRED EMPTY NODES (No Mesh)
    # -----------------------------------------------------------------------
    empties_spec = [
        ("Thruster_1",   ( 1.15,  0.02, -6.72), None),
        ("Thruster_2",   (-1.15,  0.02, -6.72), None),
        ("Muzzle_1",     ( 5.32, -0.06,  0.58), hull),
        ("Muzzle_2",     (-5.32, -0.06,  0.58), hull),
        ("Hardpoint_1",  ( 2.75, -0.44, -1.85), None),
        ("Hardpoint_2",  (-2.75, -0.44, -1.85), None),
        ("Hardpoint_3",  ( 3.85, -0.45, -2.30), None),
        ("Hardpoint_4",  (-3.85, -0.45, -2.30), None),
        ("Seat_Pilot",   ( 0.00,  0.44,  1.78), None),
        ("Camera_Chase", ( 0.00,  3.80, -14.50), None),
        ("Light_Nav_L",  ( 5.50, -0.07, -3.10), None),
        ("Light_Nav_R",  (-5.50, -0.07, -3.10), None),
        ("Light_Beacon", ( 0.00,  2.25, -3.95), None),
    ]
    empties = []
    for ename, eloc, eparent in empties_spec:
        emp = bpy.data.objects.new(ename, None)
        emp.empty_display_type = 'PLAIN_AXES'
        emp.empty_display_size = 0.25
        emp.location = Vector(eloc)
        bpy.context.collection.objects.link(emp)
        if eparent is not None:
            emp.parent = eparent
        empties.append(emp)

    # Convert from glTF Y-up (X=left, Y=up, Z=forward) to Blender Z-up (X=left, -Y=forward, Z=up)
    def to_blender_zup(v):
        return Vector((v.x, -v.z, v.y))

    for v in hull.data.vertices:
        v.co = to_blender_zup(v.co)
    hull.data.update()

    canopy.location = to_blender_zup(canopy.location)
    for v in canopy.data.vertices:
        v.co = to_blender_zup(v.co)
    canopy.data.update()

    for emp in empties:
        emp.location = to_blender_zup(emp.location)

    for obj in (hull, canopy):
        bm = bmesh.new()
        bm.from_mesh(obj.data)
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
        bm.to_mesh(obj.data)
        bm.free()

    return hull, canopy, empties


# ---------------------------------------------------------------------------
# UV Unwrapping & Cycles Baking Pipeline (Game Weight: <=1024 Base/Nor, <=512 ORM)
# ---------------------------------------------------------------------------
def unwrap_meshes_for_baking(hull, canopy):
    """
    Unwrap Hull and Canopy using high-density cube_project (3.4m tile scale) so
    the 1024x1024 baked texture sets provide ~300 px/m crisp panel lines, rivets,
    and brushed aerospace plates across the entire 13.5m hull with zero UV distortion.
    """
    for obj in (hull, canopy):
        bpy.ops.object.select_all(action='DESELECT')
        obj.select_set(True)
        bpy.context.view_layer.objects.active = obj
        if not obj.data.uv_layers:
            obj.data.uv_layers.new(name="UVMap")
        bpy.ops.object.mode_set(mode='EDIT')
        bpy.ops.mesh.select_all(action='SELECT')
        bpy.ops.uv.cube_project(cube_size=3.4, correct_aspect=True, clip_to_bounds=False, scale_to_bounds=False)
        bpy.ops.object.mode_set(mode='OBJECT')


def bake_all_materials_cycles(hull, canopy, mats):
    scene = bpy.context.scene
    scene.cycles.samples = 16
    scene.render.bake.use_pass_direct = False
    scene.render.bake.use_pass_indirect = False
    scene.render.bake.use_pass_color = True
    scene.render.bake.margin = 8

    bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0.0, 0.0, 25.0))
    proxy = bpy.context.active_object
    proxy.name = "_BakePlate"

    baked_files = {}

    for mname, mat in mats.items():
        is_simple = mname in ('Glass', 'Engines', 'Lamps')
        res_col = 512 if is_simple else 1024
        res_orm = 512

        nt = mat.node_tree
        nodes, links = nt.nodes, nt.links
        bsdf = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED')
        out = next(n for n in nodes if n.type == 'OUTPUT_MATERIAL')

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

        # 1. Bake BaseColor (via Emit shader)
        img_bc = bpy.data.images.new(f"{mname}_BaseColor", width=res_col, height=res_col, alpha=False)
        img_bc.colorspace_settings.name = 'sRGB'
        tex_bc = nodes.new('ShaderNodeTexImage')
        tex_bc.image = img_bc
        for n in nodes:
            n.select = False
        tex_bc.select = True
        nodes.active = tex_bc

        emit_node = nodes.new('ShaderNodeEmission')
        if bsdf.inputs['Base Color'].is_linked:
            links.new(bsdf.inputs['Base Color'].links[0].from_socket, emit_node.inputs['Color'])
        else:
            emit_node.inputs['Color'].default_value = bsdf.inputs['Base Color'].default_value
        links.new(emit_node.outputs['Emission'], out.inputs['Surface'])

        bpy.ops.object.bake(type='EMIT')
        bc_path = os.path.join(BAKE_DIR, f"{mname}_BaseColor.jpg")
        img_bc.filepath_raw = bc_path
        img_bc.file_format = 'JPEG'
        img_bc.save()

        # 2. Bake ORM (R = AO, G = Roughness, B = Metallic) at 512x512
        img_orm = bpy.data.images.new(f"{mname}_ORM", width=res_orm, height=res_orm, alpha=False)
        img_orm.colorspace_settings.name = 'Non-Color'
        tex_orm = nodes.new('ShaderNodeTexImage')
        tex_orm.image = img_orm
        for n in nodes:
            n.select = False
        tex_orm.select = True
        nodes.active = tex_orm

        comb_orm = nodes.new('ShaderNodeCombineColor')
        if mname == 'Paint' and bsdf.inputs['Base Color'].is_linked:
            bw_ao = nodes.new('ShaderNodeRGBToBW')
            links.new(bsdf.inputs['Base Color'].links[0].from_socket, bw_ao.inputs['Color'])
            links.new(bw_ao.outputs['Val'], comb_orm.inputs['Red'])
        else:
            comb_orm.inputs['Red'].default_value = 0.94

        if bsdf.inputs['Roughness'].is_linked:
            links.new(bsdf.inputs['Roughness'].links[0].from_socket, comb_orm.inputs['Green'])
        else:
            comb_orm.inputs['Green'].default_value = bsdf.inputs['Roughness'].default_value

        if bsdf.inputs['Metallic'].is_linked:
            links.new(bsdf.inputs['Metallic'].links[0].from_socket, comb_orm.inputs['Blue'])
        else:
            comb_orm.inputs['Blue'].default_value = bsdf.inputs['Metallic'].default_value

        links.new(comb_orm.outputs['Color'], emit_node.inputs['Color'])
        bpy.ops.object.bake(type='EMIT')
        orm_path = os.path.join(BAKE_DIR, f"{mname}_ORM.png")
        img_orm.filepath_raw = orm_path
        img_orm.file_format = 'PNG'
        img_orm.save()

        # 3. Bake Normal Map at res_col x res_col
        links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
        img_nor = bpy.data.images.new(f"{mname}_Normal", width=res_col, height=res_col, alpha=False)
        img_nor.colorspace_settings.name = 'Non-Color'
        tex_nor = nodes.new('ShaderNodeTexImage')
        tex_nor.image = img_nor
        for n in nodes:
            n.select = False
        tex_nor.select = True
        nodes.active = tex_nor

        bpy.ops.object.bake(type='NORMAL')
        nor_path = os.path.join(BAKE_DIR, f"{mname}_Normal.png")
        img_nor.filepath_raw = nor_path
        img_nor.file_format = 'PNG'
        img_nor.save()

        baked_files[mname] = {
            "bc": bc_path,
            "orm": orm_path,
            "nor": nor_path,
        }

    bpy.data.meshes.remove(proxy.data, do_unlink=True)

    for img in list(bpy.data.images):
        bpy.data.images.remove(img)

    gltf_group = bpy.data.node_groups.new("glTF Material Output", 'ShaderNodeTree')
    gltf_group.interface.new_socket(name="Occlusion", in_out='INPUT', socket_type='NodeSocketFloat')

    paint_nodes_ref = {}
    for mname, mat in mats.items():
        nt = mat.node_tree
        nodes, links = nt.nodes, nt.links
        nodes.clear()

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

        img_bc = bpy.data.images.load(baked_files[mname]["bc"])
        img_bc.colorspace_settings.name = 'sRGB'
        img_bc.name = f"{mname}_BaseColor"

        img_orm = bpy.data.images.load(baked_files[mname]["orm"])
        img_orm.colorspace_settings.name = 'Non-Color'
        img_orm.name = f"{mname}_ORM"

        img_nor = bpy.data.images.load(baked_files[mname]["nor"])
        img_nor.colorspace_settings.name = 'Non-Color'
        img_nor.name = f"{mname}_Normal"

        tex_bc = nodes.new('ShaderNodeTexImage')
        tex_bc.image = img_bc

        tex_orm = nodes.new('ShaderNodeTexImage')
        tex_orm.image = img_orm

        tex_nor = nodes.new('ShaderNodeTexImage')
        tex_nor.image = img_nor

        if mname == 'Paint':
            mix_factor = nodes.new('ShaderNodeMix')
            mix_factor.name = "PaintFactorMix"
            mix_factor.data_type = 'RGBA'
            mix_factor.blend_type = 'MULTIPLY'
            mix_factor.inputs['Factor'].default_value = 1.0
            links.new(tex_bc.outputs['Color'], mix_factor.inputs['A'])
            mix_factor.inputs['B'].default_value = (0.75, 0.74, 0.70, 1.0)
            links.new(tex_bc.outputs['Color'], bsdf.inputs['Base Color'])
            paint_nodes_ref['mat'] = mat
            paint_nodes_ref['tex_bc'] = tex_bc
            paint_nodes_ref['mix_factor'] = mix_factor
            paint_nodes_ref['bsdf'] = bsdf
        else:
            links.new(tex_bc.outputs['Color'], bsdf.inputs['Base Color'])

        sep = nodes.new('ShaderNodeSeparateColor')
        links.new(tex_orm.outputs['Color'], sep.inputs['Color'])
        links.new(sep.outputs['Green'], bsdf.inputs['Roughness'])
        links.new(sep.outputs['Blue'], bsdf.inputs['Metallic'])

        gltf_node = nodes.new('ShaderNodeGroup')
        gltf_node.node_tree = gltf_group
        gltf_node.name = "glTF Material Output"
        links.new(sep.outputs['Red'], gltf_node.inputs['Occlusion'])

        nmap = nodes.new('ShaderNodeNormalMap')
        links.new(tex_nor.outputs['Color'], nmap.inputs['Color'])
        links.new(nmap.outputs['Normal'], bsdf.inputs['Normal'])

        if mname == 'Glass':
            mat.blend_method = 'BLEND'
            bsdf.inputs['Alpha'].default_value = 0.28
            bsdf.inputs['Roughness'].default_value = 0.04
        elif mname == 'Engines':
            links.new(tex_bc.outputs['Color'], bsdf.inputs['Emission Color'])
            bsdf.inputs['Emission Strength'].default_value = 2.8
        elif mname == 'Lamps':
            links.new(tex_bc.outputs['Color'], bsdf.inputs['Emission Color'])
            bsdf.inputs['Emission Strength'].default_value = 3.5

    return paint_nodes_ref


def patch_glb_paint_and_glass(glb_path):
    with open(glb_path, "rb") as f:
        data = f.read()

    magic, version, length = struct.unpack_from("<4sII", data, 0)
    assert magic == b"glTF", "Invalid GLB magic"

    chunk0_len, chunk0_type = struct.unpack_from("<II", data, 12)
    json_bytes = data[20 : 20 + chunk0_len]
    bin_rest = data[20 + chunk0_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.75, 0.74, 0.70, 1.0]
        elif mat.get("name") == "Glass":
            mat["alphaMode"] = "BLEND"
            mat["doubleSided"] = True

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

    total_len = 12 + 8 + len(new_json_bytes) + len(bin_rest)
    header = struct.pack("<4sII", b"glTF", version, total_len)
    chunk0_hdr = struct.pack("<II", len(new_json_bytes), chunk0_type)

    with open(glb_path, "wb") as f:
        f.write(header)
        f.write(chunk0_hdr)
        f.write(new_json_bytes)
        f.write(bin_rest)


def export_glb_and_nodes_json(hull, canopy, empties, mats):
    bpy.context.view_layer.update()
    all_objs = [hull, canopy] + empties

    def bl_to_gltf(v):
        return [round(float(v[0]), 5), round(float(v[2]), 5), round(float(-v[1]), 5)]

    world_verts_gltf = []
    for obj in (hull, canopy):
        mw = obj.matrix_world
        for v in obj.data.vertices:
            wv = mw @ v.co
            world_verts_gltf.append((wv.x, wv.z, -wv.y))

    min_pt = [round(min(p[i] for p in world_verts_gltf), 4) for i in range(3)]
    max_pt = [round(max(p[i] for p in world_verts_gltf), 4) for i in range(3)]
    size_pt = [round(max_pt[i] - min_pt[i], 4) for i in range(3)]

    tri_count = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in (hull, canopy))

    bpy.ops.object.select_all(action='DESELECT')
    for obj in all_objs:
        obj.select_set(True)
    bpy.context.view_layer.objects.active = hull

    glb_path = os.path.join(SCRIPT_DIR, "starfighter-opera.glb")
    bpy.ops.export_scene.gltf(
        filepath=glb_path,
        export_format='GLB',
        use_selection=True,
        export_yup=True,
        export_apply=True,
        export_texcoords=True,
        export_normals=True,
        export_materials='EXPORT',
        export_cameras=False,
        export_lights=False,
        export_image_format='AUTO',
    )
    patch_glb_paint_and_glass(glb_path)
    glb_bytes = os.path.getsize(glb_path)

    nodes_list = []
    for obj in all_objs:
        nodes_list.append({
            "name": obj.name,
            "type": obj.type,
            "world_position": bl_to_gltf(obj.matrix_world.translation),
            "parent": obj.parent.name if obj.parent else None,
        })

    paints_json = [
        {"name": p["name"], "color": p["color"], "linear_rgb": p["color"]}
        for p in PAINTS
    ]

    nodes_data = {
        "asset": "starfighter-opera",
        "units": "metres",
        "frame": {
            "forward": "+Z",
            "up": "+Y",
            "left": "+X",
        },
        "bounding_box": {
            "min": min_pt,
            "max": max_pt,
            "size": size_pt,
            "dimensions": size_pt,
        },
        "bbox": {
            "min": min_pt,
            "max": max_pt,
            "size": size_pt,
        },
        "triangles": tri_count,
        "materials": list(mats.keys()),
        "glb_bytes": glb_bytes,
        "nodes": nodes_list,
        "axes": {
            "Canopy": [1.0, 0.0, 0.0],
        },
        "paints": paints_json,
    }

    with open(os.path.join(SCRIPT_DIR, "nodes.json"), "w") as f:
        json.dump(nodes_data, f, indent=2)

    notes_lines = [
        "Original space-opera starfighter (13.5m x 11.0m x 3.4m): sleek arrowhead hull, bubble canopy with full cockpit, canards, swept wings with wingtip laser cannons, underwing missiles, dorsal fin, and twin glowing round engines.",
        f"Triangle count: {tri_count:,} triangles across 2 mesh nodes (Hull, Canopy) and 13 game-socket Empty nodes; GLB size {glb_bytes / (1024*1024):.2f} MB.",
        "Materials (8): Paint (neutral baked detail + [0.75, 0.74, 0.70] baseColorFactor), Metal, Scorched, Weapons, Cockpit, Glass (alpha BLEND), Engines (emissive cyan-blue), Lamps (emissive).",
        "Texture sizes (Game Weight): BaseColor and Normal maps baked at 1024x1024 px (512x512 px for simple emissive/glass), all ORM occlusion-roughness-metallic maps baked at 512x512 px.",
        "Poly Haven CC0 texture IDs used: metal_plate, metal_plate_02, rusty_metal_03, rubberized_track (https://polyhaven.com/license).",
        "Differences from reference sheet: no text or faction markings per contract; internal intake ducting and micro-cockpit switches simplified for game-weight triangle budget.",
    ]
    with open(os.path.join(SCRIPT_DIR, "NOTES.md"), "w") as f:
        f.write("\n".join(notes_lines) + "\n")

    print(f"EXPORTED starfighter-opera.glb: {tri_count:,} tris, {glb_bytes / 1048576:.2f} MiB, bbox={size_pt}")
    return tri_count, glb_bytes, size_pt


# ---------------------------------------------------------------------------
# Space Lighting, Camera & Renders (final.png, side.png, clay.png, paints.png)
# ---------------------------------------------------------------------------
def render_all_views(hull, canopy, mats, paint_nodes_ref):
    import numpy as np

    nt_p = paint_nodes_ref['mat'].node_tree
    nt_p.links.new(paint_nodes_ref['mix_factor'].outputs['Result'], paint_nodes_ref['bsdf'].inputs['Base Color'])
    paint_nodes_ref['mix_factor'].inputs['B'].default_value = (0.75, 0.74, 0.70, 1.0)

    scene = bpy.context.scene
    scene.cycles.samples = 48

    world = bpy.data.worlds.new("SpaceBackdrop")
    scene.world = world
    world.use_nodes = True
    nt = world.node_tree
    nodes, links = nt.nodes, nt.links
    nodes.clear()

    out = nodes.new('ShaderNodeOutputWorld')
    bg = nodes.new('ShaderNodeBackground')
    tc = nodes.new('ShaderNodeTexCoord')
    sep = nodes.new('ShaderNodeSeparateXYZ')
    ramp = nodes.new('ShaderNodeValToRGB')
    ramp.color_ramp.elements[0].position = 0.0
    ramp.color_ramp.elements[0].color = (0.003, 0.006, 0.014, 1.0)
    ramp.color_ramp.elements[1].position = 1.0
    ramp.color_ramp.elements[1].color = (0.016, 0.032, 0.068, 1.0)
    links.new(tc.outputs['Window'], sep.inputs['Vector'])
    links.new(sep.outputs['Y'], ramp.inputs['Fac'])
    links.new(ramp.outputs['Color'], bg.inputs['Color'])
    bg.inputs['Strength'].default_value = 1.0
    links.new(bg.outputs['Background'], out.inputs['Surface'])

    # In Blender Z-up space, ship nose is at -Y, tail is at +Y, left wing is at +X, up is +Z!
    def add_area_light(name, loc, target, energy, size, color):
        ld = bpy.data.lights.new(name, 'AREA')
        ld.energy = energy
        ld.shape = 'DISK'
        ld.size = size
        ld.color = color
        lo = bpy.data.objects.new(name, ld)
        scene.collection.objects.link(lo)
        lo.location = Vector(loc)
        lo.rotation_euler = (Vector(target) - lo.location).to_track_quat('-Z', 'Y').to_euler()
        return lo

    add_area_light("Key_UpperFrontLeft", (12.0, -14.0, 13.0), (0.0, 0.0, 0.0), 3200.0, 10.0, (1.0, 0.98, 0.94))
    add_area_light("Fill_CoolRight", (-14.0, -8.0, 5.0), (0.0, 0.0, 0.0), 1400.0, 12.0, (0.70, 0.84, 1.0))
    add_area_light("Rim_RearTop", (4.0, 14.0, 10.0), (0.0, 0.0, 0.0), 2200.0, 9.0, (0.82, 0.92, 1.0))
    add_area_light("Under_Fill", (0.0, -6.0, -10.0), (0.0, 0.0, 0.0), 650.0, 12.0, (0.65, 0.78, 0.95))

    # Add cyan-blue thruster glow point lights right at the twin engine nozzle exits (+Y = +6.75 in Blender Z-up)
    for sx in (-1.0, 1.0):
        pld = bpy.data.lights.new(f"NozzleGlow_{sx}", 'POINT')
        pld.energy = 140.0
        pld.color = (0.12, 0.68, 1.0)
        pld.shadow_soft_size = 0.45
        plo = bpy.data.objects.new(f"NozzleGlow_{sx}", pld)
        scene.collection.objects.link(plo)
        plo.location = Vector((sx * 1.15, 6.65, 0.02))

    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

    def set_camera(loc, target, ortho=False, ortho_scale=15.0, lens=45.0):
        cam_data.type = 'ORTHO' if ortho else 'PERSP'
        if ortho:
            cam_data.ortho_scale = ortho_scale
        else:
            cam_data.lens = lens
        cam_obj.location = Vector(loc)
        cam_obj.rotation_euler = (Vector(target) - cam_obj.location).to_track_quat('-Z', 'Y').to_euler()

    # 1. final.png: 1920x1080 Cycles render, 3/4 front view matching sheet.jpg bottom-left
    hero_loc = (11.2, -13.2, 6.2)
    hero_target = (0.0, 0.2, 0.15)
    scene.render.resolution_x = 1920
    scene.render.resolution_y = 1080
    scene.render.resolution_percentage = 100
    set_camera(hero_loc, hero_target, ortho=False, lens=46.0)
    scene.render.filepath = os.path.join(SCRIPT_DIR, "final.png")
    bpy.ops.render.render(write_still=True)

    # 2. side.png: orthographic side view matching sheet.jpg top-left (nose pointing left -> view from +X looking at 0)
    set_camera((20.0, 0.0, 0.45), (0.0, 0.0, 0.45), ortho=True, ortho_scale=15.2)
    scene.render.filepath = os.path.join(SCRIPT_DIR, "side.png")
    bpy.ops.render.render(write_still=True)

    # 3. Verify Moving Node ("Canopy") rotated 30 degrees in a test check render in /tmp
    canopy.rotation_euler = (math.radians(-30.0), 0.0, 0.0)
    set_camera(hero_loc, hero_target, ortho=False, lens=46.0)
    scene.cycles.samples = 16
    scene.render.filepath = "/tmp/canopy_open_test.png"
    bpy.ops.render.render(write_still=True)
    canopy.rotation_euler = (0.0, 0.0, 0.0)

    # 4. clay.png: same view as final, plain grey material
    scene.cycles.samples = 40
    clay_mat = bpy.data.materials.new("ClayGrey")
    clay_mat.use_nodes = True
    clay_bsdf = clay_mat.node_tree.nodes.get("Principled BSDF")
    clay_bsdf.inputs['Base Color'].default_value = (0.48, 0.49, 0.51, 1.0)
    clay_bsdf.inputs['Roughness'].default_value = 0.58
    clay_bsdf.inputs['Metallic'].default_value = 0.08

    saved_mats = {}
    for obj in (hull, canopy):
        saved_mats[obj.name] = (
            list(obj.data.materials),
            [p.material_index for p in obj.data.polygons],
        )
        obj.data.materials.clear()
        obj.data.materials.append(clay_mat)

    set_camera(hero_loc, hero_target, ortho=False, lens=46.0)
    scene.render.filepath = os.path.join(SCRIPT_DIR, "clay.png")
    bpy.ops.render.render(write_still=True)

    for obj in (hull, canopy):
        obj.data.materials.clear()
        for m in saved_mats[obj.name][0]:
            obj.data.materials.append(m)
        for p, mi in zip(obj.data.polygons, saved_mats[obj.name][1]):
            p.material_index = mi

    # 5. paints.png: 3x2 grid of the same 3/4 view in the 6 paints (1920x1080 -> 3 cols x 640, 2 rows x 540)
    tile_w, tile_h = 640, 540
    scene.render.resolution_x = tile_w
    scene.render.resolution_y = tile_h
    scene.cycles.samples = 20
    set_camera(hero_loc, hero_target, ortho=False, lens=46.0)

    tiles = []
    for idx, p in enumerate(PAINTS):
        r, g, b = p["color"]
        paint_nodes_ref['mix_factor'].inputs['B'].default_value = (r, g, b, 1.0)
        tile_path = f"/tmp/paint_tile_{idx}.png"
        scene.render.filepath = tile_path
        bpy.ops.render.render(write_still=True)
        im = bpy.data.images.load(tile_path, check_existing=False)
        arr = np.array(im.pixels[:], dtype=np.float32).reshape(tile_h, tile_w, 4)
        tiles.append(arr)
        bpy.data.images.remove(im)

    grid = np.zeros((tile_h * 2, tile_w * 3, 4), dtype=np.float32)
    for idx, arr in enumerate(tiles):
        row = 1 - (idx // 3)
        col = idx % 3
        grid[row * tile_h : (row + 1) * tile_h, col * tile_w : (col + 1) * tile_w] = arr

    grid_img = bpy.data.images.new("PaintsGrid", width=tile_w * 3, height=tile_h * 2, alpha=True)
    grid_img.pixels.foreach_set(grid.ravel())
    grid_img.filepath_raw = os.path.join(SCRIPT_DIR, "paints.png")
    grid_img.file_format = 'PNG'
    grid_img.save()

    paint_nodes_ref['mix_factor'].inputs['B'].default_value = (0.75, 0.74, 0.70, 1.0)


def main():
    clear_scene()
    configure_cycles_gpu(samples=48)
    ph_paths = ensure_polyhaven_textures()
    mats = create_rich_materials(ph_paths)
    hull, canopy, empties = build_starfighter(mats)
    unwrap_meshes_for_baking(hull, canopy)
    paint_nodes_ref = bake_all_materials_cycles(hull, canopy, mats)
    export_glb_and_nodes_json(hull, canopy, empties, mats)
    render_all_views(hull, canopy, mats, paint_nodes_ref)
    print("ALL DONE.")


if __name__ == "__main__":
    main()
