main
Drew Owens 0.11.4 source 8889116 1d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688
//! The `swx_run_script` modeling DSL — parsed here, executed on the com host.
//!
//! Fusion's escape hatch (`fusion_run_modeling_script`) is a Python string
//! evaluated inside Fusion, because Fusion HAS a Python interpreter in-process.
//! SOLIDWORKS has VBA, which is a macro *file* format driven through a UI, not
//! an in-process eval — so a faithful copy of Fusion's verb is not available.
//!
//! What IS available, and what the verb actually has to do, is build real
//! geometry deterministically: the analysis verbs (mass properties, bodies,
//! BOM, export) are worthless without a body to point them at, and the live
//! test suite has to be able to MAKE one. So the escape hatch here is a small
//! typed DSL over the SOLIDWORKS calls proven to work on a live install:
//!
//! ```json
//! {"script": [
//!    {"op": "new_part"},
//!    {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 50, "heightMm": 30},
//!    {"op": "extrude", "depthMm": 20},
//!    {"op": "set_material", "name": "6061 Alloy"},
//!    {"op": "set_prop", "name": "PartNo", "value": "BRK-100"},
//!    {"op": "save_as", "path": "C:\\parts\\bracket.sldprt"}
//! ]}
//! ```
//!
//! Every dimension the caller writes is in MILLIMETRES (what a mechanical
//! engineer says out loud, and what `swx_inspect_bodies` reports back);
//! SOLIDWORKS' API is metres, and the conversion happens exactly once, here.
//!
//! Parsing lives on the server side, outside `cfg(windows)`, so a malformed
//! script is refused with a precise message BEFORE anything touches the live
//! CAD session — and so the whole grammar is unit-tested on this Linux host.

use serde_json::Value;

/// Millimetres to metres — the single place the unit boundary is crossed.
pub const MM: f64 = 0.001;

/// WHERE A SKETCH LIVES (0.10.0, issue #1).
///
/// COORDINATE CONVENTIONS — stated, because Michael (issue #1) had to work them
/// out by testing:
///   * a sketch's (x, y) are the PLANE's own 2D axes. On **Top Plane**, sketch
///     +X is model +X and sketch +Y is model **−Z** (measured by Michael on
///     SOLIDWORKS 2021). On Front Plane sketch (x, y) is model (X, Y).
///   * an OFFSET plane keeps its base plane's axes; it sits `offsetMm` along the
///     base plane's normal (Top +Y, Front +Z, Right +X).
///   * a FACE is resolved to the offset plane that coincides with it, so its
///     sketch axes are the matching default plane's axes too (only faces parallel
///     to a default plane are accepted).
#[derive(Debug, Clone, PartialEq)]
pub enum PlaneRef {
    /// A plane by name: "Front Plane", "Top Plane", "Right Plane", or any
    /// reference plane in the part.
    Named(String),
    /// A reference plane `offset` metres along `from`'s normal (negative = the
    /// other side). Created by the script and owned by it.
    Offset { from: String, offset: f64 },
    /// The planar face of the part that passes through `point` (metres).
    Face { point: [f64; 3] },
    /// A plane `offset` metres from the planar face through `point`, along the
    /// face's OUTWARD normal (negative = into the part) — 0.11.0, CSWA Tool Block
    /// step 3's "plane 12 from the back face". Built as an offset of the
    /// parallel default plane; a sketch on it keeps that plane's axes, and the
    /// face's outward normal stays the reference (a cut goes inward).
    FaceOffset { point: [f64; 3], offset: f64 },
}

impl PlaneRef {
    pub fn describe(&self) -> Value {
        match self {
            PlaneRef::Named(n) => Value::String(n.clone()),
            PlaneRef::Offset { from, offset } => serde_json::json!({"offsetFrom": from, "offsetMm": offset / MM}),
            PlaneRef::Face { point } => serde_json::json!({"face": {"atPointMm": [point[0] / MM, point[1] / MM, point[2] / MM]}}),
            PlaneRef::FaceOffset { point, offset } => serde_json::json!({"offsetFrom": {"face": {"atPointMm": [point[0] / MM, point[1] / MM, point[2] / MM]}}, "offsetMm": offset / MM}),
        }
    }
}

/// One sketch entity, in the plane's 2D coordinates (metres).
/// A rectangle is stored by its two CORNERS — the `anchor` was resolved at
/// parse time, so both backends draw exactly the same thing.
///
/// LINES AND ARCS (0.11.0) are the segments of closed CONTOURS: every
/// composite the caller writes — `polyline`, `slot`, `polygon`, a rectangle
/// with `cornerRadiusMm`, `arc3`, `tangentArc` — is EXPANDED at parse time into
/// these two, so both backends draw (and the simulator measures) exactly the
/// same segments, and the contours are checked closed before anything is drawn
/// (`contours`). A `construction` segment is drawn as construction geometry: it
/// is never part of a profile (a revolve axis, a reference line).
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Entity {
    Rect { x0: f64, y0: f64, x1: f64, y1: f64 },
    Circle { cx: f64, cy: f64, r: f64 },
    /// A straight segment from (x0, y0) to (x1, y1).
    Line { x0: f64, y0: f64, x1: f64, y1: f64, construction: bool },
    /// A circular arc about (cx, cy) from (x0, y0) to (x1, y1), counter-clockwise
    /// when `ccw` (in the plane's own 2D frame). Its radius is |start − centre|.
    Arc { cx: f64, cy: f64, x0: f64, y0: f64, x1: f64, y1: f64, ccw: bool, construction: bool },
}

impl Entity {
    pub fn describe(&self) -> Value {
        match self {
            Entity::Rect { x0, y0, x1, y1 } => serde_json::json!({"rect": {"cornersMm": [[x0 / MM, y0 / MM], [x1 / MM, y1 / MM]]}}),
            Entity::Circle { cx, cy, r } => serde_json::json!({"circle": {"centerMm": [cx / MM, cy / MM], "radiusMm": r / MM}}),
            Entity::Line { x0, y0, x1, y1, construction } => {
                let mut v = serde_json::json!({"line": {"fromMm": [x0 / MM, y0 / MM], "toMm": [x1 / MM, y1 / MM]}});
                if *construction {
                    v["line"]["construction"] = Value::Bool(true);
                }
                v
            }
            Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => {
                let mut v = serde_json::json!({"arc": {"centerMm": [cx / MM, cy / MM], "startMm": [x0 / MM, y0 / MM],
                    "endMm": [x1 / MM, y1 / MM], "radiusMm": ((x0 - cx).hypot(y0 - cy)) / MM, "ccw": ccw}});
                if *construction {
                    v["arc"]["construction"] = Value::Bool(true);
                }
                v
            }
        }
    }

    /// A segment of a contour: a line or an arc that is not construction.
    pub fn is_segment(&self) -> bool {
        matches!(self, Entity::Line { construction: false, .. } | Entity::Arc { construction: false, .. })
    }

    pub fn is_construction(&self) -> bool {
        matches!(self, Entity::Line { construction: true, .. } | Entity::Arc { construction: true, .. })
    }

    /// A line's or an arc's two ends (start, end).
    pub fn ends(&self) -> Option<((f64, f64), (f64, f64))> {
        match *self {
            Entity::Line { x0, y0, x1, y1, .. } | Entity::Arc { x0, y0, x1, y1, .. } => Some(((x0, y0), (x1, y1))),
            _ => None,
        }
    }
}

/// How close two segment ends must be to JOIN (1e-6 mm, in metres).
pub const JOIN_TOL: f64 = 1e-9;

/// THE CLOSED CONTOURS of a sketch's lines and arcs (0.11.0): each contour is
/// its segments in order, as (entity index, reversed). A segment end that
/// joins nothing is an OPEN profile and is refused, naming the gap; a point
/// where three or more segments meet is a branch and is refused too.
/// Rectangles and circles are closed by themselves and are not listed;
/// construction geometry is never part of a contour.
pub fn contours(entities: &[Entity]) -> Result<Vec<Vec<(usize, bool)>>, (usize, String)> {
    let segs: Vec<usize> = (0..entities.len()).filter(|i| entities[*i].is_segment()).collect();
    let ends = |i: usize| entities[i].ends().unwrap_or(((0.0, 0.0), (0.0, 0.0)));
    let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).hypot(a.1 - b.1) <= JOIN_TOL;
    let fmt = |p: (f64, f64)| format!("[{}, {}] mm", trim_mm(p.0), trim_mm(p.1));
    for &i in &segs {
        for p in [ends(i).0, ends(i).1] {
            let n: usize = segs.iter().map(|&j| [ends(j).0, ends(j).1].iter().filter(|q| close(p, **q)).count()).sum();
            if n > 2 {
                return Err((i, format!("{n} segment ends meet at {} — a profile's corner joins exactly two (a branch cannot be extruded)", fmt(p))));
            }
        }
    }
    let mut used = vec![false; entities.len()];
    let mut out = Vec::new();
    for &first in &segs {
        if used[first] {
            continue;
        }
        used[first] = true;
        let start = ends(first).0;
        let mut cur = ends(first).1;
        let mut contour = vec![(first, false)];
        while !close(cur, start) {
            let next = segs.iter().copied().find(|&j| !used[j] && (close(ends(j).0, cur) || close(ends(j).1, cur)));
            match next {
                Some(j) => {
                    used[j] = true;
                    let rev = !close(ends(j).0, cur);
                    contour.push((j, rev));
                    cur = if rev { ends(j).0 } else { ends(j).1 };
                }
                None => {
                    // The gap: from the dangling end to the nearest end it could
                    // have joined (the contour's own start included).
                    let mut best: Option<((f64, f64), f64)> = Some((start, (cur.0 - start.0).hypot(cur.1 - start.1)));
                    for &j in segs.iter().filter(|j| !used[**j]) {
                        for q in [ends(j).0, ends(j).1] {
                            let d = (cur.0 - q.0).hypot(cur.1 - q.1);
                            if best.map(|b| d < b.1).unwrap_or(true) {
                                best = Some((q, d));
                            }
                        }
                    }
                    let (q, d) = best.unwrap_or((start, 0.0));
                    return Err((
                        contour.last().map(|c| c.0).unwrap_or(first),
                        format!(
                            "the contour is OPEN: it ends at {} and no segment continues from there — the nearest free end is {}, a gap of {} mm. A profile must close (its last point equal to its first within 1e-6 mm); mark a reference line construction:true",
                            fmt(cur),
                            fmt(q),
                            trim_mm(d)
                        ),
                    ));
                }
            }
        }
        out.push(contour);
    }
    Ok(out)
}

/// Metres as millimetres for a message, without float noise.
fn trim_mm(m: f64) -> String {
    let v = (m / MM * 1.0e6).round() / 1.0e6;
    if v == v.trunc() { format!("{v:.1}") } else { format!("{v}") }
}

/// The one-entity shorthands' result fields (`widthMm`/`heightMm`,
/// `radiusMm`), kept so a sketch_rect / sketch_circle step reports what it
/// always reported.
pub fn single_entity_fields(entities: &[Entity], out: &mut Value) {
    if let (Some(e), true, Value::Object(m)) = (entities.first(), entities.len() == 1, out) {
        match *e {
            Entity::Rect { x0, y0, x1, y1 } => {
                m.insert("widthMm".into(), serde_json::json!((x1 - x0).abs() / MM));
                m.insert("heightMm".into(), serde_json::json!((y1 - y0).abs() / MM));
            }
            Entity::Circle { r, .. } => {
                m.insert("radiusMm".into(), serde_json::json!(r / MM));
            }
            _ => {}
        }
    }
}

/// Which way(s) an extrude or cut goes from its sketch plane.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Direction {
    /// One direction: along the plane's normal for an extrude, along its
    /// −normal for a cut (measured, issue #1); `reverse` flips it.
    One,
    /// Two directions: `depth` one way and `depth2` the other.
    Both,
    /// Symmetric about the sketch plane, `depth` in total.
    MidPlane,
}

/// The extent of an extrude or a cut.
#[derive(Debug, Clone, PartialEq)]
pub struct Extent {
    pub depth: f64,
    pub depth2: f64,
    pub through_all: bool,
    pub reverse: bool,
    pub direction: Direction,
    /// An end condition other than a depth (0.11.0): up to the next face, up
    /// to a face, or a distance short of a face.
    pub up_to: Option<UpTo>,
    /// A taper (0.11.0): `angle` radians from the extrude direction; inward
    /// (the profile shrinks as it goes) unless `outward`.
    pub draft: Option<Draft>,
    /// SKETCH REGIONS (0.11.4): extrude only the regions of the sketch that
    /// hold these points ((u, v) metres, the sketch's own coordinates) —
    /// SOLIDWORKS' "Selected Contours", for a sketch whose contours CROSS
    /// (an offset outline and a circle). Empty: the whole sketch.
    pub regions: Vec<[f64; 2]>,
}

impl Extent {
    /// A plain blind extent `depth` one way.
    pub fn blind(depth: f64) -> Extent {
        Extent { depth, depth2: depth, through_all: false, reverse: false, direction: Direction::One, up_to: None, draft: None, regions: Vec::new() }
    }
}

/// OFFSET ENTITIES (0.11.4): a model face's edges offset into the sketch by
/// SOLIDWORKS itself (IModelDoc2::SketchOffsetEntities2) — an outline the DSL
/// cannot draw, because it is the MODEL's (the CSWA Tool Block's Step 4
/// pocket: the front face's outline 1 mm in).
#[derive(Debug, Clone, PartialEq)]
pub struct OffsetFace {
    /// A point ON the face (metres), picked geometrically.
    pub point: [f64; 3],
    /// Metres, never 0: NEGATIVE = INWARD (the new outline lies inside the
    /// face), positive = outward. SOLIDWORKS' own sign does not decide the
    /// side (it varies by face): the bridge checks where the new entities
    /// landed and offsets the other way if they landed outside.
    pub offset: f64,
    /// Which of the face's edges.
    pub loops: OffsetLoops,
    /// SketchOffsetEntities2's `Chain`: carry the offset along every edge
    /// contiguous with the selected ones.
    pub chain: bool,
}

/// The edges of a face that Offset Entities takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffsetLoops {
    /// The face's OUTER loop only (its edges selected): holes in the face are
    /// left out of the sketch.
    Outer,
    /// The whole FACE selected: every loop, a hole's edge offset away from
    /// the face's material too.
    All,
}

impl OffsetFace {
    pub fn describe(&self) -> serde_json::Value {
        serde_json::json!({"offsetEntities": {
            "fromFace": {"atPointMm": [self.point[0] / MM, self.point[1] / MM, self.point[2] / MM]},
            "offsetMm": self.offset / MM,
            "side": if self.offset < 0.0 { "inward" } else { "outward" },
            "loops": match self.loops { OffsetLoops::Outer => "outer", OffsetLoops::All => "all" },
            "chain": self.chain,
        }})
    }
}

/// END CONDITIONS beyond blind / through all (swEndConditions_e, verified on
/// help.solidworks.com: UpToNext 11, UpToSelection 10 — UpToSurface 4 is the
/// deprecated id — and OffsetFromSurface 5).
#[derive(Debug, Clone, PartialEq)]
pub enum UpTo {
    /// Up to the next face the profile meets.
    Next,
    /// Up to the face through `point` (metres), picked geometrically.
    Surface { point: [f64; 3] },
    /// `offset` metres SHORT of the face through `point` (`reverse_offset`:
    /// beyond it).
    OffsetFrom { point: [f64; 3], offset: f64, reverse_offset: bool },
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Draft {
    pub angle: f64,
    pub outward: bool,
}

/// A revolve's axis as the caller wrote it (0.11.0): a line entity of the
/// sketch, a line given by two points, or the sketch's own x / y axis.
#[derive(Debug, Clone, PartialEq)]
pub enum AxisSpec {
    /// The caller's entity `i` (0-based, as written) of the sketch — a `line`.
    Entity(usize),
    Line([f64; 2], [f64; 2]),
    SketchX,
    SketchY,
}

/// A revolve or a revolve-cut of the sketch just closed.
#[derive(Debug, Clone, PartialEq)]
pub struct Revolution {
    /// The axis, resolved at parse time to a LINE entity of the sketch (an
    /// axis given by points or as x / y is added to the sketch as a
    /// construction line).
    pub axis: usize,
    pub axis_spec: AxisSpec,
    /// Radians, (0, 2π].
    pub angle: f64,
    pub mid_plane: bool,
    pub reverse: bool,
}

/// A model axis (patterns, directions).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Axis {
    X,
    Y,
    Z,
}

impl Axis {
    pub fn name(&self) -> &'static str {
        match self {
            Axis::X => "x",
            Axis::Y => "y",
            Axis::Z => "z",
        }
    }
    /// The two default planes whose intersection IS this axis.
    pub fn planes(&self) -> (&'static str, &'static str) {
        match self {
            Axis::X => ("Top Plane", "Front Plane"),
            Axis::Y => ("Front Plane", "Right Plane"),
            Axis::Z => ("Top Plane", "Right Plane"),
        }
    }
}

/// Which edges a fillet or chamfer takes.
#[derive(Debug, Clone, PartialEq)]
pub enum EdgeSel {
    /// The edge passing through each point (metres).
    AtPoints(Vec<[f64; 3]>),
    /// Every edge of a feature's faces ("last" = the previous feature).
    AllOfFeature(String),
}

/// What an appearance is applied to.
#[derive(Debug, Clone, PartialEq)]
pub enum AppearanceTarget {
    Part,
    Body(String),
    FaceAt([f64; 3]),
}

