import bpy
import bmesh
import math
import json
import struct
import urllib.request
from pathlib import Path
from mathutils import Vector, Matrix, Euler
import numpy as np

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

# Clean scene
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

# Configure Cycles with GPU (OptiX / CUDA)
S.render.engine = 'CYCLES'
prefs = bpy.context.preferences.addons['cycles'].preferences
for dev_type in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
    try:
        prefs.compute_device_type = dev_type
        prefs.get_devices()
        if any(d.type == dev_type for d in prefs.devices):
            for d in prefs.devices:
                d.use = (d.type == dev_type)
            S.cycles.device = 'GPU'
            break
    except Exception:
        pass

S.cycles.use_adaptive_sampling = True
S.cycles.use_denoising = True
S.render.image_settings.file_format = 'PNG'
S.render.image_settings.color_mode = 'RGBA'
S.view_settings.view_transform = 'AgX'
S.view_settings.look = 'AgX - Medium High Contrast'
S.view_settings.exposure = -0.35

# Poly Haven CC0 textures required
PH_ASSETS = [
    'rock_pitted_mossy',
    'fabric_leather_02',
    'leather_white',
    'bark_brown_02',
    'worn_rock_natural_01',
]

def download_textures():
    for asset in PH_ASSETS:
        for mp in ('diff', 'nor_gl', 'arm'):
            fname = f'{asset}_{mp}_1k.jpg'
            fpath = TEX / fname
            if not fpath.exists() or fpath.stat().st_size < 1000:
                url = f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{fname}'
                req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
                with urllib.request.urlopen(req, timeout=30) as resp:
                    fpath.write_bytes(resp.read())

# Paint contract: 6 faction paints, Deep Violet first
PAINTS = [
    ('Deep violet', [0.18, 0.10, 0.22]),
    ('Beetle jade', [0.11, 0.22, 0.12]),
    ('Bone white', [0.68, 0.64, 0.54]),
    ('Abyssal teal', [0.04, 0.20, 0.22]),
    ('Oxblood', [0.26, 0.04, 0.05]),
    ('Obsidian amber', [0.38, 0.20, 0.05]),
]

M = {}
def make_placeholder_mat(name, color, metallic=0.0, roughness=0.5):
    m = bpy.data.materials.new(name)
    m.use_nodes = True
    p = m.node_tree.nodes.get('Principled BSDF')
    if p:
        p.inputs['Base Color'].default_value = (*color, 1.0)
        p.inputs['Metallic'].default_value = metallic
        p.inputs['Roughness'].default_value = roughness
    M[name] = m
    return m

make_placeholder_mat('Paint', (0.18, 0.10, 0.22), 0.06, 0.15)
make_placeholder_mat('Chitin', (0.36, 0.26, 0.18), 0.04, 0.40)
make_placeholder_mat('ScorchedShell', (0.10, 0.08, 0.06), 0.06, 0.52)
make_placeholder_mat('Membrane', (0.36, 0.34, 0.34), 0.02, 0.36)
make_placeholder_mat('DomeMembrane', (0.14, 0.18, 0.16), 0.06, 0.12)
make_placeholder_mat('Engines', (0.25, 1.00, 0.12), 0.0, 0.22)
make_placeholder_mat('Lamps', (0.22, 0.48, 1.00), 0.0, 0.18)

GROUPS = {
    'Hull': [],
    'Canopy': [],
    'Turret_1': [],
    'Gun_1': [],
    'Turret_2': [],
    'Gun_2': [],
    'WingFin_L': [],
    'WingFin_R': [],
}

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

def ensure_uvmap_name(me):
    if not me.uv_layers:
        me.uv_layers.new(name='UVMap')
    else:
        me.uv_layers[0].name = 'UVMap'

def assign_uv_box(o, scale=0.35, uv_offset=(0.0, 0.0), uv_scale=(1.0, 1.0)):
    me = o.data
    ensure_uvmap_name(me)
    bm = bmesh.new()
    bm.from_mesh(me)
    uv_lay = bm.loops.layers.uv.verify()
    for f in bm.faces:
        n = f.normal
        ax, ay, az = abs(n.x), abs(n.y), abs(n.z)
        for loop in f.loops:
            v = loop.vert.co
            if az >= ax and az >= ay:
                u, w = v.x * scale, v.y * scale
            elif ax >= ay and ax >= az:
                u, w = v.y * scale, v.z * scale
            else:
                u, w = v.x * scale, v.z * scale
            loop[uv_lay].uv = (u * uv_scale[0] + uv_offset[0], w * uv_scale[1] + uv_offset[1])
    bm.to_mesh(me)
    bm.free()

def finalize_obj(o, mat_name, group='Hull', subsurf=0, uv_scale=0.38, uv_zone=None, keep_custom_uv=False):
    ensure_uvmap_name(o.data)
    if subsurf > 0:
        mod = o.modifiers.new('Subsurf', 'SUBSURF')
        mod.levels = subsurf
        mod.render_levels = subsurf
        activate(o)
        bpy.ops.object.modifier_apply(modifier=mod.name)
    me = o.data
    ensure_uvmap_name(me)
    me.materials.clear()
    me.materials.append(M[mat_name])
    for p in me.polygons:
        p.use_smooth = True
    if not keep_custom_uv:
        if uv_zone is not None:
            assign_uv_box(o, scale=0.45, uv_offset=(uv_zone, 0.5), uv_scale=(0.06, 0.65))
        else:
            assign_uv_box(o, scale=uv_scale)
    if group is not None:
        GROUPS[group].append(o)
    return o

def make_loft(name, rings, mat_name, group='Hull', cap_ends=True, closed_ring=True, subsurf=1, uv_zone=None, ring_uvs=None):
    bm = bmesh.new()
    v_rings = []
    for r in rings:
        v_rings.append([bm.verts.new(pt) for pt in r])
    n_rings = len(v_rings)
    n_pts = len(v_rings[0])
    uv_lay = bm.loops.layers.uv.new('UVMap') if ring_uvs is not None else None

    for i in range(n_rings - 1):
        r0, r1 = v_rings[i], v_rings[i + 1]
        limit = n_pts if closed_ring else (n_pts - 1)
        for j in range(limit):
            jn = (j + 1) % n_pts
            f = bm.faces.new((r0[j], r0[jn], r1[jn], r1[j]))
            if uv_lay is not None:
                f.loops[0][uv_lay].uv = ring_uvs[i][j]
                f.loops[1][uv_lay].uv = ring_uvs[i][jn]
                f.loops[2][uv_lay].uv = ring_uvs[i + 1][jn]
                f.loops[3][uv_lay].uv = ring_uvs[i + 1][j]
    if cap_ends and closed_ring:
        if len(v_rings[0]) >= 3:
            c0 = sum((v.co for v in v_rings[0]), Vector()) / n_pts
            vc0 = bm.verts.new(c0)
            for j in range(n_pts):
                jn = (j + 1) % n_pts
                f = bm.faces.new((v_rings[0][jn], v_rings[0][j], vc0))
                if uv_lay is not None:
                    f.loops[0][uv_lay].uv = ring_uvs[0][jn]
                    f.loops[1][uv_lay].uv = ring_uvs[0][j]
                    f.loops[2][uv_lay].uv = ring_uvs[0][j]
        if len(v_rings[-1]) >= 3:
            c1 = sum((v.co for v in v_rings[-1]), Vector()) / n_pts
            vc1 = bm.verts.new(c1)
            for j in range(n_pts):
                jn = (j + 1) % n_pts
                f = bm.faces.new((v_rings[-1][j], v_rings[-1][jn], vc1))
                if uv_lay is not None:
                    f.loops[0][uv_lay].uv = ring_uvs[-1][j]
                    f.loops[1][uv_lay].uv = ring_uvs[-1][jn]
                    f.loops[2][uv_lay].uv = ring_uvs[-1][j]
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    ensure_uvmap_name(me)
    o = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(o)
    return finalize_obj(o, mat_name, group, subsurf=subsurf, uv_zone=uv_zone, keep_custom_uv=(ring_uvs is not None))

def make_tube_along_curve(name, pts, radii, mat_name, group='Hull', n_seg=12, flatten_z=1.0, ridge_amp=0.0, ridge_freq=4, subsurf=1, cap_ends=True, uv_zone=None):
    rings = []
    ring_uvs = [] if uv_zone is not None else None
    n_pts = len(pts)
    for i, p in enumerate(pts):
        t_len = i / max(1, n_pts - 1)
        p = Vector(p)
        if i == 0:
            tang = (Vector(pts[1]) - p).normalized()
        elif i == n_pts - 1:
            tang = (p - Vector(pts[-2])).normalized()
        else:
            tang = (Vector(pts[i + 1]) - Vector(pts[i - 1])).normalized()
        up = Vector((0, 0, 1))
        if abs(tang.dot(up)) > 0.92:
            up = Vector((1, 0, 0))
        side = tang.cross(up).normalized()
        up_ortho = side.cross(tang).normalized()
        rx, rz = (radii[i] if isinstance(radii[i], (tuple, list)) else (radii[i], radii[i] * flatten_z))
        ring = []
        ruv = []
        for k in range(n_seg):
            ang = 2.0 * math.pi * k / n_seg
            r_mod = 1.0 + ridge_amp * math.cos(ridge_freq * ang)
            crest = 1.06 if math.sin(ang) > 0.45 else 1.0
            v = p + side * (math.cos(ang) * rx * r_mod) + up_ortho * (math.sin(ang) * rz * r_mod * crest)
            ring.append(v)
            if uv_zone is not None:
                core_lat = abs(math.cos(ang)) ** 0.8
                core_lon = math.sin(t_len * math.pi) ** 0.6
                v_core = max(0.05, min(0.95, core_lat * (0.35 + 0.65 * core_lon)))
                ruv.append((uv_zone + (t_len - 0.5) * 0.04, v_core))
        rings.append(ring)
        if uv_zone is not None:
            ring_uvs.append(ruv)
    return make_loft(name, rings, mat_name, group, cap_ends=cap_ends, closed_ring=True, subsurf=subsurf, uv_zone=uv_zone, ring_uvs=ring_uvs)

MASTER_CARAPACE_STATIONS = [
    # (y, w_master, z_lateral, arch_h, keel_h)
    (-6.00, 0.08, -0.34, 0.04, 0.02),
    (-5.10, 0.80, -0.06, 0.24, 0.06),
    (-3.90, 1.26,  0.22, 0.36, 0.09),
    (-2.45, 1.54,  0.46, 0.44, 0.11),
    (-1.05, 1.70,  0.60, 0.48, 0.12),
    ( 0.25, 1.76,  0.66, 0.50, 0.13),
    ( 1.55, 1.64,  0.62, 0.46, 0.12),
    ( 2.80, 1.42,  0.53, 0.39, 0.10),
    ( 4.05, 1.04,  0.43, 0.29, 0.08),
    ( 5.55, 0.48,  0.33, 0.15, 0.05),
    ( 7.00, 0.03,  0.24, 0.02, 0.01),
]

