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 white [0.80, 0.80, 0.78] first per contract)
PAINTS = [
    {'name': 'Colony White', 'linear_rgb': [0.80, 0.80, 0.78], 'rgb': [0.80, 0.80, 0.78]},
    {'name': 'Industrial Orange', 'linear_rgb': [0.80, 0.42, 0.06], 'rgb': [0.80, 0.42, 0.06]},
    {'name': 'Safety Yellow', 'linear_rgb': [0.82, 0.66, 0.05], 'rgb': [0.82, 0.66, 0.05]},
    {'name': 'Signal Red', 'linear_rgb': [0.68, 0.08, 0.06], 'rgb': [0.68, 0.08, 0.06]},
    {'name': 'Deep Space Teal', 'linear_rgb': [0.06, 0.40, 0.46], 'rgb': [0.06, 0.40, 0.46]},
    {'name': 'Pale Hull Blue', 'linear_rgb': [0.38, 0.52, 0.68], 'rgb': [0.38, 0.52, 0.68]},
]

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.80, 0.78), 0.48, 0.14)
make_mat('Metal', (0.50, 0.53, 0.57), 0.36, 0.86)
make_mat('ScorchedMetal', (0.36, 0.24, 0.15), 0.36, 0.85)
make_mat('ReactorHull', (0.25, 0.27, 0.31), 0.50, 0.62)
make_mat('HullTrim', (0.11, 0.14, 0.19), 0.48, 0.45)
make_mat('Containers', (0.35, 0.32, 0.28), 0.55, 0.35)
make_mat('Glass', (0.30, 0.65, 0.94), 0.06, 0.10)
make_mat('Lamps', (1.0, 0.72, 0.26), 0.18, 0.05)
make_mat('Engines', (0.42, 0.78, 1.0), 0.18, 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.5
    brick.squash = 1.0
    brick.inputs['Scale'].default_value = 4.0 if name == 'Paint' else 2.8
    brick.inputs['Brick Width'].default_value = 1.5
    brick.inputs['Row Height'].default_value = 0.75
    brick.inputs['Mortar Size'].default_value = 0.010 if name in ('Paint', 'ReactorHull') else 0.014
    brick.inputs['Mortar Smooth'].default_value = 0.28
    brick.inputs['Color1'].default_value = (0.98, 0.98, 0.98, 1.0)
    brick.inputs['Color2'].default_value = (0.90, 0.90, 0.90, 1.0)
    brick.inputs['Mortar'].default_value = (0.50, 0.50, 0.52, 1.0)
    l.new(uv.outputs['UV'], brick.inputs['Vector'])

    noise = n.new('ShaderNodeTexNoise')
    noise.inputs['Scale'].default_value = 8.5
    noise.inputs['Detail'].default_value = 8.0
    noise.inputs['Roughness'].default_value = 0.76
    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':
        # Neutral light grey/white detail: grime, panel lines, micro-meteorite pitting
        ramp.color_ramp.elements[0].color = (0.64, 0.64, 0.66, 1.0)
        ramp.color_ramp.elements[1].color = (0.97, 0.97, 0.97, 1.0)
    else:
        ramp.color_ramp.elements[0].color = (0.54, 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.76, 0.76, 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.72 if name == 'Paint' else 0.76
    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 = 'LINEAR'
        pal.color_ramp.elements[0].position = 0.15
        pal.color_ramp.elements[0].color = (0.09, 0.12, 0.17, 1.0)
        pal.color_ramp.elements[1].position = 0.75
        pal.color_ramp.elements[1].color = (0.18, 0.24, 0.33, 1.0)
        l.new(sep_uv.outputs['Y'], pal.inputs[0])
        l.new(pal.outputs[0], tint.inputs[2])

    elif name == 'ScorchedMetal':
        # Heat-scorched silver -> bronze/brown -> tempered blue-violet gradient matching sheet.jpg!
        pal = n.new('ShaderNodeValToRGB')
        pal.color_ramp.elements[0].position = 0.05
        pal.color_ramp.elements[0].color = (0.52, 0.48, 0.44, 1.0)
        e1 = pal.color_ramp.elements.new(0.38)
        e1.color = (0.46, 0.28, 0.15, 1.0)
        pal.color_ramp.elements[1].position = 0.70
        pal.color_ramp.elements[1].color = (0.20, 0.13, 0.09, 1.0)
        e2 = pal.color_ramp.elements.new(0.92)
        e2.color = (0.15, 0.26, 0.46, 1.0)
        l.new(sep_uv.outputs['Y'], pal.inputs[0])
        l.new(pal.outputs[0], tint.inputs[2])

    elif name == 'Lamps':
        # Multi-zone emissive trim separated along V (Y):
        # Y in [0.0, 0.72]: discrete lit colony windows (golden-amber window clusters + human-scale sub-mullions, dark unlit gaps)
        # Y in [0.72, 1.0]: solid emissive lamps selected by X:
        #   X in [0.0, 0.55]: bright electric cyan-blue shield dome energy arcs
        #   X in [0.55, 0.78]: red nav/warning lamps
        #   X in [0.78, 1.00]: green nav lamps
        win_macro = n.new('ShaderNodeTexBrick')
        win_macro.offset = 0.5
        win_macro.squash = 1.0
        win_macro.inputs['Scale'].default_value = 8.0
        win_macro.inputs['Brick Width'].default_value = 1.6
        win_macro.inputs['Row Height'].default_value = 0.85
        win_macro.inputs['Mortar Size'].default_value = 0.26
        win_macro.inputs['Mortar Smooth'].default_value = 0.02
        win_macro.inputs['Color1'].default_value = (1.0, 0.64, 0.14, 1.0)
        win_macro.inputs['Color2'].default_value = (0.0, 0.0, 0.0, 1.0)
        win_macro.inputs['Mortar'].default_value = (0.0, 0.0, 0.0, 1.0)
        l.new(uv.outputs['UV'], win_macro.inputs['Vector'])

        win_micro = n.new('ShaderNodeTexBrick')
        win_micro.offset = 0.0
        win_micro.squash = 1.0
        win_micro.inputs['Scale'].default_value = 32.0
        win_micro.inputs['Brick Width'].default_value = 1.2
        win_micro.inputs['Row Height'].default_value = 0.70
        win_micro.inputs['Mortar Size'].default_value = 0.18
        win_micro.inputs['Mortar Smooth'].default_value = 0.02
        win_micro.inputs['Color1'].default_value = (1.0, 0.92, 0.75, 1.0)
        win_micro.inputs['Color2'].default_value = (0.65, 0.48, 0.25, 1.0)
        win_micro.inputs['Mortar'].default_value = (0.02, 0.02, 0.02, 1.0)
        l.new(uv.outputs['UV'], win_micro.inputs['Vector'])

        win_comb = n.new('ShaderNodeMixRGB')
        win_comb.blend_type = 'MULTIPLY'
        win_comb.inputs[0].default_value = 1.0
        l.new(win_macro.outputs['Color'], win_comb.inputs[1])
        l.new(win_micro.outputs['Color'], win_comb.inputs[2])

        pal = n.new('ShaderNodeValToRGB')
        pal.color_ramp.interpolation = 'CONSTANT'
        pal.color_ramp.elements[0].position = 0.0
        pal.color_ramp.elements[0].color = (0.28, 0.82, 1.0, 1.0)
        pal.color_ramp.elements[1].position = 0.55
        pal.color_ramp.elements[1].color = (0.95, 0.08, 0.04, 1.0)
        e2 = pal.color_ramp.elements.new(0.78)
        e2.color = (0.06, 0.92, 0.18, 1.0)
        l.new(sep_uv.outputs['X'], pal.inputs[0])

        is_solid_lamp = n.new('ShaderNodeMath')
        is_solid_lamp.operation = 'GREATER_THAN'
        is_solid_lamp.inputs[1].default_value = 0.72
        l.new(sep_uv.outputs['Y'], is_solid_lamp.inputs[0])

        lamp_mix = n.new('ShaderNodeMixRGB')
        lamp_mix.blend_type = 'MIX'
        l.new(is_solid_lamp.outputs[0], lamp_mix.inputs[0])
        l.new(win_comb.outputs[0], lamp_mix.inputs[1])
        l.new(pal.outputs[0], lamp_mix.inputs[2])
        tint.inputs[1].default_value = (1.0, 1.0, 1.0, 1.0)
        for lk in list(tint.inputs[1].links):
            l.remove(lk)
        l.new(lamp_mix.outputs[0], tint.inputs[2])

    elif name == 'Engines':
        # Fusion engine glow matching sheet.jpg swatch: fiery orange-amber nozzle bell to intense cyan-blue/white plasma core
        pal = n.new('ShaderNodeValToRGB')
        pal.color_ramp.elements[0].position = 0.12
        pal.color_ramp.elements[0].color = (1.0, 0.42, 0.06, 1.0)
        pal.color_ramp.elements[1].position = 0.55
        pal.color_ramp.elements[1].color = (0.28, 0.76, 1.0, 1.0)
        e2 = pal.color_ramp.elements.new(0.86)
        e2.color = (0.92, 0.98, 1.0, 1.0)
        l.new(sep_uv.outputs['Y'], pal.inputs[0])
        tint.inputs[1].default_value = (1.0, 1.0, 1.0, 1.0)
        for lk in list(tint.inputs[1].links):
            l.remove(lk)
        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.25 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.10 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.22
    r_map.inputs[2].default_value = max(0.08, 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', 'Lamps')
    res_base = 1024 if is_main else (512 if name == 'Engines' else 128)
    res_norm = 512 if name not in ('Glass', 'Lamps', 'Engines') else 128
    res_orm = 512 if name not in ('Glass', 'Lamps', 'Engines') 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.5 if name == 'Lamps' else 16.0
    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.24
        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.24)
    else:
        m.diffuse_color = (*base, 1.0)
    print('BAKED', name, flush=True)


# Fast world-scale UV projection without per-object view_layer.update() overhead
def apply_uv_and_reg(o, group='Hull', mat='Paint', uv_mode='WORLD', v_range=(0.05, 0.95), u_val=None, wmat=None):
    me = o.data
    if not me.uv_layers:
        me.uv_layers.new(name='UVMap')
    uv_layer = me.uv_layers.active.data
    scale = 0.025  # 1 UV tile = 40m world-scale tiling for 2000m capital mothership
    v_lo, v_hi = v_range
    if wmat is None:
        wmat = Matrix.Translation(o.location) @ o.rotation_euler.to_matrix().to_4x4()
    rot_mat = wmat.to_3x3()
    for poly in me.polygons:
        nrm = rot_mat @ 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
            local_co = me.vertices[vi].co
            co = wmat @ local_co
            if uv_mode == 'WORLD':
                u = co[axes[0]] * scale
                v = co[axes[1]] * scale
            elif uv_mode == 'WINDOW_RING':
                # Continuous circumferential U (no % 1.0 wrap across faces!) & V in [0.06, 0.64] window zone
                p_c = poly.center
                ang_c = math.atan2(p_c.z, p_c.x)
                ang = math.atan2(co.z, co.x)
                if ang - ang_c > math.pi:
                    ang -= 2.0 * math.pi
                elif ang - ang_c < -math.pi:
                    ang += 2.0 * math.pi
                u = (ang / (2.0 * math.pi)) * 8.0
                v = 0.08 + ((co.y * 0.04) % 0.54)
            elif uv_mode == 'WINDOW_BOX':
                u = co[axes[0]] * 0.025
                v = 0.08 + ((co[axes[1]] * 0.04) % 0.54)
            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 == 'SCORCH_Y':
                ang = math.atan2(co.z, co.x)
                u = (ang / (2.0 * math.pi)) * 4.0
                t = max(0.0, min(1.0, (co.y - v_lo) / max(0.001, (v_hi - v_lo))))
                v = 0.06 + t * 0.88
            elif uv_mode == 'NOZZLE':
                ang = math.atan2(local_co.z, local_co.x)
                u = (ang / (2.0 * math.pi)) % 1.0
                t = max(0.0, min(1.0, (co.y - v_lo) / max(0.001, (v_hi - v_lo))))
                v = 0.08 + (1.0 - t) * 0.84
            elif uv_mode == 'CONST_U':
                u = u_val if u_val is not None else 0.25
                v = 0.86
            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 make_obj(name, bm, loc=(0,0,0), rot=(0,0,0), group='Hull', mat='Paint', smooth=False, uv_mode='WORLD', v_range=(0.05, 0.95), u_val=None):
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new(name)
    bm.to_mesh(me)
    bm.free()
    if smooth:
        for p in me.polygons:
            p.use_smooth = True
    o = bpy.data.objects.new(name, me)
    o.location = loc
    o.rotation_euler = rot
    S.collection.objects.link(o)
    wmat = Matrix.Translation(loc) @ Euler(rot, 'XYZ').to_matrix().to_4x4()
    return apply_uv_and_reg(o, group, mat, uv_mode, v_range, u_val, wmat=wmat)

def add_bevel_box(name, loc, dims, bevel=0.35, 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)
    return make_obj(name, bm, loc=loc, rot=rot, group=group, mat=mat, smooth=smooth, uv_mode=uv_mode, v_range=v_range, u_val=u_val)

def add_cyl(name, loc, rad1, rad2, depth, verts=24, bevel=0.25, group='Hull', mat='Metal', rot=(0,0,0), smooth=True, cap_ends=True, uv_mode='WORLD', v_range=(0.05, 0.95), u_val=None):
    bm = bmesh.new()
    bmesh.ops.create_cone(bm, cap_ends=cap_ends, cap_tris=False, segments=verts, radius1=rad1, radius2=rad2, depth=depth)
    if bevel > 0 and cap_ends:
        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)
    wmat = Matrix.Translation(loc) @ Euler(rot, 'XYZ').to_matrix().to_4x4()
    return apply_uv_and_reg(o, group, mat, uv_mode, v_range, u_val, wmat=wmat)

def add_strut(name, p0, p1, radius=1.2, verts=6, group='Hull', mat='Metal', smooth=False, uv_mode='WORLD', u_val=None):
    v0 = Vector(p0)
    v1 = Vector(p1)
    dv = v1 - v0
    length = dv.length
    if length < 0.01:
        return None
    mid = 0.5 * (v0 + v1)
    rot_q = dv.to_track_quat('Z', 'Y')
    bm = bmesh.new()
    bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=verts, radius1=radius, radius2=radius, depth=length)
    return make_obj(name, bm, loc=mid, rot=rot_q.to_euler(), group=group, mat=mat, smooth=smooth, uv_mode=uv_mode, u_val=u_val)

