main
Drew Owens 0.11.4 source 8889116 1d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988
//! THE MODELLING DSL'S GEOMETRIC DECISIONS (0.10.2) — pure functions BOTH
//! backends use: the COM host (with numbers read from SOLIDWORKS) and the
//! simulator (with its own model). Pure, so every rule is unit-tested here.
//!
//! An independent review of 0.10.x (2026-10-13) found that the API calls were
//! right and the BEHAVIOUR was the risk: picks made through the view, a face's
//! normal sign dropped, duplicate edges toggling themselves off, a negative
//! offset plane's orientation unverified. The answers live here:
//!   * a face or edge is picked GEOMETRICALLY — the entity whose closest point
//!     to the caller's point is within a tolerance — never through the view;
//!   * a sketch's coordinates are the BASE PLANE's own axes (its IRefPlane
//!     transform), mapped into whatever sketch SOLIDWORKS opened through that
//!     sketch's ModelToSketchTransform — so a flipped offset plane or a face's
//!     own sketch axes cannot mirror the geometry;
//!   * an extrude goes along a REFERENCE direction (the face's outward normal,
//!     the base plane's normal) and a cut against it, whatever way the sketch's
//!     own normal points: `Dir` is flipped when they disagree;
//!   * entities are de-duplicated before they are selected, because selecting an
//!     already-selected entity DESELECTS it.

use crate::script::{Direction, Entity, Extent};

pub type V3 = [f64; 3];

pub fn dot(a: V3, b: V3) -> f64 {
    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}

pub fn add(a: V3, b: V3) -> V3 {
    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}

pub fn sub(a: V3, b: V3) -> V3 {
    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}

pub fn scale(a: V3, k: f64) -> V3 {
    [a[0] * k, a[1] * k, a[2] * k]
}

pub fn norm(a: V3) -> f64 {
    dot(a, a).sqrt()
}

/// An IMathTransform's `ArrayData`: the rotation's ROWS are the images of the
/// local X, Y and Z axes (indices 0-2, 3-5, 6-8), then the translation (9-11)
/// and the scale (12). The same layout `swx_add_component` writes — measured
/// live (component placement, 2026-09).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Xform {
    pub rows: [V3; 3],
    pub t: V3,
    pub s: f64,
}

impl Xform {
    pub fn from_array_data(a: &[f64]) -> Option<Xform> {
        if a.len() < 12 || a.iter().take(12).any(|v| !v.is_finite()) {
            return None;
        }
        let s = a.get(12).copied().filter(|s| s.is_finite() && *s != 0.0).unwrap_or(1.0);
        Some(Xform { rows: [[a[0], a[1], a[2]], [a[3], a[4], a[5]], [a[6], a[7], a[8]]], t: [a[9], a[10], a[11]], s })
    }

    pub fn identity() -> Xform {
        Xform { rows: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], t: [0.0; 3], s: 1.0 }
    }

    /// p' = (p.x·row0 + p.y·row1 + p.z·row2)·scale + t.
    pub fn apply(&self, p: V3) -> V3 {
        let r = add(add(scale(self.rows[0], p[0]), scale(self.rows[1], p[1])), scale(self.rows[2], p[2]));
        add(scale(r, self.s), self.t)
    }

    /// The inverse of a rigid transform (orthonormal rows, uniform scale):
    /// p = ((p' − t) / s)·Rᵀ.
    pub fn inverse(&self) -> Xform {
        let r = self.rows;
        // Rows of Rᵀ are the columns of R.
        let rows = [[r[0][0], r[1][0], r[2][0]], [r[0][1], r[1][1], r[2][1]], [r[0][2], r[1][2], r[2][2]]];
        let inv_s = if self.s != 0.0 { 1.0 / self.s } else { 1.0 };
        let neg_t = scale(self.t, -inv_s);
        let t = add(add(scale(rows[0], neg_t[0]), scale(rows[1], neg_t[1])), scale(rows[2], neg_t[2]));
        Xform { rows, t, s: inv_s }
    }

    /// The direction (in the SOURCE space) that this transform maps onto its
    /// target's +Z: for a ModelToSketch transform, the sketch's normal in model
    /// space. With orthonormal rows that is the third COLUMN.
    pub fn source_z(&self) -> V3 {
        [self.rows[0][2], self.rows[1][2], self.rows[2][2]]
    }
}

/// A plane's frame in model space: origin, its own X and Y axes (a sketch on
/// it draws in these), and its normal.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PlaneFrame {
    pub origin: V3,
    pub x: V3,
    pub y: V3,
    pub n: V3,
}

impl PlaneFrame {
    /// From the plane's IRefPlane::Transform (plane-local -> model).
    pub fn from_plane_xform(x: &Xform) -> PlaneFrame {
        PlaneFrame { origin: x.t, x: x.rows[0], y: x.rows[1], n: x.rows[2] }
    }

    /// The default planes as the documented conventions state them — Front
    /// (x→X, y→Y, normal +Z), Top (x→X, y→−Z, normal +Y: MEASURED, issue #1),
    /// Right (x→−Z, y→Y, normal +X). The simulator's frames; the COM host reads
    /// the real ones from each plane's transform.
    pub fn defaults() -> [PlaneFrame; 3] {
        [
            PlaneFrame { origin: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 1.0, 0.0], n: [0.0, 0.0, 1.0] },
            PlaneFrame { origin: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 0.0, -1.0], n: [0.0, 1.0, 0.0] },
            PlaneFrame { origin: [0.0; 3], x: [0.0, 0.0, -1.0], y: [0.0, 1.0, 0.0], n: [1.0, 0.0, 0.0] },
        ]
    }

    /// The model point of sketch (u, v) on the plane parallel to this one at
    /// height `h` along its normal.
    pub fn point(&self, u: f64, v: f64, h: f64) -> V3 {
        add(add(add(self.origin, scale(self.n, h)), scale(self.x, u)), scale(self.y, v))
    }

    /// How far `p` is along this plane's normal.
    pub fn height_of(&self, p: V3) -> f64 {
        dot(sub(p, self.origin), self.n)
    }
}

impl PlaneFrame {
    /// Do two frames agree (same axes and normal, origins within 1 µm)?
    pub fn same_as(&self, o: &PlaneFrame) -> bool {
        let close = |a: V3, b: V3, tol: f64| norm(sub(a, b)) < tol;
        close(self.x, o.x, 1e-6) && close(self.y, o.y, 1e-6) && close(self.n, o.n, 1e-6) && close(self.origin, o.origin, 1e-6)
    }
}

