#!/usr/bin/env python3
"""Military Hover Recon Speeder (military-hover-speeder), metres.
Blender 5.2 clean build, Cycles GPU material bake, glTF export and QA renders.
Usage: blender -b --python-exit-code 1 --python build_military-hover-speeder.py
Source maps: Poly Haven CC0.
"""
import bpy, bmesh, math, os, sys, json, random, struct, subprocess, time
from pathlib import Path
from mathutils import Vector, Matrix, Euler
import numpy as np

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

random.seed(42)
START = time.time()
PREVIEW = '--preview' in sys.argv

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'
sc.cycles.samples = 32
sc.cycles.use_denoising = True

prefs = bpy.context.preferences.addons['cycles'].preferences
for backend in ['OPTIX', 'CUDA']:
    try:
        prefs.compute_device_type = backend
        prefs.get_devices()
        gpu = [d for d in prefs.devices if d.type == backend]
        if gpu:
            for d in prefs.devices:
                d.use = (d in gpu)
            sc.cycles.device = 'GPU'
            print('GPU_DEVICE', backend, [d.name for d in gpu], flush=True)
            break
    except Exception:
        pass
else:
    raise RuntimeError('A Cycles CUDA/OptiX GPU is required')

sc.render.image_settings.file_format = 'PNG'
sc.render.film_transparent = False
sc.view_settings.view_transform = 'AgX'
sc.view_settings.look = 'AgX - Medium High Contrast'
sc.render.bake.margin = 12
sc.cycles.max_bounces = 6
sc.cycles.transparent_max_bounces = 6


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


def download(asset, kind):
    p = SOURCE / f'{asset}_{kind}_1k.jpg'
    if not p.exists():
        url = f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
        subprocess.run(['curl', '-fL', '--retry', '2', '-A', 'Mozilla/5.0', '-sS', url, '-o', str(p)], check=True)
    return p


# Geometry is authored in Blender Z-up / Front -Y / Left +X.
# Blender glTF export with export_yup=True maps this to glTF Y-up / Front +Z / Left +X.
MATS = {}


def node(nt, typ):
    return nt.nodes.new(typ)


def image_node(nt, path, noncolor=False):
    n = node(nt, 'ShaderNodeTexImage')
    n.image = bpy.data.images.load(str(path), check_existing=True)
    if noncolor:
        n.image.colorspace_settings.name = 'Non-Color'
    return n


def mathn(nt, op, a=None, b=None):
    n = node(nt, 'ShaderNodeMath')
    n.operation = op
    for i, v in enumerate([a, b]):
        if v is None:
            continue
        if hasattr(v, 'node'):
            nt.links.new(v, n.inputs[i])
        else:
            n.inputs[i].default_value = v
    return n.outputs[0]


def bake_material(name, asset, tint, metal, rough, normal_strength=0.35, neutral=False, emission=0.0, alpha=1.0, secondary_asset=None):
    """Bake Poly Haven photographic maps mixed with organic weathering, grime streaks & micro-pitting."""
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    nt.nodes.clear()
    out = node(nt, 'ShaderNodeOutputMaterial')
    bs = node(nt, 'ShaderNodeBsdfPrincipled')
    nt.links.new(bs.outputs['BSDF'], out.inputs['Surface'])
    uv = node(nt, 'ShaderNodeTexCoord')

    if asset:
        diff = image_node(nt, download(asset, 'diff'))
        nor = image_node(nt, download(asset, 'nor_gl'), True)
        arm = image_node(nt, download(asset, 'arm'), True)
        for n in [diff, nor, arm]:
            nt.links.new(uv.outputs['UV'], n.inputs['Vector'])

        gray = node(nt, 'ShaderNodeRGBToBW')
        nt.links.new(diff.outputs['Color'], gray.inputs[0])

        if secondary_asset:
            diff2 = image_node(nt, download(secondary_asset, 'diff'))
            nt.links.new(uv.outputs['UV'], diff2.inputs['Vector'])
            gray2 = node(nt, 'ShaderNodeRGBToBW')
            nt.links.new(diff2.outputs['Color'], gray2.inputs[0])
            val_src = mathn(nt, 'ADD', mathn(nt, 'MULTIPLY', gray.outputs[0], 0.58), mathn(nt, 'MULTIPLY', gray2.outputs[0], 0.42))
        else:
            val_src = gray.outputs[0]

        ramp = node(nt, 'ShaderNodeValToRGB')
        nt.links.new(val_src, ramp.inputs[0])
        # Rich organic tonal contrast while keeping Paint neutral for runtime baseColorFactor
        low = 0.60 if neutral else 0.44
        high = 1.00 if neutral else 1.22
        ramp.color_ramp.elements[0].position = 0.14
        ramp.color_ramp.elements[0].color = tuple(min(1.0, c * low) for c in tint) + (1.0,)
        ramp.color_ramp.elements[1].position = 0.80
        ramp.color_ramp.elements[1].color = tuple(min(1.0, c * high) for c in tint) + (1.0,)
        base = ramp.outputs['Color']

        nm = node(nt, 'ShaderNodeNormalMap')
        nm.inputs['Strength'].default_value = normal_strength
        nt.links.new(nor.outputs['Color'], nm.inputs['Color'])

        sep = node(nt, 'ShaderNodeSeparateColor')
        nt.links.new(arm.outputs['Color'], sep.inputs[0])
        rr = mathn(nt, 'ADD', mathn(nt, 'MULTIPLY', sep.outputs['Green'], 0.38), rough - 0.17)
        ao = mathn(nt, 'ADD', mathn(nt, 'MULTIPLY', sep.outputs['Red'], 0.26), 0.74)
        norm = nm.outputs['Normal']
    else:
        rgb = node(nt, 'ShaderNodeRGB')
        rgb.outputs[0].default_value = tuple(tint) + (1.0,)
        base = rgb.outputs[0]
        rr = rough
        ao = 1.0
        norm = None

    if name == 'Lamps':
        u = node(nt, 'ShaderNodeSeparateXYZ')
        nt.links.new(uv.outputs['UV'], u.inputs[0])
        ramp = node(nt, 'ShaderNodeValToRGB')
        ramp.color_ramp.interpolation = 'CONSTANT'
        nt.links.new(u.outputs['X'], ramp.inputs[0])
        ramp.color_ramp.elements.remove(ramp.color_ramp.elements[1])
        ramp.color_ramp.elements[0].color = (1.0, 0.94, 0.80, 1.0)  # Headlights (warm white)
        for pos, col in [
            (0.25, (1.0, 0.03, 0.015, 1.0)),  # Tail lights (red)
            (0.50, (0.05, 0.95, 0.12, 1.0)),  # Status green
            (0.75, (1.0, 0.42, 0.02, 1.0)),   # Amber marker
        ]:
            ramp.color_ramp.elements.new(pos).color = col
        base = ramp.outputs[0]

    if name == 'Engines':
        noise_e = node(nt, 'ShaderNodeTexNoise')
        noise_e.inputs['Scale'].default_value = 16.0
        nt.links.new(uv.outputs['UV'], noise_e.inputs['Vector'])
        ramp = node(nt, 'ShaderNodeValToRGB')
        nt.links.new(noise_e.outputs['Fac'], ramp.inputs[0])
        ramp.color_ramp.elements[0].position = 0.15
        ramp.color_ramp.elements[0].color = (0.08, 0.62, 1.00, 1.0)
        ramp.color_ramp.elements[1].position = 0.85
        ramp.color_ramp.elements[1].color = (0.65, 0.96, 1.00, 1.0)
        base = ramp.outputs[0]

    # Micro-pitting and surface grain bump
    noise = node(nt, 'ShaderNodeTexNoise')
    noise.inputs['Scale'].default_value = 85.0
    noise.inputs['Detail'].default_value = 4.0
    nt.links.new(uv.outputs['UV'], noise.inputs['Vector'])
    bump = node(nt, 'ShaderNodeBump')
    bump.inputs['Strength'].default_value = 0.08 if asset else 0.02
    bump.inputs['Distance'].default_value = 0.004
    nt.links.new(noise.outputs['Fac'], bump.inputs['Height'])
    if norm:
        nt.links.new(norm, bump.inputs['Normal'])
    nt.links.new(bump.outputs['Normal'], bs.inputs['Normal'])

    if asset:
        # Organic multi-scale grime, dust & scratch streaks
        stretch = node(nt, 'ShaderNodeVectorMath')
        stretch.operation = 'MULTIPLY'
        stretch.inputs[1].default_value = (6.5, 1.1, 1.0)
        nt.links.new(uv.outputs['UV'], stretch.inputs[0])
        streak = node(nt, 'ShaderNodeTexNoise')
        streak.inputs['Scale'].default_value = 4.8
        streak.inputs['Detail'].default_value = 5.0
        streak.inputs['Roughness'].default_value = 0.65
        nt.links.new(stretch.outputs[0], streak.inputs['Vector'])
        shade = mathn(nt, 'ADD', mathn(nt, 'MULTIPLY', streak.outputs['Fac'], 0.26), 0.76)
        multiply = node(nt, 'ShaderNodeMixRGB')
        multiply.blend_type = 'MULTIPLY'
        multiply.inputs[0].default_value = 1.0
        nt.links.new(base, multiply.inputs[1])
        nt.links.new(shade, multiply.inputs[2])
        base = multiply.outputs[0]

        # Fine metallic edge scratches / wear specks
        scratch_vec = node(nt, 'ShaderNodeVectorMath')
        scratch_vec.operation = 'MULTIPLY'
        scratch_vec.inputs[1].default_value = (22.0, 2.5, 1.0)
        nt.links.new(uv.outputs['UV'], scratch_vec.inputs[0])
        scratch = node(nt, 'ShaderNodeTexNoise')
        scratch.inputs['Scale'].default_value = 16.0
        scratch.inputs['Detail'].default_value = 6.0
        nt.links.new(scratch_vec.outputs[0], scratch.inputs['Vector'])
        scratch_mask = mathn(nt, 'GREATER_THAN', scratch.outputs['Fac'], 0.72)
        mix_sc = node(nt, 'ShaderNodeMixRGB')
        mix_sc.blend_type = 'MIX'
        nt.links.new(mathn(nt, 'MULTIPLY', scratch_mask, 0.24), mix_sc.inputs[0])
        nt.links.new(base, mix_sc.inputs[1])
        hi_val = 0.98 if neutral else min(1.0, max(tint) * 1.65)
        mix_sc.inputs[2].default_value = (hi_val, hi_val, hi_val, 1.0)
        base = mix_sc.outputs[0]

    nt.links.new(base, bs.inputs['Base Color'])
    bs.inputs['Metallic'].default_value = metal
    if hasattr(rr, 'node'):
        nt.links.new(rr, bs.inputs['Roughness'])
    else:
        bs.inputs['Roughness'].default_value = rr

    # Bake tile onto a 4m plane
    bpy.ops.mesh.primitive_plane_add(size=4.0)
    plane = bpy.context.object
    plane.name = '_BakeTile'
    plane.data.materials.append(mat)
    target = node(nt, 'ShaderNodeTexImage')
    images = {}
    for kind, res in [('base', 1024 if asset else 64), ('normal', 1024 if asset else 64), ('orm', 512 if asset else 64)]:
        im = bpy.data.images.new(f'{name}_{kind}', res, res, alpha=False)
        if kind != 'base':
            im.colorspace_settings.name = 'Non-Color'
        target.image = im
        nt.nodes.active = target
        active(plane)
        if kind == 'normal':
            nt.links.new(bs.outputs['BSDF'], out.inputs['Surface'])
            bpy.ops.object.bake(type='NORMAL')
        else:
            em = node(nt, 'ShaderNodeEmission')
            if kind == 'base':
                nt.links.new(base, em.inputs['Color'])
            else:
                comb = node(nt, 'ShaderNodeCombineColor')
                for inp, val in zip(['Red', 'Green', 'Blue'], [ao, rr, metal]):
                    if hasattr(val, 'node'):
                        nt.links.new(val, comb.inputs[inp])
                    else:
                        comb.inputs[inp].default_value = val
                nt.links.new(comb.outputs[0], em.inputs['Color'])
            nt.links.new(em.outputs[0], out.inputs['Surface'])
            bpy.ops.object.bake(type='EMIT')
            nt.nodes.remove(em)
        fmt = 'JPEG' if asset and kind in ('base', 'normal') else 'PNG'
        im.filepath_raw = str(TEX / (f'{name}_{kind}.' + ('jpg' if fmt == 'JPEG' else 'png')))
        im.file_format = fmt
        im.save()
        images[kind] = im

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

    # Portable glTF node graph
    nt.nodes.clear()
    out = node(nt, 'ShaderNodeOutputMaterial')
    bs = node(nt, 'ShaderNodeBsdfPrincipled')
    nt.links.new(bs.outputs[0], out.inputs[0])
    maps = {}
    for kind, im in images.items():
        tex = node(nt, 'ShaderNodeTexImage')
        tex.image = im
        tex.label = f'Cycles baked {kind}'
        maps[kind] = tex

    if name == 'Paint':
        mul = node(nt, 'ShaderNodeMixRGB')
        mul.blend_type = 'MULTIPLY'
        mul.inputs[0].default_value = 1.0
        mul.inputs[2].default_value = (0.30, 0.33, 0.24, 1.0)
        mul.name = 'FactionPaintFactor'
        nt.links.new(maps['base'].outputs['Color'], mul.inputs[1])
        nt.links.new(mul.outputs[0], bs.inputs['Base Color'])
    else:
        nt.links.new(maps['base'].outputs['Color'], bs.inputs['Base Color'])

    normal = node(nt, 'ShaderNodeNormalMap')
    nt.links.new(maps['normal'].outputs['Color'], normal.inputs['Color'])
    nt.links.new(normal.outputs['Normal'], bs.inputs['Normal'])

    split = node(nt, 'ShaderNodeSeparateColor')
    nt.links.new(maps['orm'].outputs[0], split.inputs[0])
    nt.links.new(split.outputs['Green'], bs.inputs['Roughness'])
    nt.links.new(split.outputs['Blue'], bs.inputs['Metallic'])

    group = bpy.data.node_groups.get('glTF Material Output')
    if not group:
        group = bpy.data.node_groups.new('glTF Material Output', 'ShaderNodeTree')
        group.interface.new_socket(name='Occlusion', in_out='INPUT', socket_type='NodeSocketFloat')
    occ = node(nt, 'ShaderNodeGroup')
    occ.node_tree = group
    nt.links.new(split.outputs['Red'], occ.inputs['Occlusion'])

    if emission:
        nt.links.new(maps['base'].outputs[0], bs.inputs['Emission Color'])
        bs.inputs['Emission Strength'].default_value = emission
    if alpha < 1.0:
        bs.inputs['Alpha'].default_value = alpha
        mat.surface_render_method = 'BLENDED'
        bs.inputs['Coat Weight'].default_value = 0.55
    mat.diffuse_color = tuple(tint) + (alpha,)
    MATS[name] = mat
    print('BAKED', name, flush=True)
    return mat