def add_revolved_profile(name, profile_rz, y_center=0.0, segs=48, group='Hull', mat='Paint', smooth=True, uv_mode='WORLD', v_range=(0.05, 0.95), u_val=None):
    """Revolves a closed 2D profile [(r, dy), ...] around the Y axis centered at (0, y_center, 0)."""
    bm = bmesh.new()
    n_p = len(profile_rz)
    rings = []
    for s in range(segs):
        ang = (2.0 * math.pi * s) / segs
        ca, sa = math.cos(ang), math.sin(ang)
        ring = [bm.verts.new((r * ca, y_center + dy, r * sa)) for (r, dy) in profile_rz]
        rings.append(ring)
    for s in range(segs):
        r0 = rings[s]
        r1 = rings[(s + 1) % segs]
        for i in range(n_p):
            ni = (i + 1) % n_p
            bm.faces.new((r0[i], r0[ni], r1[ni], r1[i]))
    return make_obj(name, bm, loc=(0, 0, 0), group=group, mat=mat, smooth=smooth, uv_mode=uv_mode, v_range=v_range, u_val=u_val)


# ==============================================================================
# SECTION 1: FORWARD SHIELD DOME (Y = -1000.0 to -725.0, Radius up to 198 m)
# ==============================================================================
def build_shield_dome():
    # Full rounded hemispherical geodesic dome matching sheet.jpg silhouette
    lat_rings = [
        (-1000.0, 0.0, 1),
        (-985.0, 74.0, 10),
        (-952.0, 132.0, 20),
        (-904.0, 170.0, 20),
        (-842.0, 190.0, 20),
        (-772.0, 196.0, 20),
    ]

    # 1A. Translucent Geodesic Shield Glazing (inset by 1.2 m so frame ribs sit proudly outside & inside)
    bm_glass = bmesh.new()
    g_rings = []
    for r_idx, (yy, rr, n_seg) in enumerate(lat_rings):
        if n_seg == 1:
            g_rings.append([bm_glass.verts.new((0.0, yy + 1.2, 0.0))])
        else:
            twist = (math.pi / n_seg) if (r_idx % 2 == 1) else 0.0
            pts = []
            for k in range(n_seg):
                ang = (2.0 * math.pi * k) / n_seg + twist
                pts.append(bm_glass.verts.new(((rr - 1.2) * math.cos(ang), yy + 0.8, (rr - 1.2) * math.sin(ang))))
            g_rings.append(pts)

    apex = g_rings[0][0]
    for k in range(len(g_rings[1])):
        nk = (k + 1) % len(g_rings[1])
        bm_glass.faces.new((apex, g_rings[1][nk], g_rings[1][k]))

    for k in range(10):
        nk = (k + 1) % 10
        v_a, v_b = g_rings[1][k], g_rings[1][nk]
        w0, w1, w2 = g_rings[2][2 * k], g_rings[2][2 * k + 1], g_rings[2][(2 * k + 2) % 20]
        bm_glass.faces.new((v_a, w1, w0))
        bm_glass.faces.new((v_a, v_b, w1))
        bm_glass.faces.new((v_b, w2, w1))

    for r_idx in range(2, 5):
        rA = g_rings[r_idx]
        rB = g_rings[r_idx + 1]
        for k in range(20):
            nk = (k + 1) % 20
            if r_idx % 2 == 0:
                bm_glass.faces.new((rA[k], rA[nk], rB[k]))
                bm_glass.faces.new((rA[nk], rB[nk], rB[k]))
            else:
                bm_glass.faces.new((rA[k], rB[nk], rB[k]))
                bm_glass.faces.new((rA[k], rA[nk], rB[nk]))

    bmesh.ops.solidify(bm_glass, geom=list(bm_glass.faces), thickness=1.4)
    make_obj('Shield_Dome_Glass', bm_glass, group='Hull', mat='Glass', smooth=False)

    # 1B. Heavy 3D Geodesic Lattice Struts & Nodal Clamps (Metal & ReactorHull)
    node_coords = []
    for r_idx, (yy, rr, n_seg) in enumerate(lat_rings):
        if n_seg == 1:
            node_coords.append([(0.0, yy, 0.0)])
        else:
            twist = (math.pi / n_seg) if (r_idx % 2 == 1) else 0.0
            pts = []
            for k in range(n_seg):
                ang = (2.0 * math.pi * k) / n_seg + twist
                pts.append((rr * math.cos(ang), yy, rr * math.sin(ang)))
            node_coords.append(pts)

    add_cyl('Shield_Apex_Hub', (0.0, -998.2, 0.0), 8.0, 12.0, 3.6, verts=16, bevel=0.4,
            rot=(math.radians(90), 0, 0), group='Hull', mat='Metal')

    for k in range(10):
        nk = (k + 1) % 10
        add_strut(f'Dome_R0_M_{k}', (0.0, -998.0, 0.0), node_coords[1][k], radius=1.9, verts=6, group='Hull', mat='Metal')
        add_strut(f'Dome_R1_Lat_{k}', node_coords[1][k], node_coords[1][nk], radius=1.7, verts=6, group='Hull', mat='Metal')
        w0, w1, w2 = node_coords[2][2 * k], node_coords[2][2 * k + 1], node_coords[2][(2 * k + 2) % 20]
        add_strut(f'Dome_R1_D1_{k}', node_coords[1][k], w0, radius=1.6, verts=6, group='Hull', mat='Metal')
        add_strut(f'Dome_R1_D2_{k}', node_coords[1][k], w1, radius=1.6, verts=6, group='Hull', mat='Metal')
        add_strut(f'Dome_R1_D3_{k}', node_coords[1][nk], w1, radius=1.6, verts=6, group='Hull', mat='Metal')

    for r_idx in range(2, 5):
        rA = node_coords[r_idx]
        rB = node_coords[r_idx + 1]
        for k in range(20):
            nk = (k + 1) % 20
            add_strut(f'Dome_Lat_{r_idx}_{k}', rA[k], rA[nk], radius=1.7, verts=6, group='Hull', mat='Metal')
            add_strut(f'Dome_Mer_{r_idx}_{k}', rA[k], rB[k], radius=1.8, verts=6, group='Hull', mat='Metal')
            if r_idx % 2 == 0:
                add_strut(f'Dome_Diag_{r_idx}_{k}', rA[nk], rB[k], radius=1.5, verts=6, group='Hull', mat='Metal')
            else:
                add_strut(f'Dome_Diag_{r_idx}_{k}', rA[k], rB[nk], radius=1.5, verts=6, group='Hull', mat='Metal')

    for r_idx in range(1, 6):
        for k, pt in enumerate(node_coords[r_idx]):
            v_pt = Vector(pt)
            n_dir = Vector((pt[0], pt[1] + 760.0, pt[2])).normalized()
            rot_e = n_dir.to_track_quat('Z', 'Y').to_euler()
            add_cyl(f'Dome_Node_{r_idx}_{k}', v_pt, 4.0, 4.6, 3.0, verts=8, bevel=0.3, rot=rot_e, group='Hull', mat='Metal', smooth=False)

    # 1C. Internal Field Emitter Spire & Lightning/Plasma Filaments (visible inside translucent dome)
    add_cyl('Dome_Emitter_Core', (0.0, -868.0, 0.0), 7.5, 28.0, 175.0, verts=24, bevel=0.5,
            rot=(math.radians(90), 0, 0), group='Hull', mat='ReactorHull')
    for c_i in range(6):
        yc = -945.0 + c_i * 26.0
        rc = 10.0 + c_i * 3.1
        add_cyl(f'Dome_Emitter_Coil_{c_i}', (0.0, yc, 0.0), rc + 3.0, rc + 3.0, 4.2, verts=24, bevel=0.4,
                rot=(math.radians(90), 0, 0), group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.55)
        add_cyl(f'Dome_Emitter_Clamp_{c_i}', (0.0, yc + 3.5, 0.0), rc + 1.8, rc + 1.8, 3.0, verts=24, bevel=0.3,
                rot=(math.radians(90), 0, 0), group='Hull', mat='Metal')

    for k in range(20):
        p_start = Vector((0.0, -935.0 + (k % 3) * 28.0, 0.0))
        target_ring = 2 + (k % 3)
        p_end = Vector(node_coords[target_ring][k]) * 0.94
        p_end.y = node_coords[target_ring][k][1] + 2.0
        p_mid1 = p_start.lerp(p_end, 0.35) + Vector((14.0 * math.sin(k * 1.7), -10.0, 14.0 * math.cos(k * 1.7)))
        p_mid2 = p_start.lerp(p_end, 0.70) + Vector((-12.0 * math.cos(k * 2.3), 8.0, 12.0 * math.sin(k * 2.3)))
        add_strut(f'Dome_Arc_A_{k}', p_start, p_mid1, radius=1.25, verts=5, group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.55)
        add_strut(f'Dome_Arc_B_{k}', p_mid1, p_mid2, radius=1.05, verts=5, group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.55)
        add_strut(f'Dome_Arc_C_{k}', p_mid2, p_end, radius=0.85, verts=5, group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.55)

    # 1D. Dark Concave Interior Parabolic Reflector Cavity (HullTrim) + Outer Armoured Collar (Paint)
    # Making the inner reflector dark blue-slate HullTrim makes the cyan energy arcs pop inside the translucent dome!
    inner_reflector_prof = [
        (36.0, -752.0),
        (118.0, -755.0),
        (168.0, -764.0),
        (188.0, -779.0),
        (184.0, -781.0),
        (162.0, -768.0),
        (114.0, -759.0),
        (36.0, -756.0),
    ]
    add_revolved_profile('Shield_Inner_Reflector', inner_reflector_prof, y_center=0.0, segs=48, group='Hull', mat='HullTrim', smooth=True)

    outer_collar_prof = [
        (40.0, -725.0),
        (82.0, -725.0),
        (118.0, -734.0),
        (162.0, -742.0),
        (188.0, -752.0),
        (198.0, -762.0),
        (198.0, -778.0),
        (188.0, -779.0),
        (168.0, -764.0),
        (118.0, -755.0),
        (40.0, -752.0),
    ]
    add_revolved_profile('Shield_Backing_Dish', outer_collar_prof, y_center=0.0, segs=48, group='Hull', mat='Paint', smooth=True)

    rim_profile = [
        (194.5, -758.0),
        (199.5, -760.0),
        (199.5, -777.0),
        (194.5, -779.0),
    ]
    add_revolved_profile('Shield_Rim_Band', rim_profile, y_center=0.0, segs=48, group='Hull', mat='HullTrim', smooth=True, uv_mode='BAND_V', v_range=(0.2, 0.8))

    for k in range(24):
        ang = (2.0 * math.pi * k) / 24.0
        ca, sa = math.cos(ang), math.sin(ang)
        add_bevel_box(
            f'Shield_Rim_Clamp_{k}',
            (196.5 * ca, -768.5, 196.5 * sa),
            (7.5, 22.0, 10.0),
            bevel=0.7,
            rot=(0.0, -ang, 0.0),
            group='Hull',
            mat='Metal'
        )
        add_strut(
            f'Shield_Back_Strut_{k}',
            (180.0 * ca, -750.0, 180.0 * sa),
            (58.0 * ca, -727.0, 58.0 * sa),
            radius=2.8,
            verts=6,
            group='Hull',
            mat='Metal'
        )


