//! Whole-ship assembly from XBG7 part families.
//!
//! Project Sylpheed's capital ships are never stored as a single mesh. A ship is
//! modelled, then **split into destructible parts** — hull segments, bridge,
//! engines, integral turrets — each shipped as its own XBG7 resource inside a
//! `hidden/resource3d/Stage_SNN.xpr` container. The split lets the game hide a
//! part when the player destroys it (bridge, shield generator, thruster) and swap
//! in a `_break` variant.
//!
//! Each part's vertices are authored around the origin; the world placement lives
//! in the **primary composite scene graph** `e_rou_<id>` (see [`assemble_ship`]).
//! Its node hierarchy (`rou_<id>_root`→`…_front`/`…_rear`→`rou_<id>_bdy_NN`) names
//! the separate hull resources and carries their world TRS — the cross-resource
//! analogue of how [`mesh::node_transforms`] poses one model's sub-parts. The
//! `GN_*` nodes in the same composite are the Vessel hardpoint frames (bridge /
//! shield / engine / turret mounts, matching the IDXD recipe `Frame` names).
//!
//! Resource naming (learned from the retail stage containers):
//! - `<id>` is `[a-z]NNN` (`e106`, `f105`, `n050`). The leading letter is the
//!   faction: `e` = ADAN (enemy), `f` = TCAF (player side), `n` = neutral /
//!   structures / debris.
//! - A base part is `<id>_<name>` (`e106_bdy_01`, `e106_brg_01`, `f105_sld_02`).
//! - `_l` / `_m` / `_d`(`NN`) suffixes are lower level-of-detail copies; `_bNN` /
//!   `_dead` / `_break` are destruction geometry; `_joint` / `_open` / `_Near` are
//!   logic/animation nodes. All are skipped.
//! - `e_rou_<id>` is the primary composite; `e_rou_<id>_eng` / `_wep_*` are LOCAL
//!   detail rigs (their nodes are in their own frame, not ship-space) and are NOT
//!   used for placement.

use crate::mesh::{scene_world_nodes, xbg7_resource_names, ScenePart};
use std::collections::HashSet;

/// Which side a ship belongs to, from the first letter of its resource id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Faction {
    /// `e…` — ADAN (the enemy).
    Adan,
    /// `f…` — TCAF (the player's coalition).
    Tcaf,
    /// `n…` — neutral: stations, structures, asteroids, debris.
    Neutral,
    /// Anything else (`j…`, `s…`, …).
    Other,
}

impl Faction {
    fn from_id(id: &str) -> Faction {
        match id.as_bytes().first() {
            Some(b'e') => Faction::Adan,
            Some(b'f') => Faction::Tcaf,
            Some(b'n') => Faction::Neutral,
            _ => Faction::Other,
        }
    }

    /// Short human label.
    pub fn label(self) -> &'static str {
        match self {
            Faction::Adan => "ADAN",
            Faction::Tcaf => "TCAF",
            Faction::Neutral => "Neutral",
            Faction::Other => "Other",
        }
    }
}

/// A ship reconstructed from one XBG7 part family in a stage container.
#[derive(Debug, Clone)]
pub struct ShipModel {
    /// The family id, e.g. `e106` — matches a [`crate::game_data::Vessel`] whose
    /// `model` is `rou_e106`.
    pub id: String,
    /// Faction inferred from the id's leading letter.
    pub faction: Faction,
    /// Base geometry resource names to draw together, in directory order.
    pub parts: Vec<String>,
    /// Whether a composite scene graph (`e_rou_<id>`) exists — i.e. this is a
    /// real assembled ship. Families without one (fighter morph sets, shared
    /// turret/prop parts) have no placement data and would stack at the origin.
    pub has_composite: bool,
}

