#!/usr/bin/env python3
"""Pirate Scrap Fighter (pirate-scrap-fighter).
Clean, deterministic Blender 5.2 build in metres from an empty scene.
Run:
  blender -b --python-exit-code 1 --python build_pirate-scrap-fighter.py

Dimensions: 12.0 m long (+Z forward), 9.0 m wide (+X left), 4.5 m high (+Y up).
Photographic inputs: CC0 Poly Haven textures (metal_plate, metal_plate_02,
rusty_metal_03, fabric_leather_01) mixed with procedural weathering nodes and
baked with Cycles to shared game-weight textures (<=1024 px base/normal, <=512 px ORM).
"""

import bpy
import bmesh
import math
import os
import json
import random
import struct
import subprocess
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np

ROOT = Path(__file__).resolve().parent
TEX = ROOT / 'textures'
TEX.mkdir(exist_ok=True)
QA = ROOT / 'qa'
QA.mkdir(exist_ok=True)

random.seed(42)

bpy.ops.wm.read_factory_settings(use_empty=True)
S = bpy.context.scene
S.unit_settings.system = 'METRIC'
S.unit_settings.scale_length = 1.0

S.render.engine = 'CYCLES'
S.cycles.samples = 32
S.cycles.use_denoising = True
S.cycles.max_bounces = 8
S.cycles.transmission_bounces = 6

prefs = bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type = 'OPTIX'
prefs.get_devices()
optix_found = False
for d in prefs.devices:
    if d.type == 'OPTIX':
        d.use = True
        optix_found = True
    else:
        d.use = False
if not optix_found:
    prefs.compute_device_type = 'CUDA'
    prefs.get_devices()
    for d in prefs.devices:
        d.use = (d.type in ('CUDA', 'OPTIX'))
S.cycles.device = 'GPU'

S.render.image_settings.file_format = 'PNG'
S.render.film_transparent = False
S.view_settings.view_transform = 'AgX'
S.view_settings.look = 'AgX - Medium High Contrast'
S.view_settings.exposure = 0.55
S.render.resolution_percentage = 100

M = {}
GROUPS = {
    'Hull': [],
    'Canopy': [],
    'Turret_1': [],
    'Gun_1': [],
}

PAINTS = [
    ('Rust red', [0.45, 0.12, 0.06]),
    ('Faded green', [0.16, 0.28, 0.14]),
    ('Dirty yellow', [0.55, 0.42, 0.11]),
    ('Pirate black', [0.06, 0.06, 0.07]),
    ('Patchwork grey', [0.28, 0.30, 0.32]),
    ('Toxic green', [0.18, 0.48, 0.09]),
]

PH_IDS = ['metal_plate', 'metal_plate_02', 'rusty_metal_03', 'fabric_leather_01']


def activate(o):
    bpy.ops.object.select_all(action='DESELECT')
    o.select_set(True)
    bpy.context.view_layer.objects.active = o


def make_material(name, color, metal=0.0, rough=0.5, emission=0.0, alpha=1.0):
    m = bpy.data.materials.new(name)
    m.use_nodes = True
    p = m.node_tree.nodes.get('Principled BSDF')
    p.inputs['Base Color'].default_value = (*color, alpha)
    p.inputs['Metallic'].default_value = metal
    p.inputs['Roughness'].default_value = rough
    if emission > 0:
        p.inputs['Emission Color'].default_value = (*color, 1.0)
        p.inputs['Emission Strength'].default_value = emission
    p.inputs['Alpha'].default_value = alpha
    if alpha < 1.0:
        m.surface_render_method = 'DITHERED'
        p.inputs['Transmission Weight'].default_value = 0.18
        p.inputs['IOR'].default_value = 1.48
    M[name] = m
    return m


# 8 materials (<= 9 budget)
make_material('Paint', (0.45, 0.12, 0.06), metal=0.22, rough=0.58)
make_material('Alloy', (0.46, 0.48, 0.51), metal=0.86, rough=0.35)
make_material('Scorched', (0.12, 0.11, 0.10), metal=0.78, rough=0.46)
make_material('RustPatch', (0.42, 0.28, 0.14), metal=0.48, rough=0.62)
make_material('Interior', (0.07, 0.08, 0.07), metal=0.10, rough=0.78)
make_material('Glass', (0.12, 0.20, 0.26), metal=0.18, rough=0.10, alpha=0.34)
make_material('Engines', (0.35, 0.68, 1.00), metal=0.15, rough=0.35, emission=2.4)
make_material('Lamps', (1.00, 0.25, 0.08), metal=0.20, rough=0.30, emission=2.2)


def download_polyhaven():
    for ph_id in PH_IDS:
        for mp in ['diff', 'nor_gl', 'arm']:
            path = TEX / f'{ph_id}_{mp}_1k.jpg'
            if not path.exists() or path.stat().st_size < 10000:
                url = f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{ph_id}_{mp}_1k.jpg'
                subprocess.run(
                    ['curl', '--fail', '--retry', '3', '-L', '-A', 'Mozilla/5.0', url, '-o', str(path)],
                    check=True,
                )
            assert path.stat().st_size > 10000, f'Missing texture {path}'


def uv_project(o, scale=2.4):
    me = o.data
    uv = me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers[0]
    for p in me.polygons:
        n = p.normal.normalized()
        ref = Vector((0, 1, 0)) if abs(n.y) < 0.9 else Vector((1, 0, 0))
        u_ax = n.cross(ref).normalized()
        v_ax = n.cross(u_ax).normalized()
        for li in p.loop_indices:
            co = me.vertices[me.loops[li].vertex_index].co
            uv.data[li].uv = (co.dot(u_ax) / scale, co.dot(v_ax) / scale)


def apply_bevel(o, w=0.012, segs=2, smooth_thresh=0.020):
    if not w or w <= 0:
        return
    activate(o)
    mod = o.modifiers.new('Bevel', 'BEVEL')
    mod.width = w
    mod.segments = segs
    mod.limit_method = 'ANGLE'
    mod.angle_limit = 0.42
    mod.use_clamp_overlap = True
    bpy.ops.object.modifier_apply(modifier=mod.name)
    for p in o.data.polygons:
        p.use_smooth = (p.area < smooth_thresh)
    try:
        wn = o.modifiers.new('WeightedNormal', 'WEIGHTED_NORMAL')
        wn.keep_sharp = True
        wn.weight = 50
        bpy.ops.object.modifier_apply(modifier=wn.name)
    except Exception:
        pass


def mesh_from_pydata(name, verts, faces, mat='Paint', group='Hull', bev=0.010, segs=2, uv_scale=2.4, smooth_all=False):
    me = bpy.data.meshes.new(name)
    me.from_pydata(verts, [], faces)
    me.update()
    o = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(o)
    me.materials.append(M[mat])
    bm = bmesh.new()
    bm.from_mesh(me)
    bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
    bm.to_mesh(me)
    bm.free()
    if bev > 0:
        apply_bevel(o, bev, segs)
    if smooth_all:
        for p in o.data.polygons:
            p.use_smooth = True
    uv_project(o, uv_scale)
    if group:
        GROUPS[group].append(o)
    return o


def planarize_pts(points, normal_hint=None):
    v = [Vector(p) for p in points]
    c = sum(v, Vector()) / len(v)
    n = Vector((0, 0, 0))
    for i in range(len(v)):
        p0 = v[i] - c
        p1 = v[(i + 1) % len(v)] - c
        n += p0.cross(p1)
    if n.length < 1e-6:
        n = Vector(normal_hint) if normal_hint else Vector((0, 0, 1))
    n.normalize()
    if normal_hint is not None and n.dot(Vector(normal_hint)) < 0:
        n = -n
        v = list(reversed(v))
    planar_v = [p - n * (p - c).dot(n) for p in v]
    return planar_v, n, c


def prism(name, points, depth=0.04, mat='Paint', group='Hull', bev=0.008, normal=None, segs=2):
    v, n, _ = planarize_pts(points, normal)
    l = len(v)
    verts = [tuple(p) for p in v] + [tuple(p - n * depth) for p in v]
    faces = [tuple(range(l)), tuple(reversed(range(l, 2 * l)))] + [
        (i, (i + 1) % l, (i + 1) % l + l, i + l) for i in range(l)
    ]
    return mesh_from_pydata(name, verts, faces, mat, group, bev, segs)


def box(name, loc, dims, mat='Paint', group='Hull', bev=0.012, rot=None, segs=2):
    x, y, z = [d / 2.0 for d in dims]
    verts = [
        (a * x, b * y, c * z)
        for a, b, c in [
            (-1, -1, -1), (-1, -1, 1), (-1, 1, -1), (-1, 1, 1),
            (1, -1, -1), (1, -1, 1), (1, 1, -1), (1, 1, 1),
        ]
    ]
    R = Matrix.Identity(3) if rot is None else rot
    verts = [tuple(R @ Vector(v) + Vector(loc)) for v in verts]
    faces = [
        (0, 4, 6, 2), (1, 3, 7, 5), (0, 1, 5, 4),
        (2, 6, 7, 3), (0, 2, 3, 1), (4, 5, 7, 6),
    ]
    return mesh_from_pydata(name, verts, faces, mat, group, bev, segs)


def tapered_box(name, loc, dims_bot, dims_top, height, mat='Paint', group='Hull', bev=0.012, rot=None, top_offset=(0, 0), segs=2):
    xb, yb = dims_bot[0] / 2.0, dims_bot[1] / 2.0
    xt, yt = dims_top[0] / 2.0, dims_top[1] / 2.0
    hz = height / 2.0
    ox, oy = top_offset
    verts = [
        (-xb, -yb, -hz), (-xt + ox, -yt + oy, hz),
        (-xb, yb, -hz), (-xt + ox, yt + oy, hz),
        (xb, -yb, -hz), (xt + ox, -yt + oy, hz),
        (xb, yb, -hz), (xt + ox, yt + oy, hz),
    ]
    R = Matrix.Identity(3) if rot is None else rot
    verts = [tuple(R @ Vector(v) + Vector(loc)) for v in verts]
    faces = [
        (0, 4, 6, 2), (1, 3, 7, 5), (0, 1, 5, 4),
        (2, 6, 7, 3), (0, 2, 3, 1), (4, 5, 7, 6),
    ]
    return mesh_from_pydata(name, verts, faces, mat, group, bev, segs)


def beam(name, a, b, width=0.04, depth=None, mat='Alloy', group='Hull', bev=0.005):
    a, b = Vector(a), Vector(b)
    v = b - a
    if v.length < 1e-5:
        return None
    rot = v.to_track_quat('Z', 'Y').to_matrix()
    return box(name, (a + b) / 2.0, (width, depth or width, v.length), mat, group, bev, rot, segs=1)


def cyl(name, loc, radius, depth, mat='Alloy', group='Hull', axis=(0, 0, 1), vertices=16, bev=0.004, r2=None):
    if r2 is not None:
        bpy.ops.mesh.primitive_cone_add(
            vertices=vertices, radius1=radius, radius2=r2, depth=depth, location=loc
        )
    else:
        bpy.ops.mesh.primitive_cylinder_add(
            vertices=vertices, radius=radius, depth=depth, location=loc
        )
    o = bpy.context.object
    o.name = name
    o.rotation_euler = Vector(axis).to_track_quat('Z', 'Y').to_euler()
    activate(o)
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
    o.data.materials.append(M[mat])
    if bev > 0:
        apply_bevel(o, bev, segs=1, smooth_thresh=0.5)
    for p in o.data.polygons:
        p.use_smooth = (len(p.vertices) == 4)
    uv_project(o, 0.8)
    if group:
        GROUPS[group].append(o)
    return o


def hollow_cyl(name, loc, r_out, r_in, depth, mat='Alloy', group='Hull', axis=(0, 1, 0), vertices=20, bev=0.004, r_out2=None, r_in2=None):
    ro1 = r_out
    ro2 = r_out2 if r_out2 is not None else r_out
    ri1 = r_in
    ri2 = r_in2 if r_in2 is not None else r_in
    hz = depth / 2.0
    R = Vector(axis).to_track_quat('Z', 'Y').to_matrix()
    C = Vector(loc)
    verts = []
    for r_val, z_val in [(ro1, -hz), (ro2, hz), (ri2, hz), (ri1, -hz)]:
        for k in range(vertices):
            ang = 2.0 * math.pi * k / vertices
            v_local = Vector((r_val * math.cos(ang), r_val * math.sin(ang), z_val))
            verts.append(tuple(C + R @ v_local))
    faces = []
    for ring_i in range(4):
        b0 = ring_i * vertices
        b1 = ((ring_i + 1) % 4) * vertices
        for k in range(vertices):
            kn = (k + 1) % vertices
            faces.append((b0 + k, b0 + kn, b1 + kn, b1 + k))
    o = mesh_from_pydata(name, verts, faces, mat, group, bev=bev, segs=1, uv_scale=0.9, smooth_all=True)
    return o