# Bake the 9 materials matching sheet.jpg swatches
bake_material('Paint', 'metal_plate_02', (0.94, 0.94, 0.94), 0.08, 0.54, 0.34, neutral=True, secondary_asset='rusty_metal_03')
bake_material('BareMetal', 'metal_plate_02', (0.22, 0.23, 0.24), 0.78, 0.44, 0.36, secondary_asset='rusty_metal_03')
bake_material('DarkMetal', 'rusty_metal_03', (0.09, 0.095, 0.10), 0.68, 0.56, 0.32)
bake_material('Rubber', 'rubberized_track', (0.055, 0.058, 0.062), 0.03, 0.86, 0.44)
bake_material('Cockpit', 'fabric_leather_02', (0.22, 0.17, 0.13), 0.04, 0.68, 0.30)
bake_material('Weapons', 'rusty_metal_03', (0.12, 0.13, 0.14), 0.84, 0.38, 0.28)
bake_material('Glass', None, (0.48, 0.58, 0.62), 0.05, 0.08, 0.02, alpha=0.40)
bake_material('Engines', None, (0.15, 0.78, 1.00), 0.0, 0.15, 0.02, emission=9.5)
bake_material('Lamps', None, (1.0, 1.0, 1.0), 0.0, 0.15, 0.02, emission=7.5)


class Assembly:
    def __init__(self, name, budget=62000):
        self.name = name
        self.budget = budget
        self.v = []
        self.f = []
        self.mi = []
        self.smooth = []
        self.width = []
        self.materials = []

    def add(self, verts, faces, mat='Paint', bevel=0.016, smooth=False):
        idx = len(self.v)
        self.v.extend(verts)
        if mat not in self.materials:
            self.materials.append(mat)
        mid = self.materials.index(mat)
        for face in faces:
            self.f.append(tuple(idx + i for i in face))
            self.mi.append(mid)
            self.smooth.append(smooth)
            self.width.append(bevel)

    def finish(self, pivot=(0.0, 0.0, 0.0)):
        mesh = bpy.data.meshes.new(self.name)
        mesh.from_pydata(self.v, [], self.f)
        mesh.update()
        ob = bpy.data.objects.new(self.name, mesh)
        sc.collection.objects.link(ob)
        for m in self.materials:
            mesh.materials.append(MATS[m])
        for p, m, s in zip(mesh.polygons, self.mi, self.smooth):
            p.material_index = m
            p.use_smooth = s

        bm = bmesh.new()
        bm.from_mesh(mesh)
        layer = bm.edges.layers.float.new('bevel_weight_edge')
        bm.faces.ensure_lookup_table()
        for e in bm.edges:
            if len(e.link_faces) == 2 and e.calc_face_angle(0) > math.radians(25):
                e[layer] = min(self.width[f.index] for f in e.link_faces)
        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
        bm.to_mesh(mesh)
        bm.free()

        active(ob)
        mod = ob.modifiers.new('Manufactured edge radii', 'BEVEL')
        mod.width = 1.0
        mod.segments = 2
        mod.limit_method = 'WEIGHT'
        mod.harden_normals = True
        bpy.ops.object.modifier_apply(modifier=mod.name)

        count = sum(len(p.vertices) - 2 for p in ob.data.polygons)
        if count > self.budget:
            dec = ob.modifiers.new('Game triangle allocation', 'DECIMATE')
            dec.ratio = self.budget / count
            dec.use_collapse_triangulate = True
            bpy.ops.object.modifier_apply(modifier=dec.name)

        clean = bmesh.new()
        clean.from_mesh(ob.data)
        bmesh.ops.triangulate(clean, faces=list(clean.faces), quad_method='BEAUTY', ngon_method='BEAUTY')
        dead = [f for f in clean.faces if f.calc_area() < 1e-10]
        if dead:
            bmesh.ops.delete(clean, geom=dead, context='FACES')
        clean.to_mesh(ob.data)
        clean.free()
        ob.data.update()

        mod = ob.modifiers.new('Face-weighted normals', 'WEIGHTED_NORMAL')
        mod.keep_sharp = True
        mod.weight = 35
        bpy.ops.object.modifier_apply(modifier=mod.name)

        mesh = ob.data
        uv = mesh.uv_layers.new(name='UVMap')
        for p in mesh.polygons:
            n = p.normal
            axis = max(range(3), key=lambda i: abs(n[i]))
            mname = mesh.materials[p.material_index].name
            scale = 2.4 if mname == 'Paint' else 1.6
            for li in p.loop_indices:
                co = mesh.vertices[mesh.loops[li].vertex_index].co
                xy = (co.y, co.z) if axis == 0 else ((co.x, co.z) if axis == 1 else (co.x, co.y))
                uv.data[li].uv = (xy[0] / scale, xy[1] / scale)
                if mname == 'Lamps':
                    cx, cy, cz = p.center
                    swatch = 0.12 if cy < -1.5 else (0.375 if cy > 1.5 else 0.875)
                    uv.data[li].uv = (swatch + xy[0] * 0.001, 0.5 + xy[1] * 0.001)

        p = Vector(pivot)
        for v in mesh.vertices:
            v.co -= p
        ob.location = p
        mesh.update()
        return ob


def cube(A, loc, size, mat='Paint', bev=0.015, rot=None):
    x, y, z = size[0] / 2, size[1] / 2, size[2] / 2
    vs = [
        (-x, -y, -z), (x, -y, -z), (x, y, -z), (-x, y, -z),
        (-x, -y, z), (x, -y, z), (x, y, z), (-x, y, z)
    ]
    M = Matrix.Translation(loc)
    if rot:
        M = M @ Euler(rot).to_matrix().to_4x4()
    A.add([tuple(M @ Vector(v)) for v in vs],
          [(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)],
          mat, bev, False)


def prism_x(A, x_center, profile_yz, width_x, mat='BareMetal', bev=0.012):
    """Extrude a 2D (y, z) polygon along X centred at x_center with thickness width_x."""
    hx = width_x / 2.0
    n = len(profile_yz)
    area2 = sum(profile_yz[i][0] * profile_yz[(i + 1) % n][1] - profile_yz[(i + 1) % n][0] * profile_yz[i][1] for i in range(n))
    pts = list(profile_yz) if area2 > 0 else list(reversed(profile_yz))
    vs = [(x_center - hx, y, z) for y, z in pts] + [(x_center + hx, y, z) for y, z in pts]
    faces = [tuple(range(n - 1, -1, -1)), tuple(range(n, 2 * n))]
    for i in range(n):
        j = (i + 1) % n
        faces.append((i, j, n + j, n + i))
    A.add(vs, faces, mat, bev, False)