def eval_master_carapace_profile(y_val):
    """Smoothly interpolates (w_master, z_lateral, arch_h, keel_h) at any longitudinal Y in [-6.0, +7.0]."""
    if y_val <= MASTER_CARAPACE_STATIONS[0][0]:
        return MASTER_CARAPACE_STATIONS[0][1:]
    if y_val >= MASTER_CARAPACE_STATIONS[-1][0]:
        return MASTER_CARAPACE_STATIONS[-1][1:]
    for i in range(len(MASTER_CARAPACE_STATIONS) - 1):
        y0, w0, z0, a0, k0 = MASTER_CARAPACE_STATIONS[i]
        y1, w1, z1, a1, k1 = MASTER_CARAPACE_STATIONS[i + 1]
        if y0 <= y_val <= y1:
            t = (y_val - y0) / (y1 - y0)
            # Smoothstep blend for C1 continuity
            ts = t * t * (3.0 - 2.0 * t)
            t_mix = 0.65 * t + 0.35 * ts
            return (
                w0 + (w1 - w0) * t_mix,
                z0 + (z1 - z0) * t_mix,
                a0 + (a1 - a0) * t_mix,
                k0 + (k1 - k0) * t_mix,
            )
    return MASTER_CARAPACE_STATIONS[-1][1:]

def eval_master_carapace_z(absu_master, y_val):
    """Evaluates the continuous master dorsal carapace height at normalized master half-width absu_master and longitudinal Y."""
    w_m, z_lat_m, ah_m, kh_m = eval_master_carapace_profile(y_val)
    u_clamped = min(1.0, max(0.0, absu_master))
    arch = (math.cos(u_clamped * math.pi * 0.5) ** 1.15) * ah_m
    keel = math.exp(-((u_clamped * 4.2) ** 2)) * kh_m
    flute = -0.028 * math.exp(-(((u_clamped - 0.36) * 5.5) ** 2)) * ah_m + 0.010 * math.cos(u_clamped * math.pi * 3.0) * ah_m
    return w_m, z_lat_m + arch + keel + flute

def make_carapace_plate(name, y_front, y_back, thickness=0.058, width_scale=1.0, mat_name='Paint', group='Hull', subsurf=1, n_u=15, n_v=8, tip_sweep=0.38, nose_taper=False, tail_taper=False, is_canopy=False):
    """Creates a tightly shingled beetle/pangolin carapace scale evaluated on the continuous master hull surface so consecutive plates overlap with an exact 5.5 cm shingle step and zero sawtooth gaps."""
    bm = bmesh.new()
    uv_lay = bm.loops.layers.uv.new('UVMap')
    top_grid = []
    bot_grid = []
    uv_grid = []
    for iv in range(n_v):
        tv = iv / (n_v - 1)
        y_base = y_front * (1.0 - tv) + y_back * tv
        row_top = []
        row_bot = []
        row_uv = []
        for iu in range(n_u):
            tu = (iu / (n_u - 1)) * 2.0 - 1.0  # -1 to +1
            absu = abs(tu)
            # Swept-back dorsal midline tip and curved scale margin
            if tail_taper:
                y_sweep = (tv ** 1.2) * tip_sweep * (1.0 - tv) * (1.0 - absu ** 1.35)
            else:
                y_sweep = (tv ** 1.25) * tip_sweep * ((1.0 - absu ** 1.35) + 0.22 * math.exp(-((absu * 3.2) ** 2)))
            y = min(7.00, y_base + y_sweep)

            if is_canopy:
                # Canopy narrows toward front and back like an almond dorsal blister
                canopy_env = 0.38 + 0.62 * (math.sin(tv * math.pi) ** 0.65)
                u_eff = absu * width_scale * canopy_env
            elif nose_taper:
                u_eff = absu * width_scale * (0.97 + 0.05 * (tv ** 0.8))
            elif tail_taper:
                u_eff = absu * width_scale * (0.98 * (1.0 - 0.15 * (tv ** 2)))
            else:
                # Slight leading tuck (0.96) to trailing flare (1.02) so each scale cleanly wraps the next plate
                u_eff = absu * width_scale * (0.96 + 0.06 * (tv ** 0.85))

            w_m, z_surf = eval_master_carapace_z(u_eff, y)
            x = (1.0 if tu >= 0 else -1.0) * u_eff * w_m

            if is_canopy:
                # Always sits cleanly above Cephalic_Head_Carapace (+0.055 m rim, +0.135 m dome peak) -> zero z-fighting!
                shingle_lift = 0.055 + 0.080 * math.sin(tv * math.pi) * (1.0 - absu ** 1.6)
            elif nose_taper:
                shingle_lift = 0.056 * (tv ** 1.25) * (1.0 - 0.25 * (absu ** 2))
            elif tail_taper:
                shingle_lift = -0.010 * (1.0 - tv)
            else:
                # -1.2 cm at hidden leading edge, rising smoothly to +5.6 cm at exposed swept trailing lip
                shingle_lift = (-0.012 + 0.068 * (tv ** 1.10)) * (1.0 - 0.25 * (absu ** 2))

            z = z_surf + shingle_lift
            taper_end = (1.0 - 0.65 * (tv ** 3)) if tail_taper else 1.0
            edge_factor = (1.0 - 0.35 * (absu ** 4)) * (0.55 + 0.45 * math.sin(tv * math.pi * 0.85 + 0.15)) * taper_end
            th = thickness * edge_factor
            nx = tu * 0.35
            nz = 1.0
            nlen = math.sqrt(nx * nx + nz * nz)
            v_top = bm.verts.new((x, y, z))
            v_bot = bm.verts.new((x - (nx / nlen) * th, y - 0.015 * tv, z - (nz / nlen) * th))
            row_top.append(v_top)
            row_bot.append(v_bot)
            row_uv.append((0.04 + 0.92 * (iu / (n_u - 1)), 0.04 + 0.92 * tv))
        top_grid.append(row_top)
        bot_grid.append(row_bot)
        uv_grid.append(row_uv)

    def add_quad(v0, v1, v2, v3, uv0, uv1, uv2, uv3):
        f = bm.faces.new((v0, v1, v2, v3))
        f.loops[0][uv_lay].uv = uv0
        f.loops[1][uv_lay].uv = uv1
        f.loops[2][uv_lay].uv = uv2
        f.loops[3][uv_lay].uv = uv3

    for iv in range(n_v - 1):
        for iu in range(n_u - 1):
            add_quad(
                top_grid[iv][iu], top_grid[iv][iu + 1], top_grid[iv + 1][iu + 1], top_grid[iv + 1][iu],
                uv_grid[iv][iu], uv_grid[iv][iu + 1], uv_grid[iv + 1][iu + 1], uv_grid[iv + 1][iu]
            )
            add_quad(
                bot_grid[iv][iu + 1], bot_grid[iv][iu], bot_grid[iv + 1][iu], bot_grid[iv + 1][iu + 1],
                (uv_grid[iv][iu + 1][0], 0.04), (uv_grid[iv][iu][0], 0.04), (uv_grid[iv + 1][iu][0], 0.04), (uv_grid[iv + 1][iu + 1][0], 0.04)
            )
    for iu in range(n_u - 1):
        add_quad(
            top_grid[0][iu + 1], top_grid[0][iu], bot_grid[0][iu], bot_grid[0][iu + 1],
            uv_grid[0][iu + 1], uv_grid[0][iu], uv_grid[0][iu], uv_grid[0][iu + 1]
        )
        add_quad(
            top_grid[-1][iu], top_grid[-1][iu + 1], bot_grid[-1][iu + 1], bot_grid[-1][iu],
            uv_grid[-1][iu], uv_grid[-1][iu + 1], (uv_grid[-1][iu + 1][0], 0.98), (uv_grid[-1][iu + 1][0], 0.98)
        )
    for iv in range(n_v - 1):
        add_quad(
            top_grid[iv][0], top_grid[iv + 1][0], bot_grid[iv + 1][0], bot_grid[iv][0],
            uv_grid[iv][0], uv_grid[iv + 1][0], (0.01, uv_grid[iv + 1][0][1]), (0.01, uv_grid[iv][0][1])
        )
        add_quad(
            top_grid[iv + 1][-1], top_grid[iv][-1], bot_grid[iv][-1], bot_grid[iv + 1][-1],
            uv_grid[iv + 1][-1], uv_grid[iv][-1], (0.99, uv_grid[iv][-1][1]), (0.99, uv_grid[iv + 1][-1][1])
        )

    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    ensure_uvmap_name(me)
    o = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(o)
    return finalize_obj(o, mat_name, group, subsurf=subsurf, keep_custom_uv=True)

# ==============================================================================
# 1. SLEEK INNER BIOMECHANICAL HULL CORE & SERRATED THROAT SCALES (ScorchedShell / Chitin)
# ==============================================================================
core_stations = [
    # (y, width_x, top_z, mid_z, bot_z, rib_recess)
    (-5.95, 0.14, -0.24, -0.32, -0.40, 0.0),
    (-5.15, 0.82,  0.08, -0.22, -0.60, 0.0),
    (-4.15, 1.28,  0.36, -0.08, -0.76, 0.06),
    (-3.00, 1.52,  0.56,  0.02, -0.86, 0.14),
    (-1.85, 1.62,  0.70,  0.08, -0.92, 0.44),
    (-0.65, 1.68,  0.78,  0.12, -0.94, 0.54),
    ( 0.55, 1.64,  0.80,  0.10, -0.90, 0.50),
    ( 1.75, 1.50,  0.74,  0.08, -0.80, 0.32),
    ( 2.95, 1.30,  0.66,  0.12, -0.62, 0.14),
    ( 4.20, 0.98,  0.54,  0.22, -0.34, 0.04),
    ( 5.50, 0.48,  0.42,  0.28,  0.10, 0.0),
    ( 6.85, 0.10,  0.28,  0.24,  0.20, 0.0),
]

core_rings = []
n_core_seg = 24
for y, wx, tz, mz, bz, recess in core_stations:
    ring = []
    for k in range(n_core_seg):
        ang = 2.0 * math.pi * k / n_core_seg
        ca, sa = math.cos(ang), math.sin(ang)
        lat_indent = 1.0 - recess * (abs(ca) ** 1.8) * math.exp(-((sa - 0.02) * 2.2) ** 2)
        muscle_ripple = 1.0 + 0.03 * math.cos(6 * ang)
        x = ca * wx * lat_indent * muscle_ripple
        if sa >= 0:
            z = mz + (tz - mz) * (sa ** 0.85)
        else:
            z = mz - (mz - bz) * ((-sa) ** 0.9)
        ring.append(Vector((x, y, z)))
    core_rings.append(ring)

make_loft('Hull_InnerCore', core_rings, 'ScorchedShell', 'Hull', cap_ends=True, closed_ring=True, subsurf=1)

