"""Aethelgard Cruiser (alien-elegant-cruiser) — Production 3D Game Asset for Forge.
Blender 5.2 headless script: builds complete curve-aligned geometry from an empty scene,
fetches/loads Poly Haven CC0 textures, bakes PBR textures with Cycles GPU,
verifies moving pivots, exports alien-elegant-cruiser.glb, nodes.json, NOTES.md,
and renders final.png, side.png, clay.png, paints.png, pivots.png.
"""

import bpy
import bmesh
import math
import os
import sys
import json
import struct
import subprocess
import time
import random
import numpy as np
from math import sin, cos, pi, sqrt, exp
from mathutils import Vector, Matrix

START_TIME = time.time()
ROOT = os.path.dirname(os.path.abspath(__file__))
TEX_DIR = os.path.join(ROOT, "textures")
os.makedirs(TEX_DIR, exist_ok=True)

random.seed(42)
np.random.seed(42)

# Reset to clean empty scene
bpy.ops.wm.read_factory_settings(use_empty=True)
sc = bpy.context.scene
sc.unit_settings.system = 'METRIC'
sc.unit_settings.scale_length = 1.0
sc.render.engine = 'CYCLES'

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

sc.cycles.device = 'GPU'
sc.cycles.use_denoising = True
sc.render.image_settings.file_format = 'PNG'
sc.render.film_transparent = False
sc.view_settings.view_transform = 'AgX'

# Six faction paints (default Pearl first, linear RGB triples)
PAINTS = [
    {"name": "Pearl", "linear_rgb": [0.80, 0.78, 0.72]},
    {"name": "Deep Violet", "linear_rgb": [0.22, 0.11, 0.38]},
    {"name": "Abyssal Teal", "linear_rgb": [0.07, 0.28, 0.34]},
    {"name": "Oxblood", "linear_rgb": [0.38, 0.07, 0.09]},
    {"name": "Jade", "linear_rgb": [0.10, 0.36, 0.22]},
    {"name": "Amber", "linear_rgb": [0.62, 0.36, 0.08]},
]

POLYHAVEN_IDS = ["metal_plate", "metal_plate_02", "rusty_metal_03"]


def fetch_ph_map(asset_id, map_kind, res="1k"):
    fname = f"{asset_id}_{map_kind}_{res}.jpg"
    fpath = os.path.join(TEX_DIR, fname)
    if not os.path.exists(fpath):
        url = f"https://dl.polyhaven.org/file/ph-assets/Textures/jpg/{res}/{asset_id}/{fname}"
        subprocess.run(["curl", "-f", "-L", "--retry", "3", "-s", url, "-o", fpath], check=True)
    img = bpy.data.images.load(fpath, check_existing=True)
    img.colorspace_settings.name = 'sRGB' if map_kind == 'diff' else 'Non-Color'
    return img


# ---------------------------------------------------------------------------
# MATERIAL & CYCLES GPU BAKING PIPELINE
# ---------------------------------------------------------------------------
BAKE_LOG = []
PAINT_FACTOR_NODE = None