# ==============================================================================
# SECTION 2: CENTRAL SPINE TRUSS & AXIAL CORE (Y = -728.0 to +565.0)
# ==============================================================================
def build_spine_truss():
    add_cyl('Spine_Core_Tube', (0.0, -80.0, 0.0), 17.5, 17.5, 1296.0, verts=20, bevel=0.5,
            rot=(math.radians(90), 0, 0), group='Hull', mat='ReactorHull')

    for k in range(8):
        ang = (2.0 * math.pi * k) / 8.0 + (math.pi / 8.0)
        px, pz = 21.5 * math.cos(ang), 21.5 * math.sin(ang)
        add_cyl(f'Spine_Conduit_{k}', (px, -80.0, pz), 2.2, 2.2, 1285.0, verts=8, bevel=0.0,
                rot=(math.radians(90), 0, 0), group='Hull', mat='Metal', cap_ends=False)

    chord_positions = [
        (32.0, 32.0, 3.6, 'Metal'),
        (-32.0, 32.0, 3.6, 'Metal'),
        (32.0, -32.0, 3.6, 'Metal'),
        (-32.0, -32.0, 3.6, 'Metal'),
        (36.0, 0.0, 2.6, 'Paint'),
        (-36.0, 0.0, 2.6, 'Paint'),
        (0.0, 36.0, 2.6, 'Paint'),
        (0.0, -36.0, 2.6, 'Paint'),
    ]
    for idx, (cx, cz, cw, cmat) in enumerate(chord_positions):
        add_bevel_box(f'Spine_Main_Chord_{idx}', (cx, -80.0, cz), (cw, 1290.0, cw), bevel=0.35, group='Hull', mat=cmat)

    # 25 Truss Bays along Y from -722.0 to +553.0 (bay length = 51.0 m)
    n_bays = 25
    y_start = -722.0
    bay_len = 51.0
    oct_pts = [
        (36.0, 0.0), (32.0, 32.0), (0.0, 36.0), (-32.0, 32.0),
        (-36.0, 0.0), (-32.0, -32.0), (0.0, -36.0), (32.0, -32.0)
    ]
    for b in range(n_bays + 1):
        yb = y_start + b * bay_len
        add_cyl(f'Spine_Core_Collar_{b}', (0.0, yb, 0.0), 23.5, 23.5, 3.2, verts=16, bevel=0.3,
                rot=(math.radians(90), 0, 0), group='Hull', mat='Paint')
        for s in range(8):
            ns = (s + 1) % 8
            p0 = (oct_pts[s][0], yb, oct_pts[s][1])
            p1 = (oct_pts[ns][0], yb, oct_pts[ns][1])
            add_strut(f'Spine_Ring_{b}_{s}', p0, p1, radius=1.5, verts=6, group='Hull', mat='Metal')
            if s % 2 == 1:
                add_strut(f'Spine_Rad_{b}_{s}', (0.0, yb, 0.0), p0, radius=1.35, verts=6, group='Hull', mat='Metal')

        if b < n_bays:
            yn = yb + bay_len
            ymid = yb + 0.5 * bay_len
            faces_quads = [
                ((32.0, 32.0), (-32.0, 32.0)),
                ((32.0, -32.0), (-32.0, -32.0)),
                ((32.0, 32.0), (32.0, -32.0)),
                ((-32.0, 32.0), (-32.0, -32.0)),
            ]
            for f_i, (qa, qb) in enumerate(faces_quads):
                add_strut(f'Spine_X1_{b}_{f_i}', (qa[0], yb, qa[1]), (qb[0], yn, qb[1]), radius=1.2, verts=6, group='Hull', mat='Metal')
                add_strut(f'Spine_X2_{b}_{f_i}', (qb[0], yb, qb[1]), (qa[0], yn, qa[1]), radius=1.2, verts=6, group='Hull', mat='Metal')

            if yb < -430.0 and (b % 2 == 0):
                for side in (-1, 1):
                    add_bevel_box(f'Spine_TechBox_{b}_{side}', (side * 24.0, ymid, 0.0), (10.0, 18.0, 14.0), bevel=0.6, group='Hull', mat='Paint')
                    add_bevel_box(f'Spine_TechTrim_{b}_{side}', (side * 29.2, ymid, 0.0), (1.5, 14.0, 8.0), bevel=0.25, group='Hull', mat='HullTrim')


# ==============================================================================
# SECTION 3: THREE ROTATING HABITAT RINGS (Ring_1, Ring_2, Ring_3)
# ==============================================================================
RING_CENTERS_Y = {
    'Ring_1': -365.0,
    'Ring_2': -180.0,
    'Ring_3': 5.0,
}