/// THE SKETCH MAPPING, with its own check: entities through `m2s`; if they do
/// not land on the sketch plane (|z| > 1e-7 m), through its inverse — an
/// API whose transform runs the other way is caught, not obeyed. Returns the
/// mapped entities and whether the inverse was used; Err when neither lands.
pub fn map_entities(frame: &PlaneFrame, h: f64, entities: &[Entity], m2s: &Xform) -> Result<(Vec<Entity>, bool), f64> {
    let attempt = |t: &Xform| -> (Vec<Entity>, f64) {
        let mut out = Vec::new();
        let mut worst: f64 = 0.0;
        for e in entities {
            let (m, z) = entity_to_sketch(frame, h, e, t);
            worst = worst.max(z);
            out.push(m);
        }
        (out, worst)
    };
    let (fwd, zf) = attempt(m2s);
    if zf <= 1e-7 {
        return Ok((fwd, false));
    }
    let (inv, zi) = attempt(&m2s.inverse());
    if zi <= 1e-7 {
        return Ok((inv, true));
    }
    Err(zf.min(zi))
}

/// Which default plane (0 Front, 1 Top, 2 Right) a caller's name means —
/// English names and their short forms; a localized install's own names are
/// matched by the caller against the first three RefPlane features.
pub fn default_index(name: &str) -> Option<usize> {
    match name.trim().to_ascii_lowercase().as_str() {
        "front plane" | "front" => Some(0),
        "top plane" | "top" => Some(1),
        "right plane" | "right" => Some(2),
        _ => None,
    }
}

/// The default plane parallel to a face normal.
pub fn parallel_default(frames: &[PlaneFrame; 3], n: V3) -> Option<usize> {
    let len = norm(n);
    if len < 1e-9 {
        return None;
    }
    (0..3).find(|i| (dot(frames[*i].n, n) / len).abs() > 0.999)
}

/// An entity drawn on `frame` at height `h`, mapped into a sketch through its
/// model-to-sketch transform. Returns the sketch-space entity and how far off
/// the sketch plane (|z|) the mapping put it — anything but ~0 means the
/// transform is not what it should be, and nothing is drawn.
pub fn entity_to_sketch(frame: &PlaneFrame, h: f64, e: &Entity, m2s: &Xform) -> (Entity, f64) {
    match *e {
        Entity::Rect { x0, y0, x1, y1 } => {
            let a = m2s.apply(frame.point(x0, y0, h));
            let b = m2s.apply(frame.point(x1, y1, h));
            (Entity::Rect { x0: a[0], y0: a[1], x1: b[0], y1: b[1] }, a[2].abs().max(b[2].abs()))
        }
        Entity::Circle { cx, cy, r } => {
            let c = m2s.apply(frame.point(cx, cy, h));
            (Entity::Circle { cx: c[0], cy: c[1], r: r * m2s.s.abs() }, c[2].abs())
        }
        Entity::Line { x0, y0, x1, y1, construction } => {
            let a = m2s.apply(frame.point(x0, y0, h));
            let b = m2s.apply(frame.point(x1, y1, h));
            (Entity::Line { x0: a[0], y0: a[1], x1: b[0], y1: b[1], construction }, a[2].abs().max(b[2].abs()))
        }
        Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => {
            let c = m2s.apply(frame.point(cx, cy, h));
            let a = m2s.apply(frame.point(x0, y0, h));
            let b = m2s.apply(frame.point(x1, y1, h));
            // A MIRRORING map turns counter-clockwise into clockwise: the arc
            // keeps its ends and centre, so its direction must flip with it.
            let ccw = if mapping_reflects(frame, h, m2s) { !ccw } else { ccw };
            (Entity::Arc { cx: c[0], cy: c[1], x0: a[0], y0: a[1], x1: b[0], y1: b[1], ccw, construction }, c[2].abs().max(a[2].abs()).max(b[2].abs()))
        }
    }
}

/// Does the map from the plane's (u, v) into the sketch's (x, y) MIRROR (its
/// 2D determinant negative)? Then an arc's turning direction flips.
pub fn mapping_reflects(frame: &PlaneFrame, h: f64, m2s: &Xform) -> bool {
    let o = m2s.apply(frame.point(0.0, 0.0, h));
    let ex = sub(m2s.apply(frame.point(1.0, 0.0, h)), o);
    let ey = sub(m2s.apply(frame.point(0.0, 1.0, h)), o);
    ex[0] * ey[1] - ex[1] * ey[0] < 0.0
}

/// THE MOMENTS OF ONE CLOSED CONTOUR (0.11.0), exactly, by Green's theorem:
/// (A, Sx, Sy) = (∬dA, ∬x dA, ∬y dA), SIGNED — positive when the contour runs
/// counter-clockwise. Per segment:
///   * a line P→Q: ∮x dy − y dx = px·qy − qx·py; ∮x² dy = (qy−py)(px²+px·qx+qx²)/3;
///     ∮y² dx = (qx−px)(py²+py·qy+qy²)/3;
///   * an arc about (cx, cy) of radius r from θ0 sweeping Δ: the same three
///     integrals in closed form over θ (sin/cos, sin², cos², sin³, cos³).
/// A = ½∮(x dy − y dx), Sx = ½∮x² dy, Sy = −½∮y² dx.
pub fn contour_moments(entities: &[Entity], contour: &[(usize, bool)]) -> (f64, f64, f64) {
    let (mut cross, mut x2dy, mut y2dx) = (0.0, 0.0, 0.0);
    for &(i, rev) in contour {
        let e = &entities[i];
        match *e {
            Entity::Line { x0, y0, x1, y1, .. } => {
                let (px, py, qx, qy) = if rev { (x1, y1, x0, y0) } else { (x0, y0, x1, y1) };
                cross += px * qy - qx * py;
                x2dy += (qy - py) * (px * px + px * qx + qx * qx) / 3.0;
                y2dx += (qx - px) * (py * py + py * qy + qy * qy) / 3.0;
            }
            Entity::Arc { .. } => {
                let Some((cx, cy, r, t0, sweep)) = crate::script::arc_geom(e) else { continue };
                let (a, d) = if rev { (t0 + sweep, -sweep) } else { (t0, sweep) };
                let b = a + d;
                let (s0, c0) = a.sin_cos();
                let (s1, c1) = b.sin_cos();
                let int_c = s1 - s0;
                let int_s = -(c1 - c0);
                let int_c2 = d / 2.0 + ((2.0 * b).sin() - (2.0 * a).sin()) / 4.0;
                let int_s2 = d / 2.0 - ((2.0 * b).sin() - (2.0 * a).sin()) / 4.0;
                let int_c3 = (s1 - s1.powi(3) / 3.0) - (s0 - s0.powi(3) / 3.0);
                let int_s3 = (-c1 + c1.powi(3) / 3.0) - (-c0 + c0.powi(3) / 3.0);
                // x = cx + r cosθ, y = cy + r sinθ, dx = −r sinθ dθ, dy = r cosθ dθ.
                // x dy − y dx = (r² + cx·r·cosθ + cy·r·sinθ) dθ.
                cross += r * r * d + cx * r * int_c + cy * r * int_s;
                x2dy += cx * cx * r * int_c + 2.0 * cx * r * r * int_c2 + r * r * r * int_c3;
                y2dx += -(cy * cy * r * int_s + 2.0 * cy * r * r * int_s2 + r * r * r * int_s3);
            }
            _ => {}
        }
    }
    (cross / 2.0, x2dy / 2.0, -y2dx / 2.0)
}