def bolt(pos, normal=(0, 0, 1), r=0.026, group='Hull', mat='Alloy'):
    p = Vector(pos)
    n = Vector(normal).normalized()
    c1 = p - n * 0.005
    c2 = p + n * 0.006
    c3 = p + n * 0.020
    up = Vector((0, 0, 1)) if abs(n.z) < 0.9 else Vector((1, 0, 0))
    u = n.cross(up).normalized()
    v = n.cross(u).normalized()
    verts = []
    for k in range(6):
        ang = k * math.pi / 3.0
        d = u * math.cos(ang) + v * math.sin(ang)
        verts.append(tuple(c1 + d * (r * 1.28)))
    for k in range(6):
        ang = k * math.pi / 3.0
        d = u * math.cos(ang) + v * math.sin(ang)
        verts.append(tuple(c2 + d * r))
    for k in range(6):
        ang = k * math.pi / 3.0
        d = u * math.cos(ang) + v * math.sin(ang)
        verts.append(tuple(c3 + d * (r * 0.88)))
    faces = []
    for k in range(6):
        kn = (k + 1) % 6
        faces.append((k, kn, 6 + kn, 6 + k))
        faces.append((6 + k, 6 + kn, 12 + kn, 12 + k))
    faces.append((12, 13, 14, 15, 16, 17))
    faces.append((5, 4, 3, 2, 1, 0))
    return mesh_from_pydata('Bolt', verts, faces, mat, group, bev=0, uv_scale=0.5)


def armor_plate(name, pts, thickness=0.044, mat='Alloy', group='Hull', bev=0.010, bolt_r=0.026, bolts_per_edge=2, seam=True, normal_override=None, lift=0.012):
    """Planarized bolted armor slab that hugs the underlying hull facet cleanly."""
    # Compute natural geometric normal of pts first; only use normal_override as a sign hint!
    v, n, center = planarize_pts(pts, normal_override)
    if seam:
        seam_pts = [tuple(p + (p - center).normalized() * 0.024 + n * (lift + thickness * 0.30)) for p in v]
        prism(name + '_seam', seam_pts, thickness * 0.60 + lift, 'Scorched', group, bev=0.004, normal=n, segs=1)
    top_pts = [tuple(p + n * (lift + thickness)) for p in v]
    o = prism(name, top_pts, thickness + lift * 0.5, mat, group, bev=bev, normal=n, segs=2)
    if bolt_r > 0:
        l = len(v)
        for i in range(l):
            p0 = v[i] + (center - v[i]).normalized() * 0.095 + n * (lift + thickness)
            p1 = v[(i + 1) % l] + (center - v[(i + 1) % l]).normalized() * 0.095 + n * (lift + thickness)
            b_mat = 'Scorched' if mat == 'Alloy' else 'Alloy'
            bolt(p0, n, bolt_r, group, b_mat)
            if bolts_per_edge >= 2 and (p1 - p0).length > 0.48:
                bolt((p0 + p1) * 0.5, n, bolt_r, group, b_mat)
    return o


def hull_quad_plate(name, j0, j1, k_idx, u0=0.06, u1=0.94, v0=0.06, v1=0.94, thickness=0.042, mat='Alloy', group='Hull', bev=0.010, bolt_r=0.026, bolts_per_edge=2, lift=0.014):
    """Interpolate a plate directly on the hull quad between RINGS[j0] and RINGS[j1] at edge k_idx..k_idx+1
    so its normal and corners match the hull facet 100% with zero tilt in front/side/top views."""
    r0 = RINGS[j0]
    r1 = RINGS[j1]
    k_next = (k_idx + 1) % len(r0)
    A = Vector(r0[k_idx])
    B = Vector(r0[k_next])
    C = Vector(r1[k_next])
    D = Vector(r1[k_idx])

    def bilerp(u, v):
        p_front = A * (1.0 - u) + B * u
        p_back = D * (1.0 - u) + C * u
        return tuple(p_front * (1.0 - v) + p_back * v)

    pts = [bilerp(u0, v0), bilerp(u1, v0), bilerp(u1, v1), bilerp(u0, v1)]
    mid = (A + B + C + D) * 0.25
    out_hint = Vector((mid.x, -0.15, mid.z if abs(mid.z) > 0.2 else 0.5))
    return armor_plate(name, pts, thickness=thickness, mat=mat, group=group, bev=bev, bolt_r=bolt_r, bolts_per_edge=bolts_per_edge, seam=True, normal_override=out_hint, lift=lift)


