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'
TEX.mkdir(parents=True, exist_ok=True)

# 1. Poly Haven CC0 texture download / verification
PH_ASSETS = {
    'metal_plate_02': ['diff', 'nor_gl', 'arm'],
    'rusty_metal_03': ['diff', 'nor_gl', 'arm'],
    'rusty_painted_metal': ['diff', 'nor_gl', 'arm'],
}

SRC = {}
for ph_id, maps in PH_ASSETS.items():
    SRC[ph_id] = {}
    for mp in maps:
        fname = f'{ph_id}_{mp}_1k.jpg'
        fpath = TEX / fname
        if not fpath.exists():
            url = f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ph_id}/{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())

# Reset Blender scene cleanly
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 GPU (OptiX / CUDA)
S.render.engine = 'CYCLES'
prefs = bpy.context.preferences.addons['cycles'].preferences
for dev_type in ('OPTIX', 'CUDA'):
    try:
        prefs.compute_device_type = dev_type
        prefs.get_devices()
        gpu_devs = [d for d in prefs.devices if d.type != 'CPU']
        if gpu_devs:
            for d in prefs.devices:
                d.use = (d.type != 'CPU')
            S.cycles.device = 'GPU'
            break
    except Exception:
        pass

# Load source images into Blender
for ph_id, maps in PH_ASSETS.items():
    for mp in maps:
        img = bpy.data.images.load(str(TEX / f'{ph_id}_{mp}_1k.jpg'), check_existing=True)
        if mp != 'diff':
            img.colorspace_settings.name = 'Non-Color'
        SRC[ph_id][mp] = img

# Six faction paints (default industrial orange first, per contract)
PAINTS = [
    {'name': 'Industrial Orange', 'linear_rgb': [0.80, 0.45, 0.05], 'rgb': [0.80, 0.45, 0.05]},
    {'name': 'Safety Yellow', 'linear_rgb': [0.82, 0.65, 0.04], 'rgb': [0.82, 0.65, 0.04]},
    {'name': 'Signal Red', 'linear_rgb': [0.68, 0.08, 0.05], 'rgb': [0.68, 0.08, 0.05]},
    {'name': 'Deep Space Teal', 'linear_rgb': [0.06, 0.38, 0.44], 'rgb': [0.06, 0.38, 0.44]},
    {'name': 'Orbital White', 'linear_rgb': [0.78, 0.80, 0.82], 'rgb': [0.78, 0.80, 0.82]},
    {'name': 'Heavy Charcoal', 'linear_rgb': [0.12, 0.13, 0.15], 'rgb': [0.12, 0.13, 0.15]},
]

M = {}
groups = {}
asset = []
sockets = []

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

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

# 9 Materials (within <= 9 budget)
make_mat('Paint', (0.80, 0.45, 0.05), 0.52, 0.12)
make_mat('Containers', (0.24, 0.20, 0.15), 0.58, 0.35)
make_mat('Metal', (0.34, 0.36, 0.38), 0.38, 0.86)
make_mat('ReactorHull', (0.42, 0.44, 0.46), 0.50, 0.52)
make_mat('HullTrim', (0.09, 0.16, 0.26), 0.52, 0.28)
make_mat('ScorchedMetal', (0.30, 0.20, 0.13), 0.36, 0.86)
make_mat('Glass', (0.14, 0.22, 0.28), 0.06, 0.10)
make_mat('Lamps', (1.0, 0.90, 0.70), 0.15, 0.05)
make_mat('Engines', (0.35, 0.70, 1.0), 0.20, 0.05)

def bake_material(m):
    name = m.name
    nt = m.node_tree
    n = nt.nodes
    l = nt.links
    p = n.get('Principled BSDF')
    out = n.get('Material Output')
    base = tuple(p.inputs['Base Color'].default_value[:3])
    rough = p.inputs['Roughness'].default_value
    metal = p.inputs['Metallic'].default_value

    uv = n.new('ShaderNodeTexCoord')

    if name in ('Paint', 'ReactorHull'):
        ph1, ph2 = SRC['metal_plate_02'], SRC['rusty_metal_03']
    elif name in ('Containers', 'HullTrim'):
        ph1, ph2 = SRC['rusty_painted_metal'], SRC['rusty_metal_03']
    else:
        ph1, ph2 = SRC['rusty_metal_03'], SRC['metal_plate_02']

    tex1 = n.new('ShaderNodeTexImage')
    tex1.image = ph1['diff']
    l.new(uv.outputs['UV'], tex1.inputs['Vector'])

    tex2 = n.new('ShaderNodeTexImage')
    tex2.image = ph2['diff']
    l.new(uv.outputs['UV'], tex2.inputs['Vector'])

    bw1 = n.new('ShaderNodeRGBToBW')
    l.new(tex1.outputs['Color'], bw1.inputs[0])
    bw2 = n.new('ShaderNodeRGBToBW')
    l.new(tex2.outputs['Color'], bw2.inputs[0])

    brick = n.new('ShaderNodeTexBrick')
    brick.offset = 0.0
    brick.squash = 1.0
    brick.inputs['Scale'].default_value = 2.0
    brick.inputs['Brick Width'].default_value = 1.0
    brick.inputs['Row Height'].default_value = 1.0
    brick.inputs['Mortar Size'].default_value = 0.006 if name in ('Paint', 'ReactorHull') else 0.010
    brick.inputs['Mortar Smooth'].default_value = 0.35
    brick.inputs['Color1'].default_value = (0.98, 0.98, 0.98, 1.0)
    brick.inputs['Color2'].default_value = (0.93, 0.93, 0.93, 1.0)
    brick.inputs['Mortar'].default_value = (0.62, 0.62, 0.62, 1.0)
    l.new(uv.outputs['UV'], brick.inputs['Vector'])

    noise = n.new('ShaderNodeTexNoise')
    noise.inputs['Scale'].default_value = 6.5
    noise.inputs['Detail'].default_value = 8.0
    noise.inputs['Roughness'].default_value = 0.78
    l.new(uv.outputs['UV'], noise.inputs['Vector'])

    ramp = n.new('ShaderNodeValToRGB')
    ramp.color_ramp.elements[0].position = 0.08
    ramp.color_ramp.elements[1].position = 0.88
    if name == 'Paint':
        ramp.color_ramp.elements[0].color = (0.48, 0.44, 0.42, 1.0)
        ramp.color_ramp.elements[1].color = (0.86, 0.82, 0.79, 1.0)
    else:
        ramp.color_ramp.elements[0].color = (0.55, 0.52, 0.50, 1.0)
        ramp.color_ramp.elements[1].color = (1.04, 1.02, 1.00, 1.0)
    l.new(bw2.outputs[0], ramp.inputs[0])

    nr = n.new('ShaderNodeValToRGB')
    nr.color_ramp.elements[0].position = 0.15
    nr.color_ramp.elements[1].position = 0.85
    nr.color_ramp.elements[0].color = (0.76, 0.74, 0.72, 1.0)
    nr.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
    l.new(noise.outputs['Fac'], nr.inputs[0])

    mix_noise = n.new('ShaderNodeMixRGB')
    mix_noise.blend_type = 'MULTIPLY'
    mix_noise.inputs[0].default_value = 1.0
    l.new(ramp.outputs[0], mix_noise.inputs[1])
    l.new(nr.outputs[0], mix_noise.inputs[2])

    mix_scan = n.new('ShaderNodeMixRGB')
    mix_scan.blend_type = 'MULTIPLY'
    mix_scan.inputs[0].default_value = 0.55 if name == 'Paint' else 0.70
    l.new(mix_noise.outputs[0], mix_scan.inputs[1])
    l.new(brick.outputs['Color'], mix_scan.inputs[2])

    tint = n.new('ShaderNodeMixRGB')
    tint.blend_type = 'MULTIPLY'
    tint.inputs[0].default_value = 1.0
    l.new(mix_scan.outputs[0], tint.inputs[1])
    tint.inputs[2].default_value = (*( (1.0, 1.0, 1.0) if name == 'Paint' else base ), 1.0)

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

    if name == 'HullTrim':
        pal = n.new('ShaderNodeValToRGB')
        pal.color_ramp.interpolation = 'CONSTANT'
        pal.color_ramp.elements[0].position = 0.0
        pal.color_ramp.elements[0].color = (0.038, 0.040, 0.046, 1.0)
        pal.color_ramp.elements[1].position = 0.5
        pal.color_ramp.elements[1].color = (0.075, 0.145, 0.255, 1.0)
        l.new(sep_uv.outputs['Y'], pal.inputs[0])
        l.new(pal.outputs[0], tint.inputs[2])

    elif name == 'ScorchedMetal':
        pal = n.new('ShaderNodeValToRGB')
        pal.color_ramp.elements[0].position = 0.08
        pal.color_ramp.elements[0].color = (0.36, 0.25, 0.16, 1.0)
        pal.color_ramp.elements[1].position = 0.62
        pal.color_ramp.elements[1].color = (0.16, 0.11, 0.08, 1.0)
        e2 = pal.color_ramp.elements.new(0.88)
        e2.color = (0.12, 0.18, 0.28, 1.0)
        l.new(sep_uv.outputs['Y'], pal.inputs[0])
        l.new(pal.outputs[0], tint.inputs[2])

    elif name == 'Lamps':
        pal = n.new('ShaderNodeValToRGB')
        pal.color_ramp.interpolation = 'CONSTANT'
        pal.color_ramp.elements[0].position = 0.0
        pal.color_ramp.elements[0].color = (1.0, 0.88, 0.66, 1.0)
        pal.color_ramp.elements[1].position = 0.333
        pal.color_ramp.elements[1].color = (0.95, 0.06, 0.03, 1.0)
        e2 = pal.color_ramp.elements.new(0.667)
        e2.color = (0.05, 0.92, 0.16, 1.0)
        l.new(sep_uv.outputs['X'], pal.inputs[0])
        l.new(pal.outputs[0], tint.inputs[2])

    elif name == 'Engines':
        pal = n.new('ShaderNodeValToRGB')
        pal.color_ramp.elements[0].position = 0.12
        pal.color_ramp.elements[0].color = (1.0, 0.55, 0.12, 1.0)
        pal.color_ramp.elements[1].position = 0.52
        pal.color_ramp.elements[1].color = (0.92, 0.95, 1.0, 1.0)
        e2 = pal.color_ramp.elements.new(0.82)
        e2.color = (0.14, 0.48, 1.0, 1.0)
        l.new(sep_uv.outputs['Y'], pal.inputs[0])
        l.new(pal.outputs[0], tint.inputs[2])

    ntex = n.new('ShaderNodeTexImage')
    ntex.image = ph1['nor_gl']
    l.new(uv.outputs['UV'], ntex.inputs['Vector'])
    nmap = n.new('ShaderNodeNormalMap')
    nmap.inputs['Strength'].default_value = 0.22 if name not in ('Glass', 'Lamps', 'Engines') else 0.02
    l.new(ntex.outputs['Color'], nmap.inputs['Color'])

    bump = n.new('ShaderNodeBump')
    bump.inputs['Strength'].default_value = 0.08 if name in ('Paint', 'ReactorHull', 'Containers') else 0.04
    bump.inputs['Distance'].default_value = 0.015
    l.new(brick.outputs['Fac'], bump.inputs['Height'])
    l.new(nmap.outputs['Normal'], bump.inputs['Normal'])
    l.new(bump.outputs['Normal'], p.inputs['Normal'])

    arm = n.new('ShaderNodeTexImage')
    arm.image = ph1['arm']
    l.new(uv.outputs['UV'], arm.inputs['Vector'])
    sep_arm = n.new('ShaderNodeSeparateColor')
    l.new(arm.outputs['Color'], sep_arm.inputs[0])

    ao_mult = n.new('ShaderNodeMath')
    ao_mult.operation = 'MULTIPLY'
    l.new(sep_arm.outputs['Red'], ao_mult.inputs[0])
    l.new(brick.outputs['Color'], ao_mult.inputs[1])

    r_map = n.new('ShaderNodeMath')
    r_map.operation = 'MULTIPLY_ADD'
    r_map.inputs[1].default_value = 0.24
    r_map.inputs[2].default_value = max(0.10, rough - 0.10)
    l.new(sep_arm.outputs['Green'], r_map.inputs[0])

    comb_orm = n.new('ShaderNodeCombineColor')
    l.new(ao_mult.outputs[0], comb_orm.inputs['Red'])
    l.new(r_map.outputs[0], comb_orm.inputs['Green'])
    comb_orm.inputs['Blue'].default_value = metal

    emit = n.new('ShaderNodeEmission')
    bpy.ops.mesh.primitive_plane_add(size=2.0)
    plane = bpy.context.object
    plane.name = 'BakeFixture'
    plane.data.materials.append(m)
    target = n.new('ShaderNodeTexImage')

    S.cycles.samples = 8
    baked = {}
    is_main = name in ('Paint', 'Containers', 'Metal', 'ReactorHull', 'HullTrim', 'ScorchedMetal')
    res_base = 1024 if is_main else (256 if name in ('Lamps', 'Engines') else 128)
    res_norm = 512 if is_main else 128
    res_orm = 512 if is_main else 128

    for channel, res in [('Base', res_base), ('Normal', res_norm), ('ORM', res_orm)]:
        img = bpy.data.images.new(f'{name}_{channel}', width=res, height=res, alpha=False)
        if channel != 'Base':
            img.colorspace_settings.name = 'Non-Color'
        target.image = img
        n.active = target
        for nd in n:
            nd.select = False
        target.select = True
        if channel == 'Normal':
            l.new(p.outputs[0], out.inputs[0])
            bpy.ops.object.bake(type='NORMAL', margin=8)
        else:
            l.new(emit.outputs[0], out.inputs[0])
            l.new(tint.outputs[0] if channel == 'Base' else comb_orm.outputs[0], emit.inputs[0])
            bpy.ops.object.bake(type='EMIT', margin=8)
        ext = 'jpg' if channel == 'Base' else 'png'
        img.filepath_raw = str(TEX / f'{name}_{channel}.{ext}')
        img.file_format = 'JPEG' if channel == 'Base' else 'PNG'
        img.save()
        baked[channel] = img

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

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

    al = n.new('ShaderNodeTexImage')
    al.image = baked['Base']

    if name == 'Paint':
        mix = n.new('ShaderNodeMixRGB')
        mix.name = 'PaintFactor'
        mix.blend_type = 'MULTIPLY'
        mix.inputs[0].default_value = 1.0
        mix.inputs[2].default_value = (*PAINTS[0]['linear_rgb'], 1.0)
        l.new(al.outputs['Color'], mix.inputs[1])
        l.new(mix.outputs['Color'], p.inputs['Base Color'])
    elif name in ('Lamps', 'Engines'):
        p.inputs['Base Color'].default_value = (*base, 1.0)
        l.new(al.outputs['Color'], p.inputs['Emission Color'])
        p.inputs['Emission Strength'].default_value = 4.2 if name == 'Lamps' else 5.5
    else:
        l.new(al.outputs['Color'], p.inputs['Base Color'])

    no = n.new('ShaderNodeTexImage')
    no.image = baked['Normal']
    nm = n.new('ShaderNodeNormalMap')
    l.new(no.outputs['Color'], nm.inputs['Color'])
    l.new(nm.outputs['Normal'], p.inputs['Normal'])

    orm = n.new('ShaderNodeTexImage')
    orm.image = baked['ORM']
    sp = n.new('ShaderNodeSeparateColor')
    l.new(orm.outputs['Color'], sp.inputs[0])
    l.new(sp.outputs['Green'], p.inputs['Roughness'])
    l.new(sp.outputs['Blue'], p.inputs['Metallic'])

    if 'glTF Material Output' not in bpy.data.node_groups:
        ng = bpy.data.node_groups.new('glTF Material Output', 'ShaderNodeTree')
        ng.interface.new_socket(name='Occlusion', in_out='INPUT', socket_type='NodeSocketFloat')
    ao = n.new('ShaderNodeGroup')
    ao.node_tree = bpy.data.node_groups['glTF Material Output']
    l.new(sp.outputs['Red'], ao.inputs['Occlusion'])

    if name == 'Glass':
        p.inputs['Alpha'].default_value = 0.34
        p.inputs['Roughness'].default_value = 0.05
        for lk in list(p.inputs['Roughness'].links):
            l.remove(lk)
        m.surface_render_method = 'BLENDED'
        m.diffuse_color = (*base, 0.34)
    else:
        m.diffuse_color = (*base, 1.0)
    print('BAKED', name, flush=True)