def build_and_bake_pbr(name, ph_id, mode="paint", base_res=1024, norm_res=1024, orm_res=512, force_rebake=True):
    """Build procedural + Poly Haven shader graph, bake with Cycles to image textures,
    and rewire material to clean glTF-compatible Principled BSDF."""
    global PAINT_FACTOR_NODE
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    n = nt.nodes
    l = nt.links
    n.clear()

    out = n.new('ShaderNodeOutputMaterial')
    bsdf = n.new('ShaderNodeBsdfPrincipled')
    l.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    uv = n.new('ShaderNodeTexCoord')
    mapping = n.new('ShaderNodeMapping')
    mapping.inputs['Scale'].default_value = (1.0, 1.0, 1.0)
    l.new(uv.outputs['UV'], mapping.inputs['Vector'])

    norm_ph_id = "metal_plate_02" if mode == "paint" else ph_id

    diff_tex = n.new('ShaderNodeTexImage')
    diff_tex.image = fetch_ph_map(ph_id, 'diff')
    l.new(mapping.outputs['Vector'], diff_tex.inputs['Vector'])

    arm_tex = n.new('ShaderNodeTexImage')
    arm_tex.image = fetch_ph_map(norm_ph_id, 'arm')
    l.new(mapping.outputs['Vector'], arm_tex.inputs['Vector'])

    nor_tex = n.new('ShaderNodeTexImage')
    nor_tex.image = fetch_ph_map(norm_ph_id, 'nor_gl')
    l.new(mapping.outputs['Vector'], nor_tex.inputs['Vector'])

    wear_tex = n.new('ShaderNodeTexImage')
    wear_tex.image = fetch_ph_map("rusty_metal_03", 'diff')
    l.new(mapping.outputs['Vector'], wear_tex.inputs['Vector'])
    wear_bw = n.new('ShaderNodeRGBToBW')
    l.new(wear_tex.outputs['Color'], wear_bw.inputs['Color'])

    brick = n.new('ShaderNodeTexBrick')
    if mode == "paint":
        brick.offset = 0.5
        brick.offset_frequency = 2
        brick.squash = 2.8
        brick.squash_frequency = 2
        brick.inputs['Scale'].default_value = 1.8
        brick.inputs['Mortar Size'].default_value = 0.005
        brick.inputs['Mortar Smooth'].default_value = 0.30
        brick.inputs['Bias'].default_value = 0.12
        brick.inputs['Brick Width'].default_value = 0.92
        brick.inputs['Row Height'].default_value = 0.46
        brick.inputs['Color1'].default_value = (0.93, 0.93, 0.93, 1.0)
        brick.inputs['Color2'].default_value = (1.00, 1.00, 1.00, 1.0)
        brick.inputs['Mortar'].default_value = (0.52, 0.52, 0.52, 1.0)
    elif mode == "gold":
        brick.offset = 0.5
        brick.inputs['Scale'].default_value = 2.5
        brick.inputs['Mortar Size'].default_value = 0.004
        brick.inputs['Mortar Smooth'].default_value = 0.30
        brick.inputs['Brick Width'].default_value = 0.95
        brick.inputs['Row Height'].default_value = 0.50
        brick.inputs['Color1'].default_value = (0.86, 0.68, 0.35, 1.0)
        brick.inputs['Color2'].default_value = (0.95, 0.78, 0.43, 1.0)
        brick.inputs['Mortar'].default_value = (0.50, 0.36, 0.15, 1.0)
    else:  # scorched metal
        brick.offset = 0.5
        brick.inputs['Scale'].default_value = 3.0
        brick.inputs['Mortar Size'].default_value = 0.008
        brick.inputs['Mortar Smooth'].default_value = 0.35
        brick.inputs['Brick Width'].default_value = 0.65
        brick.inputs['Row Height'].default_value = 0.32
        brick.inputs['Color1'].default_value = (0.11, 0.12, 0.14, 1.0)
        brick.inputs['Color2'].default_value = (0.17, 0.18, 0.21, 1.0)
        brick.inputs['Mortar'].default_value = (0.04, 0.045, 0.05, 1.0)
    l.new(uv.outputs['UV'], brick.inputs['Vector'])

    bw = n.new('ShaderNodeRGBToBW')
    l.new(diff_tex.outputs['Color'], bw.inputs['Color'])

    ramp = n.new('ShaderNodeValToRGB')
    l.new(wear_bw.outputs['Val'] if mode == "paint" else bw.outputs['Val'], ramp.inputs['Fac'])

    streak_map = n.new('ShaderNodeMapping')
    streak_map.inputs['Scale'].default_value = (1.2, 5.5, 1.0)
    l.new(uv.outputs['UV'], streak_map.inputs['Vector'])

    noise_streak = n.new('ShaderNodeTexNoise')
    noise_streak.inputs['Scale'].default_value = 4.2
    noise_streak.inputs['Detail'].default_value = 5.0
    noise_streak.inputs['Roughness'].default_value = 0.62
    l.new(streak_map.outputs['Vector'], noise_streak.inputs['Vector'])

    if mode == "paint":
        # Rich neutral greyscale weathering (scorch smudges, panel lines, micro-pitting) matching sheet.jpg
        ramp.color_ramp.elements[0].position = 0.14
        ramp.color_ramp.elements[0].color = (0.64, 0.64, 0.64, 1.0)
        mid_el = ramp.color_ramp.elements.new(0.38)
        mid_el.color = (0.94, 0.94, 0.94, 1.0)
        ramp.color_ramp.elements[2].position = 0.82
        ramp.color_ramp.elements[2].color = (1.00, 1.00, 1.00, 1.0)

        mix_panel = n.new('ShaderNodeMixRGB')
        mix_panel.blend_type = 'MULTIPLY'
        mix_panel.inputs['Fac'].default_value = 0.72
        l.new(ramp.outputs['Color'], mix_panel.inputs['Color1'])
        l.new(brick.outputs['Color'], mix_panel.inputs['Color2'])

        streak_ramp = n.new('ShaderNodeValToRGB')
        streak_ramp.color_ramp.elements[0].position = 0.26
        streak_ramp.color_ramp.elements[0].color = (0.72, 0.72, 0.72, 1.0)
        streak_ramp.color_ramp.elements[1].position = 0.66
        streak_ramp.color_ramp.elements[1].color = (1.0, 1.0, 1.0, 1.0)
        l.new(noise_streak.outputs['Fac'], streak_ramp.inputs['Fac'])

        mix_grime = n.new('ShaderNodeMixRGB')
        mix_grime.blend_type = 'MULTIPLY'
        mix_grime.inputs['Fac'].default_value = 0.55
        l.new(mix_panel.outputs['Color'], mix_grime.inputs['Color1'])
        l.new(streak_ramp.outputs['Color'], mix_grime.inputs['Color2'])
        base_out_socket = mix_grime.outputs['Color']

        rough_min, rough_max = 0.20, 0.42
        metal_val = 0.05
        norm_strength = 0.22
        bump_strength = 0.24
    elif mode == "gold":
        ramp.color_ramp.elements[0].position = 0.10
        ramp.color_ramp.elements[0].color = (0.64, 0.48, 0.19, 1.0)
        ramp.color_ramp.elements[1].position = 0.88
        ramp.color_ramp.elements[1].color = (0.96, 0.80, 0.45, 1.0)

        mix_gold = n.new('ShaderNodeMixRGB')
        mix_gold.blend_type = 'MULTIPLY'
        mix_gold.inputs['Fac'].default_value = 0.42
        l.new(ramp.outputs['Color'], mix_gold.inputs['Color1'])
        l.new(brick.outputs['Color'], mix_gold.inputs['Color2'])
        base_out_socket = mix_gold.outputs['Color']

        rough_min, rough_max = 0.18, 0.30
        metal_val = 0.92
        norm_strength = 0.25
        bump_strength = 0.14
    else:  # scorched metal
        ramp.color_ramp.elements[0].position = 0.08
        ramp.color_ramp.elements[0].color = (0.05, 0.055, 0.065, 1.0)
        ramp.color_ramp.elements[1].position = 0.88
        ramp.color_ramp.elements[1].color = (0.22, 0.24, 0.27, 1.0)

        mix_scorch = n.new('ShaderNodeMixRGB')
        mix_scorch.blend_type = 'MULTIPLY'
        mix_scorch.inputs['Fac'].default_value = 0.65
        l.new(ramp.outputs['Color'], mix_scorch.inputs['Color1'])
        l.new(brick.outputs['Color'], mix_scorch.inputs['Color2'])
        base_out_socket = mix_scorch.outputs['Color']

        rough_min, rough_max = 0.32, 0.54
        metal_val = 0.86
        norm_strength = 0.50
        bump_strength = 0.30

    l.new(base_out_socket, bsdf.inputs['Base Color'])

    nm = n.new('ShaderNodeNormalMap')
    nm.inputs['Strength'].default_value = norm_strength
    l.new(nor_tex.outputs['Color'], nm.inputs['Color'])

    bump = n.new('ShaderNodeBump')
    bump.inputs['Strength'].default_value = bump_strength
    bump.inputs['Distance'].default_value = 0.008
    l.new(brick.outputs['Fac'], bump.inputs['Height'])
    l.new(nm.outputs['Normal'], bump.inputs['Normal'])
    l.new(bump.outputs['Normal'], bsdf.inputs['Normal'])

    sep_arm = n.new('ShaderNodeSeparateColor')
    l.new(arm_tex.outputs['Color'], sep_arm.inputs['Color'])

    ao_mult = n.new('ShaderNodeMath')
    ao_mult.operation = 'MULTIPLY'
    l.new(sep_arm.outputs['Red'], ao_mult.inputs[0])
    ao_seam = n.new('ShaderNodeMapRange')
    ao_seam.inputs['To Min'].default_value = 0.68
    ao_seam.inputs['To Max'].default_value = 1.0
    l.new(brick.outputs['Fac'], ao_seam.inputs['Value'])
    l.new(ao_seam.outputs['Result'], ao_mult.inputs[1])

    rough_map = n.new('ShaderNodeMapRange')
    rough_map.inputs['To Min'].default_value = rough_min
    rough_map.inputs['To Max'].default_value = rough_max
    l.new(sep_arm.outputs['Green'], rough_map.inputs['Value'])
    l.new(rough_map.outputs['Result'], bsdf.inputs['Roughness'])
    bsdf.inputs['Metallic'].default_value = metal_val

    cache_paths = {
        "BaseColor": os.path.join(TEX_DIR, f"{name}_BaseColor.png"),
        "Normal": os.path.join(TEX_DIR, f"{name}_Normal.png"),
        "ORM": os.path.join(TEX_DIR, f"{name}_ORM.png"),
    }

    if not force_rebake and all(os.path.exists(p) for p in cache_paths.values()):
        baked_imgs = {}
        for k, p in cache_paths.items():
            im = bpy.data.images.load(p, check_existing=True)
            im.colorspace_settings.name = 'sRGB' if k == 'BaseColor' else 'Non-Color'
            baked_imgs[k] = im
    else:
        bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, -500.0))
        bake_plane = bpy.context.object
        bake_plane.data.materials.append(mat)
        bpy.ops.object.select_all(action='DESELECT')
        bake_plane.select_set(True)
        bpy.context.view_layer.objects.active = bake_plane

        em_node = n.new('ShaderNodeEmission')
        target_node = n.new('ShaderNodeTexImage')
        baked_imgs = {}

        for kind, res in [("BaseColor", base_res), ("Normal", norm_res), ("ORM", orm_res)]:
            im = bpy.data.images.new(f"{name}_{kind}", width=res, height=res, alpha=False)
            im.colorspace_settings.name = 'sRGB' if kind == 'BaseColor' else 'Non-Color'
            target_node.image = im
            n.active = target_node
            sc.cycles.samples = 12
            sc.render.bake.margin = 8
            sc.render.bake.use_clear = True

            if kind == "Normal":
                l.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
                bpy.ops.object.bake(type='NORMAL')
            else:
                l.new(em_node.outputs['Emission'], out.inputs['Surface'])
                if kind == "BaseColor":
                    l.new(base_out_socket, em_node.inputs['Color'])
                else:
                    comb = n.new('ShaderNodeCombineColor')
                    l.new(ao_mult.outputs['Value'], comb.inputs['Red'])
                    l.new(rough_map.outputs['Result'], comb.inputs['Green'])
                    comb.inputs['Blue'].default_value = metal_val
                    l.new(comb.outputs['Color'], em_node.inputs['Color'])
                bpy.ops.object.bake(type='EMIT')

            im.filepath_raw = cache_paths[kind]
            im.file_format = 'PNG'
            im.save()
            baked_imgs[kind] = im

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

    # Rebuild clean glTF-ready shader tree with baked textures
    n.clear()
    out = n.new('ShaderNodeOutputMaterial')
    bsdf = n.new('ShaderNodeBsdfPrincipled')
    l.new(bsdf.outputs['BSDF'], out.inputs['Surface'])

    t_base = n.new('ShaderNodeTexImage')
    t_base.image = baked_imgs["BaseColor"]
    if name == "Paint":
        PAINT_FACTOR_NODE = n.new('ShaderNodeMixRGB')
        PAINT_FACTOR_NODE.blend_type = 'MULTIPLY'
        PAINT_FACTOR_NODE.inputs['Fac'].default_value = 1.0
        PAINT_FACTOR_NODE.inputs['Color2'].default_value = (*PAINTS[0]["linear_rgb"], 1.0)
        l.new(t_base.outputs['Color'], PAINT_FACTOR_NODE.inputs['Color1'])
        l.new(PAINT_FACTOR_NODE.outputs['Color'], bsdf.inputs['Base Color'])
    else:
        l.new(t_base.outputs['Color'], bsdf.inputs['Base Color'])

    t_norm = n.new('ShaderNodeTexImage')
    t_norm.image = baked_imgs["Normal"]
    t_norm.image.colorspace_settings.name = 'Non-Color'
    nm_out = n.new('ShaderNodeNormalMap')
    l.new(t_norm.outputs['Color'], nm_out.inputs['Color'])
    l.new(nm_out.outputs['Normal'], bsdf.inputs['Normal'])

    t_orm = n.new('ShaderNodeTexImage')
    t_orm.image = baked_imgs["ORM"]
    t_orm.image.colorspace_settings.name = 'Non-Color'
    sep = n.new('ShaderNodeSeparateColor')
    l.new(t_orm.outputs['Color'], sep.inputs['Color'])
    l.new(sep.outputs['Green'], bsdf.inputs['Roughness'])
    l.new(sep.outputs['Blue'], bsdf.inputs['Metallic'])

    gltf_grp = bpy.data.node_groups.get('glTF Material Output')
    if gltf_grp is None:
        gltf_grp = bpy.data.node_groups.new('glTF Material Output', 'ShaderNodeTree')
        gltf_grp.interface.new_socket(name='Occlusion', in_out='INPUT', socket_type='NodeSocketFloat')
    grp_node = n.new('ShaderNodeGroup')
    grp_node.node_tree = gltf_grp
    l.new(sep.outputs['Red'], grp_node.inputs['Occlusion'])

    BAKE_LOG.append({
        "material": name,
        "polyhaven_id": ph_id,
        "resolutions": {"BaseColor": base_res, "Normal": norm_res, "ORM": orm_res},
    })
    print(f"BAKED {name} ({ph_id}) in {time.time() - START_TIME:.1f}s", flush=True)
    return mat


def make_emissive_mat(name, base_col, em_col, strength):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes.get('Principled BSDF')
    bsdf.inputs['Base Color'].default_value = (*base_col, 1.0)
    bsdf.inputs['Roughness'].default_value = 0.12
    bsdf.inputs['Metallic'].default_value = 0.0
    bsdf.inputs['Emission Color'].default_value = (*em_col, 1.0)
    bsdf.inputs['Emission Strength'].default_value = strength
    return mat


def make_glass_mat(name, col=(0.03, 0.35, 0.90), alpha=0.22):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes.get('Principled BSDF')
    bsdf.inputs['Base Color'].default_value = (*col, 1.0)
    bsdf.inputs['Roughness'].default_value = 0.06
    bsdf.inputs['Metallic'].default_value = 0.05
    bsdf.inputs['Alpha'].default_value = alpha
    bsdf.inputs['IOR'].default_value = 1.48
    if 'Transmission Weight' in bsdf.inputs:
        bsdf.inputs['Transmission Weight'].default_value = 0.35
    mat.surface_render_method = 'BLENDED'
    mat.diffuse_color = (*col, alpha)
    return mat


