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

# ==============================================================================
# CONFIGURATION & PATHS
# ==============================================================================
ROOT = Path(__file__).resolve().parent
TEX = Path('/tmp/lifeboat_pod_tex')
OUT = Path('/tmp/lifeboat_pod_baked')
TEX.mkdir(parents=True, exist_ok=True)
OUT.mkdir(parents=True, exist_ok=True)

random.seed(42)

# Clean scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.meshes):
    bpy.data.meshes.remove(d)
for d in list(bpy.data.materials):
    bpy.data.materials.remove(d)
for d in list(bpy.data.images):
    bpy.data.images.remove(d)

S = bpy.context.scene
S.unit_settings.system = 'METRIC'
S.unit_settings.scale_length = 1.0
S.render.engine = 'CYCLES'
S.cycles.samples = 48
S.cycles.use_denoising = True

P = bpy.context.preferences.addons['cycles'].preferences
P.compute_device_type = 'OPTIX'
P.get_devices()
for d in P.devices:
    d.use = (d.type != 'CPU')
S.cycles.device = 'GPU'
S.cycles.max_bounces = 6
S.cycles.transparent_max_bounces = 8
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.render.resolution_percentage = 100

GROUPS = {}
MATS = {}
ASSET = []
PIVOTS = {}

# Paint contract: default factor = [0.85, 0.35, 0.05] (rescue orange) first
PAINTS = [
    ("Rescue orange", [0.85, 0.35, 0.05]),
    ("Safety yellow", [0.85, 0.68, 0.04]),
    ("Signal red", [0.78, 0.08, 0.06]),
    ("Deep rescue teal", [0.06, 0.48, 0.52]),
    ("Pale ice blue", [0.42, 0.62, 0.82]),
    ("Charcoal grey", [0.16, 0.18, 0.20]),
]

# ==============================================================================
# COORDINATE CONVERSION HELPERS
# Ship Frame (glTF target):
#   +X = Left side
#   +Y = Up
#   +Z = Nose (forward), -Z = Rear (exhaust)
# Blender Internal Frame (Z-up, exported with export_yup=True):
#   x_b = x_ship
#   y_b = -z_ship
#   z_b = y_ship
# ==============================================================================
def s2b(x, y=None, z=None):
    if y is None:
        x, y, z = x[0], x[1], x[2]
    return Vector((float(x), float(-z), float(y)))

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

# ==============================================================================
# MATERIALS INITIALIZATION
# ==============================================================================
def make_mat(name, col, metal=0.0, rough=0.45):
    m = bpy.data.materials.new(name)
    m.use_nodes = True
    p = m.node_tree.nodes.get('Principled BSDF')
    p.inputs['Base Color'].default_value = (*col, 1.0)
    p.inputs['Metallic'].default_value = metal
    p.inputs['Roughness'].default_value = rough
    m.diffuse_color = (*col, 1.0)
    MATS[name] = m
    return m

mat_paint = make_mat('Paint', PAINTS[0][1], 0.12, 0.38)
mat_white = make_mat('Hull_White', (0.86, 0.88, 0.90), 0.10, 0.36)
mat_metal = make_mat('Bare_Metal', (0.52, 0.55, 0.59), 0.88, 0.28)
mat_scorch = make_mat('Scorched_Metal', (0.32, 0.30, 0.28), 0.85, 0.36)
mat_dark = make_mat('Dark_Interior', (0.05, 0.06, 0.08), 0.50, 0.55)

mat_glass = make_mat('Glass', (0.14, 0.32, 0.44), 0.08, 0.05)
p_glass = mat_glass.node_tree.nodes.get('Principled BSDF')
p_glass.inputs['Alpha'].default_value = 0.34
p_glass.inputs['Transmission Weight'].default_value = 0.0
if hasattr(mat_glass, 'surface_render_method'):
    mat_glass.surface_render_method = 'BLENDED'

mat_engines = make_mat('Engines', (0.25, 0.75, 1.0), 0.10, 0.20)
p_eng = mat_engines.node_tree.nodes.get('Principled BSDF')
p_eng.inputs['Emission Color'].default_value = (0.35, 0.85, 1.0, 1.0)
p_eng.inputs['Emission Strength'].default_value = 8.5