def apply_uv_and_reg(o, group='Hull', mat='Paint', uv_mode='WORLD', v_range=(0.05, 0.95), u_val=None):
    me = o.data
    if not me.uv_layers:
        me.uv_layers.new(name='UVMap')
    uv_layer = me.uv_layers.active.data
    scale = 0.14
    v_lo, v_hi = v_range
    for poly in me.polygons:
        nrm = poly.normal
        ax = max(range(3), key=lambda k: abs(nrm[k]))
        axes = [k for k in range(3) if k != ax]
        for li in poly.loop_indices:
            vi = me.loops[li].vertex_index
            co = o.matrix_world @ me.vertices[vi].co
            if uv_mode == 'WORLD':
                u = co[axes[0]] * scale
                v = co[axes[1]] * scale
            elif uv_mode == 'BAND_V':
                u = (co[axes[0]] * scale) % 1.0
                raw_v = (co[axes[1]] * scale) % 1.0
                v = v_lo + raw_v * (v_hi - v_lo)
            elif uv_mode == 'NOZZLE':
                ang = math.atan2(me.vertices[vi].co.z, me.vertices[vi].co.x)
                u = (ang / (2.0 * math.pi)) % 1.0
                v = 0.08 + max(0.0, min(1.0, (co.y - v_range[0]) / max(0.001, (v_range[1] - v_range[0])))) * 0.84
            elif uv_mode == 'CONST_U':
                u = u_val if u_val is not None else 0.16
                v = 0.5
            else:
                u, v = co.x * scale, co.y * scale
            uv_layer[li].uv = (u, v)
    me.materials.clear()
    me.materials.append(M[mat])
    groups.setdefault(group, []).append(o)
    return o

def add_bevel_box(name, loc, dims, bevel=0.07, segs=1, group='Hull', mat='Paint', smooth=False, rot=(0,0,0), uv_mode='WORLD', v_range=(0.05, 0.95), u_val=None):
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=1.0)
    bmesh.ops.scale(bm, vec=dims, verts=bm.verts)
    if bevel > 0:
        bmesh.ops.bevel(bm, geom=list(bm.edges), offset=bevel, segments=segs, profile=0.5, clamp_overlap=True)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = smooth
    o = bpy.data.objects.new(name, me)
    o.location = loc
    o.rotation_euler = rot
    S.collection.objects.link(o)
    bpy.context.view_layer.update()
    return apply_uv_and_reg(o, group, mat, uv_mode, v_range, u_val)

def add_cyl(name, loc, rad1, rad2, depth, verts=20, bevel=0.03, group='Hull', mat='Metal', rot=(0,0,0), smooth=True, uv_mode='WORLD', v_range=(0.05, 0.95), u_val=None):
    bm = bmesh.new()
    bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=verts, radius1=rad1, radius2=rad2, depth=depth)
    if bevel > 0:
        cap_edges = [e for e in bm.edges if len(e.link_faces) == 2 and abs(e.link_faces[0].normal.z - e.link_faces[1].normal.z) > 0.3]
        if cap_edges:
            bmesh.ops.bevel(bm, geom=cap_edges, offset=bevel, segments=1, profile=0.5, clamp_overlap=True)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = smooth and (abs(p.normal.z) < 0.85)
    o = bpy.data.objects.new(name, me)
    o.location = loc
    o.rotation_euler = rot
    S.collection.objects.link(o)
    bpy.context.view_layer.update()
    return apply_uv_and_reg(o, group, mat, uv_mode, v_range, u_val)

def add_lofted_hull(name, sections, bevel=0.06, segs=1, group='Hull', mat='Paint', smooth=False, uv_mode='WORLD', v_range=(0.05, 0.95)):
    bm = bmesh.new()
    rings = []
    for y_pos, pts in sections:
        ring = [bm.verts.new((x, y_pos, z)) for (x, z) in pts]
        rings.append(ring)
    n_pts = len(rings[0])
    for r_idx in range(len(rings) - 1):
        r0, r1 = rings[r_idx], rings[r_idx + 1]
        for i in range(n_pts):
            ni = (i + 1) % n_pts
            bm.faces.new((r0[i], r0[ni], r1[ni], r1[i]))
    bm.faces.new(list(reversed(rings[0])))
    bm.faces.new(rings[-1])
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    if bevel > 0:
        sharp_edges = [e for e in bm.edges if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > 0.22]
        if sharp_edges:
            bmesh.ops.bevel(bm, geom=sharp_edges, offset=bevel, segments=segs, profile=0.5, clamp_overlap=True)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = smooth
    o = bpy.data.objects.new(name, me)
    S.collection.objects.link(o)
    bpy.context.view_layer.update()
    return apply_uv_and_reg(o, group, mat, uv_mode, v_range)