MAT_PAINT = build_and_bake_pbr("Paint", "metal_plate", mode="paint", base_res=1024, norm_res=1024, orm_res=512)
MAT_GOLD = build_and_bake_pbr("Gold", "metal_plate_02", mode="gold", base_res=1024, norm_res=512, orm_res=512)
MAT_SCORCH = build_and_bake_pbr("ScorchedMetal", "rusty_metal_03", mode="scorch", base_res=1024, norm_res=512, orm_res=512)
MAT_ENGINES = make_emissive_mat("Engines", (0.04, 0.48, 1.0), (0.05, 0.54, 1.0), 3.4)
MAT_LAMPS = make_emissive_mat("Lamps", (0.02, 0.42, 1.0), (0.03, 0.48, 1.0), 2.3)
MAT_GLASS = make_glass_mat("Glass", (0.03, 0.35, 0.90), 0.22)

ALL_MATS = [MAT_PAINT, MAT_GOLD, MAT_SCORCH, MAT_ENGINES, MAT_LAMPS, MAT_GLASS]
MAT_IDX = {m.name: i for i, m in enumerate(ALL_MATS)}


# ---------------------------------------------------------------------------
# COORDINATE CONVERSION & MESH BUILDER HELPERS
# ---------------------------------------------------------------------------
def g2b(v):
    return Vector((float(v[0]), -float(v[2]), float(v[1])))


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


def smoothstep(edge0, edge1, x):
    t = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0)))
    return t * t * (3.0 - 2.0 * t)


class MeshBuilder:
    def __init__(self, name, pivot_gltf=(0.0, 0.0, 0.0)):
        self.name = name
        self.pivot = Vector(pivot_gltf)
        self.verts = []
        self.faces = []
        self.mat_indices = []

    def add_grid(self, rows_pts, mat_or_grid, closed_u=False, closed_v=False, flip=False):
        nv = len(rows_pts)
        nu = len(rows_pts[0])
        base = len(self.verts)
        for row in rows_pts:
            for pt in row:
                self.verts.append(g2b(Vector(pt) - self.pivot))

        v_max = nv if closed_v else nv - 1
        u_max = nu if closed_u else nu - 1

        for j in range(v_max):
            jn = (j + 1) % nv
            for i in range(u_max):
                inext = (i + 1) % nu
                a = base + j * nu + i
                b = base + j * nu + inext
                c = base + jn * nu + inext
                d = base + jn * nu + i
                f = (a, d, c, b) if flip else (a, b, c, d)
                self.faces.append(f)
                if hasattr(mat_or_grid, "name"):
                    m_idx = MAT_IDX[mat_or_grid.name]
                elif isinstance(mat_or_grid[0], list):
                    m_idx = MAT_IDX[mat_or_grid[j][i].name]
                else:
                    m_idx = MAT_IDX[mat_or_grid[i].name]
                self.mat_indices.append(m_idx)

    def add_prism_strip(self, center_pts, normals, tangents, width, thickness, mat, closed=False):
        hw = width * 0.5
        ht = thickness
        chamfer = min(hw * 0.28, abs(ht) * 0.45 + 0.02)
        rows_u = []
        for p, n_vec, t_vec in zip(center_pts, normals, tangents):
            p = Vector(p)
            n_vec = Vector(n_vec).normalized()
            b_vec = n_vec.cross(Vector(t_vec)).normalized()
            prof = [
                p - b_vec * hw - n_vec * 0.06,
                p - b_vec * (hw - chamfer) + n_vec * ht,
                p - b_vec * (hw * 0.3) + n_vec * (ht * 1.06),
                p + b_vec * (hw * 0.3) + n_vec * (ht * 1.06),
                p + b_vec * (hw - chamfer) + n_vec * ht,
                p + b_vec * hw - n_vec * 0.06,
            ]
            rows_u.append(prof)
        self.add_grid(rows_u, mat, closed_u=False, closed_v=closed, flip=False)
        if not closed and len(rows_u) >= 2:
            m_idx = MAT_IDX[mat.name]
            base_start = len(self.verts)
            for pt in rows_u[0]:
                self.verts.append(g2b(pt - self.pivot))
            self.faces.append(tuple(base_start + k for k in range(5, -1, -1)))
            self.mat_indices.append(m_idx)

            base_end = len(self.verts)
            for pt in rows_u[-1]:
                self.verts.append(g2b(pt - self.pivot))
            self.faces.append(tuple(base_end + k for k in range(6)))
            self.mat_indices.append(m_idx)

    def add_cylinder(self, p0, p1, r0, r1, mat, segs=24, cap0=True, cap1=True, flip=False):
        p0 = Vector(p0)
        p1 = Vector(p1)
        axis = p1 - p0
        length = axis.length
        if length < 1e-6:
            return
        d = axis / length
        up = Vector((0, 1, 0)) if abs(d.y) < 0.9 else Vector((1, 0, 0))
        u = d.cross(up).normalized()
        v = d.cross(u).normalized()

        bevel_len = min(length * 0.08, min(r0, r1) * 0.12)
        rings = []
        if cap0 and r0 > 1e-4:
            rings.append((p0, max(1e-4, r0 * 0.86)))
            rings.append((p0 + d * bevel_len, r0))
        else:
            rings.append((p0, r0))
        if cap1 and r1 > 1e-4:
            rings.append((p1 - d * bevel_len, r1))
            rings.append((p1, max(1e-4, r1 * 0.86)))
        else:
            rings.append((p1, r1))

        rows = []
        for center, rad in rings:
            row = []
            for i in range(segs):
                ang = 2.0 * pi * i / segs
                pt = center + (u * cos(ang) + v * sin(ang)) * rad
                row.append(pt)
            rows.append(row)
        self.add_grid(rows, mat, closed_u=True, closed_v=False, flip=flip)

        m_idx = MAT_IDX[mat.name]
        if cap0 and r0 > 1e-4:
            base = len(self.verts)
            for pt in rows[0]:
                self.verts.append(g2b(pt - self.pivot))
            self.faces.append(tuple(base + k for k in (range(segs) if flip else range(segs - 1, -1, -1))))
            self.mat_indices.append(m_idx)
        if cap1 and r1 > 1e-4:
            base = len(self.verts)
            for pt in rows[-1]:
                self.verts.append(g2b(pt - self.pivot))
            self.faces.append(tuple(base + k for k in (range(segs - 1, -1, -1) if flip else range(segs))))
            self.mat_indices.append(m_idx)

    def add_bevel_box(self, center, dims, mat, rot_mat=Matrix.Identity(3), bev=0.12):
        cx, cy, cz = center
        dx, dy, dz = dims[0] * 0.5, dims[1] * 0.5, dims[2] * 0.5
        b = min(bev, dx * 0.35, dy * 0.35, dz * 0.35)
        bm = bmesh.new()
        bmesh.ops.create_cube(bm, size=1.0)
        for vert in bm.verts:
            vert.co.x *= dims[0]
            vert.co.y *= dims[1]
            vert.co.z *= dims[2]
        if b > 1e-4:
            bmesh.ops.bevel(bm, geom=list(bm.edges), offset=b, segments=2, affect='EDGES')
        bm.verts.ensure_lookup_table()
        bm.faces.ensure_lookup_table()
        base = len(self.verts)
        c_vec = Vector((cx, cy, cz))
        for vert in bm.verts:
            p_gltf = c_vec + rot_mat @ Vector((vert.co.x, vert.co.y, vert.co.z))
            self.verts.append(g2b(p_gltf - self.pivot))
        m_idx = MAT_IDX[mat.name]
        for face in bm.faces:
            self.faces.append(tuple(base + v.index for v in face.verts))
            self.mat_indices.append(m_idx)
        bm.free()

    def build_object(self):
        me = bpy.data.meshes.new(self.name)
        me.from_pydata(self.verts, [], self.faces)
        for m in ALL_MATS:
            me.materials.append(m)
        for i, poly in enumerate(me.polygons):
            poly.material_index = self.mat_indices[i]
            poly.use_smooth = True

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

        uv_layer = me.uv_layers.new(name="UVMap")
        piv_b = g2b(self.pivot)
        for poly in me.polygons:
            n = poly.normal
            ax = max(range(3), key=lambda k: abs(n[k]))
            axes = [k for k in range(3) if k != ax]
            m_name = me.materials[poly.material_index].name
            sc_uv = 28.0 if m_name == "Paint" else (10.0 if m_name == "Gold" else 12.0)
            for li in poly.loop_indices:
                co = me.vertices[me.loops[li].vertex_index].co + piv_b
                uv_layer.data[li].uv = (co[axes[0]] / sc_uv, co[axes[1]] / sc_uv)

        obj = bpy.data.objects.new(self.name, me)
        sc.collection.objects.link(obj)
        obj.location = piv_b
        return obj


# ---------------------------------------------------------------------------
# SMOOTH C-INFINITY MATHEMATICAL PROFILES OF THE AETHELGARD CRUISER (sheet.jpg)
# ---------------------------------------------------------------------------
_U_GRID = np.linspace(0.0, 1.0, 1000)
_RAW_H = [sin(pi * (u ** 0.78) * 0.835) ** 0.68 for u in _U_GRID]
_MAX_RAW_H = max(_RAW_H)


def hull_half_height(z):
    """Smooth C-infinity seed-pod vertical profile H(z) from z=+90 (nose) to z=-90 (stern).
    Closes cleanly to a rounded prow at z=+90 so there is zero gap at the nose tip."""
    u = max(0.0, min(1.0, (90.0 - z) / 180.0))
    val = (sin(pi * (u ** 0.78) * 0.835) ** 0.68) / _MAX_RAW_H
    return 0.08 + 19.92 * val


_RAW_W = [sin(pi * (u ** 0.74) * 0.84) ** 0.62 for u in _U_GRID]
_MAX_RAW_W = max(_RAW_W)