mat_lamps = make_mat('Lamps', (0.95, 0.42, 0.05), 0.1, 0.20)
lamp_im = bpy.data.images.new('Lamp_palette', width=16, height=4)
# 4 zones across U: [0..0.25]=Red, [0.25..0.5]=Green, [0.5..0.75]=Cyan/White cabin, [0.75..1.0]=Amber beacon
lamp_cols = [
    (1.0, 0.03, 0.01, 1.0),
    (0.02, 1.0, 0.12, 1.0),
    (0.38, 0.84, 1.0, 1.0),
    (1.0, 0.36, 0.02, 1.0),
]
lamp_im.pixels = [v for _y in range(4) for x in range(16) for v in lamp_cols[x // 4]]
lamp_im.filepath_raw = str(OUT / 'Lamps.png')
lamp_im.file_format = 'PNG'
lamp_im.save()
nt_l = mat_lamps.node_tree
tex_l = nt_l.nodes.new('ShaderNodeTexImage')
tex_l.image = lamp_im
tex_l.interpolation = 'Closest'
p_lamps = nt_l.nodes.get('Principled BSDF')
nt_l.links.new(tex_l.outputs['Color'], p_lamps.inputs['Base Color'])
nt_l.links.new(tex_l.outputs['Color'], p_lamps.inputs['Emission Color'])
p_lamps.inputs['Emission Strength'].default_value = 4.5

# ==============================================================================
# MESH PRIMITIVE HELPERS (SHIP FRAME INPUT -> BLENDER FRAME MESH)
# ==============================================================================
def assign_box_uv(me, scale=2.5):
    uv = me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
    for f in me.polygons:
        no = f.normal
        axis = max(range(3), key=lambda i: abs(no[i]))
        axes = [i for i in range(3) if i != axis]
        for li in f.loop_indices:
            co = me.vertices[me.loops[li].vertex_index].co
            uv.data[li].uv = ((co[axes[0]] + 4.5) / scale, (co[axes[1]] + 4.5) / scale)

def create_mesh_obj(name, verts_b, faces, mat=mat_paint, group='Hull', bevel=0.0, segments=1, smooth=False):
    me = bpy.data.meshes.new(name)
    me.from_pydata(verts_b, [], faces)
    me.update()
    ob = bpy.data.objects.new(name, me)
    S.collection.objects.link(ob)
    me.materials.append(mat)
    bm = bmesh.new()
    bm.from_mesh(me)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(me)
    bm.free()
    if bevel > 0:
        mod = ob.modifiers.new('Bevel', 'BEVEL')
        mod.width = bevel
        mod.segments = segments
        mod.limit_method = 'ANGLE'
        mod.angle_limit = math.radians(32)
        bpy.context.view_layer.objects.active = ob
        ob.select_set(True)
        bpy.ops.object.modifier_apply(modifier=mod.name)
        ob.select_set(False)
    assign_box_uv(me)
    for f in me.polygons:
        f.use_smooth = smooth
    GROUPS.setdefault(group, []).append(ob)
    return ob

def ship_oriented_box(name, center_s, size_s, u_dir_s=(1, 0, 0), v_dir_s=(0, 1, 0), w_dir_s=(0, 0, 1), mat=mat_paint, group='Hull', bevel=0.010, segments=1):
    c = Vector(center_s)
    u = Vector(u_dir_s).normalized() * (size_s[0] * 0.5)
    v = Vector(v_dir_s).normalized() * (size_s[1] * 0.5)
    w = Vector(w_dir_s).normalized() * (size_s[2] * 0.5)
    if u.cross(v).dot(w) < 0:
        u = -u
    corners_s = [
        c + dx * u + dy * v + dz * w
        for dx in (-1, 1) for dy in (-1, 1) for dz in (-1, 1)
    ]
    verts_b = [tuple(s2b(p)) for p in corners_s]
    faces = [
        (0, 1, 3, 2), (4, 6, 7, 5),
        (0, 4, 5, 1), (2, 3, 7, 6),
        (0, 2, 6, 4), (1, 5, 7, 3)
    ]
    return create_mesh_obj(name, verts_b, faces, mat, group, bevel, segments, smooth=False)

def ship_box(name, center_s, size_s, mat=mat_paint, group='Hull', bevel=0.012, segments=1):
    return ship_oriented_box(name, center_s, size_s, (1, 0, 0), (0, 1, 0), (0, 0, 1), mat, group, bevel, segments)

def ship_cyl(name, p1_s, p2_s, r1, r2=None, mat=mat_metal, group='Hull', N=24, bevel=0.0, segments=1, smooth=True, cap_ends=True):
    a = s2b(p1_s)
    b = s2b(p2_s)
    r2 = r1 if r2 is None else r2
    z_dir = (b - a).normalized()
    u = z_dir.cross(Vector((0, 0, 1)))
    if u.length < 0.1:
        u = z_dir.cross(Vector((0, 1, 0)))
    u.normalize()
    v = z_dir.cross(u).normalized()
    verts_b = []
    for c, rad in ((a, r1), (b, r2)):
        for i in range(N):
            ang = 2.0 * math.pi * i / N
            verts_b.append(tuple(c + rad * (math.cos(ang) * u + math.sin(ang) * v)))
    faces = []
    for i in range(N):
        ni = (i + 1) % N
        faces.append((i, ni, N + ni, N + i))
    if cap_ends:
        faces.append(tuple(reversed(range(N))))
        faces.append(tuple(range(N, 2 * N)))
    ob = create_mesh_obj(name, verts_b, faces, mat, group, bevel, segments, smooth)
    for f in ob.data.polygons:
        f.use_smooth = (len(f.vertices) == 4) if smooth else False
    return ob

def ship_lathe(name, origin_s, axis_s, profile_zr, mat=mat_metal, group='Hull', N=48, smooth=True, close_caps=False):
    c = s2b(origin_s)
    d = (s2b(Vector(origin_s) + Vector(axis_s)) - c).normalized()
    u = d.cross(Vector((0, 0, 1)))
    if u.length < 0.1:
        u = d.cross(Vector((0, 1, 0)))
    u.normalize()
    v = d.cross(u).normalized()
    verts_b = []
    for axial, rad in profile_zr:
        ring_c = c + d * axial
        for i in range(N):
            ang = 2.0 * math.pi * i / N
            verts_b.append(tuple(ring_c + rad * (math.cos(ang) * u + math.sin(ang) * v)))
    faces = []
    M = len(profile_zr)
    for j in range(M - 1):
        for i in range(N):
            ni = (i + 1) % N
            faces.append((j * N + i, j * N + ni, (j + 1) * N + ni, (j + 1) * N + i))
    if close_caps:
        faces.append(tuple(reversed(range(N))))
        faces.append(tuple(range((M - 1) * N, M * N)))
    ob = create_mesh_obj(name, verts_b, faces, mat, group, 0.0, 1, smooth)
    return ob

def set_lamp_uv(ob, zone_idx):
    """zone_idx: 0=Red, 1=Green, 2=Cyan/Cabin, 3=Amber Beacon"""
    u_val = (zone_idx + 0.5) * 0.25
    uv = ob.data.uv_layers.active
    for d in uv.data:
        d.uv = (u_val, 0.5)

# ==============================================================================
# 1. MAIN HULL & BULLET NOSE (MEASURED EXACTLY FROM SHEET.JPG)
# ==============================================================================
Y_AXIS = -0.05
R_HULL = 1.44

def hull_radius(z):
    """Exact radius profile of the bullet lifeboat from z=-3.40 to z=+4.50 matching sheet.jpg."""
    if z <= -3.12:
        # Aft orange tapered collar: z=-3.40 (R=1.30) to z=-3.12 (R=1.44)
        t = max(0.0, min(1.0, (z - (-3.40)) / 0.28))
        return 1.30 + 0.14 * (0.5 - 0.5 * math.cos(math.pi * t))
    elif z <= 1.02:
        return R_HULL
    else:
        # Sleek tangent bullet ogive from z=1.02 (R=1.44) to z=4.50 (R=0.0)
        u = max(0.0, min(1.0, (z - 1.02) / 3.48))
        return R_HULL * math.pow(max(0.0, 1.0 - math.pow(u, 1.76)), 0.70)

def hull_normal_s(phi, z):
    dz = 0.01
    dr = (hull_radius(min(4.49, z + dz)) - hull_radius(max(-3.39, z - dz))) / (2.0 * dz)
    rx, ry = math.sin(phi), math.cos(phi)
    return Vector((rx, ry, -dr)).normalized()

Z_WIN_CENTER = 3.74

def build_main_hull():
    N_THETA = 80
    groove_zs = [-3.12, -2.04, -0.70, 1.02, 2.35, 3.48, 3.98]
    gw = 0.012
    gs = 0.016

    def groove_pack(gz):
        return [gz - gs, gz - gw, gz, gz + gw, gz + gs]

    raw_zs = (
        [-3.40, -3.38, -3.26]
        + groove_pack(-3.12)
        + [-2.76, -2.40]
        + groove_pack(-2.04)
        + [-1.60, -1.15]
        + groove_pack(-0.70)
        + [-0.22, 0.35, 0.72]
        + groove_pack(1.02)
        + [1.35, 1.68, 2.02]
        + groove_pack(2.35)
        + [2.72, 3.10]
        + groove_pack(3.48)
        + [3.56, 3.65, 3.74, 3.83, 3.91]
        + groove_pack(3.98)
        + [4.10, 4.22, 4.33, 4.41, 4.46, 4.492, 4.50]
    )

    verts_s = []
    ring_meta = []

    for z_val in raw_zs:
        is_groove = any(abs(z_val - gz) < 1e-4 for gz in groove_zs)
        ring_meta.append((z_val, is_groove))
        for i in range(N_THETA):
            phi = 2.0 * math.pi * i / N_THETA
            sin_p = math.sin(phi)
            cos_p = math.cos(phi)

            z_actual = z_val
            frac_y = cos_p
            if abs(z_val - 0.35) < 1e-4:
                if frac_y >= 0.073:
                    z_actual = 0.34
                elif frac_y <= -0.603:
                    z_actual = 0.96
                else:
                    t_diag = (0.073 - frac_y) / (0.073 - (-0.603))
                    z_actual = 0.34 + t_diag * (0.96 - 0.34)
            elif abs(z_val - 0.72) < 1e-4:
                if frac_y >= 0.073:
                    z_actual = 0.70
                elif frac_y <= -0.603:
                    z_actual = 0.98
                else:
                    t_diag = (0.073 - frac_y) / (0.073 - (-0.603))
                    z_actual = 0.70 + t_diag * (0.98 - 0.70)

            r = hull_radius(z_actual)
            if is_groove and r > 0.05:
                r -= 0.008

            x = r * sin_p
            y = Y_AXIS + r * cos_p
            verts_s.append(Vector((x, y, z_actual)))

    j_win_c = next(j for j, zv in enumerate(raw_zs) if abs(zv - 3.74) < 1e-4)
    win_j_indices = [j_win_c - 1, j_win_c]

    r_win_c = hull_radius(Z_WIN_CENTER)
    dr_dz = (hull_radius(3.98) - hull_radius(3.48)) / (3.98 - 3.48)
    slope_ang = math.atan2(-dr_dz, 1.0)
    r_hole = 0.118

    for k in range(10):
        phi_c = (k + 0.5) * (2.0 * math.pi / 10.0)
        rx, ry = math.sin(phi_c), math.cos(phi_c)
        nx = rx * math.cos(slope_ang)
        ny = ry * math.cos(slope_ang)
        nz = math.sin(slope_ang)
        n_vec = Vector((nx, ny, nz)).normalized()
        u_vec = Vector((-ry, rx, 0.0)).normalized()
        v_vec = n_vec.cross(u_vec).normalized()
        w_center = Vector((r_win_c * rx, Y_AXIS + r_win_c * ry, Z_WIN_CENTER))

        i_base = k * 8
        for r_off in (-1, 0, 1):
            for c_off in (-1, 0, 1):
                if r_off == 0 and c_off == 0:
                    continue
                ang_h = math.atan2(r_off, c_off)
                pt_circle = w_center + r_hole * (math.cos(ang_h) * u_vec + math.sin(ang_h) * v_vec)
                r_exact = hull_radius(pt_circle.z)
                phi_pt = math.atan2(pt_circle.x, pt_circle.y - Y_AXIS)
                v_idx = (j_win_c + r_off) * N_THETA + ((i_base + 4 + c_off) % N_THETA)
                verts_s[v_idx] = Vector((r_exact * math.sin(phi_pt), Y_AXIS + r_exact * math.cos(phi_pt), pt_circle.z))

    verts_b = [tuple(s2b(p)) for p in verts_s]
    aft_center_idx = len(verts_b)
    verts_b.append(tuple(s2b(0.0, Y_AXIS, -3.40)))

    faces = []
    face_mats = []
    M = len(raw_zs)

    for j in range(M - 1):
        z0, g0 = ring_meta[j]
        z1, g1 = ring_meta[j + 1]
        z_mid = 0.5 * (z0 + z1)
        is_win_band = (j in win_j_indices)
        is_groove_wall = (g0 or g1)

        for i in range(N_THETA):
            ni = (i + 1) % N_THETA
            bay_pos = i % 8
            if is_win_band and bay_pos in (3, 4):
                continue

            v00 = j * N_THETA + i
            v10 = j * N_THETA + ni
            v11 = (j + 1) * N_THETA + ni
            v01 = (j + 1) * N_THETA + i
            faces.append((v00, v10, v11, v01))

            phi_mid = 2.0 * math.pi * (i + 0.5) / N_THETA
            frac_y = math.cos(phi_mid)

            if is_groove_wall:
                mat_idx = 2
            elif z_mid >= 1.01 or z_mid <= -3.12:
                mat_idx = 0
            else:
                if frac_y >= 0.688:
                    mat_idx = 1
                elif frac_y >= 0.073:
                    mat_idx = 0
                elif frac_y >= -0.603:
                    t_diag = max(0.0, min(1.0, (0.073 - frac_y) / (0.073 - (-0.603))))
                    z_boundary = 0.34 + t_diag * (0.96 - 0.34)
                    fz = 0.25 * (verts_s[v00].z + verts_s[v10].z + verts_s[v11].z + verts_s[v01].z)
                    mat_idx = 1 if fz <= z_boundary + 0.005 else 0
                elif frac_y >= -0.761:
                    mat_idx = 0
                else:
                    mat_idx = 1

            face_mats.append(mat_idx)

    for i in range(N_THETA):
        ni = (i + 1) % N_THETA
        faces.append((ni, i, aft_center_idx))
        face_mats.append(2)

    me = bpy.data.meshes.new('Main_Hull_Skin')
    me.from_pydata(verts_b, [], faces)
    me.update()
    ob = bpy.data.objects.new('Main_Hull_Skin', me)
    S.collection.objects.link(ob)

    for m in (mat_paint, mat_white, mat_metal, mat_dark):
        me.materials.append(m)

    for poly, m_idx in zip(me.polygons, face_mats):
        poly.material_index = m_idx
        poly.use_smooth = True

    uv = me.uv_layers.new(name='UVMap')
    for poly in me.polygons:
        for li in poly.loop_indices:
            co_b = me.vertices[me.loops[li].vertex_index].co
            x_s, y_s, z_s = co_b.x, co_b.z, -co_b.y
            ang = math.atan2(x_s, y_s - Y_AXIS)
            u = (z_s + 4.5) / 3.0
            v = (ang + math.pi) / (2.0 * math.pi) * 2.0
            uv.data[li].uv = (u, v)

    bm = bmesh.new()
    bm.from_mesh(me)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(me)
    bm.free()

    GROUPS.setdefault('Hull', []).append(ob)

    for k_seam in range(8):
        phi_s = k_seam * (math.pi / 4.0) + (math.pi / 8.0)
        for z_l in (-3.04, -2.10, -1.98, -0.76, -0.64, 0.96, 1.08, 2.29, 2.41, 3.42):
            r_l = hull_radius(z_l)
            lx = r_l * math.sin(phi_s)
            ly = Y_AXIS + r_l * math.cos(phi_s)
            ship_cyl(
                'Seam_Fastener',
                (lx * 0.994, Y_AXIS + (ly - Y_AXIS) * 0.994, z_l - 0.024),
                (lx * 0.994, Y_AXIS + (ly - Y_AXIS) * 0.994, z_l + 0.024),
                0.011, 0.011, mat_metal, 'Hull', N=8, bevel=0.0
            )

build_main_hull()

# ==============================================================================
# 2. RING OF 10 PORTHOLE WINDOWS, CONVEX LENSES & VISIBLE CABIN INTERIOR
# ==============================================================================
def build_windows_and_cabin():
    cabin_profile = [
        (2.85, 0.92),
        (3.15, 0.84),
        (3.48, 0.70),
        (3.74, 0.56),
        (3.98, 0.42),
        (4.12, 0.26),
        (4.13, 0.0),
    ]
    ship_lathe('Cabin_Inner_Bulkhead', (0.0, Y_AXIS, 0.0), (0.0, 0.0, 1.0), cabin_profile, mat_dark, 'Hull', N=36, smooth=True)
    ship_cyl('Cabin_Aft_Wall', (0.0, Y_AXIS, 2.85), (0.0, Y_AXIS, 2.88), 0.92, 0.92, mat_dark, 'Hull', N=36, bevel=0.0)

    ship_cyl('Cabin_Core_Column', (0.0, Y_AXIS, 2.88), (0.0, Y_AXIS, 4.05), 0.20, 0.13, mat_metal, 'Hull', N=20, bevel=0.008)
    lamp_ring = ship_cyl('Cabin_Core_Light_Ring', (0.0, Y_AXIS, 3.68), (0.0, Y_AXIS, 3.78), 0.18, 0.17, mat_lamps, 'Hull', N=20, bevel=0.0)
    set_lamp_uv(lamp_ring, 2)

    R_WIN = hull_radius(Z_WIN_CENTER)
    dr_dz = (hull_radius(3.98) - hull_radius(3.48)) / (3.98 - 3.48)
    slope_ang = math.atan2(-dr_dz, 1.0)

    N_W = 10
    for k in range(N_W):
        phi = (k + 0.5) * (2.0 * math.pi / N_W)
        rx = math.sin(phi)
        ry = math.cos(phi)
        nx = rx * math.cos(slope_ang)
        ny = ry * math.cos(slope_ang)
        nz = math.sin(slope_ang)

        wx = R_WIN * rx
        wy = Y_AXIS + R_WIN * ry
        wz = Z_WIN_CENTER

        n_vec = Vector((nx, ny, nz)).normalized()
        u_vec = Vector((-ry, rx, 0.0)).normalized()
        v_vec = n_vec.cross(u_vec).normalized()

        p_outer = Vector((wx, wy, wz)) + n_vec * 0.012
        p_inner = Vector((wx, wy, wz)) - n_vec * 0.12

        N_RING = 24
        r_in, r_out = 0.110, 0.154
        ring_verts = []
        for d_off, r_a, r_b in [(-0.024, r_in, r_out + 0.014), (0.012, r_in, r_out)]:
            c_pt = Vector((wx, wy, wz)) + n_vec * d_off
            for r_val in (r_a, r_b):
                for idx_r in range(N_RING):
                    a_r = 2.0 * math.pi * idx_r / N_RING
                    pt = c_pt + r_val * (math.cos(a_r) * u_vec + math.sin(a_r) * v_vec)
                    ring_verts.append(tuple(s2b(pt)))
        ring_faces = []
        for idx_r in range(N_RING):
            n_idx = (idx_r + 1) % N_RING
            ring_faces.append((2 * N_RING + idx_r, 2 * N_RING + n_idx, 3 * N_RING + n_idx, 3 * N_RING + idx_r))
            ring_faces.append((1 * N_RING + idx_r, 1 * N_RING + n_idx, 3 * N_RING + n_idx, 3 * N_RING + idx_r))
            ring_faces.append((2 * N_RING + idx_r, 0 * N_RING + idx_r, 0 * N_RING + n_idx, 2 * N_RING + n_idx))
        create_mesh_obj(f'Win_Bezel_Flange_{k}', ring_verts, ring_faces, mat_metal, 'Hull', bevel=0.003, segments=1, smooth=True)

        ship_cyl(f'Win_Inner_Wall_{k}', p_inner, p_outer, 0.112, 0.112, mat_dark, 'Hull', N=24, bevel=0.0, smooth=True, cap_ends=False)

        for b_i in range(8):
            ba = 2.0 * math.pi * b_i / 8.0
            b_pt = Vector((wx, wy, wz)) + 0.134 * (math.cos(ba) * u_vec + math.sin(ba) * v_vec)
            ship_cyl(f'Win_Bolt_{k}_{b_i}', b_pt + n_vec * 0.008, b_pt + n_vec * 0.016, 0.0055, 0.005, mat_metal, 'Hull', N=6, bevel=0.0)

        # Curved Convex Porthole Glass Lens (avoids flat mirror blowout and catches crisp specular highlights!)
        lens_profile = [
            (-0.014, 0.110),
            (-0.005, 0.088),
            (0.002, 0.048),
            (0.005, 0.0),
        ]
        ship_lathe(f'Win_Glass_{k}', (wx, wy, wz), n_vec, lens_profile, mat_glass, 'Hull', N=24, smooth=True)

        # Interior Crash Seat & Illuminated HUD visible behind each porthole
        seat_c = Vector((wx * 0.62, Y_AXIS + (wy - Y_AXIS) * 0.62, wz - 0.12))
        seat_top = seat_c + Vector((rx * 0.09, ry * 0.09, 0.16))
        ship_cyl(f'Cabin_Seat_Back_{k}', seat_c - Vector((0, 0, 0.12)), seat_top, 0.085, 0.068, mat_dark, 'Hull', N=10, bevel=0.006)
        ship_cyl(f'Cabin_Headrest_{k}', seat_top, seat_top + Vector((0, 0, 0.055)), 0.060, 0.050, mat_metal, 'Hull', N=8, bevel=0.005)
        hud_pt = Vector((wx * 0.72, Y_AXIS + (wy - Y_AXIS) * 0.72, wz + 0.03))
        hud = ship_cyl(f'Cabin_HUD_{k}', hud_pt, hud_pt + n_vec * 0.015, 0.042, 0.042, mat_lamps, 'Hull', N=8, bevel=0.0)
        set_lamp_uv(hud, 2)

build_windows_and_cabin()

# ==============================================================================
# 3. FLUSH RECESSED VENTS, LOUVERS, MICRO-PORTS & ACCESS PANELS (FROM SHEET.JPG)
# ==============================================================================
def build_surface_details():
    for phi_deg in (32, -32, 148, -148):
        phi = math.radians(phi_deg)
        z_c = 1.38
        r_c = hull_radius(z_c)
        rx, ry = math.sin(phi), math.cos(phi)
        n_surf = hull_normal_s(phi, z_c)
        u_tan = Vector((ry, -rx, 0.0)).normalized()
        w_slope = u_tan.cross(n_surf).normalized()

        c_pt = Vector((r_c * rx, Y_AXIS + r_c * ry, z_c))
        ship_oriented_box(
            f'Vent_Frame_{phi_deg}',
            c_pt - n_surf * 0.007,
            (0.21, 0.024, 0.50),
            u_tan, n_surf, w_slope,
            mat_metal, 'Hull', bevel=0.005, segments=1
        )
        ship_oriented_box(
            f'Vent_Dark_{phi_deg}',
            c_pt - n_surf * 0.003,
            (0.17, 0.022, 0.44),
            u_tan, n_surf, w_slope,
            mat_dark, 'Hull', bevel=0.003, segments=1
        )
        for k_l in range(8):
            zl = z_c - 0.175 + k_l * 0.050
            rl = hull_radius(zl) + 0.003
            p_l = Vector((rl * rx, Y_AXIS + rl * ry, zl))
            ship_cyl(f'Vent_Slat_{phi_deg}_{k_l}', p_l - u_tan * 0.075, p_l + u_tan * 0.075, 0.0075, 0.0075, mat_metal, 'Hull', N=6, bevel=0.0)

    for side in (-1, 1):
        phi_s = side * (math.pi / 2.0)
        z_c = 1.28
        r_c = hull_radius(z_c)
        n_surf = hull_normal_s(phi_s, z_c)
        u_vert = Vector((0.0, 1.0, 0.0))
        w_slope = u_vert.cross(n_surf).normalized()
        c_pt = Vector((side * r_c, Y_AXIS - 0.03, z_c))

        ship_oriented_box(f'Side_Louver_Frame_{side}', c_pt - n_surf * 0.005, (0.38, 0.022, 0.22), u_vert, n_surf, w_slope, mat_paint, 'Hull', bevel=0.005)
        ship_oriented_box(f'Side_Louver_Well_{side}', c_pt - n_surf * 0.002, (0.32, 0.020, 0.17), u_vert, n_surf, w_slope, mat_dark, 'Hull', bevel=0.003)
        for k_s in range(6):
            ys = (Y_AXIS - 0.03) - 0.125 + k_s * 0.050
            p_bar = Vector((side * (r_c + 0.004), ys, z_c))
            ship_cyl(f'Side_Louver_Bar_{side}_{k_s}', p_bar - w_slope * 0.07, p_bar + w_slope * 0.07, 0.007, 0.007, mat_metal, 'Hull', N=6, bevel=0.0)

        phi_p = side * math.radians(55)
        rp = hull_radius(1.25)
        n_p = hull_normal_s(phi_p, 1.25)
        u_p = Vector((math.cos(phi_p), -math.sin(phi_p), 0.0)).normalized()
        w_p = u_p.cross(n_p).normalized()
        c_p = Vector((rp * math.sin(phi_p), Y_AXIS + rp * math.cos(phi_p), 1.25))
        ship_oriented_box(f'Fwd_Inspect_Plate_{side}', c_p - n_p * 0.005, (0.13, 0.018, 0.22), u_p, n_p, w_p, mat_paint, 'Hull', bevel=0.004)

        phi_a = side * math.radians(54)
        n_a = Vector((math.sin(phi_a), math.cos(phi_a), 0.0))
        u_a = Vector((math.cos(phi_a), -math.sin(phi_a), 0.0))
        c_a = Vector((R_HULL * math.sin(phi_a), Y_AXIS + R_HULL * math.cos(phi_a), -2.45))
        ship_oriented_box(f'Aft_Service_Hatch_{side}', c_a - n_a * 0.005, (0.22, 0.018, 0.22), u_a, n_a, (0, 0, 1), mat_paint, 'Hull', bevel=0.004)

        for m_name, z_m, phi_m_deg, rows, cols in [
            ('Dorsal_Ports', 0.15, 28, 2, 6),
            ('Mid_Ports', -1.35, 52, 3, 5),
            ('Ventral_Ports', -1.10, 118, 2, 5),
        ]:
            for r_i in range(rows):
                for c_i in range(cols):
                    ph = side * math.radians(phi_m_deg + (r_i - (rows - 1) / 2.0) * 2.2)
                    zz = z_m + (c_i - (cols - 1) / 2.0) * 0.055
                    px_m = R_HULL * math.sin(ph)
                    py_m = Y_AXIS + R_HULL * math.cos(ph)
                    n_m = Vector((math.sin(ph), math.cos(ph), 0.0))
                    ship_cyl(
                        f'{m_name}_{side}_{r_i}_{c_i}',
                        Vector((px_m, py_m, zz)) - n_m * 0.006,
                        Vector((px_m, py_m, zz)) + n_m * 0.006,
                        0.012, 0.011, mat_dark, 'Hull', N=6, bevel=0.0
                    )

build_surface_details()

# ==============================================================================
# 4. TOP DOCKING COLLAR ('Hull') & MOVING HATCH ('Hatch' WITH HINGE PIVOT)
# ==============================================================================
Z_COLLAR = -1.25
HATCH_PIVOT_S = (0.0, Y_AXIS + R_HULL + 0.12, Z_COLLAR + 0.56)

def build_docking_collar_and_hatch():
    y_base = Y_AXIS + R_HULL - 0.14
    y_rim = Y_AXIS + R_HULL + 0.11

    collar_profile = [
        (y_base, 0.72),
        (y_base + 0.09, 0.65),
        (y_rim - 0.04, 0.58),
        (y_rim, 0.58),
        (y_rim, 0.46),
        (y_base + 0.04, 0.46),
    ]
    ship_lathe('Docking_Collar_Ring', (0.0, 0.0, Z_COLLAR), (0.0, 1.0, 0.0), collar_profile, mat_metal, 'Hull', N=48, smooth=True)

    ship_cyl('Docking_Airlock_Throat', (0.0, y_base + 0.02, Z_COLLAR), (0.0, y_rim - 0.02, Z_COLLAR), 0.46, 0.46, mat_dark, 'Hull', N=32, bevel=0.0)
    ship_cyl('Docking_Inner_Seal', (0.0, y_rim - 0.03, Z_COLLAR), (0.0, y_rim - 0.008, Z_COLLAR), 0.50, 0.48, mat_metal, 'Hull', N=32, bevel=0.003)

    for i in range(16):
        ang = 2.0 * math.pi * i / 16.0
        bx = 0.62 * math.sin(ang)
        bz = Z_COLLAR + 0.62 * math.cos(ang)
        by = Y_AXIS + math.sqrt(max(0.1, R_HULL * R_HULL - bx * bx)) + 0.028
        ship_cyl(f'Collar_Bolt_{i}', (bx, by - 0.015, bz), (bx, by + 0.024, bz), 0.015, 0.013, mat_metal, 'Hull', N=8, bevel=0.0)

    px, py, pz = HATCH_PIVOT_S
    for sx in (-0.11, 0.11):
        ship_box(f'Collar_Hinge_Lug_{sx}', (sx, py - 0.03, pz - 0.015), (0.05, 0.10, 0.12), mat_metal, 'Hull', bevel=0.006)
    ship_cyl('Collar_Hinge_Pin', (-0.15, py, pz), (0.15, py, pz), 0.024, 0.024, mat_metal, 'Hull', N=14, bevel=0.003)

    hatch_metal_profile = [
        (y_rim - 0.006, 0.54),
        (y_rim + 0.045, 0.53),
        (y_rim + 0.062, 0.46),
        (y_rim - 0.006, 0.46),
    ]
    ship_lathe('Hatch_Rim', (0.0, 0.0, Z_COLLAR), (0.0, 1.0, 0.0), hatch_metal_profile, mat_metal, 'Hatch', N=48, smooth=True)

    hatch_dome_profile = [
        (y_rim + 0.018, 0.0),
        (y_rim + 0.05, 0.15),
        (y_rim + 0.068, 0.19),
        (y_rim + 0.092, 0.34),
        (y_rim + 0.076, 0.45),
        (y_rim + 0.042, 0.47),
        (y_rim, 0.47),
        (y_rim, 0.0),
    ]
    ship_lathe('Hatch_White_Dome', (0.0, 0.0, Z_COLLAR), (0.0, 1.0, 0.0), hatch_dome_profile, mat_white, 'Hatch', N=48, smooth=True)
    ship_cyl('Hatch_Center_Plate', (0.0, y_rim + 0.045, Z_COLLAR), (0.0, y_rim + 0.072, Z_COLLAR), 0.12, 0.105, mat_metal, 'Hatch', N=20, bevel=0.004)

    ship_box('Hatch_Hinge_Arm', (0.0, py + 0.016, pz - 0.11), (0.15, 0.075, 0.24), mat_metal, 'Hatch', bevel=0.008)
    ship_cyl('Hatch_Hinge_Barrel', (-0.075, py, pz), (0.075, py, pz), 0.035, 0.035, mat_metal, 'Hatch', N=16, bevel=0.003)

    for i in range(1, 8):
        ang = 2.0 * math.pi * i / 8.0
        lx = 0.51 * math.sin(ang)
        lz = Z_COLLAR + 0.51 * math.cos(ang)
        u_tan = (math.cos(ang), 0.0, -math.sin(ang))
        w_rad = (math.sin(ang), 0.0, math.cos(ang))
        ship_oriented_box(
            f'Hatch_Dog_{i}',
            (lx, y_rim + 0.042, lz),
            (0.042, 0.050, 0.085),
            u_tan, (0, 1, 0), w_rad,
            mat_metal, 'Hatch', bevel=0.005, segments=1
        )

    PIVOTS['Hatch'] = (tuple(s2b(HATCH_PIVOT_S)), (1, 0, 0), 'Hull')

build_docking_collar_and_hatch()

# ==============================================================================
# 5. TOP REAR STROBE BEACON ('Hull' + Light_Beacon)
# ==============================================================================
Z_BEACON = -2.42
BEACON_TOP_Y = Y_AXIS + R_HULL + 0.22

def build_beacon():
    y_b0 = Y_AXIS + R_HULL - 0.04
    ship_cyl('Beacon_Base_Flange', (0.0, y_b0, Z_BEACON), (0.0, y_b0 + 0.07, Z_BEACON), 0.16, 0.13, mat_metal, 'Hull', N=24, bevel=0.006)

    shroud_profile = [
        (y_b0 + 0.065, 0.12),
        (y_b0 + 0.15, 0.105),
        (y_b0 + 0.21, 0.088),
        (y_b0 + 0.21, 0.065),
        (y_b0 + 0.065, 0.065),
    ]
    ship_lathe('Beacon_Orange_Shroud', (0.0, 0.0, Z_BEACON), (0.0, 1.0, 0.0), shroud_profile, mat_paint, 'Hull', N=24, smooth=True)

    dome_profile = [
        (y_b0 + 0.12, 0.095),
        (y_b0 + 0.21, 0.086),
        (y_b0 + 0.25, 0.062),
        (y_b0 + 0.27, 0.0),
    ]
    ob_lens = ship_lathe('Beacon_Lens_Dome', (0.0, 0.0, Z_BEACON), (0.0, 1.0, 0.0), dome_profile, mat_lamps, 'Hull', N=24, smooth=True)
    set_lamp_uv(ob_lens, 3)

build_beacon()

# ==============================================================================
# 6. SHORT STABILISER FINS ('Hull' + Light_Nav_L / Light_Nav_R)
# ==============================================================================
FIN_TIP_L = (1.98, Y_AXIS - 1.16, -2.75)
FIN_TIP_R = (-1.98, Y_AXIS - 1.16, -2.75)

def build_stabiliser_fins():
    for side in (-1, 1):
        def airfoil_ring(x_val, y_val, z_le, z_te, thick):
            chord = z_le - z_te
            pts = []
            nx = side * math.sin(math.radians(26.5))
            ny = math.cos(math.radians(26.5))
            profile_2d = [
                (0.00, 0.00),
                (0.12, 0.42),
                (0.35, 0.50),
                (0.72, 0.34),
                (1.00, 0.12),
                (1.00, -0.12),
                (0.72, -0.34),
                (0.35, -0.50),
                (0.12, -0.42),
            ]
            for u_c, v_t in profile_2d:
                zz = z_le - u_c * chord
                xx = x_val + nx * (v_t * thick)
                yy = y_val + ny * (v_t * thick)
                pts.append(tuple(s2b(xx, yy, zz)))
            return pts

        r_root = airfoil_ring(side * 1.14, Y_AXIS - 0.75, -1.78, -3.22, 0.125)
        r_mid = airfoil_ring(side * 1.58, Y_AXIS - 0.96, -2.06, -3.24, 0.10)
        r_tip = airfoil_ring(side * 1.98, Y_AXIS - 1.16, -2.32, -3.26, 0.068)

        rings = [r_root, r_mid, r_tip]
        NP = len(r_root)
        f_verts = [pt for r in rings for pt in r]
        tip_center_idx = len(f_verts)
        f_verts.append(tuple(s2b(side * 2.00, Y_AXIS - 1.17, -2.78)))

        f_faces = []
        for j in range(len(rings) - 1):
            for i in range(NP):
                ni = (i + 1) % NP
                if side == 1:
                    f_faces.append((j * NP + i, (j + 1) * NP + i, (j + 1) * NP + ni, j * NP + ni))
                else:
                    f_faces.append((j * NP + i, j * NP + ni, (j + 1) * NP + ni, (j + 1) * NP + i))
        base_tip = (len(rings) - 1) * NP
        for i in range(NP):
            ni = (i + 1) % NP
            if side == 1:
                f_faces.append((base_tip + i, tip_center_idx, base_tip + ni))
            else:
                f_faces.append((base_tip + ni, tip_center_idx, base_tip + i))

        create_mesh_obj(f'Stabiliser_Fin_{side}', f_verts, f_faces, mat_white, 'Hull', bevel=0.005, segments=1, smooth=True)

        # Tight dark/metallic Root Gasket (protrudes only 8mm at hull intersection, matching sheet.jpg!)
        r_fillet_in = airfoil_ring(side * 1.21, Y_AXIS - 0.785, -1.78, -3.225, 0.138)
        r_fillet_out = airfoil_ring(side * 1.26, Y_AXIS - 0.81, -1.81, -3.225, 0.126)
        fil_verts = r_fillet_in + r_fillet_out
        fil_faces = []
        for i in range(NP):
            ni = (i + 1) % NP
            if side == 1:
                fil_faces.append((i, NP + i, NP + ni, ni))
            else:
                fil_faces.append((i, ni, NP + ni, NP + i))
        create_mesh_obj(f'Fin_Root_Fairing_{side}', fil_verts, fil_faces, mat_metal, 'Hull', bevel=0.002, segments=1, smooth=True)

        # Wingtip Nav Light Housing & Emissive Lens (Red on Left +X, Green on Right -X)
        tx, ty, tz = (FIN_TIP_L if side == 1 else FIN_TIP_R)
        ship_cyl(f'Nav_Light_Mount_{side}', (tx - side * 0.025, ty + 0.012, tz + 0.09), (tx + side * 0.015, ty - 0.008, tz - 0.09), 0.024, 0.019, mat_metal, 'Hull', N=12, bevel=0.0)
        nav_lens = ship_cyl(f'Nav_Light_Lens_{side}', (tx, ty, tz + 0.05), (tx + side * 0.02, ty - 0.01, tz - 0.04), 0.019, 0.015, mat_lamps, 'Hull', N=12, bevel=0.0)
        set_lamp_uv(nav_lens, 0 if side == 1 else 1)

build_stabiliser_fins()

# ==============================================================================
# 7. AFT PROPULSION DRUM & FOUR ROCKET ENGINES ('Hull' + Thruster_1..4)
# ==============================================================================
THRUSTERS_S = [
    ('Thruster_1', (0.0, Y_AXIS, -4.50), -3.98, -4.50, 0.180, 0.295),
    ('Thruster_2', (0.0, Y_AXIS + 0.68, -4.34), -3.80, -4.34, 0.170, 0.280),
    ('Thruster_3', (0.60, Y_AXIS - 0.36, -4.34), -3.80, -4.34, 0.170, 0.280),
    ('Thruster_4', (-0.60, Y_AXIS - 0.36, -4.34), -3.80, -4.34, 0.170, 0.280),
]

def build_aft_propulsion_and_engines():
    # 1. Forward Bare Metal Propulsion Drum (z = -3.39 to -3.66)
    drum_fwd_profile = [
        (-3.39, 1.29),
        (-3.42, 1.29),
        (-3.54, 1.275),
        (-3.56, 1.265),
        (-3.58, 1.275),
        (-3.66, 1.26),
    ]
    ship_lathe('Aft_Drum_BareMetal', (0.0, Y_AXIS, 0.0), (0.0, 0.0, 1.0), drum_fwd_profile, mat_metal, 'Hull', N=64, smooth=True)

    # 2. Aft Heat-Scorched Propulsion Shroud & Recessed Bulkhead Rim (z = -3.66 to -3.88)
    drum_aft_profile = [
        (-3.66, 1.26),
        (-3.78, 1.24),
        (-3.85, 1.21),
        (-3.88, 1.17),
        (-3.88, 1.06),
        (-3.82, 1.03),
        (-3.92, 1.00),
    ]
    ship_lathe('Aft_Drum_Scorched', (0.0, Y_AXIS, 0.0), (0.0, 0.0, 1.0), drum_aft_profile, mat_scorch, 'Hull', N=64, smooth=True)

    ship_cyl('Aft_Recessed_Bulkhead', (0.0, Y_AXIS, -3.94), (0.0, Y_AXIS, -3.88), 1.02, 1.02, mat_dark, 'Hull', N=48, bevel=0.0)

    ship_lathe(
        'Aft_Inner_Ring',
        (0.0, Y_AXIS, 0.0), (0.0, 0.0, 1.0),
        [(-3.90, 1.00), (-3.85, 0.97), (-3.85, 0.91), (-3.90, 0.89)],
        mat_metal, 'Hull', N=48, smooth=True
    )

    # 3. Central Conical Thrust Core (z = -3.86 to -4.12, housing Thruster_1)
    core_cone_profile = [
        (-3.86, 0.62),
        (-3.92, 0.54),
        (-4.00, 0.40),
        (-4.08, 0.25),
        (-4.12, 0.19),
    ]
    ship_lathe('Aft_Central_Thrust_Cone', (0.0, Y_AXIS, 0.0), (0.0, 0.0, 1.0), core_cone_profile, mat_scorch, 'Hull', N=40, smooth=True)

    for i in range(6):
        ang = i * (math.pi / 3.0) + (math.pi / 6.0)
        sx, sy = math.sin(ang), math.cos(ang)
        p_in = (0.42 * sx, Y_AXIS + 0.42 * sy, -3.92)
        p_out = (0.92 * sx, Y_AXIS + 0.92 * sy, -3.88)
        ship_cyl(f'Aft_Radial_Pipe_{i}', p_in, p_out, 0.028, 0.025, mat_metal, 'Hull', N=10, bevel=0.0)

    for t_name, exit_s, z_start, z_exit, r_throat, r_exit in THRUSTERS_S:
        ex, ey, _ = exit_s
        length = z_start - z_exit

        ship_cyl(
            f'{t_name}_Gimbal',
            (ex, ey, z_start + 0.04), (ex, ey, z_start - 0.05),
            r_throat * 1.22, r_throat * 1.08, mat_metal, 'Hull', N=32, bevel=0.005
        )

        bell_profile = [
            (z_start, r_throat),
            (z_start - 0.25 * length, r_throat + 0.20 * (r_exit - r_throat)),
            (z_start - 0.55 * length, r_throat + 0.52 * (r_exit - r_throat)),
            (z_start - 0.85 * length, r_throat + 0.86 * (r_exit - r_throat)),
            (z_exit + 0.012, r_exit),
            (z_exit, r_exit - 0.010),
            (z_exit, r_exit - 0.026),
        ]
        ob_bell = ship_lathe(f'{t_name}_Bell', (ex, ey, 0.0), (0.0, 0.0, 1.0), bell_profile, mat_scorch, 'Hull', N=36, smooth=True)
        uv_b = ob_bell.data.uv_layers.active
        for poly in ob_bell.data.polygons:
            for li in poly.loop_indices:
                co_b = ob_bell.data.vertices[ob_bell.data.loops[li].vertex_index].co
                z_s = -co_b.y
                u_norm = max(0.02, min(0.98, (z_start - z_s) / max(0.01, length)))
                ang = math.atan2(co_b.x - ex, co_b.z - ey)
                uv_b.data[li].uv = (u_norm, (ang + math.pi) / (2.0 * math.pi))

        for b_frac in (0.28, 0.58, 0.84):
            zb = z_start - b_frac * length
            rb = r_throat + math.pow(b_frac, 0.92) * (r_exit - r_throat) + 0.006
            ship_cyl(f'{t_name}_Band_{b_frac}', (ex, ey, zb + 0.012), (ex, ey, zb - 0.012), rb, rb, mat_metal, 'Hull', N=32, bevel=0.0)

        for k_rib in range(8):
            a_rib = 2.0 * math.pi * k_rib / 8.0
            rx_r, ry_r = math.cos(a_rib), math.sin(a_rib)
            p_r1 = (ex + (r_throat + 0.008) * rx_r, ey + (r_throat + 0.008) * ry_r, z_start - 0.02)
            r_mid_bell = r_throat + 0.50 * (r_exit - r_throat) + 0.006
            p_r2 = (ex + r_mid_bell * rx_r, ey + r_mid_bell * ry_r, z_start - 0.52 * length)
            ship_cyl(f'{t_name}_Rib_{k_rib}', p_r1, p_r2, 0.008, 0.007, mat_metal, 'Hull', N=6, bevel=0.0)

        glow_profile = [
            (z_exit, r_exit - 0.024),
            (z_exit + 0.03, r_exit - 0.032),
            (z_start - 0.50 * length, r_throat + 0.42 * (r_exit - r_throat)),
            (z_start - 0.12 * length, r_throat * 0.78),
            (z_start - 0.08 * length, 0.0),
        ]
        ship_lathe(f'{t_name}_Glow_Core', (ex, ey, 0.0), (0.0, 0.0, 1.0), glow_profile, mat_engines, 'Hull', N=32, smooth=True)

build_aft_propulsion_and_engines()

print('GEOMETRY_PARTS_BUILT', sum(len(v) for v in GROUPS.values()), flush=True)

# ==============================================================================
# 8. JOIN RIGID ASSEMBLIES ('Hull' AND 'Hatch'), PRESERVE EXACT PIVOTS
# ==============================================================================
OBJS = {}
for name, parts in GROUPS.items():
    bpy.ops.object.select_all(action='DESELECT')
    for ob in parts:
        ob.select_set(True)
    bpy.context.view_layer.objects.active = parts[0]
    bpy.ops.object.join()
    ob = bpy.context.object
    ob.name = name

    pivot = Vector(PIVOTS[name][0]) if name in PIVOTS else Vector((0.0, 0.0, 0.0))
    ob.data.transform(Matrix.Translation(-pivot))
    ob.location = pivot
    OBJS[name] = ob
    ASSET.append(ob)

    bm = bmesh.new()
    bm.from_mesh(ob.data)
    bmesh.ops.triangulate(bm, faces=list(bm.faces))
    bad = [f for f in bm.faces if f.calc_area() < 1e-9]
    if bad:
        bmesh.ops.delete(bm, geom=bad, context='FACES')
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(ob.data)
    bm.free()
    ob.data.update()

for name, (pivot, axis, parent) in PIVOTS.items():
    ob = OBJS[name]
    par = OBJS[parent]
    ob.parent = par
    ob.matrix_parent_inverse = Matrix.Identity(4)
    ob.location = Vector(pivot) - par.matrix_world.translation

bpy.context.view_layer.update()

pts = [o.matrix_world @ v.co for o in OBJS.values() for v in o.data.vertices]
lo = Vector(tuple(min(p[i] for p in pts) for i in range(3)))
hi = Vector(tuple(max(p[i] for p in pts) for i in range(3)))
s_len = 9.0 / (hi.y - lo.y)
s_rad = 4.0 / (hi.x - lo.x)
scale_vec = Vector((s_rad, s_len, s_rad))
center_vec = (lo + hi) * 0.5
center_vec.x = 0.0

D_mat = Matrix.Diagonal((*scale_vec, 1.0))
for ob in OBJS.values():
    ob.data.transform(D_mat)
for name, ob in OBJS.items():
    if ob.parent:
        ob.location = Vector([ob.location[i] * scale_vec[i] for i in range(3)])
    else:
        ob.location = Vector([(ob.location[i] - center_vec[i]) * scale_vec[i] for i in range(3)])

hull_ob = OBJS['Hull']
offset_h = hull_ob.location.copy()
hull_ob.data.transform(Matrix.Translation(offset_h))
hull_ob.location = (0.0, 0.0, 0.0)
for child in hull_ob.children:
    child.location += offset_h

bpy.context.view_layer.update()

def norm_s2b(pt_s):
    raw_b = s2b(pt_s)
    return Vector(((raw_b[i] - center_vec[i]) * scale_vec[i] for i in range(3)))

def add_socket(name, pt_s, parent_ob):
    ob = bpy.data.objects.new(name, None)
    S.collection.objects.link(ob)
    ob.empty_display_size = 0.15
    w_pos = norm_s2b(pt_s)
    ob.parent = parent_ob
    ob.location = parent_ob.matrix_world.inverted() @ w_pos
    ASSET.append(ob)
    return ob

for t_name, exit_s, *_ in THRUSTERS_S:
    add_socket(t_name, exit_s, hull_ob)

add_socket('Seat_Pilot', (0.0, Y_AXIS + 0.08, 3.55), hull_ob)
add_socket('Camera_Chase', (0.0, 2.50, -10.50), hull_ob)
add_socket('Light_Nav_L', FIN_TIP_L, hull_ob)
add_socket('Light_Nav_R', FIN_TIP_R, hull_ob)
add_socket('Light_Beacon', (0.0, BEACON_TOP_Y, Z_BEACON), hull_ob)

bpy.context.view_layer.update()

# ==============================================================================
# 9. STUDIO LIGHTING, SPACE BACKDROP & CLAY RENDER
# ==============================================================================
world = bpy.data.worlds.new('Space_Backdrop')
S.world = world
world.use_nodes = True
wn = world.node_tree.nodes
wl = world.node_tree.links
wn.clear()
w_out = wn.new('ShaderNodeOutputWorld')
w_mix = wn.new('ShaderNodeMixShader')
w_lp = wn.new('ShaderNodeLightPath')
w_amb = wn.new('ShaderNodeBackground')
w_amb.inputs[0].default_value = (0.18, 0.22, 0.28, 1.0)
w_amb.inputs[1].default_value = 0.35

w_sky = wn.new('ShaderNodeBackground')
w_tc = wn.new('ShaderNodeTexCoord')
w_sep = wn.new('ShaderNodeSeparateXYZ')
w_ramp = wn.new('ShaderNodeValToRGB')
w_ramp.color_ramp.elements[0].position = 0.0
w_ramp.color_ramp.elements[0].color = (0.003, 0.005, 0.010, 1.0)
w_ramp.color_ramp.elements[1].position = 0.80
w_ramp.color_ramp.elements[1].color = (0.016, 0.028, 0.052, 1.0)
wl.new(w_tc.outputs['Normal'], w_sep.inputs[0])
wl.new(w_sep.outputs['Z'], w_ramp.inputs[0])
wl.new(w_ramp.outputs[0], w_sky.inputs[0])
wl.new(w_lp.outputs['Is Camera Ray'], w_mix.inputs[0])
wl.new(w_amb.outputs[0], w_mix.inputs[1])
wl.new(w_sky.outputs[0], w_mix.inputs[2])
wl.new(w_mix.outputs[0], w_out.inputs[0])

def add_area_light(name, pos_b, power, size, color):
    ld = bpy.data.lights.new(name, 'AREA')
    ld.energy = power
    ld.shape = 'DISK'
    ld.size = size
    ld.color = color
    lo_ob = bpy.data.objects.new(name, ld)
    S.collection.objects.link(lo_ob)
    lo_ob.location = pos_b
    lo_ob.rotation_euler = (-Vector(pos_b)).to_track_quat('-Z', 'Y').to_euler()

add_area_light('Key_Light_UpperFrontLeft', (8.5, -10.5, 9.5), 1850, 6.5, (1.0, 0.96, 0.90))
add_area_light('Fill_Light_Cool', (-9.5, -6.5, 4.5), 850, 8.0, (0.68, 0.82, 1.0))
add_area_light('Rim_Light_Aft', (6.5, 11.5, 6.5), 1500, 6.0, (0.78, 0.88, 1.0))
add_area_light('Belly_Fill', (4.5, -2.5, -7.5), 650, 7.0, (0.72, 0.82, 0.98))

cam_data = bpy.data.cameras.new('Render_Cam')
cam_ob = bpy.data.objects.new('Render_Cam', cam_data)
S.collection.objects.link(cam_ob)
S.camera = cam_ob
cam_data.type = 'ORTHO'
cam_data.lens = 50

HERO_POS = (14.8, -8.2, 5.0)

def set_camera(pos_b, ortho_scale=10.1, target=(0.0, 0.0, 0.0)):
    cam_ob.location = pos_b
    cam_ob.rotation_euler = (Vector(target) - Vector(pos_b)).to_track_quat('-Z', 'Y').to_euler()
    cam_data.ortho_scale = ortho_scale

def render_img(filename, w=1920, h=1080, samples=36):
    S.render.resolution_x = w
    S.render.resolution_y = h
    S.cycles.samples = samples
    out_p = Path(filename) if str(filename).startswith('/') else (ROOT / filename)
    S.render.filepath = str(out_p)
    bpy.ops.render.render(write_still=True)
    print('RENDERED', filename, flush=True)

set_camera(HERO_POS, 10.1)
mat_clay = make_mat('Clay_Override', (0.36, 0.38, 0.40), 0.0, 0.55)
S.view_layers[0].material_override = mat_clay
render_img('clay.png', 1920, 1080, 24)
S.view_layers[0].material_override = None

# ==============================================================================
# 10. POLY HAVEN CC0 TEXTURES + CYCLES PROCEDURAL NODE WEATHERING & BAKING
# ==============================================================================
def download_ph(ph_id, map_type):
    fn = f'{ph_id}_{map_type}_1k.jpg'
    path = TEX / fn
    if not path.exists():
        url = f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{fn}'
        subprocess.run(
            ['curl', '-fL', '--retry', '3', '-A', 'ForgeAssetBuilder/1.0', url, '-o', str(path)],
            check=True, stdout=subprocess.DEVNULL
        )
    return bpy.data.images.load(str(path), check_existing=True)

SHARED_NORMALS = {}

def bake_material_set(ma, ph_id, detail_id, tone_lo, tone_hi, metal_val, rough_val, is_scorch=False):
    print('BAKE_START', ma.name, flush=True)
    src = bpy.data.materials.new(ma.name + '_bake_src')
    src.use_nodes = True
    nt = src.node_tree
    n = nt.nodes
    l = nt.links
    n.clear()

    out = n.new('ShaderNodeOutputMaterial')
    bs = n.new('ShaderNodeBsdfPrincipled')
    l.new(bs.outputs[0], out.inputs[0])

    tc = n.new('ShaderNodeTexCoord')
    diff = n.new('ShaderNodeTexImage')
    diff.image = download_ph(ph_id, 'diff')
    l.new(tc.outputs['UV'], diff.inputs[0])

    norm = n.new('ShaderNodeTexImage')
    norm.image = download_ph(ph_id, 'nor_gl')
    norm.image.colorspace_settings.name = 'Non-Color'
    l.new(tc.outputs['UV'], norm.inputs[0])

    arm = n.new('ShaderNodeTexImage')
    arm.image = download_ph(ph_id, 'arm')
    arm.image.colorspace_settings.name = 'Non-Color'
    l.new(tc.outputs['UV'], arm.inputs[0])

    rust_diff = n.new('ShaderNodeTexImage')
    rust_diff.image = download_ph(detail_id, 'diff')
    l.new(tc.outputs['UV'], rust_diff.inputs[0])

    sep = n.new('ShaderNodeSeparateColor')
    l.new(arm.outputs['Color'], sep.inputs[0])

    bw = n.new('ShaderNodeRGBToBW')
    l.new(diff.outputs[0], bw.inputs[0])

    ramp = n.new('ShaderNodeValToRGB')
    ramp.color_ramp.elements[0].position = 0.05
    ramp.color_ramp.elements[0].color = (*tone_lo, 1.0)
    ramp.color_ramp.elements[1].position = 0.85
    ramp.color_ramp.elements[1].color = (*tone_hi, 1.0)
    l.new(bw.outputs[0], ramp.inputs[0])

    bw_r = n.new('ShaderNodeRGBToBW')
    l.new(rust_diff.outputs[0], bw_r.inputs[0])
    grime_map = n.new('ShaderNodeMapRange')
    grime_map.inputs['To Min'].default_value = 0.76
    grime_map.inputs['To Max'].default_value = 1.02
    l.new(bw_r.outputs[0], grime_map.inputs[0])

    mix_grime = n.new('ShaderNodeMixRGB')
    mix_grime.blend_type = 'MULTIPLY'
    mix_grime.inputs[0].default_value = 0.32
    l.new(ramp.outputs[0], mix_grime.inputs[1])
    l.new(grime_map.outputs[0], mix_grime.inputs[2])

    sep_uv = n.new('ShaderNodeSeparateXYZ')
    l.new(tc.outputs['UV'], sep_uv.inputs[0])

    pits = n.new('ShaderNodeTexVoronoi')
    pits.inputs['Scale'].default_value = 95.0
    l.new(tc.outputs['UV'], pits.inputs['Vector'])
    pit_ramp = n.new('ShaderNodeValToRGB')
    pit_ramp.color_ramp.elements[0].position = 0.022
    pit_ramp.color_ramp.elements[0].color = (0.50, 0.50, 0.50, 1.0)
    pit_ramp.color_ramp.elements[1].position = 0.055
    pit_ramp.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
    l.new(pits.outputs['Distance'], pit_ramp.inputs[0])

    noise = n.new('ShaderNodeTexNoise')
    noise.inputs['Scale'].default_value = 45.0
    noise.inputs['Detail'].default_value = 5.0
    l.new(tc.outputs['UV'], noise.inputs[0])
    n_map = n.new('ShaderNodeMapRange')
    n_map.inputs['To Min'].default_value = 0.82
    n_map.inputs['To Max'].default_value = 1.0
    l.new(noise.outputs['Fac'], n_map.inputs[0])

    base_out = n.new('ShaderNodeMixRGB')
    base_out.blend_type = 'MULTIPLY'
    base_out.inputs[0].default_value = 0.55
    l.new(mix_grime.outputs[0], base_out.inputs[1])
    l.new(pit_ramp.outputs[0], base_out.inputs[2])

    final_col = n.new('ShaderNodeMixRGB')
    final_col.blend_type = 'MULTIPLY'
    final_col.inputs[0].default_value = 0.40
    l.new(base_out.outputs[0], final_col.inputs[1])
    l.new(n_map.outputs[0], final_col.inputs[2])

    if is_scorch:
        sc_ramp = n.new('ShaderNodeValToRGB')
        sc_ramp.color_ramp.elements[0].position = 0.0
        sc_ramp.color_ramp.elements[0].color = (0.60, 0.58, 0.56, 1.0)
        sc_ramp.color_ramp.elements[1].position = 0.35
        sc_ramp.color_ramp.elements[1].color = (0.64, 0.52, 0.34, 1.0)
        e2 = sc_ramp.color_ramp.elements.new(0.62)
        e2.color = (0.40, 0.30, 0.48, 1.0)
        e3 = sc_ramp.color_ramp.elements.new(0.82)
        e3.color = (0.24, 0.34, 0.50, 1.0)
        e4 = sc_ramp.color_ramp.elements.new(0.98)
        e4.color = (0.14, 0.15, 0.17, 1.0)
        l.new(sep_uv.outputs['X'], sc_ramp.inputs[0])

        sc_mix = n.new('ShaderNodeMixRGB')
        sc_mix.blend_type = 'MULTIPLY'
        sc_mix.inputs[0].default_value = 0.85
        l.new(final_col.outputs[0], sc_mix.inputs[1])
        l.new(sc_ramp.outputs[0], sc_mix.inputs[2])
        color_socket = sc_mix.outputs[0]
    else:
        color_socket = final_col.outputs[0]

    l.new(color_socket, bs.inputs['Base Color'])

    nm = n.new('ShaderNodeNormalMap')
    nm.inputs['Strength'].default_value = 0.22
    l.new(norm.outputs[0], nm.inputs['Color'])

    bump = n.new('ShaderNodeBump')
    bump.inputs['Strength'].default_value = 0.10
    bump.inputs['Distance'].default_value = 0.003
    l.new(noise.outputs['Fac'], bump.inputs['Height'])
    l.new(nm.outputs[0], bump.inputs['Normal'])
    l.new(bump.outputs[0], bs.inputs['Normal'])

    rr = n.new('ShaderNodeMapRange')
    rr.inputs['To Min'].default_value = max(0.05, rough_val - 0.10)
    rr.inputs['To Max'].default_value = min(0.95, rough_val + 0.12)
    l.new(sep.outputs['Green'], rr.inputs[0])
    l.new(rr.outputs[0], bs.inputs['Roughness'])
    bs.inputs['Metallic'].default_value = metal_val

    bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0.0, 0.0, 40.0))
    ob_bake = bpy.context.object
    ob_bake.name = '_BakeProxy'
    ob_bake.data.materials.append(src)

    bake_node = n.new('ShaderNodeTexImage')
    n.active = bake_node

    def run_bake_pass(pass_name, res, bake_type, emit_socket=None):
        img = bpy.data.images.new(f'{ma.name}_{pass_name}', res, res, alpha=False)
        img.colorspace_settings.name = 'sRGB' if pass_name == 'BaseColor' else 'Non-Color'
        bake_node.image = img
        n.active = bake_node
        if emit_socket is not None:
            em = n.new('ShaderNodeEmission')
            l.new(emit_socket, em.inputs[0])
            l.new(em.outputs[0], out.inputs[0])
        bpy.ops.object.select_all(action='DESELECT')
        ob_bake.select_set(True)
        bpy.context.view_layer.objects.active = ob_bake
        S.cycles.samples = 12
        S.render.bake.margin = 8
        S.render.bake.use_selected_to_active = False
        bpy.ops.object.bake(type=bake_type)
        if emit_socket is not None:
            l.new(bs.outputs[0], out.inputs[0])
        ext = '.jpg' if pass_name == 'BaseColor' else '.png'
        img.filepath_raw = str(OUT / f'{ma.name}_{pass_name}{ext}')
        img.file_format = 'JPEG' if pass_name == 'BaseColor' else 'PNG'
        img.save()
        return img

    img_base = run_bake_pass('BaseColor', 1024, 'EMIT', color_socket)
    if ph_id in SHARED_NORMALS:
        img_norm = SHARED_NORMALS[ph_id]
    else:
        img_norm = run_bake_pass('Normal', 1024, 'NORMAL')
        SHARED_NORMALS[ph_id] = img_norm

    comb = n.new('ShaderNodeCombineColor')
    comb.mode = 'RGB'
    l.new(sep.outputs['Red'], comb.inputs[0])
    l.new(rr.outputs[0], comb.inputs[1])
    comb.inputs[2].default_value = metal_val
    img_orm = run_bake_pass('ORM', 512, 'EMIT', comb.outputs[0])

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

    nt_m = ma.node_tree
    nm_nodes = nt_m.nodes
    lm_links = nt_m.links
    nm_nodes.clear()

    out_m = nm_nodes.new('ShaderNodeOutputMaterial')
    bs_m = nm_nodes.new('ShaderNodeBsdfPrincipled')
    lm_links.new(bs_m.outputs[0], out_m.inputs[0])

    bt = nm_nodes.new('ShaderNodeTexImage')
    bt.name = 'Baked_BaseColor'
    bt.image = img_base

    if ma == mat_paint:
        fac = nm_nodes.new('ShaderNodeMixRGB')
        fac.name = 'Faction colour'
        fac.blend_type = 'MULTIPLY'
        fac.inputs[0].default_value = 1.0
        fac.inputs[2].default_value = (*PAINTS[0][1], 1.0)
        lm_links.new(bt.outputs[0], fac.inputs[1])
        lm_links.new(fac.outputs[0], bs_m.inputs['Base Color'])
    else:
        lm_links.new(bt.outputs[0], bs_m.inputs['Base Color'])

    no_tex = nm_nodes.new('ShaderNodeTexImage')
    no_tex.image = img_norm
    nm_map = nm_nodes.new('ShaderNodeNormalMap')
    lm_links.new(no_tex.outputs[0], nm_map.inputs['Color'])
    lm_links.new(nm_map.outputs[0], bs_m.inputs['Normal'])

    orm_tex = nm_nodes.new('ShaderNodeTexImage')
    orm_tex.image = img_orm
    sep_orm = nm_nodes.new('ShaderNodeSeparateColor')
    lm_links.new(orm_tex.outputs[0], sep_orm.inputs[0])
    lm_links.new(sep_orm.outputs['Green'], bs_m.inputs['Roughness'])
    lm_links.new(sep_orm.outputs['Blue'], bs_m.inputs['Metallic'])

    gltf_group = bpy.data.node_groups.get('glTF Material Output')
    if not gltf_group:
        gltf_group = bpy.data.node_groups.new('glTF Material Output', 'ShaderNodeTree')
        gltf_group.interface.new_socket(name='Occlusion', in_out='INPUT', socket_type='NodeSocketFloat')
    g_node = nm_nodes.new('ShaderNodeGroup')
    g_node.node_tree = gltf_group
    lm_links.new(sep_orm.outputs['Red'], g_node.inputs['Occlusion'])
    print('BAKE_DONE', ma.name, flush=True)