/// The `[a-z]NNN` id prefix of a resource name, if it has one (`e106_bdy_01`
/// → `e106`). Returns `None` for scene/logic resources (`e_rou_e106`) and
/// anything not starting with a letter + three digits.
pub fn ship_id_of(resource: &str) -> Option<&str> {
    let b = resource.as_bytes();
    if b.len() >= 4
        && b[0].is_ascii_lowercase()
        && b[1].is_ascii_digit()
        && b[2].is_ascii_digit()
        && b[3].is_ascii_digit()
    {
        // Reject a longer alnum run (`e1234…`): the id is exactly letter+3 digits,
        // followed by end-of-string or a non-alphanumeric (`_`).
        if b.get(4).is_none_or(|c| !c.is_ascii_alphanumeric()) {
            return Some(&resource[..4]);
        }
    }
    None
}

/// Whether a resource is a base geometry part (not a LOD copy, destruction
/// variant, or scene/logic node) that should be drawn in a whole-ship render.
pub fn is_base_part(resource: &str) -> bool {
    if ship_id_of(resource).is_none() {
        return false;
    }
    // Level-of-detail copies of a base part: a trailing `_l`/`_m`/`_d` token,
    // optionally with a number (`_d01`, `_l02`).
    if let Some(last) = resource.rsplit('_').next() {
        let mut cs = last.chars();
        if matches!(cs.next(), Some('l' | 'm' | 'd')) && cs.all(|c| c.is_ascii_digit()) {
            return false;
        }
    }
    // Destruction / logic geometry.
    if resource.contains("break")
        || resource.contains("dead")
        || resource.contains("_joint")
        || resource.contains("_open")
        || resource.contains("_Near")
    {
        return false;
    }
    // Break-piece geometry carries a bare `b` / `bNN` token (`e106_brg_01_b_02`,
    // `e108_eng_01_b01`). `bdy` etc. are safe — only an exact `b`-then-digits token
    // counts.
    if resource.split('_').skip(1).any(|t| {
        t == "b" || (t.starts_with('b') && t.len() >= 2 && t[1..].bytes().all(|c| c.is_ascii_digit()))
    }) {
        return false;
    }
    true
}

/// Group every base part in an XPR2 container into whole ships, keyed by id.
///
/// Ships are returned sorted by id. A stage container also holds shared turret /
/// prop models as their own ids — every group with at least one base part is
/// returned; the caller can filter by [`ShipModel::parts`] length or faction.
pub fn ships_in_container(bytes: &[u8]) -> Vec<ShipModel> {
    let names = xbg7_resource_names(bytes);
    let mut ids: Vec<String> = Vec::new();
    let mut ships: std::collections::HashMap<String, ShipModel> = std::collections::HashMap::new();
    for n in &names {
        if !is_base_part(n) {
            continue;
        }
        let id = ship_id_of(n).unwrap().to_string();
        let entry = ships.entry(id.clone()).or_insert_with(|| {
            ids.push(id.clone());
            let has_composite = !composites_for(&names, &id).is_empty();
            ShipModel {
                id: id.clone(),
                faction: Faction::from_id(&id),
                parts: Vec::new(),
                has_composite,
            }
        });
        entry.parts.push(n.clone());
    }
    ids.sort();
    ids.into_iter().map(|id| ships.remove(&id).unwrap()).collect()
}