#[derive(Debug, Clone, PartialEq)]
pub enum Op {
    /// Create a new part document from the resolved part template. `visible`
    /// false creates it HIDDEN (ISldWorks::DocumentVisible bracketed around
    /// NewDocument, 0.10.6): no window, no graphics context.
    NewPart { visible: bool },
    /// Create a new assembly document from the resolved assembly template.
    NewAssembly { visible: bool },
    /// DELETE EVERY FEATURE after the origin of a part the BRIDGE created
    /// (0.10.6): N geometry checks cost one document instead of N. Refused on
    /// any document the bridge did not create.
    ClearPart,
    /// ONE sketch holding every entity listed (0.10.0) — several closed
    /// profiles in one sketch extrude into rings, walls or hole patterns in ONE
    /// feature. `shorthand` is the op the caller wrote: "sketch", "sketch_rect"
    /// or "sketch_circle" (the last two are one-entity shorthands).
    Sketch { shorthand: &'static str, plane: PlaneRef, entities: Vec<Entity>, offsets: Vec<OffsetFace> },
    /// Extrude the sketch just closed.
    Extrude(Extent),
    /// Cut-extrude through the sketch just closed.
    Cut(Extent),
    /// A material from a library, and/or the part's density (0.11.0): with
    /// `density` alone (kg/m³) the document's own material density is set
    /// (no library material may be applied); with both, the material is
    /// applied and its density must be the one asked for. `name` is empty
    /// when only a density is given.
    SetMaterial { name: String, database: String, density: Option<f64> },
    /// Colour, as 0..1 RGB (the caller writes 0..255).
    SetAppearance { rgb: [f64; 3], target: AppearanceTarget },
    Fillet { radius: f64, edges: EdgeSel },
    /// An angle-distance chamfer: `distance` metres, `angle` radians.
    Chamfer { distance: f64, angle: f64, edges: EdgeSel },
    /// Hollow the part, removing the faces through `faces`.
    Shell { thickness: f64, faces: Vec<[f64; 3]>, outward: bool },
    /// Revolve the sketch just closed about a line of it (0.11.0).
    Revolve(Revolution),
    /// Revolve-cut the sketch just closed (FeatureRevolve2 with IsCut).
    RevolveCut(Revolution),
    /// Mirror features (or every body) about a plane or a planar face (0.11.0).
    Mirror { features: Vec<String>, bodies: bool, plane: PlaneRef },
    /// A HOLE at a point on a planar face (0.11.0): cuts of circles sketched on
    /// that face's plane — a counterbore or a countersink first, then the
    /// hole from where it ends. `depth` None = through all.
    Hole { at: [f64; 3], diameter: f64, depth: Option<f64>, counterbore: Option<(f64, f64)>, countersink: Option<(f64, f64)> },
    /// Pattern a feature along one or two model axes.
    LinearPattern { feature: String, dirs: Vec<(Axis, u32, f64)> },
    /// Pattern a feature about a model axis; `angle` is the TOTAL angle (radians).
    CircularPattern { feature: String, axis: Axis, count: u32, angle: f64 },
    SetProp { name: String, value: String },
    SaveAs { path: String },
    Rebuild,
    ZoomFit,
}

impl Op {
    /// The slug used in the DSL and echoed in the per-step result.
    pub fn name(&self) -> &'static str {
        match self {
            Op::NewPart { .. } => "new_part",
            Op::NewAssembly { .. } => "new_assembly",
            Op::ClearPart => "clear_part",
            Op::Sketch { shorthand, .. } => shorthand,
            Op::Extrude(_) => "extrude",
            Op::Cut(_) => "cut",
            Op::SetMaterial { .. } => "set_material",
            Op::SetAppearance { .. } => "set_appearance",
            Op::Fillet { .. } => "fillet",
            Op::Chamfer { .. } => "chamfer",
            Op::Shell { .. } => "shell",
            Op::Revolve(_) => "revolve",
            Op::RevolveCut(_) => "revolve_cut",
            Op::Mirror { .. } => "mirror",
            Op::Hole { .. } => "hole",
            Op::LinearPattern { .. } => "linear_pattern",
            Op::CircularPattern { .. } => "circular_pattern",
            Op::SetProp { .. } => "set_prop",
            Op::SaveAs { .. } => "save_as",
            Op::Rebuild => "rebuild",
            Op::ZoomFit => "zoom_fit",
        }
    }

    /// Does this op SELECT something in the document? SOLIDWORKS selects only in
    /// its ACTIVE document — a plane, a sketch, a face, an edge, a feature — and
    /// a document created invisible cannot be activated (MEASURED 2026-10-14:
    /// `new_part {visible:false}` then `sketch_rect` on Top Plane failed "could
    /// not activate 'Part1'"). So these ops cannot run in a hidden document.
    pub fn needs_selection(&self) -> bool {
        matches!(
            self,
            Op::Sketch { .. }
                | Op::Extrude(_)
                | Op::Cut(_)
                | Op::Fillet { .. }
                | Op::Chamfer { .. }
                | Op::Shell { .. }
                | Op::Revolve(_)
                | Op::RevolveCut(_)
                | Op::Mirror { .. }
                | Op::Hole { .. }
                | Op::LinearPattern { .. }
                | Op::CircularPattern { .. }
                | Op::ClearPart
                | Op::SetAppearance { target: AppearanceTarget::FaceAt(_), .. }
        )
    }

    /// Does this op make a FEATURE in the tree (the one "last" refers to)?
    pub fn makes_feature(&self) -> bool {
        matches!(
            self,
            Op::Extrude(_) | Op::Cut(_) | Op::Fillet { .. } | Op::Chamfer { .. } | Op::Shell { .. }
                | Op::LinearPattern { .. } | Op::CircularPattern { .. }
                | Op::Revolve(_) | Op::RevolveCut(_) | Op::Mirror { .. } | Op::Hole { .. }
        )
    }
}

/// Every op slug, for the unknown-op hint and for `swx_describe`.
pub const OPS: &[&str] = &[
    "new_part",
    "new_assembly",
    "clear_part",
    "sketch",
    "sketch_rect",
    "sketch_circle",
    "extrude",
    "cut",
    "set_material",
    "set_appearance",
    "fillet",
    "chamfer",
    "shell",
    "revolve",
    "revolve_cut",
    "mirror",
    "hole",
    "linear_pattern",
    "circular_pattern",
    "set_prop",
    "save_as",
    "rebuild",
    "zoom_fit",
];

fn mm(step: &Value, key: &str, default: Option<f64>) -> Result<f64, String> {
    match step.get(key).and_then(|v| v.as_f64()) {
        Some(v) if v.is_finite() => Ok(v * MM),
        Some(_) => Err(format!("{key} must be a finite number of millimetres")),
        None => default.map(|d| d * MM).ok_or_else(|| format!("{key} (millimetres) is required")),
    }
}

fn text(step: &Value, key: &str) -> Result<String, String> {
    step.get(key)
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .ok_or_else(|| format!("{key} is required"))
}

fn flag(step: &Value, key: &str, default: bool) -> bool {
    step.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
}

/// Destination-path rules for the DSL's `save_as`, applied AT PARSE TIME.
///
/// The standalone `swx_save_as` verb has always run its destination through
/// `safe_out`; the DSL's `save_as` reached `SaveAs3` by a different road and
/// skipped it entirely, so a relative path resolved against the bridge's own
/// working directory and a `.wrl` destination walked straight past the block on
/// the format that hangs SOLIDWORKS. Checking here means both roads meet the
/// same rules, and a bad path is refused before the script starts rather than
/// halfway through it.
fn safe_dest(path: &str) -> Result<String, String> {
    let p = std::path::Path::new(path);
    let b = path.as_bytes();
    let drive_rooted =
        b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/');
    if !(p.is_absolute() || path.starts_with('\\') || drive_rooted) {
        return Err(format!(
            "save_as path must be ABSOLUTE, got '{path}' — a relative path would resolve against              the bridge's own working directory"
        ));
    }
    if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
        return Err(format!("save_as path must not contain a '..' component: {path}"));
    }
    // The SAME normalisation the verb layer uses: `a.wrl.`, `a.wrl ` and
    // `a.wrl::$DATA` all resolve to `a.wrl` on Windows.
    let ext = crate::verbs3::normalized_extension(path).unwrap_or_default();
    if BLOCKED_DEST_EXTENSIONS.contains(&ext.as_str()) {
        return Err(format!(
            "save_as would write a .{ext} file, which this bridge refuses: VRML export raises a              modal option sheet no unattended caller can answer, and on a live install it hung              SOLIDWORKS until the watchdog killed the host"
        ));
    }
    Ok(path.to_string())
}

/// Kept in step with `verbs3::BLOCKED_FORMATS`; duplicated here rather than
/// imported so the grammar module stays free of the verb layer (and so this
/// rule is unit-testable on its own). The pairing is asserted by a test.
pub const BLOCKED_DEST_EXTENSIONS: &[&str] = &["wrl", "vrml"];

/// One component insertion for `swx_add_component`, position in METRES.
///
/// Lives here, beside the modelling DSL, for the same reason the DSL does: this
/// is where millimetres become metres, exactly once, on the SERVER, so a bad
/// request is refused before anything touches the live CAD session — and so the
/// grammar is unit-tested on a Linux host. Both backends parse with this
/// function, so the mock cannot accept a request SOLIDWORKS would reject.
#[derive(Debug, Clone, PartialEq)]
pub struct Component {
    pub file: String,
    pub x: f64,
    pub y: f64,
    pub z: f64,
    /// Rotation about the ASSEMBLY's axes, in RADIANS, applied X then Y then Z.
    /// Degrees in (`rotXDeg`), radians here: a component that has to lie on its
    /// side is the ordinary case in a crate, and without this every part used in
    /// two orientations would need two files — which would split its BOM line in
    /// two and make the quantities lie.
    pub rx: f64,
    pub ry: f64,
    pub rz: f64,
    pub config: String,
}

impl Component {
    /// True when this component is not axis-aligned with the assembly and
    /// therefore needs its transform written after insertion.
    pub fn rotated(&self) -> bool {
        self.rx != 0.0 || self.ry != 0.0 || self.rz != 0.0
    }

    /// `IMathUtility::CreateTransform`'s sixteen doubles: a 3x3 rotation
    /// (indices 0-8), the translation in metres (9-11), the scale (12), and
    /// three SOLIDWORKS leaves at zero (13-15).
    ///
    /// The composed rotation is Rz·Ry·Rx — X first, then Y, then Z — which is
    /// what "roll it onto its side, then swing it round" means to the person
    /// writing the numbers.
    ///
    /// THE 3x3 IS WRITTEN COLUMN-MAJOR, and that is a live finding, not a
    /// reading of the docs: filling indices 0-8 row-major put a component
    /// rotated 90° about Z at -90° instead (measured off the assembly's centre
    /// of mass on SOLIDWORKS Connected R2026x). For the axis-aligned quarter
    /// turns a crate is made of, a transposed rotation is a rotation the other
    /// way — silently wrong rather than obviously broken, which is exactly the
    /// kind of error a bounding-box check catches and eyeballing does not.
    pub fn transform(&self) -> [f64; 16] {
        let (sx, cx) = self.rx.sin_cos();
        let (sy, cy) = self.ry.sin_cos();
        let (sz, cz) = self.rz.sin_cos();
        // Rz * Ry * Rx as r[row][col], v' = R·v.
        let r = [
            [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx],
            [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx],
            [-sy, cy * sx, cy * cx],
        ];
        let mut t = [0.0f64; 16];
        for col in 0..3 {
            for row in 0..3 {
                t[col * 3 + row] = r[row][col];
            }
        }
        t[9] = self.x;
        t[10] = self.y;
        t[11] = self.z;
        t[12] = 1.0;
        t
    }
}

/// Parse `components:[{filePath,xMm,yMm,zMm,configuration}]`, or the
/// single-component shorthand where those keys sit on the args themselves.
///
/// `filePath` is deliberately the ONLY accepted spelling of the component file:
/// `path` already means THE TARGET ASSEMBLY on every doc verb, and letting it
/// mean the component here would make the single-component shorthand ambiguous
/// in the one direction that matters — which document gets modified.
pub fn parse_components(args: &Value) -> Result<Vec<Component>, String> {
    let items: Vec<Value> = match args.get("components") {
        Some(Value::Array(a)) => a.clone(),
        Some(other) if !other.is_null() => {
            return Err(
                "components must be an ARRAY of {filePath, xMm, yMm, zMm} objects".to_string()
            )
        }
        _ => vec![args.clone()],
    };
    if items.is_empty() {
        return Err("components is empty — there is nothing to insert".to_string());
    }
    let mut out = Vec::with_capacity(items.len());
    for (i, it) in items.iter().enumerate() {
        let at = i + 1;
        let file = it
            .get("filePath")
            .or_else(|| it.get("file"))
            .and_then(|v| v.as_str())
            .filter(|s| !s.is_empty())
            .ok_or_else(|| {
                format!(
                    "component {at}: filePath is required — an absolute .sldprt/.sldasm path on \
                     the DESKTOP filesystem"
                )
            })?;
        let p = std::path::Path::new(file);
        let b = file.as_bytes();
        let drive_rooted = b.len() >= 3
            && b[0].is_ascii_alphabetic()
            && b[1] == b':'
            && (b[2] == b'\\' || b[2] == b'/');
        if !(p.is_absolute() || file.starts_with('\\') || drive_rooted) {
            return Err(format!(
                "component {at}: filePath must be ABSOLUTE, got '{file}' — a relative path would \
                 resolve against the bridge's own working directory"
            ));
        }
        if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
            return Err(format!("component {at}: filePath must not contain a '..' component: {file}"));
        }
        let coord = |k: &str| -> Result<f64, String> {
            match it.get(k) {
                None | Some(Value::Null) => Ok(0.0),
                Some(v) => v
                    .as_f64()
                    .filter(|f| f.is_finite())
                    .map(|f| f * MM)
                    .ok_or_else(|| format!("component {at}: {k} must be a finite number of millimetres")),
            }
        };
        let degrees = |k: &str| -> Result<f64, String> {
            match it.get(k) {
                None | Some(Value::Null) => Ok(0.0),
                Some(v) => v
                    .as_f64()
                    .filter(|f| f.is_finite())
                    .map(|f| f.to_radians())
                    .ok_or_else(|| format!("component {at}: {k} must be a finite number of degrees")),
            }
        };
        out.push(Component {
            file: file.to_string(),
            x: coord("xMm")?,
            y: coord("yMm")?,
            z: coord("zMm")?,
            rx: degrees("rotXDeg")?,
            ry: degrees("rotYDeg")?,
            rz: degrees("rotZDeg")?,
            config: it.get("configuration").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        });
    }
    Ok(out)
}

/// Where in `paths` (an assembly's components, in SOLIDWORKS' order) the run of
/// `reqs` file paths sits, and which way round: `Some((offset, forward))`.
///
/// The components one `add_component` call inserted occupy a CONTIGUOUS run of
/// the assembly's component list — but SOLIDWORKS does not promise which end
/// new components land at, and the insert call returns no handle to them. So
/// the run is FOUND by matching the sequence of file paths rather than assumed
/// from an ordering, which means a wrong guess about that ordering can never
/// silently move the wrong component: no match, nothing written, and the
/// response says so.
pub fn locate_run(paths: &[String], reqs: &[Component]) -> Option<(usize, bool)> {
    if reqs.is_empty() || reqs.len() > paths.len() {
        return None;
    }
    let matches = |offset: usize, forward: bool| {
        reqs.iter().enumerate().all(|(i, r)| {
            let idx = if forward { offset + i } else { offset + reqs.len() - 1 - i };
            crate::verbs::same_path(&r.file, &paths[idx])
        })
    };
    // Newest components are usually at one end, so try both ends first; the
    // full scan is the fallback.
    let last = paths.len() - reqs.len();
    for offset in std::iter::once(last).chain(std::iter::once(0)).chain(0..=last) {
        for forward in [true, false] {
            if matches(offset, forward) {
                return Some((offset, forward));
            }
        }
    }
    None
}

/// Parse a `script` argument into ops. Accepts the array directly or an object
/// with a `steps`/`script` array, because both are things a caller writes.
pub fn parse(script: &Value) -> Result<Vec<Op>, String> {
    let steps = match script {
        Value::Array(a) => a.clone(),
        Value::Object(_) => script
            .get("steps")
            .or_else(|| script.get("script"))
            .and_then(|v| v.as_array())
            .cloned()
            .ok_or_else(|| "script must be an array of steps, or an object with `steps`".to_string())?,
        _ => {
            return Err(
                "script must be a JSON array of steps (this bridge has no scripting host to \
                 evaluate a string of code — see the op list in the hint)"
                    .to_string(),
            )
        }
    };
    if steps.is_empty() {
        return Err("script has no steps".to_string());
    }
    let mut ops = Vec::with_capacity(steps.len());
    for (i, step) in steps.iter().enumerate() {
        ops.push(parse_step(step).map_err(|e| format!("step {}: {e}", i + 1))?);
    }
    // A REVOLVE'S AXIS, resolved into the sketch it consumes (0.11.0).
    let mut last_sketch: Option<usize> = None;
    for i in 0..ops.len() {
        match &ops[i] {
            Op::Sketch { .. } => last_sketch = Some(i),
            Op::Revolve(_) | Op::RevolveCut(_) => {
                if let Some(j) = last_sketch.take() {
                    resolve_axis(&mut ops, j, i, &steps[j]).map_err(|e| format!("step {}: {e}", i + 1))?;
                }
            }
            Op::Extrude(_) | Op::Cut(_) | Op::NewPart { .. } | Op::NewAssembly { .. } | Op::ClearPart | Op::Hole { .. } => last_sketch = None,
            _ => {}
        }
    }
    Ok(ops)
}