bake_material_set(mat_paint, 'concrete_floor_painted', 'metal_plate_02', (0.76, 0.76, 0.76), (0.94, 0.94, 0.94), 0.12, 0.38, is_scorch=False)
bake_material_set(mat_white, 'concrete_floor_painted', 'rusty_metal_02', (0.76, 0.78, 0.80), (0.94, 0.95, 0.96), 0.10, 0.36, is_scorch=False)
bake_material_set(mat_metal, 'metal_plate_02', 'rusty_metal_02', (0.42, 0.45, 0.49), (0.72, 0.75, 0.79), 0.88, 0.28, is_scorch=False)
bake_material_set(mat_scorch, 'metal_plate_02', 'rusty_metal_02', (0.44, 0.44, 0.46), (0.84, 0.84, 0.86), 0.85, 0.36, is_scorch=True)
bake_material_set(mat_dark, 'rusty_metal_02', 'metal_plate', (0.05, 0.06, 0.07), (0.14, 0.15, 0.17), 0.50, 0.55, is_scorch=False)

# ==============================================================================
# 11. EXPORT GLB & PATCH baseColorFactor + Glass BLEND
# ==============================================================================
bpy.ops.object.select_all(action='DESELECT')
for o in ASSET:
    o.select_set(True)

nt_p = mat_paint.node_tree
bs_p = next(n for n in nt_p.nodes if n.type == 'BSDF_PRINCIPLED')
bt_p = nt_p.nodes['Baked_BaseColor']
fac_p = nt_p.nodes['Faction colour']
nt_p.links.new(bt_p.outputs[0], bs_p.inputs['Base Color'])