def cyl(A, loc, r1, r2, length, axis='Y', segs=20, mat='BareMetal', bev=0.007, smooth=True, rot=None):
    vs = []
    for z, r in [(-length / 2, r1), (length / 2, r2)]:
        for i in range(segs):
            a = 2 * math.pi * i / segs
            vs.append((r * math.cos(a), r * math.sin(a), z))
    R = Matrix.Identity(4)
    if axis == 'Y':
        R = Matrix.Rotation(math.pi / 2, 4, 'X')
    elif axis == 'X':
        R = Matrix.Rotation(math.pi / 2, 4, 'Y')
    if rot:
        R = Euler(rot).to_matrix().to_4x4() @ R
    M = Matrix.Translation(loc) @ R
    verts = [tuple(M @ Vector(v)) for v in vs]
    side_faces = [((i + 1) % segs, i, i + segs, (i + 1) % segs + segs) for i in range(segs)]
    cap_faces = [tuple(range(segs - 1, -1, -1)), tuple(range(segs, 2 * segs))]
    A.add(verts, side_faces, mat, bev, smooth)
    A.add(verts, cap_faces, mat, bev, False)


def frustum_ring_z(A, cx, cy, r_out_bot, r_in_bot, r_out_top, r_in_top, z_bot, z_top, segs=28, mat='DarkMetal', bev=0.008, smooth=True):
    """Tapered hollow conical ring/shroud along Z around (cx, cy)."""
    vs = []
    for z, ro, ri in [(z_bot, r_out_bot, r_in_bot), (z_top, r_out_top, r_in_top)]:
        for r in (ro, ri):
            for i in range(segs):
                a = 2 * math.pi * i / segs
                vs.append((cx + r * math.cos(a), cy + r * math.sin(a), z))
    side_faces = []
    cap_faces = []
    for i in range(segs):
        j = (i + 1) % segs
        side_faces.append((i, j, 2 * segs + j, 2 * segs + i))
        side_faces.append((1 * segs + j, 1 * segs + i, 3 * segs + i, 3 * segs + j))
        cap_faces.append((j, i, 1 * segs + i, 1 * segs + j))
        cap_faces.append((2 * segs + i, 2 * segs + j, 3 * segs + j, 3 * segs + i))
    A.add(vs, side_faces, mat, bev, smooth)
    A.add(vs, cap_faces, mat, bev, False)


def hollow_duct_y(A, front_out, front_in, back_out, back_in, mat_frame='BareMetal', mat_cavity='DarkMetal', bev=0.012):
    """Build a true hollow 4-sided duct opening toward -Y with beveled front rim and recessed dark cavity."""
    # Ensure consistent counter-clockwise winding when viewed from -Y (i.e. in X-Z plane: bottom-left, bottom-right, top-right, top-left)
    # 0..3: front_out, 4..7: front_in, 8..11: back_out, 12..15: back_in
    vs = list(front_out) + list(front_in) + list(back_out) + list(back_in)
    frame_faces = []
    cavity_faces = []
    for i in range(4):
        j = (i + 1) % 4
        # Front rim face (between front_out and front_in)
        frame_faces.append((i, j, 4 + j, 4 + i))
        # Outer wall (between front_out and back_out)
        frame_faces.append((j, i, 8 + i, 8 + j))
        # Rear cap of outer wall
        frame_faces.append((8 + i, 8 + j, 12 + j, 12 + i))
        # Inner tunnel wall (between front_in and back_in)
        cavity_faces.append((4 + i, 4 + j, 12 + j, 12 + i))
    # Recessed back wall of cavity
    cavity_faces.append((12, 13, 14, 15))
    A.add(vs, frame_faces, mat_frame, bev, False)
    A.add(vs, cavity_faces, mat_cavity, 0.006, False)


def beam(A, p1, p2, r1, r2=None, segs=12, mat='DarkMetal', bev=0.005, smooth=True):
    r2 = r1 if r2 is None else r2
    p1 = Vector(p1)
    p2 = Vector(p2)
    d = p2 - p1
    L = d.length
    if L < 1e-5:
        return
    q = d.to_track_quat('Z', 'Y').to_matrix().to_4x4()
    M = Matrix.Translation((p1 + p2) * 0.5) @ q
    vs = []
    for z, r in [(-L / 2, r1), (L / 2, r2)]:
        for i in range(segs):
            a = 2 * math.pi * i / segs
            vs.append(tuple(M @ Vector((r * math.cos(a), r * math.sin(a), z))))
    side_faces = [((i + 1) % segs, i, i + segs, (i + 1) % segs + segs) for i in range(segs)]
    cap_faces = [tuple(range(segs - 1, -1, -1)), tuple(range(segs, 2 * segs))]
    A.add(vs, side_faces, mat, bev, smooth)
    A.add(vs, cap_faces, mat, bev, False)


def loft(A, sections, mat='Paint', bev=0.018, caps=True):
    verts = [p for s in sections for p in s]
    n = len(sections[0])
    faces = []
    for k in range(len(sections) - 1):
        b = k * n
        for i in range(n):
            j = (i + 1) % n
            faces.append((b + i, b + j, b + n + j, b + n + i))
    if caps:
        faces.append(tuple(range(n - 1, -1, -1)))
        b = (len(sections) - 1) * n
        faces.append(tuple(range(b, b + n)))
    A.add(verts, faces, mat, bev, False)


def bolt(A, loc, normal=(1, 0, 0), r=0.015, h=0.012, mat='BareMetal'):
    n = Vector(normal).normalized()
    p1 = Vector(loc) - n * (h * 0.3)
    p2 = Vector(loc) + n * h
    beam(A, p1, p2, r, r * 0.90, segs=6, mat=mat, bev=0.002, smooth=False)


def storage_box_side(A, loc, size, sign_x, mat='Paint'):
    """Military side storage box / jerrycan case with recessed face, X-stamp ribs, strap & latches."""
    sx, sy, sz = size
    cx, cy, cz = loc
    cube(A, (cx, cy, cz), (sx, sy, sz), mat, 0.022)
    ox = cx + sign_x * (sx * 0.5 + 0.007)
    bt = 0.040
    cube(A, (ox, cy, cz + sz * 0.5 - bt * 0.5), (0.016, sy - 0.02, bt), mat, 0.006)
    cube(A, (ox, cy, cz - sz * 0.5 + bt * 0.5), (0.016, sy - 0.02, bt), mat, 0.006)
    cube(A, (ox, cy - sy * 0.5 + bt * 0.5, cz), (0.016, bt, sz - 0.02), mat, 0.006)
    cube(A, (ox, cy + sy * 0.5 - bt * 0.5, cz), (0.016, bt, sz - 0.02), mat, 0.006)
    dy = sy * 0.34
    dz = sz * 0.30
    beam(A, (ox, cy - dy, cz - dz), (ox, cy + dy, cz + dz), 0.013, 0.013, segs=6, mat=mat, bev=0.004, smooth=False)
    beam(A, (ox, cy - dy, cz + dz), (ox, cy + dy, cz - dz), 0.013, 0.013, segs=6, mat=mat, bev=0.004, smooth=False)
    cube(A, (ox + sign_x * 0.004, cy, cz), (0.018, 0.080, 0.070), mat, 0.006)
    cube(A, (cx + sign_x * 0.004, cy, cz + sz * 0.28), (sx + 0.016, sy + 0.016, 0.022), 'DarkMetal', 0.005)
    for oy in (-sy * 0.26, sy * 0.26):
        cube(A, (cx + sign_x * 0.005, cy + oy, cz), (sx + 0.024, 0.032, sz + 0.020), 'DarkMetal', 0.005)
        cube(A, (ox + sign_x * 0.010, cy + oy, cz + sz * 0.24), (0.020, 0.044, 0.070), 'BareMetal', 0.004)


def empty(name, loc, parent):
    o = bpy.data.objects.new(name, None)
    sc.collection.objects.link(o)
    o.empty_display_size = 0.22
    o.parent = parent
    o.location = loc
    return o


# ==============================================================================
# 1. BUILD MAIN HULL (Fixed body, cockpit, intakes, side skirts, boxes, antenna)
# ==============================================================================
H = Assembly('Hull', budget=62000)


def hull_section(y, w_bot, w_mid, w_top, z_bot, z_mid, z_top, z_deck):
    """8-point octagonal cross-section for main hull core."""
    return [
        (-w_bot, y, z_bot),
        (w_bot, y, z_bot),
        (w_mid, y, z_mid),
        (w_top, y, z_top),
        (w_top * 0.58, y, z_deck),
        (-w_top * 0.58, y, z_deck),
        (-w_top, y, z_top),
        (-w_mid, y, z_mid),
    ]


# Zone A1: Upper Overhanging Shark-Nose Hood Lip (Y = -3.24 to -2.65, Z = 1.24 to 1.45)
# Notice this only covers Z >= 1.24 at the front lip so the raked Front Air Intakes & Center Bumper
# below Z = 1.24 have ZERO intersecting green hull faces!
nose_hood_sections = [
    [
        (-0.98, -3.22, 1.24), (0.98, -3.22, 1.24),
        (1.04, -3.18, 1.28), (0.94, -3.24, 1.34),
        (0.54, -3.24, 1.37), (-0.54, -3.24, 1.37),
        (-0.94, -3.24, 1.34), (-1.04, -3.18, 1.28),
    ],
    [
        (-1.02, -2.65, 1.20), (1.02, -2.65, 1.20),
        (1.12, -2.65, 1.28), (1.02, -2.65, 1.40),
        (0.60, -2.65, 1.45), (-0.60, -2.65, 1.45),
        (-1.02, -2.65, 1.40), (-1.12, -2.65, 1.28),
    ],
]
loft(H, nose_hood_sections, 'Paint', 0.020)

# Zone A2: Main Forward Hull behind the Front Intake Fascia (Y = -2.68 to -0.92)
fwd_sections = [
    hull_section(-2.68, 0.82, 1.10, 1.02, 0.72, 1.08, 1.40, 1.45),
    hull_section(-2.10, 0.88, 1.16, 1.06, 0.70, 1.12, 1.49, 1.56),
    hull_section(-1.45, 0.92, 1.20, 1.10, 0.68, 1.16, 1.58, 1.65),
    hull_section(-0.92, 0.94, 1.22, 1.12, 0.66, 1.18, 1.64, 1.70),
]
loft(H, fwd_sections, 'Paint', 0.022)

# Zone B: Under-Cockpit Lower Hull Tub (Y = -0.95 to +0.10, Z = 0.66 to 1.04)
mid_under_sections = [
    hull_section(-0.95, 0.94, 1.22, 1.16, 0.66, 0.88, 1.02, 1.04),
    hull_section(-0.40, 0.94, 1.24, 1.18, 0.66, 0.88, 1.02, 1.04),
    hull_section(0.12, 0.96, 1.24, 1.18, 0.66, 0.88, 1.02, 1.04),
]
loft(H, mid_under_sections, 'Paint', 0.020)