/// Map a composite scene-graph node name to the geometry resource it draws.
/// Hull nodes are `rou_<id>_<part>[_root|_mov|_NN]`; the geometry resource is the
/// `<id>_<part>` stem. Structural nodes (`rou_e106_front`, `…_root`) resolve to
/// nothing and only pose their children.
fn resolve_hull_resource(node: &str, resources: &HashSet<String>) -> Option<String> {
    let stem = node.rsplit_once("rou_").map(|(_, s)| s).unwrap_or(node);
    let try_stem = |s: &str| -> Option<String> {
        if resources.contains(s) {
            return Some(s.to_string());
        }
        // Squeezed digit run (`wep02` names the `wep_02_01` resource): insert an
        // underscore before the digits and try the `_01` first-part too.
        if let Some(pos) = s.rfind(|c: char| c.is_ascii_digit()) {
            let mut start = pos;
            while start > 0 && s.as_bytes()[start - 1].is_ascii_digit() {
                start -= 1;
            }
            if start > 0 && s.as_bytes()[start - 1] != b'_' {
                let split = format!("{}_{}", &s[..start], &s[start..]);
                if resources.contains(&split) {
                    return Some(split);
                }
                let first = format!("{split}_01");
                if resources.contains(&first) {
                    return Some(first);
                }
            }
        }
        None
    };
    if let Some(r) = try_stem(stem) {
        return Some(r);
    }
    for suf in ["_root", "_mov"] {
        if let Some(s) = stem.strip_suffix(suf) {
            if let Some(r) = try_stem(s) {
                return Some(r);
            }
        }
    }
    // Trailing `_NN` instance index (`eng_01_1` → `eng_01`).
    if let Some(pos) = stem.rfind('_') {
        if !stem[pos + 1..].is_empty() && stem[pos + 1..].bytes().all(|c| c.is_ascii_digit()) {
            if let Some(r) = try_stem(&stem[..pos]) {
                return Some(r);
            }
        }
    }
    None
}

/// The trailing digit run of a name (`GN_Bridge_01` → `01`, `sld_02` → `02`).
fn trailing_index(name: &str) -> &str {
    let bytes = name.as_bytes();
    let mut start = bytes.len();
    while start > 0 && bytes[start - 1].is_ascii_digit() {
        start -= 1;
    }
    &name[start..]
}

/// Which stage-container resources are the composite scene graphs for ship `id`
/// (`e_rou_e106`, `e_rou_e106_eng`, …). Excludes destruction (`_break`) scenes.
fn composites_for<'a>(names: &'a [String], id: &str) -> Vec<&'a String> {
    let needle = format!("rou_{id}");
    names
        .iter()
        .filter(|n| {
            n.contains(&needle)
                && ship_id_of(n).is_none()
                && !n.contains("break")
                && !n.contains("dead")
        })
        .collect()
}