glb_path = ROOT / 'lifeboat-pod.glb'
bpy.ops.export_scene.gltf(
    filepath=str(glb_path),
    export_format='GLB',
    use_selection=True,
    export_yup=True,
    export_apply=False,
    export_animations=False,
    export_cameras=False,
    export_lights=False,
    export_extras=True,
    export_image_format='AUTO'
)

raw = glb_path.read_bytes()
jlen = struct.unpack_from('<I', raw, 12)[0]
doc = json.loads(raw[20:20 + jlen])
binary = raw[20 + jlen:]
for m in doc.get('materials', []):
    if m.get('name') == 'Paint':
        m.setdefault('pbrMetallicRoughness', {})['baseColorFactor'] = [*PAINTS[0][1], 1.0]
    if m.get('name') == 'Glass':
        m['alphaMode'] = 'BLEND'
        m['doubleSided'] = True

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

nt_p.links.new(fac_p.outputs[0], bs_p.inputs['Base Color'])

tris = sum(len(p.vertices) - 2 for o in OBJS.values() for p in o.data.polygons)
print('EXPORTED', tris, 'triangles', glb_path.stat().st_size, 'bytes', flush=True)
assert 20000 <= tris <= 60000, f'Triangle count {tris} outside 20k..60k budget!'
assert glb_path.stat().st_size <= 12_000_000, 'GLB exceeds 12 MB!'