/// THE `Dir` FLAG for FeatureExtrusion3 / FeatureCut4. SOLIDWORKS extrudes a
/// boss ALONG the sketch normal and cuts AGAINST it ("The default sketch normal
/// is the same as the face or plane normal… boss… along the sketch normal; cut…
/// opposite"). The DSL's rule is about a REFERENCE direction — a face's outward
/// normal, an offset plane's base normal: a boss goes along it, a cut against
/// it. When the sketch's own normal points the other way (a plane SOLIDWORKS
/// flipped), `Dir` is flipped to keep the rule; the caller's `reverse` flips it
/// again. Returns (dir, flipped).
pub fn dir_flag(sketch_normal: Option<V3>, reference: Option<V3>, reverse: bool) -> (bool, bool) {
    let flipped = match (sketch_normal, reference) {
        (Some(s), Some(r)) => dot(s, r) < 0.0,
        _ => false,
    };
    (flipped ^ reverse, flipped)
}

/// The swEndConditions_e id of an up-to end condition (verified on
/// help.solidworks.com, swconst): UpToNext 11, UpToSelection 10 (the face
/// selected with mark 1 — UpToSurface 4 is the obsolete id), OffsetFromSurface 5.
pub fn end_condition_id(u: &crate::script::UpTo) -> i32 {
    match u {
        crate::script::UpTo::Next => 11,
        // swEndCondUpToSurface 4, the id the macro recorder writes, NOT
        // swEndCondUpToSelection 10 (the help's current name for it).
        // MEASURED 2026-10-16 (SOLIDWORKS 2026, canary 0.11.4-rc1, cswa-12):
        // with 10 and the face at mark 1, a boss and a cut both built with an
        // EMPTY direction box and stopped at the NEXT face (the boss 3392.9 mm3
        // short, the cut 1005.3 mm3 short, both exactly one face early);
        // OffsetFromSurface 5 with the same selection went to the face (the
        // pillar at x 68 was right). A forum report says the same of 10
        // (3dswym, "FeatureCut4 Method": the recorder writes 4, 10 leaves the
        // direction box empty).
        crate::script::UpTo::Surface { .. } => 4,
        crate::script::UpTo::OffsetFrom { .. } => 5,
    }
}

/// The `Ddir1` / `Ddir2` flag `FeatureExtrusion3` and `FeatureCut4` take for a
/// draft. MEASURED 2026-10-16 (SOLIDWORKS 2026, canary 0.11.4-rc1): the flag
/// TRUE made the profile GROW along the extrude - a 5 deg boss widened (cswa-12)
/// and a 45 deg countersink cut widened into exactly the outward frustum, 804.25
/// mm3 more than the inward one (cswa-11, 1273.4 vs 469.1 mm3). The 2024 help
/// for FeatureExtrusion3 says "True for first draft angle to be inward"; its
/// sibling FeatureExtruRefSurface3 says the opposite, and SOLIDWORKS itself
/// did the opposite. So: TRUE = OUTWARD, and the bridge passes `outward` as is.
pub fn draft_outward_flag(d: &crate::script::Draft) -> bool {
    d.outward
}

/// Why a geometric pick found no single entity.
#[derive(Clone, Debug, PartialEq)]
pub enum PickError {
    /// Nothing within the tolerance; the nearest was this far (metres).
    None(Option<f64>),
    /// The point lies on several entities (an edge between faces, a vertex
    /// between edges) — which one is meant cannot be decided.
    Ambiguous(usize),
}

/// THE GEOMETRIC PICK: `cands` are (index, distance from the caller's point to
/// the entity's closest point, outward offset — the point's height above the
/// face along its normal, 0 for edges). Within `tol`: one is the pick; several
/// prefer those the point is not BEHIND (outward >= -tol); still several is
/// ambiguous. Never "the first face along the view ray".
pub fn pick(cands: &[(usize, f64, f64)], tol: f64) -> Result<usize, PickError> {
    let near: Vec<&(usize, f64, f64)> = cands.iter().filter(|c| c.1 <= tol).collect();
    match near.len() {
        0 => Err(PickError::None(cands.iter().map(|c| c.1).fold(None, |m: Option<f64>, d| Some(m.map_or(d, |m| m.min(d)))))),
        1 => Ok(near[0].0),
        _ => {
            let front: Vec<&&(usize, f64, f64)> = near.iter().filter(|c| c.2 >= -tol).collect();
            if front.len() == 1 {
                Ok(front[0].0)
            } else {
                Err(PickError::Ambiguous(near.len()))
            }
        }
    }
}

/// Indices of the FIRST occurrence of each key — the de-duplication every
/// multi-entity selection goes through (a box's 6 faces list its 12 edges 24
/// times; an already-selected entity selected again is DESELECTED).
pub fn dedupe<K: PartialEq>(keys: &[K]) -> Vec<usize> {
    let mut out: Vec<usize> = Vec::new();
    for (i, k) in keys.iter().enumerate() {
        if !out.iter().any(|j| keys[*j] == *k) {
            out.push(i);
        }
    }
    out
}

/// What SOLIDWORKS ends up with selected after selecting `keys` one by one with
/// Append: an entity selected twice is deselected (the documented toggle). The
/// simulator MODELS the hazard with this.
pub fn toggled_selection<K: PartialEq + Clone>(keys: &[K]) -> Vec<K> {
    let mut sel: Vec<K> = Vec::new();
    for k in keys {
        if let Some(i) = sel.iter().position(|s| s == k) {
            sel.remove(i);
        } else {
            sel.push(k.clone());
        }
    }
    sel
}