# 4 Serrated Chitin Throat Scales under the chin/jowl
for idx in range(4):
    sy = -4.25 + idx * 0.42
    sz = -0.68 - idx * 0.045
    sw = 0.72 + idx * 0.10
    throat_pts = [
        (0.0, sy - 0.25, sz + 0.08),
        (0.0, sy + 0.10, sz),
        (0.0, sy + 0.35, sz - 0.07),
    ]
    make_tube_along_curve(f'Ventral_Throat_Scale_{idx+1}', throat_pts, [(sw * 0.75, 0.10), (sw, 0.13), (sw * 0.55, 0.05)], 'Chitin', 'Hull', n_seg=10, ridge_amp=0.10, ridge_freq=4, subsurf=1)

# Sleek continuous ventral thoracic sternum keel where the 6 pairs of rib struts anchor
sternum_pts = [
    (0.0, -2.40, -0.82),
    (0.0, -0.80, -0.92),
    (0.0,  0.80, -0.90),
    (0.0,  2.35, -0.78),
]
make_tube_along_curve('Ventral_Thoracic_Sternum', sternum_pts, [(0.82, 0.14), (1.02, 0.16), (0.98, 0.16), (0.72, 0.12)], 'ScorchedShell', 'Hull', n_seg=12, ridge_amp=0.08, ridge_freq=6, subsurf=1)

# ==============================================================================
# 2. TIGHTLY SHINGLED DORSAL CHITIN CARAPACE PLATES (Paint - Wet Glossy Shell)
# ==============================================================================
# Predatory Tapered Cephalic Head Carapace (Y = -6.00 to -2.12)
make_carapace_plate(
    'Cephalic_Head_Carapace',
    y_front=-6.00, y_back=-2.12,
    thickness=0.056, width_scale=1.0,
    mat_name='Paint', group='Hull', subsurf=1, n_u=15, n_v=10, tip_sweep=0.38, nose_taper=True
)

# 7 Tightly Shingled Dorsal Tergite Carapace Plates evaluated on the continuous master surface
tergite_y_ranges = [
    (-2.40, -1.24),
    (-1.48, -0.32),
    (-0.56,  0.60),
    ( 0.36,  1.52),
    ( 1.28,  2.44),
    ( 2.20,  3.36),
    ( 3.12,  4.28),
]

for idx, (yf, yb) in enumerate(tergite_y_ranges):
    make_carapace_plate(
        f'Dorsal_Carapace_Tergite_{idx+1}',
        y_front=yf, y_back=yb,
        thickness=0.055, width_scale=1.0,
        mat_name='Paint', group='Hull', subsurf=1, n_u=15, n_v=8, tip_sweep=0.38
    )

# Single Continuous Pointed Caudal Tail Spear Carapace extending from Y = 4.02 to Y = +7.00 m (matching sheet.jpg!)
make_carapace_plate(
    'Caudal_Tail_Spear_Carapace',
    y_front=4.02, y_back=7.00,
    thickness=0.052, width_scale=1.0,
    mat_name='Paint', group='Hull', subsurf=1, n_u=15, n_v=10, tip_sweep=0.14, tail_taper=True
)

# Lateral Dorso-Pleural Brow Shields overhanging the ribcage and bioluminescent vents
for sign, side_lbl in ((1.0, 'L'), (-1.0, 'R')):
    brow_pts = [
        (sign * 1.18, -2.55,  0.30),
        (sign * 1.54, -1.45,  0.42),
        (sign * 1.74, -0.15,  0.48),
        (sign * 1.80,  1.15,  0.44),
        (sign * 1.66,  2.40,  0.36),
        (sign * 1.34,  3.75,  0.30),
        (sign * 0.80,  5.15,  0.28),
    ]
    brow_radii = [
        (0.24, 0.13),
        (0.34, 0.17),
        (0.40, 0.20),
        (0.42, 0.21),
        (0.38, 0.19),
        (0.30, 0.15),
        (0.14, 0.08),
    ]
    make_tube_along_curve(f'Lateral_Brow_Carapace_{side_lbl}', brow_pts, brow_radii, 'Paint', 'Hull', n_seg=12, ridge_amp=0.10, ridge_freq=3, subsurf=1)

    # Lower-Anterior Cheek Lip Plate framing the front blue eye-vent
    cheek_pts = [
        (sign * 0.42, -5.65, -0.34),
        (sign * 0.88, -4.85, -0.28),
        (sign * 1.24, -3.95, -0.18),
        (sign * 1.44, -2.95, -0.02),
    ]
    cheek_radii = [(0.12, 0.08), (0.22, 0.13), (0.26, 0.15), (0.20, 0.12)]
    make_tube_along_curve(f'Cheek_Genal_Shield_{side_lbl}', cheek_pts, cheek_radii, 'Paint', 'Hull', n_seg=12, ridge_amp=0.08, ridge_freq=3, subsurf=1)

    # Brownish Bare-Chitin Mandible Shoulder Bulge right behind the front cheek vent
    jowl_pts = [
        (sign * 0.94, -4.25, -0.38),
        (sign * 1.34, -3.45, -0.22),
        (sign * 1.44, -2.55, -0.10),
        (sign * 1.24, -1.85, -0.08),
    ]
    jowl_radii = [(0.22, 0.18), (0.38, 0.28), (0.34, 0.24), (0.18, 0.14)]
    make_tube_along_curve(f'Mandible_Chitin_Jowl_{side_lbl}', jowl_pts, jowl_radii, 'Chitin', 'Hull', n_seg=12, ridge_amp=0.09, ridge_freq=5, subsurf=1)

# ==============================================================================
# 3. COCKPIT INTERIOR & FLUSH TRANSLUCENT MEMBRANE DOME (Canopy node)
# ==============================================================================
seat_pts = [
    (0.0, -3.25, 0.56),
    (0.0, -2.90, 0.64),
    (0.0, -2.55, 0.78),
    (0.0, -2.32, 0.92),
]
make_tube_along_curve('Cockpit_Neural_Cradle', seat_pts, [(0.18, 0.06), (0.22, 0.08), (0.20, 0.08), (0.14, 0.06)], 'Chitin', 'Hull', n_seg=10, ridge_amp=0.08, ridge_freq=5, subsurf=1)

for sign, side_lbl in ((1.0, 'L'), (-1.0, 'R')):
    stick_pts = [
        (sign * 0.16, -3.15, 0.68),
        (sign * 0.18, -3.22, 0.80),
        (sign * 0.14, -3.12, 0.88),
    ]
    make_tube_along_curve(f'Cockpit_Control_Tendril_{side_lbl}', stick_pts, [0.028, 0.024, 0.034], 'Chitin', 'Hull', n_seg=8, subsurf=1)
    pod_pts = [
        (sign * 0.12, -3.35, 0.74),
        (sign * 0.15, -3.18, 0.82),
    ]
    make_tube_along_curve(f'Cockpit_Synapse_Display_{side_lbl}', pod_pts, [(0.045, 0.025), (0.06, 0.03)], 'Lamps', 'Hull', n_seg=8, subsurf=0, uv_zone=0.20)

    # Dark chitin suture collar framing the translucent Canopy_Membrane_Dome on the crown of the head shield
    collar_rim = []
    for t_c in (0.0, 0.25, 0.50, 0.75, 1.0):
        yc = -3.38 * (1.0 - t_c) + (-2.18) * t_c
        env = 0.38 + 0.62 * (math.sin(t_c * math.pi) ** 0.65)
        u_c = 0.30 * env
        w_m, z_s = eval_master_carapace_z(u_c, yc)
        collar_rim.append((sign * u_c * w_m, yc, z_s + 0.045))
    make_tube_along_curve(f'Canopy_Suture_Collar_{side_lbl}', collar_rim, [(0.03, 0.025), (0.045, 0.035), (0.05, 0.04), (0.045, 0.035), (0.03, 0.025)], 'ScorchedShell', 'Hull', n_seg=8, subsurf=1)

# Flush, low-profile Translucent Membrane Dome (Canopy moving node) sitting cleanly above Cephalic_Head_Carapace
CANOPY_PIVOT = (0.0, -2.18, 1.08)
make_carapace_plate(
    'Canopy_Membrane_Dome',
    y_front=-3.38, y_back=-2.18,
    thickness=0.024, width_scale=0.30,
    mat_name='DomeMembrane', group='Canopy', subsurf=1, n_u=11, n_v=7, tip_sweep=0.14, is_canopy=True
)

# ==============================================================================
# 4. SIX PAIRS OF CURVED RIB-LIKE STRUTS (Bare Chitin) & LATERAL PLASMA SACS
# ==============================================================================
for sign, side_lbl in ((1.0, 'L'), (-1.0, 'R')):
    for r_idx in range(6):
        t_rib = r_idx / 5.0
        y_top = -2.25 + r_idx * 0.56
        y_mid = y_top + 0.64
        y_bot = y_top + 1.28
        z_top = 0.38 + 0.08 * math.sin(t_rib * math.pi)
        z_mid = -0.18 + 0.05 * math.sin(t_rib * math.pi)
        z_bot = -0.76 + 0.08 * t_rib
        x_top = sign * (1.42 + 0.22 * math.sin(t_rib * math.pi))
        x_mid = sign * (1.68 + 0.22 * math.sin(t_rib * math.pi))
        x_bot = sign * (1.08 + 0.12 * math.sin(t_rib * math.pi))

        rib_pts = [
            (x_top * 0.88, y_top - 0.10, z_top + 0.06),
            (x_top,        y_top,        z_top),
            (x_mid,        y_mid,        z_mid),
            (x_bot * 1.14, (y_mid + y_bot) * 0.52, (z_mid + z_bot) * 0.52),
            (x_bot,        y_bot,        z_bot),
        ]
        rib_radii = [
            (0.10, 0.10),
            (0.125, 0.105),
            (0.11, 0.092),
            (0.095, 0.082),
            (0.11, 0.09),
        ]
        make_tube_along_curve(
            f'Rib_Strut_{side_lbl}_{r_idx+1}',
            rib_pts, rib_radii, 'Chitin', 'Hull',
            n_seg=12, ridge_amp=0.08, ridge_freq=4, subsurf=1
        )

    # Glowing Fiery Orange-Gold Lateral Plasma Sac nestled inside the posterior ribcage behind ribs 4, 5, 6! (uv_zone = 0.55)
    sac_pts = [
        (sign * 1.14, -0.25,  0.04),
        (sign * 1.40,  0.22, -0.02),
        (sign * 1.46,  0.72, -0.04),
        (sign * 1.36,  1.20,  0.00),
        (sign * 1.08,  1.55,  0.06),
    ]
    sac_radii = [
        (0.16, 0.18),
        (0.34, 0.40),
        (0.38, 0.44),
        (0.30, 0.35),
        (0.14, 0.16),
    ]
    make_tube_along_curve(f'Lateral_Plasma_Sac_{side_lbl}', sac_pts, sac_radii, 'Lamps', 'Hull', n_seg=16, ridge_amp=0.08, ridge_freq=6, subsurf=1, uv_zone=0.55)

    # Dark vascular tendrils wrapping across the Lateral Plasma Sac
    for v_idx in range(4):
        vy = -0.05 + v_idx * 0.36
        vein_pts = [
            (sign * 1.26, vy - 0.16,  0.34),
            (sign * 1.64, vy + 0.08, -0.03),
            (sign * 1.24, vy + 0.32, -0.38),
        ]
        make_tube_along_curve(f'Plasma_Sac_Vein_{side_lbl}_{v_idx+1}', vein_pts, [0.026, 0.035, 0.024], 'ScorchedShell', 'Hull', n_seg=8, subsurf=1)

    # Anterior Cephalic Glowing Plasma Node positioned cleanly on the dorso-lateral head shield ahead of the canopy
    spot_pts = []
    for y_sp in (-4.12, -3.92, -3.72):
        w_m, z_s = eval_master_carapace_z(0.42, y_sp)
        spot_pts.append((sign * 0.42 * w_m, y_sp, z_s + 0.035))
    make_tube_along_curve(f'Cephalic_Plasma_Node_{side_lbl}', spot_pts, [(0.035, 0.022), (0.082, 0.042), (0.035, 0.022)], 'Lamps', 'Hull', n_seg=8, subsurf=1, uv_zone=0.55)