# Left & Right Armored Cockpit Side Coamings / Cut-Down Doors (Y = -0.95 to +0.08)
for sign in (-1, 1):
    coaming_sec = [
        [
            (sign * 0.92, y, 1.00),
            (sign * 1.22, y, 1.00),
            (sign * 1.24, y, 1.24),
            (sign * 1.14, y, z_top),
            (sign * 0.94, y, z_top),
        ] if sign > 0 else [
            (sign * 1.22, y, 1.00),
            (sign * 0.92, y, 1.00),
            (sign * 0.94, y, z_top),
            (sign * 1.14, y, z_top),
            (sign * 1.24, y, 1.24),
        ]
        for y, z_top in [(-0.95, 1.66), (-0.68, 1.47), (-0.18, 1.47), (0.08, 1.68)]
    ]
    loft(H, coaming_sec, 'Paint', 0.018)

# Zone C: Cabin Arch / Gun Superstructure & Rear Hull (Y = +0.02 to +3.10)
# Notice the cabin arch rises right behind the cockpit at Y = +0.02..+0.25 up to Z = 2.04,
# matching the exact B-pillar / arch position in sheet.jpg Side View!
aft_sections = [
    hull_section(0.02, 0.96, 1.24, 1.08, 0.67, 1.20, 1.66, 1.70),
    hull_section(0.24, 0.96, 1.26, 1.02, 0.68, 1.24, 1.94, 2.04),
    hull_section(0.88, 0.98, 1.28, 1.02, 0.72, 1.28, 1.94, 2.03),
    hull_section(1.16, 1.00, 1.28, 1.08, 0.74, 1.32, 1.84, 1.88),
    hull_section(2.52, 0.98, 1.28, 1.08, 0.74, 1.32, 1.84, 1.88),
    hull_section(2.92, 0.90, 1.18, 0.96, 0.76, 1.26, 1.70, 1.74),
    hull_section(3.10, 0.82, 1.08, 0.88, 0.80, 1.18, 1.48, 1.52),
]
loft(H, aft_sections, 'Paint', 0.022)


# ==============================================================================
# 2. FLARING FRONT & REAR SPONSONS, WAIST SKIRTS & CUSTOM POLYGONAL ARMOR PLATES
# ==============================================================================
for sign in (-1, 1):
    def sp_ring(y, x_in, x_out, z_bot, z_mid, z_top):
        pts = [
            (sign * x_in, y, z_bot),
            (sign * x_out, y, z_bot + 0.03),
            (sign * x_out, y, z_mid),
            (sign * (x_out - 0.09), y, z_top),
            (sign * x_in, y, z_top + 0.04),
        ]
        return pts if sign > 0 else list(reversed(pts))

    # Front Flaring Sponson (Y = -2.55 to -0.62, max width X = ±1.48)
    front_sponson = [
        sp_ring(-2.55, 0.92, 1.12, 0.73, 1.10, 1.24),
        sp_ring(-2.12, 0.98, 1.44, 0.74, 1.18, 1.32),
        sp_ring(-1.45, 1.02, 1.48, 0.74, 1.26, 1.40),
        sp_ring(-0.90, 1.04, 1.46, 0.72, 1.18, 1.32),
        sp_ring(-0.62, 1.04, 1.26, 0.68, 1.14, 1.25),
    ]
    loft(H, front_sponson, 'Paint', 0.020)

    # Mid-Waist Lower Side Skirt Block (Y = -0.66 to +0.72, X = ±1.26)
    waist_skirt = [
        sp_ring(-0.66, 0.96, 1.26, 0.66, 1.14, 1.24),
        sp_ring(0.22, 0.96, 1.26, 0.66, 1.14, 1.24),
        sp_ring(0.72, 0.98, 1.30, 0.68, 1.18, 1.28),
    ]
    loft(H, waist_skirt, 'Paint', 0.018)

    # Rear Flaring Sponson (Y = +0.68 to +3.10, max width X = ±1.48)
    rear_sponson = [
        sp_ring(0.68, 0.98, 1.30, 0.70, 1.20, 1.34),
        sp_ring(1.08, 1.02, 1.48, 0.74, 1.36, 1.48),
        sp_ring(2.58, 1.02, 1.48, 0.74, 1.36, 1.48),
        sp_ring(2.90, 0.96, 1.38, 0.76, 1.28, 1.40),
        sp_ring(3.10, 0.86, 1.20, 0.80, 1.18, 1.30),
    ]
    loft(H, rear_sponson, 'Paint', 0.020)

    # Continuous Dark Rubber Hover-Skirt along bottom perimeter (RUBBER SKIRT swatch)
    rubber_skirt = [
        sp_ring(-2.52, 0.90, 1.13, 0.68, 0.76, 0.79),
        sp_ring(-2.10, 0.98, 1.45, 0.70, 0.78, 0.81),
        sp_ring(-1.45, 1.00, 1.49, 0.70, 0.78, 0.81),
        sp_ring(-0.90, 1.00, 1.47, 0.68, 0.76, 0.79),
        sp_ring(-0.62, 0.96, 1.27, 0.62, 0.73, 0.76),
        sp_ring(0.62, 0.96, 1.27, 0.62, 0.73, 0.76),
        sp_ring(1.08, 1.00, 1.49, 0.70, 0.78, 0.81),
        sp_ring(2.58, 1.00, 1.49, 0.70, 0.78, 0.81),
        sp_ring(3.08, 0.86, 1.21, 0.74, 0.81, 0.84),
    ]
    loft(H, rubber_skirt, 'Rubber', 0.012)

    # Custom Polygonal Bolted Bare-Metal Side Armor Plates (matching sheet.jpg 1-to-1!)
    # 1) Front Sponson Forward Trapezoidal Armor Plate (Y = -2.08 to -1.47)
    prism_x(H, sign * 1.475, [
        (-2.08, 0.82), (-1.47, 0.82), (-1.47, 1.23), (-2.08, 1.13)
    ], 0.045, 'BareMetal', 0.010)
    for by, bz_top in [(-2.00, 1.10), (-1.77, 1.14), (-1.54, 1.18)]:
        bolt(H, (sign * 1.50, by, 0.86), (sign, 0, 0), 0.014, 0.010)
        bolt(H, (sign * 1.50, by, bz_top), (sign, 0, 0), 0.014, 0.010)

    # 2) Front Sponson Aft Trapezoidal Armor Plate (Y = -1.42 to -0.92)
    prism_x(H, sign * 1.47, [
        (-1.42, 0.82), (-0.92, 0.80), (-0.92, 1.15), (-1.42, 1.23)
    ], 0.045, 'BareMetal', 0.010)
    for by, bz_top in [(-1.35, 1.18), (-1.00, 1.12)]:
        bolt(H, (sign * 1.495, by, 0.85), (sign, 0, 0), 0.014, 0.010)
        bolt(H, (sign * 1.495, by, bz_top), (sign, 0, 0), 0.014, 0.010)

    # 3) Mid-Hull Waist Bolted Side Skirt Plate (Y = -0.62 to +0.18, Z = 0.78 to 1.12)
    prism_x(H, sign * 1.26, [
        (-0.62, 0.78), (0.18, 0.78), (0.18, 1.12), (-0.62, 1.12)
    ], 0.045, 'BareMetal', 0.010)
    for by in (-0.54, -0.32, -0.10, 0.11):
        bolt(H, (sign * 1.285, by, 0.83), (sign, 0, 0), 0.014, 0.010)
        bolt(H, (sign * 1.285, by, 1.07), (sign, 0, 0), 0.014, 0.010)
        bolt(H, (sign * 1.28, by, 0.69), (sign, 0, 0), 0.013, 0.009)

    # 4) Mid-Hull Recessed Side Louvered Cooling Vent (Y = +0.23 to +0.62, Z = 0.78 to 1.12)
    cube(H, (sign * 1.23, 0.425, 0.95), (0.06, 0.36, 0.32), 'DarkMetal', 0.008)
    for lz in (0.83, 0.91, 0.99, 1.07):
        cube(H, (sign * 1.258, 0.425, lz), (0.035, 0.32, 0.032), 'BareMetal', 0.004)

    # 5) Rear Sponson Forward Angled Armor Plate (Y = +1.05 to +1.50)
    prism_x(H, sign * 1.475, [
        (1.05, 0.84), (1.50, 0.84), (1.50, 1.34), (1.05, 1.26)
    ], 0.045, 'BareMetal', 0.010)
    for by, bz_top in [(1.12, 1.23), (1.42, 1.29)]:
        bolt(H, (sign * 1.50, by, 0.89), (sign, 0, 0), 0.014, 0.010)
        bolt(H, (sign * 1.50, by, bz_top), (sign, 0, 0), 0.014, 0.010)

    # 6) Rear Sponson Main Rectangular Armor Plate (Y = +1.55 to +2.58, Z = 0.84 to 1.34)
    prism_x(H, sign * 1.475, [
        (1.55, 0.84), (2.58, 0.84), (2.58, 1.34), (1.55, 1.34)
    ], 0.045, 'BareMetal', 0.010)
    for by in (1.63, 1.92, 2.22, 2.50):
        bolt(H, (sign * 1.50, by, 0.89), (sign, 0, 0), 0.014, 0.010)
        bolt(H, (sign * 1.50, by, 1.29), (sign, 0, 0), 0.014, 0.010)
        bolt(H, (sign * 1.495, by, 0.74), (sign, 0, 0), 0.013, 0.009)

    # 7) Upper Side Door Latch Panel (Y = -0.58 to -0.18, Z = 1.28 to 1.43)
    prism_x(H, sign * 1.21, [
        (-0.58, 1.28), (-0.18, 1.28), (-0.18, 1.43), (-0.58, 1.43)
    ], 0.045, 'DarkMetal', 0.006)
    prism_x(H, sign * 1.225, [
        (-0.36, 1.32), (-0.23, 1.32), (-0.23, 1.39), (-0.36, 1.39)
    ], 0.035, 'BareMetal', 0.004)

    # 8) Signature Tilted Rectangular Bolted Bare-Metal Armor Patch sloping UP and BACKWARD ('/' in side.png)!
    # Exact rectangle tilted +28 deg: front-bottom (-0.04, 1.22) -> rear-bottom (0.38, 1.44) -> rear-top (0.28, 1.62) -> front-top (-0.14, 1.40)
    prism_x(H, sign * 1.20, [
        (-0.04, 1.22), (0.38, 1.44), (0.28, 1.62), (-0.14, 1.40)
    ], 0.055, 'BareMetal', 0.010)
    for by, bz in [(-0.02, 1.26), (0.33, 1.45), (0.26, 1.57), (-0.09, 1.38)]:
        bolt(H, (sign * 1.23, by, bz), (sign, 0, 0), 0.013, 0.009)

    # 9) Cabin Arch Angled Trapezoidal Side Window & Beveled Frame directly above the diagonal patch!
    prism_x(H, sign * 1.07, [
        (-0.04, 1.56), (0.42, 1.72), (0.42, 1.93), (0.14, 1.93)
    ], 0.055, 'BareMetal', 0.008)
    prism_x(H, sign * 1.08, [
        (0.02, 1.60), (0.38, 1.74), (0.38, 1.89), (0.17, 1.89)
    ], 0.045, 'Glass', 0.004)