# ==============================================================================
# 12. WRITE nodes.json AND NOTES.md
# ==============================================================================
bpy.context.view_layer.update()
pts_final = [o.matrix_world @ v.co for o in OBJS.values() for v in o.data.vertices]
bb_min_b = [min(p[i] for p in pts_final) for i in range(3)]
bb_max_b = [max(p[i] for p in pts_final) for i in range(3)]
bb_min_gltf = [round(bb_min_b[0], 4), round(bb_min_b[2], 4), round(-bb_max_b[1], 4)]
bb_max_gltf = [round(bb_max_b[0], 4), round(bb_max_b[2], 4), round(-bb_min_b[1], 4)]
bb_dims = [round(bb_max_gltf[i] - bb_min_gltf[i], 4) for i in range(3)]

metadata = {
    "asset": "lifeboat-pod",
    "units": "metres",
    "forward": "+Z",
    "up": "+Y",
    "left": "+X",
    "bounding_box": {
        "min": bb_min_gltf,
        "max": bb_max_gltf,
        "dimensions": bb_dims,
        "size": bb_dims
    },
    "triangles": tris,
    "materials": [m['name'] for m in doc.get('materials', [])],
    "file_bytes": glb_path.stat().st_size,
    "axes": {k: list(v[1]) for k, v in PIVOTS.items()},
    "paints": [{"name": n_p, "linear_rgb": v_p} for n_p, v_p in PAINTS],
    "nodes": {
        o.name: {
            "world_position": b2s(o.matrix_world.translation),
            "position": b2s(o.matrix_world.translation),
            "parent": o.parent.name if o.parent else None,
            "type": "mesh" if o.type == "MESH" else "empty"
        }
        for o in ASSET
    }
}
(ROOT / 'nodes.json').write_text(json.dumps(metadata, indent=2) + '\n')