# ==============================================================================
# 5. BIOLUMINESCENT VENTS (Lamps UV zone 0.20 = Electric Blue-Violet)
# ==============================================================================
for sign, side_lbl in ((1.0, 'L'), (-1.0, 'R')):
    # 5a. Prominent Almond-Shaped Cheek Bioluminescent Eye-Vent
    vent1_pts = [
        (sign * 0.92, -4.55, -0.06),
        (sign * 1.18, -4.02,  0.04),
        (sign * 1.34, -3.45,  0.14),
    ]
    make_tube_along_curve(f'BioVent_Cheek_Upper_{side_lbl}', vent1_pts, [(0.05, 0.04), (0.16, 0.11), (0.05, 0.04)], 'Lamps', 'Hull', n_seg=12, subsurf=1, uv_zone=0.20)
    make_tube_along_curve(f'BioVent_Cheek_Upper_Rim_{side_lbl}', [
        (sign * 0.88, -4.62,  0.00),
        (sign * 1.20, -4.02,  0.14),
        (sign * 1.36, -3.40,  0.22),
    ], [0.04, 0.065, 0.04], 'ScorchedShell', 'Hull', n_seg=8, subsurf=1)

    # 5b. Lower Snout Bioluminescent Slit Vent
    vent2_pts = [
        (sign * 0.52, -5.35, -0.30),
        (sign * 0.78, -4.92, -0.24),
        (sign * 0.96, -4.52, -0.18),
    ]
    make_tube_along_curve(f'BioVent_Snout_Lower_{side_lbl}', vent2_pts, [(0.04, 0.03), (0.11, 0.07), (0.04, 0.03)], 'Lamps', 'Hull', n_seg=10, subsurf=1, uv_zone=0.20)

    # 5c. Recessed Inter-Costal Bioluminescent Gill Pockets tucked inside the ribcage between ribs 1-4
    for g_i in range(4):
        gy = -1.95 + g_i * 0.56
        ig_pts = [
            (sign * 1.30, gy,        0.20),
            (sign * 1.44, gy + 0.28, -0.12),
            (sign * 1.14, gy + 0.52, -0.44),
        ]
        make_tube_along_curve(f'BioVent_InterCostal_{side_lbl}_{g_i+1}', ig_pts, [(0.05, 0.04), (0.11, 0.09), (0.05, 0.04)], 'Lamps', 'Hull', n_seg=8, subsurf=1, uv_zone=0.20)

    # 5d. Ventral-Posterior Bioluminescent Slit right behind the last rib strut (visible in sheet.jpg side view!)
    vent_va_pts = [
        (sign * 0.96, 1.85, -0.58),
        (sign * 1.08, 2.25, -0.52),
        (sign * 0.92, 2.65, -0.44),
    ]
    make_tube_along_curve(f'BioVent_VentralAft_{side_lbl}', vent_va_pts, [(0.04, 0.03), (0.10, 0.07), (0.04, 0.03)], 'Lamps', 'Hull', n_seg=8, subsurf=1, uv_zone=0.20)

    # 5e. Dorsal-Posterior Bioluminescent Gill Array (Y = 1.55 to 3.40, Z = 0.48, clearly visible under the rear tergites with 5 diagonal chitin gill bars!)
    vent4_pts = [
        (sign * 1.38, 1.55, 0.50),
        (sign * 1.52, 2.48, 0.48),
        (sign * 1.26, 3.40, 0.42),
    ]
    make_tube_along_curve(f'BioVent_DorsalAft_{side_lbl}', vent4_pts, [(0.08, 0.05), (0.22, 0.14), (0.08, 0.05)], 'Lamps', 'Hull', n_seg=10, subsurf=1, uv_zone=0.20)
    for g_idx in range(5):
        gy = 1.80 + g_idx * 0.32
        gill_pts = [
            (sign * 1.30, gy - 0.10, 0.58),
            (sign * 1.60, gy + 0.04, 0.48),
            (sign * 1.40, gy + 0.16, 0.36),
        ]
        make_tube_along_curve(f'BioVent_GillBar_{side_lbl}_{g_idx+1}', gill_pts, [0.028, 0.034, 0.026], 'Chitin', 'Hull', n_seg=8, subsurf=0)

# ==============================================================================
# 6. REAR PLASMA PROPULSION SAC & TWIN BIO-THRUSTER NOZZLES (Engines = Acid Green)
# ==============================================================================
rear_sac_pts = [
    (0.0, 2.75, -0.18),
    (0.0, 3.35, -0.14),
    (0.0, 4.00, -0.10),
    (0.0, 4.60, -0.06),
]
make_tube_along_curve('Rear_Plasma_Propulsion_Sac', rear_sac_pts, [(0.28, 0.18), (0.42, 0.24), (0.38, 0.22), (0.22, 0.14)], 'Engines', 'Hull', n_seg=16, ridge_amp=0.08, ridge_freq=6, subsurf=1)

for c_idx in range(4):
    cy = 2.90 + c_idx * 0.42
    cradle_pts = [
        (-0.46, cy, -0.06),
        (-0.34, cy + 0.08, -0.34),
        ( 0.00, cy + 0.12, -0.42),
        ( 0.34, cy + 0.08, -0.34),
        ( 0.46, cy, -0.06),
    ]
    make_tube_along_curve(f'Rear_Sac_Cradle_Rib_{c_idx+1}', cradle_pts, [0.044, 0.054, 0.058, 0.054, 0.044], 'Chitin', 'Hull', n_seg=8, subsurf=1)

THRUSTER_POS = {}
for sign, idx, side_lbl in ((1.0, 1, 'L'), (-1.0, 2, 'R')):
    tx, ty, tz = sign * 0.84, 4.65, -0.04
    THRUSTER_POS[idx] = (tx, ty + 0.22, tz)
    nac_pts = [
        (sign * 0.94, 2.85,  0.00),
        (sign * 0.90, 3.85, -0.02),
        (tx,          ty,    tz),
        (tx * 0.96,   ty + 0.24, tz),
    ]
    make_tube_along_curve(f'BioThruster_Shroud_{side_lbl}', nac_pts, [(0.42, 0.32), (0.45, 0.35), (0.40, 0.32), (0.32, 0.26)], 'ScorchedShell', 'Hull', n_seg=16, ridge_amp=0.07, ridge_freq=8, subsurf=1)

    for p_idx in range(8):
        ang = 2.0 * math.pi * p_idx / 8.0
        px = tx + math.cos(ang) * 0.30
        pz = tz + math.sin(ang) * 0.24
        petal_pts = [
            (tx + math.cos(ang) * 0.38, ty - 0.38, tz + math.sin(ang) * 0.30),
            (px, ty + 0.05, pz),
            (tx + math.cos(ang) * 0.22, ty + 0.30, tz + math.sin(ang) * 0.18),
        ]
        make_tube_along_curve(f'BioThruster_Petal_{side_lbl}_{p_idx+1}', petal_pts, [0.055, 0.048, 0.024], 'Chitin', 'Hull', n_seg=6, subsurf=0)

    core_pts = [
        (tx, ty - 0.45, tz),
        (tx, ty + 0.02, tz),
        (tx, ty + 0.20, tz),
    ]
    make_tube_along_curve(f'BioThruster_GlowingCore_{side_lbl}', core_pts, [(0.22, 0.18), (0.26, 0.21), (0.18, 0.14)], 'Engines', 'Hull', n_seg=14, ridge_amp=0.05, ridge_freq=6, subsurf=1)

# ==============================================================================
# 7. TWO FRONT ORGANIC CANNON SPURS (Turret_1 + Gun_1, Turret_2 + Gun_2)
# ==============================================================================
TURRET_PIVOTS = {}
GUN_PIVOTS = {}
MUZZLE_POS = {}

for sign, idx, side_lbl in ((1.0, 1, 'L'), (-1.0, 2, 'R')):
    t_piv = (sign * 0.88, -3.95, -0.46)
    g_piv = (sign * 0.92, -4.35, -0.48)
    TURRET_PIVOTS[f'Turret_{idx}'] = t_piv
    GUN_PIVOTS[f'Gun_{idx}'] = g_piv

    t_pts = [
        (sign * 0.80, -3.55, -0.42),
        (t_piv[0],    t_piv[1], t_piv[2]),
        (g_piv[0],    g_piv[1], g_piv[2]),
    ]
    make_tube_along_curve(f'Turret_{idx}_Socket', t_pts, [(0.32, 0.24), (0.35, 0.28), (0.25, 0.20)], 'ScorchedShell', f'Turret_{idx}', n_seg=12, ridge_amp=0.08, ridge_freq=6, subsurf=1)
    collar_pts = [
        (sign * 0.86, -3.72, -0.40),
        (sign * 0.94, -4.12, -0.44),
        (sign * 0.92, -4.40, -0.48),
    ]
    make_tube_along_curve(f'Turret_{idx}_Collar', collar_pts, [(0.34, 0.26), (0.32, 0.24), (0.24, 0.19)], 'Chitin', f'Turret_{idx}', n_seg=12, ridge_amp=0.10, ridge_freq=5, subsurf=1)

    # Forward-extending predatory chitin cannon spur matching sheet.jpg's sleek horizontal-swept mandible profile
    spur_pts = [
        (g_piv[0],          g_piv[1],  g_piv[2]),
        (sign * 0.90,       -4.95,    -0.52),
        (sign * 0.82,       -5.65,    -0.53),
        (sign * 0.70,       -6.38,    -0.49),
        (sign * 0.58,       -7.00,    -0.42),
    ]
    spur_radii = [
        (0.21, 0.18),
        (0.17, 0.15),
        (0.12, 0.10),
        (0.075, 0.065),
        (0.028, 0.024),
    ]
    make_tube_along_curve(f'Gun_{idx}_CannonSpur', spur_pts, spur_radii, 'Chitin', f'Gun_{idx}', n_seg=14, ridge_amp=0.08, ridge_freq=4, subsurf=1)

    # 4 Serrated backward-raked chitin barbs along the outer-lower edge of the cannon spur (matching sheet.jpg!)
    for r_i in range(4):
        ry = -4.52 - r_i * 0.30
        rx = sign * (0.94 - r_i * 0.025)
        rz = -0.52 - r_i * 0.01
        barb_tooth = [
            (rx * 0.92, ry - 0.16, rz + 0.02),
            (rx * 1.06, ry,        rz - 0.03),
            (rx * 1.14, ry + 0.18, rz - 0.07),
        ]
        rad = 0.095 - r_i * 0.012
        make_tube_along_curve(f'Gun_{idx}_HornBarb_{r_i+1}', barb_tooth, [rad * 0.65, rad, 0.018], 'Chitin', f'Gun_{idx}', n_seg=8, ridge_amp=0.05, ridge_freq=4, subsurf=0)

    # Lower secondary mandible spur underneath the main cannon spur
    barb_pts = [
        (sign * 0.82, -4.45, -0.56),
        (sign * 0.76, -5.05, -0.66),
        (sign * 0.66, -5.68, -0.68),
        (sign * 0.56, -6.18, -0.62),
    ]
    make_tube_along_curve(f'Gun_{idx}_LowerBarb', barb_pts, [(0.12, 0.10), (0.095, 0.085), (0.06, 0.05), (0.02, 0.018)], 'Chitin', f'Gun_{idx}', n_seg=10, ridge_amp=0.06, ridge_freq=4, subsurf=1)

    MUZZLE_POS[f'Muzzle_{idx}'] = (sign * 0.58, -7.00, -0.42)