/// Put revolve `r`'s axis into sketch `j`: the line entity it names, or a
/// construction line added for an axis given by points or as x / y. Then
/// check the profile lies on ONE side of it (SOLIDWORKS revolves nothing else).
fn resolve_axis(ops: &mut [Op], j: usize, r: usize, sketch_step: &Value) -> Result<(), String> {
    let spec = match &ops[r] {
        Op::Revolve(rv) | Op::RevolveCut(rv) => rv.axis_spec.clone(),
        _ => return Ok(()),
    };
    if let Op::Sketch { offsets, .. } = &ops[j] {
        if !offsets.is_empty() {
            return Err("a sketch with offsetEntities cannot be revolved: its profile is SOLIDWORKS' own, so no side of an axis can be checked — extrude or cut it".into());
        }
    }
    let Op::Sketch { entities, .. } = &mut ops[j] else { return Ok(()) };
    let line_from = |a: (f64, f64), b: (f64, f64)| Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction: true };
    let span = |ents: &[Entity], a: f64, b: f64| {
        let (lo, hi) = ents.iter().map(|e| extent_along(e, a, b)).fold((f64::MAX, f64::MIN), |m, x| (m.0.min(x.0), m.1.max(x.1)));
        if lo > hi { (-0.01, 0.01) } else { (lo - 0.001, hi + 0.001) }
    };
    let axis = match spec {
        AxisSpec::Entity(k) => {
            let ranges = match sketch_step.get("entities").and_then(|e| e.as_array()) {
                Some(list) => sketch_entities(list)?.1,
                None => vec![0..entities.len()],
            };
            let range = ranges.get(k).cloned().ok_or_else(|| format!("axis.entity {k} — the sketch has {} entities (0-based)", ranges.len()))?;
            if range.len() != 1 || !matches!(entities[range.start], Entity::Line { .. }) {
                return Err(format!("axis.entity {k} is not a line — a revolve axis is a `line` or `centerline` entity"));
            }
            range.start
        }
        AxisSpec::Line(a, b) => {
            entities.push(line_from((a[0], a[1]), (b[0], b[1])));
            entities.len() - 1
        }
        AxisSpec::SketchX => {
            let (lo, hi) = span(entities, 1.0, 0.0);
            entities.push(line_from((lo, 0.0), (hi, 0.0)));
            entities.len() - 1
        }
        AxisSpec::SketchY => {
            let (lo, hi) = span(entities, 0.0, 1.0);
            entities.push(line_from((0.0, lo), (0.0, hi)));
            entities.len() - 1
        }
    };
    profile_one_side(entities, axis)?;
    if let Op::Revolve(rv) | Op::RevolveCut(rv) = &mut ops[r] {
        rv.axis = axis;
    }
    Ok(())
}

/// The profile (every entity but construction and the axis itself) on ONE
/// side of the axis line; touching it is allowed.
pub fn profile_one_side(entities: &[Entity], axis: usize) -> Result<(), String> {
    let Some(((ax, ay), (bx, by))) = entities.get(axis).and_then(|e| e.ends()) else {
        return Err("the revolve axis is not a line".into());
    };
    let l = (bx - ax).hypot(by - ay);
    // Signed distance from the axis: n·p − n·a with n the axis's left normal.
    let (nx, ny) = (-(by - ay) / l, (bx - ax) / l);
    let off = nx * ax + ny * ay;
    let (mut lo, mut hi) = (f64::MAX, f64::MIN);
    for (i, e) in entities.iter().enumerate() {
        if i == axis || e.is_construction() {
            continue;
        }
        let (a, b) = extent_along(e, nx, ny);
        lo = lo.min(a - off);
        hi = hi.max(b - off);
    }
    if lo < -JOIN_TOL && hi > JOIN_TOL {
        return Err(format!(
            "the profile crosses the revolve axis (it reaches {} mm on one side and {} mm on the other) — SOLIDWORKS revolves only a profile on ONE side of its axis",
            trim_mm(-lo), trim_mm(hi)
        ));
    }
    Ok(())
}

fn point3(v: &Value, what: &str) -> Result<[f64; 3], String> {
    let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| format!("{what} must be [x, y, z] in millimetres"))?;
    let mut out = [0.0; 3];
    for (i, x) in a.iter().enumerate() {
        out[i] = x.as_f64().filter(|f| f.is_finite()).ok_or_else(|| format!("{what} must be three finite numbers (mm)"))? * MM;
    }
    Ok(out)
}

fn points3(v: Option<&Value>, what: &str) -> Result<Vec<[f64; 3]>, String> {
    let a = v.and_then(|v| v.as_array()).filter(|a| !a.is_empty()).ok_or_else(|| format!("{what} must be a non-empty list of [x, y, z] points in millimetres"))?;
    a.iter().map(|p| point3(p, what)).collect()
}

fn positive_mm(v: &Value, key: &str) -> Result<f64, String> {
    let x = mm(v, key, None)?;
    if x <= 0.0 {
        return Err(format!("{key} must be greater than zero"));
    }
    Ok(x)
}

/// `"plane": "Top Plane"` | `"plane": {"offsetFrom": "Top Plane", "offsetMm": 9.6}`
/// | `"face": {"atPointMm": [x, y, z]}`.
fn plane_ref(step: &Value) -> Result<PlaneRef, String> {
    if let Some(face) = step.get("face").filter(|f| !f.is_null()) {
        if step.get("plane").map(|p| !p.is_null()).unwrap_or(false) {
            return Err("give `plane` OR `face`, not both".into());
        }
        let p = face.get("atPointMm").ok_or_else(|| "face needs atPointMm: [x, y, z] (mm), a point ON the planar face".to_string())?;
        return Ok(PlaneRef::Face { point: point3(p, "face.atPointMm")? });
    }
    match step.get("plane") {
        None | Some(Value::Null) => Ok(PlaneRef::Named("Front Plane".into())),
        Some(Value::String(s)) if !s.trim().is_empty() => Ok(PlaneRef::Named(s.clone())),
        Some(o @ Value::Object(_)) if o.get("offsetFrom").map(|f| f.is_object()).unwrap_or(false) => {
            let f = &o["offsetFrom"];
            let point = point3(
                f.get("face").and_then(|x| x.get("atPointMm")).or_else(|| f.get("atPointMm"))
                    .ok_or_else(|| "plane.offsetFrom as an object is a face: {\"face\": {\"atPointMm\": [x, y, z]}}".to_string())?,
                "plane.offsetFrom.face.atPointMm",
            )?;
            let off = mm(o, "offsetMm", None).map_err(|e| format!("plane.{e} (along the face's OUTWARD normal; negative = into the part)"))?;
            if off == 0.0 {
                return Ok(PlaneRef::Face { point });
            }
            Ok(PlaneRef::FaceOffset { point, offset: off })
        }
        Some(o @ Value::Object(_)) => {
            let from = o.get("offsetFrom").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty())
                .ok_or_else(|| "plane.offsetFrom (a plane name, e.g. \"Top Plane\") is required for an offset plane".to_string())?;
            let off = mm(o, "offsetMm", None).map_err(|e| format!("plane.{e}"))?;
            if off == 0.0 {
                return Ok(PlaneRef::Named(from.to_string()));
            }
            Ok(PlaneRef::Offset { from: from.to_string(), offset: off })
        }
        _ => Err("plane must be a plane name or {offsetFrom, offsetMm}".into()),
    }
}

/// A centre given as `centerMm: [x, y]`, if present.
fn center2(v: &Value) -> Result<Option<(f64, f64)>, String> {
    match v.get("centerMm").or_else(|| v.get("center")) {
        None | Some(Value::Null) => Ok(None),
        Some(c) => {
            let a = c.as_array().filter(|a| a.len() == 2).ok_or_else(|| "centerMm must be [x, y] in millimetres".to_string())?;
            let f = |i: usize| a[i].as_f64().filter(|f| f.is_finite()).map(|f| f * MM).ok_or_else(|| "centerMm must be two finite numbers".to_string());
            Ok(Some((f(0)?, f(1)?)))
        }
    }
}

/// A rectangle: `widthMm`, `heightMm`, and its position — `xMm`/`yMm` are the
/// CORNER by default (`anchor:"corner"`), the CENTRE with `anchor:"center"`, or
/// `centerMm:[x, y]`.
fn rect(v: &Value) -> Result<Entity, String> {
    let w = positive_mm(v, "widthMm")?;
    let h = positive_mm(v, "heightMm")?;
    let anchor = v.get("anchor").and_then(|a| a.as_str()).unwrap_or("corner");
    let (x0, y0) = match (center2(v)?, anchor) {
        (Some((cx, cy)), _) => (cx - w / 2.0, cy - h / 2.0),
        (None, "center" | "centre") => (mm(v, "xMm", Some(0.0))? - w / 2.0, mm(v, "yMm", Some(0.0))? - h / 2.0),
        (None, "corner") => (mm(v, "xMm", Some(0.0))?, mm(v, "yMm", Some(0.0))?),
        (None, other) => return Err(format!("anchor must be \"corner\" or \"center\", got {other:?}")),
    };
    Ok(Entity::Rect { x0, y0, x1: x0 + w, y1: y0 + h })
}

/// A rectangle as the entities it is drawn with: one `Rect`, or — with
/// `cornerRadiusMm` (0.11.0) — four lines and four quarter arcs, or four
/// construction lines with `construction:true`.
fn rect_entities(v: &Value) -> Result<Vec<Entity>, String> {
    let r = rect(v)?;
    let Entity::Rect { x0, y0, x1, y1 } = r else { return Ok(vec![r]) };
    let construction = flag(v, "construction", false);
    let rc = match v.get("cornerRadiusMm").filter(|c| !c.is_null()) {
        None => 0.0,
        Some(_) => {
            let rc = positive_mm(v, "cornerRadiusMm")?;
            let half = ((x1 - x0).min(y1 - y0)) / 2.0;
            if rc > half + JOIN_TOL {
                return Err(format!("cornerRadiusMm {} is more than half the rectangle's shorter side ({} mm)", trim_mm(rc), trim_mm(half)));
            }
            rc.min(half)
        }
    };
    if rc == 0.0 && !construction {
        return Ok(vec![r]);
    }
    let mut out = Vec::new();
    let line = |a: (f64, f64), b: (f64, f64), out: &mut Vec<Entity>| {
        if (a.0 - b.0).hypot(a.1 - b.1) > JOIN_TOL {
            out.push(Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction });
        }
    };
    let arc = |c: (f64, f64), a: (f64, f64), b: (f64, f64), out: &mut Vec<Entity>| {
        if rc > 0.0 {
            out.push(Entity::Arc { cx: c.0, cy: c.1, x0: a.0, y0: a.1, x1: b.0, y1: b.1, ccw: true, construction });
        }
    };
    line((x0 + rc, y0), (x1 - rc, y0), &mut out);
    arc((x1 - rc, y0 + rc), (x1 - rc, y0), (x1, y0 + rc), &mut out);
    line((x1, y0 + rc), (x1, y1 - rc), &mut out);
    arc((x1 - rc, y1 - rc), (x1, y1 - rc), (x1 - rc, y1), &mut out);
    line((x1 - rc, y1), (x0 + rc, y1), &mut out);
    arc((x0 + rc, y1 - rc), (x0 + rc, y1), (x0, y1 - rc), &mut out);
    line((x0, y1 - rc), (x0, y0 + rc), &mut out);
    arc((x0 + rc, y0 + rc), (x0, y0 + rc), (x0 + rc, y0), &mut out);
    Ok(out)
}

/// A circle as drawn: one `Circle`, or two construction half arcs.
fn circle_entities(v: &Value) -> Result<Vec<Entity>, String> {
    let c = circle(v)?;
    match (c.clone(), flag(v, "construction", false)) {
        (Entity::Circle { cx, cy, r }, true) => Ok(vec![
            Entity::Arc { cx, cy, x0: cx + r, y0: cy, x1: cx - r, y1: cy, ccw: true, construction: true },
            Entity::Arc { cx, cy, x0: cx - r, y0: cy, x1: cx + r, y1: cy, ccw: true, construction: true },
        ]),
        _ => Ok(vec![c]),
    }
}

/// A 2D point under the first of `keys` present, as [x, y] millimetres.
fn pt2(v: &Value, keys: &[&str]) -> Result<Option<(f64, f64)>, String> {
    for k in keys {
        if let Some(c) = v.get(*k).filter(|c| !c.is_null()) {
            let a = c.as_array().filter(|a| a.len() == 2).ok_or_else(|| format!("{k} must be [x, y] in millimetres"))?;
            let f = |i: usize| a[i].as_f64().filter(|f| f.is_finite()).map(|f| f * MM).ok_or_else(|| format!("{k} must be two finite numbers (mm)"));
            return Ok(Some((f(0)?, f(1)?)));
        }
    }
    Ok(None)
}

fn need_pt2(v: &Value, keys: &[&str]) -> Result<(f64, f64), String> {
    pt2(v, keys)?.ok_or_else(|| format!("{} ([x, y] in millimetres) is required", keys[0]))
}

/// Degrees under `key` (default `default`), as radians.
fn degrees(v: &Value, key: &str, default: f64) -> Result<f64, String> {
    match v.get(key).filter(|d| !d.is_null()) {
        None => Ok(default.to_radians()),
        Some(d) => d.as_f64().filter(|f| f.is_finite()).map(|f| f.to_radians()).ok_or_else(|| format!("{key} must be a finite number of degrees")),
    }
}

fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
    (a.0 - b.0).hypot(a.1 - b.1)
}

/// `line {from, to}` (`centerline` = a construction line).
fn line_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
    let a = need_pt2(v, &["from", "fromMm"])?;
    let b = need_pt2(v, &["to", "toMm"])?;
    if dist(a, b) <= JOIN_TOL {
        return Err("a line's from and to are the same point".into());
    }
    Ok(vec![Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction }])
}

/// `polyline {points, close}`: a line between each pair of points.
fn polyline_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
    let pts = v.get("points").or_else(|| v.get("pointsMm")).and_then(|p| p.as_array()).filter(|a| a.len() >= 2)
        .ok_or_else(|| "polyline needs points: at least two [x, y] (mm)".to_string())?;
    let mut ps = Vec::new();
    for (i, p) in pts.iter().enumerate() {
        ps.push(need_pt2(&serde_json::json!({"point": p}), &["point"]).map_err(|e| format!("point {}: {e}", i + 1))?);
    }
    if flag(v, "close", false) && dist(ps[0], ps[ps.len() - 1]) > JOIN_TOL {
        ps.push(ps[0]);
    }
    let mut out = Vec::new();
    for (i, w) in ps.windows(2).enumerate() {
        if dist(w[0], w[1]) <= JOIN_TOL {
            return Err(format!("points {} and {} are the same point", i + 1, i + 2));
        }
        out.push(Entity::Line { x0: w[0].0, y0: w[0].1, x1: w[1].0, y1: w[1].1, construction });
    }
    Ok(out)
}

/// `arc {center, radiusMm, startDeg, endDeg, ccw}` (CreateArc's direction:
/// counter-clockwise unless `ccw:false`).
fn arc_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
    let c = need_pt2(v, &["center", "centerMm"])?;
    let r = positive_mm(v, "radiusMm")?;
    let a0 = degrees(v, "startDeg", f64::NAN)?;
    let a1 = degrees(v, "endDeg", f64::NAN)?;
    if a0.is_nan() || a1.is_nan() {
        return Err("arc needs startDeg and endDeg".into());
    }
    let sweep = (a1 - a0).rem_euclid(std::f64::consts::TAU);
    if sweep < 1e-9 || sweep > std::f64::consts::TAU - 1e-9 {
        return Err("an arc's startDeg and endDeg are the same angle — for a full circle use circle".into());
    }
    let (s0, c0) = a0.sin_cos();
    let (s1, c1) = a1.sin_cos();
    Ok(vec![Entity::Arc { cx: c.0, cy: c.1, x0: c.0 + r * c0, y0: c.1 + r * s0, x1: c.0 + r * c1, y1: c.1 + r * s1, ccw: flag(v, "ccw", true), construction }])
}

/// `arc3 {start, mid, end}`: the arc through three points.
fn arc3_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
    let a = need_pt2(v, &["start", "startMm"])?;
    let b = need_pt2(v, &["mid", "midMm"])?;
    let c = need_pt2(v, &["end", "endMm"])?;
    let cross = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
    if cross.abs() <= 1e-9 * dist(a, b) * dist(a, c) || dist(a, c) <= JOIN_TOL {
        return Err("arc3's three points are in a line (or start equals end) — no arc passes through them".into());
    }
    let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
    let (a2, b2, c2) = (a.0 * a.0 + a.1 * a.1, b.0 * b.0 + b.1 * b.1, c.0 * c.0 + c.1 * c.1);
    let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d;
    let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d;
    Ok(vec![Entity::Arc { cx: ux, cy: uy, x0: a.0, y0: a.1, x1: c.0, y1: c.1, ccw: cross > 0.0, construction }])
}

/// The direction a line or an arc LEAVES its end in.
fn end_tangent(e: &Entity) -> Option<((f64, f64), (f64, f64))> {
    match *e {
        Entity::Line { x0, y0, x1, y1, .. } => {
            let l = (x1 - x0).hypot(y1 - y0);
            Some(((x1, y1), ((x1 - x0) / l, (y1 - y0) / l)))
        }
        Entity::Arc { cx, cy, x1, y1, ccw, .. } => {
            let (rx, ry) = (x1 - cx, y1 - cy);
            let l = rx.hypot(ry);
            Some(((x1, y1), if ccw { (-ry / l, rx / l) } else { (ry / l, -rx / l) }))
        }
        _ => None,
    }
}