def catmull_rom(pts, num_segs=24):
    pts = [Vector(p) for p in pts]
    if len(pts) < 2:
        return pts
    ext = [pts[0] + (pts[0] - pts[1])] + pts + [pts[-1] + (pts[-1] - pts[-2])]
    out = []
    n_spans = len(pts) - 1
    for i in range(n_spans):
        p0, p1, p2, p3 = ext[i], ext[i + 1], ext[i + 2], ext[i + 3]
        steps = max(2, num_segs // n_spans)
        for s in range(steps):
            t = s / float(steps)
            t2 = t * t
            t3 = t2 * t
            pt = 0.5 * (
                (2.0 * p1)
                + (-p0 + p2) * t
                + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
                + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3
            )
            out.append(pt)
    out.append(pts[-1])
    return out


def tube(name, ctrl_pts, radius=0.056, mat='Scorched', group='Hull', segs=24, sides=10, clamps=True, clamp_mat='Alloy'):
    path = catmull_rom(ctrl_pts, segs)
    verts = []
    faces = []
    up = Vector((0, 0, 1))
    for i, pt in enumerate(path):
        if i == 0:
            tangent = (path[1] - path[0]).normalized()
        elif i == len(path) - 1:
            tangent = (path[-1] - path[-2]).normalized()
        else:
            tangent = (path[i + 1] - path[i - 1]).normalized()
        ref = up if abs(tangent.dot(up)) < 0.92 else Vector((1, 0, 0))
        u = tangent.cross(ref).normalized()
        v = tangent.cross(u).normalized()
        for k in range(sides):
            ang = 2.0 * math.pi * k / sides
            d = u * math.cos(ang) + v * math.sin(ang)
            verts.append(tuple(pt + d * radius))
    n_rings = len(path)
    for i in range(n_rings - 1):
        b0 = i * sides
        b1 = (i + 1) * sides
        for k in range(sides):
            kn = (k + 1) % sides
            faces.append((b0 + k, b0 + kn, b1 + kn, b1 + k))
    faces.append(tuple(reversed(range(sides))))
    faces.append(tuple(range((n_rings - 1) * sides, n_rings * sides)))
    o = mesh_from_pydata(name, verts, faces, mat, group, bev=0, uv_scale=0.6, smooth_all=True)
    if clamps and len(path) >= 4:
        clamp_indices = [0, len(path) // 3, (2 * len(path)) // 3, len(path) - 1]
        for ci in clamp_indices:
            pt = path[ci]
            t_idx = min(max(ci, 1), len(path) - 2)
            tang = (path[t_idx + 1] - path[t_idx - 1]).normalized()
            cyl(name + '_clamp', pt, radius * 1.40, radius * 1.45, clamp_mat, group, tang, vertices=10, bev=0.003)
    return o


# ==============================================================================
# 1. MAIN FUSELAGE HULL LOFT (Y from -4.85 behind radiator cavity to +4.45 aft)
# ==============================================================================

# (y, w_top_in, w_top_out, w_mid, w_belly, z_crown, z_shoulder, z_chine, z_lower, z_belly)
HULL_SECTIONS = [
    (-4.85, 0.14, 0.92, 1.06, 0.68,  0.48,  0.36, -0.06, -0.58, -0.78),
    (-3.55, 0.16, 1.12, 1.30, 0.82,  0.80,  0.62,  0.08, -0.72, -0.96),
    (-2.15, 0.18, 1.26, 1.46, 0.92,  1.16,  0.94,  0.22, -0.84, -1.12),
    (-0.85, 0.72, 1.32, 1.50, 0.98,  1.26,  1.04,  0.28, -0.88, -1.18),
    ( 0.55, 0.76, 1.36, 1.54, 1.00,  1.34,  1.10,  0.32, -0.88, -1.16),
    ( 2.15, 0.78, 1.38, 1.52, 0.94,  1.36,  1.12,  0.30, -0.80, -1.08),
    ( 3.55, 0.70, 1.22, 1.36, 0.80,  1.26,  1.02,  0.24, -0.68, -0.94),
    ( 4.45, 0.56, 1.00, 1.12, 0.64,  1.10,  0.88,  0.18, -0.50, -0.72),
]


def section_ring(s):
    y, wti, wto, wm, wb, zc, zsh, zch, zlo, zbe = s
    return [
        ( wti, y, zc),   # 0: port crown edge
        ( wto, y, zsh),  # 1: port upper shoulder chine
        ( wm,  y, zch),  # 2: port mid chine
        ( wm,  y, zlo),  # 3: port lower chine
        ( wb,  y, zbe),  # 4: port belly
        (-wb,  y, zbe),  # 5: stbd belly
        (-wm,  y, zlo),  # 6: stbd lower chine
        (-wm,  y, zch),  # 7: stbd mid chine
        (-wto, y, zsh),  # 8: stbd upper shoulder chine
        (-wti, y, zc),   # 9: stbd crown edge
    ]


RINGS = [section_ring(s) for s in HULL_SECTIONS]
hull_verts = [v for r in RINGS for v in r]
hull_faces = []
n_sec = len(RINGS)
n_rv = len(RINGS[0])
for j in range(n_sec - 1):
    b0 = j * n_rv
    b1 = (j + 1) * n_rv
    for k in range(n_rv):
        kn = (k + 1) % n_rv
        hull_faces.append((b0 + k, b0 + kn, b1 + kn, b1 + k))
hull_faces.append(tuple(reversed(range(n_rv))))
hull_faces.append(tuple(range((n_sec - 1) * n_rv, n_sec * n_rv)))

mesh_from_pydata('Main_Hull_Body', hull_verts, hull_faces, mat='Paint', group='Hull', bev=0.016, segs=2)

# Recessed dark centerline seam strip along the nose spine
for j in range(0, 2):
    y0 = HULL_SECTIONS[j][0] + 0.08
    y1 = HULL_SECTIONS[j + 1][0] - 0.08
    z0 = HULL_SECTIONS[j][5] + 0.008
    z1 = HULL_SECTIONS[j + 1][5] + 0.008
    pts = [(-0.06, y0, z0), (0.06, y0, z0), (0.06, y1, z1), (-0.06, y1, z1)]
    prism(f'Nose_Center_Seam_{j}', pts, 0.016, 'Scorched', 'Hull', bev=0.003, normal=(0, -0.22, 1))


# ==============================================================================
# 2. OPEN-MOUTH FRONT RAM BUMPER, DEEP RADIATOR GRILLE & 4 NOSE SPIKES
# ==============================================================================

box('Radiator_Backwall', (0.0, -4.88, -0.20), (1.56, 0.08, 0.90), 'Scorched', 'Hull', bev=0.006)
box('Radiator_Duct_Top', (0.0, -5.14, 0.22), (1.62, 0.56, 0.08), 'Scorched', 'Hull', bev=0.008)
box('Radiator_Duct_Bot', (0.0, -5.14, -0.60), (1.62, 0.56, 0.08), 'Scorched', 'Hull', bev=0.008)
for sign in [-1, 1]:
    box(f'Radiator_Duct_Side_{sign}', (sign * 0.77, -5.14, -0.19), (0.09, 0.56, 0.86), 'Scorched', 'Hull', bev=0.008)

for kz in range(6):
    zz = -0.49 + kz * 0.12
    box(f'Radiator_Grille_Slat_{kz}', (0.0, -5.22, zz), (1.46, 0.14, 0.045), 'Scorched', 'Hull', bev=0.005)
for xx in [-0.46, 0.0, 0.46]:
    box(f'Radiator_Strut_{xx}', (xx, -5.16, -0.19), (0.055, 0.18, 0.78), 'Alloy', 'Hull', bev=0.004)

armor_plate(
    'Nose_Upper_Ram_Brow',
    [(-0.86, -5.40, 0.24), (0.86, -5.40, 0.24), (0.92, -4.80, 0.46), (-0.92, -4.80, 0.46)],
    thickness=0.055, mat='Alloy', group='Hull', bev=0.012, bolt_r=0.026, normal_override=(0, -0.35, 0.94), lift=0.012
)
armor_plate(
    'Nose_Lower_Ram_Jaw',
    [(-0.86, -5.44, -0.54), (0.86, -5.44, -0.54), (0.78, -5.32, -0.82), (-0.78, -5.32, -0.82)],
    thickness=0.075, mat='Alloy', group='Hull', bev=0.014, bolt_r=0.026, normal_override=(0, -0.92, -0.38), lift=0.012
)

for sign in [-1, 1]:
    pts_cb = [
        (sign * 0.80, -5.42, 0.28),
        (sign * 1.04, -4.62, 0.38),
        (sign * 1.10, -4.52, -0.72),
        (sign * 0.78, -5.40, -0.80),
    ]
    if sign < 0:
        pts_cb = list(reversed(pts_cb))
    armor_plate(
        f'Nose_Cheek_Bumper_{sign}',
        pts_cb,
        thickness=0.055, mat='Alloy', group='Hull', bev=0.012, bolt_r=0.026,
        normal_override=(sign * 0.88, -0.45, 0.10), lift=0.012
    )

# Upper nose brow stub barrel / sensor tube (visible protruding at upper nose in SIDE VIEW of sheet.jpg!)
cyl('Nose_Brow_Sensor_Collar', (0.56, -4.88, 0.44), 0.10, 0.34, 'Scorched', 'Hull', (0, 1, 0), vertices=14, bev=0.004)
hollow_cyl(
    'Nose_Brow_Sensor_Barrel', (0.56, -5.18, 0.44),
    r_out=0.078, r_in=0.045, depth=0.46, mat='Alloy', group='Hull', axis=(0, 1, 0), vertices=14, bev=0.003
)

SPIKE_SPECS = [
    ( 0.30, -5.34, -0.64,  0.32, -6.00, -0.71, 0.095),
    (-0.30, -5.34, -0.64, -0.32, -6.00, -0.71, 0.095),
    ( 0.74, -5.30, -0.60,  0.86, -5.90, -0.67, 0.084),
    (-0.74, -5.30, -0.60, -0.86, -5.90, -0.67, 0.084),
    ( 0.88, -5.26, -0.40,  0.98, -5.68, -0.42, 0.064),
    (-0.88, -5.26, -0.40, -0.98, -5.68, -0.42, 0.064),
]
for idx, (xb, yb, zb, xt, yt, zt, rb) in enumerate(SPIKE_SPECS):
    p_base = Vector((xb, yb, zb))
    p_tip = Vector((xt, yt, zt))
    axis_v = p_tip - p_base
    length = axis_v.length
    mid = (p_base + p_tip) * 0.5
    cyl(f'Spike_Collar_{idx}', p_base + axis_v.normalized() * 0.05, rb * 1.35, 0.13, 'Scorched', 'Hull', axis_v, vertices=8, bev=0.005)
    cyl(f'Nose_Spike_{idx}', mid, rb, length, 'Alloy', 'Hull', axis_v, vertices=6, bev=0.003, r2=0.008)

for sign in [-1, 1]:
    box(f'Chin_Cleat_Tooth_{sign}', (sign * 0.28, -5.02, -0.94), (0.12, 0.36, 0.44), 'Scorched', 'Hull', bev=0.010)
    box(f'Chin_Cleat_Tip_{sign}', (sign * 0.28, -5.06, -1.14), (0.09, 0.24, 0.20), 'Alloy', 'Hull', bev=0.008)


# ==============================================================================
# 3. HULL-HUGGING MISMATCHED BOLTED ARMOUR PLATES ON NOSE & CHEEKS
#    Interpolated directly on the exact hull quads so every plate sits 100% flush!
# ==============================================================================

# Starboard (-X) front upper nose deck plate (quad k=8, j=0..1) + side cheek wrap (quad k=7, j=0..1)
hull_quad_plate('Scrap_Plate_Nose_R_FrontTop', 0, 1, 8, u0=0.05, u1=0.92, v0=0.08, v1=0.94, thickness=0.044, mat='Alloy', lift=0.012)
hull_quad_plate('Scrap_Plate_Nose_R_SideWrap', 0, 1, 7, u0=0.18, u1=0.95, v0=0.08, v1=0.94, thickness=0.044, mat='Alloy', lift=0.012)

# Starboard (-X) mid upper nose deck plate (quad k=8, j=1..2)
hull_quad_plate('Scrap_Plate_Nose_R_MidTop', 1, 2, 8, u0=0.08, u1=0.90, v0=0.14, v1=0.92, thickness=0.044, mat='Alloy', lift=0.012)

# Port (+X) mid upper nose deck plate (quad k=0, j=1..2) + forward corner patch (quad k=0, j=0..1)
hull_quad_plate('Scrap_Plate_Nose_L_MidTop', 1, 2, 0, u0=0.08, u1=0.92, v0=0.06, v1=0.92, thickness=0.046, mat='Alloy', lift=0.012)
hull_quad_plate('Scrap_Plate_Nose_L_TipPatch', 0, 1, 0, u0=0.42, u1=0.94, v0=0.10, v1=0.78, thickness=0.042, mat='Alloy', bolts_per_edge=1, lift=0.012)

# Port (+X) and Starboard (-X) upper cheek Alloy plates (quads k=1 and k=7, j=1..2)
hull_quad_plate('Scrap_Cheek_Upper_L', 1, 2, 1, u0=0.08, u1=0.88, v0=0.12, v1=0.94, thickness=0.044, mat='Alloy', lift=0.012)
hull_quad_plate('Scrap_Cheek_Upper_R', 1, 2, 7, u0=0.12, u1=0.92, v0=0.12, v1=0.94, thickness=0.044, mat='Alloy', lift=0.012)

for sign in [-1, 1]:
    hollow_cyl(
        f'Cheek_Port_Boss_{sign}', (sign * 1.35, -2.45, 0.56), 0.12, 0.06, 0.11,
        'Alloy', 'Hull', (sign, -0.15, 0.40), vertices=14, bev=0.004
    )

# Patched yellow-ochre / rusty scrap plates on mid-side cheeks (quads k=2 and k=6, j=1..2)
hull_quad_plate('Scrap_Cheek_RustPatch_L', 1, 2, 2, u0=0.08, u1=0.78, v0=0.10, v1=0.92, thickness=0.046, mat='RustPatch', lift=0.012)
hull_quad_plate('Scrap_Cheek_RustPatch_R', 1, 2, 6, u0=0.22, u1=0.92, v0=0.10, v1=0.92, thickness=0.046, mat='RustPatch', lift=0.012)

# Dark heat-scorched lower forward cheek plates with 4 vertical louvre slots (quads k=2 and k=6, j=0..1)
hull_quad_plate('Scrap_Cheek_LowerScorched_L', 0, 1, 2, u0=0.18, u1=0.94, v0=0.08, v1=0.95, thickness=0.044, mat='Scorched', lift=0.012)
hull_quad_plate('Scrap_Cheek_LowerScorched_R', 0, 1, 6, u0=0.06, u1=0.82, v0=0.08, v1=0.95, thickness=0.044, mat='Scorched', lift=0.012)

for sign in [-1, 1]:
    for kl in range(4):
        yy = -4.25 + kl * 0.22
        box(f'Cheek_Louvre_{sign}_{kl}', (sign * 1.24, yy, -0.48), (0.07, 0.09, 0.24), 'Alloy', 'Hull', bev=0.004)


# ==============================================================================
# 4. WELDED COCKPIT POD, OPEN-VIEW INTERIOR & MOVING CANOPY NODE
#    Canopy Hinge Pivot: (0.0, 0.42, 2.08) -> rotates about (-1, 0, 0) so +30 deg opens UP!
# ==============================================================================

for sign in [-1, 1]:
    beam(
        f'Cockpit_Sill_Rail_{sign}',
        (sign * 0.68, -2.08, 1.16),
        (sign * 0.80, -0.24, 1.24),
        0.18, 0.14, 'Paint', 'Hull', bev=0.012
    )
    beam(
        f'Cockpit_Weld_Collar_{sign}',
        (sign * 0.86, -2.08, 1.08),
        (sign * 0.98, 0.55, 1.20),
        0.09, 0.06, 'Scorched', 'Hull', bev=0.008
    )
    for kw in range(7):
        t = kw / 6.0
        wy = -1.95 + t * 2.40
        wz = 1.09 + t * 0.11
        wx = sign * (0.88 + t * 0.09)
        bolt((wx, wy, wz + 0.03), (sign * 0.7, 0, 0.7), 0.026, 'Hull', 'Alloy')

tapered_box(
    'Cockpit_Rear_Spine_Fairing',
    (0.0, 0.18, 1.66),
    dims_bot=(1.56, 0.92),
    dims_top=(1.10, 0.86),
    height=0.88,
    mat='Paint',
    group='Hull',
    bev=0.016,
)
box('Cockpit_Rear_Top_Strap', (0.0, 0.20, 2.11), (0.86, 0.66, 0.048), 'Alloy', 'Hull', bev=0.008)
for sx in [-0.36, 0.36]:
    for sy in [-0.04, 0.42]:
        bolt((sx, sy, 2.13), (0, 0, 1), 0.024, 'Hull', 'Scorched')
for sx in [-0.42, 0.42]:
    cyl(f'Canopy_Hinge_Knuckle_{sx}', (sx, 0.42, 2.08), 0.058, 0.18, 'Alloy', 'Hull', (1, 0, 0), vertices=14, bev=0.004)

# Cockpit Interior
box('Cockpit_Interior_Tub_Floor', (0.0, -0.88, 0.80), (1.16, 2.10, 0.08), 'Interior', 'Hull', bev=0.008)
box('Cockpit_Rear_Bulkhead', (0.0, -0.20, 1.48), (1.14, 0.10, 1.16), 'Interior', 'Hull', bev=0.010)
tapered_box(
    'Cockpit_Dashboard',
    (0.0, -1.62, 1.16),
    dims_bot=(1.10, 0.56),
    dims_top=(0.90, 0.44),
    height=0.46,
    mat='Interior',
    group='Hull',
    bev=0.010,
)
box('Cockpit_Glare_Shield', (0.0, -1.58, 1.40), (0.94, 0.48, 0.06), 'Scorched', 'Hull', bev=0.008)
for idx, (mx, mw, mh, u_val) in enumerate([(-0.26, 0.22, 0.15, 0.72), (0.0, 0.25, 0.17, 0.72), (0.26, 0.22, 0.15, 0.84)]):
    rot_mfd = Matrix.Rotation(math.radians(24), 3, 'X')
    scr = box(f'Cockpit_Console_MFD_{idx}', (mx, -1.36, 1.24), (mw, 0.03, mh), 'Lamps', 'Hull', bev=0.003, rot=rot_mfd)
    for d in scr.data.uv_layers[0].data:
        d.uv = (u_val, 0.5)
    box(f'Cockpit_Console_Bezel_{idx}', (mx, -1.37, 1.24), (mw + 0.04, 0.025, mh + 0.04), 'Alloy', 'Hull', bev=0.004, rot=rot_mfd)

box('Pilot_Seat_Pan', (0.0, -0.68, 0.90), (0.58, 0.56, 0.12), 'Interior', 'Hull', bev=0.012)
rot_seat = Matrix.Rotation(math.radians(-12), 3, 'X')
box('Pilot_Seat_Back', (0.0, -0.42, 1.32), (0.56, 0.12, 0.78), 'Interior', 'Hull', bev=0.014, rot=rot_seat)
box('Pilot_Seat_Headrest', (0.0, -0.32, 1.78), (0.38, 0.14, 0.26), 'Interior', 'Hull', bev=0.012, rot=rot_seat)
for sx in [-0.28, 0.28]:
    box(f'Pilot_Seat_Rail_{sx}', (sx, -0.38, 1.34), (0.055, 0.14, 0.86), 'Alloy', 'Hull', bev=0.005, rot=rot_seat)
    box(f'Cockpit_Side_Console_{sx}', (sx * 1.60, -0.95, 1.00), (0.20, 1.15, 0.24), 'Interior', 'Hull', bev=0.008)
cyl('Control_Stick_Shaft', (0.0, -1.12, 1.02), 0.024, 0.36, 'Alloy', 'Hull', (0, -0.25, 1), vertices=10, bev=0.002)
box('Control_Stick_Grip', (0.0, -1.16, 1.20), (0.065, 0.075, 0.14), 'Interior', 'Hull', bev=0.006)
box('Throttle_Quadrant', (-0.45, -1.08, 1.18), (0.09, 0.16, 0.10), 'Alloy', 'Hull', bev=0.005)

# MOVING CANOPY NODE
c_fl = (-0.58, -2.06, 1.20)
c_fr = ( 0.58, -2.06, 1.20)
c_ml = (-0.50, -1.04, 2.02)
c_mr = ( 0.50, -1.04, 2.02)
c_rl = (-0.52, -0.26, 2.08)
c_rr = ( 0.52, -0.26, 2.08)
c_bl = (-0.74, -1.02, 1.18)
c_br = ( 0.74, -1.02, 1.18)
c_srl = (-0.74, -0.26, 1.24)
c_srr = ( 0.74, -0.26, 1.24)


def inset_quad(pts, amt=0.042):
    v = [Vector(p) for p in pts]
    c = sum(v, Vector()) / len(v)
    return [tuple(p + (c - p).normalized() * amt) for p in v]


ws_quad = [c_fl, c_fr, c_mr, c_ml]
prism('Canopy_Windscreen_Glass', inset_quad(ws_quad, 0.045), 0.016, 'Glass', 'Canopy', bev=0.003, normal=(0, -0.62, 0.78))
roof_quad = [c_ml, c_mr, c_rr, c_rl]
prism('Canopy_Roof_Glass', inset_quad(roof_quad, 0.045), 0.016, 'Glass', 'Canopy', bev=0.003, normal=(0, -0.05, 1.0))

for sign, (p_f, p_mt, p_mb, p_rt, p_rb) in [
    (-1, (c_fl, c_ml, c_bl, c_rl, c_srl)),
    ( 1, (c_fr, c_mr, c_br, c_rr, c_srr)),
]:
    q_fwd = [p_f, p_mb, p_mt] if sign > 0 else [p_f, p_mt, p_mb]
    q_aft = [p_mb, p_rb, p_rt, p_mt] if sign > 0 else [p_mb, p_mt, p_rt, p_rb]
    prism(f'Canopy_SideFwd_Glass_{sign}', inset_quad(q_fwd, 0.038), 0.016, 'Glass', 'Canopy', bev=0.003, normal=(sign, -0.2, 0.35))
    prism(f'Canopy_SideAft_Glass_{sign}', inset_quad(q_aft, 0.042), 0.016, 'Glass', 'Canopy', bev=0.003, normal=(sign, 0.0, 0.35))

CANOPY_BEAMS = [
    (c_fl, c_fr, 0.095, 0.075),
    (c_ml, c_mr, 0.105, 0.080),
    (c_rl, c_rr, 0.105, 0.080),
    (c_fl, c_ml, 0.095, 0.075),
    (c_fr, c_mr, 0.095, 0.075),
    (c_ml, c_rl, 0.090, 0.075),
    (c_mr, c_rr, 0.090, 0.075),
    (c_fl, c_bl, 0.090, 0.075),
    (c_fr, c_br, 0.090, 0.075),
    (c_bl, c_srl, 0.090, 0.075),
    (c_br, c_srr, 0.090, 0.075),
    (c_bl, c_ml, 0.085, 0.070),
    (c_br, c_mr, 0.085, 0.070),
    (c_srl, c_rl, 0.095, 0.075),
    (c_srr, c_rr, 0.095, 0.075),
    (c_rl, (-0.42, 0.42, 2.08), 0.085, 0.070),
    (c_rr, ( 0.42, 0.42, 2.08), 0.085, 0.070),
]
for idx, (a, b, w, d) in enumerate(CANOPY_BEAMS):
    beam(f'Canopy_Frame_Beam_{idx}', a, b, w, d, 'Alloy', 'Canopy', bev=0.009)


# ==============================================================================
# 5. SALVAGED ENGINE BLOCK (MAIN DORSAL BLOCK, Y = +0.55 to +4.45, Z up to +2.60)
# ==============================================================================

tapered_box(
    'Salvaged_Engine_Block_Main',
    (0.0, 1.95, 1.76),
    dims_bot=(2.32, 2.72),
    dims_top=(1.92, 2.48),
    height=1.36,
    mat='Alloy',
    group='Hull',
    bev=0.024,
)
box('Salvaged_Engine_Top_Well', (0.0, 1.85, 2.42), (1.52, 2.02, 0.08), 'Scorched', 'Hull', bev=0.010)
box('Salvaged_Engine_Top_Cover', (0.0, 1.85, 2.50), (1.32, 1.82, 0.14), 'Alloy', 'Hull', bev=0.016)
box('Salvaged_Engine_Top_VentCap', (0.0, 2.18, 2.57), (0.84, 0.68, 0.05), 'Scorched', 'Hull', bev=0.008)
for sx in [-0.56, 0.56]:
    for sy in [1.08, 1.85, 2.62]:
        bolt((sx, sy, 2.57), (0, 0, 1), 0.026, 'Hull', 'Scorched')

box('Salvaged_Engine_Front_Brow', (0.0, 0.66, 2.22), (1.62, 0.44, 0.36), 'Scorched', 'Hull', bev=0.014)

for sign in [-1, 1]:
    box(f'EngineBlock_Side_Recess_{sign}', (sign * 1.02, 1.65, 1.94), (0.18, 1.48, 0.58), 'Scorched', 'Hull', bev=0.012)
    for ky, yy in enumerate([1.30, 1.88]):
        cyl(f'EngineBlock_Valve_Base_{sign}_{ky}', (sign * 1.08, yy, 1.94), 0.24, 0.10, 'Scorched', 'Hull', (sign, 0, 0), vertices=16, bev=0.006)
        hollow_cyl(
            f'EngineBlock_Valve_Barrel_{sign}_{ky}', (sign * 1.20, yy, 1.94),
            r_out=0.19, r_in=0.11, depth=0.22, mat='Alloy', group='Hull', axis=(sign, 0, 0), vertices=16, bev=0.005
        )

    box(f'EngineBlock_Lower_Manifold_{sign}', (sign * 1.20, 1.92, 1.32), (0.38, 1.58, 0.52), 'Alloy', 'Hull', bev=0.014)
    for r_y in [1.35, 1.72, 2.10, 2.48]:
        box(f'EngineBlock_Manifold_Rib_{sign}_{r_y}', (sign * 1.40, r_y, 1.32), (0.06, 0.08, 0.44), 'Scorched', 'Hull', bev=0.004)
        bolt((sign * 1.39, r_y - 0.12, 1.48), (sign, 0, 0), 0.026, 'Hull', 'Scorched')
        bolt((sign * 1.39, r_y - 0.12, 1.16), (sign, 0, 0), 0.026, 'Hull', 'Scorched')

tapered_box(
    'Salvaged_Engine_Aft_Housing',
    (0.0, 3.78, 1.32),
    dims_bot=(2.06, 1.36),
    dims_top=(1.68, 1.26),
    height=1.32,
    mat='Scorched',
    group='Hull',
    bev=0.016,
)
for kr in range(6):
    ry = 3.28 + kr * 0.18
    box(f'Aft_Dorsal_Fin_{kr}', (0.0, ry, 2.02), (1.28, 0.08, 0.16), 'Alloy', 'Hull', bev=0.005)


# ==============================================================================
# 6. ONE BIG CENTRAL ENGINE (Thruster_1) & TWO SMALL AUXILIARY ENGINES (Thruster_2, 3)
#    Deep stepped dark throat + vivid non-blown-out blue-orange plasma ring
# ==============================================================================

def build_engine_nozzle(prefix, cx, y_start, y_end, cz, r_outer, n_petals=12, group='Hull'):
    length = y_end - y_start
    y_collar = y_start + length * 0.34
    y_mid = y_start + length * 0.56
    cyl(f'{prefix}_Collar', (cx, (y_start + y_collar) * 0.5, cz), r_outer * 1.02, y_collar - y_start, 'Alloy', group, (0, 1, 0), vertices=24, bev=0.008)
    cyl(f'{prefix}_Actuator_Ring', (cx, (y_collar + y_mid) * 0.5, cz), r_outer * 0.96, y_mid - y_collar, 'Scorched', group, (0, 1, 0), vertices=24, bev=0.006)
    for by in [y_start + 0.12, y_collar, y_mid]:
        cyl(f'{prefix}_Band_{by:.2f}', (cx, by, cz), r_outer * 1.06, 0.09, 'Alloy', group, (0, 1, 0), vertices=24, bev=0.004)
    hollow_cyl(
        f'{prefix}_Nozzle_Shroud',
        (cx, (y_mid + y_end) * 0.5, cz),
        r_out=r_outer * 0.96,
        r_in=r_outer * 0.72,
        depth=y_end - y_mid,
        mat='Scorched',
        group=group,
        axis=(0, 1, 0),
        vertices=24,
        bev=0.005,
        r_out2=r_outer * 0.85,
        r_in2=r_outer * 0.68,
    )
    for k in range(n_petals):
        ang = 2.0 * math.pi * k / n_petals
        dx = math.cos(ang)
        dz = math.sin(ang)
        p_a0 = (cx + dx * r_outer * 1.03, y_start + 0.16, cz + dz * r_outer * 1.03)
        p_a1 = (cx + dx * r_outer * 0.98, y_mid + 0.08, cz + dz * r_outer * 0.98)
        beam(f'{prefix}_Actuator_{k}', p_a0, p_a1, 0.048, 0.048, 'Alloy', group, bev=0.004)
        p_p0 = (cx + dx * r_outer * 0.99, y_mid - 0.02, cz + dz * r_outer * 0.99)
        p_p1 = (cx + dx * r_outer * 0.88, y_end - 0.02, cz + dz * r_outer * 0.88)
        tang_w = (2.0 * math.pi * r_outer / n_petals) * 0.76
        v_dir = Vector(p_p1) - Vector(p_p0)
        n_rad = Vector((dx, 0.12, dz)).normalized()
        t_vec = v_dir.normalized().cross(n_rad).normalized() * (tang_w * 0.5)
        pts_petal = [
            tuple(Vector(p_p0) - t_vec),
            tuple(Vector(p_p0) + t_vec),
            tuple(Vector(p_p1) + t_vec * 0.86),
            tuple(Vector(p_p1) - t_vec * 0.86),
        ]
        prism(f'{prefix}_Petal_{k}', pts_petal, 0.045, 'Scorched' if k % 2 == 0 else 'Alloy', group, bev=0.005, normal=n_rad)

    # Concentric dark flameholder ring inside the nozzle throat (matches REAR VIEW of sheet.jpg!)
    hollow_cyl(
        f'{prefix}_Flameholder_Ring',
        (cx, y_mid + 0.24, cz),
        r_out=r_outer * 0.56,
        r_in=r_outer * 0.44,
        depth=0.22,
        mat='Scorched',
        group=group,
        axis=(0, 1, 0),
        vertices=20,
        bev=0.004,
    )

    # Glowing blue-orange combustion throat recessed behind the flameholder ring
    glow = cyl(f'{prefix}_Glow_Core', (cx, y_mid + 0.08, cz), r_outer * 0.69, 0.10, 'Engines', group, (0, 1, 0), vertices=20, bev=0.002)
    for p in glow.data.polygons:
        for li in p.loop_indices:
            co = glow.data.vertices[glow.data.loops[li].vertex_index].co
            rad_t = min(1.0, math.hypot(co.x - cx, co.z - cz) / (r_outer * 0.69))
            glow.data.uv_layers[0].data[li].uv = (0.5, rad_t)

    liner = hollow_cyl(
        f'{prefix}_Glow_Liner',
        (cx, (y_mid + y_end) * 0.5 - 0.05, cz),
        r_out=r_outer * 0.71,
        r_in=r_outer * 0.66,
        depth=(y_end - y_mid) * 0.74,
        mat='Engines',
        group=group,
        axis=(0, 1, 0),
        vertices=20,
        bev=0.002,
    )
    for p in liner.data.polygons:
        for li in p.loop_indices:
            co = liner.data.vertices[liner.data.loops[li].vertex_index].co
            ax_t = min(1.0, max(0.0, (co.y - y_mid) / max(0.01, (y_end - y_mid))))
            liner.data.uv_layers[0].data[li].uv = (0.5, 0.20 + 0.75 * ax_t)

    # Central dark turbine cone + 10 radial stator vanes
    cyl(f'{prefix}_Turbine_Cone', (cx, y_mid + 0.28, cz), r_outer * 0.30, 0.44, 'Scorched', group, (0, 1, 0), vertices=16, bev=0.003, r2=0.06)
    for kv in range(10):
        vang = 2.0 * math.pi * kv / 10.0
        vx = cx + math.cos(vang) * r_outer * 0.68
        vz = cz + math.sin(vang) * r_outer * 0.68
        beam(f'{prefix}_Stator_Vane_{kv}', (cx, y_mid + 0.22, cz), (vx, y_mid + 0.26, vz), 0.052, 0.026, 'Scorched', group, bev=0.002)
    return glow


build_engine_nozzle('MainEngine', 0.0, 4.35, 6.00, 0.66, r_outer=0.95, n_petals=12, group='Hull')

for sign, idx in [(1, 2), (-1, 3)]:
    cx = sign * 1.78
    cz = -0.32
    build_engine_nozzle(f'AuxEngine_{idx}', cx, 4.25, 5.64, cz, r_outer=0.65, n_petals=10, group='Hull')
    cyl(f'AuxNacelle_Body_{idx}', (cx, 3.00, cz), 0.67, 2.50, 'Scorched', 'Hull', (0, 1, 0), vertices=20, bev=0.008)
    cyl(f'AuxNacelle_PaintedCowl_{idx}', (cx, 2.38, cz), 0.71, 1.32, 'Paint', 'Hull', (0, 1, 0), vertices=20, bev=0.010)
    for by in [1.78, 3.12, 3.92]:
        cyl(f'AuxNacelle_ClampRing_{idx}_{by}', (cx, by, cz), 0.73, 0.14, 'Alloy', 'Hull', (0, 1, 0), vertices=20, bev=0.005)
    box(f'AuxNacelle_VentralPod_{idx}', (cx - sign * 0.12, 3.65, -1.06), (0.76, 1.42, 0.38), 'Scorched', 'Hull', bev=0.012)

    icx = sign * 1.90
    icz = -0.36
    hollow_cyl(
        f'AuxIntake_HollowCowl_{idx}',
        (icx, 1.30, icz),
        r_out=0.65,
        r_in=0.49,
        depth=0.96,
        mat='Alloy',
        group='Hull',
        axis=(0, 1, 0),
        vertices=20,
        bev=0.008,
        r_out2=0.69,
        r_in2=0.50,
    )
    hollow_cyl(
        f'AuxIntake_FrontLip_{idx}',
        (icx, 0.82, icz),
        r_out=0.63,
        r_in=0.48,
        depth=0.14,
        mat='Scorched',
        group='Hull',
        axis=(0, 1, 0),
        vertices=20,
        bev=0.006,
    )
    cyl(f'AuxIntake_Backplate_{idx}', (icx, 1.28, icz), 0.50, 0.08, 'Scorched', 'Hull', (0, 1, 0), vertices=18, bev=0.002)
    cyl(f'AuxIntake_Spinner_{idx}', (icx, 1.02, icz), 0.18, 0.36, 'Alloy', 'Hull', (0, -1, 0), vertices=14, bev=0.003, r2=0.02)
    for kb in range(12):
        ang = 2.0 * math.pi * kb / 12.0
        bx = icx + math.cos(ang) * 0.47
        bz = icz + math.sin(ang) * 0.47
        beam(f'AuxIntake_Blade_{idx}_{kb}', (icx, 1.04, icz), (bx, 1.00, bz), 0.075, 0.024, 'Alloy', 'Hull', bev=0.002)


# ==============================================================================
# 7. STUBBY SWEPT ANHEDRAL WINGS & WING-ROOT SHOULDER BLOCKS (Span = 9.00 m)
# ==============================================================================

for sign in [-1, 1]:
    tapered_box(
        f'Wing_Root_Shoulder_Box_{sign}',
        (sign * 1.68, 2.22, 0.62),
        dims_bot=(1.24, 3.05),
        dims_top=(1.08, 2.85),
        height=0.46,
        mat='Alloy',
        group='Hull',
        bev=0.016,
    )
    armor_plate(
        f'Wing_Root_TopPlate_Fwd_{sign}',
        [
            (sign * 1.18, 0.78, 0.85),
            (sign * 2.16, 0.78, 0.82),
            (sign * 2.18, 2.15, 0.82),
            (sign * 1.18, 2.15, 0.85),
        ] if sign > 0 else [
            (sign * 1.18, 2.15, 0.85),
            (sign * 2.18, 2.15, 0.82),
            (sign * 2.16, 0.78, 0.82),
            (sign * 1.18, 0.78, 0.85),
        ],
        thickness=0.044, mat='Alloy', group='Hull', bev=0.011, bolt_r=0.026, normal_override=(0, 0, 1), lift=0.014
    )
    armor_plate(
        f'Wing_Root_TopPlate_Aft_{sign}',
        [
            (sign * 1.18, 2.28, 0.85),
            (sign * 2.20, 2.28, 0.82),
            (sign * 2.08, 3.68, 0.80),
            (sign * 1.12, 3.68, 0.84),
        ] if sign > 0 else [
            (sign * 1.12, 3.68, 0.84),
            (sign * 2.08, 3.68, 0.80),
            (sign * 2.20, 2.28, 0.82),
            (sign * 1.18, 2.28, 0.85),
        ],
        thickness=0.044, mat='Alloy', group='Hull', bev=0.011, bolt_r=0.026, normal_override=(0, 0, 1), lift=0.014
    )
    box(f'Wing_Root_CableManifold_{sign}', (sign * 1.64, 0.62, 0.62), (0.82, 0.30, 0.38), 'Scorched', 'Hull', bev=0.010)

    # Swept Anhedral Wing Panel
    x_in = sign * 1.55
    x_out = sign * 4.46
    z_in_top, z_in_bot = 0.54, 0.30
    z_out_top, z_out_bot = -0.32, -0.48
    w_verts = [
        (x_in, -0.05, z_in_top),
        (x_out, 1.52, z_out_top),
        (x_out, 2.68, z_out_top),
        (x_in,  3.02, z_in_top),
        (x_in,  0.02, z_in_bot),
        (x_out, 1.56, z_out_bot),
        (x_out, 2.64, z_out_bot),
        (x_in,  2.98, z_in_bot),
    ]
    if sign > 0:
        w_faces = [
            (0, 1, 2, 3), (7, 6, 5, 4),
            (0, 4, 5, 1), (1, 5, 6, 2), (2, 6, 7, 3), (3, 7, 4, 0),
        ]
    else:
        w_faces = [
            (3, 2, 1, 0), (4, 5, 6, 7),
            (1, 5, 4, 0), (2, 6, 5, 1), (3, 7, 6, 2), (0, 4, 7, 3),
        ]
    mesh_from_pydata(f'Wing_Main_Panel_{sign}', w_verts, w_faces, mat='Paint', group='Hull', bev=0.014, segs=2)

    def wing_z(x_abs):
        t = (x_abs - 1.55) / (4.46 - 1.55)
        return z_in_top * (1.0 - t) + z_out_top * t

    pts_wpatch = [
        (sign * 2.48, 0.74, wing_z(2.48)),
        (sign * 3.72, 1.28, wing_z(3.72)),
        (sign * 3.66, 2.72, wing_z(3.66)),
        (sign * 2.48, 2.86, wing_z(2.48)),
    ]
    if sign < 0:
        pts_wpatch = list(reversed(pts_wpatch))
    armor_plate(
        f'Wing_Mid_Armor_Patch_{sign}',
        pts_wpatch,
        thickness=0.044, mat='Alloy', group='Hull', bev=0.011, bolt_r=0.026, bolts_per_edge=2,
        normal_override=(sign * 0.28, 0.0, 0.96), lift=0.014
    )

    pts_wtip = [
        (sign * 4.16, 1.40, wing_z(4.16)),
        (sign * 4.50, 1.54, wing_z(4.46)),
        (sign * 4.48, 2.64, wing_z(4.46)),
        (sign * 4.14, 2.70, wing_z(4.14)),
    ]
    if sign < 0:
        pts_wtip = list(reversed(pts_wtip))
    armor_plate(
        f'Wing_Tip_Cap_{sign}',
        pts_wtip,
        thickness=0.14, mat='Alloy', group='Hull', bev=0.010, bolt_r=0.024, bolts_per_edge=1, seam=False,
        normal_override=(sign * 0.28, 0.0, 0.96), lift=0.016
    )

    pts_flap = [
        (sign * 2.55, 2.80, wing_z(2.55) - 0.02),
        (sign * 4.08, 2.66, wing_z(4.08) - 0.02),
        (sign * 4.08, 2.90, wing_z(4.08) - 0.06),
        (sign * 2.55, 3.04, wing_z(2.55) - 0.06),
    ]
    if sign < 0:
        pts_flap = list(reversed(pts_flap))
    prism(f'Wing_Trailing_Flap_{sign}', pts_flap, 0.06, 'Scorched', 'Hull', bev=0.006, normal=(0, 0, 1))

    box(f'Nav_Light_Housing_{sign}', (sign * 4.44, 1.96, -0.38), (0.12, 0.28, 0.10), 'Scorched', 'Hull', bev=0.006)
    nav_lens = box(f'Nav_Light_Lens_{sign}', (sign * 4.48, 1.96, -0.38), (0.06, 0.22, 0.07), 'Lamps', 'Hull', bev=0.004)
    u_nav = 0.15 if sign > 0 else 0.45
    for d in nav_lens.data.uv_layers[0].data:
        d.uv = (u_nav, 0.5)

    box(f'Wing_Hardpoint_Pylon_{sign}', (sign * 2.88, 1.72, -0.14), (0.16, 0.92, 0.34), 'Scorched', 'Hull', bev=0.008)
    box(f'Wing_Hardpoint_Rack_{sign}', (sign * 2.88, 1.72, -0.32), (0.22, 0.68, 0.08), 'Alloy', 'Hull', bev=0.006)

box('Beacon_Housing', (0.0, 1.85, 2.58), (0.18, 0.24, 0.05), 'Scorched', 'Hull', bev=0.005)
beacon_lens = box('Beacon_Lens', (0.0, 1.85, 2.61), (0.12, 0.16, 0.04), 'Lamps', 'Hull', bev=0.004)
for d in beacon_lens.data.uv_layers[0].data:
    d.uv = (0.92, 0.5)


# ==============================================================================
# 8. OVERSIZED CANNON ON PORT (+X) SIDE (Turret_1 + Gun_1 + Sponson)
# ==============================================================================

box('Cannon_Hull_Sponson_Box', (1.52, -0.95, -0.36), (0.68, 1.55, 0.68), 'RustPatch', 'Hull', bev=0.014)
box('Cannon_Sponson_ArmorCap', (1.72, -0.95, -0.36), (0.32, 1.32, 0.52), 'Scorched', 'Hull', bev=0.012)
cyl('Cannon_Ammo_Drum', (1.54, -0.22, -0.36), 0.32, 0.52, 'Alloy', 'Hull', (1, 0, 0), vertices=16, bev=0.008)
for ky in [-1.45, -1.05, -0.65]:
    bolt((1.88, ky, -0.18), (1, 0, 0), 0.026, 'Hull', 'Alloy')
    bolt((1.88, ky, -0.54), (1, 0, 0), 0.026, 'Hull', 'Alloy')

# TURRET_1 (Azimuth gimbal housing at (1.62, -1.85, -0.36))
cyl('Turret1_Azimuth_Ring', (1.62, -1.85, -0.36), 0.34, 0.62, 'Scorched', 'Turret_1', (0, 0, 1), vertices=16, bev=0.008)
box('Turret1_Gimbal_Yoke', (1.64, -1.96, -0.36), (0.54, 0.64, 0.56), 'Alloy', 'Turret_1', bev=0.012)
cyl('Turret1_Trunnion_Pin', (1.66, -2.05, -0.36), 0.14, 0.62, 'Scorched', 'Turret_1', (1, 0, 0), vertices=14, bev=0.005)

# GUN_1 (Massive oversized cannon barrel & breech assembly, child of Turret_1)
box('Gun1_Breech_Receiver', (1.66, -2.38, -0.34), (0.44, 0.96, 0.46), 'Scorched', 'Gun_1', bev=0.012)
box('Gun1_Breech_TopCover', (1.66, -2.42, -0.09), (0.34, 0.84, 0.10), 'Alloy', 'Gun_1', bev=0.008)
for kv in range(3):
    vy = -2.22 - kv * 0.18
    box(f'Gun1_Receiver_Slot_{kv}', (1.88, vy, -0.30), (0.06, 0.10, 0.16), 'Alloy', 'Gun_1', bev=0.003)

cyl('Gun1_Recoil_Upper', (1.66, -3.05, -0.12), 0.085, 1.15, 'Alloy', 'Gun_1', (0, 1, 0), vertices=12, bev=0.004)
cyl('Gun1_Recoil_Lower', (1.66, -3.05, -0.56), 0.085, 1.15, 'Alloy', 'Gun_1', (0, 1, 0), vertices=12, bev=0.004)

cyl('Gun1_Cooling_Jacket_Core', (1.66, -3.26, -0.36), 0.21, 0.92, 'Scorched', 'Gun_1', (0, 1, 0), vertices=18, bev=0.005)
for kr in range(7):
    ry = -2.86 - kr * 0.13
    cyl(f'Gun1_Cooling_Ring_{kr}', (1.66, ry, -0.36), 0.25, 0.055, 'Alloy' if kr % 2 == 0 else 'Scorched', 'Gun_1', (0, 1, 0), vertices=18, bev=0.003)

cyl('Gun1_Main_Barrel', (1.66, -4.42, -0.36), 0.165, 1.64, 'Alloy', 'Gun_1', (0, 1, 0), vertices=18, bev=0.005)
cyl('Gun1_Barrel_MidCollar', (1.66, -4.38, -0.36), 0.195, 0.24, 'Scorched', 'Gun_1', (0, 1, 0), vertices=18, bev=0.004)

hollow_cyl(
    'Gun1_Muzzle_Brake', (1.66, -5.34, -0.36),
    r_out=0.205, r_in=0.115, depth=0.48, mat='Scorched', group='Gun_1', axis=(0, 1, 0), vertices=18, bev=0.006
)
hollow_cyl(
    'Gun1_Muzzle_Rim', (1.66, -5.55, -0.36),
    r_out=0.19, r_in=0.115, depth=0.08, mat='Alloy', group='Gun_1', axis=(0, 1, 0), vertices=18, bev=0.004
)


# ==============================================================================
# 9. VENTRAL KEEL BLOCK, UNDERBELLY MACHINERY & COMPACT SKID CLEATS
# ==============================================================================

tapered_box(
    'Ventral_Keel_Main_Box',
    (0.0, -0.25, -1.36),
    dims_bot=(1.12, 2.55),
    dims_top=(1.48, 3.15),
    height=0.44,
    mat='Scorched',
    group='Hull',
    bev=0.016,
)
box('Ventral_Keel_Aft_Step', (0.0, 2.05, -1.24), (1.18, 1.85, 0.36), 'Alloy', 'Hull', bev=0.014)
box('Ventral_Keel_Fwd_Ramp', (0.0, -2.15, -1.18), (1.06, 1.45, 0.34), 'Scorched', 'Hull', bev=0.014)

for sign in [-1, 1]:
    cyl(f'Belly_Hydraulic_Tank_{sign}', (sign * 0.96, -1.05, -1.08), 0.17, 1.28, 'RustPatch', 'Hull', (0, 1, 0), vertices=14, bev=0.006)
    for ky in [-1.45, -0.65]:
        cyl(f'Belly_Tank_Clamp_{sign}_{ky}', (sign * 0.96, ky, -1.08), 0.20, 0.10, 'Alloy', 'Hull', (0, 1, 0), vertices=14, bev=0.003)

for sign in [-1, 1]:
    box(f'Ventral_Skid_Fin_{sign}', (sign * 0.34, 1.25, -1.64), (0.11, 1.45, 0.34), 'Scorched', 'Hull', bev=0.010)
    box(f'Ventral_Skid_Shoe_{sign}', (sign * 0.34, 1.25, -1.82), (0.15, 1.15, 0.08), 'Alloy', 'Hull', bev=0.006)


# ==============================================================================
# 10. EXPOSED CABLES, HYDRAULIC HOSES & TANGLED ENGINE PIPES
# ==============================================================================

for sign in [-1, 1]:
    tube(
        f'Cheek_Cable_Upper_{sign}',
        [
            (sign * 1.38, -3.35, 0.56),
            (sign * 1.56, -2.35, 0.64),
            (sign * 1.60, -1.15, 0.80),
            (sign * 1.64, -0.10, 0.78),
            (sign * 1.58,  0.58, 0.68),
        ],
        radius=0.056, mat='RustPatch', group='Hull', segs=22, sides=10, clamps=True
    )
    tube(
        f'Cheek_Cable_Mid_{sign}',
        [
            (sign * 1.46, -3.55, 0.34),
            (sign * 1.64, -2.45, 0.40),
            (sign * 1.70, -1.15, 0.58),
            (sign * 1.74, -0.05, 0.60),
            (sign * 1.76,  0.58, 0.58),
        ],
        radius=0.052, mat='Scorched', group='Hull', segs=22, sides=10, clamps=True
    )
    tube(
        f'Cheek_Cable_High_{sign}',
        [
            (sign * 1.30, -2.05, 0.98),
            (sign * 1.44, -0.85, 1.14),
            (sign * 1.46,  0.15, 1.36),
            (sign * 1.34,  1.05, 1.56),
        ],
        radius=0.050, mat='RustPatch', group='Hull', segs=18, sides=10, clamps=True
    )

for sign in [-1, 1]:
    tube(
        f'Belly_Hanging_Cable_Outer_{sign}',
        [
            (sign * 0.90, -4.65, -0.74),
            (sign * 1.04, -3.85, -1.48),
            (sign * 0.98, -2.95, -1.86),
            (sign * 0.96, -1.95, -1.44),
            (sign * 0.94, -1.15, -1.10),
        ],
        radius=0.054, mat='RustPatch', group='Hull', segs=24, sides=10, clamps=False
    )
    tube(
        f'Belly_Hanging_Cable_Inner_{sign}',
        [
            (sign * 0.68, -4.55, -0.78),
            (sign * 0.78, -3.65, -1.36),
            (sign * 0.74, -2.75, -1.68),
            (sign * 0.76, -1.85, -1.32),
            (sign * 0.78, -1.05, -1.12),
        ],
        radius=0.048, mat='Scorched', group='Hull', segs=22, sides=10, clamps=False
    )

for sign in [-1, 1]:
    tube(
        f'Rear_Engine_Pipe_Arch_{sign}',
        [
            (sign * 0.64, 2.72, 2.44),
            (sign * 0.86, 3.45, 2.26),
            (sign * 0.92, 4.15, 1.72),
            (sign * 0.72, 4.62, 1.20),
        ],
        radius=0.064, mat='Scorched', group='Hull', segs=20, sides=10, clamps=True
    )
    tube(
        f'Rear_Engine_Pipe_Mid_{sign}',
        [
            (sign * 1.32, 2.55, 1.44),
            (sign * 1.44, 3.35, 1.32),
            (sign * 1.24, 4.05, 1.04),
            (sign * 0.88, 4.58, 0.82),
        ],
        radius=0.058, mat='RustPatch', group='Hull', segs=20, sides=10, clamps=True
    )
    tube(
        f'Rear_Engine_Pipe_Low_{sign}',
        [
            (sign * 1.52, 2.85, 0.92),
            (sign * 1.48, 3.65, 0.64),
            (sign * 1.18, 4.25, 0.44),
            (sign * 0.82, 4.65, 0.42),
        ],
        radius=0.052, mat='Alloy', group='Hull', segs=18, sides=10, clamps=True
    )


# ==============================================================================
# 11. CONSOLIDATE NODE HIERARCHY, EXACT ENVELOPE FIT & EMPTIES
# ==============================================================================

PIVOTS = {
    'Hull': Vector((0.0, 0.0, 0.0)),
    'Canopy': Vector((0.0, 0.42, 2.08)),
    'Turret_1': Vector((1.62, -1.85, -0.36)),
    'Gun_1': Vector((1.66, -2.05, -0.36)),
}

# Moving axes in parent space (Blender coordinates):
# Canopy: (-1, 0, 0) -> +30 deg opens canopy UPWARD!
# Turret_1: (0, 0, 1) -> +30 deg traverses forward-pointing barrel OUTWARD (+X)!
# Gun_1: (-1, 0, 0) -> +30 deg elevates forward-pointing barrel UPWARD!
MOVING_BL = {
    'Canopy': (-1.0, 0.0, 0.0),
    'Turret_1': (0.0, 0.0, 1.0),
    'Gun_1': (-1.0, 0.0, 0.0),
}


def join_group(name):
    objs = GROUPS[name]
    bpy.ops.object.select_all(action='DESELECT')
    for o in objs:
        o.select_set(True)
    bpy.context.view_layer.objects.active = objs[0]
    bpy.ops.object.join()
    o = bpy.context.object
    o.name = name
    bm = bmesh.new()
    bm.from_mesh(o.data)
    bad = [f for f in bm.faces if f.calc_area() < 1e-9]
    if bad:
        bmesh.ops.delete(bm, geom=bad, context='FACES')
    bm.to_mesh(o.data)
    bm.free()
    o.data.update()
    return o


H = join_group('Hull')
C = join_group('Canopy')
T1 = join_group('Turret_1')
G1 = join_group('Gun_1')
MESH_OBJS = [H, C, T1, G1]

bpy.context.view_layer.update()
all_pts = [o.matrix_world @ v.co for o in MESH_OBJS for v in o.data.vertices]
lo = Vector([min(p[i] for p in all_pts) for i in range(3)])
hi = Vector([max(p[i] for p in all_pts) for i in range(3)])
center = (lo + hi) * 0.5
scale = Vector((
    9.0 / (hi.x - lo.x),
    12.0 / (hi.y - lo.y),
    4.5 / (hi.z - lo.z),
))


def map_pt(p):
    p = Vector(p)
    return Vector(((p.x - center.x) * scale.x, (p.y - center.y) * scale.y, (p.z - center.z) * scale.z))


for o in MESH_OBJS:
    for v in o.data.vertices:
        v.co = map_pt(v.co)
    o.data.update()

for k in PIVOTS:
    PIVOTS[k] = map_pt(PIVOTS[k])

H.location = (0.0, 0.0, 0.0)

C.data.transform(Matrix.Translation(-PIVOTS['Canopy']))
C.parent = H
C.location = PIVOTS['Canopy']

T1.data.transform(Matrix.Translation(-PIVOTS['Turret_1']))
T1.parent = H
T1.location = PIVOTS['Turret_1']

G1.data.transform(Matrix.Translation(-PIVOTS['Gun_1']))
G1.parent = T1
G1.location = PIVOTS['Gun_1'] - PIVOTS['Turret_1']

bpy.context.view_layer.update()


def make_empty(name, world_pos_raw, parent=H):
    wp = map_pt(world_pos_raw)
    o = bpy.data.objects.new(name, None)
    bpy.context.collection.objects.link(o)
    o.empty_display_size = 0.18
    o.parent = parent
    if parent == H:
        o.location = wp
    else:
        o.location = wp - parent.matrix_world.translation
    return o


make_empty('Thruster_1', (0.0, 5.98, 0.66), H)
make_empty('Thruster_2', (1.78, 5.62, -0.32), H)
make_empty('Thruster_3', (-1.78, 5.62, -0.32), H)
make_empty('Muzzle_1', (1.66, -5.58, -0.36), G1)
make_empty('Hardpoint_1', (2.88, 1.72, -0.36), H)
make_empty('Hardpoint_2', (-2.88, 1.72, -0.36), H)
make_empty('Seat_Pilot', (0.0, -0.65, 0.96), H)
make_empty('Camera_Chase', (0.0, 11.5, 4.2), H)
make_empty('Light_Nav_L', (4.48, 1.96, -0.38), H)
make_empty('Light_Nav_R', (-4.48, 1.96, -0.38), H)
make_empty('Light_Beacon', (0.0, 1.85, 2.61), H)

bpy.context.view_layer.update()


# ==============================================================================
# 12. CYCLES MATERIAL BAKING
# ==============================================================================

def bake_materials():
    download_polyhaven()
    ph_imgs = {}
    for ph_id in PH_IDS:
        ph_imgs[ph_id] = {}
        for mp in ['diff', 'nor_gl', 'arm']:
            im = bpy.data.images.load(str(TEX / f'{ph_id}_{mp}_1k.jpg'), check_existing=True)
            if mp != 'diff':
                im.colorspace_settings.name = 'Non-Color'
            ph_imgs[ph_id][mp] = im

    for ob in MESH_OBJS:
        ob.hide_render = True
        ob.hide_set(True)

    mat_ph = {
        'Paint': 'metal_plate_02',
        'Alloy': 'metal_plate_02',
        'Scorched': 'rusty_metal_03',
        'RustPatch': 'rusty_metal_03',
        'Interior': 'fabric_leather_01',
        'Glass': 'metal_plate_02',
        'Engines': 'metal_plate_02',
        'Lamps': 'metal_plate_02',
    }

    for name in list(M.keys()):
        m = M[name]
        nt = m.node_tree
        nt.nodes.clear()
        L = nt.links
        out = nt.nodes.new('ShaderNodeOutputMaterial')
        p = nt.nodes.new('ShaderNodeBsdfPrincipled')
        L.new(p.outputs['BSDF'], out.inputs['Surface'])
        uv = nt.nodes.new('ShaderNodeTexCoord')

        simple = name in ('Glass', 'Engines', 'Lamps')
        size = 64 if simple else (512 if name == 'Interior' else 1024)
        ph_set = ph_imgs[mat_ph[name]]
        rust_set = ph_imgs['rusty_metal_03']
        tread_set = ph_imgs['metal_plate']

        nodes = {}
        for mp in ('diff', 'nor_gl', 'arm'):
            n = nt.nodes.new('ShaderNodeTexImage')
            n.image = ph_set[mp]
            L.new(uv.outputs['UV'], n.inputs['Vector'])
            nodes[mp] = n

        r_diff = nt.nodes.new('ShaderNodeTexImage')
        r_diff.image = rust_set['diff']
        L.new(uv.outputs['UV'], r_diff.inputs['Vector'])

        t_diff = nt.nodes.new('ShaderNodeTexImage')
        t_diff.image = tread_set['diff']
        L.new(uv.outputs['UV'], t_diff.inputs['Vector'])

        bw = nt.nodes.new('ShaderNodeRGBToBW')
        L.new(nodes['diff'].outputs['Color'], bw.inputs[0])

        bw_rust = nt.nodes.new('ShaderNodeRGBToBW')
        L.new(r_diff.outputs['Color'], bw_rust.inputs[0])

        bw_tread = nt.nodes.new('ShaderNodeRGBToBW')
        L.new(t_diff.outputs['Color'], bw_tread.inputs[0])

        noise = nt.nodes.new('ShaderNodeTexNoise')
        noise.inputs['Scale'].default_value = 11.0
        noise.inputs['Detail'].default_value = 7.0
        L.new(uv.outputs['UV'], noise.inputs['Vector'])

        palettes = {
            'Paint': ((0.60, 0.60, 0.60, 1.0), (0.98, 0.98, 0.98, 1.0)),
            'Alloy': ((0.24, 0.26, 0.29, 1.0), (0.58, 0.61, 0.65, 1.0)),
            'Scorched': ((0.032, 0.030, 0.028, 1.0), (0.17, 0.13, 0.10, 1.0)),
            'RustPatch': ((0.18, 0.10, 0.05, 1.0), (0.52, 0.36, 0.18, 1.0)),
            'Interior': ((0.03, 0.035, 0.032, 1.0), (0.11, 0.12, 0.11, 1.0)),
        }

        if not simple:
            ramp = nt.nodes.new('ShaderNodeValToRGB')
            lo_c, hi_c = palettes[name]
            ramp.color_ramp.elements[0].position = 0.08
            ramp.color_ramp.elements[0].color = lo_c
            ramp.color_ramp.elements[1].position = 0.78
            ramp.color_ramp.elements[1].color = hi_c
            L.new(bw.outputs[0], ramp.inputs[0])

            grime_ramp = nt.nodes.new('ShaderNodeValToRGB')
            grime_ramp.color_ramp.elements[0].position = 0.22
            grime_ramp.color_ramp.elements[0].color = (
                (0.45, 0.45, 0.45, 1.0) if name == 'Paint' else (0.22, 0.10, 0.04, 1.0)
            )
            grime_ramp.color_ramp.elements[1].position = 0.76
            grime_ramp.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
            L.new(bw_rust.outputs[0], grime_ramp.inputs[0])

            mix1 = nt.nodes.new('ShaderNodeMixRGB')
            mix1.blend_type = 'MULTIPLY'
            mix1.inputs[0].default_value = 0.36 if name != 'RustPatch' else 0.58
            L.new(ramp.outputs[0], mix1.inputs[1])
            L.new(grime_ramp.outputs[0], mix1.inputs[2])

            mix_tread = nt.nodes.new('ShaderNodeMixRGB')
            mix_tread.blend_type = 'MULTIPLY'
            mix_tread.inputs[0].default_value = 0.08
            L.new(mix1.outputs[0], mix_tread.inputs[1])
            L.new(bw_tread.outputs[0], mix_tread.inputs[2])

            mix2 = nt.nodes.new('ShaderNodeMixRGB')
            mix2.blend_type = 'MULTIPLY'
            mix2.inputs[0].default_value = 0.16
            L.new(mix_tread.outputs[0], mix2.inputs[1])
            L.new(noise.outputs['Fac'], mix2.inputs[2])
            base_out = mix2.outputs[0]

            nm = nt.nodes.new('ShaderNodeNormalMap')
            nm.inputs['Strength'].default_value = 0.38 if name == 'Paint' else 0.55
            L.new(nodes['nor_gl'].outputs['Color'], nm.inputs['Color'])
            bump = nt.nodes.new('ShaderNodeBump')
            bump.inputs['Strength'].default_value = 0.15
            bump.inputs['Distance'].default_value = 0.014
            L.new(noise.outputs['Fac'], bump.inputs['Height'])
            L.new(nm.outputs[0], bump.inputs['Normal'])
            L.new(bump.outputs[0], p.inputs['Normal'])
        else:
            if name == 'Glass':
                rgb = nt.nodes.new('ShaderNodeRGB')
                rgb.outputs[0].default_value = (0.12, 0.20, 0.26, 1.0)
                base_out = rgb.outputs[0]
            elif name == 'Engines':
                uvsep = nt.nodes.new('ShaderNodeSeparateXYZ')
                L.new(uv.outputs['UV'], uvsep.inputs[0])
                eramp = nt.nodes.new('ShaderNodeValToRGB')
                eramp.color_ramp.elements[0].position = 0.12
                eramp.color_ramp.elements[0].color = (0.04, 0.38, 0.96, 1.0)
                e_mid = eramp.color_ramp.elements.new(0.50)
                e_mid.color = (0.14, 0.62, 1.00, 1.0)
                eramp.color_ramp.elements[2].position = 0.85
                eramp.color_ramp.elements[2].color = (0.96, 0.24, 0.02, 1.0)
                L.new(uvsep.outputs['Y'], eramp.inputs[0])
                base_out = eramp.outputs[0]
            elif name == 'Lamps':
                uvsep = nt.nodes.new('ShaderNodeSeparateXYZ')
                L.new(uv.outputs['UV'], uvsep.inputs[0])
                lramp = nt.nodes.new('ShaderNodeValToRGB')
                lramp.color_ramp.interpolation = 'CONSTANT'
                lramp.color_ramp.elements[0].position = 0.0
                lramp.color_ramp.elements[0].color = (0.95, 0.05, 0.02, 1.0)
                lramp.color_ramp.elements[1].position = 0.32
                lramp.color_ramp.elements[1].color = (0.04, 0.85, 0.14, 1.0)
                e1 = lramp.color_ramp.elements.new(0.60)
                e1.color = (0.08, 0.65, 0.88, 1.0)
                e2 = lramp.color_ramp.elements.new(0.80)
                e2.color = (0.95, 0.52, 0.08, 1.0)
                L.new(uvsep.outputs['X'], lramp.inputs[0])
                base_out = lramp.outputs[0]

        rough_val = {
            'Paint': 0.58, 'Alloy': 0.35, 'Scorched': 0.46, 'RustPatch': 0.62,
            'Interior': 0.76, 'Glass': 0.10, 'Engines': 0.85, 'Lamps': 0.30,
        }[name]
        metal_val = {
            'Paint': 0.22, 'Alloy': 0.86, 'Scorched': 0.78, 'RustPatch': 0.48,
            'Interior': 0.10, 'Glass': 0.18, 'Engines': 0.0, 'Lamps': 0.20,
        }[name]

        sep = nt.nodes.new('ShaderNodeSeparateColor')
        L.new(nodes['arm'].outputs['Color'], sep.inputs[0])
        roughmath = nt.nodes.new('ShaderNodeMath')
        roughmath.operation = 'MULTIPLY_ADD'
        roughmath.inputs[1].default_value = 0.22
        roughmath.inputs[2].default_value = rough_val - 0.10
        L.new(sep.outputs[1], roughmath.inputs[0])

        orm = nt.nodes.new('ShaderNodeCombineColor')
        orm.inputs[0].default_value = 1.0
        orm.inputs[1].default_value = rough_val
        orm.inputs[2].default_value = metal_val
        if not simple:
            ao = nt.nodes.new('ShaderNodeMath')
            ao.operation = 'MULTIPLY_ADD'
            ao.inputs[1].default_value = 0.28
            ao.inputs[2].default_value = 0.72
            L.new(sep.outputs[0], ao.inputs[0])
            L.new(ao.outputs[0], orm.inputs[0])
            L.new(roughmath.outputs[0], orm.inputs[1])

        bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, 25))
        sample = bpy.context.object
        sample.name = 'BakeTile'
        sample.data.materials.append(m)

        emit = nt.nodes.new('ShaderNodeEmission')
        emit.inputs['Strength'].default_value = 1.0
        baked = {}
        for kind in ('base', 'normal', 'orm'):
            res = min(size, 512) if kind == 'orm' else size
            im = bpy.data.images.new(f'{name}_{kind}', res, res, alpha=False)
            im.colorspace_settings.name = 'sRGB' if kind == 'base' else 'Non-Color'
            dst = nt.nodes.new('ShaderNodeTexImage')
            dst.image = im
            nt.nodes.active = dst
            for nd in nt.nodes:
                nd.select = False
            dst.select = True
            if kind == 'normal':
                L.new(p.outputs['BSDF'], out.inputs[0])
                btype = 'NORMAL'
            else:
                L.new(base_out if kind == 'base' else orm.outputs[0], emit.inputs[0])
                L.new(emit.outputs[0], out.inputs[0])
                btype = 'EMIT'
            activate(sample)
            S.cycles.samples = 12
            S.render.bake.margin = 8
            bpy.ops.object.bake(type=btype)
            if kind == 'base' and not simple:
                im.filepath_raw = str(TEX / f'{name}_{kind}.jpg')
                im.file_format = 'JPEG'
            else:
                im.filepath_raw = str(TEX / f'{name}_{kind}.png')
                im.file_format = 'PNG'
            im.save()
            baked[kind] = im

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

        nt.nodes.clear()
        out = nt.nodes.new('ShaderNodeOutputMaterial')
        p = nt.nodes.new('ShaderNodeBsdfPrincipled')
        L = nt.links
        L.new(p.outputs[0], out.inputs[0])
        ns = {}
        for kind, im in baked.items():
            n = nt.nodes.new('ShaderNodeTexImage')
            n.image = im
            ns[kind] = n
        if name != 'Engines':
            L.new(ns['base'].outputs['Color'], p.inputs['Base Color'])
        else:
            p.inputs['Base Color'].default_value = (0.01, 0.02, 0.04, 1.0)
        if name == 'Paint':
            tint = nt.nodes.new('ShaderNodeMixRGB')
            tint.name = 'Faction colour'
            tint.blend_type = 'MULTIPLY'
            tint.inputs[0].default_value = 1.0
            tint.inputs[2].default_value = (*PAINTS[0][1], 1.0)
            L.new(ns['base'].outputs[0], tint.inputs[1])
            L.new(tint.outputs[0], p.inputs['Base Color'])
        nm = nt.nodes.new('ShaderNodeNormalMap')
        L.new(ns['normal'].outputs[0], nm.inputs['Color'])
        L.new(nm.outputs[0], p.inputs['Normal'])
        sep = nt.nodes.new('ShaderNodeSeparateColor')
        L.new(ns['orm'].outputs[0], sep.inputs[0])
        L.new(sep.outputs[1], p.inputs['Roughness'])
        L.new(sep.outputs[2], p.inputs['Metallic'])

        g = bpy.data.node_groups.get('glTF Material Output')
        if not g:
            g = bpy.data.node_groups.new('glTF Material Output', 'ShaderNodeTree')
            g.interface.new_socket(name='Occlusion', in_out='INPUT', socket_type='NodeSocketFloat')
        occ = nt.nodes.new('ShaderNodeGroup')
        occ.node_tree = g
        L.new(sep.outputs[0], occ.inputs['Occlusion'])

        if name in ('Engines', 'Lamps'):
            L.new(ns['base'].outputs[0], p.inputs['Emission Color'])
            p.inputs['Emission Strength'].default_value = 2.4 if name == 'Engines' else 2.2
        if name == 'Glass':
            p.inputs['Alpha'].default_value = 0.34
            p.inputs['Transmission Weight'].default_value = 0.18
            p.inputs['IOR'].default_value = 1.48
            m.surface_render_method = 'DITHERED'
        print('BAKED', name, size, flush=True)

    for ob in MESH_OBJS:
        ob.hide_render = False
        ob.hide_set(False)