/// Reconstruct a whole ship as a list of geometry-resource placements in a shared
/// ship-local frame — the placement the game builds at runtime.
///
/// Fully static and EXACT (validated part-for-part against an e106 runtime
/// capture — see `docs/re/ship-placement-runtime-capture.md`):
/// 1. **Composite nodes** — every `rou_*` node in the primary composite
///    (`e_rou_<id>`) places a geometry resource, INCLUDING repeated instances
///    and cross-id turret mounts (`rou_e303_wep_01_root` ×2 on the e106 hull).
/// 2. **Detail rigs** — `e_rou_<id>_eng` is the engine cluster rig; its nodes
///    (e.g. two `eng_01` nacelle instances + `eng_02`) mount at the primary
///    composite's `GN_Engine_01` frame: `world = frame ∘ rig_local`.
/// 3. **GN hardpoints** — remaining unplaced `brg`/`sld` parts sit exactly at
///    their `GN_Bridge_NN` / `GN_ShieldG_NN` frame (frames carry full TRS).
/// 4. **Mirrored twins** — a port/starboard pair (`bdy_01`/`bdy_02`) shares one
///    geometry; the instance whose lateral offset points the geometry's dominant
///    side inboard is drawn X-reflected (capture-verified; det < 0 → the caller
///    reverses winding).
///
/// `include_external` keeps tiers 2–4 (off = bare composite-node hull).
pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<ScenePart> {
    let names = xbg7_resource_names(bytes);
    let resources: HashSet<String> = names.iter().cloned().collect();

    // The PRIMARY composite carries the ship-space layout (hull bodies, turret
    // mounts + every `GN_*` hardpoint frame): the one with the most nodes.
    let scenes: Vec<(String, Vec<ScenePart>)> = composites_for(&names, id)
        .into_iter()
        .filter(|c| !c.ends_with("_joint"))
        .map(|c| (c.clone(), scene_world_nodes(bytes, c)))
        .collect();
    let Some((_, nodes)) = scenes.iter().max_by_key(|(_, n)| n.len()) else {
        // No composite at all → raw fallback below.
        let mut placed = Vec::new();
        const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
        for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
            placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
        }
        return placed;
    };

    let mut placed: Vec<ScenePart> = Vec::new();
    let mut placed_res: HashSet<String> = HashSet::new();
    // GN hardpoint frames, world-space, from the primary composite.
    let mut frames: Vec<ScenePart> = Vec::new();

    // Tier 1: every composite node placement, instances included. Cross-id
    // resources (shared e303/e4NN turret models the composite mounts) count too.
    for node in nodes {
        if node.resource.starts_with("GN_") {
            frames.push(node.clone());
        } else if let Some(res) = resolve_hull_resource(&node.resource, &resources) {
            if ship_id_of(&res) != Some(id) && !include_external {
                continue; // hull-only view: own parts only
            }
            // Distinct INSTANCES keep their own placement; a nested alias of the
            // same resource at the same transform (`bdy_01_mov` + its identity
            // `bdy_01` child) collapses to one draw.
            let dup = placed.iter().any(|p| {
                p.resource == res
                    && (p.t[0] - node.t[0]).abs() < 0.01
                    && (p.t[1] - node.t[1]).abs() < 0.01
                    && (p.t[2] - node.t[2]).abs() < 0.01
            });
            if !dup {
                placed_res.insert(res.clone());
                placed.push(ScenePart { resource: res, m: node.m, t: node.t, s: node.s });
            }
        }
    }

    // Tier 2: the engine cluster rig, mounted at GN_Engine_01. The rig's nodes
    // are in the FRAME's local space (two mirrored `eng_01` nacelles + centre
    // `eng_02` for e106) — capture-verified to 0.1 units.
    if include_external {
        let rig_name = format!("e_rou_{id}_eng");
        if names.contains(&rig_name) {
            if let Some(frame) = frames.iter().find(|f| f.resource.starts_with("GN_Engine")) {
                for rn in scene_world_nodes(bytes, &rig_name) {
                    let Some(res) = resolve_hull_resource(&rn.resource, &resources) else {
                        continue;
                    };
                    // world = frame ∘ rig_local
                    let m = m3_mul_pub(frame.m, rn.m);
                    let rt = m3_vec_pub(frame.m, rn.t);
                    let t = [rt[0] + frame.t[0], rt[1] + frame.t[1], rt[2] + frame.t[2]];
                    placed_res.insert(res.clone());
                    placed.push(ScenePart { resource: res, m, t, s: rn.s });
                }
            }
        }
    }

    // Tier 3: remaining external parts at their exact GN frame (full TRS).
    const CATS: [(&str, &str); 3] = [("brg", "Bridge"), ("sld", "ShieldG"), ("eng", "Engine")];
    for part in names
        .iter()
        .filter(|_| include_external)
        .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id))
    {
        if placed_res.contains(part) {
            continue;
        }
        let rest = &part[(id.len() + 1).min(part.len())..]; // "brg_01"
        let mut toks = rest.split('_');
        let cat = toks.next().unwrap_or("");
        let idx = toks.next().unwrap_or("");
        let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
            continue;
        };
        // An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
        // compare `"01" == ""` and fall through, so the bridge was silently
        // dropped from the assembly while the game draws it — caught by a runtime
        // capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
        // With no index to match on, take the lowest-numbered frame of the
        // category; an indexed part still matches its own index only.
        let mut cands: Vec<&ScenePart> = frames
            .iter()
            .filter(|f| {
                f.resource.contains(gncat)
                    && (idx.is_empty() || trailing_index(&f.resource) == idx)
            })
            .collect();
        cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
        if let Some(frame) = cands.first().copied() {
            placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
            placed_res.insert(part.clone());
        }
    }

    // Tier 4: mirror one of a shared-geometry twin pair. The engine uploads the
    // second of a port/starboard pair X-reflected (capture-verified on
    // bdy_01/bdy_02): for two same-geometry resources placed at ±TX, the
    // instance whose TX sign matches the geometry's dominant-X side draws
    // mirrored (so the pair ends up symmetric instead of twice the same side).
    apply_twin_mirrors(bytes, &mut placed);

    // Fallback for a ship with no composite scene graph (a simple prop): draw its
    // base parts untransformed rather than nothing.
    if placed.is_empty() {
        const ID: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
        for part in names.iter().filter(|n| is_base_part(n) && ship_id_of(n) == Some(id)) {
            placed.push(ScenePart { resource: part.clone(), m: ID, t: [0.0; 3], s: [1.0; 3] });
        }
    }

    placed
}