/// The span along a plane's normal an extrude or cut covers, given the
/// direction its DEPTH goes (`own`: +1 or −1 along the normal — the op's own
/// direction after the sketch normal, the boss/cut rule and `reverse`).
/// `direction:"both"`: `depthMm` along the op's own direction, `depth2Mm` the
/// other way (SOLIDWORKS' D1 / D2). Through all spans `through` (the part's
/// extent along the normal) in the op's direction.
pub fn extent_span(o: f64, own: f64, e: &Extent, through: (f64, f64)) -> (f64, f64) {
    let sorted = |a: f64, b: f64| if a <= b { (a, b) } else { (b, a) };
    if e.through_all {
        return match e.direction {
            Direction::One => {
                if own > 0.0 { (o, through.1.max(o)) } else { (through.0.min(o), o) }
            }
            _ => (through.0.min(o), through.1.max(o)),
        };
    }
    match e.direction {
        Direction::One => sorted(o, o + own * e.depth),
        Direction::MidPlane => (o - e.depth / 2.0, o + e.depth / 2.0),
        Direction::Both => sorted(o + own * e.depth, o - own * e.depth2),
    }
}

// ---------------------------------------------------------------------------
// THE SELECTION LADDER (0.10.3)
//
// The first live run on SOLIDWORKS 2026 showed every selection after a
// geometric pick selecting NOTHING: IModelDocExtension::MultiSelect2 given a
// SAFEARRAY of VARIANTs (each wrapping an IDispatch) returned 0 and listed
// nothing. So no single selection call is trusted: each RUNG is tried in turn
// and is believed only when ISelectionMgr::GetSelectedObjectCount2 GROWS by
// exactly what was asked. The rung that worked is reported (`selectedVia`).
// ---------------------------------------------------------------------------

/// One way of selecting a picked entity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Rung {
    /// MultiSelect2 with a SAFEARRAY of VARIANT(VT_DISPATCH) — the 0.10.2 call,
    /// MEASURED on SOLIDWORKS 2026 to select nothing. Not on the ladder; named
    /// so the simulator can model what was measured.
    MultiSelectVariantArray,
    /// MultiSelect2 with a TYPED SAFEARRAY of VT_DISPATCH (what .NET's
    /// `object[] Objects` marshals as). The whole batch at once.
    MultiSelectTyped,
    /// IEntity::Select4(append, ISelectData), per entity, through the entity's
    /// IEntity interface.
    EntitySelect4,
    /// IModelDocExtension::SelectByRay (2017+) with the bridge's OWN ray: from
    /// just outside the picked point back onto it. View-independent.
    SelectByRay,
    /// IModelDocExtension::SelectByID2 at the picked point — goes through the
    /// VIEW, so a hidden face can be missed or the wrong one taken. Last resort.
    SelectByIdAtPoint,
}

impl Rung {
    pub fn label(self) -> &'static str {
        match self {
            Rung::MultiSelectVariantArray => "MultiSelect2(VARIANT array)",
            Rung::MultiSelectTyped => "MultiSelect2(typed array)",
            Rung::EntitySelect4 => "IEntity.Select4",
            Rung::SelectByRay => "SelectByRay",
            Rung::SelectByIdAtPoint => "SelectByID2 at the point (view-dependent)",
        }
    }
    pub fn view_dependent(self) -> bool {
        matches!(self, Rung::SelectByIdAtPoint)
    }
}

/// The batch rung, tried once for all entities.
pub const BATCH_RUNG: Rung = Rung::MultiSelectTyped;
/// The per-entity rungs, in order, for whatever the batch did not select.
pub const PER_ENTITY_RUNGS: [Rung; 3] = [Rung::EntitySelect4, Rung::SelectByRay, Rung::SelectByIdAtPoint];

/// What the ladder needs from a selection backend (the COM host, or the
/// simulator). `attempt` APPENDS; its own return value is never believed —
/// only `count` is.
pub trait Selector {
    fn clear(&mut self);
    /// The number of selected objects (GetSelectedObjectCount2(-1)).
    fn count(&mut self) -> Option<usize>;
    /// Try to select `items` (indices into the caller's list) by `rung`.
    /// `Err` = that rung is unavailable for them (no API, no ray, ...).
    fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String>;
}

/// The outcome: how many ended up selected (by the count), and per entity the
/// rung that selected it (`None` = nothing did).
#[derive(Clone, Debug, PartialEq)]
pub struct Selected {
    pub count: usize,
    pub via: Vec<Option<Rung>>,
    pub notes: Vec<String>,
}

impl Selected {
    pub fn all(&self, n: usize) -> bool {
        self.count == n && self.via.iter().all(|v| v.is_some())
    }
    /// The `selectedVia` report: one label when one rung did it all, else the
    /// list per entity.
    pub fn report(&self) -> serde_json::Value {
        let labels: Vec<serde_json::Value> =
            self.via.iter().map(|v| v.map(|r| serde_json::json!(r.label())).unwrap_or(serde_json::Value::Null)).collect();
        match labels.first() {
            Some(first) if labels.iter().all(|l| l == first) => first.clone(),
            _ => serde_json::Value::Array(labels),
        }
    }
    pub fn view_dependent(&self) -> bool {
        self.via.iter().any(|v| v.map(|r| r.view_dependent()).unwrap_or(false))
    }
}

/// Select `n` entities, starting from an EMPTY selection, rung by rung, every
/// rung VERIFIED by the selection count.
pub fn select_ladder<S: Selector>(s: &mut S, n: usize) -> Selected {
    let mut via = vec![None; n];
    let mut notes = Vec::new();
    if n == 0 {
        return Selected { count: 0, via, notes };
    }
    s.clear();
    let Some(base) = s.count() else {
        notes.push("the selection count could not be read, so no selection could be verified".into());
        return Selected { count: 0, via, notes };
    };
    let all: Vec<usize> = (0..n).collect();
    match s.attempt(BATCH_RUNG, &all) {
        Ok(()) if s.count() == Some(base + n) => {
            return Selected { count: n, via: vec![Some(BATCH_RUNG); n], notes };
        }
        Ok(()) => {
            notes.push(format!("{} selected {} of {n}", BATCH_RUNG.label(), s.count().unwrap_or(base).saturating_sub(base)));
            s.clear();
        }
        Err(e) => notes.push(format!("{}: {e}", BATCH_RUNG.label())),
    }
    let mut have = s.count().unwrap_or(base);
    for (i, slot) in via.iter_mut().enumerate() {
        let mut why: Vec<String> = Vec::new();
        for rung in PER_ENTITY_RUNGS {
            match s.attempt(rung, &[i]) {
                Ok(()) => {
                    let now = s.count().unwrap_or(have);
                    if now == have + 1 {
                        *slot = Some(rung);
                        have = now;
                        break;
                    }
                    why.push(format!("{} changed the count by {}", rung.label(), now as i64 - have as i64));
                    have = now;
                }
                Err(e) => why.push(format!("{}: {e}", rung.label())),
            }
        }
        if slot.is_none() {
            notes.push(format!("entity {i}: nothing selected it ({})", why.join("; ")));
        }
    }
    let count = s.count().unwrap_or(have).saturating_sub(base);
    Selected { count, via, notes }
}