def build_habitat_rings():
    # Each habitat ring has exact outer radius R = 300.0 m (600.0 m diameter), inner radius R = 232.0 m, axial width = 94.0 m
    ring_segs = 52
    # Multi-stepped architectural torus cross-section with real 3D recessed outer & inner trenches for trim and window bands!
    main_profile = [
        (300.0, -30.0),
        (300.0, -20.0),
        (297.2, -19.0),
        (297.2, -11.0),
        (300.0, -10.0),
        (300.0, -3.5),
        (297.5, -3.0),
        (297.5, 3.0),
        (300.0, 3.5),
        (300.0, 10.0),
        (297.2, 11.0),
        (297.2, 19.0),
        (300.0, 20.0),
        (300.0, 30.0),
        (293.0, 43.0),
        (276.0, 47.0),
        (254.0, 47.0),
        (238.0, 38.0),
        (232.0, 24.0),
        (232.0, -24.0),
        (238.0, -38.0),
        (254.0, -47.0),
        (276.0, -47.0),
        (293.0, -43.0),
    ]

    for rname, yc in RING_CENTERS_Y.items():
        # 3A. Stationary Stator Bearing Drum on Fixed Hull (inside the rotating ring hub)
        stator_profile = [
            (36.0, -44.0),
            (60.0, -44.0),
            (60.0, -34.0),
            (48.0, -30.0),
            (48.0, 30.0),
            (60.0, 34.0),
            (60.0, 44.0),
            (36.0, 44.0),
        ]
        add_revolved_profile(f'{rname}_Stator_Drum', stator_profile, y_center=yc, segs=36, group='Hull', mat='ReactorHull', smooth=True)

        # 3B. Main Rotating Habitat Torus Shell (Paint)
        add_revolved_profile(f'{rname}_Main_Torus', main_profile, y_center=yc, segs=ring_segs, group=rname, mat='Paint', smooth=True)

        # 3C. Recessed Equatorial Trim Trench & Outer Window Bay Liners (sit at R = 297.8..298.4 m inside the R = 297.2 m recesses!)
        eq_trench_prof = [
            (297.0, -3.2),
            (298.4, -2.8),
            (298.4, 2.8),
            (297.0, 3.2),
        ]
        add_revolved_profile(f'{rname}_Eq_Trench', eq_trench_prof, y_center=yc, segs=ring_segs, group=rname, mat='HullTrim', smooth=True)

        for side_y in (-1, 1):
            # Dark liner in the outer recessed window channel (dy = ±11..19)
            outer_bay_prof = [
                (296.8, side_y * 11.0),
                (298.0, side_y * 11.4),
                (298.0, side_y * 18.6),
                (296.8, side_y * 19.0),
            ]
            add_revolved_profile(f'{rname}_Outer_Bay_{side_y}', outer_bay_prof, y_center=yc, segs=ring_segs, group=rname, mat='HullTrim', smooth=True)

            # Dark window housing channel on the bevelled inner shoulder (frames the golden window grid!)
            shoulder_trim_prof = [
                (237.5, side_y * 36.5),
                (253.5, side_y * 45.8),
                (252.5, side_y * 47.4),
                (236.5, side_y * 38.0),
            ]
            add_revolved_profile(f'{rname}_Shoulder_Channel_{side_y}', shoulder_trim_prof, y_center=yc, segs=ring_segs, group=rname, mat='HullTrim', smooth=True)

            # Bevelled outer shoulder trim band (R = 278..288, dy = ±45.5)
            outer_sh_prof = [
                (278.0, side_y * 45.0),
                (288.0, side_y * 43.5),
                (288.8, side_y * 45.2),
                (278.5, side_y * 46.8),
            ]
            add_revolved_profile(f'{rname}_Outer_Shoulder_Trim_{side_y}', outer_sh_prof, y_center=yc, segs=ring_segs, group=rname, mat='ReactorHull', smooth=True)

        # Dark terraced city-deck housing across the inner spin-gravity wall (R = 231.4 .. 233.0 m)
        inner_city_prof = [
            (231.4, -22.0),
            (233.0, -22.0),
            (233.0, 22.0),
            (231.4, 22.0),
        ]
        add_revolved_profile(f'{rname}_Inner_City_Base', inner_city_prof, y_center=yc, segs=ring_segs, group=rname, mat='HullTrim', smooth=True)

        # 3D. Human-Scale Colony Window Strips (Lamps with WINDOW_RING UV mode -> discrete golden-amber window dots!)
        window_tracks = [
            # Bevelled inner shoulder window bands (prominent in 3/4 view of sheet.jpg)
            [(240.0, 39.2), (250.5, 45.2), (249.6, 46.6), (239.2, 40.5)],
            [(239.2, -40.5), (249.6, -46.6), (250.5, -45.2), (240.0, -39.2)],
            # Inner terraced city-deck window tracks (facing spin axis)
            [(230.9, 12.0), (230.9, 18.5), (232.0, 18.5), (232.0, 12.0)],
            [(230.9, 2.0), (230.9, 8.5), (232.0, 8.5), (232.0, 2.0)],
            [(230.9, -8.5), (230.9, -2.0), (232.0, -2.0), (232.0, -8.5)],
            [(230.9, -18.5), (230.9, -12.0), (232.0, -12.0), (232.0, -18.5)],
            # Outer recessed porthole window tracks (at R = 298.4 m inside the R = 297.2 m channels -> clearly visible on outside!)
            [(297.5, 13.0), (298.5, 13.0), (298.5, 17.0), (297.5, 17.0)],
            [(297.5, -17.0), (298.5, -17.0), (298.5, -13.0), (297.5, -13.0)],
        ]
        for w_idx, w_prof in enumerate(window_tracks):
            add_revolved_profile(
                f'{rname}_WinTrack_{w_idx}',
                w_prof,
                y_center=yc,
                segs=ring_segs,
                group=rname,
                mat='Lamps',
                smooth=True,
                uv_mode='WINDOW_RING'
            )

        # 3E. 24 Circumferential Structural Seam Bands & Outer Hull Bridge Clamps (gives crisp hull plate segmentation!)
        n_ribs = 24
        for k in range(n_ribs):
            ang = (2.0 * math.pi * k) / n_ribs
            ca, sa = math.cos(ang), math.sin(ang)
            # Flush structural arch band across the outer recessed channels & flanks (stays within R = 299.8 m)
            add_bevel_box(
                f'{rname}_Outer_Arch_{k}',
                (296.0 * ca, yc, 296.0 * sa),
                (7.6, 62.0, 3.8),
                bevel=0.45,
                rot=(0.0, -ang, 0.0),
                group=rname,
                mat='Metal'
            )
            add_bevel_box(
                f'{rname}_Flank_Seam_{k}',
                (268.0 * ca, yc, 268.0 * sa),
                (52.0, 95.5, 3.2),
                bevel=0.45,
                rot=(0.0, -ang, 0.0),
                group=rname,
                mat='ReactorHull'
            )
            add_bevel_box(
                f'{rname}_Inner_Pilaster_{k}',
                (234.5 * ca, yc, 234.5 * sa),
                (8.5, 80.0, 4.2),
                bevel=0.5,
                rot=(0.0, -ang, 0.0),
                group=rname,
                mat='Metal'
            )

        # 3F. Rotating Central Hub Collar (R = 49 m to 68 m)
        hub_prof = [
            (49.0, -32.0),
            (66.0, -32.0),
            (70.0, -20.0),
            (70.0, 20.0),
            (66.0, 32.0),
            (49.0, 32.0),
        ]
        add_revolved_profile(f'{rname}_Rotating_Hub', hub_prof, y_center=yc, segs=36, group=rname, mat='Paint', smooth=True)

        # 3G. 8 Radial Twin-Girder Elevator Spokes
        for s_idx in range(8):
            ang = (2.0 * math.pi * s_idx) / 8.0
            ca, sa = math.cos(ang), math.sin(ang)
            r_mid = 151.0
            spoke_len = 168.0
            for sy in (-13.5, 13.5):
                add_cyl(
                    f'{rname}_Spoke_Tube_{s_idx}_{int(sy)}',
                    (r_mid * ca, yc + sy, r_mid * sa),
                    4.8, 4.8, spoke_len,
                    verts=12, bevel=0.3,
                    rot=(0.0, -ang + math.pi * 0.5, 0.0),
                    group=rname,
                    mat='Metal'
                )
            add_bevel_box(
                f'{rname}_Spoke_OuterRoot_{s_idx}',
                (224.0 * ca, yc, 224.0 * sa),
                (18.0, 42.0, 20.0),
                bevel=1.0,
                rot=(0.0, -ang, 0.0),
                group=rname,
                mat='ReactorHull'
            )
            add_bevel_box(
                f'{rname}_Spoke_InnerRoot_{s_idx}',
                (73.0 * ca, yc, 73.0 * sa),
                (16.0, 38.0, 18.0),
                bevel=0.9,
                rot=(0.0, -ang, 0.0),
                group=rname,
                mat='ReactorHull'
            )
            for b_i in range(5):
                rb0 = 80.0 + b_i * 27.0
                rb1 = rb0 + 27.0
                p_f0 = (rb0 * ca, yc - 13.5, rb0 * sa)
                p_a0 = (rb0 * ca, yc + 13.5, rb0 * sa)
                p_f1 = (rb1 * ca, yc - 13.5, rb1 * sa)
                p_a1 = (rb1 * ca, yc + 13.5, rb1 * sa)
                add_strut(f'{rname}_Spoke_Br1_{s_idx}_{b_i}', p_f0, p_a0, radius=1.4, verts=6, group=rname, mat='Metal')
                add_strut(f'{rname}_Spoke_Br2_{s_idx}_{b_i}', p_f0, p_a1, radius=1.2, verts=6, group=rname, mat='Metal')
                add_strut(f'{rname}_Spoke_Br3_{s_idx}_{b_i}', p_a0, p_f1, radius=1.2, verts=6, group=rname, mat='Metal')