/// `tangentArc {to, radiusMm?}`: the arc that leaves the PREVIOUS entity's end
/// along its direction and ends at `to` — there is exactly one; `radiusMm`, if
/// given, must be its radius.
fn tangent_arc_entities(v: &Value, construction: bool, prev: Option<&Entity>) -> Result<Vec<Entity>, String> {
    let (p, t) = prev.and_then(end_tangent).ok_or_else(|| "tangentArc continues from the entity before it, which must be a line or an arc".to_string())?;
    let q = need_pt2(v, &["to", "toMm"])?;
    let n = (-t.1, t.0);
    let d = (q.0 - p.0, q.1 - p.1);
    let s = d.0 * n.0 + d.1 * n.1;
    let len2 = d.0 * d.0 + d.1 * d.1;
    if len2.sqrt() <= JOIN_TOL || s.abs() <= 1e-9 * len2.sqrt() {
        return Err("tangentArc's `to` lies on the tangent line (or is the start point) — that is a straight line, not an arc".into());
    }
    let r = len2 / (2.0 * s);
    if let Some(want) = v.get("radiusMm").filter(|r| !r.is_null()) {
        let want = want.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| "radiusMm must be greater than zero".to_string())? * MM;
        if (want - r.abs()).abs() > JOIN_TOL {
            return Err(format!(
                "a tangent arc from [{}, {}] to [{}, {}] mm has radius {} mm, not the radiusMm given — omit radiusMm or move `to`",
                trim_mm(p.0), trim_mm(p.1), trim_mm(q.0), trim_mm(q.1), trim_mm(r.abs())
            ));
        }
    }
    Ok(vec![Entity::Arc { cx: p.0 + n.0 * r, cy: p.1 + n.1 * r, x0: p.0, y0: p.1, x1: q.0, y1: q.1, ccw: s > 0.0, construction }])
}

/// `slot {center, lengthMm (centre to centre), widthMm, angleDeg}` — the
/// straight slot ISketchManager::CreateSketchSlot draws, drawn here as its two
/// lines and two half arcs so both backends and the closure check see the
/// same segments.
fn slot_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
    let c = need_pt2(v, &["center", "centerMm"])?;
    let len = positive_mm(v, "lengthMm")?;
    let r = positive_mm(v, "widthMm")? / 2.0;
    let (s, co) = degrees(v, "angleDeg", 0.0)?.sin_cos();
    let (u, n) = ((co, s), (-s, co));
    let c1 = (c.0 - u.0 * len / 2.0, c.1 - u.1 * len / 2.0);
    let c2 = (c.0 + u.0 * len / 2.0, c.1 + u.1 * len / 2.0);
    let at = |o: (f64, f64), k: f64| (o.0 + n.0 * k, o.1 + n.1 * k);
    let (a, b, cc, d) = (at(c1, -r), at(c2, -r), at(c2, r), at(c1, r));
    Ok(vec![
        Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction },
        Entity::Arc { cx: c2.0, cy: c2.1, x0: b.0, y0: b.1, x1: cc.0, y1: cc.1, ccw: true, construction },
        Entity::Line { x0: cc.0, y0: cc.1, x1: d.0, y1: d.1, construction },
        Entity::Arc { cx: c1.0, cy: c1.1, x0: d.0, y0: d.1, x1: a.0, y1: a.1, ccw: true, construction },
    ])
}

/// `polygon {center, sides, circumradiusMm | inscribedRadiusMm, angleDeg}` —
/// a regular polygon; `angleDeg` is the direction of its first VERTEX.
fn polygon_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {
    let c = need_pt2(v, &["center", "centerMm"])?;
    let n = v.get("sides").and_then(|s| s.as_u64()).filter(|n| (3..=200).contains(n)).ok_or_else(|| "sides must be a whole number from 3 to 200".to_string())?;
    let r = match (v.get("circumradiusMm").filter(|x| !x.is_null()), v.get("inscribedRadiusMm").filter(|x| !x.is_null())) {
        (Some(_), None) => positive_mm(v, "circumradiusMm")?,
        (None, Some(_)) => positive_mm(v, "inscribedRadiusMm")? / (std::f64::consts::PI / n as f64).cos(),
        _ => return Err("polygon needs ONE of circumradiusMm (centre to vertex) or inscribedRadiusMm (centre to the middle of a side)".into()),
    };
    let a0 = degrees(v, "angleDeg", 0.0)?;
    let vtx = |k: u64| {
        let (s, co) = (a0 + std::f64::consts::TAU * k as f64 / n as f64).sin_cos();
        (c.0 + r * co, c.1 + r * s)
    };
    Ok((0..n).map(|k| {
        let (a, b) = (vtx(k), vtx(k + 1));
        Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction }
    }).collect())
}

/// A SKETCH FILLET or CHAMFER (0.11.0) on the corner where two LINES drawn
/// before it meet at `at`: both lines are trimmed, and the corner becomes a
/// tangent arc of `radius` (fillet) or a line `distance` along each (chamfer).
/// Computed here — not ISketchManager::CreateFillet / CreateChamfer — so both
/// backends draw, and the closure check sees, the same segments.
fn corner_entities(v: &Value, out: &mut [Entity], fillet: bool, construction: bool) -> Result<Vec<Entity>, String> {
    let at = need_pt2(v, &["at", "atMm"])?;
    let size = if fillet { positive_mm(v, "radiusMm")? } else { positive_mm(v, "distanceMm")? };
    let mut hits: Vec<(usize, bool)> = Vec::new();
    for (i, e) in out.iter().enumerate() {
        if let Entity::Line { x0, y0, x1, y1, construction: false } = *e {
            if dist((x0, y0), at) <= JOIN_TOL {
                hits.push((i, false));
            } else if dist((x1, y1), at) <= JOIN_TOL {
                hits.push((i, true));
            }
        }
    }
    if hits.len() != 2 {
        return Err(format!(
            "{} lines end at [{}, {}] mm — a sketch {} takes the corner where exactly TWO lines drawn before it meet",
            hits.len(), trim_mm(at.0), trim_mm(at.1), if fillet { "fillet" } else { "chamfer" }
        ));
    }
    // Each line's direction AWAY from the corner, and its length.
    let away = |e: &Entity, at_end: bool| -> ((f64, f64), f64) {
        let ((x0, y0), (x1, y1)) = e.ends().unwrap_or(((0.0, 0.0), (0.0, 0.0)));
        let (fx, fy, tx, ty) = if at_end { (x1, y1, x0, y0) } else { (x0, y0, x1, y1) };
        let l = (tx - fx).hypot(ty - fy);
        (((tx - fx) / l, (ty - fy) / l), l)
    };
    let (a, la) = away(&out[hits[0].0], hits[0].1);
    let (b, lb) = away(&out[hits[1].0], hits[1].1);
    let cos = (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0);
    let theta = cos.acos();
    if theta < 1e-6 || theta > std::f64::consts::PI - 1e-6 {
        return Err("the two lines at that corner are in line — there is no corner to round or cut".into());
    }
    let t = if fillet { size / (theta / 2.0).tan() } else { size };
    if t >= la - JOIN_TOL || t >= lb - JOIN_TOL {
        return Err(format!("the {} needs {} mm along each line, and a line at that corner is shorter", if fillet { "fillet" } else { "chamfer" }, trim_mm(t)));
    }
    let pa = (at.0 + a.0 * t, at.1 + a.1 * t);
    let pb = (at.0 + b.0 * t, at.1 + b.1 * t);
    for (k, p) in [(hits[0], pa), (hits[1], pb)] {
        if let Entity::Line { x0, y0, x1, y1, .. } = &mut out[k.0] {
            if k.1 {
                *x1 = p.0;
                *y1 = p.1;
            } else {
                *x0 = p.0;
                *y0 = p.1;
            }
        }
    }
    if !fillet {
        return Ok(vec![Entity::Line { x0: pa.0, y0: pa.1, x1: pb.0, y1: pb.1, construction }]);
    }
    let w = {
        let (x, y) = (a.0 + b.0, a.1 + b.1);
        let l = x.hypot(y);
        (x / l, y / l)
    };
    let c = (at.0 + w.0 * size / (theta / 2.0).sin(), at.1 + w.1 * size / (theta / 2.0).sin());
    let mid = (c.0 - w.0 * size, c.1 - w.1 * size);
    let cross = (mid.0 - pa.0) * (pb.1 - pa.1) - (mid.1 - pa.1) * (pb.0 - pa.0);
    Ok(vec![Entity::Arc { cx: c.0, cy: c.1, x0: pa.0, y0: pa.1, x1: pb.0, y1: pb.1, ccw: cross > 0.0, construction }])
}

/// The entity kinds a sketch takes.
pub const ENTITY_KINDS: &[&str] = &[
    "rect", "circle", "line", "centerline", "polyline", "arc", "arc3", "tangentArc", "slot", "polygon", "sketchFillet", "sketchChamfer",
];

/// EVERY ENTITY OF A SKETCH, expanded (0.11.0): the entities drawn, and for
/// each entity the caller wrote, the range of drawn entities it became. Lines
/// and arcs must form closed contours (`contours`), checked here so an open
/// profile is refused before anything is drawn.
pub fn sketch_entities(list: &[Value]) -> Result<(Vec<Entity>, Vec<std::ops::Range<usize>>), String> {
    let mut out: Vec<Entity> = Vec::new();
    let mut ranges = Vec::with_capacity(list.len());
    for (i, e) in list.iter().enumerate() {
        let start = out.len();
        let found = ENTITY_KINDS.iter().find_map(|k| e.get(*k).filter(|b| b.is_object()).map(|b| (*k, b)));
        let (kind, body) = match found {
            Some(f) => f,
            None => match e.get("type").and_then(|t| t.as_str()).and_then(|t| ENTITY_KINDS.iter().find(|k| **k == t)) {
                Some(k) => (*k, e),
                None => return Err(format!("entity {}: each entity is one of {}, e.g. {{\"line\": {{\"from\": [0, 0], \"to\": [10, 0]}}}}", i + 1, ENTITY_KINDS.join(", "))),
            },
        };
        let construction = flag(body, "construction", false) || kind == "centerline";
        let made = match kind {
            "rect" => rect_entities(body),
            "circle" => circle_entities(body),
            "line" | "centerline" => line_entities(body, construction),
            "polyline" => polyline_entities(body, construction),
            "arc" => arc_entities(body, construction),
            "arc3" => arc3_entities(body, construction),
            "tangentArc" => tangent_arc_entities(body, construction, out.last()),
            "slot" => slot_entities(body, construction),
            "sketchFillet" => corner_entities(body, &mut out, true, construction),
            "sketchChamfer" => corner_entities(body, &mut out, false, construction),
            _ => polygon_entities(body, construction),
        }
        .map_err(|m| format!("entity {} ({kind}): {m}", i + 1))?;
        out.extend(made);
        ranges.push(start..out.len());
    }
    if out.len() > 5000 {
        return Err(format!("a sketch may draw at most 5000 entities; this one expands to {}", out.len()));
    }
    if let Err((at, msg)) = contours(&out) {
        let caller = ranges.iter().position(|r| r.contains(&at)).unwrap_or(0);
        return Err(format!("entity {}: {msg}", caller + 1));
    }
    Ok((out, ranges))
}

/// An arc's centre, radius, start angle and SIGNED sweep (radians; positive
/// counter-clockwise).
pub fn arc_geom(e: &Entity) -> Option<(f64, f64, f64, f64, f64)> {
    let Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, .. } = *e else { return None };
    let r = (x0 - cx).hypot(y0 - cy);
    let t0 = (y0 - cy).atan2(x0 - cx);
    let t1 = (y1 - cy).atan2(x1 - cx);
    let tau = std::f64::consts::TAU;
    let mut sweep = if ccw { (t1 - t0).rem_euclid(tau) } else { -((t0 - t1).rem_euclid(tau)) };
    if sweep.abs() < 1e-12 {
        sweep = if ccw { tau } else { -tau };
    }
    Some((cx, cy, r, t0, sweep))
}

/// Is the angle `phi` on an arc that starts at `t0` and sweeps `sweep`?
pub fn on_sweep(phi: f64, t0: f64, sweep: f64) -> bool {
    let tau = std::f64::consts::TAU;
    if sweep >= 0.0 { (phi - t0).rem_euclid(tau) <= sweep + 1e-12 } else { (t0 - phi).rem_euclid(tau) <= -sweep + 1e-12 }
}

/// The least and greatest of `f(x, y) = a·x + b·y` over an entity (its
/// extent across a direction): a revolve's side check, a bounding box.
pub fn extent_along(e: &Entity, a: f64, b: f64) -> (f64, f64) {
    let f = |x: f64, y: f64| a * x + b * y;
    match *e {
        Entity::Rect { x0, y0, x1, y1 } => {
            let v = [f(x0, y0), f(x0, y1), f(x1, y0), f(x1, y1)];
            (v.iter().cloned().fold(f64::MAX, f64::min), v.iter().cloned().fold(f64::MIN, f64::max))
        }
        Entity::Circle { cx, cy, r } => {
            let k = r * a.hypot(b);
            (f(cx, cy) - k, f(cx, cy) + k)
        }
        Entity::Line { x0, y0, x1, y1, .. } => (f(x0, y0).min(f(x1, y1)), f(x0, y0).max(f(x1, y1))),
        Entity::Arc { x0, y0, x1, y1, .. } => {
            let (cx, cy, r, t0, sweep) = arc_geom(e).unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0));
            let mut lo = f(x0, y0).min(f(x1, y1));
            let mut hi = f(x0, y0).max(f(x1, y1));
            let phi = b.atan2(a);
            if on_sweep(phi, t0, sweep) {
                hi = hi.max(f(cx, cy) + r * a.hypot(b));
            }
            if on_sweep(phi + std::f64::consts::PI, t0, sweep) {
                lo = lo.min(f(cx, cy) - r * a.hypot(b));
            }
            (lo, hi)
        }
    }
}

fn circle(v: &Value) -> Result<Entity, String> {
    let r = positive_mm(v, "radiusMm").or_else(|e| {
        v.get("diameterMm").map(|_| positive_mm(v, "diameterMm").map(|d| d / 2.0)).unwrap_or(Err(e))
    })?;
    let (cx, cy) = match center2(v)? {
        Some(c) => c,
        None => (mm(v, "xMm", Some(0.0))?, mm(v, "yMm", Some(0.0))?),
    };
    Ok(Entity::Circle { cx, cy, r })
}

/// A face given as `{"face": {"atPointMm": [...]}}` or `{"atPointMm": [...]}`.
fn face_point(v: &Value, what: &str) -> Result<[f64; 3], String> {
    let p = v.get("face").and_then(|f| f.get("atPointMm")).or_else(|| v.get("atPointMm"))
        .ok_or_else(|| format!("{what} needs the face: {{\"face\": {{\"atPointMm\": [x, y, z]}}}} (mm, a point ON the face)"))?;
    point3(p, &format!("{what}.atPointMm"))
}

/// `{"offsetEntities": {"fromFace": {"atPointMm": [x, y, z]}, "offsetMm": -1,
/// "loops": "outer" | "all", "chain": true}}` (0.11.4).
fn offset_entities(v: &Value) -> Result<OffsetFace, String> {
    if !v.is_object() {
        return Err("offsetEntities is {\"fromFace\": {\"atPointMm\": [x, y, z]}, \"offsetMm\": -1 (negative = inward)}".into());
    }
    let from = v.get("fromFace").filter(|f| !f.is_null()).ok_or_else(|| "offsetEntities needs fromFace: {\"atPointMm\": [x, y, z]} (mm, a point ON the face)".to_string())?;
    let point = face_point(from, "fromFace")?;
    let offset = mm(v, "offsetMm", None)?;
    if offset == 0.0 {
        return Err("offsetMm must not be 0 (negative offsets INTO the face, positive away from it)".into());
    }
    if offset.abs() > 1.0 {
        return Err("offsetMm must be within ±1000 mm".into());
    }
    let loops = match v.get("loops").and_then(|l| l.as_str()) {
        None | Some("outer") => OffsetLoops::Outer,
        Some("all") => OffsetLoops::All,
        Some(other) => return Err(format!("loops is \"outer\" (the face's outer loop, the default) or \"all\" (every loop: the face selected), got {other:?}")),
    };
    Ok(OffsetFace { point, offset, loops, chain: flag(v, "chain", true) })
}

/// "entity N …" from `sketch_entities` over the DRAWN entities, renumbered
/// as the caller wrote the list (the offsets taken out of it).
fn renumber_entity(m: &str, written_at: &[usize]) -> String {
    let Some(rest) = m.strip_prefix("entity ") else { return m.to_string() };
    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
    match digits.parse::<usize>().ok().and_then(|n| n.checked_sub(1)).and_then(|k| written_at.get(k)) {
        Some(at) => format!("entity {}{}", at + 1, &rest[digits.len()..]),
        None => m.to_string(),
    }
}

/// `regionsAtMm: [[u, v], ...]` (or one `regionAtMm: [u, v]`): the sketch
/// regions to extrude, by a point inside each (0.11.4).
fn regions(step: &Value) -> Result<Vec<[f64; 2]>, String> {
    let list: Vec<Value> = match (step.get("regionsAtMm").filter(|r| !r.is_null()), step.get("regionAtMm").filter(|r| !r.is_null())) {
        (None, None) => return Ok(Vec::new()),
        (Some(_), Some(_)) => return Err("give regionsAtMm OR regionAtMm, not both".into()),
        (Some(r), None) => r.as_array().filter(|a| !a.is_empty()).cloned().ok_or_else(|| "regionsAtMm must be a non-empty list of [u, v] points (mm, the sketch's coordinates)".to_string())?,
        (None, Some(r)) => vec![r.clone()],
    };
    if list.len() > 32 {
        return Err("at most 32 regions".into());
    }
    list.iter()
        .map(|p| need_pt2(&serde_json::json!({"p": p}), &["p"]).map(|(u, v)| [u, v]).map_err(|_| "each region is a point [u, v] (mm) inside it, in the sketch's coordinates".to_string()))
        .collect()
}