# ==============================================================================
# 3. TRUE HOLLOW RAKED FRONT AIR INTAKES & CENTER BUMPER GRILLE (Zero overlap!)
# ==============================================================================
# Central Nose Bumper & Recessed 4-Slot Vertical Grille (X = -0.44 to +0.44, Y = -3.16 at top to -2.84 at chin)
hollow_duct_y(
    H,
    front_out=[(-0.44, -2.84, 0.72), (0.44, -2.84, 0.72), (0.42, -3.16, 1.18), (-0.42, -3.16, 1.18)],
    front_in=[(-0.36, -2.86, 0.78), (0.36, -2.86, 0.78), (0.35, -3.11, 1.11), (-0.35, -3.11, 1.11)],
    back_out=[(-0.46, -2.65, 0.72), (0.46, -2.65, 0.72), (0.44, -2.88, 1.20), (-0.44, -2.88, 1.20)],
    back_in=[(-0.36, -2.72, 0.78), (0.36, -2.72, 0.78), (0.35, -2.96, 1.11), (-0.35, -2.96, 1.11)],
    mat_frame='BareMetal',
    mat_cavity='DarkMetal',
    bev=0.014,
)
# 4 Vertical Armored Grille Ribs inside the hollow Center Bumper Cavity
for gx in (-0.22, -0.075, 0.075, 0.22):
    beam(H, (gx, -2.84, 0.78), (gx, -3.09, 1.10), 0.022, 0.022, 6, 'BareMetal', 0.004, False)

# Left & Right True Hollow Trapezoidal Front Air Intakes (X = ±0.44 to ±1.02)
for sign in (-1, 1):
    # Define the 4 corners of the outer front rim, inner front mouth, outer back, and inner back cavity
    if sign > 0:
        f_out = [(0.44, -2.84, 0.72), (0.98, -2.78, 0.75), (1.02, -3.14, 1.24), (0.42, -3.22, 1.24)]
        f_in  = [(0.50, -2.86, 0.79), (0.91, -2.81, 0.81), (0.94, -3.10, 1.17), (0.49, -3.17, 1.17)]
        b_out = [(0.46, -2.65, 0.72), (1.04, -2.52, 0.74), (1.08, -2.68, 1.26), (0.44, -2.75, 1.26)]
        b_in  = [(0.50, -2.70, 0.79), (0.91, -2.64, 0.81), (0.94, -2.88, 1.17), (0.49, -2.94, 1.17)]
    else:
        f_out = [(-0.98, -2.78, 0.75), (-0.44, -2.84, 0.72), (-0.42, -3.22, 1.24), (-1.02, -3.14, 1.24)]
        f_in  = [(-0.91, -2.81, 0.81), (-0.50, -2.86, 0.79), (-0.49, -3.17, 1.17), (-0.94, -3.10, 1.17)]
        b_out = [(-1.04, -2.52, 0.74), (-0.46, -2.65, 0.72), (-0.44, -2.75, 1.26), (-1.08, -2.68, 1.26)]
        b_in  = [(-0.91, -2.64, 0.81), (-0.50, -2.70, 0.79), (-0.49, -2.94, 1.17), (-0.94, -2.88, 1.17)]

    hollow_duct_y(H, f_out, f_in, b_out, b_in, mat_frame='BareMetal', mat_cavity='DarkMetal', bev=0.014)

    # 6 Crisp Horizontal Louver Vanes inside each Hollow Intake Tunnel
    for k in range(6):
        t = (k + 0.5) / 6.0
        z_in = 0.80 + t * (1.16 - 0.80)
        y_in = -2.85 + t * (-3.16 - (-2.85))
        z_out = 0.82 + t * (1.16 - 0.82)
        y_out = -2.80 + t * (-3.09 - (-2.80))
        beam(H, (sign * 0.50, y_in + 0.02, z_in), (sign * 0.92, y_out + 0.02, z_out), 0.014, 0.014, 6, 'BareMetal', 0.003, False)

    # Recessed Tactical Headlights integrated cleanly into upper intake brow (Light_Head_L / Light_Head_R)
    cube(H, (sign * 0.74, -3.15, 1.27), (0.30, 0.10, 0.055), 'DarkMetal', 0.005)
    cube(H, (sign * 0.74, -3.19, 1.27), (0.24, 0.04, 0.035), 'Lamps', 0.002)

# Raised Center Hood Cowl & Recessed Hood Air Scoop (Y = -2.85 to -1.10)
hood_cowl = [
    [(-0.52, -2.82, 1.38), (0.52, -2.82, 1.38), (0.44, -2.82, 1.46), (-0.44, -2.82, 1.46)],
    [(-0.62, -2.25, 1.48), (0.62, -2.25, 1.48), (0.52, -2.25, 1.59), (-0.52, -2.25, 1.59)],
    [(-0.66, -1.15, 1.61), (0.66, -1.15, 1.61), (0.56, -1.15, 1.71), (-0.56, -1.15, 1.71)],
]
loft(H, hood_cowl, 'Paint', 0.016)
# Recessed Hood Scoop Intake Slot at Y = -2.35 (matches ORTHOGRAPHIC front view & 3/4 view!)
cube(H, (0.0, -2.32, 1.55), (0.76, 0.36, 0.06), 'Paint', 0.010, rot=(0.14, 0, 0))
cube(H, (0.0, -2.46, 1.52), (0.68, 0.14, 0.045), 'DarkMetal', 0.004, rot=(0.14, 0, 0))
for sx in (-0.22, 0.0, 0.22):
    cube(H, (sx, -2.47, 1.52), (0.03, 0.12, 0.038), 'BareMetal', 0.003, rot=(0.14, 0, 0))

# Cowl Ventilator Louvers right ahead of windscreen (Y = -1.04, visible in Top View)
cube(H, (0.0, -1.04, 1.69), (0.78, 0.15, 0.035), 'DarkMetal', 0.005)
for lx in [i * 0.055 for i in range(-6, 7)]:
    cube(H, (lx, -1.04, 1.705), (0.024, 0.12, 0.018), 'BareMetal', 0.003)

# Left & Right Trapezoidal Hood Latch Plates ahead of windscreen (X = ±0.78, Y = -1.36)
for sign in (-1, 1):
    cube(H, (sign * 0.78, -1.36, 1.61), (0.26, 0.34, 0.035), 'BareMetal', 0.008, rot=(0.12, -sign * 0.12, 0))
    for dy in (-0.11, 0.11):
        bolt(H, (sign * 0.78, -1.36 + dy, 1.635), (0, 0, 1), 0.013, 0.009)


# ==============================================================================
# 4. ARMORED WINDSCREEN & OPEN-TOP TWO-CREW COCKPIT (DRIVER & GUNNER STATIONS)
# ==============================================================================
# Windscreen Lower Base Frame & Raked Pillars (Y = -0.96 to -0.45, Z = 1.66 to 1.95)
cube(H, (0.0, -0.92, 1.69), (1.74, 0.10, 0.06), 'BareMetal', 0.008)
cube(H, (0.0, -0.72, 1.93), (1.52, 0.05, 0.035), 'BareMetal', 0.006)
beam(H, (0.0, -0.92, 1.68), (0.0, -0.72, 1.94), 0.022, 0.020, 8, 'BareMetal')
for sign in (-1, 1):
    beam(H, (sign * 0.85, -0.90, 1.68), (sign * 0.75, -0.72, 1.94), 0.026, 0.022, 8, 'BareMetal')
    beam(H, (sign * 0.75, -0.72, 1.93), (sign * 0.96, -0.44, 1.50), 0.022, 0.020, 8, 'BareMetal')
    beam(H, (sign * 0.85, -0.90, 1.68), (sign * 0.96, -0.44, 1.50), 0.026, 0.024, 8, 'BareMetal')
    cube(H, (sign * 0.39, -0.82, 1.81), (0.73, 0.022, 0.27), 'Glass', 0.003, rot=(-0.62, 0, 0))
    cube(H, (sign * 0.86, -0.66, 1.71), (0.022, 0.35, 0.22), 'Glass', 0.003, rot=(-0.38, sign * 0.26, 0))

# Cockpit Floor & Anti-Slip Tread Ribs
cube(H, (0.0, -0.38, 1.03), (1.82, 1.12, 0.06), 'DarkMetal', 0.008)
for ry in [i * 0.12 for i in range(-6, 2)]:
    cube(H, (0.0, ry, 1.065), (1.72, 0.028, 0.014), 'BareMetal', 0.003)

# Dashboard Console & Instruments (Y = -0.88 to -0.60, Z = 1.15 to 1.64)
cube(H, (0.0, -0.76, 1.40), (1.78, 0.34, 0.40), 'DarkMetal', 0.014)
cube(H, (0.0, -0.64, 1.54), (1.74, 0.22, 0.15), 'Cockpit', 0.012)
cube(H, (0.44, -0.62, 1.60), (0.46, 0.20, 0.11), 'DarkMetal', 0.008)
cube(H, (0.44, -0.53, 1.56), (0.40, 0.03, 0.08), 'Lamps', 0.003)
cube(H, (0.0, -0.58, 1.49), (0.32, 0.08, 0.20), 'BareMetal', 0.008, rot=(-0.25, 0, 0))
cube(H, (0.0, -0.54, 1.49), (0.26, 0.03, 0.15), 'Glass', 0.003, rot=(-0.25, 0, 0))
beam(H, (-0.28, -0.55, 1.55), (-0.64, -0.55, 1.55), 0.015, 0.015, 10, 'BareMetal')
beam(H, (-0.28, -0.62, 1.55), (-0.28, -0.55, 1.55), 0.015, 0.015, 10, 'BareMetal')
beam(H, (-0.64, -0.62, 1.55), (-0.64, -0.55, 1.55), 0.015, 0.015, 10, 'BareMetal')

# Driver Steering Column & Tactical Twin-Grip Yoke (at Left seat X = +0.44)
beam(H, (0.44, -0.64, 1.46), (0.44, -0.44, 1.58), 0.034, 0.028, 12, 'DarkMetal')
cube(H, (0.44, -0.43, 1.58), (0.28, 0.05, 0.09), 'BareMetal', 0.008, rot=(-0.38, 0, 0))
for gx in (0.30, 0.58):
    beam(H, (gx, -0.46, 1.50), (gx, -0.40, 1.66), 0.020, 0.018, 10, 'Rubber')