# ==============================================================================
# 13. SPACE STUDIO LIGHTING, RENDERS, GLB EXPORT & PIVOT VERIFICATION
# ==============================================================================

def setup_lighting():
    world = bpy.data.worlds.new('SpaceBackdrop')
    S.world = world
    world.use_nodes = True
    nt = world.node_tree
    nt.nodes.clear()
    L = nt.links
    out = nt.nodes.new('ShaderNodeOutputWorld')
    lp = nt.nodes.new('ShaderNodeLightPath')
    env = nt.nodes.new('ShaderNodeBackground')
    env.inputs['Color'].default_value = (0.22, 0.27, 0.36, 1.0)
    env.inputs['Strength'].default_value = 0.38

    bg = nt.nodes.new('ShaderNodeBackground')
    bg.inputs['Strength'].default_value = 1.0
    tc = nt.nodes.new('ShaderNodeTexCoord')
    sep = nt.nodes.new('ShaderNodeSeparateXYZ')
    L.new(tc.outputs['Window'], sep.inputs[0])
    ramp = nt.nodes.new('ShaderNodeValToRGB')
    ramp.color_ramp.elements[0].position = 0.0
    ramp.color_ramp.elements[0].color = (0.002, 0.004, 0.009, 1.0)
    ramp.color_ramp.elements[1].position = 1.0
    ramp.color_ramp.elements[1].color = (0.012, 0.024, 0.048, 1.0)
    L.new(sep.outputs['Y'], ramp.inputs[0])
    L.new(ramp.outputs[0], bg.inputs[0])

    mix = nt.nodes.new('ShaderNodeMixShader')
    L.new(lp.outputs['Is Camera Ray'], mix.inputs[0])
    L.new(env.outputs[0], mix.inputs[1])
    L.new(bg.outputs[0], mix.inputs[2])
    L.new(mix.outputs[0], out.inputs[0])

    def area_light(name, loc, power, color, size, target=(0, 0, 0)):
        d = bpy.data.lights.new(name, 'AREA')
        d.energy = power
        d.color = color
        d.shape = 'DISK'
        d.size = size
        o = bpy.data.objects.new(name, d)
        bpy.context.collection.objects.link(o)
        o.location = loc
        o.rotation_euler = (Vector(target) - o.location).to_track_quat('-Z', 'Y').to_euler()

    area_light('Key_UpperFrontLeft', (7.5, -10.0, 11.0), 2800, (1.0, 0.96, 0.91), 7.5)
    area_light('Fill_CoolRight', (-9.0, -6.5, 5.5), 1650, (0.60, 0.76, 1.0), 9.5)
    area_light('Rim_AftUpper', (-3.5, 10.5, 8.5), 2100, (0.80, 0.88, 1.0), 6.5)
    area_light('Bounce_Ventral', (2.0, -4.0, -8.0), 850, (0.55, 0.70, 1.0), 8.5)