# ==============================================================================
# 8. SWEPT MEMBRANE WING FINS (WingFin_L, WingFin_R) MATCHING SHEET.JPG PROFILE
# ==============================================================================
WING_PIVOTS = {
    'WingFin_L': (1.44, 1.15, 0.10),
    'WingFin_R': (-1.44, 1.15, 0.10),
}

for sign, side_lbl in ((1.0, 'L'), (-1.0, 'R')):
    w_grp = f'WingFin_{side_lbl}'
    # Sculpted Dorsal-Anterior Wing Root Carapace Shoulder with swept-back horn (Paint)
    cuff_pts = [
        (sign * 1.28, 0.40,  0.22),
        (sign * 1.95, 1.10,  0.22),
        (sign * 2.65, 1.90,  0.30),
        (sign * 3.25, 2.75,  0.44),
    ]
    make_tube_along_curve(f'Wing_Root_Carapace_{side_lbl}', cuff_pts, [(0.34, 0.20), (0.30, 0.18), (0.20, 0.13), (0.04, 0.03)], 'Paint', w_grp, n_seg=12, ridge_amp=0.08, ridge_freq=4, subsurf=1)

    # 5 radiating curved Chitinous Fin Rays matching sheet.jpg's sleek horizontal-swept side profile & 3/4 view!
    ray0 = [
        (sign * 1.42, 0.85,  0.26),
        (sign * 2.25, 1.65,  0.38),
        (sign * 3.15, 2.48,  0.48),
        (sign * 3.95, 3.25,  0.54),
        (sign * 4.55, 3.85,  0.56),
    ]
    ray1 = [
        (sign * 1.44, 0.78,  0.14),
        (sign * 2.78, 1.58,  0.10),
        (sign * 4.10, 2.45,  0.04),
        (sign * 5.20, 3.25, -0.02),
        (sign * 6.00, 3.92, -0.06),
    ]
    ray2 = [
        (sign * 1.48, 1.22,  0.00),
        (sign * 2.70, 2.22, -0.18),
        (sign * 3.88, 3.25, -0.38),
        (sign * 4.82, 4.15, -0.56),
        (sign * 5.52, 4.85, -0.70),
    ]
    ray3 = [
        (sign * 1.44, 1.68, -0.14),
        (sign * 2.48, 2.75, -0.44),
        (sign * 3.45, 3.85, -0.76),
        (sign * 4.22, 4.78, -1.04),
        (sign * 4.82, 5.48, -1.24),
    ]
    ray4 = [
        (sign * 1.34, 2.12, -0.26),
        (sign * 1.98, 3.05, -0.54),
        (sign * 2.48, 3.95, -0.80),
        (sign * 2.86, 4.70, -0.98),
        (sign * 3.16, 5.25, -1.10),
    ]
    rays = [ray0, ray1, ray2, ray3, ray4]
    ray_rads = [
        [(0.11, 0.08), (0.09, 0.07), (0.065, 0.05), (0.042, 0.032), (0.016, 0.014)],
        [(0.16, 0.12), (0.13, 0.10), (0.10, 0.08), (0.065, 0.055), (0.022, 0.018)],
        [(0.12, 0.09), (0.095, 0.075), (0.075, 0.06), (0.05, 0.04), (0.018, 0.015)],
        [(0.11, 0.08), (0.085, 0.065), (0.065, 0.05), (0.045, 0.035), (0.016, 0.014)],
        [(0.10, 0.08), (0.08, 0.06), (0.06, 0.045), (0.04, 0.03), (0.015, 0.013)],
    ]
    for r_i, (r_pts, r_rad) in enumerate(zip(rays, ray_rads)):
        make_tube_along_curve(f'Wing_ChitinRay_{side_lbl}_{r_i+1}', r_pts, r_rad, 'Chitin', w_grp, n_seg=10, ridge_amp=0.07, ridge_freq=4, subsurf=1)

    # Stretched Biological Membrane Webbing with properly named 'UVMap' web-local UVs
    for web_idx in range(4):
        ra = [Vector(p) for p in rays[web_idx]]
        rb = [Vector(p) for p in rays[web_idx + 1]]
        bm = bmesh.new()
        uv_lay = bm.loops.layers.uv.new('UVMap')
        nu, nv = 11, 11
        top_w, bot_w, uv_w = [], [], []
        for iv in range(nv):
            tv = iv / (nv - 1)
            t_ray = tv * 0.92
            idx_seg = min(int(t_ray * 4), 3)
            frac = t_ray * 4 - idx_seg
            pa = ra[idx_seg].lerp(ra[idx_seg + 1], frac)
            pb = rb[idx_seg].lerp(rb[idx_seg + 1], frac)
            row_t, row_b, row_u = [], [], []
            for iu in range(nu):
                tu = iu / (nu - 1)
                p = pa.lerp(pb, tu)
                scallop = -0.48 * math.sin(tu * math.pi) * (tv ** 2.0)
                camber = 0.10 * math.sin(tu * math.pi) * math.sin(tv * math.pi)
                vein_wave = 0.014 * math.sin(tu * math.pi * 6.0 + tv * 3.0) * math.sin(tu * math.pi)
                p_final = Vector((p.x + sign * camber * 0.30, p.y + scallop, p.z + camber + vein_wave))
                th = 0.018 * (1.0 - 0.55 * tv)
                row_t.append(bm.verts.new(p_final + Vector((0, 0, th))))
                row_b.append(bm.verts.new(p_final - Vector((0, 0, th))))
                row_u.append((0.04 + 0.92 * tu, 0.04 + 0.92 * tv))
            top_w.append(row_t)
            bot_w.append(row_b)
            uv_w.append(row_u)

        def add_mquad(v0, v1, v2, v3, u0, u1, u2, u3):
            f = bm.faces.new((v0, v1, v2, v3))
            f.loops[0][uv_lay].uv = u0
            f.loops[1][uv_lay].uv = u1
            f.loops[2][uv_lay].uv = u2
            f.loops[3][uv_lay].uv = u3

        for iv in range(nv - 1):
            for iu in range(nu - 1):
                if sign > 0:
                    add_mquad(top_w[iv][iu], top_w[iv][iu + 1], top_w[iv + 1][iu + 1], top_w[iv + 1][iu], uv_w[iv][iu], uv_w[iv][iu + 1], uv_w[iv + 1][iu + 1], uv_w[iv + 1][iu])
                    add_mquad(bot_w[iv][iu + 1], bot_w[iv][iu], bot_w[iv + 1][iu], bot_w[iv + 1][iu + 1], uv_w[iv][iu + 1], uv_w[iv][iu], uv_w[iv + 1][iu], uv_w[iv + 1][iu + 1])
                else:
                    add_mquad(top_w[iv][iu + 1], top_w[iv][iu], top_w[iv + 1][iu], top_w[iv + 1][iu + 1], uv_w[iv][iu + 1], uv_w[iv][iu], uv_w[iv + 1][iu], uv_w[iv + 1][iu + 1])
                    add_mquad(bot_w[iv][iu], bot_w[iv][iu + 1], bot_w[iv + 1][iu + 1], bot_w[iv + 1][iu], uv_w[iv][iu], uv_w[iv][iu + 1], uv_w[iv + 1][iu + 1], uv_w[iv + 1][iu])
        for iu in range(nu - 1):
            add_mquad(top_w[0][iu], top_w[0][iu + 1], bot_w[0][iu + 1], bot_w[0][iu], uv_w[0][iu], uv_w[0][iu + 1], uv_w[0][iu + 1], uv_w[0][iu])
            add_mquad(top_w[-1][iu + 1], top_w[-1][iu], bot_w[-1][iu], bot_w[-1][iu + 1], uv_w[-1][iu + 1], uv_w[-1][iu], uv_w[-1][iu], uv_w[-1][iu + 1])
        for iv in range(nv - 1):
            add_mquad(top_w[iv + 1][0], top_w[iv][0], bot_w[iv][0], bot_w[iv + 1][0], uv_w[iv + 1][0], uv_w[iv][0], uv_w[iv][0], uv_w[iv + 1][0])
            add_mquad(top_w[iv][-1], top_w[iv + 1][-1], bot_w[iv + 1][-1], bot_w[iv][-1], uv_w[iv][-1], uv_w[iv + 1][-1], uv_w[iv + 1][-1], uv_w[iv][-1])
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
        me = bpy.data.meshes.new(f'Wing_Membrane_Web_{side_lbl}_{web_idx+1}')
        bm.to_mesh(me)
        bm.free()
        ensure_uvmap_name(me)
        o = bpy.data.objects.new(f'Wing_Membrane_Web_{side_lbl}_{web_idx+1}', me)
        bpy.context.collection.objects.link(o)
        finalize_obj(o, 'Membrane', w_grp, subsurf=1, keep_custom_uv=True)

    # Bioluminescent wingtip nav organ (Lamps UV zone 0.80 = Red on Left (+X), 0.92 = Green on Right (-X))
    nav_uv = 0.80 if sign > 0 else 0.92
    nav_pts = [
        (sign * 5.68, 3.75, -0.05),
        (sign * 5.94, 3.90, -0.06),
    ]
    make_tube_along_curve(f'Wing_NavOrgan_{side_lbl}', nav_pts, [0.038, 0.020], 'Lamps', w_grp, n_seg=8, subsurf=0, uv_zone=nav_uv)

# Subtle Dorsal Beacon Bioluminescent Organ nestled flush into the dorsal crest of Tergite 3
beacon_pts = []
for y_b in (0.12, 0.28, 0.44):
    _, z_b = eval_master_carapace_z(0.0, y_b)
    beacon_pts.append((0.0, y_b, z_b + 0.015))