notes_lines = [
    "Built twelve-person sci-fi rescue lifeboat pod (9.0 m long, 4.0 m span/diameter) with bullet nose, 10 porthole windows over a 3D cabin interior, dorsal docking collar with hinged Hatch, strobe beacon, swept stabiliser fins, and 4 aft rocket engines.",
    f"Triangle count: {tris:,} triangles across Hull and articulated Hatch (GLB size {glb_path.stat().st_size / 1048576:.2f} MB).",
    "Materials (8): Paint (neutral baked base multiplied by linear RGB [0.85, 0.35, 0.05]), Hull_White, Bare_Metal, Scorched_Metal, Dark_Interior, Glass (alpha BLEND), Engines (blue emissive), and Lamps.",
    "Texture sizes: Cycles-baked 1024x1024 base colour and normal maps, 512x512 packed ORM (occlusion/roughness/metallic) maps, and 16x4 lamp palette.",
    "Poly Haven CC0 texture IDs used: concrete_floor_painted, metal_plate_02, rusty_metal_02, metal_plate (https://polyhaven.com/license).",
    "Differences from reference sheet: added 3D interior crash seats and HUD consoles visible behind the 10 tinted porthole windows, plus detailed gimbal actuators and cooling bands on the four rear engines."
]
(ROOT / 'NOTES.md').write_text('\n'.join(notes_lines) + '\n')