def set_camera(loc, target=(0, 0, 0), ortho=None, lens=46):
    if not S.camera:
        d = bpy.data.cameras.new('RenderCamera')
        o = bpy.data.objects.new('RenderCamera', d)
        bpy.context.collection.objects.link(o)
        S.camera = o
    o = S.camera
    o.location = loc
    o.rotation_euler = (Vector(target) - o.location).to_track_quat('-Z', 'Y').to_euler()
    o.data.lens = lens
    if ortho:
        o.data.type = 'ORTHO'
        o.data.ortho_scale = ortho
    else:
        o.data.type = 'PERSP'
    return o


def render_img(path, w=1920, h=1080, samples=48):
    S.render.resolution_x = w
    S.render.resolution_y = h
    S.cycles.samples = samples
    S.render.filepath = str(path)
    bpy.ops.render.render(write_still=True)


setup_lighting()
HERO_CAM = (10.2, -12.6, 8.2)
HERO_TGT = (0.0, -0.15, -0.05)

set_camera(HERO_CAM, HERO_TGT, ortho=None, lens=46)
clay_mat = make_material('_QA_Clay', (0.22, 0.22, 0.23), metal=0.05, rough=0.65)
S.view_layers[0].material_override = clay_mat
render_img(ROOT / 'clay.png', 1920, 1080, 28)
S.view_layers[0].material_override = None
M.pop('_QA_Clay')