/// The end condition other than a depth, if one is given.
fn up_to(step: &Value) -> Result<Option<UpTo>, String> {
    let mut found: Vec<UpTo> = Vec::new();
    match step.get("endCondition").and_then(|e| e.as_str()) {
        None | Some("blind") | Some("throughAll") => {}
        Some("upToNext") => found.push(UpTo::Next),
        Some(other) => {
            return Err(format!(
                "endCondition must be \"blind\", \"throughAll\" or \"upToNext\" (got {other:?}); up to a face is upToSurface: {{\"face\": {{\"atPointMm\": [x,y,z]}}}}"
            ))
        }
    }
    if flag(step, "upToNext", false) {
        found.push(UpTo::Next);
    }
    if let Some(u) = step.get("upToSurface").filter(|u| !u.is_null()) {
        found.push(UpTo::Surface { point: face_point(u, "upToSurface")? });
    }
    if let Some(o) = step.get("offsetFromSurface").filter(|u| !u.is_null()) {
        let offset = mm(o, "distanceMm", None).map_err(|e| format!("offsetFromSurface.{e}"))?;
        if offset < 0.0 {
            return Err("offsetFromSurface.distanceMm must not be negative (reverseOffset:true puts it beyond the face)".into());
        }
        found.push(UpTo::OffsetFrom { point: face_point(o, "offsetFromSurface")?, offset, reverse_offset: flag(o, "reverseOffset", false) });
    }
    found.dedup();
    match found.len() {
        0 => Ok(None),
        1 => Ok(found.pop()),
        _ => Err("give ONE end condition: upToNext, upToSurface or offsetFromSurface".into()),
    }
}

fn draft(step: &Value) -> Result<Option<Draft>, String> {
    match step.get("draftDeg").filter(|d| !d.is_null()) {
        None => Ok(None),
        Some(d) => {
            let a = d.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 90.0).ok_or_else(|| "draftDeg must be between 0 and 90 (exclusive)".to_string())?;
            Ok(Some(Draft { angle: a.to_radians(), outward: flag(step, "draftOutward", false) }))
        }
    }
}

fn extent(step: &Value, is_cut: bool) -> Result<Extent, String> {
    let up = up_to(step)?;
    let draft = draft(step)?;
    if let Some(u) = up {
        if flag(step, "throughAll", false) {
            return Err("throughAll and an up-to end condition exclude each other".into());
        }
        if step.get("depthMm").map(|d| !d.is_null()).unwrap_or(false) {
            return Err("depthMm has no meaning with an up-to end condition — the face decides where it ends".into());
        }
        if step.get("direction").and_then(|d| d.as_str()).map(|d| d != "one").unwrap_or(false) || flag(step, "midPlane", false) {
            return Err("an up-to end condition goes ONE way (the bridge does not build two-direction up-to extents)".into());
        }
        let depth = if let UpTo::OffsetFrom { offset, .. } = u { offset } else { 0.0 };
        return Ok(Extent { depth, depth2: depth, through_all: false, reverse: flag(step, "reverse", false), direction: Direction::One, up_to: Some(u), draft, regions: regions(step)? });
    }
    let through_all = flag(step, "throughAll", false);
    let mid = flag(step, "midPlane", false);
    let mut direction = match step.get("direction").and_then(|d| d.as_str()) {
        None => Direction::One,
        Some("one") => Direction::One,
        Some("both") => Direction::Both,
        Some("midPlane") | Some("mid_plane") | Some("midplane") => Direction::MidPlane,
        Some(other) => return Err(format!("direction must be \"one\", \"both\" or \"midPlane\", got {other:?}")),
    };
    if mid {
        direction = Direction::MidPlane;
    }
    if through_all && direction == Direction::MidPlane {
        // "Through All - Both" is SOLIDWORKS' own name for this.
        direction = Direction::Both;
    }
    let depth = if through_all {
        mm(step, "depthMm", Some(0.0))?
    } else if is_cut && step.get("depthMm").is_none() {
        return Err("cut needs depthMm (mm), or throughAll:true".into());
    } else {
        positive_mm(step, "depthMm")?
    };
    let depth2 = match step.get("depth2Mm") {
        Some(_) => positive_mm(step, "depth2Mm")?,
        None => depth,
    };
    Ok(Extent { depth, depth2, through_all, reverse: flag(step, "reverse", false), direction, up_to: None, draft, regions: regions(step)? })
}

fn edges(step: &Value) -> Result<EdgeSel, String> {
    let e = step.get("edges").ok_or_else(|| "edges is required: {\"atPointsMm\": [[x,y,z], ...]} or {\"allOfFeature\": \"Boss-Extrude1\" | \"last\"}".to_string())?;
    if let Some(f) = e.get("allOfFeature").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
        return Ok(EdgeSel::AllOfFeature(f.to_string()));
    }
    Ok(EdgeSel::AtPoints(points3(e.get("atPointsMm"), "edges.atPointsMm")?))
}

fn axis(v: Option<&Value>, what: &str) -> Result<Axis, String> {
    match v.and_then(|a| a.as_str()).map(|a| a.to_ascii_lowercase()) {
        Some(a) if a == "x" => Ok(Axis::X),
        Some(a) if a == "y" => Ok(Axis::Y),
        Some(a) if a == "z" => Ok(Axis::Z),
        _ => Err(format!("{what} must be \"x\", \"y\" or \"z\" (a model axis)")),
    }
}

fn count(v: &Value, key: &str) -> Result<u32, String> {
    let n = v.get(key).and_then(|c| c.as_u64()).ok_or_else(|| format!("{key} (a whole number of instances, including the original) is required"))?;
    if !(2..=1000).contains(&n) {
        return Err(format!("{key} must be between 2 and 1000"));
    }
    Ok(n as u32)
}

/// `visible` on new_part / new_assembly: a boolean, default true (a user's
/// script shows its part).
fn visible_arg(step: &Value) -> Result<bool, String> {
    match step.get("visible") {
        None | Some(Value::Null) => Ok(true),
        Some(Value::Bool(b)) => Ok(*b),
        Some(other) => Err(format!("visible must be true or false, got {other}")),
    }
}

fn feature_ref(step: &Value) -> String {
    step.get("feature").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()).unwrap_or("last").to_string()
}

/// `revolve` / `revolve_cut`: `axis` is "x" | "y" (the sketch's own axes),
/// {"entity": i} (the sketch's i-th entity as written, 0-based — a line) or
/// {"line": [[x, y], [x, y]]}; `angleDeg` (default 360), `midPlane`, `reverse`.
fn revolution(step: &Value) -> Result<Revolution, String> {
    let angle = match step.get("angleDeg").filter(|a| !a.is_null()) {
        None => 360.0,
        Some(a) => a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a <= 360.0).ok_or_else(|| "angleDeg must be in (0, 360]".to_string())?,
    };
    let axis_spec = match step.get("axis") {
        Some(Value::String(a)) if a.eq_ignore_ascii_case("x") => AxisSpec::SketchX,
        Some(Value::String(a)) if a.eq_ignore_ascii_case("y") => AxisSpec::SketchY,
        Some(o @ Value::Object(_)) if o.get("entity").is_some() => AxisSpec::Entity(
            o.get("entity").and_then(|e| e.as_u64()).ok_or_else(|| "axis.entity is the 0-based index of a line in the sketch".to_string())? as usize,
        ),
        Some(o @ Value::Object(_)) if o.get("line").is_some() => {
            let l = o.get("line").and_then(|l| l.as_array()).filter(|l| l.len() == 2).ok_or_else(|| "axis.line must be [[x, y], [x, y]] (mm)".to_string())?;
            let a = need_pt2(&serde_json::json!({"p": l[0]}), &["p"]).map_err(|e| format!("axis.line: {e}"))?;
            let b = need_pt2(&serde_json::json!({"p": l[1]}), &["p"]).map_err(|e| format!("axis.line: {e}"))?;
            if dist(a, b) <= JOIN_TOL {
                return Err("axis.line's two points are the same point".into());
            }
            AxisSpec::Line([a.0, a.1], [b.0, b.1])
        }
        _ => return Err("axis is required: \"x\" or \"y\" (the sketch's own axes), {\"entity\": i} (a line of the sketch, 0-based) or {\"line\": [[x, y], [x, y]]} (mm)".into()),
    };
    let mid_plane = flag(step, "midPlane", false) || step.get("direction").and_then(|d| d.as_str()) == Some("midPlane");
    Ok(Revolution { axis: usize::MAX, axis_spec, angle: angle.to_radians(), mid_plane, reverse: flag(step, "reverse", false) })
}

fn mirror(step: &Value) -> Result<Op, String> {
    if step.get("plane").map(|p| p.is_null()).unwrap_or(true) && step.get("face").map(|p| p.is_null()).unwrap_or(true) {
        return Err("mirror needs the plane: \"plane\": \"Right Plane\" | {offsetFrom, offsetMm}, or \"face\": {\"atPointMm\": [x,y,z]} (a planar face)".into());
    }
    let plane = plane_ref(step)?;
    let what = step.get("featureOrBody").or_else(|| step.get("features")).or_else(|| step.get("feature"));
    let mut features: Vec<String> = match what {
        None | Some(Value::Null) => vec!["last".into()],
        Some(Value::String(f)) if !f.trim().is_empty() => vec![f.clone()],
        Some(Value::Array(a)) if !a.is_empty() => a
            .iter()
            .map(|f| f.as_str().filter(|s| !s.trim().is_empty()).map(String::from).ok_or_else(|| "features must be feature names".to_string()))
            .collect::<Result<_, _>>()?,
        _ => return Err("features is \"last\", a feature name, a list of names, or \"all\" (every body)".into()),
    };
    let mut bodies = flag(step, "mirrorBody", false);
    if features.iter().any(|f| f == "all") {
        bodies = true;
    }
    if bodies {
        features.clear();
    }
    Ok(Op::Mirror { features, bodies, plane })
}

fn hole(step: &Value) -> Result<Op, String> {
    let at = point3(step.get("atMm").or_else(|| step.get("at")).ok_or_else(|| "hole needs atMm: [x, y, z] (mm), a point ON a planar face — the hole's centre".to_string())?, "atMm")?;
    let diameter = positive_mm(step, "diameterMm")?;
    let through = flag(step, "throughAll", false);
    let depth = match (through, step.get("depthMm").filter(|d| !d.is_null())) {
        (true, None) => None,
        (false, Some(_)) => Some(positive_mm(step, "depthMm")?),
        (true, Some(_)) => return Err("give depthMm OR throughAll:true, not both".into()),
        (false, None) => return Err("hole needs depthMm (mm), or throughAll:true".into()),
    };
    let counterbore = match step.get("counterbore").filter(|c| !c.is_null()) {
        None => None,
        Some(c) => {
            let d = positive_mm(c, "diameterMm").map_err(|e| format!("counterbore.{e}"))?;
            let h = positive_mm(c, "depthMm").map_err(|e| format!("counterbore.{e}"))?;
            if d <= diameter {
                return Err("counterbore.diameterMm must be larger than the hole's diameterMm".into());
            }
            if depth.map(|t| h >= t).unwrap_or(false) {
                return Err("counterbore.depthMm must be less than the hole's depthMm".into());
            }
            Some((d, h))
        }
    };
    let countersink = match step.get("countersink").filter(|c| !c.is_null()) {
        None => None,
        Some(c) => {
            let d = positive_mm(c, "diameterMm").map_err(|e| format!("countersink.{e}"))?;
            if d <= diameter {
                return Err("countersink.diameterMm must be larger than the hole's diameterMm".into());
            }
            let a = c.get("angleDeg").filter(|a| !a.is_null()).map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 180.0)
                .ok_or_else(|| "countersink.angleDeg (the included angle, e.g. 82 or 90) must be in (0, 180)".to_string())).transpose()?.unwrap_or(90.0).to_radians();
            if let Some(t) = depth {
                if countersink_depth(diameter, d, a) >= t {
                    return Err("the countersink is deeper than the hole".into());
                }
            }
            Some((d, a))
        }
    };
    if counterbore.is_some() && countersink.is_some() {
        return Err("a hole has a counterbore OR a countersink, not both".into());
    }
    Ok(Op::Hole { at, diameter, depth, counterbore, countersink })
}

/// THE HOLE AS FEATURES (0.11.0), shared by both backends: the face's plane
/// is `base` (a default plane) at height `h` along its normal, the face looks
/// along +normal when `outward_is_normal`. Circles at the hole's centre are
/// sketched on that plane (an OFFSET plane where h ≠ 0 — it outlives the cuts,
/// unlike the face) and cut into the material: a counterbore (a blind cut) or
/// a countersink (a cut drafted INWARD at half its included angle, so it
/// narrows to the hole's diameter) first, then the hole from where that ends.
#[allow(clippy::too_many_arguments)]
pub fn hole_plan(
    frame: &crate::modeling::PlaneFrame,
    base: &str,
    h: f64,
    outward_is_normal: bool,
    at: [f64; 3],
    diameter: f64,
    depth: Option<f64>,
    counterbore: Option<(f64, f64)>,
    countersink: Option<(f64, f64)>,
) -> Vec<Op> {
    use crate::modeling::{dot, sub};
    let rel = sub(at, frame.origin);
    let (u, v) = (dot(rel, frame.x), dot(rel, frame.y));
    // A cut from a plane goes against the plane's normal: reversed when the
    // face looks the other way.
    let reverse = !outward_is_normal;
    let inward = if outward_is_normal { -1.0 } else { 1.0 };
    let plane_at = |hh: f64| if hh.abs() < 1e-12 { PlaneRef::Named(base.to_string()) } else { PlaneRef::Offset { from: base.to_string(), offset: hh } };
    let sketch = |hh: f64, r: f64| Op::Sketch { shorthand: "sketch", plane: plane_at(hh), entities: vec![Entity::Circle { cx: u, cy: v, r }], offsets: Vec::new() };
    let blind = |t: f64| Extent { reverse, ..Extent::blind(t) };
    let mut ops = Vec::new();
    let mut start = h;
    let mut left = depth;
    if let Some((dcb, hcb)) = counterbore {
        ops.push(sketch(h, dcb / 2.0));
        ops.push(Op::Cut(blind(hcb)));
        start = h + inward * hcb;
        left = left.map(|t| t - hcb);
    }
    if let Some((dcs, angle)) = countersink {
        let hcs = countersink_depth(diameter, dcs, angle);
        ops.push(sketch(h, dcs / 2.0));
        ops.push(Op::Cut(Extent { draft: Some(Draft { angle: angle / 2.0, outward: false }), ..blind(hcs) }));
        start = h + inward * hcs;
        left = left.map(|t| t - hcs);
    }
    ops.push(sketch(start, diameter / 2.0));
    ops.push(Op::Cut(match left {
        Some(t) => blind(t),
        None => Extent { through_all: true, ..blind(0.0) },
    }));
    ops
}

/// How deep a countersink of diameter `dcs` and included angle `angle`
/// (radians) goes on a hole of diameter `d`: (dcs − d) / (2·tan(angle/2)).
pub fn countersink_depth(d: f64, dcs: f64, angle: f64) -> f64 {
    (dcs - d) / (2.0 * (angle / 2.0).tan())
}