def hull_half_width(z):
    """Smooth C-infinity horizontal half-width W(z) of the central seed-pod hull."""
    u = max(0.0, min(1.0, (90.0 - z) / 180.0))
    val = (sin(pi * (u ** 0.74) * 0.84) ** 0.62) / _MAX_RAW_W
    return 0.12 + 19.18 * val


def channel_y_center(z):
    """Smooth bifurcating flank energy channel path y_ch(z) >= 0."""
    if z >= 12.0:
        return 0.0
    elif z >= -36.0:
        s = smoothstep(12.0, -36.0, z)
        return 12.8 * (s ** 1.08)
    else:
        s = smoothstep(-36.0, -90.0, z)
        return 12.8 - 5.2 * (s ** 1.12)


def upper_gold_band_y(z):
    """Path of the upper/lower sweeping Aged Gold trim band from nose (z=+82) to dorsal waist crest (z=-15)."""
    if z >= 14.0:
        s = smoothstep(82.0, 14.0, z)
        return 2.6 + 4.4 * s
    else:
        s = smoothstep(14.0, -15.0, z)
        return 7.0 + 12.85 * (s ** 0.90)


def hull_x_at_yz(z, y, r_offset=0.0):
    """Evaluates lateral coordinate x >= 0 on the smooth ovoid seed-pod hull at (z, y)."""
    H = max(0.08, hull_half_height(z))
    W = max(0.12, hull_half_width(z))
    t = min(0.9999, abs(y) / H)
    nose_taper = smoothstep(90.0, 86.0, z)
    x_base = W * (max(0.0, 1.0 - (t ** 2.05)) ** 0.49)
    return max(0.0, x_base + r_offset * nose_taper * (1.0 - 0.45 * (t ** 2)))


# ---------------------------------------------------------------------------
# BUILD CURVE-ALIGNED MAIN HULL NODE ("Hull")
# ---------------------------------------------------------------------------
print("Building curve-aligned Hull geometry...", flush=True)
hull_mb = MeshBuilder("Hull", pivot_gltf=(0.0, 0.0, 0.0))

NZ = 200
u_lin = np.linspace(0.0, 1.0, NZ)
z_stations = [float(90.0 - 180.0 * (0.5 - 0.5 * cos(pi * u))) for u in u_lin]


def build_half_hull_profile(z, side):
    """Returns vertices and face materials from Ventral Centerline (-H) to Dorsal Centerline (+H)."""
    H = hull_half_height(z)
    nose_scale = min(1.0, H / 2.2)

    yc = channel_y_center(z) * nose_scale
    y_alcove = max(0.02 * nose_scale, yc - 1.02 * nose_scale)
    bif_open = smoothstep(12.0, 4.0, z)

    wishbone_t = smoothstep(12.0, 5.0, z) * smoothstep(-21.0, -15.0, z)
    wing_fairing_t = smoothstep(-16.0, -22.0, z) * smoothstep(-66.0, -60.0, z)
    stern_t = smoothstep(-60.0, -68.0, z)

    alcove_depth = -1.45 * wishbone_t - 0.38 * wing_fairing_t - 5.10 * stern_t
    if z >= 11.5:
        alcove_mat = MAT_LAMPS
    elif z >= -18.5 or z <= -62.5:
        alcove_mat = MAT_SCORCH
    else:
        alcove_mat = MAT_PAINT

    ch_hw = 0.42 * nose_scale
    lip_w = 0.58 * nose_scale

    y_ch_in = y_alcove
    y_ch_lo = y_alcove + lip_w * bif_open
    y_ch_hi = y_ch_lo + 2.0 * ch_hw * (1.0 if z < 12.0 else 0.55)
    y_lip_out = min(H * 0.84, y_ch_hi + lip_w)

    y_gold = upper_gold_band_y(z) * nose_scale
    y_gold_lo = min(H * 0.980, max(y_lip_out + 0.12 * nose_scale, y_gold - 0.45 * nose_scale))
    y_gold_hi = min(H * 0.990, max(y_gold_lo + 0.08 * nose_scale, y_gold + 0.45 * nose_scale))

    upper_pts = []
    upper_mats = []

    def add_zone(y0, y1, r0, r1, n_segs, mat, include_start=False):
        for k in range(0 if include_start else 1, n_segs + 1):
            s = k / n_segs
            yy = y0 + (y1 - y0) * s
            rr = r0 + (r1 - r0) * s
            xx = side * hull_x_at_yz(z, yy, rr)
            upper_pts.append((xx, yy, z))
            if k > 0 or not include_start:
                upper_mats.append(mat)

    add_zone(0.0, y_ch_in, alcove_depth, alcove_depth, 6, alcove_mat, include_start=True)
    add_zone(y_ch_in, y_ch_lo, alcove_depth + 0.2, 0.22 * bif_open, 2, MAT_GOLD if z < 11.5 else MAT_LAMPS)
    add_zone(y_ch_lo, y_ch_hi, -0.85, -0.85, 3, MAT_LAMPS)
    add_zone(y_ch_hi, y_lip_out, 0.22, 0.16, 2, MAT_GOLD)
    add_zone(y_lip_out, y_gold_lo, 0.0, 0.0, 6, MAT_PAINT)
    band_active = (-15.2 <= z <= 82.0)
    gold_rel = 0.16 * smoothstep(-16.0, -12.0, z) * smoothstep(83.0, 77.0, z) if band_active else 0.0
    add_zone(y_gold_lo, y_gold_hi, gold_rel, gold_rel, 2, MAT_GOLD if band_active else MAT_PAINT)
    y_spine_edge = H * 0.992
    add_zone(y_gold_hi, y_spine_edge, 0.0, 0.0, 10, MAT_PAINT)

    x_sp = hull_x_at_yz(z, y_spine_edge, 0.0)
    spine_active = (-89.0 <= z <= -15.0)
    upper_pts.append((side * x_sp * 0.55, H * 0.997, z))
    upper_mats.append(MAT_GOLD if spine_active else MAT_PAINT)
    upper_pts.append((side * x_sp * 0.22, H * (0.975 if spine_active else 0.999), z))
    upper_mats.append(MAT_LAMPS if spine_active else MAT_PAINT)
    upper_pts.append((0.0, H * (0.972 if spine_active else 1.0), z))
    upper_mats.append(MAT_LAMPS if spine_active else MAT_PAINT)

    lower_pts = [(pt[0], -pt[1], pt[2]) for pt in reversed(upper_pts[1:])]
    lower_mats = list(reversed(upper_mats))
    return lower_pts + upper_pts, lower_mats + upper_mats


for side in (1, -1):
    rows_pts = []
    rows_mats = []
    for z in z_stations:
        pts, ms = build_half_hull_profile(z, side)
        rows_pts.append(pts)
        rows_mats.append(ms)
    hull_mb.add_grid(rows_pts, rows_mats[:-1], closed_u=False, closed_v=False, flip=(side < 0))
    # Cap tiny nose prow tip cleanly
    n_prof_len = len(rows_pts[0])
    nose_c = len(hull_mb.verts)
    hull_mb.verts.append(g2b((0.0, 0.0, 90.0)))
    for i in range(n_prof_len - 1):
        f = (nose_c, i, i + 1) if side > 0 else (nose_c, i + 1, i)
        hull_mb.faces.append(f)
        hull_mb.mat_indices.append(MAT_IDX[MAT_LAMPS.name])


def hull_surf_point_and_normal(z, y_target, side_sign):
    H = max(0.2, hull_half_height(z))
    W = max(0.2, hull_half_width(z))
    yy = max(-H * 0.98, min(H * 0.98, y_target))
    xx = side_sign * hull_x_at_yz(z, yy, 0.0)
    p = Vector((xx, yy, z))
    n = Vector((side_sign * (xx / (W * W + 1e-4)), yy / (H * H + 1e-4), 0.02)).normalized()
    return p, n