bake_materials()


def glv(v):
    return [round(float(v[0]), 6), round(float(v[2]), 6), round(float(-v[1]), 6)]


bpy.context.view_layer.update()
all_nodes = MESH_OBJS + [o for o in S.objects if o.type == 'EMPTY']
final_pts = [o.matrix_world @ v.co for o in MESH_OBJS for v in o.data.vertices]
lo = Vector([min(p[i] for p in final_pts) for i in range(3)])
hi = Vector([max(p[i] for p in final_pts) for i in range(3)])
tri_count = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in MESH_OBJS)

pnt = M['Paint'].node_tree
pp = pnt.nodes.get('Principled BSDF')
tint_node = pnt.nodes.get('Faction colour')
base_node = [n for n in pnt.nodes if n.type == 'TEX_IMAGE' and n.image.name == 'Paint_base'][0]
pnt.links.new(base_node.outputs[0], pp.inputs['Base Color'])

bpy.ops.object.select_all(action='DESELECT')
for o in all_nodes:
    o.select_set(True)
bpy.context.view_layer.objects.active = H
glb_path = ROOT / 'pirate-scrap-fighter.glb'
bpy.ops.export_scene.gltf(
    filepath=str(glb_path),
    export_format='GLB',
    use_selection=True,
    export_yup=True,
    export_apply=False,
    export_texcoords=True,
    export_normals=True,
    export_materials='EXPORT',
    export_cameras=False,
    export_lights=False,
    export_animations=False,
    export_image_format='AUTO',
)