fn parse_step(step: &Value) -> Result<Op, String> {
    let op = step
        .get("op")
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("missing `op` (one of: {})", OPS.join(", ")))?;
    Ok(match op {
        "new_part" => Op::NewPart { visible: visible_arg(step)? },
        "new_assembly" => Op::NewAssembly { visible: visible_arg(step)? },
        "clear_part" => Op::ClearPart,
        "sketch_rect" => Op::Sketch { shorthand: "sketch_rect", plane: plane_ref(step)?, entities: rect_entities(step)?, offsets: Vec::new() },
        "sketch_circle" => Op::Sketch { shorthand: "sketch_circle", plane: plane_ref(step)?, entities: vec![circle(step)?], offsets: Vec::new() },
        "sketch" => {
            let list = step.get("entities").and_then(|e| e.as_array()).filter(|a| !a.is_empty())
                .ok_or_else(|| format!("sketch needs entities: a non-empty list, each one of {}, or offsetEntities", ENTITY_KINDS.join(", ")))?;
            if list.len() > 500 {
                return Err("a sketch may hold at most 500 entities".into());
            }
            // OFFSET ENTITIES (0.11.4) are the MODEL's edges, drawn by
            // SOLIDWORKS: kept apart from the entities the bridge draws.
            let mut offsets = Vec::new();
            let mut drawn: Vec<Value> = Vec::new();
            // Where each drawn entity stands in the caller's list, so an error
            // names the entity the caller wrote (offsets are taken out first).
            let mut written_at: Vec<usize> = Vec::new();
            for (i, e) in list.iter().enumerate() {
                match e.get("offsetEntities") {
                    Some(o) => offsets.push(offset_entities(o).map_err(|m| format!("entity {} (offsetEntities): {m}", i + 1))?),
                    None => {
                        drawn.push(e.clone());
                        written_at.push(i);
                    }
                }
            }
            if offsets.len() > 8 {
                return Err("a sketch may hold at most 8 offsetEntities".into());
            }
            let entities = if drawn.is_empty() {
                Vec::new()
            } else {
                sketch_entities(&drawn).map_err(|m| renumber_entity(&m, &written_at))?.0
            };
            if offsets.is_empty() && !entities.iter().any(|e| !e.is_construction()) {
                return Err("the sketch has only construction geometry — there is no profile".into());
            }
            Op::Sketch { shorthand: "sketch", plane: plane_ref(step)?, entities, offsets }
        }
        "extrude" => Op::Extrude(extent(step, false)?),
        "cut" => Op::Cut(extent(step, true)?),
        "revolve" => Op::Revolve(revolution(step)?),
        "revolve_cut" => Op::RevolveCut(revolution(step)?),
        "mirror" => mirror(step)?,
        "hole" => hole(step)?,
        "set_material" => {
            let density = match step.get("densityKgM3").filter(|d| !d.is_null()) {
                None => None,
                Some(d) => Some(d.as_f64().filter(|d| d.is_finite() && *d > 0.0 && *d < 100_000.0)
                    .ok_or_else(|| "densityKgM3 must be a positive number of kg/m³ (e.g. 2700 for aluminium)".to_string())?),
            };
            let name = match (text(step, "name"), density) {
                (Ok(n), _) => n,
                (Err(_), Some(_)) => String::new(),
                (Err(e), None) => return Err(format!("{e} (or densityKgM3 alone, to set the part's density)")),
            };
            Op::SetMaterial {
                name,
                database: step
                    .get("database")
                    .and_then(|v| v.as_str())
                    .filter(|s| !s.is_empty())
                    .unwrap_or("SOLIDWORKS Materials.sldmat")
                    .to_string(),
                density,
            }
        }
        "set_appearance" => {
            let rgb = step.get("rgb").and_then(|v| v.as_array()).filter(|a| a.len() == 3)
                .ok_or_else(|| "rgb must be [r, g, b], each 0..255".to_string())?;
            let mut c = [0.0; 3];
            for (i, v) in rgb.iter().enumerate() {
                let x = v.as_f64().filter(|x| x.is_finite() && (0.0..=255.0).contains(x)).ok_or_else(|| "rgb values must be 0..255".to_string())?;
                c[i] = x / 255.0;
            }
            let target = match step.get("target") {
                None | Some(Value::Null) => AppearanceTarget::Part,
                Some(t) => {
                    if let Some(b) = t.get("bodyName").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
                        AppearanceTarget::Body(b.to_string())
                    } else if let Some(p) = t.get("faceAtPointMm") {
                        AppearanceTarget::FaceAt(point3(p, "target.faceAtPointMm")?)
                    } else if t.as_str() == Some("part") {
                        AppearanceTarget::Part
                    } else {
                        return Err("target is \"part\", {\"bodyName\": ...} or {\"faceAtPointMm\": [x,y,z]}".into());
                    }
                }
            };
            Op::SetAppearance { rgb: c, target }
        }
        "fillet" => Op::Fillet { radius: positive_mm(step, "radiusMm")?, edges: edges(step)? },
        "chamfer" => {
            let angle = step.get("angleDeg").map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 90.0)
                .ok_or_else(|| "angleDeg must be between 0 and 90".to_string())).transpose()?.unwrap_or(45.0);
            Op::Chamfer { distance: positive_mm(step, "distanceMm")?, angle: angle.to_radians(), edges: edges(step)? }
        }
        "shell" => Op::Shell {
            thickness: positive_mm(step, "thicknessMm")?,
            faces: points3(step.get("removeFacesAtPointsMm"), "removeFacesAtPointsMm")?,
            outward: flag(step, "outward", false),
        },
        "linear_pattern" => {
            let dirs = step.get("directions").and_then(|d| d.as_array()).filter(|a| !a.is_empty() && a.len() <= 2)
                .ok_or_else(|| "directions: one or two of {\"axis\": \"x\"|\"y\"|\"z\", \"count\": n, \"spacingMm\": s}".to_string())?;
            let mut out = Vec::new();
            for (i, d) in dirs.iter().enumerate() {
                let one = (|| -> Result<(Axis, u32, f64), String> {
                    Ok((axis(d.get("axis"), "axis")?, count(d, "count")?, mm(d, "spacingMm", None)?))
                })()
                .map_err(|m| format!("direction {}: {m}", i + 1))?;
                if one.2 == 0.0 {
                    return Err(format!("direction {}: spacingMm must not be zero", i + 1));
                }
                out.push(one);
            }
            if out.len() == 2 && out[0].0 == out[1].0 {
                return Err("the two directions must be different axes".into());
            }
            Op::LinearPattern { feature: feature_ref(step), dirs: out }
        }
        "circular_pattern" => {
            let angle = step.get("angleDeg").map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a <= 360.0)
                .ok_or_else(|| "angleDeg must be in (0, 360]".to_string())).transpose()?.unwrap_or(360.0);
            Op::CircularPattern { feature: feature_ref(step), axis: axis(step.get("axis"), "axis")?, count: count(step, "count")?, angle: angle.to_radians() }
        }
        "set_prop" => Op::SetProp {
            name: text(step, "name")?,
            value: match step.get("value") {
                Some(Value::String(s)) => s.clone(),
                Some(other) => other.to_string(),
                None => return Err("value is required".into()),
            },
        },
        "save_as" => Op::SaveAs { path: safe_dest(&text(step, "path")?)? },
        "rebuild" => Op::Rebuild,
        "zoom_fit" => Op::ZoomFit,
        other => {
            return Err(format!("unknown op '{other}' (valid ops: {})", OPS.join(", ")))
        }
    })
}

/// The message for a modelling op aimed at a HIDDEN document.
pub const HIDDEN_DOCUMENT: &str = "SOLIDWORKS can select only in the active document, and a document \
     created invisible cannot be activated (measured 2026-10-14) — so it cannot be modelled";

/// A script that makes a HIDDEN part and then models it: the step that would
/// fail mid-script, named up front (0.10.7).
pub fn hidden_conflict(ops: &[Op]) -> Option<String> {
    let mut hidden = false;
    for (i, op) in ops.iter().enumerate() {
        match op {
            Op::NewPart { visible } | Op::NewAssembly { visible } => hidden = !*visible,
            _ if hidden && op.needs_selection() => {
                return Some(format!(
                    "step {} ({}) would model a part created with visible:false: {HIDDEN_DOCUMENT}. Leave \
                     visible at its default (true) — to keep a battery's memory flat, reuse one document \
                     (reuseDocument) instead",
                    i + 1,
                    op.name()
                ))
            }
            _ => {}
        }
    }
    None
}