# 2. Flush Crystalline Glass Conduit Covers, Aft Parallel Gold Trim Band & Wing-Root Gold Crescent
for side in (-1, 1):
    eq_zs = np.linspace(86.0, 11.0, 40)
    eq_pts, eq_norms, eq_tans = [], [], []
    for z in eq_zs:
        p, n = hull_surf_point_and_normal(z, 0.0, side)
        eq_pts.append(p - n * 0.35)
        eq_norms.append(n)
        eq_tans.append(Vector((0.0, 0.0, -1.0)))
    hull_mb.add_prism_strip(eq_pts, eq_norms, eq_tans, width=0.78, thickness=0.09, mat=MAT_GLASS)

    for y_sign in (-1, 1):
        sp_zs = np.linspace(11.0, -88.0, 68)
        sp_pts, sp_norms, sp_tans = [], [], []
        for z in sp_zs:
            yc = y_sign * channel_y_center(z)
            p, n = hull_surf_point_and_normal(z, yc, side)
            sp_pts.append(p - n * 0.35)
            sp_norms.append(n)
        for k in range(len(sp_pts)):
            sp_tans.append((sp_pts[min(len(sp_pts) - 1, k + 1)] - sp_pts[max(0, k - 1)]).normalized())
        hull_mb.add_prism_strip(sp_pts, sp_norms, sp_tans, width=0.78, thickness=0.09, mat=MAT_GLASS)

        # Aft outer parallel Gold Trim Band
        aft_zs = np.linspace(24.0, -88.0, 60)
        aft_pts, aft_norms, aft_tans = [], [], []
        for z in aft_zs:
            yc = channel_y_center(z) + 2.15
            ya = y_sign * min(hull_half_height(z) * 0.94, yc)
            p, n = hull_surf_point_and_normal(z, ya, side)
            aft_pts.append(p)
            aft_norms.append(n)
        for k in range(len(aft_pts)):
            aft_tans.append((aft_pts[min(len(aft_pts) - 1, k + 1)] - aft_pts[max(0, k - 1)]).normalized())
        hull_mb.add_prism_strip(aft_pts, aft_norms, aft_tans, width=0.88, thickness=0.18, mat=MAT_GOLD)

        # Upper & Lower Wing-Root Gold Crescent Accents (sheet.jpg Side View at z in [-16, -34])
        wr_zs = np.linspace(-17.0, -34.0, 18)
        wr_pts, wr_norms, wr_tans = [], [], []
        for idx_w, z in enumerate(wr_zs):
            sw = idx_w / 17.0
            yw = y_sign * (5.2 + 2.2 * sin(pi * sw))
            p, n = hull_surf_point_and_normal(z, yw, side)
            wr_pts.append(p - n * 0.25)
            wr_norms.append(n)
        for k in range(len(wr_pts)):
            wr_tans.append((wr_pts[min(len(wr_pts) - 1, k + 1)] - wr_pts[max(0, k - 1)]).normalized())
        hull_mb.add_prism_strip(wr_pts, wr_norms, wr_tans, width=1.15, thickness=0.22, mat=MAT_GOLD)

    # 3. Branching Dendritic Blue Energy Veins inside the Wishbone Bifurcation Alcove
    vein_branches = [
        [(10.0, 0.0), (2.0, 1.8), (-6.0, 4.4), (-14.5, 7.8)],
        [(10.0, 0.0), (2.0, -1.8), (-6.0, -4.4), (-14.5, -7.8)],
        [(3.0, 1.4), (-4.0, 0.0), (-11.0, -2.4), (-17.5, -6.2)],
        [(3.0, -1.4), (-4.0, 0.0), (-11.0, 2.4), (-17.5, 6.2)],
        [(-2.0, 2.8), (-9.5, 5.2), (-16.5, 9.2)],
        [(-2.0, -2.8), (-9.5, -5.2), (-16.5, -9.2)],
    ]
    for branch in vein_branches:
        b_pts, b_norms, b_tans = [], [], []
        for k in range(len(branch) - 1):
            z0, y0 = branch[k]
            z1, y1 = branch[k + 1]
            for s_step in range(8):
                t = s_step / 8.0
                zz = z0 + (z1 - z0) * t
                yy = y0 + (y1 - y0) * t
                p, n = hull_surf_point_and_normal(zz, yy, side)
                b_pts.append(p - n * 1.28)
                b_norms.append(n)
        for idx_p in range(len(b_pts)):
            b_tans.append((b_pts[min(len(b_pts) - 1, idx_p + 1)] - b_pts[max(0, idx_p - 1)]).normalized())
        hull_mb.add_prism_strip(b_pts, b_norms, b_tans, width=0.36, thickness=0.12, mat=MAT_LAMPS)
        hull_mb.add_prism_strip(b_pts, b_norms, b_tans, width=0.50, thickness=0.15, mat=MAT_GLASS)


# 4. Human-Scale Recessed Lit Observation Windows & Conformal Bridge Glazing
for side in (-1, 1):
    for z_win in np.linspace(52.0, 18.0, 14):
        for y_win in [1.85, -1.85]:
            p, n = hull_surf_point_and_normal(z_win, y_win, side)
            t_f = Vector((0.0, 0.0, 1.0))
            u_f = n.cross(t_f).normalized()
            rot = Matrix((u_f, t_f.cross(u_f).normalized(), t_f)).transposed()
            hull_mb.add_bevel_box(p + n * 0.02, (0.12, 0.28, 0.85), MAT_LAMPS, rot_mat=rot, bev=0.02)

z_br = 34.0
y_br = hull_half_height(z_br) - 0.35
hull_mb.add_bevel_box((0.0, y_br - 0.15, z_br), (2.6, 0.45, 7.8), MAT_SCORCH, bev=0.10)
hull_mb.add_bevel_box((0.0, y_br + 0.05, z_br + 1.2), (1.1, 0.32, 0.7), MAT_GOLD, bev=0.05)
hull_mb.add_bevel_box((0.0, y_br + 0.22, z_br + 1.2), (0.85, 0.10, 0.5), MAT_LAMPS, bev=0.02)
hull_mb.add_bevel_box((0.0, y_br + 0.10, z_br), (0.55, 0.55, 0.55), MAT_SCORCH, bev=0.05)
br_rows = []
for dz in np.linspace(-3.6, 3.6, 11):
    t_z = 1.0 - (dz / 3.8) ** 2
    w_b = 1.25 * sqrt(max(0.02, t_z))
    h_b = 0.42 * t_z
    row = [(-w_b * cos(pi * k_u / 8.0), y_br + 0.05 + h_b * sin(pi * k_u / 8.0), z_br + dz) for k_u in range(9)]
    br_rows.append(row)
hull_mb.add_grid(br_rows, MAT_GLASS, closed_u=False, closed_v=False)


# 5. Sweeping Crescent Wing Pylons (Fixed to Hull, spanning |x| from 13.5m out to 39.0m)
for side in (-1, 1):
    nx_w = 30
    nu_w = 36
    wing_rows = []
    wing_mats = []

    for ix in range(nx_w):
        s = ix / (nx_w - 1)
        x_abs = 13.5 + 25.5 * s
        x = side * x_abs

        # Deep concave forward crescent + swept trailing crescent matching sheet.jpg Top & 3/4 Views
        concave_le = sin(pi * (s ** 0.80))
        concave_te = sin(pi * (s ** 0.90))
        z_le = -14.0 - 15.5 * concave_le + 6.5 * (s ** 2.2)
        z_te = -60.0 + 13.5 * concave_te - 4.5 * (s ** 2.0)
        z_mid = 0.5 * (z_le + z_te)
        half_chord = 0.5 * (z_le - z_te)

        y_center = 0.35 * sin(pi * s) - 1.10 * (s ** 1.25)
        half_thick = 3.2 * ((1.0 - s) ** 0.65) + 1.65 * s

        row = []
        row_m = []
        for iu in range(nu_w):
            ang = 2.0 * pi * iu / nu_w
            cz = cos(ang)
            sy = sin(ang)
            # Embedded flush Gold Crescent Cuff on leading edge (cz > 0.74) and trailing edge (cz < -0.82)
            if cz > 0.74 or cz < -0.82:
                m_w = MAT_GOLD
                r_w = 1.03
            else:
                m_w = MAT_PAINT
                r_w = 1.0
            pz = z_mid + half_chord * cz * r_w
            py = y_center + half_thick * sy * (0.88 + 0.12 * cz) * r_w
            row.append((x, py, pz))
            row_m.append(m_w)
        wing_rows.append(row)
        wing_mats.append(row_m)

    hull_mb.add_grid(wing_rows, wing_mats[:-1], closed_u=True, closed_v=False, flip=(side < 0))


# 6. Rear Thruster Bay Mechanics: Central Main Nozzle & Two Flanking Crescent Nozzles (sheet.jpg Rear View)
hull_mb.add_cylinder((0.0, 0.0, -64.0), (0.0, 0.0, -87.0), 6.2, 5.8, MAT_SCORCH, segs=48, cap0=True, cap1=False)
hull_mb.add_cylinder((0.0, 0.0, -87.0), (0.0, 0.0, -77.5), 5.1, 4.0, MAT_ENGINES, segs=48, cap0=False, cap1=True, flip=True)
hull_mb.add_cylinder((0.0, 0.0, -86.0), (0.0, 0.0, -87.2), 5.9, 5.5, MAT_GOLD, segs=48, cap0=False, cap1=False)

for side in (-1, 1):
    c_rows_outer = []
    c_rows_inner = []
    for zz in np.linspace(-71.0, -87.2, 10):
        flare = 1.0 + 0.12 * ((zz + 71.0) / -16.2)
        ring_out = []
        ring_in = []
        for iu in range(28):
            ang = 2.0 * pi * iu / 28.0
            r_arc = 8.4 + 2.1 * cos(ang)
            theta_arc = 0.72 * sin(ang)
            px = side * (r_arc * cos(theta_arc)) * flare
            py = (r_arc * sin(theta_arc)) * flare
            ring_out.append((px, py, zz))
            px_in = side * ((7.9 + 1.65 * cos(ang)) * cos(0.66 * sin(ang))) * flare
            py_in = ((7.9 + 1.65 * cos(ang)) * sin(0.66 * sin(ang))) * flare
            ring_in.append((px_in, py_in, zz - 0.15))
        c_rows_outer.append(ring_out)
        c_rows_inner.append(ring_in)
    hull_mb.add_grid(c_rows_outer, MAT_SCORCH, closed_u=True, closed_v=False, flip=(side < 0))
    hull_mb.add_grid(c_rows_inner, MAT_ENGINES, closed_u=True, closed_v=False, flip=(side > 0))

    for vy in [-3.4, -1.7, 0.0, 1.7, 3.4]:
        hull_mb.add_bevel_box((side * 8.4, vy, -84.2), (3.0, 0.20, 4.6), MAT_SCORCH, bev=0.04)

# 7. Flush Ventral Torpedo Launch Bays (Hardpoint_1..4)
for side in (-1, 1):
    for z_hp in [28.0, 10.0]:
        p_hp, n_hp = hull_surf_point_and_normal(z_hp, -11.5, side)
        t_f = Vector((0.0, 0.0, 1.0))
        u_f = n_hp.cross(t_f).normalized()
        rot = Matrix((u_f, t_f.cross(u_f).normalized(), t_f)).transposed()
        hull_mb.add_bevel_box(p_hp - n_hp * 0.05, (0.85, 0.35, 3.6), MAT_GOLD, rot_mat=rot, bev=0.06)
        hull_mb.add_bevel_box(p_hp - n_hp * 0.02, (0.55, 0.32, 3.0), MAT_SCORCH, rot_mat=rot, bev=0.04)

HULL_OBJ = hull_mb.build_object()