b = glb_path.read_bytes()
jl = struct.unpack_from('<I', b, 12)[0]
g = json.loads(b[20 : 20 + jl])
rest = b[20 + jl :]
for m in g.get('materials', []):
    if m.get('name') == 'Paint':
        m.setdefault('pbrMetallicRoughness', {})['baseColorFactor'] = [0.45, 0.12, 0.06, 1.0]
    if m.get('name') == 'Glass':
        m['alphaMode'] = 'BLEND'
        m['doubleSided'] = True

axes_gltf = {k: glv(v) for k, v in MOVING_BL.items()}
for n in g.get('nodes', []):
    if n.get('name') in axes_gltf:
        n['extras'] = {'axis': axes_gltf[n['name']]}

j_bytes = json.dumps(g, separators=(',', ':')).encode()
j_bytes += b' ' * ((-len(j_bytes)) % 4)
glb_path.write_bytes(
    struct.pack('<4sII', b'glTF', 2, 20 + len(j_bytes) + len(rest))
    + struct.pack('<I4s', len(j_bytes), b'JSON')
    + j_bytes
    + rest
)

pnt.links.new(tint_node.outputs[0], pp.inputs['Base Color'])

print('EXPORTED', glb_path, 'TRIS:', tri_count, 'BYTES:', glb_path.stat().st_size, flush=True)
assert 40000 <= tri_count <= 90000, f'Triangle count {tri_count} outside 40k..90k budget'
assert glb_path.stat().st_size <= 12 * 1024 * 1024, f'GLB size {glb_path.stat().st_size} exceeds 12 MB'