# Driver Foot Pedals (at X = +0.53 and +0.35)
for px in (0.35, 0.53):
    cube(H, (px, -0.78, 1.13), (0.07, 0.04, 0.12), 'BareMetal', 0.004, rot=(0.45, 0, 0))

# Center Console & Pintle Support Pedestal between seats
cube(H, (0.0, -0.32, 1.18), (0.24, 0.78, 0.26), 'DarkMetal', 0.012)
cube(H, (0.0, -0.38, 1.32), (0.18, 0.42, 0.05), 'BareMetal', 0.006)
for tx in (-0.04, 0.04):
    beam(H, (tx, -0.40, 1.32), (tx, -0.35, 1.44), 0.010, 0.010, 8, 'BareMetal')
cyl(H, (0.0, -0.35, 1.44), 0.018, 0.018, 0.12, 'X', 10, 'Rubber')

# Two Detailed Military Bucket Seats (Driver at X = +0.44, Gunner/Co-Driver at X = -0.44)
for sx in (-0.44, 0.44):
    cube(H, (sx, -0.22, 1.11), (0.44, 0.44, 0.12), 'DarkMetal', 0.010)
    cube(H, (sx, -0.24, 1.20), (0.46, 0.48, 0.09), 'Cockpit', 0.016)
    for bx in (-0.21, 0.21):
        cube(H, (sx + bx, -0.24, 1.24), (0.08, 0.46, 0.10), 'Cockpit', 0.014)
    cube(H, (sx, 0.00, 1.46), (0.48, 0.10, 0.52), 'DarkMetal', 0.014, rot=(-0.18, 0, 0))
    cube(H, (sx, -0.04, 1.45), (0.42, 0.09, 0.46), 'Cockpit', 0.014, rot=(-0.18, 0, 0))
    for bx in (-0.21, 0.21):
        cube(H, (sx + bx, -0.06, 1.45), (0.08, 0.14, 0.44), 'Cockpit', 0.012, rot=(-0.18, 0, 0))
    cube(H, (sx, 0.06, 1.76), (0.28, 0.10, 0.16), 'Cockpit', 0.014, rot=(-0.12, 0, 0))
    for hx in (-0.10, 0.10):
        cube(H, (sx + hx, -0.08, 1.46), (0.045, 0.04, 0.42), 'Rubber', 0.004, rot=(-0.18, 0, 0))


# ==============================================================================
# 5. REAR DECK, STORAGE BOXES (4 SIDE + 2 TOP) & ANTENNA ARRAY
# ==============================================================================
# Recessed Central Rear Deck Trench (Y = +1.05 to +2.68, X = -0.26 to +0.26)
cube(H, (0.0, 1.88, 1.83), (0.54, 1.56, 0.12), 'DarkMetal', 0.010)
for ry in (1.28, 1.58, 1.88, 2.18, 2.48):
    cube(H, (0.0, ry, 1.88), (0.48, 0.04, 0.03), 'BareMetal', 0.004)
cube(H, (0.0, 2.76, 1.82), (0.26, 0.22, 0.05), 'BareMetal', 0.006)

# 4 Side-Mounted Military Storage Boxes (2 on Left +X, 2 on Right -X, matching sheet.jpg!)
for sign in (-1, 1):
    # Forward Side Storage Box (Y = +1.24 to +1.86)
    storage_box_side(H, (sign * 1.12, 1.55, 1.72), (0.34, 0.60, 0.42), sign, 'Paint')
    # Aft Side Storage Box (Y = +1.96 to +2.58)
    storage_box_side(H, (sign * 1.12, 2.27, 1.72), (0.34, 0.60, 0.42), sign, 'Paint')

    # Top-Mounted Strapped Cargo Box on Rear Deck (visible in Side View & Top View!)
    cube(H, (sign * 0.56, 1.76, 1.93), (0.48, 0.86, 0.16), 'Paint', 0.016)
    for sx_off in (-0.14, 0.14):
        cube(H, (sign * 0.56 + sx_off, 1.76, 1.94), (0.04, 0.90, 0.17), 'DarkMetal', 0.005)
        cube(H, (sign * 0.56 + sx_off, 1.76, 2.03), (0.055, 0.10, 0.02), 'BareMetal', 0.003)

# Antenna Array on Rear-Left Deck (+X = +0.82, Y = +2.10 to +2.44)
cube(H, (0.82, 2.28, 1.90), (0.15, 0.40, 0.08), 'DarkMetal', 0.008)
cyl(H, (0.82, 2.40, 1.98), 0.034, 0.030, 0.10, 'Z', 12, 'BareMetal', 0.004)
cyl(H, (0.82, 2.40, 2.07), 0.022, 0.022, 0.08, 'Z', 12, 'DarkMetal', 0.003)
cyl(H, (0.82, 2.40, 2.35), 0.012, 0.006, 0.50, 'Z', 10, 'BareMetal', 0.002)
cyl(H, (0.82, 2.14, 1.97), 0.026, 0.022, 0.08, 'Z', 12, 'BareMetal', 0.004)
cyl(H, (0.82, 2.14, 2.14), 0.010, 0.005, 0.26, 'Z', 10, 'BareMetal', 0.002)

# Roof Turret Ring Bearing Collar & Hatch Details on Cabin Arch (Y = +0.26, Z = 2.04)
frustum_ring_z(H, 0.0, 0.26, 0.45, 0.31, 0.42, 0.31, 2.02, 2.09, segs=24, mat='BareMetal', bev=0.008)
for i in range(12):
    a = 2 * math.pi * i / 12
    bolt(H, (0.38 * math.cos(a), 0.26 + 0.38 * math.sin(a), 2.09), (0, 0, 1), 0.013, 0.009)
for sign in (-1, 1):
    cube(H, (sign * 0.56, 0.58, 2.06), (0.16, 0.20, 0.08), 'BareMetal', 0.008)
    cube(H, (sign * 0.56, 0.48, 2.06), (0.12, 0.03, 0.045), 'Glass', 0.003)


# ==============================================================================
# 6. REAR EXHAUST THRUSTERS & TAIL LIGHTS (REAR VIEW in sheet.jpg, zero overlap!)
# ==============================================================================
cube(H, (0.0, 3.13, 1.12), (1.14, 0.08, 0.54), 'DarkMetal', 0.012)
cube(H, (0.0, 3.16, 0.84), (1.44, 0.14, 0.10), 'BareMetal', 0.012)
for sign in (-1, 1):
    cube(H, (sign * 0.52, 3.20, 0.86), (0.34, 0.10, 0.12), 'DarkMetal', 0.010)

for sx in (-0.26, 0.26):
    cube(H, (sx, 3.16, 1.12), (0.28, 0.08, 0.46), 'BareMetal', 0.010)
    for nz in (1.00, 1.24):
        cyl(H, (sx, 3.20, nz), 0.102, 0.090, 0.07, 'Y', 24, 'DarkMetal', 0.005)
        cyl(H, (sx, 3.23, nz), 0.076, 0.066, 0.04, 'Y', 24, 'BareMetal', 0.004)
        cyl(H, (sx, 3.245, nz), 0.056, 0.056, 0.015, 'Y', 20, 'Engines', 0.002)

for sign in (-1, 1):
    cube(H, (sign * 0.76, 3.12, 1.32), (0.26, 0.10, 0.34), 'BareMetal', 0.010, rot=(0.12, 0, sign * 0.10))
    cube(H, (sign * 0.76, 3.15, 1.32), (0.20, 0.07, 0.28), 'DarkMetal', 0.006, rot=(0.12, 0, sign * 0.10))
    for lz in (1.23, 1.32, 1.41):
        cube(H, (sign * 0.76, 3.185, lz), (0.15, 0.03, 0.034), 'Lamps', 0.003, rot=(0.12, 0, sign * 0.10))

FAN_POS = [
    ('Fan_FL', (0.82, -1.55, 0.58)),
    ('Fan_FR', (-0.82, -1.55, 0.58)),
    ('Fan_RL', (0.82, 1.82, 0.58)),
    ('Fan_RR', (-0.82, 1.82, 0.58)),
]
for fname, (fx, fy, fz) in FAN_POS:
    frustum_ring_z(H, fx, fy, 0.58, 0.48, 0.62, 0.50, 0.67, 0.76, segs=24, mat='DarkMetal', bev=0.008)


# ==============================================================================
# 7. MOVING NODE: Turret_1 (Pintle Gun Traverse Base, spins about +Z Blender / +Y glTF)
# ==============================================================================
TURRET_PIVOT = (0.0, 0.26, 2.09)
T1 = Assembly('Turret_1', budget=4500)
frustum_ring_z(T1, 0.0, 0.26, 0.35, 0.20, 0.32, 0.20, 2.08, 2.15, segs=24, mat='Weapons', bev=0.006)
cyl(T1, (0.0, 0.26, 2.17), 0.16, 0.13, 0.07, 'Z', 18, 'Weapons', 0.006)
for sx in (-0.15, 0.15):
    cube(T1, (sx, 0.25, 2.24), (0.045, 0.18, 0.15), 'Weapons', 0.006, rot=(0.12, 0, 0))
    cyl(T1, (sx, 0.24, 2.30), 0.040, 0.040, 0.06, 'X', 14, 'BareMetal', 0.004)
for sx in (-0.22, 0.22):
    cube(T1, (sx, 0.25, 2.18), (0.14, 0.24, 0.03), 'BareMetal', 0.004)


# ==============================================================================
# 8. MOVING NODE: Gun_1 (Pintle-Mounted Twin Gun, pitches about +X, child of Turret_1)
# ==============================================================================
GUN_PIVOT = (0.0, 0.24, 2.30)
G1 = Assembly('Gun_1', budget=7500)
cyl(G1, (0.0, 0.24, 2.30), 0.030, 0.030, 0.38, 'X', 14, 'BareMetal', 0.004)
cube(G1, (0.0, 0.22, 2.32), (0.34, 0.32, 0.08), 'Weapons', 0.008)
cube(G1, (0.0, 0.22, 2.43), (0.14, 0.20, 0.05), 'BareMetal', 0.005)
cube(G1, (0.0, 0.20, 2.49), (0.09, 0.15, 0.08), 'Weapons', 0.005)
cyl(G1, (0.0, 0.12, 2.49), 0.026, 0.026, 0.04, 'Y', 12, 'Glass', 0.002)