# ==============================================================================
# SECTION 4: CARGO & FABRICATION MODULES (Y = +85.0 to +345.0)
# ==============================================================================
def build_cargo_modules():
    bay_ys = [114.0, 170.0, 226.0, 282.0]
    for b_idx, ym in enumerate(bay_ys):
        add_bevel_box(f'Cargo_Gantry_Frame_{b_idx}', (0.0, ym, 0.0), (148.0, 9.0, 156.0), bevel=0.8, group='Hull', mat='Metal')

        for sx in (-1, 1):
            add_cyl(
                f'Refinery_Tank_{b_idx}_{sx}',
                (sx * 60.0, ym, 0.0),
                19.0, 19.0, 48.0,
                verts=20, bevel=1.4,
                rot=(math.radians(90), 0, 0),
                group='Hull',
                mat='Paint'
            )
            for ring_off in (-15.0, 0.0, 15.0):
                add_cyl(
                    f'Refinery_Band_{b_idx}_{sx}_{int(ring_off)}',
                    (sx * 60.0, ym + ring_off, 0.0),
                    20.4, 20.4, 3.2,
                    verts=20, bevel=0.3,
                    rot=(math.radians(90), 0, 0),
                    group='Hull',
                    mat='Metal'
                )

            for sz in (-1, 1):
                mx = sx * 48.0
                mz = sz * 66.0
                add_bevel_box(
                    f'Fab_Module_Body_{b_idx}_{sx}_{sz}',
                    (mx, ym, mz),
                    (54.0, 46.0, 50.0),
                    bevel=1.6,
                    group='Hull',
                    mat='Containers'
                )
                add_bevel_box(
                    f'Fab_Module_Cap_{b_idx}_{sx}_{sz}',
                    (mx, ym, mz + sz * 24.5),
                    (56.0, 48.0, 6.0),
                    bevel=0.8,
                    group='Hull',
                    mat='Paint'
                )
                for r_k in (-13.0, 0.0, 13.0):
                    add_bevel_box(
                        f'Fab_Rib_{b_idx}_{sx}_{sz}_{int(r_k)}',
                        (mx + sx * 27.2, ym + r_k, mz),
                        (2.8, 5.5, 42.0),
                        bevel=0.4,
                        group='Hull',
                        mat='HullTrim'
                    )
                add_bevel_box(
                    f'Fab_Lamp_Top_{b_idx}_{sx}_{sz}',
                    (mx + sx * 27.6, ym, mz + sz * 18.5),
                    (1.4, 38.0, 3.2),
                    bevel=0.25,
                    group='Hull',
                    mat='Lamps',
                    uv_mode='WINDOW_BOX'
                )
                add_bevel_box(
                    f'Fab_Lamp_Bot_{b_idx}_{sx}_{sz}',
                    (mx + sx * 27.6, ym, mz - sz * 18.5),
                    (1.4, 38.0, 3.2),
                    bevel=0.25,
                    group='Hull',
                    mat='Lamps',
                    uv_mode='WINDOW_BOX'
                )

    for sx in (-1, 1):
        for sz in (-1, 1):
            add_bevel_box(
                f'Cargo_Long_Rail_{sx}_{sz}',
                (sx * 70.0, 198.0, sz * 92.0),
                (5.5, 240.0, 5.5),
                bevel=0.5,
                group='Hull',
                mat='Metal'
            )


# ==============================================================================
# SECTION 5: DOCKING HUB, MANIPULATOR ARMS & DOCKED SHUTTLES (Y = +350.0 to +555.0)
# ==============================================================================
def build_shuttle(name_prefix, loc, scale_s=1.0, is_white=True):
    bx, by, bz = loc
    body_mat = 'Paint' if is_white else 'ReactorHull'
    belly_mat = 'HullTrim'

    add_cyl(f'{name_prefix}_Nose', (bx, by - 28.0 * scale_s, bz - 1.0 * scale_s), 1.8 * scale_s, 6.5 * scale_s, 20.0 * scale_s,
            verts=16, bevel=0.3, rot=(math.radians(86), 0, 0), group='Hull', mat=body_mat)
    add_bevel_box(f'{name_prefix}_Belly_Chine', (bx, by - 4.0 * scale_s, bz - 3.8 * scale_s),
                  (14.0 * scale_s, 52.0 * scale_s, 3.4 * scale_s), bevel=0.6 * scale_s, group='Hull', mat=belly_mat)
    add_bevel_box(f'{name_prefix}_Fuselage', (bx, by + 2.0 * scale_s, bz + 0.5 * scale_s),
                  (12.0 * scale_s, 44.0 * scale_s, 8.0 * scale_s), bevel=1.4 * scale_s, group='Hull', mat=body_mat)

    add_bevel_box(f'{name_prefix}_Cockpit_Tub', (bx, by - 15.5 * scale_s, bz + 2.8 * scale_s),
                  (6.5 * scale_s, 9.5 * scale_s, 2.4 * scale_s), bevel=0.2, group='Hull', mat='HullTrim')
    add_bevel_box(f'{name_prefix}_Pilot_Seat', (bx, by - 14.5 * scale_s, bz + 3.8 * scale_s),
                  (2.4 * scale_s, 2.4 * scale_s, 3.0 * scale_s), bevel=0.2, group='Hull', mat='Metal')
    add_bevel_box(f'{name_prefix}_Console_Glow', (bx, by - 17.8 * scale_s, bz + 3.7 * scale_s),
                  (3.8 * scale_s, 1.2 * scale_s, 1.2 * scale_s), bevel=0.15, group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.55)
    add_bevel_box(f'{name_prefix}_Canopy_Glass', (bx, by - 15.0 * scale_s, bz + 4.6 * scale_s),
                  (7.6 * scale_s, 13.0 * scale_s, 3.4 * scale_s), bevel=0.7 * scale_s,
                  rot=(math.radians(-12), 0, 0), group='Hull', mat='Glass', smooth=True)

    for ws in (-1, 1):
        add_bevel_box(
            f'{name_prefix}_Wing_{ws}',
            (bx + ws * 16.0 * scale_s, by + 6.0 * scale_s, bz - 2.2 * scale_s),
            (24.0 * scale_s, 28.0 * scale_s, 1.8 * scale_s),
            bevel=0.4 * scale_s,
            rot=(0.0, 0.0, math.radians(-ws * 28)),
            group='Hull',
            mat=body_mat
        )
        add_bevel_box(
            f'{name_prefix}_Wing_Edge_{ws}',
            (bx + ws * 16.5 * scale_s, by - 6.5 * scale_s, bz - 2.2 * scale_s),
            (24.5 * scale_s, 3.2 * scale_s, 2.0 * scale_s),
            bevel=0.3 * scale_s,
            rot=(0.0, 0.0, math.radians(-ws * 28)),
            group='Hull',
            mat=belly_mat
        )

    add_bevel_box(
        f'{name_prefix}_Tail_Fin',
        (bx, by + 17.0 * scale_s, bz + 9.0 * scale_s),
        (1.8 * scale_s, 15.0 * scale_s, 13.0 * scale_s),
        bevel=0.35 * scale_s,
        rot=(math.radians(24), 0, 0),
        group='Hull',
        mat=body_mat
    )

    for nx in (-3.4 * scale_s, 3.4 * scale_s):
        add_cyl(f'{name_prefix}_Noz_{nx:.1f}', (bx + nx, by + 26.0 * scale_s, bz + 0.2 * scale_s),
                2.8 * scale_s, 2.0 * scale_s, 4.8 * scale_s, verts=14, bevel=0.2,
                rot=(math.radians(90), 0, 0), group='Hull', mat='ScorchedMetal')
        add_cyl(f'{name_prefix}_NozGlow_{nx:.1f}', (bx + nx, by + 28.0 * scale_s, bz + 0.2 * scale_s),
                2.2 * scale_s, 1.6 * scale_s, 1.2 * scale_s, verts=14, bevel=0.1,
                rot=(math.radians(90), 0, 0), group='Hull', mat='Engines', uv_mode='CONST_U', u_val=0.55)

    spine_attach = (0.0 if abs(bx) < 20.0 else (34.0 if bx > 0 else -34.0),
                    by + 2.0 * scale_s,
                    0.0 if abs(bz) < 20.0 else (34.0 if bz > 0 else -34.0))
    shuttle_clamp = (bx, by + 2.0 * scale_s, bz - 3.0 * scale_s)
    add_strut(f'{name_prefix}_Dock_Boom_Main', spine_attach, shuttle_clamp, radius=2.2, verts=6, group='Hull', mat='Metal')
    add_strut(f'{name_prefix}_Dock_Boom_Brace', (spine_attach[0], by - 14.0, spine_attach[2]), shuttle_clamp, radius=1.4, verts=6, group='Hull', mat='Metal')
    add_cyl(f'{name_prefix}_Dock_Collar', shuttle_clamp, 4.5, 4.5, 3.5, verts=16, bevel=0.3, group='Hull', mat='HullTrim')