# ---------------------------------------------------------------------------
# BUILD MOVING NODE: "Ring" (Floating Waist/Drive Ring Assembly around the Aft Waist Core)
# Origin on the ship axis (0, 0, -74.0), spins about Z ([0, 0, 1])
# Includes the luminous glowing blue plasma ring slots visible in sheet.jpg Side, 3/4 & Rear Views!
# ---------------------------------------------------------------------------
print("Building Floating Ring Drive geometry...", flush=True)
RING_PIVOT = (0.0, 0.0, -74.0)
ring_mb = MeshBuilder("Ring", pivot_gltf=RING_PIVOT)

N_RING_U = 96
# Two coaxial floating drive collars at z = -70.5m and z = -79.5m with dark metallic/gold outer rim
# and a crisp recessed vertical glowing blue plasma slot (matching sheet.jpg Side & 3/4 Views)
for z_ring_c, r_in, r_out in [(-70.5, 6.25, 7.65), (-79.5, 6.15, 7.45)]:
    w_prof = [
        (-2.2, r_in + 0.15, MAT_SCORCH),
        (-1.6, r_in, MAT_SCORCH),
        (-0.5, r_in, MAT_ENGINES),
        (0.5, r_in, MAT_ENGINES),
        (1.6, r_in, MAT_SCORCH),
        (2.2, r_in + 0.15, MAT_SCORCH),
        (2.3, r_out - 0.15, MAT_SCORCH),
        (1.1, r_out, MAT_GOLD),
        (0.45, r_out - 0.25, MAT_LAMPS),
        (-0.45, r_out - 0.25, MAT_LAMPS),
        (-1.1, r_out, MAT_GOLD),
        (-2.3, r_out - 0.15, MAT_SCORCH),
    ]
    base_r = len(ring_mb.verts)
    n_prof = len(w_prof)
    for dz, rad, _ in w_prof:
        for iu in range(N_RING_U):
            ang = 2.0 * pi * iu / N_RING_U
            px = rad * cos(ang)
            py = rad * sin(ang)
            pz = z_ring_c + dz
            ring_mb.verts.append(g2b(Vector((px, py, pz)) - Vector(RING_PIVOT)))

    for ip in range(n_prof):
        ip_next = (ip + 1) % n_prof
        m = w_prof[ip][2]
        for iu in range(N_RING_U):
            iu_next = (iu + 1) % N_RING_U
            a = base_r + ip * N_RING_U + iu
            b = base_r + ip * N_RING_U + iu_next
            c = base_r + ip_next * N_RING_U + iu_next
            d = base_r + ip_next * N_RING_U + iu
            ring_mb.faces.append((a, b, c, d))
            ring_mb.mat_indices.append(MAT_IDX[m.name])

# Slanted dark metallic turbine stator ribs bridging between the two floating drive collars (matching sheet.jpg Side View)
for k in range(18):
    ang = 2.0 * pi * k / 18.0
    ca, sa = cos(ang), sin(ang)
    c_pos = Vector((7.25 * ca, 7.25 * sa, -75.0))
    rot = Matrix((Vector((-sa, ca, 0.0)), Vector((ca, sa, 0.0)), Vector((0.0, 0.0, 1.0)))).transposed()
    ring_mb.add_bevel_box(c_pos, (0.45, 0.95, 13.5), MAT_SCORCH, rot_mat=rot, bev=0.06)

# Rear Exit Floating Ring Drive Core at z = -85.0m ("FLOATING RING DRIVE" in sheet.jpg Rear View)
N_DRV_U = 72
drive_profile = [
    (-1.8, 3.2, MAT_GOLD),
    (-1.1, 2.85, MAT_ENGINES),
    (0.0, 2.75, MAT_ENGINES),
    (1.1, 2.85, MAT_ENGINES),
    (1.8, 3.2, MAT_GOLD),
    (2.0, 4.2, MAT_SCORCH),
    (1.3, 5.0, MAT_GOLD),
    (0.4, 5.15, MAT_LAMPS),
    (-0.4, 5.15, MAT_LAMPS),
    (-1.3, 5.0, MAT_GOLD),
    (-2.0, 4.2, MAT_SCORCH),
]
base_d = len(ring_mb.verts)
n_dprof = len(drive_profile)
for dz, rad, _ in drive_profile:
    for iu in range(N_DRV_U):
        ang = 2.0 * pi * iu / N_DRV_U
        px = rad * cos(ang)
        py = rad * sin(ang)
        pz = -85.0 + dz
        ring_mb.verts.append(g2b(Vector((px, py, pz)) - Vector(RING_PIVOT)))

for ip in range(n_dprof):
    ip_next = (ip + 1) % n_dprof
    m = drive_profile[ip][2]
    for iu in range(N_DRV_U):
        iu_next = (iu + 1) % N_DRV_U
        a = base_d + ip * N_DRV_U + iu
        b = base_d + ip * N_DRV_U + iu_next
        c = base_d + ip_next * N_DRV_U + iu_next
        d = base_d + ip_next * N_DRV_U + iu
        ring_mb.faces.append((a, b, c, d))
        ring_mb.mat_indices.append(MAT_IDX[m.name])

RING_OBJ = ring_mb.build_object()


# ---------------------------------------------------------------------------
# BUILD MOVING NODES: "Turret_1" (Left +X) & "Turret_2" (Right -X)
# Plus child elevating barrel nodes: "Gun_1" & "Gun_2"
# ---------------------------------------------------------------------------
print("Building Turret_1, Turret_2, Gun_1, Gun_2...", flush=True)
TURRET_OBJS = {}
GUN_OBJS = {}

for idx_t, side in [(1, 1), (2, -1)]:
    t_name = f"Turret_{idx_t}"
    g_name = f"Gun_{idx_t}"
    t_piv = (side * 39.0, -1.10, -37.0)
    g_piv = (side * 39.5, -1.10, -10.5)

    t_mb = MeshBuilder(t_name, pivot_gltf=t_piv)

    # Smooth Crescent-Spindle Weapon Pod (z from +2.5m forward prow to -75.5m aft tip)
    np_z = 60
    np_u = 36
    pod_zs = np.linspace(2.5, -75.5, np_z)
    pod_rows = []
    pod_mats = []

    for zz in pod_zs:
        u = (zz - 2.5) / (-78.0)
        env = sin(pi * u) ** 0.70
        # Sleek crescent curve with slight upward rear wingtip sweep (matching sheet.jpg 3/4 View)
        aft_upturn = 2.4 * (max(0.0, (u - 0.68) / 0.32) ** 2.0)
        x_center = side * (36.6 + 4.55 * (env ** 0.62))
        half_w = 0.25 + 3.65 * env
        half_h = 0.20 + 2.95 * env
        y_center = -1.10 + aft_upturn

        row_pts = []
        row_ms = []
        for iu in range(np_u):
            ang = 2.0 * pi * iu / np_u
            ca = cos(ang)
            sa = sin(ang)

            m = MAT_PAINT
            r_mod = 1.0
            # Sleek forward-mid glowing blue energy slot + dark recessed aft groove (sheet.jpg Side View)
            if 0.22 < u < 0.72 and ca > 0.65:
                if abs(sa) < 0.18:
                    r_mod = 0.86
                    m = MAT_LAMPS if u < 0.44 else MAT_SCORCH
                elif abs(sa) < 0.30:
                    r_mod = 0.94
                    m = MAT_SCORCH
            elif ca < -0.52 and (0.04 < u < 0.38):
                r_mod = 1.02
                m = MAT_GOLD
            elif u < 0.19 and abs(sa) < 0.36 and abs(ca) < 0.58:
                r_mod = 0.78
                m = MAT_SCORCH

            px = x_center + side * half_w * ca * r_mod
            py = y_center + half_h * sa * r_mod
            row_pts.append((px, py, zz))
            row_ms.append(m)
        pod_rows.append(row_pts)
        pod_mats.append(row_ms)

    base_p = len(t_mb.verts)
    for row in pod_rows:
        for pt in row:
            t_mb.verts.append(g2b(Vector(pt) - Vector(t_piv)))

    for j in range(np_z - 1):
        for i in range(np_u):
            inext = (i + 1) % np_u
            a = base_p + j * np_u + i
            b = base_p + j * np_u + inext
            c = base_p + (j + 1) * np_u + inext
            d = base_p + (j + 1) * np_u + i
            f = (a, d, c, b) if side < 0 else (a, b, c, d)
            m0, m1, m2 = pod_mats[j][i], pod_mats[j][inext], pod_mats[j + 1][i]
            if MAT_LAMPS in (m0, m1, m2):
                m = MAT_LAMPS
            elif MAT_SCORCH in (m0, m1, m2):
                m = MAT_SCORCH
            elif MAT_GOLD in (m0, m1, m2):
                m = MAT_GOLD
            else:
                m = MAT_PAINT
            t_mb.faces.append(f)
            t_mb.mat_indices.append(MAT_IDX[m.name])

    # Flush Glass lens over the forward-mid weapon pod energy slot
    slot_pts, slot_norms, slot_tans = [], [], []
    for zz in np.linspace(-15.0, -32.0, 16):
        u = (zz - 2.5) / (-78.0)
        env = sin(pi * u) ** 0.70
        x_out = side * (36.6 + 4.55 * (env ** 0.62) + (0.25 + 3.65 * env) * 0.90)
        slot_pts.append(Vector((x_out, -1.10, zz)))
        slot_norms.append(Vector((side, 0.0, 0.0)))
        slot_tans.append(Vector((0.0, 0.0, -1.0)))
    t_mb.add_prism_strip(slot_pts, slot_norms, slot_tans, width=0.68, thickness=0.08, mat=MAT_GLASS)

    t_obj = t_mb.build_object()
    TURRET_OBJS[t_name] = t_obj

    # Child elevating barrel assembly ("Gun_1", "Gun_2")
    g_mb = MeshBuilder(g_name, pivot_gltf=g_piv)
    g_mb.add_cylinder(
        (g_piv[0] - 0.95, g_piv[1], g_piv[2]),
        (g_piv[0] + 0.95, g_piv[1], g_piv[2]),
        0.78, 0.78, MAT_SCORCH, segs=24
    )
    for dx_b in (-0.42, 0.42):
        bx = g_piv[0] + dx_b
        by = g_piv[1]
        g_mb.add_cylinder((bx, by, -10.5), (bx, by, -3.5), 0.30, 0.24, MAT_SCORCH, segs=20)
        g_mb.add_cylinder((bx, by, -3.5), (bx, by, 1.8), 0.22, 0.15, MAT_SCORCH, segs=20)
        g_mb.add_cylinder((bx, by, 0.4), (bx, by, 1.78), 0.11, 0.11, MAT_LAMPS, segs=16)
        for z_ring in [-7.5, -4.2, -1.0]:
            g_mb.add_cylinder((bx, by, z_ring - 0.28), (bx, by, z_ring + 0.28), 0.34, 0.32, MAT_GOLD, segs=20)

    g_obj = g_mb.build_object()
    GUN_OBJS[g_name] = g_obj