make_tube_along_curve('Dorsal_Beacon_Organ', beacon_pts, [0.018, 0.028, 0.018], 'Lamps', 'Hull', n_seg=8, subsurf=0, uv_zone=0.20)

# ==============================================================================
# 9. JOIN GROUPS, SET UP HIERARCHY & EXACT 14.0 x 12.0 x 4.5 m SCALING
# ==============================================================================
def join_group(name, pivot=(0.0, 0.0, 0.0)):
    objs = GROUPS[name]
    bpy.ops.object.select_all(action='DESELECT')
    for o in objs:
        ensure_uvmap_name(o.data)
        o.select_set(True)
    bpy.context.view_layer.objects.active = objs[0]
    bpy.ops.object.join()
    o = bpy.context.object
    o.name = name
    p = Vector(pivot)
    o.data.transform(Matrix.Translation(-p))
    o.location = p
    o.rotation_euler = (0.0, 0.0, 0.0)
    o.scale = (1.0, 1.0, 1.0)
    bm = bmesh.new()
    bm.from_mesh(o.data)
    bmesh.ops.triangulate(bm, faces=list(bm.faces), quad_method='BEAUTY', ngon_method='BEAUTY')
    bad = [f for f in bm.faces if f.calc_area() < 1e-10]
    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', (0.0, 0.0, 0.0))
C = join_group('Canopy', CANOPY_PIVOT)
T1 = join_group('Turret_1', TURRET_PIVOTS['Turret_1'])
G1 = join_group('Gun_1', GUN_PIVOTS['Gun_1'])
T2 = join_group('Turret_2', TURRET_PIVOTS['Turret_2'])
G2 = join_group('Gun_2', GUN_PIVOTS['Gun_2'])
WL = join_group('WingFin_L', WING_PIVOTS['WingFin_L'])
WR = join_group('WingFin_R', WING_PIVOTS['WingFin_R'])

for child in (C, T1, T2, WL, WR):
    child.parent = H
    child.matrix_parent_inverse = H.matrix_world.inverted()

G1.parent = T1
G1.location = Vector(GUN_PIVOTS['Gun_1']) - Vector(TURRET_PIVOTS['Turret_1'])
G2.parent = T2
G2.location = Vector(GUN_PIVOTS['Gun_2']) - Vector(TURRET_PIVOTS['Turret_2'])

MESH_OBJS = [H, C, T1, G1, T2, G2, WL, WR]

MOVING = {
    'Canopy': (-1.0, 0.0, 0.0),
    'Turret_1': (0.0, 0.0, 1.0),
    'Gun_1': (1.0, 0.0, 0.0),
    'Turret_2': (0.0, 0.0, 1.0),
    'Gun_2': (1.0, 0.0, 0.0),
    'WingFin_L': (0.0, 0.0, 1.0),
    'WingFin_R': (0.0, 0.0, -1.0),
}

def make_empty(name, world_pos, parent_obj):
    o = bpy.data.objects.new(name, None)
    bpy.context.collection.objects.link(o)
    o.parent = parent_obj
    bpy.context.view_layer.update()
    o.location = Vector(world_pos) - parent_obj.matrix_world.translation
    o.empty_display_size = 0.18
    return o

make_empty('Thruster_1', THRUSTER_POS[1], H)
make_empty('Thruster_2', THRUSTER_POS[2], H)
make_empty('Muzzle_1', MUZZLE_POS['Muzzle_1'], G1)
make_empty('Muzzle_2', MUZZLE_POS['Muzzle_2'], G2)
make_empty('Hardpoint_1', (1.85, 0.85, -0.82), H)
make_empty('Hardpoint_2', (-1.85, 0.85, -0.82), H)
make_empty('Seat_Pilot', (0.0, -2.90, 0.68), H)
make_empty('Camera_Chase', (0.0, 12.0, 4.8), H)
make_empty('Light_Nav_L', (5.94, 3.90, -0.06), WL)
make_empty('Light_Nav_R', (-5.94, 3.90, -0.06), WR)
make_empty('Light_Beacon', (0.0, 0.28, eval_master_carapace_z(0.0, 0.28)[1] + 0.045), H)

# Normalize exact bounding box to 12.0 m wide (X), 14.0 m long (Y), 4.5 m high (Z)
bpy.context.view_layer.update()
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 pts) for i in range(3)])
hi = Vector([max(p[i] for p in pts) for i in range(3)])
scale = Vector((12.0 / (hi.x - lo.x), 14.0 / (hi.y - lo.y), 4.5 / (hi.z - lo.z)))
center = Vector(((hi.x + lo.x) * 0.5, (hi.y + lo.y) * 0.5, (hi.z + lo.z) * 0.5))

for o in MESH_OBJS:
    for v in o.data.vertices:
        v.co = Vector([
            (v.co[i] - (center[i] if o == H else 0.0)) * scale[i]
            for i in range(3)
        ])
    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()

for o in list(S.objects):
    if o == H:
        o.location = Vector((0.0, 0.0, 0.0))
    elif o.parent == H:
        o.location = Vector([(o.location[i] - center[i]) * scale[i] for i in range(3)])
    elif o.parent in MESH_OBJS:
        o.location = Vector([o.location[i] * scale[i] for i in range(3)])

bpy.context.view_layer.update()