def build_docking_and_shuttles():
    y_hub = 408.0
    add_cyl('Dock_Center_Drum', (0.0, y_hub, 0.0), 58.0, 58.0, 74.0, verts=28, bevel=1.8,
            rot=(math.radians(90), 0, 0), group='Hull', mat='Paint')
    add_cyl('Dock_Center_Belt', (0.0, y_hub, 0.0), 60.5, 60.5, 22.0, verts=28, bevel=0.8,
            rot=(math.radians(90), 0, 0), group='Hull', mat='HullTrim')

    for sx in (-1, 1):
        add_cyl(f'Dock_Lat_Tube_{sx}', (sx * 60.0, y_hub, 0.0), 40.0, 44.0, 34.0, verts=28, bevel=1.2,
                rot=(0.0, math.radians(90), 0.0), group='Hull', mat='Paint')
        add_cyl(f'Dock_Lat_Rim_{sx}', (sx * 77.0, y_hub, 0.0), 42.0, 42.0, 8.0, verts=28, bevel=0.8,
                rot=(0.0, math.radians(90), 0.0), group='Hull', mat='Metal')
        add_cyl(f'Dock_Lat_InnerWall_{sx}', (sx * 79.5, y_hub, 0.0), 33.0, 33.0, 5.0, verts=24, bevel=0.6,
                rot=(0.0, math.radians(90), 0.0), group='Hull', mat='HullTrim')
        add_cyl(f'Dock_Lat_IrisHub_{sx}', (sx * 81.2, y_hub, 0.0), 14.0, 18.0, 3.5, verts=20, bevel=0.4,
                rot=(0.0, math.radians(90), 0.0), group='Hull', mat='Metal')
        for a_i in range(8):
            ang = (2.0 * math.pi * a_i) / 8.0
            vy, vz = y_hub + 24.0 * math.cos(ang), 24.0 * math.sin(ang)
            add_bevel_box(f'Dock_Lat_Latch_{sx}_{a_i}', (sx * 81.2, vy, vz), (2.2, 3.5, 16.0), bevel=0.3,
                          rot=(ang, 0.0, 0.0), group='Hull', mat='ReactorHull')
            ly, lz = y_hub + 37.5 * math.cos(ang), 37.5 * math.sin(ang)
            add_bevel_box(f'Dock_Lat_Beacon_{sx}_{a_i}', (sx * 81.2, ly, lz), (1.4, 2.8, 2.8), bevel=0.2,
                          rot=(ang, 0.0, 0.0), group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.20)

    for sz in (-1, 1):
        zc = sz * 118.0
        add_cyl(f'Dock_Vert_Neck_{sz}', (0.0, y_hub, sz * 64.0), 26.0, 26.0, 42.0, verts=24, bevel=0.8, group='Hull', mat='ReactorHull')
        add_cyl(f'Dock_Neck_Collar_{sz}', (0.0, y_hub, sz * 64.0), 30.0, 30.0, 10.0, verts=24, bevel=0.6, group='Hull', mat='Metal')

        bm_sp = bmesh.new()
        bmesh.ops.create_uvsphere(bm_sp, u_segments=28, v_segments=18, radius=56.0)
        make_obj(f'Dock_Sphere_{sz}', bm_sp, loc=(0.0, y_hub, zc), group='Hull', mat='Paint', smooth=True)

        add_cyl(f'Dock_Sphere_EqBelt_{sz}', (0.0, y_hub, zc), 58.2, 58.2, 12.0, verts=28, bevel=0.7, group='Hull', mat='HullTrim')
        add_cyl(f'Dock_Sphere_EqLamps_{sz}', (0.0, y_hub, zc), 58.8, 58.8, 4.2, verts=28, bevel=0.2, group='Hull', mat='Lamps', uv_mode='WINDOW_RING', cap_ends=False)

        add_cyl(f'Dock_Sphere_PolarRim_{sz}', (0.0, y_hub, zc + sz * 53.0), 22.0, 26.0, 8.0, verts=20, bevel=0.5, group='Hull', mat='Metal')
        add_cyl(f'Dock_Sphere_PolarGlass_{sz}', (0.0, y_hub, zc + sz * 56.5), 16.0, 20.0, 4.0, verts=20, bevel=0.4, group='Hull', mat='Glass')

        for sx in (-1, 1):
            add_cyl(f'Dock_Sphere_Port_{sz}_{sx}', (sx * 54.0, y_hub, zc), 15.0, 17.0, 12.0, verts=18, bevel=0.5,
                    rot=(0.0, math.radians(90), 0.0), group='Hull', mat='Metal')

        for dir_y in (-1, 1):
            base_pt = Vector((0.0, y_hub + dir_y * 40.0, zc + sz * 34.0))
            elbow_pt = Vector((0.0, y_hub + dir_y * 72.0, zc + sz * 62.0))
            wrist_pt = Vector((0.0, y_hub + dir_y * 96.0, zc + sz * 46.0))
            add_cyl(f'Crane_Shoulder_{sz}_{dir_y}', base_pt, 8.0, 9.0, 10.0, verts=14, bevel=0.4,
                    rot=(math.radians(90), 0, 0), group='Hull', mat='Metal')
            add_strut(f'Crane_Boom1_{sz}_{dir_y}', base_pt, elbow_pt, radius=3.0, verts=6, group='Hull', mat='Metal')
            add_cyl(f'Crane_Elbow_{sz}_{dir_y}', elbow_pt, 4.8, 4.8, 9.0, verts=12, bevel=0.3,
                    rot=(0.0, math.radians(90), 0), group='Hull', mat='HullTrim')
            add_strut(f'Crane_Boom2_{sz}_{dir_y}', elbow_pt, wrist_pt, radius=2.4, verts=6, group='Hull', mat='Metal')
            add_bevel_box(f'Crane_Wrist_{sz}_{dir_y}', wrist_pt, (6.5, 6.5, 6.5), bevel=0.4, group='Hull', mat='HullTrim')
            for c_ang in (-0.5, 0.0, 0.5):
                claw_tip = wrist_pt + Vector((math.sin(c_ang) * 8.5, dir_y * 11.5, math.cos(c_ang) * sz * -5.5))
                add_strut(f'Crane_Claw_{sz}_{dir_y}_{c_ang:.1f}', wrist_pt, claw_tip, radius=1.0, verts=6, group='Hull', mat='Metal')

    build_shuttle('Shuttle_Dorsal', (0.0, 498.0, 98.0), scale_s=1.08, is_white=True)
    build_shuttle('Shuttle_Ventral', (0.0, 502.0, -92.0), scale_s=0.98, is_white=False)
    build_shuttle('Shuttle_Port', (82.0, 492.0, 10.0), scale_s=0.90, is_white=True)
    build_shuttle('Shuttle_Stbd', (-82.0, 492.0, 10.0), scale_s=0.90, is_white=False)