# ---------------------------------------------------------------------------
# EXACT DIMENSION NORMALIZATION (180m Long, 90m Wide, 40m High, Centered at 0,0,0)
# ---------------------------------------------------------------------------
MESH_OBJS = [HULL_OBJ, RING_OBJ, TURRET_OBJS["Turret_1"], TURRET_OBJS["Turret_2"], GUN_OBJS["Gun_1"], GUN_OBJS["Gun_2"]]
bpy.context.view_layer.update()

all_world_verts = [o.matrix_world @ v.co for o in MESH_OBJS for v in o.data.vertices]
mn = Vector((min(v.x for v in all_world_verts), min(v.y for v in all_world_verts), min(v.z for v in all_world_verts)))
mx = Vector((max(v.x for v in all_world_verts), max(v.y for v in all_world_verts), max(v.z for v in all_world_verts)))
center_b = 0.5 * (mn + mx)
span_b = mx - mn
scale_b = Vector((90.0 / span_b.x, 180.0 / span_b.y, 40.0 / span_b.z))

for o in MESH_OBJS:
    old_loc = o.location.copy()
    new_loc = Vector((
        (old_loc.x - center_b.x) * scale_b.x,
        (old_loc.y - center_b.y) * scale_b.y,
        (old_loc.z - center_b.z) * scale_b.z,
    ))
    o.data.transform(Matrix.Diagonal((scale_b.x, scale_b.y, scale_b.z, 1.0)))
    o.location = new_loc

HULL_OBJ.data.transform(Matrix.Translation(HULL_OBJ.location))
HULL_OBJ.location = Vector((0.0, 0.0, 0.0))

ring_offset = Vector((RING_OBJ.location.x, 0.0, RING_OBJ.location.z))
RING_OBJ.data.transform(Matrix.Translation(ring_offset))
RING_OBJ.location = Vector((0.0, RING_OBJ.location.y, 0.0))

for o in MESH_OBJS:
    bpy.context.view_layer.objects.active = o
    o.select_set(True)
    mod = o.modifiers.new("Triangulate", 'TRIANGULATE')
    bpy.ops.object.modifier_apply(modifier=mod.name)
    o.select_set(False)


# ---------------------------------------------------------------------------
# HIERARCHY & EMPTY NODES (SOCKETS)
# ---------------------------------------------------------------------------
def parent_keep_world(child_obj, parent_obj):
    bpy.context.view_layer.update()
    mw = child_obj.matrix_world.copy()
    child_obj.parent = parent_obj
    child_obj.matrix_world = mw


parent_keep_world(RING_OBJ, HULL_OBJ)
parent_keep_world(TURRET_OBJS["Turret_1"], HULL_OBJ)
parent_keep_world(TURRET_OBJS["Turret_2"], HULL_OBJ)
parent_keep_world(GUN_OBJS["Gun_1"], TURRET_OBJS["Turret_1"])
parent_keep_world(GUN_OBJS["Gun_2"], TURRET_OBJS["Turret_2"])


def make_socket(name, pos_gltf, parent_obj):
    emp = bpy.data.objects.new(name, None)
    sc.collection.objects.link(emp)
    emp.empty_display_size = 1.5
    emp.location = g2b(pos_gltf)
    parent_keep_world(emp, parent_obj)
    return emp


make_socket("Thruster_1", (0.0, 0.0, -87.0), HULL_OBJ)
make_socket("Thruster_2", (8.2, 0.0, -87.0), HULL_OBJ)
make_socket("Thruster_3", (-8.2, 0.0, -87.0), HULL_OBJ)

g1_pos = b2g(GUN_OBJS["Gun_1"].matrix_world.translation)
g2_pos = b2g(GUN_OBJS["Gun_2"].matrix_world.translation)
make_socket("Muzzle_1", (g1_pos[0], g1_pos[1], g1_pos[2] + 12.3), GUN_OBJS["Gun_1"])
make_socket("Muzzle_2", (g2_pos[0], g2_pos[1], g2_pos[2] + 12.3), GUN_OBJS["Gun_2"])

make_socket("Hardpoint_1", (14.5, -11.5, 28.0), HULL_OBJ)
make_socket("Hardpoint_2", (-14.5, -11.5, 28.0), HULL_OBJ)
make_socket("Hardpoint_3", (15.2, -11.5, 10.0), HULL_OBJ)
make_socket("Hardpoint_4", (-15.2, -11.5, 10.0), HULL_OBJ)

make_socket("Seat_Pilot", (0.0, 16.5, 34.0), HULL_OBJ)
make_socket("Camera_Chase", (0.0, 42.0, -148.0), HULL_OBJ)
make_socket("Light_Nav_L", (45.0, -1.10, -37.0), HULL_OBJ)
make_socket("Light_Nav_R", (-45.0, -1.10, -37.0), HULL_OBJ)
make_socket("Light_Beacon", (0.0, 20.0, -15.0), HULL_OBJ)

bpy.context.view_layer.update()

ASSET_OBJS = [o for o in sc.objects if o.type in {'MESH', 'EMPTY'}]
all_pts_b = [o.matrix_world @ v.co for o in MESH_OBJS for v in o.data.vertices]
all_pts_g = [b2g(p) for p in all_pts_b]
lo_g = [round(min(p[i] for p in all_pts_g), 4) for i in range(3)]
hi_g = [round(max(p[i] for p in all_pts_g), 4) for i in range(3)]
dims_g = [round(hi_g[i] - lo_g[i], 4) for i in range(3)]
TOTAL_TRIS = sum(len(o.data.polygons) for o in MESH_OBJS)
print(f"GEOMETRY READY: {TOTAL_TRIS:,} tris | glTF bounds {lo_g} -> {hi_g} | dims {dims_g}", flush=True)
assert 60000 <= TOTAL_TRIS <= 160000, f"Triangle count {TOTAL_TRIS} outside budget 60k..160k"


# ---------------------------------------------------------------------------
# EXPORT GLB & PATCH PAINT BASECOLORFACTOR + ALPHA MODES
# ---------------------------------------------------------------------------
bpy.ops.object.select_all(action='DESELECT')
for o in ASSET_OBJS:
    o.select_set(True)
bpy.context.view_layer.objects.active = HULL_OBJ

glb_path = os.path.join(ROOT, "alien-elegant-cruiser.glb")
bpy.ops.export_scene.gltf(
    filepath=glb_path,
    export_format='GLB',
    use_selection=True,
    export_yup=True,
    export_apply=True,
    export_texcoords=True,
    export_normals=True,
    export_tangents=False,
    export_materials='EXPORT',
    export_cameras=False,
    export_lights=False,
)

raw_glb = open(glb_path, "rb").read()
j_len, j_type = struct.unpack_from("<II", raw_glb, 12)
gltf_json = json.loads(raw_glb[20:20 + j_len])
bin_tail = raw_glb[20 + j_len:]

for m in gltf_json.get("materials", []):
    if m.get("name") == "Paint":
        m.setdefault("pbrMetallicRoughness", {})["baseColorFactor"] = [0.80, 0.78, 0.72, 1.0]
        m.pop("extensions", None)
    elif m.get("name") == "Glass":
        m["alphaMode"] = "BLEND"
        m["doubleSided"] = True

json_bytes = json.dumps(gltf_json, separators=(",", ":")).encode("utf-8")
json_bytes += b" " * ((-len(json_bytes)) % 4)
patched_glb = (
    struct.pack("<4sII", b"glTF", 2, 12 + 8 + len(json_bytes) + len(bin_tail))
    + struct.pack("<II", len(json_bytes), 0x4E4F534A)
    + json_bytes
    + bin_tail
)
with open(glb_path, "wb") as f:
    f.write(patched_glb)

glb_size_mb = os.path.getsize(glb_path) / (1024.0 * 1024.0)
assert os.path.getsize(glb_path) <= 12 * 1024 * 1024, f"GLB size {glb_size_mb:.2f} MB exceeds 12 MB"
print(f"EXPORTED {glb_path} ({glb_size_mb:.2f} MiB)", flush=True)


# ---------------------------------------------------------------------------
# WRITE nodes.json AND NOTES.md
# ---------------------------------------------------------------------------
MOVING_AXES = {
    "Ring": [0.0, 0.0, 1.0],
    "Turret_1": [0.0, 1.0, 0.0],
    "Gun_1": [1.0, 0.0, 0.0],
    "Turret_2": [0.0, 1.0, 0.0],
    "Gun_2": [1.0, 0.0, 0.0],
}

nodes_list = []
for o in ASSET_OBJS:
    entry = {
        "name": o.name,
        "type": o.type,
        "parent": o.parent.name if o.parent else None,
        "world_position": b2g(o.matrix_world.translation),
    }
    if o.name in MOVING_AXES:
        entry["axis"] = MOVING_AXES[o.name]
    nodes_list.append(entry)