/// A sketch op must come before the feature that consumes it, a feature needs
/// a document, and "last" needs a feature before it. Checked BEFORE the first
/// COM call so a script that cannot work never half-runs on the user's live
/// session — the difference between a clean refusal and a stray unsaved
/// document on their screen.
pub fn validate(ops: &[Op], has_open_doc: bool) -> Result<(), String> {
    let mut doc = has_open_doc;
    let mut sketch_pending = false;
    // A feature to call "last": one made EARLIER IN THIS SCRIPT. (On an
    // existing document, name the feature — swx_design_info lists them.)
    let mut have_feature = false;
    for (i, op) in ops.iter().enumerate() {
        let at = i + 1;
        match op {
            Op::NewPart { .. } | Op::NewAssembly { .. } => {
                doc = true;
                have_feature = false;
                continue;
            }
            Op::ClearPart if doc => {
                // Every feature is gone: nothing to call "last", no sketch pending.
                have_feature = false;
                sketch_pending = false;
                continue;
            }
            _ if !doc => {
                return Err(format!(
                    "step {at} ({}) needs a document: start the script with new_part, or open one \
                     first with swx_open / swx_new_document",
                    op.name()
                ))
            }
            Op::Sketch { .. } => sketch_pending = true,
            Op::Hole { .. } if sketch_pending => {
                return Err(format!(
                    "step {at} (hole) comes between a sketch and the feature that consumes it — a hole draws its own sketches; \
                     extrude, cut or revolve the pending sketch first"
                ));
            }
            Op::Revolve(rv) | Op::RevolveCut(rv) if sketch_pending && rv.axis == usize::MAX => {
                return Err(format!("step {at} ({}): its axis could not be placed in the sketch", op.name()));
            }
            Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_) => {
                if !sketch_pending {
                    return Err(format!(
                        "step {at} ({}) has no sketch to consume — put a sketch, sketch_rect or \
                         sketch_circle before it (other ops may come between: the bridge re-selects \
                         the sketch by name)",
                        op.name()
                    ));
                }
                sketch_pending = false;
            }
            Op::Fillet { edges: EdgeSel::AllOfFeature(f), .. } | Op::Chamfer { edges: EdgeSel::AllOfFeature(f), .. }
            | Op::LinearPattern { feature: f, .. } | Op::CircularPattern { feature: f, .. }
                if f == "last" && !have_feature =>
            {
                return Err(format!("step {at} ({}) refers to the \"last\" feature, and no feature comes before it", op.name()));
            }
            Op::Mirror { features, .. } if features.iter().any(|f| f == "last") && !have_feature => {
                return Err(format!("step {at} (mirror) refers to the \"last\" feature, and no feature comes before it"));
            }
            _ => {}
        }
        if op.makes_feature() {
            have_feature = true;
        }
    }
    Ok(())
}

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

    #[test]
    fn parses_the_documented_example_and_converts_mm_to_metres() {
        let ops = parse(&json!([
            {"op": "new_part"},
            {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 50, "heightMm": 30},
            {"op": "extrude", "depthMm": 20},
            {"op": "set_material", "name": "6061 Alloy"},
            {"op": "set_prop", "name": "PartNo", "value": "BRK-100"},
            {"op": "save_as", "path": "C:\\p\\b.sldprt"}
        ]))
        .unwrap();
        assert_eq!(ops.len(), 6);
        assert_eq!(
            ops[1],
            Op::Sketch {
                shorthand: "sketch_rect",
                plane: PlaneRef::Named("Top Plane".into()),
                entities: vec![Entity::Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.03 }],
                offsets: vec![]
            }
        );
        assert_eq!(
            ops[2],
            Op::Extrude(Extent { depth: 0.02, depth2: 0.02, through_all: false, reverse: false, direction: Direction::One , up_to: None, draft: None, regions: Vec::new() })
        );
        assert_eq!(
            ops[3],
            Op::SetMaterial { name: "6061 Alloy".into(), database: "SOLIDWORKS Materials.sldmat".into(), density: None }
        );
        // a non-string prop value is stringified rather than refused
        assert_eq!(parse(&json!([{"op":"set_prop","name":"Qty","value":4}])).unwrap()[0],
                   Op::SetProp { name: "Qty".into(), value: "4".into() });
    }

    #[test]
    fn defaults_the_plane_and_the_sketch_origin() {
        let ops = parse(&json!([{"op": "sketch_circle", "radiusMm": 5}])).unwrap();
        assert_eq!(
            ops[0],
            Op::Sketch {
                shorthand: "sketch_circle",
                plane: PlaneRef::Named("Front Plane".into()),
                entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.005 }],
                offsets: vec![]
            }
        );
    }

    #[test]
    fn refuses_what_it_cannot_honestly_run() {
        // a code string: there is no scripting host, and saying so beats a COM error
        assert!(parse(&json!("import adsk")).unwrap_err().contains("array of steps"));
        assert!(parse(&json!([])).unwrap_err().contains("no steps"));
        assert!(parse(&json!([{"op": "fly"}])).unwrap_err().contains("unknown op 'fly'"));
        assert!(parse(&json!([{}])).unwrap_err().contains("missing `op`"));
        // the step INDEX is in the message — a 20-step script needs to say which one
        let e = parse(&json!([{"op": "new_part"}, {"op": "extrude"}])).unwrap_err();
        assert!(e.starts_with("step 2:"), "{e}");
        assert!(e.contains("depthMm"));
        // NaN / infinity cannot become a dimension
        assert!(parse(&json!([{"op": "extrude", "depthMm": "20"}])).unwrap_err().contains("depthMm"));
    }

    #[test]
    fn validation_catches_the_two_orderings_that_cannot_work() {
        let ops = parse(&json!([{"op": "extrude", "depthMm": 5}])).unwrap();
        let e = validate(&ops, false).unwrap_err();
        assert!(e.contains("needs a document"), "{e}");
        // with a document already open, the same script fails for the OTHER reason
        assert!(validate(&ops, true).unwrap_err().contains("no sketch to consume"));

        let good = parse(&json!([
            {"op": "new_part"},
            {"op": "sketch_rect", "widthMm": 10, "heightMm": 10},
            {"op": "extrude", "depthMm": 2}
        ]))
        .unwrap();
        assert!(validate(&good, false).is_ok());
        // one sketch feeds exactly one feature
        let two = parse(&json!([
            {"op": "new_part"},
            {"op": "sketch_rect", "widthMm": 10, "heightMm": 10},
            {"op": "extrude", "depthMm": 2},
            {"op": "extrude", "depthMm": 2}
        ]))
        .unwrap();
        assert!(validate(&two, false).is_err());
    }

    #[test]
    fn save_as_paths_obey_the_same_rules_as_the_standalone_verb() {
        // absolute is fine, on either platform's spelling
        assert!(parse(&json!([{"op": "save_as", "path": "C:\\parts\\a.sldprt"}])).is_ok());
        assert!(parse(&json!([{"op": "save_as", "path": "/tmp/a.sldprt"}])).is_ok());
        // relative would resolve against the bridge's cwd — the exact bypass
        // that let a script write outside the destination the caller believed in
        let e = parse(&json!([{"op": "save_as", "path": "RELATIVE-BYPASS.sldprt"}])).unwrap_err();
        assert!(e.contains("ABSOLUTE"), "{e}");
        assert!(parse(&json!([{"op": "save_as", "path": "/tmp/../etc/x"}])).unwrap_err().contains(".."));
        // ...and the format that HANGS SOLIDWORKS cannot sneak in by filename
        let e = parse(&json!([{"op": "save_as", "path": "C:\\x\\a.WRL"}])).unwrap_err();
        assert!(e.contains("VRML"), "{e}");
    }

    #[test]
    fn a_hostile_destination_cannot_smuggle_the_blocked_extension_past() {
        // Windows strips trailing dots and spaces and ignores an ADS suffix, so
        // every one of these opens `a.wrl`. A raw string compare admitted all
        // three.
        for bad in [r"C:\x\a.wrl.", "C:\\x\\a.wrl ", r"C:\x\a.wrl::$DATA", r"C:\x\a.WRL",
                    r"C:\x\a.Wrl..  "] {
            let e = parse(&json!([{"op": "save_as", "path": bad}])).unwrap_err();
            assert!(e.contains("VRML"), "{bad} should have been refused, got: {e}");
        }
        // ...and a legitimate destination still works.
        assert!(parse(&json!([{"op": "save_as", "path": r"C:\x\a.sldprt"}])).is_ok());
    }

    #[test]
    fn the_blocked_extension_list_matches_the_verb_layers() {
        // Two lists, one rule: if these ever drift, the DSL becomes a bypass for
        // the guard the verb layer enforces.
        let mut a: Vec<&str> = BLOCKED_DEST_EXTENSIONS.to_vec();
        let mut b: Vec<&str> = crate::verbs3::BLOCKED_FORMATS.to_vec();
        a.sort();
        b.sort();
        assert_eq!(a, b, "script::BLOCKED_DEST_EXTENSIONS must match verbs3::BLOCKED_FORMATS");
    }

    #[test]
    fn components_parse_in_both_shapes_and_convert_mm_to_metres() {
        // the array shape
        let c = parse_components(&json!({"components": [
            {"filePath": "C:\\p\\a.sldprt", "xMm": 100, "yMm": -25.4, "zMm": 0},
            {"filePath": "C:\\p\\b.sldprt", "configuration": "Long"}
        ]}))
        .unwrap();
        assert_eq!(c.len(), 2);
        assert_eq!(
            c[0],
            Component {
                file: "C:\\p\\a.sldprt".into(),
                x: 0.1,
                y: -0.0254,
                z: 0.0,
                rx: 0.0,
                ry: 0.0,
                rz: 0.0,
                config: String::new()
            }
        );
        assert!(!c[0].rotated());
        // omitted coordinates are the origin, not an error
        assert_eq!(c[1].x, 0.0);
        assert_eq!(c[1].config, "Long");
        // the single-component shorthand, with the assembly named by `path`
        let one = parse_components(&json!({"path": "C:\\p\\crate.sldasm", "filePath": "C:\\p\\a.sldprt", "zMm": 12.7})).unwrap();
        assert_eq!(one.len(), 1);
        assert_eq!(one[0].z, 0.0127);
        // ...and `path` is NOT read as the component: it names the assembly.
        assert_eq!(one[0].file, "C:\\p\\a.sldprt");
    }

    #[test]
    fn components_refuse_what_would_land_somewhere_unintended() {
        assert!(parse_components(&json!({"components": []})).unwrap_err().contains("empty"));
        assert!(parse_components(&json!({"components": {"filePath": "C:\\a.sldprt"}}))
            .unwrap_err()
            .contains("ARRAY"));
        assert!(parse_components(&json!({})).unwrap_err().contains("filePath is required"));
        // the same two path rules the DSL's save_as enforces
        assert!(parse_components(&json!({"filePath": "RELATIVE.sldprt"})).unwrap_err().contains("ABSOLUTE"));
        assert!(parse_components(&json!({"filePath": "/tmp/../etc/a.sldprt"})).unwrap_err().contains(".."));
        // a NaN coordinate cannot become a position
        assert!(parse_components(&json!({"filePath": "C:\\a.sldprt", "xMm": "12"}))
            .unwrap_err()
            .contains("xMm"));
        // and the step index is in the message
        let e = parse_components(&json!({"components": [{"filePath": "C:\\a.sldprt"}, {}]})).unwrap_err();
        assert!(e.starts_with("component 2:"), "{e}");
    }

    #[test]
    fn a_component_transform_is_the_sixteen_doubles_solidworks_wants() {
        let at = |x: f64, y: f64, z: f64, rz: f64| Component {
            file: "C:\\a.sldprt".into(),
            x,
            y,
            z,
            rx: 0.0,
            ry: 0.0,
            rz: rz.to_radians(),
            config: String::new(),
        };
        // No rotation: identity, the translation, and the scale.
        let t = at(0.1, 0.2, 0.3, 0.0).transform();
        assert_eq!(&t[..9], &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
        assert_eq!((t[9], t[10], t[11], t[12]), (0.1, 0.2, 0.3, 1.0));
        assert_eq!(&t[13..], &[0.0, 0.0, 0.0]);
        // A quarter turn about Z sends local +X to assembly +Y. Written
        // COLUMN-major, so t[0..3] is where local +X ends up.
        let t = at(0.0, 0.0, 0.0, 90.0).transform();
        let round = |v: f64| (v * 1e9).round() / 1e9;
        let col = |i: usize| [round(t[i * 3]), round(t[i * 3 + 1]), round(t[i * 3 + 2])];
        assert_eq!(col(0), [0.0, 1.0, 0.0], "local +X becomes assembly +Y");
        assert_eq!(col(1), [-1.0, 0.0, 0.0], "local +Y becomes assembly -X");
        assert_eq!(col(2), [0.0, 0.0, 1.0], "Z is untouched");
        // ...and a quarter turn about Y sends local +Z to assembly +X.
        let mut lay = at(0.0, 0.0, 0.0, 0.0);
        lay.ry = std::f64::consts::FRAC_PI_2;
        let t = lay.transform();
        let col = |i: usize| [round(t[i * 3]), round(t[i * 3 + 1]), round(t[i * 3 + 2])];
        assert_eq!(col(2), [1.0, 0.0, 0.0], "local +Z becomes assembly +X");
        // Degrees in, radians kept.
        let c = parse_components(&json!({"filePath": "C:\\a.sldprt", "rotYDeg": 90})).unwrap();
        assert_eq!(round(c[0].ry), round(std::f64::consts::FRAC_PI_2));
        assert!(c[0].rotated());
        assert!(parse_components(&json!({"filePath": "C:\\a.sldprt", "rotZDeg": "90"}))
            .unwrap_err()
            .contains("rotZDeg"));
    }

    #[test]
    fn an_inserted_run_is_found_by_matching_the_path_sequence() {
        let c = |f: &str| Component {
            file: f.into(),
            x: 0.0,
            y: 0.0,
            z: 0.0,
            rx: 0.0,
            ry: 0.0,
            rz: 0.0,
            config: String::new(),
        };
        let p = |v: &[&str]| -> Vec<String> { v.iter().map(|s| s.to_string()).collect() };
        let reqs = vec![c("C:\\p\\a.sldprt"), c("C:\\p\\b.sldprt"), c("C:\\p\\a.sldprt")];
        // appended at the end, in order
        let paths = p(&["C:\\p\\z.sldprt", "C:\\p\\a.sldprt", "C:\\p\\b.sldprt", "C:\\p\\a.sldprt"]);
        assert_eq!(locate_run(&paths, &reqs), Some((1, true)));
        // prepended, newest first — the reversed run
        let paths = p(&["C:\\p\\a.sldprt", "C:\\p\\b.sldprt", "C:\\p\\a.sldprt", "C:\\p\\z.sldprt"]);
        assert_eq!(locate_run(&paths, &reqs), Some((0, true)));
        let reqs2 = vec![c("C:\\p\\a.sldprt"), c("C:\\p\\b.sldprt")];
        let paths = p(&["C:\\p\\b.sldprt", "C:\\p\\a.sldprt"]);
        assert_eq!(locate_run(&paths, &reqs2), Some((0, false)));
        // SOLIDWORKS uppercases the extension it reports back
        let paths = p(&["C:\\p\\A.SLDPRT", "C:\\p\\B.SLDPRT"]);
        assert_eq!(locate_run(&paths, &reqs2), Some((0, true)));
        // NO MATCH is an answer, not a guess
        assert_eq!(locate_run(&p(&["C:\\p\\z.sldprt", "C:\\p\\y.sldprt"]), &reqs2), None);
        assert_eq!(locate_run(&p(&["C:\\p\\a.sldprt"]), &reqs2), None);
        assert_eq!(locate_run(&p(&["C:\\p\\a.sldprt"]), &[]), None);
    }

    #[test]
    fn an_object_wrapper_is_accepted_because_callers_write_both() {
        let ops = parse(&json!({"steps": [{"op": "rebuild"}]})).unwrap();
        assert_eq!(ops, vec![Op::Rebuild]);
        assert_eq!(ops[0].name(), "rebuild");
        assert!(OPS.contains(&"rebuild"));
    }

    // ── 0.10.0: issue #1 (Michael) ──────────────────────────────────────────

    fn near(a: f64, b: f64) -> bool {
        (a - b).abs() < 1e-12
    }

    #[test]
    fn offset_planes_faces_and_anchors_parse_into_exact_geometry() {
        let ops = parse(&json!([
            {"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 9.6}, "widthMm": 31.8, "heightMm": 15.8, "anchor": "center"},
            {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 4, "centerMm": [5, 2]},
            {"op": "sketch_circle", "face": {"atPointMm": [0, 9.6, 0]}, "diameterMm": 4.8},
            {"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 0}, "widthMm": 1, "heightMm": 1}
        ]))
        .unwrap();
        match &ops[0] {
            Op::Sketch { plane: PlaneRef::Offset { from, offset }, entities, .. } => {
                assert_eq!(from, "Top Plane");
                assert!(near(*offset, 0.0096));
                match entities[0] {
                    Entity::Rect { x0, y0, x1, y1 } => assert!(near(x0, -0.0159) && near(y0, -0.0079) && near(x1, 0.0159) && near(y1, 0.0079)),
                    _ => panic!(),
                }
            }
            other => panic!("{other:?}"),
        }
        match &ops[1] {
            Op::Sketch { entities, .. } => match entities[0] {
                Entity::Rect { x0, y0, x1, y1 } => assert!(near(x0, 0.0) && near(y0, 0.0) && near(x1, 0.01) && near(y1, 0.004)),
                _ => panic!(),
            },
            _ => panic!(),
        }
        match &ops[2] {
            Op::Sketch { plane: PlaneRef::Face { point }, entities, .. } => {
                assert!(near(point[1], 0.0096));
                assert_eq!(entities[0], Entity::Circle { cx: 0.0, cy: 0.0, r: 0.0024 });
            }
            other => panic!("{other:?}"),
        }
        // an offset of zero IS the base plane
        assert!(matches!(&ops[3], Op::Sketch { plane: PlaneRef::Named(p), .. } if p == "Top Plane"));
        // refusals
        assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 1, "heightMm": 1, "anchor": "middle"}])).unwrap_err().contains("anchor"));
        assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 0, "heightMm": 1}])).unwrap_err().contains("greater than zero"));
        assert!(parse(&json!([{"op": "sketch_rect", "plane": "Top Plane", "face": {"atPointMm": [0, 0, 0]}, "widthMm": 1, "heightMm": 1}])).unwrap_err().contains("not both"));
        assert!(parse(&json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 0]}, "radiusMm": 1}])).unwrap_err().contains("[x, y, z]"));
    }

    #[test]
    fn one_sketch_holds_many_profiles_and_extents_read_every_direction() {
        let ops = parse(&json!([
            {"op": "sketch", "plane": "Top Plane", "entities": [
                {"rect": {"widthMm": 10, "heightMm": 10, "anchor": "center"}},
                {"circle": {"radiusMm": 3.255}},
                {"type": "circle", "radiusMm": 2.4}
            ]},
            {"op": "extrude", "depthMm": 8.6, "direction": "both", "depth2Mm": 2},
            {"op": "cut", "throughAll": true, "reverse": true},
            {"op": "cut", "depthMm": 4, "midPlane": true},
            {"op": "extrude", "throughAll": true, "midPlane": true}
        ]))
        .unwrap();
        assert!(matches!(&ops[0], Op::Sketch { shorthand: "sketch", entities, .. } if entities.len() == 3));
        assert_eq!(ops[1], Op::Extrude(Extent { depth: 0.0086, depth2: 0.002, through_all: false, reverse: false, direction: Direction::Both , up_to: None, draft: None, regions: Vec::new() }));
        assert_eq!(ops[2], Op::Cut(Extent { depth: 0.0, depth2: 0.0, through_all: true, reverse: true, direction: Direction::One , up_to: None, draft: None, regions: Vec::new() }));
        assert!(matches!(&ops[3], Op::Cut(e) if e.direction == Direction::MidPlane && near(e.depth, 0.004)));
        // through all + mid plane is SOLIDWORKS' "Through All - Both"
        assert!(matches!(&ops[4], Op::Extrude(e) if e.direction == Direction::Both && e.through_all));
        assert!(parse(&json!([{"op": "cut"}])).unwrap_err().contains("throughAll"));
        assert!(parse(&json!([{"op": "sketch", "entities": []}])).unwrap_err().contains("non-empty"));
        assert!(parse(&json!([{"op": "sketch", "entities": [{"triangle": {}}]}])).unwrap_err().starts_with("step 1: entity 1:"));
        assert!(parse(&json!([{"op": "extrude", "depthMm": 1, "direction": "sideways"}])).unwrap_err().contains("direction"));
    }

    #[test]
    fn appearance_fillet_chamfer_shell_and_patterns_parse() {
        let ops = parse(&json!([
            {"op": "set_appearance", "rgb": [200, 0, 0]},
            {"op": "set_appearance", "rgb": [0, 0, 255], "target": {"faceAtPointMm": [0, 1, 0]}},
            {"op": "fillet", "radiusMm": 0.5, "edges": {"allOfFeature": "last"}},
            {"op": "chamfer", "distanceMm": 0.3, "edges": {"atPointsMm": [[1, 2, 3]]}},
            {"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 0, 0]]},
            {"op": "linear_pattern", "directions": [{"axis": "x", "count": 4, "spacingMm": 8}, {"axis": "z", "count": 2, "spacingMm": -8}]},
            {"op": "circular_pattern", "feature": "Boss-Extrude2", "axis": "y", "count": 6}
        ]))
        .unwrap();
        assert!(matches!(&ops[0], Op::SetAppearance { rgb, target: AppearanceTarget::Part } if near(rgb[0], 200.0 / 255.0) && rgb[1] == 0.0));
        assert!(matches!(&ops[1], Op::SetAppearance { target: AppearanceTarget::FaceAt(p), .. } if near(p[1], 0.001)));
        assert!(matches!(&ops[2], Op::Fillet { radius, edges: EdgeSel::AllOfFeature(f) } if near(*radius, 0.0005) && f == "last"));
        assert!(matches!(&ops[3], Op::Chamfer { angle, .. } if near(*angle, std::f64::consts::FRAC_PI_4)));
        assert!(matches!(&ops[4], Op::Shell { outward: false, .. }));
        assert!(matches!(&ops[5], Op::LinearPattern { feature, dirs } if feature == "last" && dirs.len() == 2 && dirs[1].0 == Axis::Z && near(dirs[1].2, -0.008)));
        assert!(matches!(&ops[6], Op::CircularPattern { axis: Axis::Y, count: 6, angle, .. } if near(*angle, std::f64::consts::TAU)));
        assert!(ops.iter().filter(|o| o.makes_feature()).count() == 5);
        assert!(parse(&json!([{"op": "set_appearance", "rgb": [300, 0, 0]}])).unwrap_err().contains("0..255"));
        assert!(parse(&json!([{"op": "linear_pattern", "directions": [{"axis": "x", "count": 1, "spacingMm": 8}]}])).unwrap_err().contains("count"));
        assert!(parse(&json!([{"op": "linear_pattern", "directions": [{"axis": "x", "count": 2, "spacingMm": 8}, {"axis": "x", "count": 2, "spacingMm": 8}]}])).unwrap_err().contains("different axes"));
        assert!(parse(&json!([{"op": "fillet", "radiusMm": 1}])).unwrap_err().contains("edges"));
        // "last" needs a feature before it
        let bad = parse(&json!([{"op": "new_part"}, {"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "last"}}])).unwrap();
        assert!(validate(&bad, false).unwrap_err().contains("\"last\""));
        let good = parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 1, "heightMm": 1}, {"op": "extrude", "depthMm": 1},
                                 {"op": "fillet", "radiusMm": 0.1, "edges": {"allOfFeature": "last"}}])).unwrap();
        assert!(validate(&good, false).is_ok());
    }

    // ── 0.11.0: sketch geometry, end conditions, revolve, mirror, hole ─────

    fn sketch_of(entities: Value) -> Result<Vec<Entity>, String> {
        match parse(&json!([{"op": "sketch", "plane": "Front Plane", "entities": entities}]))?.remove(0) {
            Op::Sketch { entities, .. } => Ok(entities),
            _ => unreachable!(),
        }
    }

    /// OFFSET ENTITIES AND SKETCH REGIONS (0.11.4): parsed, defaulted, and
    /// refused for what would make them meaningless.
    #[test]
    fn offset_entities_and_regions_parse_and_refuse_what_they_must() {
        let ops = parse(&json!([
            {"op": "sketch", "face": {"atPointMm": [70, 10, 44]}, "entities": [
                {"offsetEntities": {"fromFace": {"atPointMm": [70, 10, 44]}, "offsetMm": -1}},
                {"circle": {"centerMm": [72, 28.7], "radiusMm": 42}}]},
            {"op": "cut", "regionsAtMm": [[25, 0], [30, 1]], "offsetFromSurface": {"face": {"atPointMm": [30, 0, 0]}, "distanceMm": 12}},
            {"op": "sketch", "plane": "Top Plane", "entities": [
                {"offsetEntities": {"fromFace": {"atPointMm": [0, 10, 0]}, "offsetMm": 2, "loops": "all", "chain": false}}]},
            {"op": "extrude", "depthMm": 3, "regionAtMm": [1, 2]}
        ]))
        .unwrap();
        let Op::Sketch { entities, offsets, .. } = &ops[0] else { panic!() };
        assert_eq!(entities.len(), 1, "the circle is drawn by the bridge; the offset is not an entity");
        assert_eq!(offsets, &vec![OffsetFace { point: [0.07, 0.01, 0.044], offset: -0.001, loops: OffsetLoops::Outer, chain: true }], "defaults: the outer loop, chained");
        assert_eq!(offsets[0].describe()["offsetEntities"]["side"], "inward");
        let Op::Cut(e) = &ops[1] else { panic!() };
        assert_eq!(e.regions, vec![[0.025, 0.0], [0.03, 0.001]]);
        assert!(matches!(e.up_to, Some(UpTo::OffsetFrom { .. })), "regions go with any end condition");
        let Op::Sketch { entities, offsets, .. } = &ops[2] else { panic!() };
        assert!(entities.is_empty(), "a sketch may hold nothing but an offset");
        assert_eq!((offsets[0].loops, offsets[0].chain, offsets[0].offset), (OffsetLoops::All, false, 0.002));
        assert_eq!(offsets[0].describe()["offsetEntities"]["side"], "outward");
        let Op::Extrude(e) = &ops[3] else { panic!() };
        assert_eq!(e.regions, vec![[0.001, 0.002]], "regionAtMm is one region");
        // Refusals.
        let one = |ent: Value| parse(&json!([{"op": "sketch", "entities": [ent]}])).unwrap_err();
        assert!(one(json!({"offsetEntities": {"offsetMm": -1}})).contains("needs fromFace"));
        assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": 0}})).contains("must not be 0"));
        assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}}})).contains("offsetMm"));
        assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": -1, "loops": "inner"}})).contains("loops is"));
        assert!(one(json!({"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": 2000}})).contains("1000"));
        assert!(one(json!({"offsetEntities": 5})).starts_with("step 1: entity 1 (offsetEntities):"));
        // An entity after an offset is numbered as the caller wrote it.
        let e = parse(&json!([{"op": "sketch", "entities": [
            {"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": -1}},
            {"circle": {"radiusMm": 2}},
            {"polyline": {"points": [[0, 0], [10, 0], [10, 10]]}}]}]))
        .unwrap_err();
        assert!(e.starts_with("step 1: entity 3"), "{e}");
        assert_eq!(renumber_entity("entity 2 (polyline): a gap", &[1, 2]), "entity 3 (polyline): a gap");
        assert_eq!(renumber_entity("entity 9: x", &[1]), "entity 9: x", "out of range: as it was");
        assert_eq!(renumber_entity("no entity", &[1]), "no entity");
        let cut = |extra: Value| {
            let mut c = json!({"op": "cut", "depthMm": 1});
            for (k, v) in extra.as_object().unwrap() {
                c[k] = v.clone();
            }
            parse(&json!([{"op": "sketch_rect", "widthMm": 1, "heightMm": 1}, c])).unwrap_err()
        };
        assert!(cut(json!({"regionsAtMm": []})).contains("non-empty"));
        assert!(cut(json!({"regionsAtMm": [[1, 2, 3]]})).contains("[u, v]"));
        assert!(cut(json!({"regionsAtMm": [[1, 2]], "regionAtMm": [1, 2]})).contains("not both"));
        // A sketch whose profile is SOLIDWORKS' own cannot be revolved: no side
        // of an axis can be checked.
        let e = parse(&json!([
            {"op": "sketch", "entities": [{"offsetEntities": {"fromFace": {"atPointMm": [0, 0, 0]}, "offsetMm": -1}}]},
            {"op": "revolve", "axis": "x"}
        ]))
        .unwrap_err();
        assert!(e.contains("cannot be revolved"), "{e}");
    }

    #[test]
    fn an_open_contour_is_refused_naming_the_gap() {
        let e = sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]]}}])).unwrap_err();
        assert!(e.contains("entity 1") && e.contains("OPEN") && e.contains("[0.0, 10.0] mm") && e.contains("gap of 10.0 mm"), "{e}");
        // Closed by its own `close`, or by a line back to the start: accepted.
        assert_eq!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]], "close": true}}])).unwrap().len(), 4);
        let joined = sketch_of(json!([
            {"line": {"from": [0, 0], "to": [10, 0]}}, {"line": {"from": [10, 10], "to": [10, 0]}},
            {"line": {"from": [10, 10], "to": [0, 0]}}
        ]))
        .unwrap();
        assert_eq!(contours(&joined).unwrap(), vec![vec![(0, false), (1, true), (2, false)]], "a reversed segment joins too");
        // A gap of 1e-5 mm is a gap; 1e-7 mm is not.
        assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 0.00001]]}}])).unwrap_err().contains("gap of 0.00001 mm"));
        assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 0.0000001]]}}])).is_ok());
        // A branch: three ends at one point.
        let e = sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10]], "close": true}}, {"line": {"from": [0, 0], "to": [-5, 0]}}])).unwrap_err();
        assert!(e.contains("3 segment ends meet"), "{e}");
        // Construction geometry is never a contour, and a sketch of only it is refused.
        assert!(sketch_of(json!([{"rect": {"widthMm": 10, "heightMm": 10}}, {"line": {"from": [0, 0], "to": [5, 5], "construction": true}}])).is_ok());
        assert!(sketch_of(json!([{"centerline": {"from": [0, 0], "to": [5, 5]}}])).unwrap_err().contains("only construction"));
    }

    #[test]
    fn arcs_three_ways_land_where_they_should() {
        // arc: centre, radius, angles — counter-clockwise by default.
        let a = sketch_of(json!([{"arc": {"center": [0, 0], "radiusMm": 10, "startDeg": 0, "endDeg": 180}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();
        let Entity::Arc { x0, y0, x1, y1, ccw, .. } = a[0] else { panic!() };
        assert!((x0 - 0.01).abs() < 1e-15 && y0.abs() < 1e-15 && (x1 + 0.01).abs() < 1e-15 && y1.abs() < 1e-15 && ccw);
        // arc3: the circumcentre, and the direction from the three points.
        let b = sketch_of(json!([{"arc3": {"start": [10, 0], "mid": [0, 10], "end": [-10, 0]}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();
        let Entity::Arc { cx, cy, ccw, .. } = b[0] else { panic!() };
        assert!(cx.abs() < 1e-15 && cy.abs() < 1e-15 && ccw);
        let c = sketch_of(json!([{"arc3": {"start": [10, 0], "mid": [0, -10], "end": [-10, 0]}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();
        assert!(matches!(c[0], Entity::Arc { ccw: false, .. }), "below the chord: clockwise");
        assert!(sketch_of(json!([{"arc3": {"start": [0, 0], "mid": [5, 0], "end": [10, 0]}}])).unwrap_err().contains("in a line"));
        // tangentArc: from a line along +x ending at (20, 10): the quarter circle about (10, 10).
        let t = sketch_of(json!([
            {"line": {"from": [0, 0], "to": [10, 0]}}, {"tangentArc": {"to": [20, 10]}},
            {"line": {"from": [20, 10], "to": [0, 10]}}, {"line": {"from": [0, 10], "to": [0, 0]}}
        ]))
        .unwrap();
        let Entity::Arc { cx, cy, ccw, .. } = t[1] else { panic!() };
        assert!((cx - 0.01).abs() < 1e-15 && (cy - 0.01).abs() < 1e-15 && ccw, "{:?}", t[1]);
        let e = sketch_of(json!([{"line": {"from": [0, 0], "to": [10, 0]}}, {"tangentArc": {"to": [20, 10], "radiusMm": 12}}])).unwrap_err();
        assert!(e.contains("radius 10.0 mm"), "{e}");
        assert!(sketch_of(json!([{"tangentArc": {"to": [20, 10]}}])).unwrap_err().contains("continues from the entity before it"));
        assert!(sketch_of(json!([{"arc": {"center": [0, 0], "radiusMm": 5, "startDeg": 10, "endDeg": 370}}])).unwrap_err().contains("same angle"));
    }

    #[test]
    fn composites_expand_to_lines_and_arcs() {
        let s = sketch_of(json!([{"slot": {"center": [0, 0], "lengthMm": 20, "widthMm": 6}}])).unwrap();
        assert_eq!(s.iter().filter(|e| matches!(e, Entity::Line { .. })).count(), 2);
        assert_eq!(s.iter().filter(|e| matches!(e, Entity::Arc { .. })).count(), 2);
        let p = sketch_of(json!([{"polygon": {"center": [0, 0], "sides": 6, "circumradiusMm": 10, "angleDeg": 90}}])).unwrap();
        assert_eq!(p.len(), 6);
        let Entity::Line { x0, y0, .. } = p[0] else { panic!() };
        assert!(x0.abs() < 1e-15 && (y0 - 0.01).abs() < 1e-15, "the first vertex at 90°");
        assert!(sketch_of(json!([{"polygon": {"center": [0, 0], "sides": 6}}])).unwrap_err().contains("ONE of circumradiusMm"));
        // A construction rect: four construction lines; a construction circle: two arcs.
        let c = sketch_of(json!([{"circle": {"radiusMm": 9}}, {"rect": {"widthMm": 4, "heightMm": 4, "construction": true}}, {"circle": {"radiusMm": 2, "construction": true}}])).unwrap();
        assert_eq!(c.iter().filter(|e| e.is_construction()).count(), 6);
        assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 4, "cornerRadiusMm": 2.5}])).unwrap_err().contains("more than half"));
        // sketch_rect with cornerRadiusMm keeps its shorthand name.
        assert_eq!(parse(&json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 4, "cornerRadiusMm": 1}])).unwrap()[0].name(), "sketch_rect");
    }

    #[test]
    fn a_revolve_axis_is_resolved_into_its_sketch() {
        let ops = parse(&json!([
            {"op": "new_part"},
            {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 5, "yMm": 0, "widthMm": 5, "heightMm": 10}}]},
            {"op": "revolve", "axis": "y"}
        ]))
        .unwrap();
        let Op::Sketch { entities, .. } = &ops[1] else { panic!() };
        let Op::Revolve(rv) = &ops[2] else { panic!() };
        assert_eq!(rv.axis, 1);
        assert!(matches!(entities[1], Entity::Line { x0, x1, construction: true, .. } if x0 == 0.0 && x1 == 0.0), "{:?}", entities[1]);
        assert_eq!(rv.angle, std::f64::consts::TAU);
        // {entity: i} is the caller's i-th entity, after any expansion before it.
        let ops = parse(&json!([
            {"op": "new_part"},
            {"op": "sketch", "entities": [{"polyline": {"points": [[0, 0], [5, 0], [5, 5], [0, 5]], "close": true}}, {"centerline": {"from": [-1, -2], "to": [-1, 9]}}]},
            {"op": "revolve_cut", "axis": {"entity": 1}, "angleDeg": 90}
        ]))
        .unwrap();
        let Op::RevolveCut(rv) = &ops[2] else { panic!() };
        assert_eq!(rv.axis, 4);
        assert!(parse(&json!([{"op": "new_part"}, {"op": "sketch", "entities": [{"rect": {"widthMm": 5, "heightMm": 5}}]}, {"op": "revolve", "axis": {"entity": 0}}]))
            .unwrap_err()
            .contains("not a line"));
        // A profile across the axis is refused before anything runs.
        let e = parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 10, "heightMm": 5, "anchor": "center"}, {"op": "revolve", "axis": "y"}])).unwrap_err();
        assert!(e.contains("step 3") && e.contains("crosses the revolve axis") && e.contains("5.0 mm"), "{e}");
        // ...an ARC bulging across it too (its ends are both on one side).
        let e = parse(&json!([{"op": "new_part"}, {"op": "sketch", "entities": [
            {"line": {"from": [2, -5], "to": [2, 5]}}, {"arc": {"center": [4, 0], "radiusMm": 5.385164807134504, "startDeg": 111.80140948635182, "endDeg": 248.19859051364818}}]},
            {"op": "revolve", "axis": "y"}])).unwrap_err();
        assert!(e.contains("crosses"), "{e}");
        assert!(parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 5, "heightMm": 5}, {"op": "revolve"}])).unwrap_err().contains("axis is required"));
        assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "revolve", "axis": "x"}])).unwrap(), false).unwrap_err().contains("no sketch to consume"));
    }

    #[test]
    fn end_conditions_and_draft_parse_and_refuse_their_conflicts() {
        let e = |v: Value| parse(&json!([v])).map(|mut o| o.remove(0));
        assert_eq!(e(json!({"op": "extrude", "upToNext": true})).unwrap(), Op::Extrude(Extent { up_to: Some(UpTo::Next), depth: 0.0, depth2: 0.0, ..Extent::blind(0.0) }));
        assert_eq!(e(json!({"op": "cut", "endCondition": "upToNext"})).unwrap(), Op::Cut(Extent { up_to: Some(UpTo::Next), ..Extent::blind(0.0) }));
        assert_eq!(
            e(json!({"op": "extrude", "upToSurface": {"face": {"atPointMm": [1, 2, 3]}}})).unwrap(),
            Op::Extrude(Extent { up_to: Some(UpTo::Surface { point: [0.001, 0.002, 0.003] }), ..Extent::blind(0.0) })
        );
        assert_eq!(
            e(json!({"op": "cut", "offsetFromSurface": {"atPointMm": [0, 0, 0], "distanceMm": 2, "reverseOffset": true}, "reverse": true})).unwrap(),
            Op::Cut(Extent { up_to: Some(UpTo::OffsetFrom { point: [0.0; 3], offset: 0.002, reverse_offset: true }), reverse: true, ..Extent::blind(0.002) })
        );
        assert!(e(json!({"op": "extrude", "upToNext": true, "throughAll": true})).unwrap_err().contains("exclude"));
        assert!(e(json!({"op": "extrude", "upToNext": true, "depthMm": 5})).unwrap_err().contains("no meaning"));
        assert!(e(json!({"op": "extrude", "upToNext": true, "direction": "both"})).unwrap_err().contains("ONE way"));
        assert!(e(json!({"op": "extrude", "upToNext": true, "upToSurface": {"atPointMm": [0, 0, 0]}})).unwrap_err().contains("ONE end condition"));
        assert!(e(json!({"op": "extrude", "endCondition": "upToVertex"})).unwrap_err().contains("endCondition"));
        assert_eq!(e(json!({"op": "extrude", "depthMm": 5, "draftDeg": 3})).unwrap(), Op::Extrude(Extent { draft: Some(Draft { angle: 3f64.to_radians(), outward: false }), ..Extent::blind(0.005) }));
        assert!(e(json!({"op": "extrude", "depthMm": 5, "draftDeg": 90})).unwrap_err().contains("draftDeg"));
        assert_eq!(crate::modeling::end_condition_id(&UpTo::Next), 11);
        assert_eq!(crate::modeling::end_condition_id(&UpTo::Surface { point: [0.0; 3] }), 4, "swEndCondUpToSurface (measured 2026-10-16; 10 stops at the next face)");
        assert_eq!(crate::modeling::end_condition_id(&UpTo::OffsetFrom { point: [0.0; 3], offset: 0.0, reverse_offset: false }), 5);
    }

    #[test]
    fn mirror_hole_and_density_parse() {
        let one = |v: Value| parse(&json!([v])).map(|mut o| o.remove(0));
        assert_eq!(one(json!({"op": "mirror", "plane": "Right Plane"})).unwrap(), Op::Mirror { features: vec!["last".into()], bodies: false, plane: PlaneRef::Named("Right Plane".into()) });
        assert_eq!(one(json!({"op": "mirror", "plane": "Right Plane", "featureOrBody": "all"})).unwrap(), Op::Mirror { features: vec![], bodies: true, plane: PlaneRef::Named("Right Plane".into()) });
        assert_eq!(
            one(json!({"op": "mirror", "face": {"atPointMm": [0, 5, 0]}, "features": ["Boss-Extrude1", "Cut-Extrude1"]})).unwrap(),
            Op::Mirror { features: vec!["Boss-Extrude1".into(), "Cut-Extrude1".into()], bodies: false, plane: PlaneRef::Face { point: [0.0, 0.005, 0.0] } }
        );
        assert!(one(json!({"op": "mirror"})).unwrap_err().contains("needs the plane"));
        assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "mirror", "plane": "Top Plane"}])).unwrap(), false).unwrap_err().contains("\"last\""));
        assert_eq!(
            one(json!({"op": "hole", "atMm": [1, 2, 3], "diameterMm": 5, "depthMm": 10, "counterbore": {"diameterMm": 9, "depthMm": 4}})).unwrap(),
            Op::Hole { at: [0.001, 0.002, 0.003], diameter: 0.005, depth: Some(0.01), counterbore: Some((9.0 * MM, 0.004)), countersink: None }
        );
        assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5})).unwrap_err().contains("depthMm"));
        assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "throughAll": true, "counterbore": {"diameterMm": 4, "depthMm": 1}})).unwrap_err().contains("larger"));
        assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "depthMm": 3, "counterbore": {"diameterMm": 9, "depthMm": 3}})).unwrap_err().contains("less than"));
        assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "depthMm": 2, "countersink": {"diameterMm": 10, "angleDeg": 90}})).unwrap_err().contains("deeper"));
        assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "throughAll": true, "countersink": {"diameterMm": 10}, "counterbore": {"diameterMm": 9, "depthMm": 1}})).unwrap_err().contains("not both"));
        assert!((countersink_depth(0.006, 0.012, 90f64.to_radians()) - 0.003).abs() < 1e-15);
        assert_eq!(one(json!({"op": "set_material", "densityKgM3": 1250})).unwrap(), Op::SetMaterial { name: String::new(), database: "SOLIDWORKS Materials.sldmat".into(), density: Some(1250.0) });
        assert!(one(json!({"op": "set_material"})).unwrap_err().contains("densityKgM3 alone"));
        assert!(one(json!({"op": "set_material", "densityKgM3": -3})).unwrap_err().contains("positive"));
        // A hole between a sketch and its extrude would take the sketch's place.
        assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 5, "heightMm": 5},
            {"op": "hole", "atMm": [0, 0, 0], "diameterMm": 1, "throughAll": true}, {"op": "extrude", "depthMm": 1}])).unwrap(), false)
            .unwrap_err().contains("between a sketch"));
    }

    #[test]
    fn a_hole_plan_cuts_into_the_face_from_where_the_counterbore_ends() {
        let top = crate::modeling::PlaneFrame::defaults()[1];
        let plan = hole_plan(&top, "Top Plane", 0.02, true, [0.005, 0.02, -0.003], 0.006, None, Some((0.011, 0.004)), None);
        assert_eq!(plan.iter().map(|o| o.name()).collect::<Vec<_>>(), ["sketch", "cut", "sketch", "cut"]);
        // Top Plane: sketch +y = model −Z, so model z −3 is sketch v +3.
        assert_eq!(plan[0], Op::Sketch { shorthand: "sketch", plane: PlaneRef::Offset { from: "Top Plane".into(), offset: 0.02 }, entities: vec![Entity::Circle { cx: 0.005, cy: 0.003, r: 0.0055 }], offsets: vec![] });
        assert_eq!(plan[1], Op::Cut(Extent::blind(0.004)));
        assert!(matches!(&plan[2], Op::Sketch { plane: PlaneRef::Offset { offset, .. }, .. } if (offset - 0.016).abs() < 1e-15));
        assert_eq!(plan[3], Op::Cut(Extent { through_all: true, ..Extent::blind(0.0) }));
        // From a face looking down (−normal) at height 0: Top Plane itself, reversed.
        let plan = hole_plan(&top, "Top Plane", 0.0, false, [0.0; 3], 0.006, Some(0.005), None, Some((0.012, std::f64::consts::FRAC_PI_2)));
        assert_eq!(plan[0], Op::Sketch { shorthand: "sketch", plane: PlaneRef::Named("Top Plane".into()), entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.006 }], offsets: vec![] });
        let Op::Cut(cs) = &plan[1] else { panic!() };
        assert!(cs.reverse && (cs.depth - 0.003).abs() < 1e-15 && cs.draft == Some(Draft { angle: std::f64::consts::FRAC_PI_4, outward: false }));
        let Op::Cut(main) = &plan[3] else { panic!() };
        assert!(main.reverse && (main.depth - 0.002).abs() < 1e-15);
        assert!(matches!(&plan[2], Op::Sketch { plane: PlaneRef::Offset { offset, .. }, .. } if (offset - 0.003).abs() < 1e-15));
    }

    #[test]
    fn sketch_fillets_and_chamfers_round_and_cut_a_corner_of_two_lines() {
        // A 20 x 10 rectangle as a polyline; the corner at (20, 10) filleted R3,
        // the corner at (0, 0) chamfered 2.
        let e = sketch_of(json!([
            {"polyline": {"points": [[0, 0], [20, 0], [20, 10], [0, 10]], "close": true}},
            {"sketchFillet": {"at": [20, 10], "radiusMm": 3}},
            {"sketchChamfer": {"at": [0, 0], "distanceMm": 2}}
        ]))
        .unwrap();
        assert_eq!(e.len(), 6, "four lines, an arc, a chamfer line");
        let Entity::Arc { cx, cy, ccw, .. } = e[4] else { panic!("{:?}", e[4]) };
        assert!((cx - 0.017).abs() < 1e-15 && (cy - 0.007).abs() < 1e-15 && ccw, "the fillet's centre 3 in from both edges");
        assert_eq!(e[5], Entity::Line { x0: 0.002, y0: 0.0, x1: 0.0, y1: 0.002, construction: false });
        assert!(contours(&e).is_ok(), "still one closed contour");
        let a = contours(&e).unwrap();
        let (area, _, _) = crate::modeling::contour_moments(&e, &a[0]);
        let want = (200.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) - 2.0) * 1e-6;
        assert!((area.abs() - want).abs() < 1e-15, "{} vs {want}", area.abs());
        // A 60° corner: the tangent length is r / tan(30°).
        let t = sketch_of(json!([
            {"polyline": {"points": [[0, 0], [40, 0], [20, 34.64101615137755]], "close": true}},
            {"sketchFillet": {"at": [40, 0], "radiusMm": 2}}
        ]))
        .unwrap();
        let Entity::Line { x1, .. } = t[0] else { panic!() };
        assert!((x1 - (0.040 - 0.002 / (30f64).to_radians().tan())).abs() < 1e-12);
        assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10]], "close": true}}, {"sketchFillet": {"at": [5, 5], "radiusMm": 1}}]))
            .unwrap_err().contains("0 lines end at"));
        assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [4, 0], [4, 4], [0, 4]], "close": true}}, {"sketchChamfer": {"at": [4, 4], "distanceMm": 5}}]))
            .unwrap_err().contains("shorter"));
    }

    #[test]
    fn a_plane_offset_from_a_face_parses() {
        let o = parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"face": {"atPointMm": [1, 2, 3]}}, "offsetMm": -12}}])).unwrap();
        let Op::Sketch { plane, .. } = &o[0] else { panic!() };
        assert_eq!(*plane, PlaneRef::FaceOffset { point: [0.001, 0.002, 0.003], offset: -0.012 });
        let z = parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"atPointMm": [1, 2, 3]}, "offsetMm": 0}}])).unwrap();
        assert!(matches!(&z[0], Op::Sketch { plane: PlaneRef::Face { .. }, .. }), "offset 0 is the face itself");
        assert!(parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"nope": 1}, "offsetMm": 3}}])).is_err());
    }
}