# ==============================================================================
# SECTION 6: FUSION ENGINE SECTION & QUAD RADIATOR WINGS (Y = +555.0 to +1000.0)
# ==============================================================================
def build_engine_and_radiators():
    # 6A. Sleek, Elongated Conical-to-Cylindrical Fusion Containment Hull matching sheet.jpg!
    # Forward painted conical-to-cylindrical section (Y = +555.0 to +745.0, R from 52m to 126m)
    eng_fwd_profile = [
        (36.0, 555.0),
        (54.0, 558.0),
        (86.0, 608.0),
        (114.0, 662.0),
        (126.0, 712.0),
        (127.0, 745.0),
        (110.0, 745.0),
        (36.0, 572.0),
    ]
    add_revolved_profile('Engine_Hull_Fwd_Paint', eng_fwd_profile, y_center=0.0, segs=48, group='Hull', mat='Paint', smooth=True)

    # Mid bare-silver metallic reactor jacket (Y = +743.0 to +785.0, R = 128m)
    eng_mid_profile = [
        (110.0, 743.0),
        (128.0, 745.0),
        (128.0, 783.0),
        (110.0, 785.0),
    ]
    add_revolved_profile('Engine_Hull_Mid_Metal', eng_mid_profile, y_center=0.0, segs=48, group='Hull', mat='Metal', smooth=True)

    # Aft heat-scorched fusion containment drum (Y = +783.0 to +892.0, ScorchedMetal with silver -> brown/bronze -> blue gradient)
    eng_aft_profile = [
        (110.0, 783.0),
        (127.5, 785.0),
        (126.0, 845.0),
        (120.0, 885.0),
        (112.0, 892.0),
        (38.0, 892.0),
        (38.0, 783.0),
    ]
    add_revolved_profile('Engine_Hull_Aft_Scorched', eng_aft_profile, y_center=0.0, segs=48, group='Hull',
                         mat='ScorchedMetal', smooth=True, uv_mode='SCORCH_Y', v_range=(783.0, 892.0))

    # Subtle low-profile structural seam rings (only 1.2m proud so no zebra stripes!)
    for r_idx, (yr, rr, rmat) in enumerate([
        (608.0, 87.2, 'Metal'),
        (662.0, 115.2, 'Metal'),
        (712.0, 127.2, 'Metal'),
        (784.0, 129.0, 'ReactorHull'),
        (835.0, 127.8, 'ScorchedMetal'),
        (875.0, 123.0, 'ReactorHull'),
    ]):
        add_cyl(f'Engine_Ring_Band_{r_idx}', (0.0, yr, 0.0), rr, rr, 4.5, verts=48, bevel=0.4,
                rot=(math.radians(90), 0, 0), group='Hull', mat=rmat)

    # 8 Longitudinal Structural Longerons & Fuel Conduits along the Engine Hull
    for k in range(8):
        ang = (2.0 * math.pi * k) / 8.0 + (math.pi / 8.0)
        ca, sa = math.cos(ang), math.sin(ang)
        p0 = (56.0 * ca, 562.0, 56.0 * sa)
        p1 = (127.0 * ca, 712.0, 127.0 * sa)
        p2 = (122.0 * ca, 882.0, 122.0 * sa)
        add_strut(f'Engine_Longeron_Fwd_{k}', p0, p1, radius=2.0, verts=6, group='Hull', mat='Metal')
        add_strut(f'Engine_Longeron_Aft_{k}', p1, p2, radius=2.2, verts=6, group='Hull', mat='Metal')

    # 6B. Four Parallel Aft-Swept Radiator Wing Arrays (±45 deg X-configuration matching sheet.jpg!)
    # BOTH the Forward Auxiliary Fin AND Main Aft Radiator Wing sweep AFT in parallel, with dark blue-slate HullTrim cells framed by bright Metal grid ribs!
    for q_idx, (sx, sz) in enumerate([(1, 1), (-1, 1), (1, -1), (-1, -1)]):
        dx = sx * math.sqrt(0.5)
        dz = sz * math.sqrt(0.5)
        ang_y = -math.atan2(dz, dx)

        # Heavy Wing Root Fairings on the Engine Hull
        for r_step in range(5):
            yr = 620.0 + r_step * 54.0
            rr = 96.0 if r_step == 0 else (118.0 if r_step == 1 else 127.0)
            add_bevel_box(
                f'Wing_Root_Pod_{q_idx}_{r_step}',
                (dx * rr, yr, dz * rr),
                (22.0, 46.0, 15.0),
                bevel=1.2,
                rot=(0.0, ang_y, 0.0),
                group='Hull',
                mat='ReactorHull'
            )

        # 1) Forward Auxiliary Aft-Swept Radiator Fin (r from 96m to 318m, sweeping AFT from Y=590 to Y=685 matching sheet.jpg!)
        for seg in range(7):
            t0 = seg / 7.0
            t1 = (seg + 1) / 7.0
            tm = 0.5 * (t0 + t1)
            r_span = 96.0 + tm * (318.0 - 96.0)
            seg_w = (318.0 - 96.0) / 7.0 + 1.2
            y_lead = 588.0 + tm * 92.0  # Parallel AFT sweep matching sheet.jpg!
            chord = 92.0 - tm * 12.0
            yc = y_lead + 0.5 * chord
            px, pz = dx * r_span, dz * r_span
            # Contiguous metallic frame substrate
            add_bevel_box(
                f'Rad_Fwd_Core_{q_idx}_{seg}',
                (px, yc, pz),
                (seg_w, chord, 2.6),
                bevel=0.3,
                rot=(0.0, ang_y, 0.0),
                group='Hull',
                mat='Metal'
            )
            # 3 Chordwise Dark Blue-Slate Radiator Panel Strips per bay (reads with crisp grid contrast in all lighting!)
            for c_sub in (-0.31, 0.0, 0.31):
                add_bevel_box(
                    f'Rad_Fwd_Tile_{q_idx}_{seg}_{c_sub:.2f}',
                    (px, yc + c_sub * chord, pz),
                    (seg_w - 2.6, 0.26 * chord, 3.6),
                    bevel=0.25,
                    rot=(0.0, ang_y, 0.0),
                    group='Hull',
                    mat='HullTrim',
                    uv_mode='BAND_V',
                    v_range=(0.45, 0.90)
                )
            # Spanwise bright Metal rib at outer edge of bay
            r_rib = 96.0 + t1 * (318.0 - 96.0)
            c_rib = 92.0 - t1 * 12.0
            y_rib = (588.0 + t1 * 92.0) + 0.5 * c_rib
            add_bevel_box(
                f'Rad_Fwd_Rib_{q_idx}_{seg}',
                (dx * r_rib, y_rib, dz * r_rib),
                (3.0, c_rib + 3.0, 5.4),
                bevel=0.4,
                rot=(0.0, ang_y, 0.0),
                group='Hull',
                mat='Metal'
            )
        add_strut(f'Rad_Fwd_LeadBoom_{q_idx}', (dx * 92.0, 586.0, dz * 92.0), (dx * 320.0, 680.0, dz * 320.0),
                  radius=4.2, verts=6, group='Hull', mat='Paint')
        add_strut(f'Rad_Fwd_TrailBoom_{q_idx}', (dx * 92.0, 678.0, dz * 92.0), (dx * 320.0, 760.0, dz * 320.0),
                  radius=2.8, verts=6, group='Hull', mat='Metal')

        # 2) Main Broad Swept Aft Radiator Wing (r from 124m to 414m -> X,Z up to ±292m; sweeping AFT from Y=715 to Y=975)
        for seg in range(9):
            t0 = seg / 9.0
            t1 = (seg + 1) / 9.0
            tm = 0.5 * (t0 + t1)
            r_span = 124.0 + tm * (414.0 - 124.0)
            seg_w = (414.0 - 124.0) / 9.0 + 1.4
            y_lead = 715.0 + tm * 132.0
            chord = 148.0 - tm * 20.0
            yc = y_lead + 0.5 * chord
            px, pz = dx * r_span, dz * r_span
            # Contiguous bright Metal structural grid substrate
            add_bevel_box(
                f'Rad_Main_Core_{q_idx}_{seg}',
                (px, yc, pz),
                (seg_w, chord, 3.0),
                bevel=0.35,
                rot=(0.0, ang_y, 0.0),
                group='Hull',
                mat='Metal'
            )
            # 4 Columns of Dark Blue-Slate Radiator Panels across the chord (framed by bright Metal grid ribs!)
            for c_sub in (-0.36, -0.12, 0.12, 0.36):
                add_bevel_box(
                    f'Rad_Main_Tile_{q_idx}_{seg}_{c_sub:.2f}',
                    (px, yc + c_sub * chord, pz),
                    (seg_w - 2.8, 0.19 * chord, 4.2),
                    bevel=0.25,
                    rot=(0.0, ang_y, 0.0),
                    group='Hull',
                    mat='HullTrim',
                    uv_mode='BAND_V',
                    v_range=(0.45, 0.90)
                )
            add_bevel_box(
                f'Rad_Main_MidSpine_{q_idx}_{seg}',
                (px, yc, pz),
                (seg_w + 0.5, 5.0, 6.2),
                bevel=0.4,
                rot=(0.0, ang_y, 0.0),
                group='Hull',
                mat='Metal'
            )
            r_rib = 124.0 + t1 * (414.0 - 124.0)
            c_rib = 148.0 - t1 * 20.0
            y_rib = (715.0 + t1 * 132.0) + 0.5 * c_rib
            add_bevel_box(
                f'Rad_Main_Rib_{q_idx}_{seg}',
                (dx * r_rib, y_rib, dz * r_rib),
                (3.4, c_rib + 4.0, 6.8),
                bevel=0.45,
                rot=(0.0, ang_y, 0.0),
                group='Hull',
                mat='Metal'
            )
        add_strut(f'Rad_Main_LeadBoom_{q_idx}', (dx * 120.0, 712.0, dz * 120.0), (dx * 415.0, 845.0, dz * 415.0),
                  radius=5.8, verts=8, group='Hull', mat='Paint')
        add_strut(f'Rad_Main_TrailBoom_{q_idx}', (dx * 120.0, 862.0, dz * 120.0), (dx * 415.0, 974.0, dz * 415.0),
                  radius=3.6, verts=6, group='Hull', mat='Metal')

    # 6C. Quad Main Fusion Engine Bell Nozzles (Y = +885.0 to +1000.0 exact)
    add_cyl('Engine_Center_Spike', (0.0, 924.0, 0.0), 8.0, 24.0, 68.0, verts=24, bevel=0.5,
            rot=(math.radians(90), 0, 0), group='Hull', mat='ScorchedMetal', uv_mode='SCORCH_Y', v_range=(888.0, 958.0))

    nozzle_centers = [
        (52.0, 52.0),
        (-52.0, 52.0),
        (52.0, -52.0),
        (-52.0, -52.0),
    ]
    for n_idx, (nx, nz) in enumerate(nozzle_centers, start=1):
        add_cyl(f'Nozzle_Mount_{n_idx}', (nx, 894.0, nz), 39.0, 43.0, 22.0, verts=32, bevel=0.8,
                rot=(math.radians(90), 0, 0), group='Hull', mat='ReactorHull')

        bm_noz = bmesh.new()
        noz_prof = [
            (35.0, 895.0),
            (40.5, 928.0),
            (45.5, 965.0),
            (49.5, 1000.0),
            (46.8, 1000.0),
            (42.5, 965.0),
            (37.5, 928.0),
            (32.0, 895.0),
        ]
        n_segs_noz = 36
        rings_n = []
        for s in range(n_segs_noz):
            ang = (2.0 * math.pi * s) / n_segs_noz
            ca, sa = math.cos(ang), math.sin(ang)
            rings_n.append([bm_noz.verts.new((nx + r * ca, yy, nz + r * sa)) for (r, yy) in noz_prof])
        for s in range(n_segs_noz):
            r0, r1 = rings_n[s], rings_n[(s + 1) % n_segs_noz]
            for i in range(len(noz_prof)):
                ni = (i + 1) % len(noz_prof)
                bm_noz.faces.new((r0[i], r0[ni], r1[ni], r1[i]))
        make_obj(f'Nozzle_Bell_{n_idx}', bm_noz, group='Hull', mat='ScorchedMetal', smooth=True, uv_mode='SCORCH_Y', v_range=(895.0, 1000.0))

        for b_i, (yb, rb) in enumerate([(924.0, 41.2), (958.0, 45.2), (990.0, 49.0)]):
            add_cyl(f'Nozzle_Band_{n_idx}_{b_i}', (nx, yb, nz), rb, rb, 4.2, verts=32, bevel=0.4,
                    rot=(math.radians(90), 0, 0), group='Hull', mat='Metal')

        for p_i in range(12):
            ang = (2.0 * math.pi * p_i) / 12.0
            ca, sa = math.cos(ang), math.sin(ang)
            p_a = (nx + 36.2 * ca, 897.0, nz + 36.2 * sa)
            p_b = (nx + 49.5 * ca, 996.0, nz + 49.5 * sa)
            add_strut(f'Nozzle_Pipe_{n_idx}_{p_i}', p_a, p_b, radius=1.15, verts=6, group='Hull', mat='Metal')

        bm_glow = bmesh.new()
        glow_prof = [
            (0.5, 935.0),
            (22.0, 912.0),
            (31.8, 898.0),
            (37.2, 928.0),
            (42.2, 965.0),
            (46.5, 999.4),
            (44.5, 999.4),
            (34.0, 965.0),
            (18.0, 945.0),
            (0.5, 935.0),
        ]
        rings_g = []
        for s in range(n_segs_noz):
            ang = (2.0 * math.pi * s) / n_segs_noz
            ca, sa = math.cos(ang), math.sin(ang)
            rings_g.append([bm_glow.verts.new((nx + r * ca, yy, nz + r * sa)) for (r, yy) in glow_prof])
        for s in range(n_segs_noz):
            r0, r1 = rings_g[s], rings_g[(s + 1) % n_segs_noz]
            for i in range(len(glow_prof) - 1):
                ni = i + 1
                bm_glow.faces.new((r0[i], r0[ni], r1[ni], r1[i]))
        make_obj(f'Nozzle_Glow_{n_idx}', bm_glow, group='Hull', mat='Engines', smooth=True, uv_mode='NOZZLE', v_range=(898.0, 999.4))


# ==============================================================================
# SECTION 7: DEFENSIVE TURRETS (Turret_1..4, Gun_1..4, Muzzle_1..4)
# ==============================================================================
TURRET_DEFS = [
    (1, (0.0, -745.0, 178.0), 1),
    (2, (0.0, -745.0, -178.0), -1),
    (3, (0.0, 645.0, 108.0), 1),
    (4, (0.0, 645.0, -108.0), -1),
]

def build_turrets():
    for idx, (tx, ty, tz), z_sign in TURRET_DEFS:
        t_group = f'Turret_{idx}'
        g_group = f'Gun_{idx}'
        add_cyl(f'Turret_Barbette_{idx}', (tx, ty, tz - z_sign * 3.5), 11.0, 13.0, 7.0, verts=20, bevel=0.5, group='Hull', mat='ReactorHull')
        add_cyl(f'{t_group}_Base', (tx, ty, tz + z_sign * 2.0), 9.5, 10.5, 4.0, verts=20, bevel=0.4, group=t_group, mat='Metal')
        add_bevel_box(f'{t_group}_Cupola', (tx, ty + 1.5, tz + z_sign * 5.8), (13.0, 16.0, 6.5), bevel=0.8, group=t_group, mat='ReactorHull')
        add_bevel_box(f'{t_group}_Sensor', (tx, ty - 3.5, tz + z_sign * 9.5), (5.5, 6.0, 3.0), bevel=0.35, group=t_group, mat='HullTrim')
        gy = ty - 4.0
        gz = tz + z_sign * 6.0
        add_cyl(f'{g_group}_Trunnion', (tx, gy, gz), 2.4, 2.4, 10.5, verts=14, bevel=0.25,
                rot=(0.0, math.radians(90), 0.0), group=g_group, mat='Metal')
        for bx_off in (-3.2, 3.2):
            add_cyl(f'{g_group}_Barrel_{bx_off:.1f}', (tx + bx_off, gy - 11.0, gz), 1.15, 1.45, 22.0, verts=12, bevel=0.15,
                    rot=(math.radians(90), 0.0, 0.0), group=g_group, mat='Metal')
            add_cyl(f'{g_group}_Brake_{bx_off:.1f}', (tx + bx_off, gy - 21.5, gz), 1.55, 1.55, 3.2, verts=12, bevel=0.15,
                    rot=(math.radians(90), 0.0, 0.0), group=g_group, mat='HullTrim')