# ==============================================================================
# 13. PRODUCTION RENDERS: final.png, side.png, articulation check, paints.png
# ==============================================================================
set_camera(HERO_POS, 10.1)
render_img('final.png', 1920, 1080, 48)

set_camera((16.0, 0.0, 0.0), 9.6)
render_img('side.png', 1920, 1080, 32)

hatch_ob = OBJS['Hatch']
base_hatch_loc = hatch_ob.location.copy()
axis_gltf = PIVOTS['Hatch'][1]
axis_blender = Vector((axis_gltf[0], -axis_gltf[2], axis_gltf[1]))
hatch_ob.rotation_euler = Quaternion(axis_blender, math.radians(30)).to_euler()
bpy.context.view_layer.update()
assert (hatch_ob.location - base_hatch_loc).length < 1e-8, "Moving node Hatch shifted from pivot!"
set_camera(HERO_POS, 10.1)
render_img('/tmp/lifeboat_articulation_test.png', 1280, 720, 20)
hatch_ob.rotation_euler = (0.0, 0.0, 0.0)
bpy.context.view_layer.update()

set_camera(HERO_POS, 10.1)
frames = []
for p_name, p_rgb in PAINTS:
    fac_p.inputs[2].default_value = (*p_rgb, 1.0)
    tmp_png = Path('/tmp') / f'_paint_{p_name.replace(" ", "_")}.png'
    S.render.resolution_x = 640
    S.render.resolution_y = 360
    S.cycles.samples = 18
    S.render.filepath = str(tmp_png)
    bpy.ops.render.render(write_still=True)
    im = bpy.data.images.load(str(tmp_png), check_existing=False)
    frames.append(np.array(im.pixels[:], dtype=np.float32).reshape(360, 640, 4))
    bpy.data.images.remove(im)

canvas = np.zeros((720, 1920, 4), dtype=np.float32)
for idx, pix in enumerate(frames):
    row = 1 - (idx // 3)
    col = idx % 3
    canvas[row * 360:(row + 1) * 360, col * 640:(col + 1) * 640] = pix

grid_im = bpy.data.images.new('Paints_Grid', 1920, 720)
grid_im.pixels.foreach_set(canvas.ravel())
grid_im.filepath_raw = str(ROOT / 'paints.png')
grid_im.file_format = 'PNG'
grid_im.save()

fac_p.inputs[2].default_value = (*PAINTS[0][1], 1.0)
print('BUILD_COMPLETE', flush=True)