pivot_test = {}
for o in (C, T1, G1):
    before = o.matrix_world.translation.copy()
    ax = Vector(MOVING_BL[o.name])
    o.rotation_mode = 'AXIS_ANGLE'
    o.rotation_axis_angle = (math.radians(30.0), *ax)
    bpy.context.view_layer.update()
    err = (o.matrix_world.translation - before).length
    pivot_test[o.name] = {'degrees': 30, 'pivot_displacement_metres': round(err, 9)}
    assert err < 1e-6, f'Pivot displacement on {o.name}: {err}'
    o.rotation_axis_angle = (0.0, 1.0, 0.0, 0.0)
    bpy.context.view_layer.update()

# 30-degree articulation check render (Canopy opens UP 30 deg, Turret swings OUT 30 deg, Gun elevates UP 30 deg)
C.rotation_axis_angle = (math.radians(30.0), *MOVING_BL['Canopy'])
T1.rotation_axis_angle = (math.radians(30.0), *MOVING_BL['Turret_1'])
G1.rotation_axis_angle = (math.radians(30.0), *MOVING_BL['Gun_1'])
bpy.context.view_layer.update()
set_camera(HERO_CAM, HERO_TGT, ortho=None, lens=46)
render_img(QA / 'articulation_30deg.png', 1280, 720, 20)
for o in (C, T1, G1):
    o.rotation_axis_angle = (0.0, 1.0, 0.0, 0.0)
bpy.context.view_layer.update()

meta = {
    'asset': 'pirate-scrap-fighter',
    'units': 'metres',
    'forward': '+Z',
    'up': '+Y',
    'left': '+X',
    'origin': 'Centre of mass at hull midpoint (0, 0, 0)',
    'bounding_box': {
        'min': [round(float(lo.x), 4), round(float(lo.z), 4), round(float(-hi.y), 4)],
        'max': [round(float(hi.x), 4), round(float(hi.z), 4), round(float(-lo.y), 4)],
        'dimensions': [
            round(float(hi.x - lo.x), 4),
            round(float(hi.z - lo.z), 4),
            round(float(hi.y - lo.y), 4),
        ],
    },
    'triangles': tri_count,
    'materials': list(M.keys()),
    'material_count': len(M),
    'file_bytes': glb_path.stat().st_size,
    'nodes': {
        o.name: {
            'position': glv(o.matrix_world.translation),
            'parent': o.parent.name if o.parent else None,
            'type': 'mesh' if o.type == 'MESH' else 'empty',
        }
        for o in all_nodes
    },
    'axes': axes_gltf,
    'paints': [{'name': n, 'linear_rgb': c} for n, c in PAINTS],
    'pivot_test': pivot_test,
}
(ROOT / 'nodes.json').write_text(json.dumps(meta, indent=2) + '\n')

set_camera(HERO_CAM, HERO_TGT, ortho=None, lens=46)
render_img(ROOT / 'final.png', 1920, 1080, 64)

set_camera((-18.0, 0.0, 0.0), (0.0, 0.0, 0.0), ortho=13.5)
render_img(ROOT / 'side.png', 1920, 1080, 40)

set_camera((0.0, 0.0, 18.0), (0.0, 0.0, 0.0), ortho=22.5)
render_img(QA / 'top.png', 1280, 720, 24)
set_camera((0.0, -18.0, 0.0), (0.0, 0.0, 0.0), ortho=10.5)
render_img(QA / 'front.png', 1280, 720, 24)
set_camera((0.0, 18.0, 0.0), (0.0, 0.0, 0.0), ortho=10.5)
render_img(QA / 'rear.png', 1280, 720, 24)

set_camera(HERO_CAM, HERO_TGT, ortho=None, lens=46)
paint_tiles = []
for idx, (pname, pcol) in enumerate(PAINTS):
    tint_node.inputs[2].default_value = (*pcol, 1.0)
    tile_path = QA / f'paint_{idx}.png'
    render_img(tile_path, 640, 360, 20)
    im = bpy.data.images.load(str(tile_path), check_existing=False)
    arr = np.array(im.pixels[:], dtype=np.float32).reshape(360, 640, 4)
    paint_tiles.append(arr)

grid_canvas = np.concatenate(
    [np.concatenate(paint_tiles[3:6], axis=1), np.concatenate(paint_tiles[0:3], axis=1)],
    axis=0,
)
im_grid = bpy.data.images.new('PaintsGrid', 1920, 720, alpha=True)
im_grid.pixels.foreach_set(grid_canvas.ravel())
im_grid.filepath_raw = str(ROOT / 'paints.png')
im_grid.file_format = 'PNG'
im_grid.save()
tint_node.inputs[2].default_value = (*PAINTS[0][1], 1.0)

notes_lines = [
    'Junk-built 12.0 x 9.0 x 4.5 m space-pirate scrap fighter with welded cockpit pod, salvaged dorsal engine block, bolted mismatched armour plates, port oversized cannon, nose ram spikes, exposed cables, and 1 main + 2 auxiliary engines.',
    f'{tri_count:,} triangles across 4 mesh nodes (Hull, Canopy, Turret_1, Gun_1) and 11 empty sockets; Y up, +Z nose, +X left; 30-degree pivot tests passed.',
    '8 materials: Paint (neutral baked texture with default baseColorFactor [0.45, 0.12, 0.06]), Alloy, Scorched, RustPatch, Interior, Glass (BLEND), Engines, Lamps.',
    f'Cycles-baked shared tiling textures: base colour and normal maps <=1024 px, packed ORM maps <=512 px, optical/emissive maps 64 px; GLB {glb_path.stat().st_size / 1048576:.2f} MiB.',
    'Poly Haven CC0 textures used: metal_plate, metal_plate_02, rusty_metal_03, fabric_leather_01 (diff, nor_gl, arm 1k jpg; https://polyhaven.com/license).',
    'Differences from reference: original procedural asymmetric scrap plate layout, hinged cockpit canopy with full interior, and articulated side cannon turret/barrel mount.',
]
(ROOT / 'NOTES.md').write_text('\n'.join(notes_lines) + '\n')
print('BUILD_COMPLETE', flush=True)