/// The ship's exhaust mount frames (`GN_Jet_*` / `GN_SJet_*`), world-space, from
/// the primary composite. These are where the game renders the engine exhaust
/// FX — the visible "thrusters". The engine GEOMETRY itself sits recessed in
/// the hull (capture-verified across 43 e106 instances: the game draws
/// `eng_01`/`eng_02` exactly where [`assemble_ship`] puts them); without the
/// FX the assembled ship reads engine-less, so a viewer can draw exhaust
/// markers at these frames.
pub fn exhaust_frames(bytes: &[u8], id: &str) -> Vec<ScenePart> {
    let names = xbg7_resource_names(bytes);
    let scenes: Vec<Vec<ScenePart>> = composites_for(&names, id)
        .into_iter()
        .filter(|c| !c.ends_with("_joint"))
        .map(|c| scene_world_nodes(bytes, c))
        .collect();
    let Some(nodes) = scenes.into_iter().max_by_key(|n| n.len()) else {
        return Vec::new();
    };
    nodes
        .into_iter()
        .filter(|n| n.resource.starts_with("GN_Jet") || n.resource.starts_with("GN_SJet"))
        .collect()
}

/// Detect shared-geometry port/starboard twin RESOURCES placed at ±TX and mark
/// the dominant-side instance as an X-reflection (negate the matrix X column).
/// Twins are `<stem>_01`/`<stem>_02` pairs with equal vertex data; the reflected
/// one is the instance whose TX sign equals the geometry's mean-X sign (the
/// un-reflected drawing already covers that side's opposite).
fn apply_twin_mirrors(bytes: &[u8], placed: &mut [ScenePart]) {
    use crate::mesh::Xbg7Model;
    // Candidate pairs: resources differing only in a trailing _01/_02, both
    // placed exactly once, at opposite-sign TX of equal magnitude.
    let mut pairs: Vec<(usize, usize)> = Vec::new();
    for i in 0..placed.len() {
        let a = &placed[i];
        let Some(stem) = a.resource.strip_suffix("_01") else { continue };
        let twin = format!("{stem}_02");
        let Some(j) = placed.iter().position(|p| p.resource == twin) else { continue };
        let b = &placed[j];
        if (a.t[0] + b.t[0]).abs() < 0.5 && a.t[0].abs() > 1.0 {
            pairs.push((i, j));
        }
    }
    if pairs.is_empty() {
        return;
    }
    let want: HashSet<String> = pairs
        .iter()
        .flat_map(|&(i, j)| [placed[i].resource.clone(), placed[j].resource.clone()])
        .collect();
    let models = Xbg7Model::models_named(bytes, &want, &|| false);
    for (i, j) in pairs {
        let get = |r: &str| models.iter().find(|m| m.name == r);
        let (Some(ma), Some(mb)) = (get(&placed[i].resource), get(&placed[j].resource)) else {
            continue;
        };
        let first = |m: &Xbg7Model| -> Vec<[f32; 3]> {
            m.meshes.iter().flat_map(|s| s.positions.iter().copied()).take(16).collect()
        };
        let (fa, fb) = (first(ma), first(mb));
        if fa.len() != fb.len()
            || !fa.iter().zip(&fb).all(|(p, q)| {
                (p[0] - q[0]).abs() < 1e-3 && (p[1] - q[1]).abs() < 1e-3 && (p[2] - q[2]).abs() < 1e-3
            })
        {
            continue; // distinct (already-mirrored) geometries — nothing to do
        }
        let mean_x: f32 = ma
            .meshes
            .iter()
            .flat_map(|s| s.positions.iter())
            .map(|p| p[0])
            .sum::<f32>()
            / ma.meshes.iter().map(|s| s.positions.len()).sum::<usize>().max(1) as f32;
        if mean_x.abs() < 1e-3 {
            continue; // symmetric geometry — mirroring is a no-op
        }
        // Reflect the instance whose lateral offset OPPOSES the geometry's
        // dominant side (drawn plain it would fold onto the centreline; the
        // capture shows the engine reflects exactly this one — for e106 the
        // −264 port instance draws the file data plain, the +264 one mirrored).
        let k = if (placed[i].t[0] > 0.0) == (mean_x > 0.0) { j } else { i };
        for row in &mut placed[k].m {
            row[0] = -row[0];
        }
    }
}