for sx in (-0.105, 0.105):
    cube(G1, (sx, 0.21, 2.33), (0.105, 0.60, 0.16), 'Weapons', 0.008)
    cube(G1, (sx, 0.19, 2.42), (0.075, 0.46, 0.032), 'BareMetal', 0.004)
    sign_g = 1 if sx > 0 else -1
    cube(G1, (sx + sign_g * 0.115, 0.24, 2.26), (0.12, 0.23, 0.17), 'BareMetal', 0.008)
    cube(G1, (sx + sign_g * 0.115, 0.24, 2.35), (0.13, 0.24, 0.03), 'Weapons', 0.004)
    cyl(G1, (sx, 0.55, 2.33), 0.026, 0.022, 0.12, 'Y', 12, 'BareMetal', 0.003)
    beam(G1, (sx, 0.63, 2.24), (sx, 0.61, 2.40), 0.014, 0.014, 10, 'Rubber')
    beam(G1, (sx, 0.57, 2.27), (sx, 0.63, 2.25), 0.012, 0.012, 8, 'Weapons')
    beam(G1, (sx, 0.57, 2.38), (sx, 0.61, 2.39), 0.012, 0.012, 8, 'Weapons')

    cyl(G1, (sx, -0.31, 2.37), 0.036, 0.034, 0.42, 'Y', 16, 'Weapons', 0.004)
    cyl(G1, (sx, -0.31, 2.28), 0.030, 0.028, 0.40, 'Y', 14, 'Weapons', 0.004)
    for cy in (-0.48, -0.34, -0.20):
        cyl(G1, (sx, cy, 2.37), 0.041, 0.041, 0.024, 'Y', 16, 'BareMetal', 0.003)

    cyl(G1, (sx, -0.94, 2.37), 0.021, 0.018, 0.88, 'Y', 14, 'Weapons', 0.003)
    cyl(G1, (sx, -0.91, 2.28), 0.017, 0.015, 0.82, 'Y', 12, 'BareMetal', 0.003)
    for by in (-0.74, -1.08):
        cube(G1, (sx, by, 2.325), (0.048, 0.042, 0.125), 'BareMetal', 0.004)
    cyl(G1, (sx, -1.39, 2.37), 0.027, 0.023, 0.11, 'Y', 14, 'BareMetal', 0.004)
    cyl(G1, (sx, -1.34, 2.28), 0.021, 0.019, 0.08, 'Y', 12, 'Weapons', 0.003)


# ==============================================================================
# 9. MOVING NODES: Fan_FL, Fan_FR, Fan_RL, Fan_RR (Tapered Conical Vectoring Lift Fans)
# ==============================================================================
FANS = []
for fname, fpivot in FAN_POS:
    FA = Assembly(fname, budget=3400)
    fx, fy, fz = fpivot
    frustum_ring_z(FA, fx, fy, 0.44, 0.37, 0.55, 0.46, 0.525, 0.69, segs=28, mat='DarkMetal', bev=0.008)
    frustum_ring_z(FA, fx, fy, 0.42, 0.36, 0.45, 0.38, 0.51, 0.535, segs=28, mat='BareMetal', bev=0.005)
    frustum_ring_z(FA, fx, fy, 0.36, 0.12, 0.39, 0.14, 0.50, 0.53, segs=24, mat='Engines', bev=0.004)
    cyl(FA, (fx, fy, 0.515), 0.35, 0.31, 0.025, 'Z', 24, 'Engines', 0.003)
    cyl(FA, (fx, fy, 0.55), 0.13, 0.08, 0.10, 'Z', 16, 'BareMetal', 0.005)
    for bi in range(12):
        ang = 2 * math.pi * bi / 12
        bx = fx + 0.25 * math.cos(ang)
        by = fy + 0.25 * math.sin(ang)
        cube(FA, (bx, by, 0.58), (0.22, 0.06, 0.016), 'BareMetal', 0.003, rot=(0.42 * math.sin(ang), 0.42 * math.cos(ang), ang))
    for vi in range(8):
        ang = 2 * math.pi * vi / 8
        vx = fx + 0.49 * math.cos(ang)
        vy = fy + 0.49 * math.sin(ang)
        cube(FA, (vx, vy, 0.60), (0.045, 0.045, 0.11), 'BareMetal', 0.004, rot=(0.28 * math.sin(ang), -0.28 * math.cos(ang), ang))
    FANS.append((fname, FA, fpivot))


# ==============================================================================
# 10. FINISH MESH ASSEMBLIES, PARENT HIERARCHY & CREATE CONTRACT EMPTIES
# ==============================================================================
hull = H.finish(pivot=(0.0, 0.0, 0.0))
turret_obj = T1.finish(pivot=TURRET_PIVOT)
gun_obj = G1.finish(pivot=GUN_PIVOT)
fan_objs = []
for fname, FA, fpivot in FANS:
    fobj = FA.finish(pivot=fpivot)
    fan_objs.append((fname, fobj, fpivot))

bpy.context.view_layer.update()

turret_obj.parent = hull
turret_obj.location = Vector(TURRET_PIVOT)

gun_obj.parent = turret_obj
gun_obj.location = Vector(GUN_PIVOT) - Vector(TURRET_PIVOT)

for fname, fobj, fpivot in fan_objs:
    fobj.parent = hull
    fobj.location = Vector(fpivot)

# Contract Empty Nodes
empty('Muzzle_1', tuple(Vector((0.0, -1.44, 2.33)) - Vector(GUN_PIVOT)), gun_obj)

empty('Seat_Driver', (0.44, -0.20, 1.26), hull)
empty('Seat_Gunner', (-0.44, -0.20, 1.26), hull)
empty('Hand_L', (0.58, -0.43, 1.58), hull)
empty('Hand_R', (0.30, -0.43, 1.58), hull)
empty('Foot_L', (0.53, -0.78, 1.13), hull)
empty('Foot_R', (0.35, -0.78, 1.13), hull)

empty('Thruster_1', (0.26, 3.25, 1.12), hull)
empty('Thruster_2', (-0.26, 3.25, 1.12), hull)

empty('Lift_1', (0.82, -1.55, 0.50), hull)
empty('Lift_2', (-0.82, -1.55, 0.50), hull)
empty('Lift_3', (0.82, 1.82, 0.50), hull)
empty('Lift_4', (-0.82, 1.82, 0.50), hull)

empty('Camera_Chase', (0.0, 8.50, 3.10), hull)
empty('Light_Head_L', (0.74, -3.19, 1.27), hull)
empty('Light_Head_R', (-0.74, -3.19, 1.27), hull)
empty('Light_Tail_L', (0.76, 3.19, 1.32), hull)
empty('Light_Tail_R', (-0.76, 3.19, 1.32), hull)

bpy.context.view_layer.update()
ASSET = [o for o in sc.objects if o.type in ('MESH', 'EMPTY')]
MOV = [turret_obj, gun_obj] + [f[1] for f in fan_objs]

# ==============================================================================
# 11. EXACT DIMENSIONAL CALIBRATION: 6.5m long, 3.0m wide, 2.1m high, hover 0.5m
# ==============================================================================
pts = [o.matrix_world @ v.co for o in ASSET if o.type == 'MESH' for v in o.data.vertices]
lo = Vector([min(v[i] for v in pts) for i in range(3)])
hi = Vector([max(v[i] for v in pts) for i in range(3)])
factor = Vector((3.0 / (hi.x - lo.x), 6.5 / (hi.y - lo.y), 2.1 / (hi.z - lo.z)))

for o in ASSET:
    o.location = Vector([o.location[i] * factor[i] for i in range(3)])
    if o.type == 'MESH':
        for v in o.data.vertices:
            v.co = Vector([v.co[i] * factor[i] for i in range(3)])
        o.data.update()
bpy.context.view_layer.update()

world_pts = [o.matrix_world @ v.co for o in ASSET if o.type == 'MESH' for v in o.data.vertices]
shift_x = (min(p.x for p in world_pts) + max(p.x for p in world_pts)) / 2.0
shift_y = (min(p.y for p in world_pts) + max(p.y for p in world_pts)) / 2.0
shift_z = min(p.z for p in world_pts) - 0.50
shift = Vector((shift_x, shift_y, shift_z))

for v in hull.data.vertices:
    v.co -= shift
for o in ASSET:
    if o.parent == hull:
        o.location -= shift
hull.data.update()
bpy.context.view_layer.update()

TRIS = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in ASSET if o.type == 'MESH')
print('GEOMETRY_TRIANGLES', TRIS, 'SCALE_FACTOR', [round(x, 4) for x in factor], flush=True)
if not (30000 <= TRIS <= 90000):
    print(f'WARNING: Triangle count {TRIS} outside 30k-90k target', flush=True)


def y_up(v):
    return [round(v.x, 5), round(v.z, 5), round(-v.y, 5)]


pts = [o.matrix_world @ v.co for o in ASSET if o.type == 'MESH' for v in o.data.vertices]
yp = [y_up(p) for p in pts]
low = [min(p[i] for p in yp) for i in range(3)]
high = [max(p[i] for p in yp) for i in range(3)]

PAINTS = [
    ('Field green', [0.30, 0.33, 0.24]),
    ('Desert sand', [0.62, 0.49, 0.31]),
    ('Naval grey', [0.35, 0.38, 0.42]),
    ('Olive drab', [0.20, 0.24, 0.14]),
    ('Arctic white', [0.80, 0.83, 0.85]),
    ('Gunmetal', [0.14, 0.16, 0.19]),
]

manifest = {
    'asset': 'Military hover recon speeder',
    'contract': 'hover/v1',
    'units': 'metres',
    'coordinate_system': {'forward': '+Z', 'up': '+Y', 'left': '+X'},
    'bounding_box': {
        'min': low,
        'max': high,
        'size': [round(b - a, 5) for a, b in zip(low, high)]
    },
    'triangles': TRIS,
    'materials': list(MATS.keys()),
    'nodes': {},
    'axes': {
        'Turret_1': [0, 1, 0],
        'Gun_1': [1, 0, 0],
        'Fan_FL': [0, 1, 0],
        'Fan_FR': [0, 1, 0],
        'Fan_RL': [0, 1, 0],
        'Fan_RR': [0, 1, 0],
    },
    'paints': [{'name': n, 'linear_rgb': c} for n, c in PAINTS],
    'sources': [
        {'id': a, 'license': 'CC0-1.0', 'url': 'https://polyhaven.com/a/' + a}
        for a in ['metal_plate_02', 'rusty_metal_03', 'rubberized_track', 'fabric_leather_02']
    ],
}

for o in ASSET:
    manifest['nodes'][o.name] = {
        'world_position': y_up(o.matrix_world.translation),
        'parent': o.parent.name if o.parent else None,
        'type': 'mesh' if o.type == 'MESH' else 'empty',
    }
    if o.name.startswith('Thruster_'):
        manifest['nodes'][o.name]['exhaust_direction'] = [0, 0, -1]

(ROOT / 'nodes.json').write_text(json.dumps(manifest, indent=2) + '\n')
print('BOUNDS_GLTF', manifest['bounding_box'], flush=True)