manifest = {
    "asset": "alien-elegant-cruiser.glb",
    "units": "metres",
    "coordinate_system": {"up": "+Y", "nose": "+Z", "left": "+X"},
    "bounds": {
        "min": lo_g,
        "max": hi_g,
        "dimensions": dims_g,
    },
    "triangles": TOTAL_TRIS,
    "materials": [m.name for m in ALL_MATS],
    "axes": MOVING_AXES,
    "paints": PAINTS,
    "nodes": nodes_list,
    "bakes": BAKE_LOG,
    "provenance": {
        "reference": "sheet.jpg",
        "polyhaven_ids": POLYHAVEN_IDS,
        "license": "CC0 (https://polyhaven.com/license)",
    },
}

with open(os.path.join(ROOT, "nodes.json"), "w") as f:
    json.dump(manifest, f, indent=2)

notes_lines = [
    "Aethelgard ancient alien cruiser: 180.0 m long (+Z nose), 90.0 m wide (+X left), 40.0 m high (+Y up) seed-pod hull with crescent wings, floating waist/drive ring and weapon pods.",
    f"{TOTAL_TRIS:,} triangles across 6 mesh nodes (Hull, Ring, Turret_1, Gun_1, Turret_2, Gun_2) and 14 socket empties; {glb_size_mb:.2f} MiB binary glTF.",
    "6 materials: Paint (neutral baked hull plates with baseColorFactor [0.80, 0.78, 0.72]), Gold (aged bare metal trims), ScorchedMetal, Glass (BLEND), Lamps, Engines.",
    "Texture sizes: Paint BaseColor/Normal 1024x1024, ORM 512x512; Gold and ScorchedMetal BaseColor 1024x1024, Normal/ORM 512x512 (Cycles GPU baked).",
    "Poly Haven CC0 texture IDs used: metal_plate, metal_plate_02, rusty_metal_03 (https://polyhaven.com/license).",
    "Differences from sheet.jpg: weapon pods include articulated wingtip rotary mounts and elevating Gun_1/Gun_2 plasma barrels; human-scale observation windows added along mid-deck.",
]
with open(os.path.join(ROOT, "NOTES.md"), "w") as f:
    f.write("\n".join(notes_lines) + "\n")


# ---------------------------------------------------------------------------
# SPACE LIGHTING & RENDERING (final.png, side.png, clay.png, paints.png, pivots.png)
# ---------------------------------------------------------------------------
world = bpy.data.worlds.new("DeepSpace")
sc.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_cam = wn.new('ShaderNodeBackground')
w_bg_env = wn.new('ShaderNodeBackground')
w_mix = wn.new('ShaderNodeMixShader')
w_lp = wn.new('ShaderNodeLightPath')

w_coord = wn.new('ShaderNodeTexCoord')
w_sep = wn.new('ShaderNodeSeparateXYZ')
w_ramp = wn.new('ShaderNodeValToRGB')
w_ramp.color_ramp.elements[0].position = 0.0
w_ramp.color_ramp.elements[0].color = (0.003, 0.005, 0.012, 1.0)
w_ramp.color_ramp.elements[1].position = 1.0
w_ramp.color_ramp.elements[1].color = (0.016, 0.032, 0.068, 1.0)
wl.new(w_coord.outputs['Window'], w_sep.inputs['Vector'])
wl.new(w_sep.outputs['Y'], w_ramp.inputs['Fac'])
wl.new(w_ramp.outputs['Color'], w_bg_cam.inputs['Color'])
w_bg_cam.inputs['Strength'].default_value = 1.0

w_bg_env.inputs['Color'].default_value = (0.18, 0.25, 0.38, 1.0)
w_bg_env.inputs['Strength'].default_value = 0.72

wl.new(w_lp.outputs['Is Camera Ray'], w_mix.inputs['Fac'])
wl.new(w_bg_env.outputs['Background'], w_mix.inputs[1])
wl.new(w_bg_cam.outputs['Background'], w_mix.inputs[2])
wl.new(w_mix.outputs['Shader'], w_out.inputs['Surface'])

bpy.ops.object.light_add(type='SUN', location=(90.0, -130.0, 95.0))
sun_key = bpy.context.object
sun_key.name = "Key_Sun"
sun_key.data.energy = 4.2
sun_key.data.angle = 0.08
sun_key.data.color = (1.0, 0.98, 0.95)
sun_key.rotation_euler = (Vector((0.0, 0.0, 0.0)) - sun_key.location).to_track_quat('-Z', 'Y').to_euler()

bpy.ops.object.light_add(type='SUN', location=(-110.0, -85.0, -35.0))
sun_fill = bpy.context.object
sun_fill.name = "Cool_Fill"
sun_fill.data.energy = 1.50
sun_fill.data.angle = 0.35
sun_fill.data.color = (0.62, 0.80, 1.0)
sun_fill.rotation_euler = (Vector((0.0, 0.0, 0.0)) - sun_fill.location).to_track_quat('-Z', 'Y').to_euler()

bpy.ops.object.light_add(type='SUN', location=(-65.0, 120.0, 75.0))
sun_rim = bpy.context.object
sun_rim.name = "Rim_Light"
sun_rim.data.energy = 1.65
sun_rim.data.angle = 0.20
sun_rim.data.color = (0.78, 0.88, 1.0)
sun_rim.rotation_euler = (Vector((0.0, 0.0, 0.0)) - sun_rim.location).to_track_quat('-Z', 'Y').to_euler()

bpy.ops.object.camera_add()
cam = bpy.context.object
cam.name = "Render_Camera"
sc.camera = cam


def aim_camera(pos, target=(0.0, 0.0, 0.0), ortho=False, ortho_scale=198.0, lens=40.0):
    cam.location = Vector(pos)
    cam.rotation_euler = (Vector(target) - cam.location).to_track_quat('-Z', 'Y').to_euler()
    if ortho:
        cam.data.type = 'ORTHO'
        cam.data.ortho_scale = ortho_scale
    else:
        cam.data.type = 'PERSP'
        cam.data.lens = lens
        cam.data.clip_end = 2000.0


def render_image(filename, w=1920, h=1080, samples=36):
    sc.render.resolution_x = w
    sc.render.resolution_y = h
    sc.render.resolution_percentage = 100
    sc.cycles.samples = samples
    out_path = filename if os.path.isabs(filename) else os.path.join(ROOT, filename)
    sc.render.filepath = out_path
    bpy.ops.render.render(write_still=True)
    print(f"RENDERED {filename} in {time.time() - START_TIME:.1f}s", flush=True)


# 1. final.png (1920x1080 Cycles render, 3/4 front view)
aim_camera((134.0, -162.0, 62.0), target=(-4.0, -2.0, -1.0), ortho=False, lens=40.0)
render_image("final.png", 1920, 1080, samples=44)

# 2. clay.png (same view as final, plain grey material)
clay_mat = bpy.data.materials.new("_Clay")
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.43, 0.45, 1.0)
clay_bsdf.inputs['Roughness'].default_value = 0.65
sc.view_layers[0].material_override = clay_mat
render_image("clay.png", 1920, 1080, samples=32)
sc.view_layers[0].material_override = None

# 3. side.png (orthographic side view matching sheet.jpg top profile: Nose on left)
aim_camera((220.0, 0.0, 0.0), target=(0.0, 0.0, 0.0), ortho=True, ortho_scale=196.0)
render_image("side.png", 1920, 1080, samples=32)

# 4. Pivot verification test render (rotate every moving node 30 degrees around its axis)
aim_camera((134.0, -162.0, 62.0), target=(-4.0, -2.0, -1.0), ortho=False, lens=40.0)
saved_rot = {o: o.rotation_euler.copy() for o in MESH_OBJS}
RING_OBJ.rotation_euler.y += pi / 6.0
TURRET_OBJS["Turret_1"].rotation_euler.z += pi / 6.0
TURRET_OBJS["Turret_2"].rotation_euler.z -= pi / 6.0
GUN_OBJS["Gun_1"].rotation_euler.x -= pi / 6.0
GUN_OBJS["Gun_2"].rotation_euler.x -= pi / 6.0
bpy.context.view_layer.update()
render_image("/tmp/pivots_alien_cruiser.png", 1280, 720, samples=16)
for o, rot in saved_rot.items():
    o.rotation_euler = rot
bpy.context.view_layer.update()

# 5. paints.png (3x2 grid of the same 3/4 view in all six paints)
tile_w, tile_h = 640, 360
frames = []
for i, p_info in enumerate(PAINTS):
    PAINT_FACTOR_NODE.inputs['Color2'].default_value = (*p_info["linear_rgb"], 1.0)
    tmp_name = f"_paint_tile_{i}.png"
    render_image(tmp_name, tile_w, tile_h, samples=16)
    tmp_path = os.path.join(ROOT, tmp_name)
    im = bpy.data.images.load(tmp_path, check_existing=False)
    arr = np.array(im.pixels[:], dtype=np.float32).reshape(tile_h, tile_w, 4)
    frames.append(arr)
    bpy.data.images.remove(im)
    try:
        os.remove(tmp_path)
    except OSError:
        pass

canvas = np.zeros((tile_h * 2, tile_w * 3, 4), dtype=np.float32)
for i, frame in enumerate(frames):
    row = 1 - (i // 3)
    col = i % 3
    canvas[row * tile_h:(row + 1) * tile_h, col * tile_w:(col + 1) * tile_w] = frame

grid_im = bpy.data.images.new("PaintsGrid", width=tile_w * 3, height=tile_h * 2, alpha=True)
grid_im.pixels.foreach_set(canvas.ravel())
grid_im.filepath_raw = os.path.join(ROOT, "paints.png")
grid_im.file_format = 'PNG'
grid_im.save()
PAINT_FACTOR_NODE.inputs['Color2'].default_value = (*PAINTS[0]["linear_rgb"], 1.0)

print(f"ALL DONE in {time.time() - START_TIME:.1f}s", flush=True)