def oct_ring(w, h, cx=0.0, cz=0.0, ch_x=0.22, ch_z=0.22):
    wx, hz = w * 0.5, h * 0.5
    ix = wx * (1.0 - ch_x)
    iz = hz * (1.0 - ch_z)
    return [
        (cx - ix, cz - hz),
        (cx + ix, cz - hz),
        (cx + wx, cz - iz),
        (cx + wx, cz + iz),
        (cx + ix, cz + hz),
        (cx - ix, cz + hz),
        (cx - wx, cz + iz),
        (cx - wx, cz - iz),
    ]

# ==============================================================================
# SECTION 1: CAB MODULE (Y from -60.0 to -30.0)
# ==============================================================================
def build_cab_module():
    sill_sections = [
        (-59.6, oct_ring(14.8, 2.7, 0.0, 0.12, 0.16, 0.16)),
        (-57.6, oct_ring(16.6, 2.7, 0.0, 0.12, 0.14, 0.14)),
        (-48.2, oct_ring(16.8, 2.7, 0.0, 0.12, 0.14, 0.14)),
    ]
    add_lofted_hull('Cab_Fwd_Sill', sill_sections, bevel=0.07, group='Hull', mat='Paint')

    brow_sections = [
        (-58.2, oct_ring(14.2, 1.45, 0.0, 5.75, 0.18, 0.22)),
        (-55.8, oct_ring(16.2, 1.75, 0.0, 5.88, 0.16, 0.20)),
        (-48.2, oct_ring(16.8, 1.75, 0.0, 5.88, 0.14, 0.18)),
    ]
    add_lofted_hull('Cab_Fwd_Brow', brow_sections, bevel=0.07, group='Hull', mat='Paint')

    cab_aft_sections = [
        (-48.4, oct_ring(16.8, 8.0, 0.0, 2.78, 0.14, 0.14)),
        (-34.2, oct_ring(16.8, 8.0, 0.0, 2.78, 0.14, 0.14)),
        (-33.5, oct_ring(15.4, 7.4, 0.0, 2.65, 0.16, 0.16)),
    ]
    add_lofted_hull('Cab_Aft_Hull', cab_aft_sections, bevel=0.08, group='Hull', mat='Paint')

    cab_skirt_sections = [
        (-60.0, oct_ring(14.8, 3.2, 0.0, -2.75, 0.18, 0.18)),
        (-58.5, oct_ring(16.8, 3.5, 0.0, -2.82, 0.14, 0.14)),
        (-54.0, oct_ring(17.1, 3.6, 0.0, -2.85, 0.12, 0.12)),
        (-34.2, oct_ring(17.0, 3.6, 0.0, -2.85, 0.12, 0.12)),
        (-33.5, oct_ring(15.6, 3.3, 0.0, -2.80, 0.15, 0.15)),
    ]
    add_lofted_hull('Cab_Slate_Skirt', cab_skirt_sections, bevel=0.07, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.55, 0.95))

    cab_chin_sections = [
        (-59.2, oct_ring(11.8, 2.0, 0.0, -5.25, 0.22, 0.22)),
        (-56.2, oct_ring(14.0, 2.3, 0.0, -5.45, 0.20, 0.20)),
        (-47.5, oct_ring(13.4, 2.2, 0.0, -5.45, 0.20, 0.20)),
        (-35.2, oct_ring(11.6, 1.9, 0.0, -5.15, 0.22, 0.22)),
    ]
    add_lofted_hull('Cab_Lower_Chin', cab_chin_sections, bevel=0.06, group='Hull', mat='Paint')

    # Hazard Stripe Belt Channel & 45-deg Diagonal Bars along Both Sides
    for side in (-1, 1):
        sx = side * 8.43
        add_bevel_box(f'Cab_Hazard_Base_{side}', (sx, -44.6, -0.42), (0.14, 20.8, 1.38), bevel=0.03, group='Hull', mat='Paint')
        add_bevel_box(f'Cab_Hazard_Rail_Top_{side}', (side * 8.48, -44.6, 0.28), (0.18, 21.0, 0.12), bevel=0.02, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_bevel_box(f'Cab_Hazard_Rail_Bot_{side}', (side * 8.48, -44.6, -1.12), (0.18, 21.0, 0.12), bevel=0.02, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        for k in range(18):
            yk = -54.0 + k * 1.10
            add_bevel_box(
                f'Cab_Hazard_Bar_{side}_{k}',
                (side * 8.49, yk, -0.42),
                (0.12, 0.48, 1.48),
                bevel=0.02,
                group='Hull',
                mat='HullTrim',
                rot=(math.radians(42 * side), 0, 0),
                uv_mode='BAND_V',
                v_range=(0.05, 0.45)
            )

    # Recessed Bridge Interior + Flush Windscreen Pillars, Frames, Wipers & Glass
    add_bevel_box('Bridge_Tub_Floor', (0.0, -53.8, 1.32), (14.6, 9.8, 0.32), bevel=0.04, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
    add_bevel_box('Bridge_Tub_Ceiling', (0.0, -53.4, 5.10), (14.4, 9.0, 0.28), bevel=0.04, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
    add_bevel_box('Bridge_Dash_Main', (0.0, -57.4, 2.15), (12.8, 1.95, 1.35), bevel=0.08, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
    add_bevel_box('Bridge_Dash_Coaming', (0.0, -57.2, 2.88), (12.4, 1.45, 0.28), bevel=0.05, group='Hull', mat='Metal')
    add_bevel_box('Bridge_Center_Console', (0.0, -55.4, 1.85), (2.4, 3.8, 1.05), bevel=0.06, group='Hull', mat='Metal')
    for sx in (-3.6, -1.2, 1.2, 3.6):
        add_bevel_box(f'Bridge_MFD_{sx}', (sx, -56.40, 2.52), (1.35, 0.10, 0.58), bevel=0.01, group='Hull', mat='Lamps', rot=(math.radians(-22), 0, 0), uv_mode='CONST_U', u_val=0.82)

    for side, sname in ((1, 'Pilot'), (-1, 'CoPilot')):
        px = side * 3.35
        add_bevel_box(f'Seat_{sname}_Pedestal', (px, -54.6, 1.62), (0.85, 0.95, 0.55), bevel=0.04, group='Hull', mat='Metal')
        add_bevel_box(f'Seat_{sname}_Cushion', (px, -54.6, 2.02), (1.55, 1.55, 0.42), bevel=0.05, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_bevel_box(f'Seat_{sname}_Back', (px, -53.95, 3.05), (1.48, 0.36, 1.85), bevel=0.05, group='Hull', mat='HullTrim', rot=(math.radians(-10), 0, 0), uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_bevel_box(f'Seat_{sname}_Headrest', (px, -53.75, 4.12), (0.92, 0.32, 0.52), bevel=0.04, group='Hull', mat='Paint')
        add_cyl(f'Yoke_Col_{sname}', (px, -56.0, 2.25), 0.11, 0.09, 1.25, verts=12, group='Hull', mat='Metal', rot=(math.radians(26), 0, 0))
        add_bevel_box(f'Yoke_Grip_{sname}', (px, -55.7, 2.78), (0.85, 0.16, 0.34), bevel=0.03, group='Hull', mat='Paint')

    add_bevel_box('Windscreen_Center_Post', (0.0, -58.85, 3.30), (0.58, 0.62, 3.95), bevel=0.06, group='Hull', mat='HullTrim', rot=(math.radians(-16), 0, 0), uv_mode='BAND_V', v_range=(0.05, 0.45))
    for side in (-1, 1):
        add_bevel_box(f'Cab_Corner_Pillar_{side}', (side * 6.95, -57.75, 3.30), (0.78, 0.85, 3.95), bevel=0.07, group='Hull', mat='Paint', rot=(math.radians(-14), 0, 0))
        add_bevel_box(f'Cab_Side_Pillar_{side}', (side * 8.15, -54.75, 3.30), (0.65, 1.15, 3.90), bevel=0.07, group='Hull', mat='Paint')
        add_bevel_box(f'Cab_Side_Fascia_T_{side}', (side * 8.18, -53.2, 5.02), (0.58, 10.2, 0.42), bevel=0.05, group='Hull', mat='Paint')
        add_bevel_box(f'Cab_Side_Fascia_B_{side}', (side * 8.18, -53.2, 1.52), (0.58, 10.2, 0.42), bevel=0.05, group='Hull', mat='Paint')

        gx = side * 3.48
        add_bevel_box(f'Windscreen_Gasket_T_{side}', (gx, -57.92, 4.92), (6.4, 0.28, 0.22), bevel=0.03, group='Hull', mat='HullTrim', rot=(0, 0, math.radians(-side * 8)), uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_bevel_box(f'Windscreen_Gasket_B_{side}', (gx, -58.92, 1.62), (6.4, 0.28, 0.22), bevel=0.03, group='Hull', mat='HullTrim', rot=(0, 0, math.radians(-side * 8)), uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_bevel_box(
            f'Windscreen_Glass_Front_{side}',
            (gx, -58.25, 3.30),
            (6.3, 0.10, 3.35),
            bevel=0.01,
            group='Hull',
            mat='Glass',
            rot=(math.radians(-16), 0, math.radians(-side * 8))
        )
        add_bevel_box(f'Wiper_Pivot_{side}', (gx - side * 0.5, -59.05, 1.58), (0.30, 0.30, 0.24), bevel=0.03, group='Hull', mat='Metal')
        add_bevel_box(
            f'Wiper_Arm_{side}',
            (gx, -58.58, 2.45),
            (0.10, 0.12, 1.90),
            bevel=0.02,
            group='Hull',
            mat='Metal',
            rot=(math.radians(-16), math.radians(side * 16), math.radians(-side * 8))
        )
        add_bevel_box(
            f'Wiper_Blade_{side}',
            (gx + side * 0.25, -58.45, 3.15),
            (2.2, 0.10, 0.12),
            bevel=0.02,
            group='Hull',
            mat='HullTrim',
            rot=(0, 0, math.radians(-side * 8)),
            uv_mode='BAND_V',
            v_range=(0.05, 0.45)
        )

        add_bevel_box(
            f'Side_Cheek_Glass_{side}',
            (side * 7.62, -56.35, 3.28),
            (0.10, 2.45, 3.15),
            bevel=0.01,
            group='Hull',
            mat='Glass',
            rot=(0, math.radians(side * 6), math.radians(-side * 18))
        )
        add_bevel_box(f'Side_Main_Win_Gasket_{side}', (side * 8.12, -51.45, 3.28), (0.22, 5.35, 3.25), bevel=0.06, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_bevel_box(f'Side_Main_Win_Glass_{side}', (side * 7.98, -51.45, 3.28), (0.10, 5.05, 2.95), bevel=0.01, group='Hull', mat='Glass')

    add_bevel_box('Cab_Brow_Visor', (0.0, -57.95, 5.52), (14.2, 1.45, 0.34), bevel=0.05, group='Hull', mat='Paint', rot=(math.radians(8), 0, 0))
    add_cyl('Cab_Front_GrabRail', (0.0, -59.25, 1.25), 0.055, 0.055, 12.6, verts=12, group='Hull', mat='Metal', rot=(0, math.radians(90), 0))
    for side in (-1, 1):
        add_bevel_box(f'Roof_Lamp_Pod_{side}', (side * 3.4, -56.2, 6.70), (1.10, 1.30, 0.64), bevel=0.06, group='Hull', mat='Metal')
        add_cyl(f'Roof_Lamp_Lens_{side}', (side * 3.4, -56.85, 6.72), 0.26, 0.26, 0.16, verts=16, group='Hull', mat='Lamps', rot=(math.radians(90), 0, 0), uv_mode='CONST_U', u_val=0.15)

    # Front Bumper Grille, Twin Stacked Headlights & Lower Fog Lamps
    add_bevel_box('Front_Grille_Surround', (0.0, -59.92, -2.75), (6.8, 0.36, 2.45), bevel=0.05, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
    for i in range(-5, 6):
        add_bevel_box(f'Front_Grille_Slat_{i}', (i * 0.56, -60.02, -2.75), (0.22, 0.24, 2.05), bevel=0.03, group='Hull', mat='Metal')

    for side in (-1, 1):
        hx = side * 5.25
        add_bevel_box(f'Headlight_Bezel_{side}', (hx, -59.65, -2.65), (1.85, 0.45, 2.55), bevel=0.06, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        for hz in (-2.05, -3.25):
            add_cyl(f'Headlight_Rim_{side}_{hz}', (hx, -59.82, hz), 0.48, 0.44, 0.28, verts=18, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))
            add_cyl(f'Headlight_Lens_{side}_{hz}', (hx, -59.94, hz), 0.38, 0.38, 0.12, verts=18, group='Hull', mat='Lamps', rot=(math.radians(90), 0, 0), uv_mode='CONST_U', u_val=0.15)
        add_cyl(f'Chin_Lamp_Rim_{side}', (side * 3.85, -59.15, -5.25), 0.62, 0.56, 0.36, verts=18, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))
        add_cyl(f'Chin_Lamp_Lens_{side}', (side * 3.85, -59.30, -5.25), 0.48, 0.48, 0.14, verts=18, group='Hull', mat='Lamps', rot=(math.radians(90), 0, 0), uv_mode='CONST_U', u_val=0.15)

    # Dorsal Cab Roof Blister, Louvers & Hinged Hatch_Cab
    add_bevel_box('Cab_Roof_Blister', (0.0, -47.8, 7.30), (8.2, 9.2, 1.50), bevel=0.16, group='Hull', mat='Paint')
    add_bevel_box('Cab_Roof_Blister_Top', (0.0, -47.8, 8.15), (6.4, 7.4, 0.42), bevel=0.08, group='Hull', mat='Paint')
    for side in (-1, 1):
        for ly in (-50.5, -48.6, -46.7, -44.8):
            add_bevel_box(f'Cab_Blister_Vent_{side}_{ly}', (side * 4.12, ly, 7.30), (0.18, 1.25, 0.65), bevel=0.03, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))

    add_bevel_box('Hatch_Cab_Coaming', (0.0, -47.6, 8.42), (3.1, 3.4, 0.24), bevel=0.04, group='Hull', mat='Metal')
    add_bevel_box('Hatch_Cab_Lid', (0.0, -47.6, 8.62), (2.8, 3.1, 0.22), bevel=0.04, group='Hatch_Cab', mat='Paint')
    add_bevel_box('Hatch_Cab_Bar', (0.0, -47.6, 8.78), (2.2, 0.28, 0.16), bevel=0.03, group='Hatch_Cab', mat='Metal')
    add_bevel_box('Hatch_Cab_Hinge', (0.0, -46.0, 8.58), (1.8, 0.36, 0.26), bevel=0.04, group='Hatch_Cab', mat='Metal')

    # Rear Cab Roof Antenna Array (Y = -35.8)
    add_bevel_box('Cab_Antenna_Pedestal', (0.0, -35.8, 7.00), (1.6, 1.8, 0.65), bevel=0.06, group='Hull', mat='Metal')
    add_cyl('Cab_Antenna_Mast1', (0.4, -35.5, 9.2), 0.11, 0.03, 4.4, verts=12, group='Hull', mat='Metal')
    add_cyl('Cab_Antenna_Mast2', (-0.4, -36.2, 8.6), 0.09, 0.025, 3.2, verts=12, group='Hull', mat='Metal')

    # Side Ladders, Crew Airlock Doors & Side Hull Panels
    for side in (-1, 1):
        # Vertical ladder rails on side wall (Z = -4.3 to +5.5, then angled arch rails over roof chamfer!)
        for ly in (-47.05, -45.75):
            add_cyl(f'Cab_Ladder_Rail_{side}_{ly}', (side * 8.66, ly, 0.60), 0.065, 0.065, 9.8, verts=10, group='Hull', mat='Metal')
            add_cyl(f'Cab_Ladder_TopSlope_{side}_{ly}', (side * 7.95, ly, 6.12), 0.06, 0.06, 1.85, verts=10, group='Hull', mat='Metal', rot=(0, math.radians(-side * 48), 0))
        for rz in np.linspace(-4.1, 5.4, 18):
            add_cyl(f'Cab_Ladder_Rung_{side}_{rz:.2f}', (side * 8.66, -46.4, rz), 0.042, 0.042, 1.3, verts=8, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))
        for bz in (-3.8, -1.0, 2.0, 5.1):
            add_bevel_box(f'Cab_Ladder_Brk_{side}_{bz}', (side * 8.48, -46.4, bz), (0.36, 1.45, 0.14), bevel=0.02, group='Hull', mat='Metal')

        add_bevel_box(f'Cab_Airlock_Frame_{side}', (side * 8.40, -42.2, 1.85), (0.30, 3.35, 4.45), bevel=0.10, group='Hull', mat='Metal')
        add_bevel_box(f'Cab_Airlock_Door_{side}', (side * 8.46, -42.2, 1.85), (0.24, 2.85, 3.95), bevel=0.08, group='Hull', mat='Paint')
        add_bevel_box(f'Cab_Airlock_Window_Frame_{side}', (side * 8.54, -42.2, 2.75), (0.16, 1.05, 1.15), bevel=0.04, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_bevel_box(f'Cab_Airlock_Window_{side}', (side * 8.52, -42.2, 2.75), (0.10, 0.78, 0.88), bevel=0.01, group='Hull', mat='Glass')
        add_bevel_box(f'Cab_Airlock_Latch_{side}', (side * 8.58, -41.2, 1.65), (0.16, 0.25, 0.85), bevel=0.03, group='Hull', mat='Metal')

        for ly in (-43.2, -41.2):
            add_cyl(f'Cab_Step_Rail_{side}_{ly}', (side * 8.66, ly, -3.05), 0.06, 0.06, 3.5, verts=10, group='Hull', mat='Metal')
        for sz in (-4.55, -3.45, -2.35, -1.35):
            add_bevel_box(f'Cab_Step_Rung_{side}_{sz}', (side * 8.66, -42.2, sz), (0.20, 2.05, 0.10), bevel=0.02, group='Hull', mat='Metal')

        add_bevel_box(f'Cab_Aft_Panel_Top_{side}', (side * 8.42, -37.2, 3.8), (0.22, 4.8, 2.2), bevel=0.05, group='Hull', mat='Paint')
        add_bevel_box(f'Cab_Aft_Panel_Mid_{side}', (side * 8.42, -37.2, 1.3), (0.22, 4.8, 1.6), bevel=0.05, group='Hull', mat='Paint')
        add_bevel_box(f'Cab_Aft_Vent_{side}', (side * 8.46, -35.8, -2.6), (0.24, 2.2, 1.4), bevel=0.04, group='Hull', mat='Metal')

    # Underslung Belly Pressure Tanks & Compact Tucked Attitude Thruster Pods
    for tx in (-2.1, 2.1):
        add_cyl(f'Cab_Belly_Tank_{tx}', (tx, -44.2, -6.35), 1.25, 1.25, 5.0, verts=20, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))
        add_cyl(f'Cab_Belly_Tank_CapF_{tx}', (tx, -46.7, -6.35), 1.22, 0.72, 0.70, verts=20, group='Hull', mat='Paint', rot=(math.radians(90), 0, 0))
        add_cyl(f'Cab_Belly_Tank_CapR_{tx}', (tx, -41.7, -6.35), 0.72, 1.22, 0.70, verts=20, group='Hull', mat='Paint', rot=(math.radians(90), 0, 0))
        for sy in (-45.4, -43.0):
            add_cyl(f'Cab_Tank_Strap_{tx}_{sy}', (tx, sy, -6.35), 1.32, 1.32, 0.40, verts=20, group='Hull', mat='Paint', rot=(math.radians(90), 0, 0))

    for side, label in ((1, 'L'), (-1, 'R')):
        grp = f'Pod_Thruster_{label}'
        px = side * 8.65
        py = -45.0
        pz = -5.35
        add_bevel_box(f'Cab_Pod_StrutF_{label}', (side * 7.4, py - 0.75, -4.65), (2.4, 0.55, 0.55), bevel=0.04, group='Hull', mat='Metal', rot=(0, math.radians(side * 22), 0))
        add_bevel_box(f'Cab_Pod_StrutR_{label}', (side * 7.4, py + 0.75, -4.65), (2.4, 0.55, 0.55), bevel=0.04, group='Hull', mat='Metal', rot=(0, math.radians(side * 22), 0))
        add_cyl(f'Cab_Pod_Trunnion_{label}', (side * 7.95, py, pz), 0.38, 0.38, 1.2, verts=14, group='Hull', mat='Metal', rot=(0, math.radians(90), 0))

        add_cyl(f'{grp}_Body', (px, py, pz), 0.96, 0.96, 2.2, verts=20, group=grp, mat='Metal', rot=(math.radians(90), 0, 0))
        add_cyl(f'{grp}_Shroud', (px, py, pz), 1.06, 1.06, 1.1, verts=20, group=grp, mat='HullTrim', rot=(math.radians(90), 0, 0), uv_mode='BAND_V', v_range=(0.05, 0.45))
        add_cyl(f'{grp}_Nozzle_F', (px, py - 1.35, pz), 0.58, 0.84, 0.65, verts=18, group=grp, mat='ScorchedMetal', rot=(math.radians(90), 0, 0), uv_mode='NOZZLE', v_range=(py - 1.7, py - 1.0))
        add_cyl(f'{grp}_Nozzle_R', (px, py + 1.35, pz), 0.84, 0.58, 0.65, verts=18, group=grp, mat='ScorchedMetal', rot=(math.radians(90), 0, 0), uv_mode='NOZZLE', v_range=(py + 1.0, py + 1.7))
        add_cyl(f'{grp}_Glow_R', (px, py + 1.62, pz), 0.50, 0.50, 0.10, verts=14, group=grp, mat='Engines', rot=(math.radians(90), 0, 0), uv_mode='BAND_V', v_range=(0.15, 0.85))

    collar_sections = [
        (-33.6, oct_ring(13.6, 11.2, 0.0, 0.0, 0.22, 0.22)),
        (-30.2, oct_ring(12.6, 10.4, 0.0, 0.0, 0.22, 0.22)),
        (-29.6, oct_ring(11.0, 9.2, 0.0, 0.0, 0.24, 0.24)),
    ]
    add_lofted_hull('Cab_Neck_Collar', collar_sections, bevel=0.08, group='Hull', mat='Metal')
    for side in (-1, 1):
        for cz in (-2.6, 0.0, 2.6):
            add_bevel_box(f'Cab_Collar_Pocket_{side}_{cz}', (side * 6.25, -31.8, cz), (0.35, 2.4, 1.7), bevel=0.04, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))


# ==============================================================================
# SECTION 2: TRUSS SPINE & 12 STANDARD CARGO CONTAINERS (Y from -30.0 to +24.5)
# ==============================================================================
def build_truss_and_containers():
    y_start, y_end = -30.2, 24.8
    span_y = y_end - y_start
    y_mid = 0.5 * (y_start + y_end)

    for sx in (-3.6, 3.6):
        for sz in (-2.35, 2.35):
            add_bevel_box(f'Truss_Main_Chord_{sx}_{sz}', (sx, y_mid, sz), (0.64, span_y, 0.64), bevel=0.05, group='Hull', mat='Metal')
        add_bevel_box(f'Truss_Mid_Rail_{sx}', (sx, y_mid, 0.0), (0.52, span_y, 0.44), bevel=0.04, group='Hull', mat='Metal')

    for cx, cz, r in ((0.0, 0.0, 0.52), (-1.35, 0.0, 0.28), (1.35, 0.0, 0.28)):
        add_cyl(f'Truss_Core_Pipe_{cx}_{cz}', (cx, y_mid, cz), r, r, span_y, verts=14, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))

    n_truss_bays = 16
    bay_ys = np.linspace(-29.2, 23.6, n_truss_bays + 1)
    for idx, by in enumerate(bay_ys):
        for sx in (-3.6, 3.6):
            add_bevel_box(f'Truss_VPost_{idx}_{sx}', (sx, by, 0.0), (0.48, 0.44, 5.10), bevel=0.04, group='Hull', mat='Metal')
        for sz in (-2.35, 2.35):
            add_bevel_box(f'Truss_HBeam_{idx}_{sz}', (0.0, by, sz), (7.5, 0.44, 0.44), bevel=0.04, group='Hull', mat='Metal')

        if idx < n_truss_bays:
            ny = bay_ys[idx + 1]
            cy = 0.5 * (by + ny)
            dy = ny - by
            direction = 1 if (idx % 2 == 0) else -1
            for sx in (-3.6, 3.6):
                for z_half in (1.18, -1.18):
                    diag_len = math.hypot(dy, 2.35)
                    ang_x = math.atan2(2.35 * direction, dy)
                    add_bevel_box(
                        f'Truss_SideDiag_{idx}_{sx}_{z_half}',
                        (sx, cy, z_half),
                        (0.34, diag_len, 0.32),
                        bevel=0.03,
                        group='Hull',
                        mat='Metal',
                        rot=(ang_x, 0, 0)
                    )
            for sz in (-2.35, 2.35):
                plan_len = math.hypot(dy, 7.2)
                ang_z = math.atan2(7.2 * direction, dy)
                add_bevel_box(
                    f'Truss_PlanDiag_{idx}_{sz}',
                    (0.0, cy, sz),
                    (0.32, plan_len, 0.30),
                    bevel=0.03,
                    group='Hull',
                    mat='Metal',
                    rot=(0, 0, ang_z)
                )

    # Vertical End-Stop Stanchions aligned with Truss Spine Chords (X = +/-3.6, Y = -28.8 and +23.4)
    for ey in (-28.80, 23.40):
        for sx in (-3.6, 3.6):
            for sz in (-5.4, 5.4):
                add_bevel_box(f'Rack_End_Stanchion_{ey}_{sx}_{sz}', (sx, ey, sz), (0.85, 0.68, 4.4), bevel=0.05, group='Hull', mat='Metal')

    # 12 STANDARD CARGO CONTAINERS (4 Longitudinal Bays x 3 Containers per Bay)
    bay_centers_y = [-22.05, -9.15, 3.75, 16.65]
    c_len = 11.75
    c_w = 7.45
    c_h = 5.75

    def build_single_container(cid, cx, cy, cz, cw, cl, ch, dual_lobe=False):
        if not dual_lobe:
            lobe_xs = [(cx, cw)]
        else:
            lobe_xs = [(+4.95, c_w), (-4.95, c_w)]
            add_bevel_box(f'Cont_{cid}_Bridge', (0.0, cy, cz + ch*0.32), (3.2, cl - 0.8, 1.4), bevel=0.04, group='Hull', mat='Containers')

        for l_idx, (lx, lw) in enumerate(lobe_xs):
            tag = f'{cid}_{l_idx}'
            add_bevel_box(f'Cont_{tag}_Core', (lx, cy, cz), (lw - 0.34, cl - 0.34, ch - 0.34), bevel=0.05, group='Hull', mat='Containers')
            for sz in (-1, 1):
                fz = cz + sz * (ch * 0.5 - 0.20)
                add_bevel_box(f'Cont_{tag}_LRail_{sz}', (lx, cy, fz), (lw, cl, 0.40), bevel=0.04, group='Hull', mat='Containers')
            for sy in (-1, 1):
                fy = cy + sy * (cl * 0.5 - 0.22)
                # Top and bottom end cross-rails + corner posts so the end panel is recessed!
                for sz in (-1, 1):
                    add_bevel_box(f'Cont_{tag}_EndBar_{sy}_{sz}', (lx, fy, cz + sz * (ch * 0.5 - 0.22)), (lw, 0.44, 0.44), bevel=0.04, group='Hull', mat='Containers')
                for sx in (-1, 1):
                    fx = lx + sx * (lw * 0.5 - 0.22)
                    add_bevel_box(f'Cont_{tag}_CornerPost_{sy}_{sx}', (fx, fy, cz), (0.50, 0.50, ch + 0.10), bevel=0.05, group='Hull', mat='Metal')
                # Vertical door corrugation ribs & locking bars on front/rear container ends
                for ex_i in range(-2, 3):
                    rx = lx + ex_i * 1.15
                    add_bevel_box(f'Cont_{tag}_EndRib_{sy}_{ex_i}', (rx, fy, cz), (0.36, 0.28, ch - 0.82), bevel=0.03, group='Hull', mat='Containers')
                for lock_x in (-0.85, 0.85):
                    add_cyl(f'Cont_{tag}_LockBar_{sy}_{lock_x}', (lx + lock_x, fy + sy * 0.18, cz), 0.045, 0.045, ch - 0.65, verts=8, group='Hull', mat='Metal')

            for half_y in (-cl * 0.25, cl * 0.25):
                for r_i in range(-2, 3):
                    ry = cy + half_y + r_i * 0.92
                    add_bevel_box(f'Cont_{tag}_SideRib_{half_y:.1f}_{r_i}', (lx, ry, cz), (lw + 0.04, 0.34, ch - 0.78), bevel=0.03, group='Hull', mat='Containers')
                    add_bevel_box(f'Cont_{tag}_RoofRib_{half_y:.1f}_{r_i}', (lx, ry, cz), (lw - 0.78, 0.34, ch + 0.04), bevel=0.03, group='Hull', mat='Containers')

            for dy in (-0.48, 0.48):
                add_bevel_box(f'Clamp_{tag}_Band_{dy}', (lx, cy + dy, cz), (lw + 0.44, 0.38, ch + 0.44), bevel=0.04, group='Hull', mat='Metal')
            out_x = lx + (1 if lx >= 0 else -1) * (lw * 0.5 + 0.28)
            add_bevel_box(f'Clamp_{tag}_SideJaws', (out_x, cy, cz), (0.50, 1.55, 2.45), bevel=0.05, group='Hull', mat='Metal')
            add_bevel_box(f'Clamp_{tag}_Actuator', (out_x, cy, cz), (0.62, 0.80, 1.35), bevel=0.04, group='Hull', mat='Paint')

    c_idx = 1
    for b_idx, by in enumerate(bay_centers_y):
        build_single_container(c_idx, +4.95, by, +5.65, c_w, c_len, c_h, dual_lobe=False)
        c_idx += 1
        build_single_container(c_idx, -4.95, by, +5.65, c_w, c_len, c_h, dual_lobe=False)
        c_idx += 1
        build_single_container(c_idx, 0.0, by, -5.65, c_w, c_len, c_h, dual_lobe=True)
        c_idx += 1

        for sz in (-2.65, 2.65):
            add_bevel_box(f'Spine_Clamp_Saddle_{b_idx}_{sz}', (0.0, by, sz), (10.4, 1.75, 0.65), bevel=0.05, group='Hull', mat='Metal')
            for side in (-1, 1):
                add_bevel_box(f'Spine_Clamp_Knuckle_{b_idx}_{sz}_{side}', (side * 4.2, by, sz), (1.35, 2.05, 0.95), bevel=0.05, group='Hull', mat='Paint')


# ==============================================================================
# SECTION 3: REACTOR BLOCK, RADIATOR FINS, ANTENNA ARRAY & 4 NOZZLES (Y +24 to +60)
# ==============================================================================
def build_reactor_and_engines():
    reactor_sections = [
        (24.2, oct_ring(13.6, 11.4, 0.0, 0.0, 0.22, 0.22)),
        (25.8, oct_ring(20.0, 15.2, 0.0, 0.0, 0.22, 0.22)),
        (28.5, oct_ring(24.8, 16.8, 0.0, 0.0, 0.24, 0.24)),
        (46.8, oct_ring(24.8, 16.8, 0.0, 0.0, 0.24, 0.24)),
        (49.2, oct_ring(21.6, 15.4, 0.0, 0.0, 0.22, 0.22)),
        (51.0, oct_ring(18.6, 14.2, 0.0, 0.0, 0.20, 0.20)),
    ]
    add_lofted_hull('Reactor_Main_Hull', reactor_sections, bevel=0.10, group='Hull', mat='ReactorHull')

    for ry in (29.2, 34.2, 46.4):
        ring_sec = [
            (ry - 0.35, oct_ring(25.1, 17.1, 0.0, 0.0, 0.24, 0.24)),
            (ry + 0.35, oct_ring(25.1, 17.1, 0.0, 0.0, 0.24, 0.24)),
        ]
        add_lofted_hull(f'Reactor_Seam_Band_{ry}', ring_sec, bevel=0.05, group='Hull', mat='Metal')

    for side in (-1, 1):
        add_bevel_box(f'Reactor_Fwd_Vent_Box_{side}', (side * 10.6, 27.5, 0.0), (1.2, 2.2, 4.2), bevel=0.05, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        for vz in np.linspace(-1.6, 1.6, 9):
            add_bevel_box(f'Reactor_Fwd_Vent_Slat_{side}_{vz:.2f}', (side * 11.1, 27.5, vz), (0.35, 1.85, 0.16), bevel=0.02, group='Hull', mat='ReactorHull')

    for side in (-1, 1):
        for v_dir in (-1, 1):
            bank_x = side * 9.85
            bank_z = v_dir * 6.25
            ang_y = math.radians(-side * v_dir * 36)
            add_bevel_box(
                f'Reactor_Gill_Trough_{side}_{v_dir}',
                (bank_x, 40.5, bank_z),
                (4.4, 11.0, 0.75),
                bevel=0.04,
                group='Hull',
                mat='HullTrim',
                rot=(0, ang_y, 0),
                uv_mode='BAND_V',
                v_range=(0.05, 0.45)
            )
            for gy in np.linspace(35.3, 45.7, 19):
                add_bevel_box(
                    f'Reactor_Gill_Blade_{side}_{v_dir}_{gy:.2f}',
                    (bank_x + side * 0.24, gy, bank_z + v_dir * 0.24),
                    (4.5, 0.25, 0.95),
                    bevel=0.03,
                    group='Hull',
                    mat='ReactorHull',
                    rot=(0, ang_y, 0)
                )

    # Crisp Multi-Module Industrial Side Equipment Sponsons reaching exact X = +/-14.0 m
    for side in (-1, 1):
        add_bevel_box(f'Reactor_Side_Sponson_{side}', (side * 12.35, 37.8, 0.15), (2.70, 11.4, 4.3), bevel=0.07, group='Hull', mat='ReactorHull')
        add_bevel_box(f'Reactor_Sponson_Recess_{side}', (side * 12.95, 37.8, 0.15), (1.10, 10.2, 3.5), bevel=0.05, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        # Protruding light-grey & metallic sub-boxes extending out to X = +/-14.0 m (on top of the dark recess!)
        add_bevel_box(f'Reactor_Sponson_BoxU1_{side}', (side * 13.45, 35.8, 1.05), (1.10, 5.2, 1.25), bevel=0.05, group='Hull', mat='ReactorHull')
        add_bevel_box(f'Reactor_Sponson_BoxU2_{side}', (side * 13.45, 40.4, 1.05), (1.10, 3.2, 1.25), bevel=0.05, group='Hull', mat='Metal')
        add_bevel_box(f'Reactor_Sponson_BoxL1_{side}', (side * 13.45, 38.0, -0.75), (1.10, 8.6, 1.20), bevel=0.05, group='Hull', mat='ReactorHull')
        for vy in (34.6, 36.8, 39.0, 41.2):
            add_bevel_box(f'Reactor_Sponson_Rib_{side}_{vy}', (side * 13.72, vy, -0.75), (0.56, 0.34, 1.34), bevel=0.02, group='Hull', mat='Metal')
            add_bevel_box(f'Reactor_Sponson_TopLug_{side}_{vy}', (side * 13.72, vy, 1.05), (0.56, 0.32, 1.32), bevel=0.02, group='Hull', mat='Metal')
        for pz in (-2.35, 0.15, 2.65):
            add_cyl(f'Reactor_Flank_Pipe_{side}_{pz}', (side * 12.65, 37.8, pz), 0.22, 0.22, 14.2, verts=12, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))

    # Ventral Radiator Fin reaching exact Z = -12.0 m
    rad_fin_sections = [
        (35.0, [(-3.6, -8.2), (3.6, -8.2), (2.8, -10.8), (-2.8, -10.8)]),
        (36.8, [(-3.8, -8.2), (3.8, -8.2), (3.0, -12.0), (-3.0, -12.0)]),
        (45.2, [(-3.8, -8.2), (3.8, -8.2), (3.0, -12.0), (-3.0, -12.0)]),
        (47.0, [(-3.2, -8.2), (3.2, -8.2), (2.4, -10.6), (-2.4, -10.6)]),
    ]
    add_lofted_hull('Ventral_Radiator_Fin', rad_fin_sections, bevel=0.06, group='Hull', mat='ReactorHull')
    add_bevel_box('Ventral_Rad_Trough', (0.0, 41.0, -10.2), (6.8, 9.6, 2.6), bevel=0.04, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
    for ry in np.linspace(36.4, 45.6, 15):
        add_bevel_box(f'Ventral_Rad_Rib_{ry:.2f}', (0.0, ry, -10.2), (7.2, 0.26, 2.9), bevel=0.03, group='Hull', mat='Metal')

    add_bevel_box('Radiator_Vane_Blade', (0.0, 41.0, -11.65), (0.55, 7.8, 0.68), bevel=0.04, group='Radiator_Vane', mat='Metal')

    # Dorsal Spine Platform & Tall Antenna Array (Z from +8.4 to +12.0 m)
    add_bevel_box('Reactor_Dorsal_Platform', (0.0, 37.8, 8.65), (7.8, 13.4, 0.75), bevel=0.08, group='Hull', mat='ReactorHull')
    add_cyl('Antenna_Main_Base', (1.25, 40.5, 9.35), 0.85, 0.58, 0.95, verts=16, group='Hull', mat='Metal')
    add_cyl('Antenna_Main_Tower', (1.25, 40.5, 10.35), 0.36, 0.22, 1.25, verts=14, group='Hull', mat='Metal')
    add_cyl('Antenna_Main_Whip', (1.25, 40.5, 11.45), 0.14, 0.05, 1.10, verts=12, group='Hull', mat='Metal')
    add_bevel_box('Antenna_Main_Crossbar1', (1.25, 40.5, 10.95), (2.4, 0.14, 0.14), bevel=0.02, group='Hull', mat='Metal')
    add_bevel_box('Antenna_Main_Crossbar2', (1.25, 40.5, 11.55), (1.5, 0.12, 0.12), bevel=0.02, group='Hull', mat='Metal')

    add_cyl('Antenna_Sec_Base', (-1.35, 38.4, 9.25), 0.55, 0.38, 0.80, verts=14, group='Hull', mat='Metal')
    add_cyl('Antenna_Sec_Whip', (-1.35, 38.4, 10.65), 0.14, 0.04, 2.20, verts=12, group='Hull', mat='Metal')

    add_cyl('Antenna_Dish_Pedestal', (0.0, 35.2, 9.20), 0.85, 0.68, 0.55, verts=16, group='Hull', mat='Metal')
    add_cyl('Antenna_Dish_Turntable', (0.0, 35.2, 9.62), 0.65, 0.58, 0.42, verts=16, group='Antenna_Dish', mat='Metal')
    add_bevel_box('Antenna_Dish_Yoke', (0.0, 35.2, 10.15), (1.35, 0.52, 0.85), bevel=0.04, group='Antenna_Dish', mat='Metal')
    add_cyl('Antenna_Dish_Reflector', (0.0, 34.65, 10.55), 1.65, 0.38, 0.65, verts=24, group='Antenna_Dish', mat='ReactorHull', rot=(math.radians(54), 0, 0))
    add_cyl('Antenna_Dish_Rim', (0.0, 34.45, 10.70), 1.68, 1.68, 0.18, verts=24, group='Antenna_Dish', mat='Metal', rot=(math.radians(54), 0, 0))
    add_cyl('Antenna_Dish_FeedHorn', (0.0, 33.95, 11.05), 0.10, 0.22, 0.95, verts=12, group='Antenna_Dish', mat='Metal', rot=(math.radians(54), 0, 0))

    # Rear Central Exhaust Grilles & 4 Main Engine Nozzles (Y from +50.5 to +60.0)
    for gz in (-4.1, 0.0, 4.1):
        add_bevel_box(f'Rear_Center_Grille_Frame_{gz}', (0.0, 51.1, gz), (3.4, 0.85, 3.4), bevel=0.06, group='Hull', mat='Metal')
        add_bevel_box(f'Rear_Center_Grille_Core_{gz}', (0.0, 51.35, gz), (2.8, 0.45, 2.8), bevel=0.03, group='Hull', mat='HullTrim', uv_mode='BAND_V', v_range=(0.05, 0.45))
        for sz in np.linspace(gz - 1.1, gz + 1.1, 7):
            add_bevel_box(f'Rear_Center_Grille_Slat_{gz}_{sz:.2f}', (0.0, 51.52, sz), (2.7, 0.22, 0.16), bevel=0.02, group='Hull', mat='Metal')

    nozzle_coords = [
        (1,  +5.25, +4.15),
        (2,  -5.25, +4.15),
        (3,  +5.25, -4.15),
        (4,  -5.25, -4.15),
    ]

    for idx, nx, nz in nozzle_coords:
        add_cyl(f'Nozzle_{idx}_MountRing', (nx, 51.2, nz), 2.80, 2.60, 1.2, verts=24, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))
        add_cyl(f'Nozzle_{idx}_NeckTube', (nx, 52.4, nz), 2.10, 2.30, 2.0, verts=24, group='Hull', mat='Metal', rot=(math.radians(90), 0, 0))

        for s_i in range(8):
            ang = s_i * (2.0 * math.pi / 8.0) + 0.39
            sx = nx + math.cos(ang) * 2.42
            sz = nz + math.sin(ang) * 2.42
            add_cyl(
                f'Nozzle_{idx}_Strut_{s_i}',
                (sx, 52.35, sz),
                0.14, 0.12, 2.5,
                verts=10,
                group='Hull',
                mat='Metal',
                rot=(math.radians(90) - math.sin(ang)*0.18, math.cos(ang)*0.18, 0)
            )

        bm = bmesh.new()
        n_seg = 32
        bell_prof = [
            (52.8, 2.22),
            (54.2, 2.55),
            (56.0, 2.92),
            (58.0, 3.24),
            (59.6, 3.44),
            (60.0, 3.50),
        ]
        rings_out = []
        for py, pr in bell_prof:
            ring = [bm.verts.new((nx + pr * math.cos(a), py, nz + pr * math.sin(a))) for a in np.linspace(0, 2*math.pi, n_seg, endpoint=False)]
            rings_out.append(ring)
        lip_in = [bm.verts.new((nx + 3.32 * math.cos(a), 60.0, nz + 3.32 * math.sin(a))) for a in np.linspace(0, 2*math.pi, n_seg, endpoint=False)]
        rings_out.append(lip_in)

        for r_i in range(len(rings_out) - 1):
            r0, r1 = rings_out[r_i], rings_out[r_i + 1]
            for j in range(n_seg):
                nj = (j + 1) % n_seg
                bm.faces.new((r0[j], r0[nj], r1[nj], r1[j]))
        bm.faces.new(list(reversed(rings_out[0])))
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
        me = bpy.data.meshes.new(f'Nozzle_{idx}_Bell_Outer')
        bm.to_mesh(me)
        bm.free()
        for p in me.polygons:
            p.use_smooth = True
        o_bell = bpy.data.objects.new(f'Nozzle_{idx}_Bell_Outer', me)
        S.collection.objects.link(o_bell)
        bpy.context.view_layer.update()
        apply_uv_and_reg(o_bell, 'Hull', 'ScorchedMetal', uv_mode='NOZZLE', v_range=(52.8, 60.0))

        for hy, hr in ((53.8, 2.50), (55.5, 2.86), (57.3, 3.17), (59.1, 3.41)):
            add_cyl(f'Nozzle_{idx}_Hoop_{hy}', (nx, hy, nz), hr + 0.08, hr + 0.08, 0.26, verts=28, group='Hull', mat='ScorchedMetal', rot=(math.radians(90), 0, 0), uv_mode='NOZZLE', v_range=(52.8, 60.0))
        for r_i in range(16):
            ang = r_i * (2.0 * math.pi / 16.0)
            rx = nx + math.cos(ang) * 2.88
            rz = nz + math.sin(ang) * 2.88
            add_bevel_box(
                f'Nozzle_{idx}_Stringer_{r_i}',
                (rx, 56.3, rz),
                (0.12, 6.6, 0.14),
                bevel=0.02,
                group='Hull',
                mat='ScorchedMetal',
                rot=(math.sin(ang)*0.17, -math.cos(ang)*0.17, ang),
                uv_mode='NOZZLE',
                v_range=(52.8, 60.0)
            )

        bm_in = bmesh.new()
        in_prof = [
            (52.5, 0.25),
            (52.8, 1.72),
            (54.5, 2.22),
            (57.0, 2.82),
            (59.70, 3.28),
            (60.00, 3.36),
        ]
        rings_in = []
        for py, pr in in_prof:
            ring = [bm_in.verts.new((nx + pr * math.cos(a), py, nz + pr * math.sin(a))) for a in np.linspace(0, 2*math.pi, n_seg, endpoint=False)]
            rings_in.append(ring)
        for r_i in range(len(rings_in) - 1):
            r0, r1 = rings_in[r_i], rings_in[r_i + 1]
            for j in range(n_seg):
                nj = (j + 1) % n_seg
                bm_in.faces.new((r0[nj], r0[j], r1[j], r1[nj]))
        bm_in.faces.new(rings_in[0])
        bmesh.ops.recalc_face_normals(bm_in, faces=bm_in.faces)
        for f in bm_in.faces:
            if f.normal.y < 0 and abs(f.normal.y) > 0.5:
                f.normal_flip()
        me_in = bpy.data.meshes.new(f'Nozzle_{idx}_Inner_Glow')
        bm_in.to_mesh(me_in)
        bm_in.free()
        for p in me_in.polygons:
            p.use_smooth = True
        o_in = bpy.data.objects.new(f'Nozzle_{idx}_Inner_Glow', me_in)
        S.collection.objects.link(o_in)
        bpy.context.view_layer.update()
        apply_uv_and_reg(o_in, 'Hull', 'Engines', uv_mode='NOZZLE', v_range=(52.5, 60.0))


build_cab_module()
build_truss_and_containers()
build_reactor_and_engines()

PIVOTS = {
    'Hull': (0.0, 0.0, 0.0),
    'Antenna_Dish': (0.0, 35.2, 9.62),
    'Hatch_Cab': (0.0, -46.0, 8.58),
    'Pod_Thruster_L': (8.65, -45.0, -5.35),
    'Pod_Thruster_R': (-8.65, -45.0, -5.35),
    'Radiator_Vane': (0.0, 41.0, -11.65),
}

MOVING_AXES = {
    'Antenna_Dish': [0.0, 1.0, 0.0],
    'Hatch_Cab': [1.0, 0.0, 0.0],
    'Pod_Thruster_L': [1.0, 0.0, 0.0],
    'Pod_Thruster_R': [1.0, 0.0, 0.0],
    'Radiator_Vane': [0.0, 1.0, 0.0],
}

mesh_nodes = {}
for gname, objs in groups.items():
    bpy.ops.object.select_all(action='DESELECT')
    for o in objs:
        o.select_set(True)
    bpy.context.view_layer.objects.active = objs[0]
    bpy.ops.object.join()
    joined = bpy.context.object
    joined.name = gname
    S.cursor.location = PIVOTS.get(gname, (0.0, 0.0, 0.0))
    bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    active(joined)
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)

    bm = bmesh.new()
    bm.from_mesh(joined.data)
    bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=0.0002)
    bmesh.ops.dissolve_degenerate(bm, edges=list(bm.edges), dist=0.00001)
    bmesh.ops.triangulate(bm, faces=list(bm.faces))
    bm.to_mesh(joined.data)
    bm.free()
    asset.append(joined)
    mesh_nodes[gname] = joined
    print('NODE_BUILT', gname, 'TRIS:', len(joined.data.polygons), flush=True)

hull_obj = mesh_nodes['Hull']
for gname, obj in mesh_nodes.items():
    if gname != 'Hull':
        wmat = obj.matrix_world.copy()
        obj.parent = hull_obj
        obj.matrix_parent_inverse = hull_obj.matrix_world.inverted()
        obj.matrix_world = wmat

# Normalize exact overall bounding box to 28.000 m (X) x 120.000 m (Y) x 24.000 m (Z) centered at (0,0,0)
bpy.context.view_layer.update()
pts_raw = [o.matrix_world @ Vector(v) for o in asset for v in o.bound_box]
lo_raw = [min(p[k] for p in pts_raw) for k in range(3)]
hi_raw = [max(p[k] for p in pts_raw) for k in range(3)]
target_dims = (28.0, 120.0, 24.0)
for k in range(3):
    cur_span = hi_raw[k] - lo_raw[k]
    cur_mid = 0.5 * (hi_raw[k] + lo_raw[k])
    scale_k = target_dims[k] / cur_span
    for o in asset:
        if o == hull_obj:
            for v in o.data.vertices:
                v.co[k] = (v.co[k] - cur_mid) * scale_k
            o.data.update()
        else:
            w_loc = o.matrix_world.translation.copy()
            new_w_k = (w_loc[k] - cur_mid) * scale_k
            delta_k = new_w_k - w_loc[k]
            o.location[k] += delta_k
            for v in o.data.vertices:
                v.co[k] *= scale_k
            o.data.update()

bpy.context.view_layer.update()

SOCKET_DEFS = [
    ('Thruster_1', (5.25, 60.0, 4.15)),
    ('Thruster_2', (-5.25, 60.0, 4.15)),
    ('Thruster_3', (5.25, 60.0, -4.15)),
    ('Thruster_4', (-5.25, 60.0, -4.15)),
    ('Seat_Pilot', (3.35, -54.6, 2.02)),
    ('Camera_Chase', (0.0, 92.0, 20.0)),
    ('Light_Nav_L', (14.0, 37.8, 0.15)),
    ('Light_Nav_R', (-14.0, 37.8, 0.15)),
    ('Light_Beacon', (1.25, 40.5, 12.0)),
]

add_bevel_box('Nav_Lamp_L', (13.94, 37.8, 0.15), (0.12, 0.65, 0.35), bevel=0.02, group='Hull_Extra', mat='Lamps', uv_mode='CONST_U', u_val=0.50)
add_bevel_box('Nav_Lamp_R', (-13.94, 37.8, 0.15), (0.12, 0.65, 0.35), bevel=0.02, group='Hull_Extra', mat='Lamps', uv_mode='CONST_U', u_val=0.82)
add_cyl('Beacon_Lamp_Top', (1.25, 40.5, 11.92), 0.14, 0.14, 0.16, verts=12, group='Hull_Extra', mat='Lamps', uv_mode='CONST_U', u_val=0.50)

if 'Hull_Extra' in groups:
    bpy.ops.object.select_all(action='DESELECT')
    hull_obj.select_set(True)
    for o in groups['Hull_Extra']:
        o.select_set(True)
    bpy.context.view_layer.objects.active = hull_obj
    bpy.ops.object.join()
    bm = bmesh.new()
    bm.from_mesh(hull_obj.data)
    bmesh.ops.triangulate(bm, faces=list(bm.faces))
    bm.to_mesh(hull_obj.data)
    bm.free()

for sname, sloc in SOCKET_DEFS:
    emp = bpy.data.objects.new(sname, None)
    emp.empty_display_type = 'PLAIN_AXES'
    emp.empty_display_size = 1.0
    S.collection.objects.link(emp)
    emp.parent = hull_obj
    emp.location = sloc
    sockets.append(emp)

bpy.context.view_layer.update()

raw_tri = sum(len(o.data.polygons) for o in asset)
print('RAW_TRIS:', raw_tri, flush=True)

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

pts = [o.matrix_world @ Vector(v) for o in asset for v in o.bound_box]
lo = [min(p[k] for p in pts) for k in range(3)]
hi = [max(p[k] for p in pts) for k in range(3)]

def setup_stage():
    world = bpy.data.worlds.new('SpaceWorld')
    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_bg = 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.004, 0.007, 0.015, 1.0)
    w_ramp.color_ramp.elements[1].position = 1.0
    w_ramp.color_ramp.elements[1].color = (0.014, 0.026, 0.052, 1.0)
    wl.new(w_tc.outputs['Window'], w_sep.inputs[0])
    wl.new(w_sep.outputs['Y'], w_ramp.inputs[0])
    wl.new(w_ramp.outputs['Color'], w_bg.inputs['Color'])
    w_bg.inputs['Strength'].default_value = 1.0
    wl.new(w_bg.outputs[0], w_out.inputs[0])

    def add_sun(name, energy, color, target_vec, from_vec):
        ld = bpy.data.lights.new(name, type='SUN')
        ld.energy = energy
        ld.color = color
        ld.angle = math.radians(6.0)
        lo_obj = bpy.data.objects.new(name, ld)
        S.collection.objects.link(lo_obj)
        lo_obj.rotation_euler = (Vector(target_vec) - Vector(from_vec)).to_track_quat('-Z', 'Y').to_euler()
        return lo_obj

    add_sun('KeyLight', 3.8, (1.0, 0.96, 0.90), (0, 0, 0), (50, -120, 95))
    add_sun('FillLight', 1.6, (0.68, 0.84, 1.0), (0, 0, 0), (120, 60, -20))
    add_sun('RimLight', 2.0, (0.82, 0.90, 1.0), (0, 0, 0), (-90, 80, 75))

    cam_d = bpy.data.cameras.new('RenderCam')
    cam_d.type = 'PERSP'
    cam_d.lens = 40.0
    cam_d.clip_end = 1000.0
    cam_o = bpy.data.objects.new('RenderCam', cam_d)
    S.collection.objects.link(cam_o)
    S.camera = cam_o
    cam_o.location = (86.0, -104.0, 42.0)
    target = Vector((0.0, -2.0, -0.5))
    cam_o.rotation_euler = (target - cam_o.location).to_track_quat('-Z', 'Y').to_euler()

    S.render.resolution_x = 1920
    S.render.resolution_y = 1080
    S.render.image_settings.file_format = 'PNG'
    S.view_settings.view_transform = 'AgX'
    S.view_settings.look = 'AgX - Medium High Contrast'
    S.cycles.use_denoising = True
    return cam_o

def render_img(filename, samples=48):
    S.cycles.samples = samples
    S.render.filepath = str(ROOT / filename)
    bpy.ops.render.render(write_still=True)
    print('RENDERED', filename, flush=True)

cam = setup_stage()

# 1. Clay render first
clay_mat = bpy.data.materials.new('ClayOverride')
clay_mat.use_nodes = True
clay_bsdf = clay_mat.node_tree.nodes.get('Principled BSDF')
clay_bsdf.inputs['Base Color'].default_value = (0.42, 0.42, 0.43, 1.0)
clay_bsdf.inputs['Roughness'].default_value = 0.58
S.view_layers[0].material_override = clay_mat
render_img('clay.png', samples=32)
S.view_layers[0].material_override = None

# 2. Bake all 9 materials with Cycles GPU
for m in M.values():
    bake_material(m)

# 3. Moving parts 30-degree pivot verification
for mname, axis_gltf in MOVING_AXES.items():
    obj = mesh_nodes[mname]
    b_axis = Vector((axis_gltf[0], -axis_gltf[2], axis_gltf[1])).normalized()
    obj.rotation_mode = 'AXIS_ANGLE'
    obj.rotation_axis_angle = (math.radians(30.0), b_axis.x, b_axis.y, b_axis.z)
bpy.context.view_layer.update()
for mname in MOVING_AXES:
    obj = mesh_nodes[mname]
    obj.rotation_axis_angle = (0.0, 0.0, 0.0, 1.0)
    obj.rotation_mode = 'XYZ'
    obj.rotation_euler = (0.0, 0.0, 0.0)
bpy.context.view_layer.update()

# 4. Export GLB
bpy.ops.object.select_all(action='DESELECT')
for o in asset + sockets:
    o.select_set(True)
bpy.context.view_layer.objects.active = hull_obj
glb_path = ROOT / 'cargo-hauler.glb'
bpy.ops.export_scene.gltf(
    filepath=str(glb_path),
    export_format='GLB',
    use_selection=True,
    export_apply=True,
    export_yup=True,
    export_texcoords=True,
    export_normals=True,
    export_tangents=False,
    export_materials='EXPORT',
    export_image_format='AUTO',
)

# Patch GLB JSON chunk so Paint has exact baseColorFactor = [0.80, 0.45, 0.05, 1.0] and Glass has BLEND
data = glb_path.read_bytes()
json_len, json_type = struct.unpack_from('<II', data, 12)
gjson = json.loads(data[20:20 + json_len])
bin_chunk = data[20 + json_len:]
for mat in gjson.get('materials', []):
    if mat.get('name') == 'Paint':
        mat.setdefault('pbrMetallicRoughness', {})['baseColorFactor'] = [0.80, 0.45, 0.05, 1.0]
    elif mat.get('name') == 'Glass':
        mat['alphaMode'] = 'BLEND'
        mat['doubleSided'] = True
payload = json.dumps(gjson, separators=(',', ':')).encode('utf-8')
payload += b' ' * ((-len(payload)) % 4)
glb_path.write_bytes(
    struct.pack('<4sII', b'glTF', 2, 20 + len(payload) + len(bin_chunk))
    + struct.pack('<II', len(payload), 0x4E4F534A)
    + payload
    + bin_chunk
)

tri_count = sum(len(o.data.polygons) for o in asset)
glb_bytes = glb_path.stat().st_size

nodes_dict = {}
nodes_list = []
for o in asset + sockets:
    wpos = gamepos(o.matrix_world.translation)
    entry = {'name': o.name, 'type': o.type, 'world_position': wpos}
    if o.name in MOVING_AXES:
        entry['axis'] = MOVING_AXES[o.name]
    nodes_list.append(entry)
    nodes_dict[o.name] = {'type': o.type, 'world_position': wpos}

manifest = {
    'units': 'metres',
    'coordinate_system': {'forward': '+Z', 'up': '+Y', 'left': '+X'},
    'bounding_box': {
        'min': [round(lo[0], 4), round(lo[2], 4), round(-hi[1], 4)],
        'max': [round(hi[0], 4), round(hi[2], 4), round(-lo[1], 4)],
        'size': [round(hi[0] - lo[0], 4), round(hi[2] - lo[2], 4), round(hi[1] - lo[1], 4)],
    },
    'axes': MOVING_AXES,
    'paints': PAINTS,
    'nodes': nodes_list,
    'node_map': nodes_dict,
    'triangles': tri_count,
    'materials': list(M.keys()),
    'glb_bytes': glb_bytes,
}
(ROOT / 'nodes.json').write_text(json.dumps(manifest, indent=2) + '\n')

assert 60000 <= tri_count <= 150000, f'Triangle count {tri_count} out of 60k..150k budget'
assert glb_bytes <= 12 * 1024 * 1024, f'GLB size {glb_bytes} exceeds 12 MB'
assert len(M) <= 9, f'Too many materials: {len(M)}'

print('GLB_EXPORTED', 'TRIS:', tri_count, 'BYTES:', glb_bytes, 'BBOX:', manifest['bounding_box']['size'], flush=True)

# 5. Render final.png (1920x1080 Cycles, 3/4 front view matching sheet.jpg)
render_img('final.png', samples=64)

# 6. Render side.png (1920x1080 Orthographic Side View)
cam.data.type = 'ORTHO'
cam.data.ortho_scale = 126.0
cam.location = (120.0, 0.0, 0.0)
cam.rotation_euler = (Vector((0.0, 0.0, 0.0)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
render_img('side.png', samples=36)

# 7. Render paints.png (3x2 grid of the 6 faction paints in 3/4 view)
cam.data.type = 'PERSP'
cam.location = (86.0, -104.0, 42.0)
cam.rotation_euler = (Vector((0.0, -2.0, -0.5)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
S.render.resolution_x = 640
S.render.resolution_y = 360
tiles = []
for i, p_info in enumerate(PAINTS):
    M['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value = (*p_info['linear_rgb'], 1.0)
    tmp_name = f'_paint_tile_{i}.png'
    render_img(tmp_name, samples=16)
    im = bpy.data.images.load(str(ROOT / tmp_name), check_existing=False)
    arr = np.array(im.pixels[:], dtype=np.float32).reshape(360, 640, 4)
    tiles.append(arr)
    bpy.data.images.remove(im)
    (ROOT / tmp_name).unlink()

grid = np.concatenate([
    np.concatenate(tiles[3:6], axis=1),
    np.concatenate(tiles[0:3], axis=1),
], axis=0)
grid_im = bpy.data.images.new('PaintsGrid', width=1920, height=720, alpha=True)
grid_im.pixels.foreach_set(grid.ravel())
grid_im.filepath_raw = str(ROOT / 'paints.png')
grid_im.file_format = 'PNG'
grid_im.save()
M['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value = (*PAINTS[0]['linear_rgb'], 1.0)

# Write 6-line NOTES.md
notes = [
    "Long-haul industrial space cargo hauler (120.0 m long x 28.0 m wide x 24.0 m high, +Z nose, +Y up, origin at centre of mass) with front cab module, open truss spine carrying 12 standard cargo containers, and rear quad-nozzle reactor block.",
    f"Triangle count: {tri_count:,} triangles across 6 mesh nodes (Hull, Antenna_Dish, Hatch_Cab, Pod_Thruster_L, Pod_Thruster_R, Radiator_Vane) and 9 empty game sockets; GLB size: {glb_bytes / (1024*1024):.2f} MB.",
    "Materials (9): Paint (neutral baked detail + editable baseColorFactor [0.80, 0.45, 0.05]), Containers, Metal, ReactorHull, HullTrim, ScorchedMetal, Glass (alpha BLEND with interior cockpit), Lamps (emissive), Engines (emissive orange/blue core).",
    "Texture sizes: Cycles GPU baked tiling and trim maps at most 1024x1024 px for Base (JPEG) and at most 512x512 px for Normal and packed ORM (Occlusion/Roughness/Metallic).",
    "Poly Haven CC0 texture IDs used: metal_plate_02, rusty_metal_03, rusty_painted_metal (https://polyhaven.com/license).",
    "Differences from reference sheet: added full interior bridge cockpit behind windscreen glazing, articulated thruster pods/hatch/dish/radiator vane with game pivots, and neutral Paint texture for runtime faction recolouring."
]
(ROOT / 'NOTES.md').write_text('\n'.join(notes) + '\n')
print('BUILD_COMPLETE', flush=True)