def build_nav_lamps():
    add_bevel_box('Nav_Lamp_L_Mesh', (81.8, 408.0, 0.0), (1.2, 5.0, 3.5), bevel=0.2, group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.72)
    add_bevel_box('Nav_Lamp_R_Mesh', (-81.8, 408.0, 0.0), (1.2, 5.0, 3.5), bevel=0.2, group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.90)
    add_cyl('Beacon_Lamp_Mesh', (0.0, 408.0, 179.0), 2.2, 2.2, 3.0, verts=12, bevel=0.2, group='Hull', mat='Lamps', uv_mode='CONST_U', u_val=0.72)


# Build all sections
build_shield_dome()
build_spine_truss()
build_habitat_rings()
build_cargo_modules()
build_docking_and_shuttles()
build_engine_and_radiators()
build_turrets()
build_nav_lamps()

bpy.context.view_layer.update()

# Pivots in Blender coordinates (X, Y, Z)
PIVOTS = {
    'Hull': (0.0, 0.0, 0.0),
    'Ring_1': (0.0, -365.0, 0.0),
    'Ring_2': (0.0, -180.0, 0.0),
    'Ring_3': (0.0, 5.0, 0.0),
}
MOVING_AXES = {
    'Ring_1': [0.0, 0.0, 1.0],
    'Ring_2': [0.0, 0.0, 1.0],
    'Ring_3': [0.0, 0.0, 1.0],
}
PARENT_MAP = {
    'Ring_1': 'Hull',
    'Ring_2': 'Hull',
    'Ring_3': 'Hull',
}
for idx, (tx, ty, tz), z_sign in TURRET_DEFS:
    t_name = f'Turret_{idx}'
    g_name = f'Gun_{idx}'
    PIVOTS[t_name] = (tx, ty, tz)
    PIVOTS[g_name] = (tx, ty - 4.0, tz + z_sign * 6.0)
    MOVING_AXES[t_name] = [0.0, 1.0, 0.0]
    MOVING_AXES[g_name] = [1.0, 0.0, 0.0]
    PARENT_MAP[t_name] = 'Hull'
    PARENT_MAP[g_name] = t_name

# Join each group into a single mesh node with its origin cleanly at PIVOTS[gname] and identity rotation/scale
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
    piv = Vector(PIVOTS.get(gname, (0.0, 0.0, 0.0)))

    wmat = joined.matrix_world.copy()
    to_local = Matrix.Translation(-piv) @ wmat
    bm = bmesh.new()
    bm.from_mesh(joined.data)
    bmesh.ops.transform(bm, matrix=to_local, verts=bm.verts)
    bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=0.002)
    bmesh.ops.dissolve_degenerate(bm, edges=list(bm.edges), dist=0.0001)
    bmesh.ops.triangulate(bm, faces=list(bm.faces))
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bm.to_mesh(joined.data)
    bm.free()

    joined.location = piv
    joined.rotation_euler = (0.0, 0.0, 0.0)
    joined.scale = (1.0, 1.0, 1.0)
    joined.data.update()

    asset.append(joined)
    mesh_nodes[gname] = joined
    print('NODE_BUILT', gname, 'TRIS:', len(joined.data.polygons), flush=True)

bpy.context.view_layer.update()

hull_obj = mesh_nodes['Hull']
for gname, p_name in PARENT_MAP.items():
    child_obj = mesh_nodes[gname]
    parent_obj = mesh_nodes[p_name]
    w_loc = Vector(PIVOTS[gname])
    p_loc = Vector(PIVOTS[p_name])
    child_obj.parent = parent_obj
    child_obj.matrix_parent_inverse = Matrix.Identity(4)
    child_obj.location = w_loc - p_loc

bpy.context.view_layer.update()

SOCKET_DEFS = [
    ('Thruster_1', 'Hull', (52.0, 1000.0, 52.0)),
    ('Thruster_2', 'Hull', (-52.0, 1000.0, 52.0)),
    ('Thruster_3', 'Hull', (52.0, 1000.0, -52.0)),
    ('Thruster_4', 'Hull', (-52.0, 1000.0, -52.0)),
    ('Dock_1', 'Hull', (82.0, 408.0, 0.0)),
    ('Dock_2', 'Hull', (-82.0, 408.0, 0.0)),
    ('Dock_3', 'Hull', (0.0, 498.0, 98.0)),
    ('Dock_4', 'Hull', (0.0, 502.0, -92.0)),
    ('Dock_5', 'Hull', (82.0, 492.0, 10.0)),
    ('Dock_6', 'Hull', (-82.0, 492.0, 10.0)),
    ('Muzzle_1', 'Gun_1', (0.0, -772.0, 184.0)),
    ('Muzzle_2', 'Gun_2', (0.0, -772.0, -184.0)),
    ('Muzzle_3', 'Gun_3', (0.0, 618.0, 114.0)),
    ('Muzzle_4', 'Gun_4', (0.0, 618.0, -114.0)),
    ('Hardpoint_1', 'Hull', (36.0, -560.0, 36.0)),
    ('Hardpoint_2', 'Hull', (-36.0, -560.0, 36.0)),
    ('Hardpoint_3', 'Hull', (36.0, -560.0, -36.0)),
    ('Hardpoint_4', 'Hull', (-36.0, -560.0, -36.0)),
    ('Camera_Chase', 'Hull', (0.0, 1480.0, 420.0)),
    ('Light_Nav_L', 'Hull', (82.5, 408.0, 0.0)),
    ('Light_Nav_R', 'Hull', (-82.5, 408.0, 0.0)),
    ('Light_Beacon', 'Hull', (0.0, 408.0, 180.5)),
]

for sname, p_name, w_loc in SOCKET_DEFS:
    emp = bpy.data.objects.new(sname, None)
    emp.empty_display_type = 'PLAIN_AXES'
    emp.empty_display_size = 8.0
    S.collection.objects.link(emp)
    parent_obj = mesh_nodes[p_name]
    p_loc = Vector(PIVOTS[p_name])
    emp.parent = parent_obj
    emp.matrix_parent_inverse = Matrix.Identity(4)
    emp.location = Vector(w_loc) - p_loc
    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]), 4), round(float(v[2]), 4), round(float(-v[1]), 4)]

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)]
print('BLENDER_BBOX:', 'LO:', [round(x, 2) for x in lo], 'HI:', [round(x, 2) for x in hi],
      'SIZE:', [round(hi[k] - lo[k], 2) for k in range(3)], flush=True)


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.003, 0.006, 0.014, 1.0)
    w_ramp.color_ramp.elements[1].position = 1.0
    w_ramp.color_ramp.elements[1].color = (0.014, 0.028, 0.058, 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(5.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', 4.2, (1.0, 0.97, 0.92), (0, 0, 0), (1050, -1350, 1100))
    add_sun('FillLight', 1.8, (0.68, 0.84, 1.0), (0, 0, 0), (-1100, -600, -450))
    add_sun('RimLight', 2.2, (0.80, 0.90, 1.0), (0, 0, 0), (600, 1400, 750))

    cam_d = bpy.data.cameras.new('RenderCam')
    cam_d.type = 'PERSP'
    cam_d.lens = 38.0
    cam_d.clip_start = 2.0
    cam_d.clip_end = 12000.0
    cam_o = bpy.data.objects.new('RenderCam', cam_d)
    S.collection.objects.link(cam_o)
    S.camera = cam_o
    cam_o.location = (1660.0, -1420.0, 620.0)
    target = Vector((0.0, -15.0, -10.0))
    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.44, 0.44, 0.46, 1.0)
clay_bsdf.inputs['Roughness'].default_value = 0.55
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 (rotate each moving node 30 deg about its axis, verify bounds, reset)
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()
pts_rot = [o.matrix_world @ Vector(v) for o in asset for v in o.bound_box]
lo_rot = [min(p[k] for p in pts_rot) for k in range(3)]
hi_rot = [max(p[k] for p in pts_rot) for k in range(3)]
print('ROT30_CHECK_SIZE:', [round(hi_rot[k] - lo_rot[k], 2) for k in range(3)], flush=True)
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 (export_apply=False preserves exact local pivots on moving nodes; all meshes already have scale=(1,1,1))
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 / 'colony-mothership.glb'
bpy.ops.export_scene.gltf(
    filepath=str(glb_path),
    export_format='GLB',
    use_selection=True,
    export_apply=False,
    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.80, 0.78, 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.80, 0.78, 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 = {
    'name': 'colony-mothership',
    'units': 'metres',
    'coordinate_system': {'forward': '+Z', 'up': '+Y', 'left': '+X'},
    'bbox': {
        '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)],
    },
    '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 100000 <= tri_count <= 220000, f'Triangle count {tri_count} out of 100k..220k 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 = 2120.0
cam.location = (1800.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 = (1660.0, -1420.0, 620.0)
cam.rotation_euler = (Vector((0.0, -15.0, -10.0)) - 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=18)
    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 = [
    "Generation colony mothership (2000.0 m long x 600.0 m wide x 600.0 m high, +Z nose, +Y up, origin at centre of mass) with forward geodesic shield dome, three rotating habitat rings, cargo/fabrication module bays, spherical docking hub with crane arms and four docked shuttles, and quad-nozzle fusion engine section with diagonal radiator wings.",
    f"Triangle count: {tri_count:,} triangles across 12 mesh nodes (Hull, Ring_1..3, Turret_1..4, Gun_1..4) and 22 empty game sockets (Thruster_1..4, Dock_1..6, Muzzle_1..4, Hardpoint_1..4, Camera_Chase, Light_Nav_L/R, Light_Beacon); GLB size: {glb_bytes / (1024*1024):.2f} MB.",
    "Materials (9): Paint (neutral baked detail + editable baseColorFactor [0.80, 0.80, 0.78]), Metal, ScorchedMetal, ReactorHull, HullTrim, Containers, Glass (alpha BLEND dome & shuttle canopies with cockpit interior), Lamps (emissive habitat windows, energy arcs & nav lights), Engines (emissive orange/blue fusion core).",
    "Texture sizes: Cycles GPU baked world-scale 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: connected all four escort shuttles via physical umbilical docking booms so no geometry floats, added four articulated point-defense turrets with elevating barrels, and baked neutral Paint texture for runtime faction recolouring."
]
(ROOT / 'NOTES.md').write_text('\n'.join(notes) + '\n')
print('BUILD_COMPLETE', flush=True)