# ==============================================================================
# 12. EXPORT GLB (Before adding non-exported ground plane, lights & cameras)
# ==============================================================================
bpy.ops.object.select_all(action='DESELECT')
for o in ASSET:
    o.select_set(True)
bpy.context.view_layer.objects.active = hull

paint = MATS['Paint']
pnt = paint.node_tree
pBS = next(n for n in pnt.nodes if n.type == 'BSDF_PRINCIPLED')
pMix = pnt.nodes['FactionPaintFactor']
pImage = next(n for n in pnt.nodes if n.type == 'TEX_IMAGE' and n.image.name == 'Paint_base')

pnt.links.new(pImage.outputs['Color'], pBS.inputs['Base Color'])
GLB = ROOT / 'military-hover-speeder.glb'
bpy.ops.export_scene.gltf(
    filepath=str(GLB),
    export_format='GLB',
    use_selection=True,
    export_yup=True,
    export_apply=False,
    export_materials='EXPORT',
    export_image_format='AUTO',
    export_texcoords=True,
    export_normals=True,
    export_tangents=True,
    export_extras=True,
    export_cameras=False,
    export_lights=False,
)
pnt.links.new(pMix.outputs[0], pBS.inputs['Base Color'])


def patch_glb(path):
    data = path.read_bytes()
    jlen, jtype = struct.unpack_from('<II', data, 12)
    doc = json.loads(data[20:20 + jlen])
    rest = data[20 + jlen:]
    for m in doc.get('materials', []):
        if m.get('name') == 'Paint':
            m.setdefault('pbrMetallicRoughness', {})['baseColorFactor'] = [0.30, 0.33, 0.24, 1.0]
        if m.get('name') == 'Glass':
            m['alphaMode'] = 'BLEND'
            m['doubleSided'] = True
    payload = json.dumps(doc, separators=(',', ':')).encode()
    payload += b' ' * ((-len(payload)) % 4)
    path.write_bytes(
        struct.pack('<4sII', b'glTF', 2, 20 + len(payload) + len(rest))
        + struct.pack('<I4s', len(payload), b'JSON')
        + payload
        + rest
    )
    return doc


patch_glb(GLB)
if GLB.stat().st_size > 12000000:
    raise RuntimeError(f'GLB size exceeds 12 MB: {GLB.stat().st_size}')
print('EXPORT_OK', GLB.stat().st_size, 'bytes', flush=True)


# ==============================================================================
# 13. SOFT DAYLIGHT STUDIO STAGE, CLAY, PIVOT CHECK, FINAL, SIDE & PAINTS RENDERS
# ==============================================================================
bpy.ops.mesh.primitive_plane_add(size=180.0, location=(0.0, 0.0, 0.0))
ground = bpy.context.object
ground.name = '_StageGround'
gmat = bpy.data.materials.new('_StageGroundMat')
gmat.use_nodes = True
gbs = gmat.node_tree.nodes.get('Principled BSDF')
gbs.inputs['Base Color'].default_value = (0.58, 0.56, 0.52, 1.0)
gbs.inputs['Roughness'].default_value = 0.88
ground.data.materials.append(gmat)

world = bpy.data.worlds.new('Soft Daylight')
world.use_nodes = True
sc.world = world
bg = world.node_tree.nodes.get('Background')
bg.inputs[0].default_value = (0.72, 0.78, 0.86, 1.0)
bg.inputs[1].default_value = 0.65


def add_sun(name, pos, energy, color, angle):
    ld = bpy.data.lights.new(name, 'SUN')
    ld.energy = energy
    ld.color = color
    ld.angle = angle
    lo = bpy.data.objects.new(name, ld)
    sc.collection.objects.link(lo)
    lo.location = pos
    lo.rotation_euler = (-Vector(pos)).to_track_quat('-Z', 'Y').to_euler()
    return lo


add_sun('KeySun', (8.0, -11.0, 14.0), 3.4, (1.0, 0.97, 0.92), 0.16)
add_sun('SkyFill', (-10.0, -4.0, 9.0), 1.1, (0.76, 0.86, 1.0), 0.55)
add_sun('RimSun', (-4.0, 12.0, 8.0), 1.6, (0.92, 0.96, 1.0), 0.28)

for idx, (_, fobj, _) in enumerate(fan_objs):
    fpos = fobj.matrix_world.translation
    pl = bpy.data.lights.new(f'_FanGlow_{idx}', 'POINT')
    pl.energy = 45.0
    pl.color = (0.10, 0.74, 1.0)
    pl.shadow_soft_size = 0.32
    po = bpy.data.objects.new(f'_FanGlow_{idx}', pl)
    sc.collection.objects.link(po)
    po.location = (fpos.x, fpos.y, 0.34)

cam_data = bpy.data.cameras.new('QA_Camera')
cam = bpy.data.objects.new('QA_Camera', cam_data)
sc.collection.objects.link(cam)
sc.camera = cam
cam_data.lens = 50
cam_data.clip_end = 500.0

TARGET = Vector((0.0, 0.0, 1.38))


def set_hero_camera():
    cam_data.type = 'PERSP'
    cam_data.lens = 50
    cam.location = Vector((5.85, -6.95, 3.15))
    cam.rotation_euler = (TARGET - cam.location).to_track_quat('-Z', 'Y').to_euler()


def render(relpath, width=1920, height=1080, samples=32):
    sc.render.resolution_x = width
    sc.render.resolution_y = height
    sc.render.resolution_percentage = 100
    sc.cycles.samples = samples
    sc.render.filepath = str(ROOT / relpath)
    bpy.ops.render.render(write_still=True)
    print('RENDER_DONE', relpath, round(time.time() - START, 1), flush=True)


# 1) 30-degree moving-part pivot verification test
set_hero_camera()
initial_pos = {o.name: o.matrix_world.translation.copy() for o in MOV}
turret_obj.rotation_euler.z = math.radians(30)
gun_obj.rotation_euler.x = -math.radians(30)
for _, fobj, _ in fan_objs:
    fobj.rotation_euler.z = math.radians(30)
bpy.context.view_layer.update()
for o in [turret_obj] + [f[1] for f in fan_objs]:
    assert (o.matrix_world.translation - initial_pos[o.name]).length < 1e-5, f'Pivot drifted on {o.name}'
render('qa/pivots-30deg.png', 1280, 720, 20)
turret_obj.rotation_euler.z = 0.0
gun_obj.rotation_euler.x = 0.0
for _, fobj, _ in fan_objs:
    fobj.rotation_euler.z = 0.0
bpy.context.view_layer.update()

# 2) Clay render (same view as final, plain grey material)
set_hero_camera()
clay = bpy.data.materials.new('_QA_Clay')
clay.use_nodes = True
clay_bs = clay.node_tree.nodes.get('Principled BSDF')
clay_bs.inputs['Base Color'].default_value = (0.42, 0.42, 0.42, 1.0)
clay_bs.inputs['Roughness'].default_value = 0.62
sc.view_layers[0].material_override = clay
render('clay.png', 1920, 1080, 28)
sc.view_layers[0].material_override = None

# 3) Final beauty render (1920x1080 Cycles, 3/4 front view, soft daylight)
set_hero_camera()
render('final.png', 1920, 1080, 48)

# 4) Orthographic side view (side.png, nose pointing left just like sheet.jpg!)
cam_data.type = 'ORTHO'
cam_data.ortho_scale = 7.35
cam.location = Vector((12.0, 0.0, 1.45))
cam.rotation_euler = (Vector((0.0, 0.0, 1.45)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
render('side.png', 1920, 1080, 32)

# Extra QA angles (front, rear, top)
cam.location = Vector((0.0, -12.0, 1.45))
cam.rotation_euler = (Vector((0.0, 0.0, 1.45)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
cam_data.ortho_scale = 4.2
render('qa/front_ortho.png', 1280, 960, 24)

cam.location = Vector((0.0, 12.0, 1.45))
cam.rotation_euler = (Vector((0.0, 0.0, 1.45)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
cam_data.ortho_scale = 4.2
render('qa/rear_ortho.png', 1280, 960, 24)

cam.location = Vector((0.0, 0.0, 14.0))
cam.rotation_euler = (0.0, 0.0, -math.pi / 2)
cam_data.ortho_scale = 7.35
render('qa/top_ortho.png', 1600, 960, 24)

# 5) 3x2 grid of 6 paints (paints.png)
set_hero_camera()
paint_frames = []
for i, (pname, pcol) in enumerate(PAINTS):
    pMix.inputs[2].default_value = tuple(pcol) + (1.0,)
    render(f'qa/paint_{i}.png', 640, 360, 18)
    im = bpy.data.images.load(str(QA / f'paint_{i}.png'), check_existing=False)
    arr = np.array(im.pixels[:], dtype=np.float32).reshape(360, 640, 4)
    paint_frames.append(arr)
    bpy.data.images.remove(im)
pMix.inputs[2].default_value = (0.30, 0.33, 0.24, 1.0)

grid = np.concatenate([
    np.concatenate(paint_frames[3:6], axis=1),
    np.concatenate(paint_frames[0:3], axis=1),
], axis=0)
im = bpy.data.images.new('PaintGrid', 1920, 720, alpha=True)
im.pixels.foreach_set(grid.ravel())
im.filepath_raw = str(ROOT / 'paints.png')
im.file_format = 'PNG'
im.save()

# 6) Write 6-line NOTES.md
notes = [
    'Armed military hover recon speeder (6.5 m × 3.0 m × 2.1 m, hovering at 0.5 m): angular open-top two-crew hull, raked hollow front air intakes, 4 conical vectoring lift fans, bolted side skirts, rear pintle twin gun, 6 storage boxes and dual antenna array.',
    f'Triangles: {TRIS:,} across 7 mesh nodes (Hull, Turret_1, Gun_1, Fan_FL, Fan_FR, Fan_RL, Fan_RR) + 16 contract empties; GLB size: {GLB.stat().st_size / 1048576:.2f} MiB.',
    '9 materials: Paint (neutral baked detail + field-green baseColorFactor [0.30, 0.33, 0.24]), BareMetal, DarkMetal, Rubber, Cockpit, Weapons, Glass (alpha BLEND), Engines (cyan emissive) and Lamps (emissive).',
    'Texture sizes: Cycles GPU baked 1024×1024 base colour and normal maps, 512×512 ORM maps (64×64 for Glass/Engines/Lamps), shared across parts with world-scale tiling.',
    'Poly Haven CC0 ids: metal_plate_02, rusty_metal_03, rubberized_track, fabric_leather_02 (https://polyhaven.com/license).',
    'Differences from reference: pintle twin gun is centered on the roll-arch matching the top and 3/4 views (slightly offset in front/rear orthographic views); no text, numbers or faction insignia.',
]
(ROOT / 'NOTES.md').write_text('\n'.join(notes) + '\n')
print('BUILD_COMPLETE', round(time.time() - START, 1), flush=True)