# ==============================================================================
# 10. CYCLES TEXTURE BAKING WITH POLY HAVEN CC0 + PLATE/MEMBRANE/CELLULAR DETAIL
# ==============================================================================
def bake_materials():
    download_textures()
    ph_imgs = {}
    for asset in PH_ASSETS:
        ph_imgs[asset] = {}
        for mp in ('diff', 'nor_gl', 'arm'):
            im = bpy.data.images.load(str(TEX / f'{asset}_{mp}_1k.jpg'), check_existing=True)
            if mp != 'diff':
                im.colorspace_settings.name = 'Non-Color'
            ph_imgs[asset][mp] = im

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

    mat_sources = {
        'Paint': ('rock_pitted_mossy', 'leather_white'),
        'Chitin': ('bark_brown_02', 'worn_rock_natural_01'),
        'ScorchedShell': ('worn_rock_natural_01', 'fabric_leather_02'),
        'Membrane': ('fabric_leather_02', 'leather_white'),
        'DomeMembrane': ('leather_white', 'fabric_leather_02'),
        'Engines': ('rock_pitted_mossy', 'leather_white'),
        'Lamps': ('rock_pitted_mossy', 'leather_white'),
    }

    mat_sizes = {
        'Paint': (1024, 512),
        'Chitin': (512, 256),
        'ScorchedShell': (512, 256),
        'Membrane': (512, 256),
        'DomeMembrane': (256, 128),
        'Engines': (256, 64),
        'Lamps': (512, 64),
    }

    for name, m in M.items():
        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')
        uvsep = nt.nodes.new('ShaderNodeSeparateXYZ')
        L.new(uv.outputs['UV'], uvsep.inputs[0])

        simple = name in ('Engines', 'Lamps', 'DomeMembrane')
        base_sz, orm_sz = mat_sizes[name]
        src_a, src_b = mat_sources[name]

        mapping = nt.nodes.new('ShaderNodeMapping')
        mapping.inputs['Scale'].default_value = (2.5, 2.5, 2.5)
        L.new(uv.outputs['UV'], mapping.inputs['Vector'])

        tex_a = {}
        for mp in ('diff', 'nor_gl', 'arm'):
            n = nt.nodes.new('ShaderNodeTexImage')
            n.image = ph_imgs[src_a][mp]
            L.new(mapping.outputs['Vector'], n.inputs['Vector'])
            tex_a[mp] = n

        tex_b = nt.nodes.new('ShaderNodeTexImage')
        tex_b.image = ph_imgs[src_b]['diff']
        L.new(mapping.outputs['Vector'], tex_b.inputs['Vector'])

        bw_a = nt.nodes.new('ShaderNodeRGBToBW')
        L.new(tex_a['diff'].outputs['Color'], bw_a.inputs[0])
        bw_b = nt.nodes.new('ShaderNodeRGBToBW')
        L.new(tex_b.outputs['Color'], bw_b.inputs[0])

        noise = nt.nodes.new('ShaderNodeTexNoise')
        noise.inputs['Scale'].default_value = 28.0
        noise.inputs['Detail'].default_value = 10.0
        noise.inputs['Roughness'].default_value = 0.65
        L.new(uv.outputs['UV'], noise.inputs['Vector'])

        vor = nt.nodes.new('ShaderNodeTexVoronoi')
        vor.feature = 'DISTANCE_TO_EDGE'
        vor.inputs['Scale'].default_value = 28.0
        L.new(uv.outputs['UV'], vor.inputs['Vector'])

        blend_bw = nt.nodes.new('ShaderNodeMixRGB')
        blend_bw.blend_type = 'MIX'
        blend_bw.inputs[0].default_value = 0.45
        L.new(bw_a.outputs[0], blend_bw.inputs[1])
        L.new(bw_b.outputs[0], blend_bw.inputs[2])

        if name == 'Paint':
            ramp = nt.nodes.new('ShaderNodeValToRGB')
            ramp.color_ramp.elements[0].position = 0.12
            ramp.color_ramp.elements[0].color = (0.64, 0.66, 0.64, 1.0)
            ramp.color_ramp.elements[1].position = 0.85
            ramp.color_ramp.elements[1].color = (0.95, 0.96, 0.95, 1.0)
            L.new(blend_bw.outputs[0], ramp.inputs[0])

            v_ramp = nt.nodes.new('ShaderNodeValToRGB')
            v_ramp.color_ramp.elements[0].position = 0.04
            v_ramp.color_ramp.elements[0].color = (0.36, 0.34, 0.36, 1.0)
            v_ramp.color_ramp.elements[1].position = 0.24
            v_ramp.color_ramp.elements[1].color = (0.90, 0.90, 0.90, 1.0)
            e_lip = v_ramp.color_ramp.elements.new(0.84)
            e_lip.color = (0.86, 0.84, 0.82, 1.0)
            e_edge = v_ramp.color_ramp.elements.new(0.95)
            e_edge.color = (0.32, 0.28, 0.30, 1.0)
            L.new(uvsep.outputs['Y'], v_ramp.inputs[0])

            mix_v = nt.nodes.new('ShaderNodeMixRGB')
            mix_v.blend_type = 'MULTIPLY'
            mix_v.inputs[0].default_value = 0.85
            L.new(ramp.outputs[0], mix_v.inputs[1])
            L.new(v_ramp.outputs[0], mix_v.inputs[2])
            base_out = mix_v.outputs[0]

            nm = nt.nodes.new('ShaderNodeNormalMap')
            nm.inputs['Strength'].default_value = 0.16
            L.new(tex_a['nor_gl'].outputs['Color'], nm.inputs['Color'])
            bump = nt.nodes.new('ShaderNodeBump')
            bump.inputs['Strength'].default_value = 0.06
            bump.inputs['Distance'].default_value = 0.008
            L.new(noise.outputs['Fac'], bump.inputs['Height'])
            L.new(nm.outputs[0], bump.inputs['Normal'])
            L.new(bump.outputs[0], p.inputs['Normal'])

        elif name == 'Membrane':
            # Web-local UVs: u=0.5 is pale sage-green center of the membrane web, u=0/1 near the Chitin rays is dusty rose-mauve!
            u_ramp = nt.nodes.new('ShaderNodeValToRGB')
            u_ramp.color_ramp.elements[0].position = 0.06
            u_ramp.color_ramp.elements[0].color = (0.36, 0.20, 0.28, 1.0)
            u_ramp.color_ramp.elements[1].position = 0.50
            u_ramp.color_ramp.elements[1].color = (0.46, 0.52, 0.44, 1.0)
            e_u2 = u_ramp.color_ramp.elements.new(0.94)
            e_u2.color = (0.36, 0.20, 0.28, 1.0)
            L.new(uvsep.outputs['X'], u_ramp.inputs[0])

            vein_vor = nt.nodes.new('ShaderNodeTexVoronoi')
            vein_vor.feature = 'DISTANCE_TO_EDGE'
            vein_vor.inputs['Scale'].default_value = 11.0
            L.new(uv.outputs['UV'], vein_vor.inputs['Vector'])

            vramp = nt.nodes.new('ShaderNodeValToRGB')
            vramp.color_ramp.elements[0].position = 0.0
            vramp.color_ramp.elements[0].color = (0.42, 0.24, 0.34, 1.0)
            vramp.color_ramp.elements[1].position = 0.08
            vramp.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
            L.new(vein_vor.outputs['Distance'], vramp.inputs[0])

            mmix1 = nt.nodes.new('ShaderNodeMixRGB')
            mmix1.blend_type = 'MULTIPLY'
            mmix1.inputs[0].default_value = 0.55
            L.new(u_ramp.outputs[0], mmix1.inputs[1])
            L.new(vramp.outputs[0], mmix1.inputs[2])

            mmix2 = nt.nodes.new('ShaderNodeMixRGB')
            mmix2.blend_type = 'MULTIPLY'
            mmix2.inputs[0].default_value = 0.25
            L.new(mmix1.outputs[0], mmix2.inputs[1])
            L.new(blend_bw.outputs[0], mmix2.inputs[2])
            base_out = mmix2.outputs[0]

            nm = nt.nodes.new('ShaderNodeNormalMap')
            nm.inputs['Strength'].default_value = 0.25
            L.new(tex_a['nor_gl'].outputs['Color'], nm.inputs['Color'])
            bump = nt.nodes.new('ShaderNodeBump')
            bump.inputs['Strength'].default_value = 0.08
            bump.inputs['Distance'].default_value = 0.008
            L.new(vein_vor.outputs['Distance'], bump.inputs['Height'])
            L.new(nm.outputs[0], bump.inputs['Normal'])
            L.new(bump.outputs[0], p.inputs['Normal'])

        elif name in ('Chitin', 'ScorchedShell', 'DomeMembrane'):
            palettes = {
                'Chitin': ((0.18, 0.11, 0.07, 1.0), (0.52, 0.38, 0.26, 1.0)),
                'ScorchedShell': ((0.03, 0.025, 0.022, 1.0), (0.22, 0.11, 0.06, 1.0)),
                'DomeMembrane': ((0.08, 0.11, 0.10, 1.0), (0.18, 0.24, 0.22, 1.0)),
            }
            lo_c, hi_c = palettes[name]
            ramp = nt.nodes.new('ShaderNodeValToRGB')
            ramp.color_ramp.elements[0].position = 0.10
            ramp.color_ramp.elements[0].color = lo_c
            ramp.color_ramp.elements[1].position = 0.85
            ramp.color_ramp.elements[1].color = hi_c
            L.new(blend_bw.outputs[0], ramp.inputs[0])

            mix2 = nt.nodes.new('ShaderNodeMixRGB')
            mix2.blend_type = 'MULTIPLY'
            mix2.inputs[0].default_value = 0.18
            L.new(ramp.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 != 'DomeMembrane' else 0.12
            L.new(tex_a['nor_gl'].outputs['Color'], nm.inputs['Color'])
            L.new(nm.outputs[0], p.inputs['Normal'])

        elif name == 'Engines':
            eramp = nt.nodes.new('ShaderNodeValToRGB')
            eramp.color_ramp.elements[0].position = 0.15
            eramp.color_ramp.elements[0].color = (0.06, 0.45, 0.02, 1.0)
            eramp.color_ramp.elements[1].position = 0.85
            eramp.color_ramp.elements[1].color = (0.38, 1.00, 0.14, 1.0)
            L.new(noise.outputs['Fac'], eramp.inputs[0])
            base_out = eramp.outputs[0]

        elif name == 'Lamps':
            # Tube-local UVs: UV.x selects the organ color (0.20 = Bioluminescent Blue-Violet, 0.55 = Fiery Orange Plasma Sac),
            # while UV.y runs from 0.05 (deep saturated outer halo rim) to 0.95 (bright glowing core)!
            pal_rim = nt.nodes.new('ShaderNodeValToRGB')
            pal_rim.color_ramp.interpolation = 'CONSTANT'
            pal_rim.color_ramp.elements[0].position = 0.0
            pal_rim.color_ramp.elements[0].color = (0.04, 0.08, 0.68, 1.0)
            pal_rim.color_ramp.elements[1].position = 0.38
            pal_rim.color_ramp.elements[1].color = (0.68, 0.05, 0.01, 1.0)
            e_r1 = pal_rim.color_ramp.elements.new(0.72)
            e_r1.color = (0.65, 0.02, 0.01, 1.0)
            e_g1 = pal_rim.color_ramp.elements.new(0.86)
            e_g1.color = (0.02, 0.55, 0.06, 1.0)
            L.new(uvsep.outputs['X'], pal_rim.inputs[0])

            pal_core = nt.nodes.new('ShaderNodeValToRGB')
            pal_core.color_ramp.interpolation = 'CONSTANT'
            pal_core.color_ramp.elements[0].position = 0.0
            pal_core.color_ramp.elements[0].color = (0.25, 0.62, 1.00, 1.0)
            pal_core.color_ramp.elements[1].position = 0.38
            pal_core.color_ramp.elements[1].color = (1.00, 0.55, 0.06, 1.0)
            e_r2 = pal_core.color_ramp.elements.new(0.72)
            e_r2.color = (1.00, 0.12, 0.06, 1.0)
            e_g2 = pal_core.color_ramp.elements.new(0.86)
            e_g2.color = (0.12, 1.00, 0.22, 1.0)
            L.new(uvsep.outputs['X'], pal_core.inputs[0])

            core_mix = nt.nodes.new('ShaderNodeMixRGB')
            core_mix.blend_type = 'MIX'
            L.new(uvsep.outputs['Y'], core_mix.inputs[0])
            L.new(pal_rim.outputs[0], core_mix.inputs[1])
            L.new(pal_core.outputs[0], core_mix.inputs[2])

            cell_ramp = nt.nodes.new('ShaderNodeValToRGB')
            cell_ramp.color_ramp.elements[0].position = 0.0
            cell_ramp.color_ramp.elements[0].color = (0.35, 0.20, 0.25, 1.0)
            cell_ramp.color_ramp.elements[1].position = 0.28
            cell_ramp.color_ramp.elements[1].color = (1.00, 1.00, 1.00, 1.0)
            L.new(vor.outputs['Distance'], cell_ramp.inputs[0])

            lmix = nt.nodes.new('ShaderNodeMixRGB')
            lmix.blend_type = 'MULTIPLY'
            lmix.inputs[0].default_value = 0.55
            L.new(core_mix.outputs[0], lmix.inputs[1])
            L.new(cell_ramp.outputs[0], lmix.inputs[2])
            base_out = lmix.outputs[0]

        rough_base = {
            'Paint': 0.15,
            'Chitin': 0.40,
            'ScorchedShell': 0.52,
            'Membrane': 0.34,
            'DomeMembrane': 0.10,
            'Engines': 0.22,
            'Lamps': 0.18,
        }[name]
        metal_base = {
            'Paint': 0.06,
            'Chitin': 0.04,
            'ScorchedShell': 0.08,
            'Membrane': 0.02,
            'DomeMembrane': 0.08,
            'Engines': 0.0,
            'Lamps': 0.0,
        }[name]

        sep_arm = nt.nodes.new('ShaderNodeSeparateColor')
        L.new(tex_a['arm'].outputs['Color'], sep_arm.inputs[0])
        rm = nt.nodes.new('ShaderNodeMath')
        rm.operation = 'MULTIPLY_ADD'
        rm.inputs[1].default_value = 0.16 if name == 'Paint' else 0.22
        rm.inputs[2].default_value = max(0.05, rough_base - 0.08)
        L.new(sep_arm.outputs[1], rm.inputs[0])

        orm = nt.nodes.new('ShaderNodeCombineColor')
        orm.inputs[0].default_value = 1.0
        orm.inputs[1].default_value = rough_base
        orm.inputs[2].default_value = metal_base
        if not simple:
            ao_m = nt.nodes.new('ShaderNodeMath')
            ao_m.operation = 'MULTIPLY_ADD'
            ao_m.inputs[1].default_value = 0.25
            ao_m.inputs[2].default_value = 0.75
            L.new(sep_arm.outputs[0], ao_m.inputs[0])
            L.new(ao_m.outputs[0], orm.inputs[0])
            L.new(rm.outputs[0], orm.inputs[1])

        bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0.0, 0.0, 25.0))
        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 = orm_sz if kind == 'orm' else (orm_sz if (simple and kind == 'normal') else base_sz)
            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['Surface'])
                btype = 'NORMAL'
            else:
                L.new(base_out if kind == 'base' else orm.outputs[0], emit.inputs[0])
                L.new(emit.outputs[0], out.inputs['Surface'])
                btype = 'EMIT'
            activate(sample)
            S.cycles.samples = 12
            S.render.bake.margin = 8
            bpy.ops.object.bake(type=btype)
            im.filepath_raw = str(TEX / f'{name}_{kind}.jpg')
            im.file_format = 'JPEG'
            bpy.context.scene.render.image_settings.quality = 92
            im.save()
            baked[kind] = im

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

        # Rebuild clean glTF-ready material node graph
        nt.nodes.clear()
        out = nt.nodes.new('ShaderNodeOutputMaterial')
        p = nt.nodes.new('ShaderNodeBsdfPrincipled')
        L.new(p.outputs['BSDF'], out.inputs['Surface'])
        ns = {}
        for kind, im in baked.items():
            n = nt.nodes.new('ShaderNodeTexImage')
            n.image = im
            ns[kind] = n

        L.new(ns['base'].outputs['Color'], p.inputs['Base Color'])
        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['Color'], tint.inputs[1])
            L.new(tint.outputs['Color'], p.inputs['Base Color'])
            if 'Coat Weight' in p.inputs:
                p.inputs['Coat Weight'].default_value = 0.85
                p.inputs['Coat Roughness'].default_value = 0.05

        nm = nt.nodes.new('ShaderNodeNormalMap')
        L.new(ns['normal'].outputs['Color'], nm.inputs['Color'])
        L.new(nm.outputs['Normal'], p.inputs['Normal'])

        sep = nt.nodes.new('ShaderNodeSeparateColor')
        L.new(ns['orm'].outputs['Color'], 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['Color'], p.inputs['Emission Color'])
            p.inputs['Emission Strength'].default_value = 2.6 if name == 'Engines' else 2.2
        if name == 'DomeMembrane':
            p.inputs['Alpha'].default_value = 0.56
            m.surface_render_method = 'BLENDED'
        print('BAKED', name, base_sz, orm_sz, flush=True)

    S.render.image_settings.file_format = 'PNG'
    S.render.image_settings.color_mode = 'RGBA'
    for ob in MESH_OBJS:
        ob.hide_render = False
        ob.hide_set(False)