// Small helpers mirroring mesh.rs's private affine ops.
fn m3_mul_pub(a: [[f32; 3]; 3], b: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
    let mut o = [[0.0f32; 3]; 3];
    for r in 0..3 {
        for c in 0..3 {
            o[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c];
        }
    }
    o
}
fn m3_vec_pub(a: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
    [
        a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2],
        a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2],
        a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2],
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn id_extraction() {
        assert_eq!(ship_id_of("e106_bdy_01"), Some("e106"));
        assert_eq!(ship_id_of("f105_sld_02_l"), Some("f105"));
        assert_eq!(ship_id_of("e_rou_e106"), None);
        assert_eq!(ship_id_of("_rou_e106_break"), None);
        assert_eq!(ship_id_of("Base"), None);
    }

    #[test]
    fn base_part_filter() {
        assert!(is_base_part("e106_bdy_01"));
        assert!(is_base_part("e106_brg_01"));
        assert!(is_base_part("f105_wep_02_01"));
        assert!(!is_base_part("e106_bdy_01_l")); // LOD
        assert!(!is_base_part("e106_bdy_01_m")); // LOD
        assert!(!is_base_part("e106_brg_01_b_02")); // break piece
        assert!(!is_base_part("_rou_e106_break")); // logic
        assert!(!is_base_part("e_rou_e106")); // scene node
    }

    #[test]
    fn faction_from_id() {
        assert_eq!(Faction::from_id("e106"), Faction::Adan);
        assert_eq!(Faction::from_id("f105"), Faction::Tcaf);
        assert_eq!(Faction::from_id("n050"), Faction::Neutral);
        assert_eq!(Faction::from_id("j004"), Faction::Other);
    }

    // Disc-gated: reconstruct the ADAN cruiser family from a real stage container.
    #[test]
    fn ships_from_real_stage() {
        let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
            eprintln!("SYLPHEED_ISO unset — skipping");
            return;
        };
        let bytes = {
            use crate::xiso::open_iso;
            let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
            rt.block_on(async {
                let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
                r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap()
            })
        };
        let ships = ships_in_container(&bytes);
        assert!(!ships.is_empty(), "stage should hold ships");
        // The ADAN frigate e108 appears in S02 with hull + engine + weapon parts.
        let e108 = ships.iter().find(|s| s.id == "e108").expect("e108 present");
        assert_eq!(e108.faction, Faction::Adan);
        assert!(e108.parts.iter().any(|p| p.contains("_bdy_")));
        assert!(e108.parts.iter().all(|p| is_base_part(p)));
    }

    // Disc-gated GROUND-TRUTH test: the fully-static assembly must reproduce the
    // runtime F10 capture (the baked e106 table) part-for-part — translations
    // AND the engine-rig rotation — plus the multi-instance parts the capture's
    // vbase-dedup could not see (two nacelles, two turrets).
    #[test]
    fn static_assembly_matches_runtime_capture() {
        let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
            eprintln!("SYLPHEED_ISO unset — skipping");
            return;
        };
        let bytes = {
            use crate::xiso::open_iso;
            let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
            rt.block_on(async {
                let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
                r.read_file("hidden/resource3d/Stage_S01.xpr").await.unwrap()
            })
        };
        let placed = assemble_ship(&bytes, "e106", true);
        let cap = crate::ship_capture::embedded_placement("e106").expect("e106 baked");
        // Express static placements relative to bdy_04 (the capture's reference).
        let r4 = placed
            .iter()
            .find(|p| p.resource == "e106_bdy_04")
            .expect("bdy_04 placed")
            .clone();
        let rel = |p: &crate::mesh::ScenePart| -> [f32; 3] {
            [p.t[0] - r4.t[0], p.t[1] - r4.t[1], p.t[2] - r4.t[2]]
        };
        for want in &cap.parts {
            let best = placed
                .iter()
                .filter(|p| p.resource == want.part)
                .map(|p| {
                    let t = rel(p);
                    let d = (t[0] - want.t[0]).abs()
                        + (t[1] - want.t[1]).abs()
                        + (t[2] - want.t[2]).abs();
                    (d, p)
                })
                .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
                .unwrap_or_else(|| panic!("{} not placed statically", want.part));
            assert!(
                best.0 < 1.0,
                "{}: static rel-T {:?} != captured {:?} (Δ={:.2})",
                want.part,
                rel(best.1),
                want.t,
                best.0
            );
            // Rotations must match too (the engine rig is the interesting case).
            let m = &best.1.m;
            for r in 0..3 {
                for c in 0..3 {
                    assert!(
                        (m[r][c] - want.m[r][c]).abs() < 0.02,
                        "{}: static M row{r} {:?} != captured {:?}",
                        want.part,
                        m[r],
                        want.m[r]
                    );
                }
            }
        }
        // Multi-instance coverage the capture couldn't see (vbase dedup).
        let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
        assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");
        assert_eq!(count("e303_wep_01"), 2, "both shared turrets placed");
        // The mirrored starboard hull reflects (det < 0), the port one doesn't.
        let det = |m: &[[f32; 3]; 3]| {
            m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
                - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
                + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
        };
        let one = |res: &str| placed.iter().find(|p| p.resource == res).unwrap();
        assert!(det(&one("e106_bdy_02").m) < 0.0, "starboard hull mirrored");
        assert!(det(&one("e106_bdy_01").m) > 0.0, "port hull plain");
    }

    // Disc-gated: the scene graph must SPREAD a ship's parts, not stack them at
    // the origin. Reconstruct the ADAN frigate e106 and assert its bridge sits
    // clearly aft of its forward hull body (a real ship layout, not a pile).
    #[test]
    fn assemble_spreads_parts() {
        let Ok(iso) = std::env::var("SYLPHEED_ISO") else {
            eprintln!("SYLPHEED_ISO unset — skipping");
            return;
        };
        let bytes = {
            use crate::xiso::open_iso;
            let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
            rt.block_on(async {
                let mut r = open_iso(std::path::Path::new(&iso)).await.unwrap();
                r.read_file("hidden/resource3d/Stage_S02.xpr").await.unwrap()
            })
        };
        let placed = assemble_ship(&bytes, "e106", true);
        assert!(placed.len() >= 5, "e106 should place its hull + external parts");
        // The forward hull body and the bridge must land at distinct fore/aft Z.
        let z = |res: &str| placed.iter().find(|p| p.resource == res).map(|p| p.t[2]);
        let bdy = z("e106_bdy_01").expect("bdy_01 placed");
        let brg = z("e106_brg_01").expect("brg_01 placed at its GN_Bridge frame");
        assert!(
            (bdy - brg).abs() > 200.0,
            "bridge ({brg}) and forward body ({bdy}) must be far apart, not stacked"
        );
    }
}