/// The ray SelectByRay is given for a FACE: from 1 mm outside the picked point
/// along the outward normal, pointing back in. Its own ray — not the view's.
pub fn face_ray(point: V3, outward: V3) -> Option<(V3, V3)> {
    let l = norm(outward);
    if l < 1e-9 {
        return None;
    }
    let n = scale(outward, 1.0 / l);
    Some((add(point, scale(n, 1.0e-3)), scale(n, -1.0)))
}

/// The ray for an EDGE: from 1 mm out along the bisector of its two faces'
/// outward normals, pointing at the edge point. One normal will do.
pub fn edge_ray(point: V3, normals: &[V3]) -> Option<(V3, V3)> {
    let mut b = [0.0; 3];
    for n in normals {
        let l = norm(*n);
        if l > 1e-9 {
            b = add(b, scale(*n, 1.0 / l));
        }
    }
    face_ray(point, b)
}

/// A simulated install: which rungs really select. `measured_2026` is the
/// first live run — the VARIANT-array MultiSelect2 selected nothing.
#[derive(Clone, Debug)]
pub struct SelProfile {
    pub dead: Vec<Rung>,
}

impl Default for SelProfile {
    fn default() -> Self {
        SelProfile::measured_2026()
    }
}

impl SelProfile {
    pub fn measured_2026() -> Self {
        SelProfile { dead: vec![Rung::MultiSelectVariantArray] }
    }
}

/// The simulator's selection list: a rung on the dead list "succeeds" but
/// selects nothing (exactly what was measured live); a live one appends — and,
/// like SOLIDWORKS, re-selecting a selected entity DESELECTS it.
pub struct ProfileSelector {
    pub profile: SelProfile,
    pub selected: Vec<usize>,
    pub calls: Vec<Rung>,
}

impl ProfileSelector {
    pub fn new(profile: SelProfile) -> Self {
        ProfileSelector { profile, selected: Vec::new(), calls: Vec::new() }
    }
}

impl Selector for ProfileSelector {
    fn clear(&mut self) {
        self.selected.clear();
    }
    fn count(&mut self) -> Option<usize> {
        Some(self.selected.len())
    }
    fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String> {
        self.calls.push(rung);
        if self.profile.dead.contains(&rung) {
            return Ok(());
        }
        for i in items {
            if let Some(k) = self.selected.iter().position(|s| s == i) {
                self.selected.remove(k);
            } else {
                self.selected.push(*i);
            }
        }
        Ok(())
    }
}

/// What clear_part did (0.10.8).
#[derive(Clone, Debug, PartialEq)]
pub struct Cleared {
    pub passes: usize,
    pub deleted: Vec<String>,
}

/// clear_part could not empty the part.
#[derive(Clone, Debug, PartialEq)]
pub enum ClearError<E> {
    /// Something is still there after `passes` passes.
    Stuck { passes: usize, left: Vec<String>, deleted: Vec<String> },
    Host(E),
}

/// CLEAR_PART IN PASSES (0.10.8), shared by the COM host and the simulator.
///
/// MEASURED 2026-10-14 (0.10.7's self-test on SOLIDWORKS 2026): three checks
/// failed because one DeleteSelection2 over every feature after the origin
/// returned TRUE and left a top-level SKETCH behind (Sketch13, Sketch4,
/// Sketch9) — a sketch the previous check left unconsumed. So: walk, delete,
/// walk again, and repeat on whatever is left, up to `max_passes`. It succeeds
/// ONLY when the walk comes back empty. `deleted` is what each pass actually
/// removed (present before it, gone after it).
pub fn clear_in_passes<E>(
    mut remaining: impl FnMut() -> Result<Vec<String>, E>,
    mut delete: impl FnMut(&[String], usize) -> Result<(), E>,
    max_passes: usize,
) -> Result<Cleared, ClearError<E>> {
    let mut left = remaining().map_err(ClearError::Host)?;
    let mut deleted: Vec<String> = Vec::new();
    let mut passes = 0;
    while !left.is_empty() {
        if passes >= max_passes {
            return Err(ClearError::Stuck { passes, left, deleted });
        }
        passes += 1;
        delete(&left, passes).map_err(ClearError::Host)?;
        let after = remaining().map_err(ClearError::Host)?;
        deleted.extend(left.iter().filter(|n| !after.contains(n)).cloned());
        left = after;
    }
    Ok(Cleared { passes, deleted })
}