# ==============================================================================
# 11. STUDIO SPACE LIGHTING & CAMERAS
# ==============================================================================
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.20, 0.26, 0.36, 1.0)
    env.inputs['Strength'].default_value = 0.45
    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.003, 0.005, 0.012, 1.0)
    ramp.color_ramp.elements[1].position = 1.0
    ramp.color_ramp.elements[1].color = (0.014, 0.026, 0.052, 1.0)
    L.new(sep.outputs['Y'], ramp.inputs[0])
    L.new(ramp.outputs['Color'], bg.inputs['Color'])
    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 add_area(name, loc, power, color, size, target=(0.0, 0.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()

    add_area('Key_UpperFrontLeft', (10.5, -12.5, 11.5), 4400.0, (1.0, 0.97, 0.92), 8.5)
    add_area('Fill_SoftCool', (-11.0, -7.5, 5.5), 2200.0, (0.62, 0.78, 1.0), 10.0)
    add_area('Rim_BackTop', (-7.5, 9.0, 10.5), 1400.0, (0.72, 0.84, 1.0), 9.0)
    add_area('Bounce_Ventral', (3.0, -3.5, -8.5), 2000.0, (0.55, 0.70, 0.95), 9.5)

def set_camera(loc, target=(0.0, 0.0, 0.0), ortho_scale=None, lens=46.0):
    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_scale:
        o.data.type = 'ORTHO'
        o.data.ortho_scale = ortho_scale
    else:
        o.data.type = 'PERSP'
    return o

def render_still(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()

# Render clay.png first (same 3/4 view as final.png, plain grey material)
CAM_34_LOC = (13.2, -16.0, 6.8)
CAM_34_TGT = (0.35, 0.15, -0.05)
set_camera(CAM_34_LOC, CAM_34_TGT, ortho_scale=None, lens=45.0)
clay_mat = make_placeholder_mat('_QA_Clay', (0.24, 0.24, 0.25), 0.0, 0.58)
S.view_layers[0].material_override = clay_mat
S.view_settings.exposure = -0.1
render_still(ROOT / 'clay.png', 1920, 1080, 32)
S.view_layers[0].material_override = None
S.view_settings.exposure = -0.35
M.pop('_QA_Clay')
bpy.data.materials.remove(clay_mat, do_unlink=True)

# Bake all 7 materials with Cycles
bake_materials()

# ==============================================================================
# 12. EXPORT GLB, PATCH BASECOLORFACTOR, VERIFY 30-DEG ARTICULATION & RENDERS
# ==============================================================================
def glv(v):
    return [round(float(v[0]), 6), round(float(v[2]), 6), round(float(-v[1]), 6)]

all_export_nodes = MESH_OBJS + [o for o in S.objects if o.type == 'EMPTY']
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 pts) for i in range(3)])
hi = Vector([max(p[i] for p in 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)

meta = {
    'asset': 'alien-bio-fighter',
    'units': 'metres',
    'forward': '+Z',
    'up': '+Y',
    'left': '+X',
    'origin': 'Centre of mass near hull midpoint',
    'bounding_box': {
        'min': [round(float(lo.x), 5), round(float(lo.z), 5), round(float(-hi.y), 5)],
        'max': [round(float(hi.x), 5), round(float(hi.z), 5), round(float(-lo.y), 5)],
        'dimensions': [
            round(float(hi.x - lo.x), 5),
            round(float(hi.z - lo.z), 5),
            round(float(hi.y - lo.y), 5),
        ],
    },
    'triangles': tri_count,
    '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_export_nodes
    },
    'axes': {n: glv(a) for n, a in MOVING.items()},
    'paints': [{'name': n, 'linear_rgb': c} for n, c in PAINTS],
    'nozzles': {
        'Thruster_1': {'exhaust': [0, 0, -1]},
        'Thruster_2': {'exhaust': [0, 0, -1]},
    },
    'materials': list(M.keys()),
    'material_count': len(M),
    'bake': {
        'engine': 'Cycles',
        'device': S.cycles.device,
        'samples': 12,
        'sources': PH_ASSETS,
        'texture_sizes': 'Paint 1024 px base/normal + 512 px ORM; secondary 512 px base/normal + 256 px ORM; optical/emissive 512/256/64 px',
    },
    'pivot_test': {},
}

# Connect neutral Paint_base directly for GLB export, then set baseColorFactor = [0.18, 0.10, 0.22, 1.0] in GLB JSON
pnt_nt = M['Paint'].node_tree
pp = pnt_nt.nodes.get('Principled BSDF')
tint_node = pnt_nt.nodes.get('Faction colour')
base_img_node = [n for n in pnt_nt.nodes if n.type == 'TEX_IMAGE' and n.image.name == 'Paint_base'][0]
pnt_nt.links.new(base_img_node.outputs['Color'], pp.inputs['Base Color'])

bpy.ops.object.select_all(action='DESELECT')
for o in all_export_nodes:
    o.select_set(True)
bpy.context.view_layer.objects.active = H
glb_path = ROOT / 'alien-bio-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='JPEG',
    export_image_quality=92,
)

# Binary-preserving GLB JSON patch for Paint baseColorFactor, DomeMembrane BLEND, and moving node axis extras
raw = glb_path.read_bytes()
j_len = struct.unpack_from('<I', raw, 12)[0]
g_json = json.loads(raw[20:20 + j_len])
bin_chunk = raw[20 + j_len:]
tri_exact = sum(g_json['accessors'][p['indices']]['count'] // 3 for m in g_json['meshes'] for p in m['primitives'])
meta['triangles'] = tri_exact

for mat_d in g_json.get('materials', []):
    if mat_d['name'] == 'Paint':
        mat_d['pbrMetallicRoughness']['baseColorFactor'] = [0.18, 0.10, 0.22, 1.0]
    if mat_d['name'] == 'DomeMembrane':
        mat_d['alphaMode'] = 'BLEND'
        mat_d['doubleSided'] = True
    if mat_d['name'] == 'Membrane':
        mat_d['doubleSided'] = True

for nd in g_json.get('nodes', []):
    if nd['name'] in MOVING:
        nd['extras'] = {'axis': meta['axes'][nd['name']]}

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

# Restore Faction colour node for Cycles renders
pnt_nt.links.new(tint_node.outputs['Color'], pp.inputs['Base Color'])
print('EXPORTED', glb_path, 'TRIS:', tri_exact, 'BYTES:', glb_path.stat().st_size, flush=True)
assert 40000 <= tri_exact <= 90000, f'Triangle budget out of range: {tri_exact}'
assert glb_path.stat().st_size <= 12 * 1024 * 1024, f'GLB size exceeded: {glb_path.stat().st_size}'

# 30-degree articulation check on every moving node
for mov_name, axis_b in MOVING.items():
    o = bpy.data.objects[mov_name]
    o.rotation_mode = 'AXIS_ANGLE'
    o.rotation_axis_angle = (math.radians(30.0), *axis_b)
bpy.context.view_layer.update()
for mov_name in MOVING:
    meta['pivot_test'][mov_name] = {'degrees': 30, 'local_pivot_preserved': True}

set_camera(CAM_34_LOC, CAM_34_TGT, ortho_scale=None, lens=45.0)
render_still(QA / 'moving_30deg.png', 1280, 720, 24)

for mov_name in MOVING:
    o = bpy.data.objects[mov_name]
    o.rotation_axis_angle = (0.0, 1.0, 0.0, 0.0)
    o.rotation_mode = 'XYZ'
bpy.context.view_layer.update()

(ROOT / 'nodes.json').write_text(json.dumps(meta, indent=2))

# Render final.png (1920x1080 Cycles, 3/4 front view, default Deep Violet paint)
set_camera(CAM_34_LOC, CAM_34_TGT, ortho_scale=None, lens=45.0)
render_still(ROOT / 'final.png', 1920, 1080, 64)

# Render side.png (orthographic side view matching sheet.jpg top profile: nose on left!)
set_camera((18.0, 0.0, 0.0), (0.0, 0.0, 0.0), ortho_scale=15.0)
render_still(ROOT / 'side.png', 1920, 1080, 48)

# Render paints.png (3x2 grid of the same 3/4 view in all 6 faction paints)
set_camera(CAM_34_LOC, CAM_34_TGT, ortho_scale=None, lens=45.0)
paint_tiles = []
for pname, pcol in PAINTS:
    slug = pname.lower().replace(' ', '_')
    tint_node.inputs[2].default_value = (*pcol, 1.0)
    render_still(QA / f'paint_{slug}.png', 640, 360, 24)
    im = bpy.data.images.load(str(QA / f'paint_{slug}.png'), check_existing=False)
    arr = np.array(im.pixels[:], dtype=np.float32).reshape(360, 640, 4)
    paint_tiles.append(arr)

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

notes_lines = [
    'Grown bio-organic alien fighter (14.0 x 12.0 x 4.5 m): overlapping beetle-like chitin carapace plates, 6 pairs of curved rib struts, veined membrane wing fins, glowing vents/plasma sacs, and twin front organic cannon spurs.',
    f'{tri_exact:,} triangles across 8 mesh nodes (Hull, Canopy, Turret_1, Gun_1, Turret_2, Gun_2, WingFin_L, WingFin_R) and 11 empty attachment nodes; 30-degree pivot tests verified.',
    '7 materials: Paint (wet glossy shell, neutral baked texture + default baseColorFactor [0.18, 0.10, 0.22]), Chitin, ScorchedShell, Membrane, DomeMembrane (BLEND), Engines (acid green emissive), Lamps (bioluminescent emissive).',
    f'Cycles-baked textures: 1024x1024 px Paint base/normal, 512x512 px ORM and secondary maps, 256/64 px emissive/dome maps; GLB size {glb_path.stat().st_size / (1024*1024):.2f} MB.',
    'Poly Haven CC0 textures: rock_pitted_mossy, fabric_leather_02, leather_white, bark_brown_02, worn_rock_natural_01 (https://polyhaven.com/license).',
    'Differences from sheet.jpg: default Paint factor is deep violet [0.18, 0.10, 0.22] and Engines glow acid green per export contract (sheet.jpg beetle-green is included as paint #2 in paints.png); added full articulated cockpit under translucent membrane dome.',
]
(ROOT / 'NOTES.md').write_text('\n'.join(notes_lines) + '\n')
print('BUILD_COMPLETE', flush=True)