/// Read the interop probe's output (`guid=…`, `itype=…`, `tlflags=…`) into
/// the IID and whether the interface can be called through IDispatch:
/// InterfaceType Dual (0) or IDispatch (2), or TypeLibType FDual (0x40) /
/// FDispatchable (0x1000); an IUnknown-only interface (1) never.
pub fn parse_iid_probe(text: &str) -> Option<(u128, bool)> {
    let mut guid = None;
    let mut itype: Option<i64> = None;
    let mut flags: Option<i64> = None;
    for l in text.lines() {
        match l.trim().split_once('=') {
            Some(("guid", v)) => {
                let hex: String = v.trim().chars().filter(|c| *c != '-' && *c != '{' && *c != '}').collect();
                if hex.len() == 32 {
                    guid = u128::from_str_radix(&hex, 16).ok();
                }
            }
            Some(("itype", v)) => itype = v.trim().parse().ok(),
            Some(("tlflags", v)) => flags = v.trim().parse().ok(),
            _ => {}
        }
    }
    let g = guid.filter(|g| *g != 0)?;
    let dual = match itype {
        Some(1) => false,
        Some(0) | Some(2) => true,
        _ => flags.map(|f| f & (0x40 | 0x1000) != 0).unwrap_or(false),
    };
    Some((g, dual))
}

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

    #[test]
    fn transforms_planes_and_the_sketch_mapping() {
        let id = Xform::identity();
        assert_eq!(id.apply([1.0, 2.0, 3.0]), [1.0, 2.0, 3.0]);
        let [front, top, right] = PlaneFrame::defaults();
        assert_eq!(top.point(1.0, 2.0, 0.5), [1.0, 0.5, -2.0], "Top: v -> -Z (measured)");
        assert_eq!(front.point(1.0, 2.0, 0.5), [1.0, 2.0, 0.5]);
        assert_eq!(right.point(1.0, 2.0, 0.5), [0.5, 2.0, -1.0]);
        // A sketch whose own axes are the plane's: model -> sketch is the
        // inverse of the plane's transform. For Top at height 9.6 (a translated
        // plane), a point drawn at (u, v) comes back as (u, v, 0).
        let h = 9.6;
        let m2s = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, -1.0, 0.0]], t: [0.0, 0.0, 0.0], s: 1.0 };
        // rows: model X -> sketch x; model Y -> sketch -z... build it honestly:
        // sketch = (X, -Z, Y - h). Rows are images of model axes in sketch space.
        let m2s = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]], t: [0.0, 0.0, -h], ..m2s };
        let (e, z) = entity_to_sketch(&top, h, &Entity::Circle { cx: 3.0, cy: 4.0, r: 1.0 }, &m2s);
        assert_eq!(e, Entity::Circle { cx: 3.0, cy: 4.0, r: 1.0 });
        assert!(z < 1e-12);
        assert_eq!(m2s.source_z(), [0.0, 1.0, 0.0], "that sketch's normal is +Y");
        // A FLIPPED plane (normal −Y, x kept, y mirrored): the same model point
        // lands at a mirrored sketch y — and is still drawn where it belongs.
        let flipped = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]], t: [0.0, 0.0, h], s: 1.0 };
        let (e, z) = entity_to_sketch(&top, h, &Entity::Rect { x0: 0.0, y0: 0.0, x1: 4.0, y1: 6.0 }, &flipped);
        assert_eq!(e, Entity::Rect { x0: 0.0, y0: 0.0, x1: 4.0, y1: -6.0 });
        assert!(z < 1e-12);
        assert_eq!(flipped.source_z(), [0.0, -1.0, 0.0]);
        assert_eq!(default_index("Top Plane"), Some(1));
        assert_eq!(default_index("Ebene oben"), None);
        assert_eq!(parallel_default(&PlaneFrame::defaults(), [0.0, -1.0, 0.0]), Some(1));
        assert_eq!(parallel_default(&PlaneFrame::defaults(), [0.7, 0.7, 0.0]), None);
    }

    #[test]
    fn a_transform_that_runs_the_other_way_is_caught_and_inverted() {
        let top = PlaneFrame::defaults()[1];
        let h = 0.01;
        // A sketch-to-model transform handed over by mistake: model = (sx, h, -sy).
        let s2m = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]], t: [0.0, h, 0.0], s: 1.0 };
        let es = [Entity::Circle { cx: 0.003, cy: 0.004, r: 0.001 }];
        let (out, inverted) = map_entities(&top, h, &es, &s2m).unwrap();
        assert!(inverted);
        assert_eq!(out[0], Entity::Circle { cx: 0.003, cy: 0.004, r: 0.001 });
        // ...and the forward one is used when it is right.
        let (out, inverted) = map_entities(&top, h, &es, &s2m.inverse()).unwrap();
        assert!(!inverted);
        assert!(matches!(out[0], Entity::Circle { cx, cy, .. } if (cx - 0.003).abs() < 1e-12 && (cy - 0.004).abs() < 1e-12));
        assert!(PlaneFrame::defaults()[0].same_as(&PlaneFrame::defaults()[0]));
        assert!(!PlaneFrame::defaults()[0].same_as(&PlaneFrame::defaults()[1]));
    }

    #[test]
    fn dir_follows_the_reference_whatever_the_sketch_normal() {
        // Sketch normal agrees with the reference: no flip.
        assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, 1.0, 0.0]), false), (false, false));
        // A bottom face's sketch on a plane SOLIDWORKS oriented +Y while the face
        // looks −Y: flipped, so the boss goes OUTWARD.
        assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, -1.0, 0.0]), false), (true, true));
        // ...and the caller's reverse flips it back.
        assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, -1.0, 0.0]), true), (false, true));
        assert_eq!(dir_flag(None, None, true), (true, false));
    }

    #[test]
    fn picks_are_geometric_and_never_guess() {
        // One face within tolerance: it.
        assert_eq!(pick(&[(0, 5.0, 0.0), (1, 1e-9, 0.0), (2, 3.0, 0.0)], 1e-6), Ok(1));
        // A point on an edge between two faces, slightly in front of one.
        assert_eq!(pick(&[(0, 1e-9, 1e-9), (1, 1e-9, -1e-3)], 1e-6), Ok(0));
        // Truly on both: ambiguous, never a guess.
        assert_eq!(pick(&[(0, 0.0, 0.0), (1, 0.0, 0.0)], 1e-6), Err(PickError::Ambiguous(2)));
        assert_eq!(pick(&[(0, 0.004, 0.0)], 1e-6), Err(PickError::None(Some(0.004))));
    }

    #[test]
    fn duplicates_are_removed_and_their_toggle_is_modelled() {
        let keys = ["e1", "e2", "e1", "e3", "e2"];
        assert_eq!(dedupe(&keys), vec![0, 1, 3]);
        assert_eq!(toggled_selection(&keys), vec!["e3"], "selected twice = deselected");
        let deduped: Vec<&str> = dedupe(&keys).into_iter().map(|i| keys[i]).collect();
        assert_eq!(toggled_selection(&deduped), vec!["e1", "e2", "e3"]);
    }

    #[test]
    fn spans_follow_the_ops_own_direction() {
        let e = |depth: f64, depth2: f64, direction: Direction, through_all: bool| Extent { depth, depth2, through_all, reverse: false, direction , up_to: None, draft: None, regions: Vec::new() };
        assert_eq!(extent_span(0.0, 1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (0.0, 5.0));
        assert_eq!(extent_span(0.0, -1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (-5.0, 0.0));
        // The audit's T27: a cut on Front Plane (own direction −Z), both 6 / 2.
        assert_eq!(extent_span(0.0, -1.0, &e(6.0, 2.0, Direction::Both, false), (0.0, 10.0)), (-6.0, 2.0));
        assert_eq!(extent_span(0.0, 1.0, &e(5.0, 3.0, Direction::Both, false), (0.0, 10.0)), (-3.0, 5.0));
        assert_eq!(extent_span(0.0, 1.0, &e(10.0, 10.0, Direction::MidPlane, false), (0.0, 10.0)), (-5.0, 5.0));
        assert_eq!(extent_span(0.0, 1.0, &e(0.0, 0.0, Direction::One, true), (0.0, 10.0)), (0.0, 10.0));
    }

    /// A backend whose rungs CLAIM success; only some really select.
    #[test]
    fn the_ladder_believes_the_count_not_the_call() {
        // As measured on SOLIDWORKS 2026 with the typed array ALSO dead: every
        // entity falls through to IEntity::Select4.
        let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectVariantArray, Rung::MultiSelectTyped] });
        let r = select_ladder(&mut s, 3);
        assert!(r.all(3), "{r:?}");
        assert_eq!(r.via, vec![Some(Rung::EntitySelect4); 3]);
        assert_eq!(r.report(), serde_json::json!("IEntity.Select4"));
        assert!(!r.view_dependent());
        assert!(r.notes[0].contains("selected 0 of 3"), "{:?}", r.notes);
        // The typed batch works: one call, nothing else tried.
        let mut s = ProfileSelector::new(SelProfile::measured_2026());
        let r = select_ladder(&mut s, 12);
        assert!(r.all(12));
        assert_eq!(s.calls, vec![Rung::MultiSelectTyped]);
        // Everything but the view-dependent last resort dead: it is used, and SAID.
        let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectTyped, Rung::EntitySelect4, Rung::SelectByRay] });
        let r = select_ladder(&mut s, 1);
        assert!(r.all(1));
        assert!(r.view_dependent());
        assert_eq!(r.report(), serde_json::json!("SelectByID2 at the point (view-dependent)"));
        // Nothing works: nothing is claimed.
        let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectTyped, Rung::EntitySelect4, Rung::SelectByRay, Rung::SelectByIdAtPoint] });
        let r = select_ladder(&mut s, 2);
        assert_eq!(r.count, 0);
        assert!(!r.all(2));
        assert_eq!(r.report(), serde_json::json!(null));
        assert_eq!(r.notes.len(), 3, "the batch, then one line per entity: {:?}", r.notes);
    }

    /// A batch that half-worked is CLEARED before the per-entity rungs, or
    /// re-selecting its survivors would toggle them OFF.
    #[test]
    fn a_partial_batch_is_cleared_first() {
        struct Half(ProfileSelector);
        impl Selector for Half {
            fn clear(&mut self) {
                self.0.clear()
            }
            fn count(&mut self) -> Option<usize> {
                self.0.count()
            }
            fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String> {
                if rung == Rung::MultiSelectTyped {
                    return self.0.attempt(rung, &items[..1]);
                }
                self.0.attempt(rung, items)
            }
        }
        let mut s = Half(ProfileSelector::new(SelProfile::measured_2026()));
        let r = select_ladder(&mut s, 2);
        assert!(r.all(2), "{r:?}");
        assert_eq!(r.via, vec![Some(Rung::EntitySelect4); 2]);
        assert_eq!(s.0.selected, vec![0, 1]);
        // The simulator toggles like SOLIDWORKS: selected twice = deselected.
        let mut t = ProfileSelector::new(SelProfile::measured_2026());
        t.attempt(Rung::EntitySelect4, &[0]).unwrap();
        t.attempt(Rung::SelectByRay, &[0]).unwrap();
        assert_eq!(t.count(), Some(0));
    }

    #[test]
    fn rays_start_outside_and_point_back_in() {
        let (o, d) = face_ray([0.0, 0.01, 0.0], [0.0, 2.0, 0.0]).unwrap();
        assert!((o[1] - 0.011).abs() < 1e-12 && d == [0.0, -1.0, 0.0]);
        assert!(face_ray([0.0; 3], [0.0; 3]).is_none(), "no normal, no ray");
        // A box's top-front edge: the bisector of +Y and +Z.
        let (o, d) = edge_ray([0.0, 0.01, 0.01], &[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap();
        let k = 1.0e-3 / 2f64.sqrt();
        assert!((o[1] - (0.01 + k)).abs() < 1e-12 && (o[2] - (0.01 + k)).abs() < 1e-12, "{o:?}");
        assert!((d[1] + 0.5f64.sqrt()).abs() < 1e-12 && (d[2] + 0.5f64.sqrt()).abs() < 1e-12);
        // Unnormalised normals still give the BISECTOR (each counts once).
        let (_, d2) = edge_ray([0.0; 3], &[[0.0, 3.0, 0.0], [0.0, 0.0, 1.0]]).unwrap();
        assert!((d2[1] - d2[2]).abs() < 1e-12, "{d2:?}");
        // One known face is enough.
        assert!(edge_ray([0.0; 3], &[[0.0, 0.0, 3.0], [0.0; 3]]).is_some());
    }

    #[test]
    fn clear_part_repeats_until_the_tree_is_empty() {
        use std::cell::RefCell;
        // THE MEASURED CASE: the first delete takes the features and leaves a sketch.
        let tree = RefCell::new(vec!["Boss-Extrude1".to_string(), "Cut-Extrude1".to_string(), "Sketch13".to_string()]);
        let r = clear_in_passes(
            || Ok::<_, String>(tree.borrow().clone()),
            |names: &[String], pass| {
                let mut t = tree.borrow_mut();
                if pass == 1 {
                    t.retain(|n| n.starts_with("Sketch") && names.contains(n));
                } else {
                    t.clear();
                }
                Ok(())
            },
            5,
        );
        assert_eq!(r, Ok(Cleared { passes: 2, deleted: vec!["Boss-Extrude1".into(), "Cut-Extrude1".into(), "Sketch13".into()] }));
        // Nothing there: no pass at all.
        assert_eq!(clear_in_passes(|| Ok::<_, String>(vec![]), |_: &[String], _| Ok(()), 5), Ok(Cleared { passes: 0, deleted: vec![] }));
        // Something that never goes: stuck after exactly max passes, and SAID.
        let calls = RefCell::new(0);
        let r = clear_in_passes(
            || Ok::<_, String>(vec!["Sketch9".to_string()]),
            |_: &[String], _| {
                *calls.borrow_mut() += 1;
                Ok(())
            },
            5,
        );
        assert_eq!(r, Err(ClearError::Stuck { passes: 5, left: vec!["Sketch9".into()], deleted: vec![] }));
        assert_eq!(*calls.borrow(), 5);
        // A host failure stops it, as a host failure.
        let r = clear_in_passes(|| Ok::<_, String>(vec!["A".into()]), |_: &[String], _| Err("COM said no".to_string()), 5);
        assert_eq!(r, Err(ClearError::Host("COM said no".to_string())));
    }

    #[test]
    fn the_iid_probe_is_read_strictly() {
        let g = "guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\nitype=0\n";
        assert_eq!(parse_iid_probe(g), Some((0x4c1e9cc6_3d6d_11d5_9a8c_00c04f80e3e7, true)));
        assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\ntlflags=4160").map(|x| x.1), Some(true));
        assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\nitype=1\ntlflags=4160").map(|x| x.1), Some(false), "IUnknown-only is never called by name");
        assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7").map(|x| x.1), Some(false), "no evidence, no call");
        assert_eq!(parse_iid_probe("guid=00000000-0000-0000-0000-000000000000\nitype=0"), None);
        assert_eq!(parse_iid_probe("nothing"), None);
    }

    #[test]
    fn contour_moments_are_greens_theorem_exactly() {
        use crate::script::Entity;
        let l = |x0: f64, y0: f64, x1: f64, y1: f64| Entity::Line { x0, y0, x1, y1, construction: false };
        // The unit square counter-clockwise: A 1, centroid (½, ½).
        let sq = [l(0.0, 0.0, 1.0, 0.0), l(1.0, 0.0, 1.0, 1.0), l(1.0, 1.0, 0.0, 1.0), l(0.0, 1.0, 0.0, 0.0)];
        let c: Vec<(usize, bool)> = (0..4).map(|i| (i, false)).collect();
        let (a, sx, sy) = contour_moments(&sq, &c);
        assert!((a - 1.0).abs() < 1e-15 && (sx - 0.5).abs() < 1e-15 && (sy - 0.5).abs() < 1e-15, "{a} {sx} {sy}");
        // Walked backwards: the same moments, negated.
        let back: Vec<(usize, bool)> = (0..4).rev().map(|i| (i, true)).collect();
        let (b, bx, by) = contour_moments(&sq, &back);
        assert!((b + 1.0).abs() < 1e-15 && (bx + 0.5).abs() < 1e-15 && (by + 0.5).abs() < 1e-15);
        // A half disc r 2 about (3, 1), the arc on top: A = 2π, ȳ = 1 + 8/(3π).
        let hd = [
            Entity::Arc { cx: 3.0, cy: 1.0, x0: 5.0, y0: 1.0, x1: 1.0, y1: 1.0, ccw: true, construction: false },
            l(1.0, 1.0, 5.0, 1.0),
        ];
        let (a, sx, sy) = contour_moments(&hd, &[(0, false), (1, false)]);
        assert!((a - 2.0 * std::f64::consts::PI).abs() < 1e-12, "{a}");
        assert!((sx / a - 3.0).abs() < 1e-12 && (sy / a - (1.0 + 8.0 / (3.0 * std::f64::consts::PI))).abs() < 1e-12, "{} {}", sx / a, sy / a);
        // The same arc clockwise is the lower half: ȳ = 1 − 8/(3π), and the
        // contour (arc then line back) runs clockwise, so A is negative.
        let lo = [Entity::Arc { cx: 3.0, cy: 1.0, x0: 5.0, y0: 1.0, x1: 1.0, y1: 1.0, ccw: false, construction: false }, l(1.0, 1.0, 5.0, 1.0)];
        let (a, _, sy) = contour_moments(&lo, &[(0, false), (1, false)]);
        assert!((a + 2.0 * std::f64::consts::PI).abs() < 1e-12 && (sy / a - (1.0 - 8.0 / (3.0 * std::f64::consts::PI))).abs() < 1e-12);
    }

    #[test]
    fn a_mirroring_sketch_map_flips_an_arc() {
        use crate::script::Entity;
        let front = PlaneFrame::defaults()[0];
        let arc = Entity::Arc { cx: 0.0, cy: 0.0, x0: 1.0, y0: 0.0, x1: 0.0, y1: 1.0, ccw: true, construction: false };
        let (same, _) = entity_to_sketch(&front, 0.0, &arc, &Xform::identity());
        assert!(matches!(same, Entity::Arc { ccw: true, .. }));
        // A sketch whose x runs the other way (a plane SOLIDWORKS flipped).
        let flip = Xform { rows: [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], t: [0.0; 3], s: 1.0 };
        assert!(mapping_reflects(&front, 0.0, &flip));
        let (m, z) = entity_to_sketch(&front, 0.0, &arc, &flip);
        assert!(matches!(m, Entity::Arc { ccw: false, x0, .. } if x0 == -1.0) && z == 0.0, "{m:?}");
        let line = Entity::Line { x0: 1.0, y0: 2.0, x1: 3.0, y1: 4.0, construction: true };
        assert_eq!(entity_to_sketch(&front, 0.0, &line, &flip).0, Entity::Line { x0: -1.0, y0: 2.0, x1: -3.0, y1: 4.0, construction: true });
    }

    /// MEASURED 2026-10-16 on SOLIDWORKS 2026 (canary 0.11.4-rc1): "up to surface" is
    /// swEndCondUpToSurface 4 (10, the help's UpToSelection, left the direction box
    /// empty and stopped at the next face), and Ddir1 TRUE drafts OUTWARD (the help
    /// says inward; a 5 deg boss and a 45 deg countersink both widened with TRUE).
    #[test]
    fn end_condition_ids_and_the_draft_flag_as_solidworks_measured_them() {
        use crate::script::{Draft, UpTo};
        assert_eq!(end_condition_id(&UpTo::Next), 11);
        assert_eq!(end_condition_id(&UpTo::Surface { point: [0.0, 0.0, 0.0] }), 4, "swEndCondUpToSurface, the macro recorder's id");
        assert_eq!(end_condition_id(&UpTo::OffsetFrom { point: [0.0, 0.0, 0.0], offset: 0.008, reverse_offset: false }), 5);
        assert!(!draft_outward_flag(&Draft { angle: 0.1, outward: false }), "inward (the default, a countersink) = Ddir FALSE");
        assert!(draft_outward_flag(&Draft { angle: 0.1, outward: true }), "outward = Ddir TRUE");
    }
}