← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.10.1+0.10.2
Cargo.lock+1−1
@@ -122,7 +122,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.10.1"+version = "0.10.2" dependencies = [  "miniz_oxide",  "serde",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.10.1"+version = "0.10.2" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+28−1
@@ -492,7 +492,34 @@ not.  ## Roadmap -- v0.10.1 (this): **the self-test never reports a closed SOLIDWORKS as a failure,+- v0.10.2 (this): **Michael's features, made to work on real SOLIDWORKS.** An+  independent review checked every new COM call against the 2021 API help. The+  signatures, argument counts, units and enum values were right; the risks were+  behavioural, and each is fixed:+  - faces and edges are picked by GEOMETRY (`GetClosestPointOn` over every face+    and edge), never through the view, so a hidden bottom face works;+  - a sketch's coordinates go through each sketch's own transform, so a flipped+    plane cannot mirror them, and a boss from a face goes outward and a cut+    inward;+  - an offset plane is found by its tree position (`IRefPlane` has no name), and+    its side and an axis's direction are read back;+  - fillet edges are de-duplicated (selecting an edge twice deselects it);+  - a material no longer wipes a colour set earlier, because the bridge+    re-applies it;+  - the extrude re-selects its sketch by name;+  - the pinned document is activated, and the user's active document is put+    back;+  - default planes are found by position, so a localized install works;+  - density is mass / volume per body;+  - `FeatureFillet3` falls back to `CreateFeature`.++  Also fixed: `ActivateDoc3` was passed 0, which is swUserDecision (a prompt),+  where 1 (don't rebuild) was meant. A failed script's new part is now owned, so+  it can be closed. The simulator models every one of these hazards.+  `swx_selftest {level:"write"}` is now a 32-check GEOMETRIC proof with+  expected vs measured on each row; it is the same battery as+  `tools/dsl-live-check.py`. 587 tests.+- v0.10.1: **the self-test never reports a closed SOLIDWORKS as a failure,   and a Connected install is not called classic.** Measured on hosted 0.10.0   with SOLIDWORKS closed: `swx_selftest` counted `swx_docs` and   `swx_get_preferences` as failures ("SOLIDWORKS is not running"). Now:
bridge.json+2−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.10.1",+  "version": "0.10.2",   "description": "Bridge for SOLIDWORKS desktop CAD — create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",@@ -151,7 +151,7 @@       "stop": 420,       "sign_in": 240,       "render_image": 600,-      "selftest": 900+      "selftest": 1500     }   },   "detect": {
docs/COMPATIBILITY.md+50−26
@@ -236,24 +236,38 @@ swx_selftest {"level": "write"}  # also builds, reads back, exports, saves and c   - the read verbs on whatever document is active: document info, design info,     parameters, properties and mass properties, plus bodies for a part or a BOM     for an assembly.-- **`write`** also makes a scratch part in the bridge's own scratch folder,-  never a user document. It runs each modelling op as its own-  `swx_run_script` call, so each passes or fails on its own:-  - an extrude from a centred rectangle;-  - a reversed through-all cut;-  - a sketch on a face;-  - a ring on an offset plane;-  - a fillet, a linear pattern and a circular pattern;-  - a chamfer;-  - appearance, material and a property;-  - a shell.--  It then reads the part back (bodies, mass, design info, parameters), sets a-  dimension, exports a STEP copy into the bridge's exports folder, saves the-  scratch file, **closes it**, and re-activates the document that was active-  before.--Each row is `{name, verb, pass, ms, errorCode, error, hostError}`. The+- **`write`** (0.10.2) is a **geometric proof**, not just a success check. It+  runs the same 32 checks as `tools/dsl-live-check.py`. Each check builds its+  own scratch part (never a user document), MEASURES it with+  `swx_inspect_bodies` / `swx_mass_properties`, and compares the measurement+  with geometry computed in the self-test itself from closed-form formulas and+  the documented conventions. Each row carries `expected`, `measured` and+  `fails`, and the check then closes what it made. The checks cover:+  - the conventions: the corner anchor, Top Plane +y = model −Z, a centred+    anchor;+  - sketches on a +10 mm and a −5 mm offset plane, on the top face, and on the+    hidden BOTTOM face, which must extrude OUTWARD;+  - a default cut into empty space, which must FAIL loudly; reverse and+    through-all cuts; an asymmetric two-way cut;+  - both-direction and mid-plane extents;+  - a ring and two bodies from one sketch;+  - appearance on the part and on a face;+  - an appearance followed by a material (the colour must survive);+  - a fillet of every edge (both a box and a cylinder);+  - a chamfer of one edge;+  - shells removing the top and the bottom face;+  - linear patterns with positive and negative spacing, and a circular pattern;+  - an op between a sketch and its extrude;+  - issue #2's density and mass;+  - the LEGO brick.++  Then, on one part made with `swx_new_document`, it checks issue #2's+  ownership (scripted by path), sets a dimension, exports a STEP copy, saves,+  **closes it**, and re-activates the document that was active before. `only:+  ["T25", …]` runs a subset of the checks.++Each verb row is `{name, verb, pass, ms, errorCode, error, hostError}`; each+geometry row adds `expected`, `measured` and `fails`. The `summary` gives passed, failed and skipped counts. Every sub-call goes through the normal dispatcher, readiness gate included. The self-test is itself exempt from the gate, so a check held at the gate is reported, not hidden.@@ -274,11 +288,21 @@ from the gate, so a check held at the gate is reported, not hidden. - The classic launch route (`CoCreateInstance`) and the `Setup\SolidWorks   Folder` lookup. - The readiness signals without the 3DEXPERIENCE add-in.-- Every op added in 0.10.0. These are offset/face sketches, multi-profile-  sketches, cut direction, both-direction and mid-plane extents, fillet,-  chamfer, shell, the patterns and appearance. They are built from the API-  documentation and asserted in the simulator. They are marked `proven:false`-  in step results until a live run says otherwise.--`swx_selftest {"level":"write"}` on a 2021 machine answers all of them in one-reply.+- Every op added in 0.10.0: offset and face sketches, multi-profile sketches,+  cut direction, both-direction and mid-plane extents, fillet, chamfer, shell,+  the patterns and appearance. An independent review (2026-10-13) checked every+  COM call against the 2021 API help: the signatures, argument counts, units+  and enum values are right. 0.10.2 fixed the BEHAVIOURAL risks it found:+  - picks through the view, now geometric;+  - a face's normal sign, now followed;+  - duplicate edges toggling themselves off, now de-duplicated;+  - a material wiping the colour, now re-applied;+  - the pinned document not activated, now activated;+  - a negative offset plane's and an axis's orientation, now read back.++  They are still asserted only in the simulator and marked `proven:false` in+  step results.++`swx_selftest {"level":"write"}` on a 2021 machine answers every one of them+with MEASURED geometry in one reply. A pass there is a proof, not a "the call+returned".
examples/lego-2x4.json+5−5
@@ -40,7 +40,7 @@     "volumeMm3": "2073.3615 = block 4823.424 - hollow 3388.056 + tubes 391.894 + studs 246.100 \u2014 the same 2073.3615208 mm\u00b3 SOLIDWORKS 2021 measured for Michael's 22-feature brick (issue #2's volumeM3 2.0733615208e-6)",     "status": "UNMEASURED in SOLIDWORKS \u2014 asserted only in the bridge's simulator (tests/issue1.rs); run it live and compare with swx_inspect_bodies"   },-  "conventions": "Top Plane: sketch +x = model +X, sketch +y = model -Z; an extrude goes +Y (the plane's normal), a cut goes -Y unless reverse:true. The brick is symmetric, so the -Z mapping does not change it.",+  "conventions": "Top Plane: sketch +x = model +X, sketch +y = model -Z; an extrude goes +Y (the plane's normal), a cut goes -Y unless reverse:true. The brick is symmetric, so the -Z mapping does not change it. The material comes BEFORE the appearance: applying a material wipes a part's appearance (SOLIDWORKS behaviour; since 0.10.2 the bridge re-applies a colour set earlier in the same script, but this order needs nothing re-applied).",   "script": [     {       "op": "new_part"@@ -217,6 +217,10 @@       "op": "extrude",       "depthMm": 1.7     },+    {+      "op": "set_material",+      "name": "ABS"+    },     {       "op": "set_appearance",       "rgb": [@@ -225,10 +229,6 @@         0       ]     },-    {-      "op": "set_material",-      "name": "ABS"-    },     {       "op": "set_prop",       "name": "Description",
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.10.1",+  "version": "0.10.2",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
references/modelling-and-export.md+19
@@ -76,8 +76,27 @@ adom-bridge --ai-thread "<thread>" swx_save_as '{"outputPath":"C:\\parts\\bracke     by Michael).   - An extrude goes along the plane's +normal (Top: +Y). A **cut goes along the     −normal**; pass `reverse:true` when the material is on the other side.+  - From a FACE, an extrude goes OUTWARD and a cut INWARD, on any face, the+    hidden bottom one included. The face is the one your point lies on, picked+    by geometry, never through the view. A point on an edge between two faces+    is refused.+  - `direction:"both"`: `depthMm` goes the op's own way (an extrude's +normal or+    outward, a cut's −normal or inward), and `depth2Mm` goes the other way.+  - **A material wipes a part's colour** (SOLIDWORKS behaviour). The bridge+    re-applies a colour the same script set earlier, and the `set_material` step+    lists it under `appearanceReapplied`. Put `set_material` first and nothing+    needs re-applying.+  - Fillet and chamfer edges are de-duplicated before they are selected. An+    edge selected twice would be deselected, so "fillet all of Boss-Extrude1"+    would select nothing.+  - A sketch need not be the step right before its extrude: the bridge+    re-selects it by name.   - A feature SOLIDWORKS returns null is an **error** that stops the script. It     is never a quiet success.+- **Prove it on the install:** `swx_selftest {"level":"write"}` builds 32 scratch+  parts. It measures each one against geometry computed independently and+  closes it. Each row carries `expected` and `measured`, so it is the one call+  that proves the ops on a release we have not run. - `examples/lego-2x4.json` is a LEGO 2x4 brick in **4 features**:   1. the block;   2. a hollow cut upward;
src/comhost/com.rs+739−301
@@ -2761,14 +2761,16 @@ impl ComBackend {         let sw = self.app(false, true)?;         let mut errors = 0i32;         // ActivateDoc3(Name, UseUserPreferences, Option, byref Errors).-        // swRebuildOnActivation_e: 0 = swDontRebuildActiveDoc.+        // swRebuildOnActivation_e: 1 = swDontRebuildActiveDoc. 0 is+        // swUserDecision — it PROMPTS the user (help.solidworks.com swconst;+        // 0.10.2: this said "0 = swDontRebuildActiveDoc" and passed 0).         let doc = sw             .call(                 "ActivateDoc3",                 &[                     &Var::bstr(name),                     &Var::boolean(false),-                    &Var::i4(0),+                    &Var::i4(1),                     &Var::byref_i4(&mut errors as *mut i32),                 ],             )?@@ -3335,7 +3337,10 @@ impl ComBackend {         if mp.call("AddBodies", &[&list]).ok()?.as_bool() != Some(true) {             return None;         }-        mp.float("Density", &[]).filter(|d| d.is_finite() && *d > 0.0)+        // Mass / Volume of the BODY-restricted mass property (review #11:+        // IMassProperty::Density is documented "for this model").+        let (m, v) = (mp.float("Mass", &[])?, mp.float("Volume", &[])?);+        (v > 0.0).then(|| m / v).filter(|d| d.is_finite() && *d > 0.0)     }      fn cmd_inspect_bodies(&mut self, args: &Value) -> Result<Value, HostError> {@@ -5331,8 +5336,9 @@ impl ComBackend {         let mut st = ScriptState::default();         for (i, op) in ops.iter().enumerate() {             let step = i + 1;+            let made_before = created.as_ref().and_then(|c| c.get("created").cloned());             let detail = self.run_op(op, &pinned, &mut st).map_err(|e| {-                HostError::detailed(+                let err = HostError::detailed(                     format!("step {step} ({}) failed: {}", op.name(), e.message),                     e.detail.unwrap_or_else(|| {                         format!(@@ -5343,7 +5349,11 @@ impl ComBackend {                             ops.len()                         )                     }),-                )+                );+                match &made_before {+                    Some(doc) => err.with_created(doc.clone()),+                    None => err,+                }             })?;             if let Some(f) = detail.get("feature").and_then(|f| f.as_str()) {                 if op.makes_feature() {@@ -5351,7 +5361,11 @@ impl ComBackend {                 }             }             if matches!(op, Op::NewPart | Op::NewAssembly) {+                // A new document is active and starts clean; whatever the user had+                // active before is still put back only if the script moved it.+                let keep = st.restore_active.take();                 st = ScriptState::default();+                st.restore_active = keep;                 pinned = detail                     .get("created")                     .and_then(|c| c.get("title"))@@ -5362,6 +5376,14 @@ impl ComBackend {             results.push(json!({"step": step, "op": op.name(), "result": detail}));         }         let doc = self.resolve(&Self::doc_arg(&pinned)).ok();+        // PUT THE USER'S ACTIVE DOCUMENT BACK if a selection step had to+        // activate the target (review #8), unless the script made a new one.+        if let (Some(prev), None) = (st.restore_active.take(), &created) {+            if let Ok(sw) = self.app(false, true) {+                let mut errors = 0i32;+                let _ = sw.call("ActivateDoc3", &[&Var::bstr(&prev), &Var::boolean(false), &Var::i4(1), &Var::byref_i4(&mut errors as *mut i32)]);+            }+        }         Ok(json!({             "steps": results,             "stepCount": results.len(),@@ -5395,62 +5417,157 @@ impl ComBackend {              Op::Sketch { plane, entities, .. } => {                 let doc = self.resolve(&target)?;-                // WHERE: a named plane, a reference plane the script makes at an-                // offset (cached per script), or the plane coincident with a face.-                let (on, resolved, created_plane) = self.sketch_plane(&doc, plane, st)?;-                let sm = Self::open_sketch(&doc, &on)?;+                self.ensure_active(&doc, st)?;+                let defaults = self.default_planes(&doc, st)?;+                // WHERE, and how the caller's (u, v) map onto the model:+                //   * a default or named plane: drawn in the plane's own sketch+                //     axes (the measured conventions);+                //   * an offset plane / a face: the BASE plane's own axes at that+                //     height, mapped into whatever sketch SOLIDWORKS opened through+                //     its ModelToSketchTransform — so a flipped plane or a face's+                //     own sketch axes cannot mirror the geometry (review #5, #13).+                let mut resolved = Value::Null;+                let mut created_plane: Option<String> = None;+                let mut mapping: Option<(crate::modeling::PlaneFrame, f64)> = None;+                let mut reference: Option<crate::modeling::V3> = None;+                let _ = Self::clear_selection(&doc);+                let on: String = match plane {+                    crate::script::PlaneRef::Named(n) => {+                        let real = Self::plane_name(&doc, n, &defaults)?;+                        if !Self::select_named(&doc, &real, "PLANE", false, 0)? {+                            return Err(HostError::msg(format!("could not select the plane '{real}'")));+                        }+                        real+                    }+                    crate::script::PlaneRef::Offset { from, offset } => {+                        let base = Self::plane_name(&doc, from, &defaults)?;+                        // A default plane's frame is the VALIDATED one (checked+                        // against the measured Front/Top axes); any other plane's+                        // comes from its own transform.+                        let frame = match defaults.iter().position(|d| d.name == base) {+                            Some(i) => defaults[i].frame,+                            None => Self::plane_frame(&doc, &base)+                                .ok_or_else(|| HostError::msg(format!("the plane '{base}' gave no transform to offset from")))?,+                        };+                        let (name, made, verified) = self.ensure_offset_plane(&doc, &base, &frame, *offset, st)?;+                        created_plane = made;+                        resolved = json!({"offsetFrom": from, "offsetMm": offset * 1000.0, "plane": name, "verified": verified});+                        mapping = Some((frame, *offset));+                        reference = Some(frame.n);+                        let _ = Self::clear_selection(&doc);+                        if !Self::select_named(&doc, &name, "PLANE", false, 0)? {+                            return Err(HostError::msg(format!("could not select the new plane '{name}'")));+                        }+                        name+                    }+                    crate::script::PlaneRef::Face { point } => {+                        // GEOMETRIC PICK (review #1): never through the view.+                        let picked = Self::pick_face(&doc, *point)?;+                        let frames = [defaults[0].frame, defaults[1].frame, defaults[2].frame];+                        let i = crate::modeling::parallel_default(&frames, picked.normal).ok_or_else(|| {+                            HostError::msg(format!(+                                "the face at that point is not planar and parallel to a default plane (normal {:?}); sketch on a \+                                 named or an offset plane instead",+                                picked.normal+                            ))+                        })?;+                        let frame = frames[i];+                        let h = frame.height_of(picked.closest);+                        mapping = Some((frame, h));+                        // A boss from a face goes OUTWARD, a cut INWARD: the face's+                        // own outward normal is the reference (review #5).+                        reference = Some(picked.normal);+                        resolved = json!({"offsetFrom": defaults[i].name, "offsetMm": h * 1000.0, "faceNormal": picked.normal, "pickedBy": "geometry"});+                        let n = self.multi_select(&doc, &[picked.face], false, 0)?;+                        if n != 1 {+                            return Err(HostError::msg("the picked face could not be selected"));+                        }+                        "the picked face".to_string()+                    }+                };+                let sm = doc.obj("SketchManager", &[])?.ok_or_else(|| HostError::msg("no SketchManager"))?;+                sm.call("InsertSketch", &[&Var::boolean(true)])?;                 // From here the sketch is OPEN. Every exit — the error path                 // included — must close it, or the user's SOLIDWORKS is left                 // parked in sketch-edit mode with a half-drawn profile on screen.+                let m2s = Self::sketch_transform(&doc);+                let normal = m2s.as_ref().map(|t| t.source_z());+                let mut to_draw: Vec<crate::script::Entity> = Vec::new();+                let mut failure: Option<String> = None;+                let mut transform_inverted = false;+                match (&mapping, &m2s) {+                    (None, _) => to_draw = entities.clone(),+                    (Some((frame, h)), Some(t)) => match crate::modeling::map_entities(frame, *h, entities, t) {+                        Ok((mapped, inverted)) => {+                            to_draw = mapped;+                            transform_inverted = inverted;+                        }+                        Err(z) => {+                            failure = Some(format!(+                                "the entities landed {:.6} mm off the sketch plane through the sketch's transform (and its inverse) — nothing was drawn",+                                z * 1000.0+                            ))+                        }+                    },+                    (Some(_), None) => failure = Some("the sketch's model-to-sketch transform could not be read, so the documented axes cannot be honoured — nothing was drawn".into()),+                }                 // AddToDB: entities go straight into the sketch, without the                 // snapping/inference that would merge nearby points of several                 // profiles (ISketchManager::AddToDB, 2008 FCS).                 sm.try_put("AddToDB", &Var::boolean(true));-                let mut drawn: Vec<Value> = Vec::new();-                let mut failure: Option<String> = None;-                for (k, e) in entities.iter().enumerate() {-                    let made = match e {-                        crate::script::Entity::Rect { x0, y0, x1, y1 } => sm-                            .call(-                                "CreateCornerRectangle",-                                &[&Var::r8(*x0), &Var::r8(*y0), &Var::r8(0.0), &Var::r8(*x1), &Var::r8(*y1), &Var::r8(0.0)],-                            )-                            .map(|v| !v.as_array().is_empty()),-                        // CreateCircleByRadius is the modern form; CreateCircle-                        // (centre plus a rim point) is what every older macro uses.-                        crate::script::Entity::Circle { cx, cy, r } => sm-                            .call("CreateCircleByRadius", &[&Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0), &Var::r8(*r)])-                            .or_else(|_| {-                                sm.call(-                                    "CreateCircle",-                                    &[&Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0), &Var::r8(cx + r), &Var::r8(*cy), &Var::r8(0.0)],+                let mut drawn = 0usize;+                if failure.is_none() {+                    for (k, e) in to_draw.iter().enumerate() {+                        let made = match e {+                            crate::script::Entity::Rect { x0, y0, x1, y1 } => sm+                                .call(+                                    "CreateCornerRectangle",+                                    &[&Var::r8(*x0), &Var::r8(*y0), &Var::r8(0.0), &Var::r8(*x1), &Var::r8(*y1), &Var::r8(0.0)],                                 )-                            })-                            .map(|v| !v.is_null()),-                    };-                    match made {-                        Ok(true) => drawn.push(e.describe()),-                        Ok(false) => {-                            failure = Some(format!("entity {} ({}) — SOLIDWORKS drew nothing", k + 1, e.describe()));-                            break;-                        }-                        Err(err) => {-                            failure = Some(format!("entity {} ({}) — {}", k + 1, e.describe(), err.message));-                            break;+                                .map(|v| !v.as_array().is_empty()),+                            crate::script::Entity::Circle { cx, cy, r } => sm+                                .call("CreateCircleByRadius", &[&Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0), &Var::r8(*r)])+                                .or_else(|_| {+                                    sm.call(+                                        "CreateCircle",+                                        &[&Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0), &Var::r8(cx + r), &Var::r8(*cy), &Var::r8(0.0)],+                                    )+                                })+                                .map(|v| !v.is_null()),+                        };+                        match made {+                            Ok(true) => drawn += 1,+                            Ok(false) => {+                                failure = Some(format!("entity {} ({}) — SOLIDWORKS drew nothing", k + 1, entities[k].describe()));+                                break;+                            }+                            Err(err) => {+                                failure = Some(format!("entity {} ({}) — {}", k + 1, entities[k].describe(), err.message));+                                break;+                            }                         }                     }                 }                 sm.try_put("AddToDB", &Var::boolean(false));                 Self::close_sketch(&sm)?;                 if let Some(f) = failure {-                    return Err(HostError::msg(format!("the sketch was closed with {} of {} entities: {f}", drawn.len(), entities.len())));+                    return Err(HostError::msg(format!("the sketch was closed with {drawn} of {} entities: {f}", entities.len())));                 }+                // The sketch's own name, so the extrude can RE-SELECT it (review+                // #7: an op in between clears the selection).+                let sketch_name = Self::last_feature_name(&doc);+                st.pending = Some(PendingSketch { name: sketch_name.clone().unwrap_or_default(), normal, reference });                 let mut out = json!({                     "plane": plane.describe(),                     "sketchedOn": on,+                    "sketch": sketch_name,                     "resolvedPlane": resolved,                     "createdPlane": created_plane,-                    "entities": drawn,+                    "entities": entities.iter().map(|e| e.describe()).collect::<Vec<_>>(),+                    "sketchNormal": normal,+                    "mapped": mapping.is_some(),+                    "transformInverted": transform_inverted,+                    "planeFrames": st.frames_source,                 });                 crate::script::single_entity_fields(entities, &mut out);                 Ok(out)@@ -5459,6 +5576,16 @@ impl ComBackend {             Op::Extrude(ext) | Op::Cut(ext) => {                 let cut = matches!(op, Op::Cut(_));                 let doc = self.resolve(&target)?;+                self.ensure_active(&doc, st)?;+                let pending = st.pending.take().ok_or_else(|| HostError::msg("no sketch to consume"))?;+                // RE-SELECT THE SKETCH BY NAME (review #7): a fillet, a pattern or a+                // face appearance between the sketch and this step cleared the+                // selection, and FeatureExtrusion3 / FeatureCut4 consume what is+                // selected.+                let _ = Self::clear_selection(&doc);+                if pending.name.is_empty() || !Self::select_named(&doc, &pending.name, "SKETCH", false, 0)? {+                    return Err(HostError::msg(format!("could not re-select the sketch '{}' to {}", pending.name, if cut { "cut" } else { "extrude" })));+                }                 // swEndConditions_e (help.solidworks.com/2024/english/api/swconst/                 // SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.                 // swEndConditions_e.html): Blind 0, ThroughAll 1, MidPlane 6,@@ -5473,8 +5600,13 @@ impl ComBackend {                     (Direction::One, true) => (true, THROUGH_ALL, BLIND, 0.01, 0.01),                     (Direction::MidPlane, _) => (true, MID_PLANE, BLIND, ext.depth, 0.01),                     (Direction::Both, false) => (false, BLIND, BLIND, ext.depth, ext.depth2),+                    // UNMEASURED: Through All - Both as Sd=true, T1=9.                     (Direction::Both, true) => (true, THROUGH_ALL_BOTH, BLIND, 0.01, 0.01),                 };+                // THE DIRECTION (review #5): along the reference for a boss,+                // against it for a cut, whatever way the sketch's own normal+                // points; the caller's reverse flips it.+                let (dir, flipped) = crate::modeling::dir_flag(pending.normal, pending.reference, ext.reverse);                 let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;                 let f = if !cut {                     // FeatureExtrusion3 (2014 FCS): Sd, Flip, Dir, T1, T2, D1, D2,@@ -5487,7 +5619,7 @@ impl ComBackend {                         &[                             &Var::boolean(sd),                             &Var::boolean(false),-                            &Var::boolean(ext.reverse),+                            &Var::boolean(dir),                             &Var::i4(t1),                             &Var::i4(t2),                             &Var::r8(d1),@@ -5524,7 +5656,7 @@ impl ComBackend {                         &[                             &Var::boolean(sd),                             &Var::boolean(false),-                            &Var::boolean(ext.reverse),+                            &Var::boolean(dir),                             &Var::i4(t1),                             &Var::i4(t2),                             &Var::r8(d1),@@ -5553,100 +5685,61 @@ impl ComBackend {                     )?                 };                 let feature = f.as_dispatch().map(Disp);-                Self::clear_selection(&doc);-                // A FEATURE THAT COMES BACK NULL IS AN ERROR (issue #1: a cut-                // from Top Plane with the body on the +Y side returned-                // feature:null and removed nothing, and the step said nothing).+                let _ = Self::clear_selection(&doc);+                // A FEATURE THAT COMES BACK NULL IS AN ERROR (issue #1).                 let Some(feature) = feature else {                     return Err(HostError::msg(if cut {-                        "SOLIDWORKS made no cut (the feature came back null) — nothing was removed. A cut \-                         goes along its plane's −NORMAL (measured: a cut from Top Plane removes −Y); if \-                         the material is on the other side, pass reverse:true, or direction:\"both\" / \-                         midPlane:true"+                        "SOLIDWORKS made no cut (the feature came back null) — nothing was removed. A cut goes along its \+                         plane's −NORMAL (measured: a cut from Top Plane removes −Y) and INTO a face it is sketched on; \+                         if the material is on the other side, pass reverse:true, or direction:\"both\" / midPlane:true"                     } else {-                        "SOLIDWORKS built no extrusion (the feature came back null) — the sketch may be \-                         open, unclosed, or self-intersecting"+                        "SOLIDWORKS built no extrusion (the feature came back null) — the sketch may be open, unclosed, \+                         self-intersecting, or the extrude would add nothing"                     }));                 };-                let name = feature.string("Name", &[]);                 Ok(json!({-                    "feature": name,+                    "feature": feature.string("Name", &[]),+                    "sketch": pending.name,                     "depthMm": ext.depth * 1000.0,                     "depth2Mm": (ext.direction == Direction::Both && !ext.through_all).then(|| ext.depth2 * 1000.0),                     "throughAll": ext.through_all,                     "direction": match ext.direction { Direction::One => "one", Direction::Both => "both", Direction::MidPlane => "midPlane" },                     "reverse": ext.reverse,+                    "dir": {"value": dir, "flippedToFollowTheReference": flipped, "sketchNormal": pending.normal, "reference": pending.reference},                     "endCondition": t1,-                    "proven": !cut && ext.direction == Direction::One && !ext.through_all,+                    "proven": !cut && ext.direction == Direction::One && !ext.through_all && pending.reference.is_none(),                 }))             }              Op::SetAppearance { rgb, target: at } => {                 let doc = self.resolve(&target)?;-                // MaterialPropertyValues: [R, G, B, Ambient, Diffuse, Specular,-                // Shininess, Transparency, Emission], each 0..1 (IModelDoc2 /-                // IBody2::MaterialPropertyValues2 / IFace2, 2001Plus–2003). The-                // current values are read first so only the COLOUR changes.-                let merged = |cur: Vec<f64>| -> Vec<f64> {-                    let mut v = if cur.len() >= 9 && cur[..3].iter().all(|c| *c >= 0.0) { cur } else { vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.5, 0.4, 0.0, 0.0] };-                    v[0] = rgb[0];-                    v[1] = rgb[1];-                    v[2] = rgb[2];-                    v-                };-                let read = |d: &Disp, prop: &str| -> Vec<f64> {-                    d.call(prop, &[]).map(|v| v.as_array().iter().filter_map(|e| e.as_f64()).collect()).unwrap_or_default()-                };-                let (holder, prop, what): (Disp, &str, String) = match at {-                    crate::script::AppearanceTarget::Part => (doc.clone(), "MaterialPropertyValues", "part".into()),-                    crate::script::AppearanceTarget::Body(name) => {-                        let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;-                        let body = bodies-                            .as_array()-                            .iter()-                            .filter_map(|b| b.as_dispatch().map(Disp))-                            .find(|b| b.string("Name", &[]).as_deref() == Some(name.as_str()))-                            .ok_or_else(|| HostError::msg(format!("no solid body named '{name}' (swx_inspect_bodies lists them)")))?;-                        (body, "MaterialPropertyValues2", format!("body {name}"))-                    }-                    crate::script::AppearanceTarget::FaceAt(p) => {-                        let face = Self::select_one(&doc, "FACE", *p)?-                            .ok_or_else(|| HostError::msg(format!("no face at [{:.3}, {:.3}, {:.3}] mm", p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0)))?;-                        Self::clear_selection(&doc);-                        (face, "MaterialPropertyValues", "face".into())-                    }-                };-                let next = merged(read(&holder, prop));-                holder.put(prop, &Var::r8_array(&next))?;-                let back = read(&holder, prop);-                let to255 = |v: &[f64]| -> Vec<i64> { v.iter().take(3).map(|c| (c * 255.0).round() as i64).collect() };-                let ok = back.len() >= 3 && (0..3).all(|i| (back[i] - rgb[i]).abs() < 1.0 / 255.0);-                if !ok {-                    return Err(HostError::msg(format!(-                        "the appearance did not read back: asked {:?}, SOLIDWORKS reports {:?}",-                        to255(rgb),-                        to255(&back)-                    )));-                }-                let _ = doc.call("GraphicsRedraw2", &[]);-                Ok(json!({"rgb": to255(rgb), "target": what, "readBack": to255(&back), "proven": false}))+                let r = self.apply_appearance(&doc, at, rgb, st)?;+                st.appearances.retain(|(t, _)| t != at);+                st.appearances.push((at.clone(), *rgb));+                Ok(r)             }              Op::Fillet { radius, edges } | Op::Chamfer { distance: radius, edges, .. } => {                 let fillet = matches!(op, Op::Fillet { .. });                 let doc = self.resolve(&target)?;+                self.ensure_active(&doc, st)?;                 self.requires(23, "IFeatureManager::FeatureFillet3 (SOLIDWORKS 2015)")?;-                // Edges first. Fillet edges are marked 1 (the recorded macros'-                // form); a chamfer takes plain selections (mark 0).-                let mark = if fillet { 1 } else { 0 };-                let n = self.select_edges(&doc, edges, mark, st)?;+                // The edges, picked by GEOMETRY and DE-DUPLICATED (review #1, #3):+                // an edge selected twice is DESELECTED.+                let (list, how) = self.collect_edges(&doc, edges, st)?;+                let _ = Self::clear_selection(&doc);+                // Fillet edges are marked 1 (the recorded macros' form); a chamfer+                // takes plain selections (mark 0).+                let n = self.multi_select(&doc, &list, false, if fillet { 1 } else { 0 })?;+                if n != list.len() {+                    let _ = Self::clear_selection(&doc);+                    return Err(HostError::msg(format!("only {n} of {} edges ended up selected — nothing was made", list.len())));+                }                 let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;-                let f = if fillet {+                let mut route = if fillet { "FeatureFillet3" } else { "InsertFeatureChamfer" };+                let mut f = if fillet {                     // swFeatureFilletOptions_e: Propagate 1 | UniformRadius 2;                     // swFeatureFilletType_Simple 0 (help.solidworks.com swconst).-                    // Note: as of 2020 the help calls FeatureFillet3 obsolete for-                    // constant-radius fillets in favour of CreateFeature +-                    // ISimpleFilletFeatureData2 — it is still present in 2021.                     fm.call(                         "FeatureFillet3",                         &[@@ -5680,69 +5773,101 @@ impl ComBackend {                         &[&Var::i4(4), &Var::i4(1), &Var::r8(*radius), &Var::r8(angle), &Var::r8(0.0), &Var::r8(0.0), &Var::r8(0.0), &Var::r8(0.0)],                     )?                 };+                // FeatureFillet3 is "obsolete for creating constant radius fillets"+                // as of SOLIDWORKS 2020 (review #12): if it builds nothing, the+                // documented route — CreateDefinition(swFmFillet) ++                // ISimpleFilletFeatureData2 + CreateFeature.+                let mut fallback_error: Option<String> = None;+                if fillet && f.as_dispatch().is_none() {+                    match self.fillet_by_definition(&doc, &fm, &list, *radius) {+                        Ok(v) => {+                            f = v;+                            route = "CreateFeature(ISimpleFilletFeatureData2)";+                        }+                        Err(e) => fallback_error = Some(e.message),+                    }+                }                 let feature = f.as_dispatch().map(Disp);-                Self::clear_selection(&doc);+                let _ = Self::clear_selection(&doc);                 let feature = feature.ok_or_else(|| {                     HostError::msg(format!(                         "SOLIDWORKS made no {} (the feature came back null) on {n} selected edge(s) — the size may be too \-                         large for the geometry, or a point did not land on an edge",-                        if fillet { "fillet" } else { "chamfer" }+                         large for the geometry{}",+                        if fillet { "fillet" } else { "chamfer" },+                        fallback_error.map(|e| format!("; the CreateFeature route failed too: {e}")).unwrap_or_default()                     ))                 })?;-                Ok(json!({"feature": feature.string("Name", &[]), "edgesSelected": n, "sizeMm": radius * 1000.0, "proven": false}))+                Ok(json!({"feature": feature.string("Name", &[]), "edgesSelected": n, "edges": how, "sizeMm": radius * 1000.0, "route": route, "proven": false}))             }              Op::Shell { thickness, faces, outward } => {                 let doc = self.resolve(&target)?;+                self.ensure_active(&doc, st)?;                 let before = Self::last_feature_name(&doc);-                Self::clear_selection(&doc);-                // Faces to remove, marked 1 (IModelDoc2::InsertFeatureShell's remarks).-                for (k, p) in faces.iter().enumerate() {-                    if !Self::select_at(&doc, "FACE", *p, k > 0, 1)? {-                        Self::clear_selection(&doc);-                        return Err(HostError::msg(format!(-                            "no face at [{:.3}, {:.3}, {:.3}] mm (face {} of {})",-                            p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0, k + 1, faces.len()-                        )));-                    }+                // Faces to remove, picked by GEOMETRY (review #1 — a bottom face is+                // hidden in an iso view), de-duplicated, marked 1+                // (IModelDoc2::InsertFeatureShell's remarks).+                let mut picked: Vec<Disp> = Vec::new();+                let mut keys: Vec<String> = Vec::new();+                for p in faces {+                    let f = Self::pick_face(&doc, *p)?;+                    keys.push(Self::entity_key(&doc, &f.face).unwrap_or_else(|| format!("{:?}", f.closest)));+                    picked.push(f.face);+                }+                let keep: Vec<Disp> = crate::modeling::dedupe(&keys).into_iter().map(|i| picked[i].clone()).collect();+                let _ = Self::clear_selection(&doc);+                let n = self.multi_select(&doc, &keep, false, 1)?;+                if n != keep.len() {+                    let _ = Self::clear_selection(&doc);+                    return Err(HostError::msg(format!("only {n} of {} faces ended up selected — no shell was made", keep.len())));                 }                 // InsertFeatureShell(Thickness, Outward) returns nothing (2001Plus),                 // so the new feature is found as the tree's new last feature.                 doc.call("InsertFeatureShell", &[&Var::r8(*thickness), &Var::boolean(*outward)])?;-                Self::clear_selection(&doc);+                let _ = Self::clear_selection(&doc);                 let after = Self::last_feature_name(&doc);                 if after.is_none() || after == before {                     return Err(HostError::msg("SOLIDWORKS made no shell (no new feature appeared) — the thickness may be too large for the part"));                 }-                Ok(json!({"feature": after, "thicknessMm": thickness * 1000.0, "facesRemoved": faces.len(), "outward": outward, "proven": false}))+                Ok(json!({"feature": after, "thicknessMm": thickness * 1000.0, "facesRemoved": keep.len(), "outward": outward, "proven": false}))             }              Op::LinearPattern { feature, dirs } => {                 let doc = self.resolve(&target)?;+                self.ensure_active(&doc, st)?;                 self.requires(23, "IFeatureManager::FeatureLinearPattern4 (SOLIDWORKS 2015)")?;                 let seed = st.resolve_feature(feature)?;-                let axes: Vec<String> = dirs.iter().map(|(a, _, _)| self.ensure_axis(&doc, *a, st)).collect::<Result<_, _>>()?;-                Self::clear_selection(&doc);+                let mut axes: Vec<(String, f64)> = Vec::new();+                for (a, _, _) in dirs {+                    axes.push(self.ensure_axis(&doc, *a, st)?);+                }+                let _ = Self::clear_selection(&doc);                 // Marks: 1 = Direction 1, 2 = Direction 2, 4 = the features to-                // pattern (FeatureLinearPattern4's remarks).-                for (k, ax) in axes.iter().enumerate() {+                // pattern (FeatureLinearPattern4's remarks). Selected by NAME —+                // view-independent.+                for (k, (ax, _)) in axes.iter().enumerate() {                     if !Self::select_named(&doc, ax, "AXIS", k > 0, (k + 1) as i32)? {-                        Self::clear_selection(&doc);+                        let _ = Self::clear_selection(&doc);                         return Err(HostError::msg(format!("could not select the axis '{ax}' for direction {}", k + 1)));                     }                 }                 if !Self::select_named(&doc, &seed, "BODYFEATURE", true, 4)? {-                    Self::clear_selection(&doc);+                    let _ = Self::clear_selection(&doc);                     return Err(HostError::msg(format!("no feature named '{seed}' to pattern")));                 }                 let (n1, s1) = (dirs[0].1, dirs[0].2);                 let (n2, s2) = dirs.get(1).map(|d| (d.1, d.2)).unwrap_or((1, 0.0));+                // A negative spacing is the flipped direction — and so is an axis+                // SOLIDWORKS made pointing the other way (review #13: its+                // direction is READ, not assumed).+                let flip1 = (s1 < 0.0) ^ (axes[0].1 < 0.0);+                let flip2 = (s2 < 0.0) ^ axes.get(1).map(|a| a.1 < 0.0).unwrap_or(false);                 let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;                 // FeatureLinearPattern4 (2015 FCS): Num1, Spacing1, Num2, Spacing2,                 // FlipDir1, FlipDir2, DName1, DName2, GeometryPattern,                 // VaryInstance, HasOffset1, HasOffset2, CtrlByNum1, CtrlByNum2,                 // FromCentroid1, FromCentroid2, RevOffset1, RevOffset2, Offset1,-                // Offset2. A negative spacing is the flipped direction.+                // Offset2.                 let f = fm.call(                     "FeatureLinearPattern4",                     &[@@ -5750,8 +5875,8 @@ impl ComBackend {                         &Var::r8(s1.abs()),                         &Var::i4(n2 as i32),                         &Var::r8(s2.abs().max(0.001)),-                        &Var::boolean(s1 < 0.0),-                        &Var::boolean(s2 < 0.0),+                        &Var::boolean(flip1),+                        &Var::boolean(flip2),                         &Var::bstr(""),                         &Var::bstr(""),                         &Var::boolean(false),@@ -5769,35 +5894,38 @@ impl ComBackend {                     ],                 )?;                 let made = f.as_dispatch().map(Disp);-                Self::clear_selection(&doc);+                let _ = Self::clear_selection(&doc);                 let made = made.ok_or_else(|| HostError::msg(format!("SOLIDWORKS made no linear pattern of '{seed}' (the feature came back null)")))?;-                Ok(json!({"feature": made.string("Name", &[]), "seed": seed, "axes": axes, "instances": n1 * n2, "proven": false}))+                Ok(json!({"feature": made.string("Name", &[]), "seed": seed, "axes": axes.iter().map(|(n, s)| json!({"axis": n, "pointsPositive": *s > 0.0})).collect::<Vec<_>>(),+                          "instances": n1 * n2, "flip": [flip1, flip2], "proven": false}))             }              Op::CircularPattern { feature, axis, count, angle } => {                 let doc = self.resolve(&target)?;+                self.ensure_active(&doc, st)?;                 self.requires(21, "IFeatureManager::FeatureCircularPattern4 (SOLIDWORKS 2013)")?;                 let seed = st.resolve_feature(feature)?;-                let ax = self.ensure_axis(&doc, *axis, st)?;-                Self::clear_selection(&doc);+                let (ax, sign) = self.ensure_axis(&doc, *axis, st)?;+                let _ = Self::clear_selection(&doc);                 // Marks: 1 = the axis, 4 = the features (FeatureCircularPattern4).                 if !Self::select_named(&doc, &ax, "AXIS", false, 1)? {                     return Err(HostError::msg(format!("could not select the axis '{ax}'")));                 }                 if !Self::select_named(&doc, &seed, "BODYFEATURE", true, 4)? {-                    Self::clear_selection(&doc);+                    let _ = Self::clear_selection(&doc);                     return Err(HostError::msg(format!("no feature named '{seed}' to pattern")));                 }                 let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;                 // FeatureCircularPattern4 (2013 FCS): Number, Spacing (the TOTAL                 // angle when EqualSpacing), FlipDirection, DName,-                // GeometryPattern, EqualSpacing, VaryInstance.+                // GeometryPattern, EqualSpacing, VaryInstance. The axis's own+                // direction decides which way "positive" turns.                 let f = fm.call(                     "FeatureCircularPattern4",-                    &[&Var::i4(*count as i32), &Var::r8(*angle), &Var::boolean(false), &Var::bstr(""), &Var::boolean(false), &Var::boolean(true), &Var::boolean(false)],+                    &[&Var::i4(*count as i32), &Var::r8(*angle), &Var::boolean(sign < 0.0), &Var::bstr(""), &Var::boolean(false), &Var::boolean(true), &Var::boolean(false)],                 )?;                 let made = f.as_dispatch().map(Disp);-                Self::clear_selection(&doc);+                let _ = Self::clear_selection(&doc);                 let made = made.ok_or_else(|| HostError::msg(format!("SOLIDWORKS made no circular pattern of '{seed}' (the feature came back null)")))?;                 Ok(json!({"feature": made.string("Name", &[]), "seed": seed, "axis": ax, "instances": count, "angleDeg": angle.to_degrees(), "proven": false}))             }@@ -5808,7 +5936,15 @@ impl ComBackend {                     "SetMaterialPropertyName2",                     &[&Var::bstr(""), &Var::bstr(database), &Var::bstr(name)],                 )?;-                Ok(json!({"material": name, "database": database, "current": Self::material_of(&doc, "")}))+                // A MATERIAL WIPES THE PART'S APPEARANCE ("when you apply a+                // material… your customized part appearance is gone" — review #4).+                // Every appearance this script set is applied again, and said so.+                let mut again: Vec<Value> = Vec::new();+                for (t, rgb) in st.appearances.clone() {+                    let r = self.apply_appearance(&doc, &t, &rgb, st)?;+                    again.push(json!({"target": r.get("target"), "rgb": r.get("rgb"), "readBack": r.get("readBack")}));+                }+                Ok(json!({"material": name, "database": database, "current": Self::material_of(&doc, ""), "appearanceReapplied": again}))             }              Op::SetProp { name, value } => {@@ -5880,32 +6016,6 @@ impl ComBackend {         }     } -    /// Select a plane by name and open a sketch on it. The five-argument legacy-    /// `SelectByID` is used deliberately: it is proven live here and needs no-    /// null callout argument, so there is one less thing to marshal wrong.-    fn open_sketch(doc: &Disp, plane: &str) -> Result<Disp, HostError> {-        // Start from a known-empty selection: SelectByID replaces the selection,-        // but anything the USER had selected before the script ran would-        // otherwise survive into the feature that consumes it, and whatever the-        // feature leaves selected would linger afterwards on their screen.-        let _ = doc.call("ClearSelection2", &[&Var::boolean(true)]);-        let selected =-            doc.call("SelectByID", &[&Var::bstr(plane), &Var::bstr("PLANE"), &Var::r8(0.0), &Var::r8(0.0), &Var::r8(0.0)])?-                .as_bool()-                .unwrap_or(false);-        if !selected {-            return Err(HostError::msg(format!(-                "no plane named '{plane}' in this document (the stock templates give you \-                 'Front Plane', 'Top Plane' and 'Right Plane')"-            )));-        }-        let sm = doc.obj("SketchManager", &[])?.ok_or_else(|| HostError::msg("no SketchManager"))?;-        sm.call("InsertSketch", &[&Var::boolean(true)])?;-        // Returned so the caller closes the SAME SketchManager it opened, and-        // can do so on the error path too.-        Ok(sm)-    }-     /// Close the open sketch. `InsertSketch(true)` toggles, and SOLIDWORKS     /// leaves the finished sketch SELECTED — which is exactly what     /// `FeatureExtrusion3` consumes, so no re-selection is needed (proven live).@@ -5945,21 +6055,9 @@ impl ComBackend {         }     } -    /// IModelDocExtension::SelectByID2 (2005 FCS) of an entity of `kind`-    /// ("FACE", "EDGE") through a model point (metres).-    fn select_at(doc: &Disp, kind: &str, p: [f64; 3], append: bool, mark: i32) -> Result<bool, HostError> {-        let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;-        Ok(ext-            .call(-                "SelectByID2",-                &[&Var::bstr(""), &Var::bstr(kind), &Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2]), &Var::boolean(append), &Var::i4(mark), &Var::null_dispatch(), &Var::i4(0)],-            )?-            .as_bool()-            .unwrap_or(false))-    }-     /// SelectByID2 by NAME ("Plane1" as "PLANE", "Axis1" as "AXIS",-    /// "Boss-Extrude1" as "BODYFEATURE").+    /// "Boss-Extrude1" as "BODYFEATURE", "Sketch3" as "SKETCH") — a name is+    /// view-independent, unlike a point.     fn select_named(doc: &Disp, name: &str, kind: &str, append: bool, mark: i32) -> Result<bool, HostError> {         let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;         Ok(ext@@ -5971,17 +6069,6 @@ impl ComBackend {             .unwrap_or(false))     } -    /// Select ONE entity at a point and hand it back (ISelectionMgr::-    /// GetSelectedObject6, 2006 FCS). The selection is left in place.-    fn select_one(doc: &Disp, kind: &str, p: [f64; 3]) -> Result<Option<Disp>, HostError> {-        let _ = doc.call("ClearSelection2", &[&Var::boolean(true)]);-        if !Self::select_at(doc, kind, p, false, 0)? {-            return Ok(None);-        }-        let sel = doc.obj("SelectionManager", &[])?.ok_or_else(|| HostError::msg("no SelectionManager"))?;-        Ok(sel.call("GetSelectedObject6", &[&Var::i4(1), &Var::i4(-1)])?.as_dispatch().map(Disp))-    }-     /// The tree's last feature's name (FirstFeature / GetNextFeature).     fn last_feature_name(doc: &Disp) -> Option<String> {         let mut f = doc.obj("FirstFeature", &[]).ok().flatten()?;@@ -6007,144 +6094,495 @@ impl ComBackend {         None     } -    /// WHERE A SKETCH GOES: (the plane's name, how a face/offset resolved, the-    /// plane the script made).-    fn sketch_plane(&mut self, doc: &Disp, plane: &crate::script::PlaneRef, st: &mut ScriptState) -> Result<(String, Value, Option<String>), HostError> {-        use crate::script::PlaneRef;-        match plane {-            PlaneRef::Named(n) => Ok((n.clone(), Value::Null, None)),-            PlaneRef::Offset { from, offset } => {-                let (name, made) = self.ensure_offset_plane(doc, from, *offset, st)?;-                Ok((name, json!({"offsetFrom": from, "offsetMm": offset * 1000.0}), made))-            }-            PlaneRef::Face { point } => {-                // The face's NORMAL picks the default plane it is parallel to;-                // the point's coordinate along it is the offset. Its sketch then-                // has that plane's axes — the documented conventions — rather-                // than whatever axes SOLIDWORKS would give a sketch on the face.-                let face = Self::select_one(doc, "FACE", *point)?.ok_or_else(|| {-                    HostError::msg(format!(-                        "no face at [{:.3}, {:.3}, {:.3}] mm — the point must lie ON a planar face of the part",-                        point[0] * 1000.0, point[1] * 1000.0, point[2] * 1000.0-                    ))-                })?;-                let n: Vec<f64> = face.call("Normal", &[]).map(|v| v.as_array().iter().filter_map(|e| e.as_f64()).collect()).unwrap_or_default();-                Self::clear_selection(doc);-                let pick = if n.len() == 3 {-                    [(1usize, "Top Plane"), (2, "Front Plane"), (0, "Right Plane")].into_iter().find(|(i, _)| n[*i].abs() > 0.999)+    fn floats(v: &Var) -> Vec<f64> {+        v.as_array().iter().filter_map(|e| e.as_f64()).collect()+    }++    /// A plane feature's frame: IFeature::GetSpecificFeature2 → IRefPlane →+    /// Transform (2004 SP4) → ArrayData.+    fn plane_frame_of(feature: &Disp) -> Option<crate::modeling::PlaneFrame> {+        let rp = feature.obj("GetSpecificFeature2", &[]).ok().flatten()?;+        let t = rp.obj("Transform", &[]).ok().flatten()?;+        let x = crate::modeling::Xform::from_array_data(&Self::floats(&t.call("ArrayData", &[]).ok()?))?;+        Some(crate::modeling::PlaneFrame::from_plane_xform(&x))+    }++    fn plane_frame(doc: &Disp, name: &str) -> Option<crate::modeling::PlaneFrame> {+        Self::plane_frame_of(&Self::feature_named(doc, name)?)+    }++    /// THE THREE DEFAULT PLANES, by POSITION — the first three RefPlane features+    /// of the tree (Front, Top, Right) — so a localized install's own names work+    /// (review #10). Each with its frame read from SOLIDWORKS (the documented+    /// conventions only when a transform cannot be read).+    fn default_planes(&mut self, doc: &Disp, st: &mut ScriptState) -> Result<[DefaultPlane; 3], HostError> {+        if let Some(d) = &st.defaults {+            return Ok(d.clone());+        }+        let fallback = crate::modeling::PlaneFrame::defaults();+        let mut found: Vec<DefaultPlane> = Vec::new();+        let mut f = doc.obj("FirstFeature", &[])?;+        let mut guard = 0;+        while let Some(feat) = f {+            guard += 1;+            if guard > 10_000 || found.len() == 3 {+                break;+            }+            if feat.string("GetTypeName2", &[]).as_deref() == Some("RefPlane") {+                let i = found.len();+                let name = feat.string("Name", &[]).unwrap_or_default();+                let frame = Self::plane_frame_of(&feat).unwrap_or(fallback[i]);+                found.push(DefaultPlane { name, frame });+            }+            f = feat.obj("GetNextFeature", &[]).ok().flatten();+        }+        if found.len() < 3 {+            return Err(HostError::msg(format!("the part has {} reference planes where Front, Top and Right were expected", found.len())));+        }+        let mut d = [found[0].clone(), found[1].clone(), found[2].clone()];+        // THE TRANSFORMS ARE CHECKED AGAINST WHAT WAS MEASURED: Front's and Top's+        // sketch axes (Top: +y = model −Z, issue #1). If the plane transforms+        // agree, they are trusted for all three (Right included); if not, the+        // documented conventions are used for all three, and the step says so.+        if d[0].frame.same_as(&fallback[0]) && d[1].frame.same_as(&fallback[1]) {+            st.frames_source = "plane transforms (they match the measured Front/Top axes)";+        } else {+            for (i, p) in d.iter_mut().enumerate() {+                p.frame = fallback[i];+            }+            st.frames_source = "documented conventions (the plane transforms did not match the measured Front/Top axes)";+        }+        st.defaults = Some(d.clone());+        Ok(d)+    }++    /// The plane the caller means: a plane of that name, or — for "Front+    /// Plane" / "Top Plane" / "Right Plane" on an install that names them+    /// differently — the default plane at that position.+    fn plane_name(doc: &Disp, name: &str, defaults: &[DefaultPlane; 3]) -> Result<String, HostError> {+        if Self::feature_named(doc, name).is_some() {+            return Ok(name.to_string());+        }+        match crate::modeling::default_index(name) {+            Some(i) => Ok(defaults[i].name.clone()),+            None => Err(HostError::msg(format!(+                "no plane named '{name}' in this document (the default planes here are '{}', '{}' and '{}')",+                defaults[0].name, defaults[1].name, defaults[2].name+            ))),+        }+    }++    /// The open sketch's model-to-sketch transform (IModelDoc2::GetActiveSketch2,+    /// ISketch::ModelToSketchTransform, IMathTransform::ArrayData).+    fn sketch_transform(doc: &Disp) -> Option<crate::modeling::Xform> {+        let sk = doc.obj("GetActiveSketch2", &[]).ok().flatten()?;+        let t = sk.obj("ModelToSketchTransform", &[]).ok().flatten()?;+        crate::modeling::Xform::from_array_data(&Self::floats(&t.call("ArrayData", &[]).ok()?))+    }++    /// Activate `doc` if it is not SOLIDWORKS' active document (review #8:+    /// selection works only there). The user's active document is remembered+    /// once and put back when the script ends. swRebuildOnActivation_e 1 =+    /// swDontRebuildActiveDoc (0 would PROMPT the user).+    fn ensure_active(&mut self, doc: &Disp, st: &mut ScriptState) -> Result<(), HostError> {+        let sw = self.app(false, true)?;+        let id = |d: &Disp| {+            let path = d.string("GetPathName", &[]).unwrap_or_default();+            if path.is_empty() { d.string("GetTitle", &[]).unwrap_or_default() } else { path }+        };+        let want = id(doc);+        let active = sw.obj("ActiveDoc", &[]).ok().flatten();+        let now = active.as_ref().map(id);+        if now.as_deref().map(|n| crate::verbs::same_path(n, &want) || n == want).unwrap_or(false) {+            return Ok(());+        }+        if st.restore_active.is_none() {+            st.restore_active = now.clone();+        }+        let mut errors = 0i32;+        let r = sw.call("ActivateDoc3", &[&Var::bstr(&want), &Var::boolean(false), &Var::i4(1), &Var::byref_i4(&mut errors as *mut i32)])?;+        if r.as_dispatch().is_none() {+            return Err(HostError::msg(format!("could not activate '{want}' (errors {errors}) — selection works only in the active document")));+        }+        Ok(())+    }++    /// A persistent id for an entity (IModelDocExtension::GetPersistReference3,+    /// 2007 SP3) — the key two references to ONE face or edge share.+    fn entity_key(doc: &Disp, ent: &Disp) -> Option<String> {+        let ext = doc.obj("Extension", &[]).ok().flatten()?;+        let bytes: Vec<i32> = ext.call("GetPersistReference3", &[&Var::dispatch(&ent.0)]).ok()?.as_array().iter().filter_map(|b| b.as_i32()).collect();+        (!bytes.is_empty()).then(|| bytes.iter().map(|b| format!("{:02x}", b & 0xff)).collect())+    }++    /// SELECT ENTITIES WITHOUT THE VIEW: IModelDocExtension::MultiSelect2+    /// (2012 SP04) with an ISelectData carrying the mark. Returns how many are+    /// selected.+    fn multi_select(&mut self, doc: &Disp, ents: &[Disp], append: bool, mark: i32) -> Result<usize, HostError> {+        if ents.is_empty() {+            return Ok(0);+        }+        let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;+        let sel = doc.obj("SelectionManager", &[])?.ok_or_else(|| HostError::msg("no SelectionManager"))?;+        let data = sel.obj("CreateSelectData", &[])?.ok_or_else(|| HostError::msg("CreateSelectData returned nothing"))?;+        data.try_put("Mark", &Var::i4(mark));+        let arr = Var::dispatch_array(&ents.iter().map(|d| d.0.clone()).collect::<Vec<_>>());+        let n = ext.call("MultiSelect2", &[&arr, &Var::boolean(append), &Var::dispatch(&data.0)])?.as_i32().unwrap_or(0).max(0) as usize;+        // ...and the selection list says so (ISelectionMgr::GetSelectedObjectCount2).+        let listed = sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).map(|c| c.max(0) as usize);+        Ok(match listed {+            Some(c) if !append => n.min(c),+            _ => n,+        })+    }++    /// THE GEOMETRIC FACE PICK (review #1): every face of every visible solid+    /// body, IFace2::GetClosestPointOn (2001Plus) the caller's point; the one+    /// within the tolerance is the pick (`modeling::pick`). Never the view.+    fn pick_face(doc: &Disp, p: crate::modeling::V3) -> Result<PickedFace, HostError> {+        use crate::modeling::{dot, norm, scale, sub};+        let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;+        let mut faces: Vec<PickedFace> = Vec::new();+        let mut cands: Vec<(usize, f64, f64)> = Vec::new();+        for b in bodies.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)) {+            for f in b.call("GetFaces", &[]).map(|v| v.as_array()).unwrap_or_default().iter().filter_map(|f| f.as_dispatch().map(Disp)) {+                let c = f.call("GetClosestPointOn", &[&Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2])]).map(|v| Self::floats(&v)).unwrap_or_default();+                if c.len() < 3 {+                    continue;+                }+                let q = [c[0], c[1], c[2]];+                let n = f.call("Normal", &[]).map(|v| Self::floats(&v)).unwrap_or_default();+                let normal = if n.len() >= 3 && norm([n[0], n[1], n[2]]) > 1e-9 { scale([n[0], n[1], n[2]], 1.0 / norm([n[0], n[1], n[2]])) } else { [0.0; 3] };+                let d = sub(p, q);+                cands.push((faces.len(), norm(d), dot(d, normal)));+                faces.push(PickedFace { face: f, closest: q, normal });+            }+        }+        match crate::modeling::pick(&cands, PICK_TOL) {+            Ok(i) => Ok(faces.swap_remove(i)),+            Err(crate::modeling::PickError::None(near)) => Err(HostError::msg(format!(+                "no face of the part passes through [{:.3}, {:.3}, {:.3}] mm (nearest {} mm away)",+                p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0,+                near.map(|d| format!("{:.3}", d * 1000.0)).unwrap_or_else(|| "—".into())+            ))),+            Err(crate::modeling::PickError::Ambiguous(n)) => Err(HostError::msg(format!(+                "the point [{:.3}, {:.3}, {:.3}] mm lies on {n} faces (an edge or a corner) — move it inside ONE face",+                p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0+            ))),+        }+    }++    /// THE GEOMETRIC EDGE PICK: IEdge::GetClosestPointOn over every edge.+    fn pick_edge(doc: &Disp, p: crate::modeling::V3) -> Result<Disp, HostError> {+        use crate::modeling::{norm, sub};+        let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;+        let mut edges: Vec<Disp> = Vec::new();+        let mut cands: Vec<(usize, f64, f64)> = Vec::new();+        for b in bodies.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)) {+            for e in b.call("GetEdges", &[]).map(|v| v.as_array()).unwrap_or_default().iter().filter_map(|e| e.as_dispatch().map(Disp)) {+                let c = e.call("GetClosestPointOn", &[&Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2])]).map(|v| Self::floats(&v)).unwrap_or_default();+                if c.len() < 3 {+                    continue;+                }+                cands.push((edges.len(), norm(sub(p, [c[0], c[1], c[2]])), 0.0));+                edges.push(e);+            }+        }+        match crate::modeling::pick(&cands, PICK_TOL) {+            Ok(i) => Ok(edges.swap_remove(i)),+            Err(crate::modeling::PickError::None(_)) => Err(HostError::msg(format!("no edge at [{:.3}, {:.3}, {:.3}] mm", p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0))),+            Err(crate::modeling::PickError::Ambiguous(n)) => Err(HostError::msg(format!(+                "the point [{:.3}, {:.3}, {:.3}] mm lies on {n} edges (a corner) — move it along ONE edge",+                p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0+            ))),+        }+    }++    /// The edges a fillet / chamfer takes, picked and DE-DUPLICATED by+    /// persistent id (review #3: a box's 6 faces list its 12 edges 24 times,+    /// and an already-selected edge selected again is deselected).+    fn collect_edges(&mut self, doc: &Disp, sel: &crate::script::EdgeSel, st: &ScriptState) -> Result<(Vec<Disp>, Value), HostError> {+        let (listed, how): (Vec<Disp>, Value) = match sel {+            crate::script::EdgeSel::AtPoints(ps) => {+                let mut v = Vec::new();+                for p in ps {+                    v.push(Self::pick_edge(doc, *p)?);+                }+                (v, json!({"atPoints": ps.len()}))+            }+            crate::script::EdgeSel::AllOfFeature(f) => {+                let name = st.resolve_feature(f)?;+                let feat = Self::feature_named(doc, &name).ok_or_else(|| HostError::msg(format!("no feature named '{name}'")))?;+                let mut v = Vec::new();+                // IFeature::GetFaces -> IFace2::GetEdges.+                for face in feat.call("GetFaces", &[])?.as_array().iter().filter_map(|f| f.as_dispatch().map(Disp)) {+                    for e in face.call("GetEdges", &[]).map(|x| x.as_array()).unwrap_or_default().iter().filter_map(|e| e.as_dispatch().map(Disp)) {+                        v.push(e);+                    }+                }+                (v, json!({"allOfFeature": name}))+            }+        };+        // The persistent id; failing that, the edge's own geometry (its curve's+        // start and end points, IEdge::GetCurveParams2) — never "no key", which+        // would let a duplicate through to toggle itself off.+        let keys: Vec<String> = listed+            .iter()+            .enumerate()+            .map(|(i, e)| {+                Self::entity_key(doc, e).unwrap_or_else(|| {+                    let p = e.call("GetCurveParams2", &[]).map(|v| Self::floats(&v)).unwrap_or_default();+                    if p.len() >= 6 {+                        p.iter().take(8).map(|x| format!("{:.9}", x)).collect::<Vec<_>>().join(",")+                    } else {+                        format!("unkeyed-{i}")+                    }+                })+            })+            .collect();+        let keep: Vec<Disp> = crate::modeling::dedupe(&keys).into_iter().map(|i| listed[i].clone()).collect();+        if keep.is_empty() {+            return Err(HostError::msg("no edges to select"));+        }+        let mut how = how;+        how["listed"] = json!(listed.len());+        how["unique"] = json!(keep.len());+        Ok((keep, how))+    }++    /// Write a colour (and read it back). MaterialPropertyValues: [R, G, B,+    /// Ambient, Diffuse, Specular, Shininess, Transparency, Emission], 0..1.+    /// The current values are read first so only the COLOUR changes.+    fn apply_appearance(&mut self, doc: &Disp, at: &crate::script::AppearanceTarget, rgb: &[f64; 3], st: &mut ScriptState) -> Result<Value, HostError> {+        use crate::script::AppearanceTarget;+        let merged = |cur: Vec<f64>| -> Vec<f64> {+            let mut v = if cur.len() >= 9 && cur[..3].iter().all(|c| *c >= 0.0) { cur } else { vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.5, 0.4, 0.0, 0.0] };+            v[0] = rgb[0];+            v[1] = rgb[1];+            v[2] = rgb[2];+            v+        };+        let read = |d: &Disp, prop: &str| -> Vec<f64> { d.call(prop, &[]).map(|v| Self::floats(&v)).unwrap_or_default() };+        let mut face_selected = false;+        let (holder, prop, route): (Disp, &str, String) = match at {+            AppearanceTarget::Part => (doc.clone(), "MaterialPropertyValues", "part".into()),+            AppearanceTarget::Body(name) => {+                let bodies: Vec<Disp> = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)).collect();+                if bodies.len() <= 1 {+                    // IBody2::MaterialPropertyValues2 is documented for NON-base+                    // bodies (review #9): a single-body part is coloured at the+                    // part level.+                    (doc.clone(), "MaterialPropertyValues", "part (single-body part)".into())                 } else {-                    None-                };-                let Some((axis, base)) = pick else {-                    return Err(HostError::msg(format!(-                        "the face at that point is not parallel to Top, Front or Right Plane (normal {n:?}); sketch on a                          named plane or an offset plane instead"-                    )));-                };-                let off = point[axis];-                if off.abs() < 1e-9 {-                    return Ok((base.to_string(), json!({"offsetFrom": base, "offsetMm": 0.0, "faceNormal": n}), None));+                    let body = bodies+                        .into_iter()+                        .find(|b| b.string("Name", &[]).as_deref() == Some(name.as_str()))+                        .ok_or_else(|| HostError::msg(format!("no solid body named '{name}' (swx_inspect_bodies lists them)")))?;+                    (body, "MaterialPropertyValues2", format!("body {name}"))                 }-                let (name, made) = self.ensure_offset_plane(doc, base, off, st)?;-                Ok((name, json!({"offsetFrom": base, "offsetMm": off * 1000.0, "faceNormal": n}), made))             }+            AppearanceTarget::FaceAt(p) => {+                self.ensure_active(doc, st)?;+                // GEOMETRIC pick, and the face SELECTED while it is written+                // (IFace2::MaterialPropertyValues is "for the selected face" —+                // review #13).+                let picked = Self::pick_face(doc, *p)?;+                let _ = Self::clear_selection(doc);+                self.multi_select(doc, &[picked.face.clone()], false, 0)?;+                face_selected = true;+                (picked.face, "MaterialPropertyValues", "face".into())+            }+        };+        let next = merged(read(&holder, prop));+        let put = holder.put(prop, &Var::r8_array(&next));+        let back = read(&holder, prop);+        if face_selected {+            let _ = Self::clear_selection(doc);+        }+        put?;+        let to255 = |v: &[f64]| -> Vec<i64> { v.iter().take(3).map(|c| (c * 255.0).round() as i64).collect() };+        let ok = back.len() >= 3 && (0..3).all(|i| (back[i] - rgb[i]).abs() < 1.0 / 255.0);+        if !ok {+            return Err(HostError::msg(format!(+                "the appearance did not read back: asked {:?}, SOLIDWORKS reports {:?}",+                to255(rgb),+                to255(&back)+            )));         }+        let _ = doc.call("GraphicsRedraw2", &[]);+        Ok(json!({"rgb": to255(rgb), "target": route, "route": route, "readBack": to255(&back), "proven": false}))     } -    /// A reference plane `offset` metres from `from` (IFeatureManager::-    /// InsertRefPlane, 2010 FCS: swRefPlaneReferenceConstraint_Distance 8, with-    /// _OptionFlip 256 for the negative side — swconst help). One per (base,-    /// offset) per script.-    fn ensure_offset_plane(&mut self, doc: &Disp, from: &str, offset: f64, st: &mut ScriptState) -> Result<(String, Option<String>), HostError> {-        let key = format!("{from}|{:.9}", offset);+    /// WHERE AN OFFSET PLANE GOES, VERIFIED (review #2, #13). The plane is found+    /// as the tree's new last feature (IRefPlane has no Name). Its position is+    /// READ BACK through its transform; a negative offset tries+    /// swRefPlaneReferenceConstraint_OptionFlip (256) and then _OptionReferenceFlip+    /// (8192), deleting a plane that landed on the wrong side.+    fn ensure_offset_plane(+        &mut self,+        doc: &Disp,+        base: &str,+        frame: &crate::modeling::PlaneFrame,+        offset: f64,+        st: &mut ScriptState,+    ) -> Result<(String, Option<String>, bool), HostError> {+        let key = format!("{base}|{:.9}", offset);         if let Some(n) = st.planes.get(&key) {-            return Ok((n.clone(), None));-        }-        Self::clear_selection(doc);-        if !Self::select_named(doc, from, "PLANE", false, 0)? {-            return Err(HostError::msg(format!("no plane named '{from}' to offset from")));+            return Ok((n.clone(), None, true));         }         let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;-        let constraint = 8 | if offset < 0.0 { 256 } else { 0 };-        let f = fm.call("InsertRefPlane", &[&Var::i4(constraint), &Var::r8(offset.abs()), &Var::i4(0), &Var::r8(0.0), &Var::i4(0), &Var::r8(0.0)])?;-        Self::clear_selection(doc);-        let plane = f.as_dispatch().map(Disp).ok_or_else(|| HostError::msg(format!("SOLIDWORKS made no plane {} mm from '{from}'", offset * 1000.0)))?;-        let name = plane.string("Name", &[]).ok_or_else(|| HostError::msg("the new plane has no name"))?;-        st.planes.insert(key, name.clone());-        Ok((name.clone(), Some(name)))+        let tries: &[i32] = if offset >= 0.0 { &[8] } else { &[8 | 256, 8 | 8192] };+        let mut seen: Vec<String> = Vec::new();+        for flags in tries {+            let _ = Self::clear_selection(doc);+            if !Self::select_named(doc, base, "PLANE", false, 0)? {+                return Err(HostError::msg(format!("no plane named '{base}' to offset from")));+            }+            let before = Self::last_feature_name(doc);+            let made = fm.call("InsertRefPlane", &[&Var::i4(*flags), &Var::r8(offset.abs()), &Var::i4(0), &Var::r8(0.0), &Var::i4(0), &Var::r8(0.0)])?;+            let _ = Self::clear_selection(doc);+            let after = Self::last_feature_name(doc);+            let name = match (&after, made.as_dispatch()) {+                (Some(a), _) if after != before => a.clone(),+                // Name only as a fallback: IRefPlane does not have one.+                (_, Some(d)) => match Disp(d).string("Name", &[]) {+                    Some(n) => n,+                    None => continue,+                },+                _ => continue,+            };+            match Self::plane_frame(doc, &name) {+                Some(pf) => {+                    let at = frame.height_of(pf.origin);+                    if (at - offset).abs() < 1e-7 {+                        st.planes.insert(key, name.clone());+                        return Ok((name.clone(), Some(name), true));+                    }+                    seen.push(format!("flags {flags}: at {:.4} mm", at * 1000.0));+                    // On the wrong side: remove it and try the other flip.+                    let _ = Self::clear_selection(doc);+                    if Self::select_named(doc, &name, "PLANE", false, 0)? {+                        let _ = doc.call("EditDelete", &[]);+                    }+                    let _ = Self::clear_selection(doc);+                }+                None => {+                    // Cannot be verified: used, and said so.+                    st.planes.insert(key, name.clone());+                    return Ok((name.clone(), Some(name), false));+                }+            }+        }+        Err(HostError::msg(format!(+            "SOLIDWORKS would not place a plane {:.4} mm from '{base}' ({}) — nothing was sketched",+            offset * 1000.0,+            seen.join("; ")+        )))     }      /// A reference axis along a model axis: the intersection of two default-    /// planes (IModelDoc2::InsertAxis2, 2001Plus). One per axis per script.-    fn ensure_axis(&mut self, doc: &Disp, axis: crate::script::Axis, st: &mut ScriptState) -> Result<String, HostError> {+    /// planes (IModelDoc2::InsertAxis2, 2001Plus), found as the new last+    /// feature; its DIRECTION is read back (IRefAxis::GetRefAxisParams — review+    /// #13), so a pattern's "positive" is the model's. One per axis per script.+    fn ensure_axis(&mut self, doc: &Disp, axis: crate::script::Axis, st: &mut ScriptState) -> Result<(String, f64), HostError> {         if let Some(n) = st.axes.get(&axis) {             return Ok(n.clone());         }-        let (a, b) = axis.planes();-        Self::clear_selection(doc);-        if !(Self::select_named(doc, a, "PLANE", false, 0)? && Self::select_named(doc, b, "PLANE", true, 0)?) {-            Self::clear_selection(doc);+        let defaults = self.default_planes(doc, st)?;+        let (ia, ib) = match axis {+            crate::script::Axis::X => (1, 0),+            crate::script::Axis::Y => (0, 2),+            crate::script::Axis::Z => (1, 2),+        };+        let (a, b) = (defaults[ia].name.clone(), defaults[ib].name.clone());+        let _ = Self::clear_selection(doc);+        if !(Self::select_named(doc, &a, "PLANE", false, 0)? && Self::select_named(doc, &b, "PLANE", true, 0)?) {+            let _ = Self::clear_selection(doc);             return Err(HostError::msg(format!("could not select '{a}' and '{b}' to make the {} axis", axis.name())));         }         let before = Self::last_feature_name(doc);         let ok = doc.call("InsertAxis2", &[&Var::boolean(true)])?.as_bool().unwrap_or(false);-        Self::clear_selection(doc);+        let _ = Self::clear_selection(doc);         let after = Self::last_feature_name(doc);         if !ok || after.is_none() || after == before {             return Err(HostError::msg(format!("SOLIDWORKS made no {} axis from '{a}' and '{b}'", axis.name())));         }         let name = after.unwrap_or_default();-        st.axes.insert(axis, name.clone());-        Ok(name)+        let idx = match axis {+            crate::script::Axis::X => 0,+            crate::script::Axis::Y => 1,+            crate::script::Axis::Z => 2,+        };+        let sign = Self::feature_named(doc, &name)+            .and_then(|f| f.obj("GetSpecificFeature2", &[]).ok().flatten())+            .and_then(|ra| ra.call("GetRefAxisParams", &[]).ok())+            .map(|v| Self::floats(&v))+            .filter(|p| p.len() >= 6)+            .map(|p| p[3 + idx] - p[idx])+            .filter(|d| d.abs() > 1e-12)+            .map(|d| d.signum())+            .unwrap_or(1.0);+        st.axes.insert(axis, (name.clone(), sign));+        Ok((name, sign))     } -    /// Select the edges a fillet/chamfer takes; returns how many.-    fn select_edges(&mut self, doc: &Disp, edges: &crate::script::EdgeSel, mark: i32, st: &ScriptState) -> Result<usize, HostError> {-        Self::clear_selection(doc);-        match edges {-            crate::script::EdgeSel::AtPoints(ps) => {-                for (k, p) in ps.iter().enumerate() {-                    if !Self::select_at(doc, "EDGE", *p, k > 0, mark)? {-                        Self::clear_selection(doc);-                        return Err(HostError::msg(format!(-                            "no edge at [{:.3}, {:.3}, {:.3}] mm (edge {} of {})",-                            p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0, k + 1, ps.len()-                        )));-                    }-                }-                Ok(ps.len())-            }-            crate::script::EdgeSel::AllOfFeature(f) => {-                let name = st.resolve_feature(f)?;-                let feat = Self::feature_named(doc, &name).ok_or_else(|| HostError::msg(format!("no feature named '{name}'")))?;-                let sel = doc.obj("SelectionManager", &[])?.ok_or_else(|| HostError::msg("no SelectionManager"))?;-                let data = sel.obj("CreateSelectData", &[])?.ok_or_else(|| HostError::msg("CreateSelectData returned nothing"))?;-                data.try_put("Mark", &Var::i4(mark));-                let data_var = Var::dispatch(&data.0);-                let mut n = 0usize;-                // IFeature::GetFaces -> IFace2::GetEdges -> IEntity::Select4(append, data).-                for face in feat.call("GetFaces", &[])?.as_array().iter().filter_map(|f| f.as_dispatch().map(Disp)) {-                    for edge in face.call("GetEdges", &[]).map(|v| v.as_array()).unwrap_or_default().iter().filter_map(|e| e.as_dispatch().map(Disp)) {-                        if edge.call("Select4", &[&Var::boolean(true), &data_var]).ok().and_then(|v| v.as_bool()).unwrap_or(false) {-                            n += 1;-                        }-                    }-                }-                if n == 0 {-                    return Err(HostError::msg(format!("feature '{name}' gave no edges to select")));-                }-                Ok(n)-            }-        }+    /// FeatureFillet3's documented successor (review #12): CreateDefinition+    /// (swFeatureNameID_e.swFmFillet, read from the installed swconst DLL —+    /// never guessed) → ISimpleFilletFeatureData2 (Initialize swConstRadiusFillet+    /// 0, DefaultRadius, Edges) → CreateFeature.+    fn fillet_by_definition(&mut self, _doc: &Disp, fm: &Disp, edges: &[Disp], radius: f64) -> Result<Var, HostError> {+        let sw = self.app(false, true)?;+        let exe = sw.string("GetExecutablePath", &[]).unwrap_or_default();+        let ids = swconst_ids(&exe, &[("swFeatureNameID_e", "swFmFillet")]).map_err(HostError::msg)?;+        let id = *ids.get("swFmFillet").ok_or_else(|| HostError::msg("swFmFillet was not read from swconst"))?;+        let data = fm.obj("CreateDefinition", &[&Var::i4(id)])?.ok_or_else(|| HostError::msg("CreateDefinition returned nothing"))?;+        data.call("Initialize", &[&Var::i4(0)])?;+        data.put("DefaultRadius", &Var::r8(radius))?;+        data.put("Edges", &Var::dispatch_array(&edges.iter().map(|d| d.0.clone()).collect::<Vec<_>>()))?;+        fm.call("CreateFeature", &[&Var::dispatch(&data.0)])     } } -/// What a running script remembers between ops: the feature "last" names, and-/// the reference planes / axes it has already made (reused, not duplicated).+/// The geometric face pick tolerance (metres): a point within 2 µm of a face+/// is ON it.+const PICK_TOL: f64 = 2e-6;++/// A picked face, with its closest point to the caller's and its outward normal.+struct PickedFace {+    face: Disp,+    closest: crate::modeling::V3,+    normal: crate::modeling::V3,+}++#[derive(Clone)]+struct DefaultPlane {+    name: String,+    frame: crate::modeling::PlaneFrame,+}++/// A sketch closed and not yet consumed: its name (to re-select it), its+/// normal, and the direction a boss from it should go.+#[derive(Clone)]+struct PendingSketch {+    name: String,+    normal: Option<crate::modeling::V3>,+    reference: Option<crate::modeling::V3>,+}++/// What a running script remembers between ops: the feature "last" names, the+/// reference planes / axes it has made (reused, not duplicated), the pending+/// sketch, the appearances it set (a material wipes them), the default planes,+/// and the document to re-activate when it ends. #[derive(Default)] struct ScriptState {     last_feature: Option<String>,     planes: std::collections::HashMap<String, String>,-    axes: std::collections::HashMap<crate::script::Axis, String>,+    axes: std::collections::HashMap<crate::script::Axis, (String, f64)>,+    pending: Option<PendingSketch>,+    appearances: Vec<(crate::script::AppearanceTarget, [f64; 3])>,+    defaults: Option<[DefaultPlane; 3]>,+    frames_source: &'static str,+    restore_active: Option<String>, }  impl ScriptState {
src/comhost/mock.rs+92−231
@@ -81,69 +81,6 @@ fn mock_density(material: Option<&str>) -> f64 {     } } -/// The simulated model's bounding box (metres), if it has a body.-fn model_bbox(d: &MockDoc) -> Option<([f64; 3], [f64; 3])> {-    let b = d.model.get("bbox")?.as_array()?;-    let v = |i: usize| -> Option<[f64; 3]> {-        let a = b.get(i)?.as_array()?;-        Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])-    };-    Some((v(0)?, v(1)?))-}--/// Record one feature on the simulated model: its name (SOLIDWORKS' own-/// numbering per kind), its volume change, and the box it covers; then rebuild-/// the part's single body from the model.-fn add_feature(d: &mut MockDoc, kind: &str, delta_m3: f64, covers: Option<([f64; 3], [f64; 3])>) -> String {-    let counters = d.model.entry("counters").or_insert_with(|| json!({}));-    let n = counters.get(kind).and_then(|v| v.as_u64()).unwrap_or(0) + 1;-    counters[kind] = json!(n);-    let name = format!("{kind}{n}");-    let vol = d.model.get("volumeM3").and_then(|v| v.as_f64()).unwrap_or(0.0) + delta_m3;-    d.model.insert("volumeM3".into(), json!(vol.max(0.0)));-    if let Some((lo, hi)) = covers {-        let (lo, hi) = match model_bbox(d) {-            Some((a, b)) => ([a[0].min(lo[0]), a[1].min(lo[1]), a[2].min(lo[2])], [b[0].max(hi[0]), b[1].max(hi[1]), b[2].max(hi[2])]),-            None => (lo, hi),-        };-        d.model.insert("bbox".into(), json!([lo, hi]));-    }-    let feats = d.model.entry("features").or_insert_with(|| json!([]));-    if let Some(a) = feats.as_array_mut() {-        a.push(json!({"name": name, "kind": kind, "volumeM3": delta_m3}));-    }-    if d.model.get("bodyName").is_none() && delta_m3 > 0.0 {-        d.model.insert("bodyName".into(), json!(name));-    }-    rebuild_body(d);-    d.dirty = true;-    name-}--fn rebuild_body(d: &mut MockDoc) {-    let (Some((lo, hi)), Some(name)) = (model_bbox(d), d.model.get("bodyName").and_then(|v| v.as_str()).map(String::from)) else { return };-    let vol = d.model.get("volumeM3").and_then(|v| v.as_f64()).unwrap_or(0.0);-    let mm = |v: f64| (v * 1.0e6).round() / 1000.0;-    let size = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];-    let mut body = json!({-        "name": name,-        "bbox": {"minMm": [mm(lo[0]), mm(lo[1]), mm(lo[2])], "maxMm": [mm(hi[0]), mm(hi[1]), mm(hi[2])]},-        "sizeMm": {"x": mm(size[0]), "y": mm(size[1]), "z": mm(size[2])},-        "volumeMm3": (vol * 1.0e9 * 1000.0).round() / 1000.0,-        "areaMm2": (2.0 * (size[0] * size[1] + size[1] * size[2] + size[0] * size[2]) * 1.0e6 * 1000.0).round() / 1000.0,-        "massKg": Value::Null,-        "centerOfMassMm": [mm((lo[0] + hi[0]) / 2.0), mm((lo[1] + hi[1]) / 2.0), mm((lo[2] + hi[2]) / 2.0)],-        "faceCount": 6 + d.model.get("features").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0),-        "visible": true,-        "isSolid": true,-        "simulated": "mock arithmetic model (mock_model.rs), not a kernel",-    });-    if let Some(rgb) = d.model.get("appearance") {-        body["appearanceRgb"] = rgb.clone();-    }-    d.bodies = vec![body];-}- struct MockDoc {     /// The IDENTITY key. For a document that has never been saved this is a     /// synthetic `untitled:Part1` — SOLIDWORKS reports an empty `GetPathName()`@@ -3756,187 +3693,111 @@ impl MockBackend {                 if pinned.is_some() {                     self.resolve(&pin_arg(&pinned))?;                 }-                let target = pin_arg(&pinned);+                // ACTIVATE THE PINNED DOCUMENT (0.10.2, review #8): selection works+                // only in SOLIDWORKS' active document. The user's active document+                // is put back at the end.+                let was_active = self.active.clone();+                if pinned.is_some() && !fault("mock-no-activation") {+                    let p = self.resolve(&pin_arg(&pinned))?;+                    self.active = Some(p);+                }                 let mut steps = Vec::new();                 let mut created = Value::Null;-                let mut pending: Option<(crate::comhost::mock_model::Frame, Vec<crate::script::Entity>)> = None;-                let mut last_feature: Option<String> = None;+                let mut run = crate::comhost::mock_model::Run::default();+                let mut failure: Option<HostError> = None;                 for (i, op) in ops.iter().enumerate() {-                    let detail = match op {-                        Op::NewPart | Op::NewAssembly => {-                            let kind = if matches!(op, Op::NewPart) { "part" } else { "assembly" };-                            let r = self.call("new_document", &json!({"type": kind}))?;-                            created = r.get("created").cloned().unwrap_or(Value::Null);-                            pinned = created.get("title").and_then(|t| t.as_str()).map(String::from);-                            r-                        }-                        Op::Sketch { plane, entities, .. } => {-                            if fault("mock-sketch-fails") {-                                return Err(HostError::msg(format!("step {} ({}) failed: SOLIDWORKS drew nothing", i + 1, op.name())));+                    let target = pin_arg(&pinned);+                    let detail: Result<Value, HostError> = (|| -> Result<Value, HostError> {+                        Ok(match op {+                            Op::NewPart | Op::NewAssembly => {+                                let kind = if matches!(op, Op::NewPart) { "part" } else { "assembly" };+                                let r = self.call("new_document", &json!({"type": kind}))?;+                                created = r.get("created").cloned().unwrap_or(Value::Null);+                                pinned = created.get("title").and_then(|t| t.as_str()).map(String::from);+                                run = crate::comhost::mock_model::Run::default();+                                r                             }-                            let path = self.resolve(&target)?;-                            let bbox = self.find(&path).and_then(model_bbox);-                            let (frame, base) = crate::comhost::mock_model::frame_of(plane, bbox)-                                .map_err(|e| HostError::msg(format!("step {} ({}) failed: {e}", i + 1, op.name())))?;-                            let mut resolved = Value::Null;-                            let mut made_plane: Option<String> = None;-                            if !matches!(plane, crate::script::PlaneRef::Named(_)) {+                            Op::SetProp { name, value } => {+                                let path = self.resolve(&target)?;                                 if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {-                                    let counters = d.model.entry("counters").or_insert_with(|| json!({}));-                                    let n = counters.get("Plane").and_then(|v| v.as_u64()).unwrap_or(3) + 1;-                                    counters["Plane"] = json!(n);-                                    made_plane = Some(format!("Plane{n}"));+                                    d.props.insert(name.clone(), prop(value));+                                    d.dirty = true;                                 }-                                resolved = json!({"offsetFrom": base, "offsetMm": frame.offset * 1000.0});-                            }-                            pending = Some((frame, entities.clone()));-                            let mut r = json!({"plane": plane.describe(), "resolvedPlane": resolved, "createdPlane": made_plane,-                                   "entities": entities.iter().map(|e| e.describe()).collect::<Vec<_>>(),-                                   "profileAreaMm2": crate::comhost::mock_model::profile_area(entities) * 1.0e6});-                            crate::script::single_entity_fields(entities, &mut r);-                            r-                        }-                        Op::Extrude(ext) | Op::Cut(ext) => {-                            let cut = matches!(op, Op::Cut(_));-                            let path = self.resolve(&target)?;-                            let (frame, entities) = pending.take().ok_or_else(|| HostError::msg("no sketch to consume"))?;-                            let kind = if cut { "Cut-Extrude" } else { "Boss-Extrude" };-                            // A FEATURE THAT COMES BACK NULL IS AN ERROR (issue #1:-                            // a cut that removed nothing reported feature:null as-                            // if it had worked).-                            if fault(&format!("mock-feature-null-{}", op.name())) {-                                return Err(HostError::msg(format!(-                                    "step {} ({}) failed: SOLIDWORKS returned no feature (nothing was {})",-                                    i + 1, op.name(), if cut { "cut — check the direction: a cut goes along the plane's −normal unless reverse:true" } else { "extruded" }-                                )));+                                json!({"property": name, "value": value, "code": 1})                             }-                            let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;-                            let bbox = model_bbox(d);-                            let through = bbox.map(|(lo, hi)| (lo[frame.normal], hi[frame.normal])).unwrap_or((frame.offset, frame.offset));-                            let sp = crate::comhost::mock_model::span(&frame, ext, cut, through);-                            let area = crate::comhost::mock_model::profile_area(&entities);-                            let vol = area * (sp.1 - sp.0);-                            if cut && bbox.is_none() {-                                return Err(HostError::msg(format!("step {} (cut) failed: SOLIDWORKS returned no feature — there is no body to cut", i + 1)));-                            }-                            let covers = (!cut).then(|| crate::comhost::mock_model::prism_box(&frame, &entities, sp));-                            let name = add_feature(d, kind, if cut { -vol } else { vol }, covers);-                            d.dimensions.insert(format!("D1@{name}"), json!(ext.depth));-                            last_feature = Some(name.clone());-                            json!({"feature": name, "depthMm": ext.depth * 1000.0, "throughAll": ext.through_all,-                                   "direction": format!("{:?}", ext.direction), "reverse": ext.reverse,-                                   "spanMm": [sp.0 * 1000.0, sp.1 * 1000.0], "volumeChangeMm3": if cut { -vol } else { vol } * 1.0e9})-                        }-                        Op::SetAppearance { rgb, target: at } => {-                            let path = self.resolve(&target)?;-                            let rgb255: Vec<i64> = rgb.iter().map(|c| (c * 255.0).round() as i64).collect();-                            if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {-                                match at {-                                    crate::script::AppearanceTarget::Part | crate::script::AppearanceTarget::Body(_) => {-                                        d.model.insert("appearance".into(), json!(rgb255));-                                    }-                                    crate::script::AppearanceTarget::FaceAt(p) => {-                                        crate::comhost::mock_model::face_frame(model_bbox(d), *p)-                                            .map_err(|e| HostError::msg(format!("step {} (set_appearance) failed: {e}", i + 1)))?;-                                        let faces = d.model.entry("faceAppearances").or_insert_with(|| json!([]));-                                        if let Some(a) = faces.as_array_mut() {-                                            a.push(json!({"atPointMm": [p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0], "rgb": rgb255}));-                                        }-                                    }+                            Op::SaveAs { path } => {+                                if let crate::writes::Destination::Unverifiable(io) =+                                    crate::writes::destination_state(Some(path))+                                {+                                    return Err(HostError::coded(+                                        "destination_unverifiable",+                                        crate::writes::unverifiable_message(path, &io),+                                    ));                                 }-                                rebuild_body(d);-                                d.dirty = true;-                            }-                            json!({"rgb": rgb255, "target": format!("{at:?}"), "readBack": rgb255})-                        }-                        Op::Fillet { edges, .. } | Op::Chamfer { edges, .. } => {-                            let path = self.resolve(&target)?;-                            let kind = if matches!(op, Op::Fillet { .. }) { "Fillet" } else { "Chamfer" };-                            let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;-                            if model_bbox(d).is_none() {-                                return Err(HostError::msg(format!("step {} ({}) failed: there is no body, so no edge", i + 1, op.name())));-                            }-                            let selected = match edges {-                                crate::script::EdgeSel::AtPoints(p) => json!({"atPoints": p.len()}),-                                crate::script::EdgeSel::AllOfFeature(f) => {-                                    let f = if f == "last" { last_feature.clone().unwrap_or_default() } else { f.clone() };-                                    json!({"allOfFeature": f})+                                if std::path::Path::new(path).exists() {+                                    return Err(HostError::msg(format!("refusing to overwrite an existing file: {path}")));                                 }-                            };-                            let name = add_feature(d, kind, 0.0, None);-                            last_feature = Some(name.clone());-                            json!({"feature": name, "edges": selected, "proven": false})-                        }-                        Op::Shell { thickness, faces, outward } => {-                            let path = self.resolve(&target)?;-                            let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;-                            for p in faces {-                                crate::comhost::mock_model::face_frame(model_bbox(d), *p)-                                    .map_err(|e| HostError::msg(format!("step {} (shell) failed: {e}", i + 1)))?;-                            }-                            let name = add_feature(d, "Shell", 0.0, None);-                            last_feature = Some(name.clone());-                            json!({"feature": name, "thicknessMm": thickness * 1000.0, "facesRemoved": faces.len(), "outward": outward, "proven": false})-                        }-                        Op::LinearPattern { feature, dirs } => {-                            let path = self.resolve(&target)?;-                            let seed = if feature == "last" { last_feature.clone().unwrap_or_default() } else { feature.clone() };-                            let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;-                            let seed_vol = d.model.get("features").and_then(|f| f.as_array())-                                .and_then(|a| a.iter().find(|f| f["name"] == seed.as_str()).and_then(|f| f["volumeM3"].as_f64()))-                                .ok_or_else(|| HostError::msg(format!("step {} (linear_pattern) failed: no feature named '{seed}'", i + 1)))?;-                            let n: u32 = dirs.iter().map(|d| d.1).product();-                            let name = add_feature(d, "LPattern", seed_vol * (n as f64 - 1.0), None);-                            last_feature = Some(name.clone());-                            json!({"feature": name, "seed": seed, "instances": n, "proven": false})-                        }-                        Op::CircularPattern { feature, count, angle, axis } => {-                            let path = self.resolve(&target)?;-                            let seed = if feature == "last" { last_feature.clone().unwrap_or_default() } else { feature.clone() };-                            let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;-                            let seed_vol = d.model.get("features").and_then(|f| f.as_array())-                                .and_then(|a| a.iter().find(|f| f["name"] == seed.as_str()).and_then(|f| f["volumeM3"].as_f64()))-                                .ok_or_else(|| HostError::msg(format!("step {} (circular_pattern) failed: no feature named '{seed}'", i + 1)))?;-                            let name = add_feature(d, "CirPattern", seed_vol * (*count as f64 - 1.0), None);-                            last_feature = Some(name.clone());-                            json!({"feature": name, "seed": seed, "axis": axis.name(), "instances": count, "angleDeg": angle.to_degrees(), "proven": false})-                        }-                        Op::SetMaterial { name, database } => {-                            let path = self.resolve(&target)?;-                            if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {-                                d.material = Some(name.clone());-                                d.dirty = true;+                                self.call("save_as", &json!({"outputPath": path, "copy": true}))?                             }-                            json!({"material": name, "database": database, "current": {"name": name, "database": database}})-                        }-                        Op::SetProp { name, value } => {-                            let path = self.resolve(&target)?;-                            if let Some(d) = self.docs.iter_mut().find(|d| d.path == path) {-                                d.props.insert(name.clone(), prop(value));+                            Op::Rebuild => json!({"rebuilt": true}),+                            Op::ZoomFit => json!({"zoomedToFit": true}),+                            _ => {+                                if matches!(op, Op::Sketch { .. }) && fault("mock-sketch-fails") {+                                    return Err(HostError::msg("SOLIDWORKS drew nothing"));+                                }+                                if matches!(op, Op::Extrude(_) | Op::Cut(_)) && fault(&format!("mock-feature-null-{}", op.name())) {+                                    return Err(HostError::msg(+                                        "SOLIDWORKS returned no feature (nothing was cut — check the direction: a cut goes along the \+                                         plane's −normal unless reverse:true)",+                                    ));+                                }+                                // RE-SELECT the pending sketch by name before it is+                                // consumed (review #7): an op in between cleared it.+                                if matches!(op, Op::Extrude(_) | Op::Cut(_)) && run.pending.is_some() && !fault("mock-no-reselect") {+                                    run.sketch_selected = true;+                                }+                                let path = self.resolve(&target)?;+                                let active = self.active.as_deref() == Some(path.as_str());+                                let d = self.docs.iter_mut().find(|d| d.path == path).ok_or_else(|| HostError::msg("document vanished"))?;+                                let mut model: crate::comhost::mock_model::Model =+                                    serde_json::from_value(Value::Object(d.model.clone())).unwrap_or_default();+                                let r = crate::comhost::mock_model::apply(&mut model, &mut run, op, active).map_err(HostError::msg)?;+                                if let Op::SetMaterial { name, .. } = op {+                                    d.material = Some(name.clone());+                                }+                                if let (Op::Extrude(e), Some(f)) = (op, r.get("feature").and_then(|f| f.as_str())) {+                                    d.dimensions.insert(format!("D1@{f}"), json!(e.depth));+                                }+                                if !model.bodies.is_empty() {+                                    d.bodies = model.bodies_json();+                                }+                                if let Value::Object(m) = serde_json::to_value(&model).unwrap_or(Value::Null) {+                                    d.model = m;+                                }                                 d.dirty = true;+                                r                             }-                            json!({"property": name, "value": value, "code": 1})-                        }-                        Op::SaveAs { path } => {-                            if let crate::writes::Destination::Unverifiable(io) =-                                crate::writes::destination_state(Some(path))-                            {-                                return Err(HostError::coded(-                                    "destination_unverifiable",-                                    crate::writes::unverifiable_message(path, &io),-                                ));-                            }-                            if std::path::Path::new(path).exists() {-                                return Err(HostError::msg(format!(-                                    "refusing to overwrite an existing file: {path}"-                                )));-                            }-                            self.call("save_as", &json!({"outputPath": path, "copy": true}))?+                        })+                    })();+                    match detail {+                        Ok(d) => steps.push(json!({"step": i + 1, "op": op.name(), "result": d})),+                        Err(e) => {+                            let err = HostError::msg(format!("step {} ({}) failed: {}", i + 1, op.name(), e.message));+                            // A part made by this script's new_part before the+                            // failing step still exists, and is the bridge's.+                            failure = Some(if created.is_object() { err.with_created(created.clone()) } else { err });+                            break;                         }-                        Op::Rebuild => json!({"rebuilt": true}),-                        Op::ZoomFit => json!({"zoomedToFit": true}),-                    };-                    steps.push(json!({"step": i + 1, "op": op.name(), "result": detail}));+                    }+                }+                // Put the user's active document back.+                if let Some(prev) = was_active {+                    if self.find(&prev).is_some() && pinned.is_some() && !created.is_object() {+                        self.active = Some(prev);+                    }+                }+                if let Some(f) = failure {+                    return Err(f);                 }                 let final_target = pin_arg(&pinned);                 let doc = self.resolve(&final_target).ok().map(|p| self.doc_info(&p));
src/comhost/mock_model.rs+893−112
@@ -1,87 +1,134 @@-//! THE MOCK'S SOLID MODEL (0.10.0) — just enough geometry to simulate the-//! modelling DSL honestly: each feature's volume change and the part's-//! bounding box, computed from the same parsed ops SOLIDWORKS is sent.+//! THE SIMULATOR'S SOLID MODEL (0.10.0, rebuilt 0.10.2) — enough geometry to+//! simulate the modelling DSL HONESTLY, including the hazards an independent+//! review of the COM code named (2026-10-13). It is ARITHMETIC, not a kernel:+//!   * bodies are boxes, cylinders or "other" solids with an exact volume and a+//!     bounding box; a profile's area counts nested entities as holes; a boss+//!     merges into every body it touches, or starts a new one; a cut removes+//!     only what lies inside a body — a cut into empty space comes back NULL,+//!     exactly as SOLIDWORKS' does;+//!   * faces and edges are picked GEOMETRICALLY (`modeling::pick`); the+//!     view-dependent pick the review warned about is modelled too+//!     (`view_pick`), so the difference is testable;+//!   * selecting an already-selected entity DESELECTS it+//!     (`modeling::toggled_selection`): an un-deduplicated edge list selects+//!     nothing;+//!   * a face's outward normal decides which way a boss from it goes;+//!   * applying a material WIPES the part's appearance (documented SOLIDWORKS+//!     behaviour), so the bridge must re-apply it;+//!   * fillets, chamfers and shells change the volume by closed-form formulas+//!     for the shapes they are applied to (box, cylinder) — anything else is+//!     reported as "not modelled", never guessed. //!-//! It is ARITHMETIC, not a kernel: a profile's area is its entities' areas with-//! every entity nested inside another counted as a hole (rings, walls); an-//! extrude adds area x depth; a cut removes it. That is exactly right for the-//! prismatic parts the DSL builds (the LEGO brick of issue #1 among them) and-//! is never presented as a measurement of SOLIDWORKS.+//! Nothing here is ever presented as a measurement of SOLIDWORKS. -use crate::script::{Direction, Entity, Extent, PlaneRef};+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};+use std::f64::consts::PI; -/// A sketch plane as the mock understands it: which model axis is its normal-/// (0 = X, 1 = Y, 2 = Z), its offset along that normal (metres), and how the-/// sketch's (u, v) map onto the other two model axes.-#[derive(Clone, Copy, Debug, PartialEq)]-pub struct Frame {-    pub normal: usize,-    pub offset: f64,-}--impl Frame {-    /// Sketch (u, v) -> model (x, y, z) at height `h` along the normal.-    /// Top Plane: u -> X, v -> -Z (measured, issue #1). Front: u -> X, v -> Y.-    /// Right: u -> -Z, v -> Y.-    pub fn to_model(&self, u: f64, v: f64, h: f64) -> [f64; 3] {-        match self.normal {-            1 => [u, h, -v],-            2 => [u, v, h],-            _ => [h, v, -u],-        }-    }-}--/// The frame of a default plane by name, or None for a name the mock does not-/// know (a script-made plane is resolved before this is asked).-pub fn named_frame(name: &str) -> Option<Frame> {-    match name.trim().to_ascii_lowercase().as_str() {-        "top plane" | "top" => Some(Frame { normal: 1, offset: 0.0 }),-        "front plane" | "front" => Some(Frame { normal: 2, offset: 0.0 }),-        "right plane" | "right" => Some(Frame { normal: 0, offset: 0.0 }),-        _ => None,-    }-}--/// A planar face of the bounding box through `p`, as a frame. The mock's part-/// is its bounding box, so a face is one of its six sides.-pub fn face_frame(bbox: Option<([f64; 3], [f64; 3])>, p: [f64; 3]) -> Result<(Frame, &'static str), String> {-    let (lo, hi) = bbox.ok_or_else(|| "the part has no body yet, so there is no face at that point".to_string())?;-    let eps = 1e-7;-    for (axis, plane) in [(1usize, "Top Plane"), (2, "Front Plane"), (0, "Right Plane")] {-        for side in [lo[axis], hi[axis]] {-            if (p[axis] - side).abs() < eps {-                let (a, b) = match axis {-                    0 => (1, 2),-                    1 => (0, 2),-                    _ => (0, 1),-                };-                let inside = |i: usize| p[i] >= lo[i] - eps && p[i] <= hi[i] + eps;-                if inside(a) && inside(b) {-                    return Ok((Frame { normal: axis, offset: side }, plane));-                }-            }-        }-    }-    Err(format!(-        "no planar face of the part passes through [{:.3}, {:.3}, {:.3}] mm",-        p[0] * 1000.0,-        p[1] * 1000.0,-        p[2] * 1000.0-    ))+use crate::modeling::{self, PlaneFrame, V3};+use crate::script::{AppearanceTarget, Axis, EdgeSel, Entity, Extent, Op, PlaneRef};++const TOL: f64 = 1e-6;++#[derive(Clone, Debug, Default, Serialize, Deserialize)]+pub struct Model {+    pub bodies: Vec<Body>,+    pub features: Vec<Feature>,+    pub counters: std::collections::BTreeMap<String, u32>,+    /// Part-level appearance (0..255 RGB).+    pub appearance: Option<[i64; 3]>,+    /// Face-level appearances: (a point on the face, rgb).+    pub face_appearances: Vec<(V3, [i64; 3])>,+    pub material: Option<String>, } -pub fn frame_of(plane: &PlaneRef, bbox: Option<([f64; 3], [f64; 3])>) -> Result<(Frame, String), String> {-    match plane {-        PlaneRef::Named(n) => named_frame(n).map(|f| (f, n.clone())).ok_or_else(|| format!("no plane named '{n}' in this document")),-        PlaneRef::Offset { from, offset } => {-            let base = named_frame(from).ok_or_else(|| format!("no plane named '{from}' in this document"))?;-            Ok((Frame { normal: base.normal, offset: base.offset + offset }, from.clone()))-        }-        PlaneRef::Face { point } => face_frame(bbox, *point).map(|(f, n)| (f, n.to_string())),+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]+pub enum Shape {+    /// The body IS its bounding box.+    Box,+    /// A cylinder along `axis` (0 X, 1 Y, 2 Z) of radius `r`; its extent along+    /// the axis is the box's.+    Cylinder { axis: usize, r: f64 },+    Other,+}++#[derive(Clone, Debug, Serialize, Deserialize)]+pub struct Body {+    pub name: String,+    pub lo: V3,+    pub hi: V3,+    pub volume: f64,+    pub faces: u32,+    pub shape: Shape,+    pub appearance: Option<[i64; 3]>,+}++#[derive(Clone, Debug, Serialize, Deserialize)]+pub struct Feature {+    pub name: String,+    pub kind: String,+    /// The volume this feature added (negative: removed).+    pub volume: f64,+    /// The box of the material it added (a boss), for patterns.+    pub lo: Option<V3>,+    pub hi: Option<V3>,+    pub body: Option<usize>,+}++/// A sketch closed and not yet consumed.+#[derive(Clone, Debug)]+pub struct Pending {+    pub name: String,+    pub frame: PlaneFrame,+    pub h: f64,+    /// The REFERENCE direction along the frame's normal: +1, or −1 for a face+    /// whose outward normal is the frame's −normal.+    pub sign: f64,+    pub entities: Vec<Entity>,+}++/// What a running script's simulation remembers (mirrors com.rs ScriptState).+#[derive(Default)]+pub struct Run {+    pub pending: Option<Pending>,+    /// Is the pending sketch still SELECTED? Ops between a sketch and its+    /// extrude clear the selection; the bridge re-selects the sketch by name.+    pub sketch_selected: bool,+    pub last_feature: Option<String>,+    pub appearances: Vec<(AppearanceTarget, [f64; 3])>,+}++fn mm(v: f64) -> f64 {+    (v * 1.0e6).round() / 1000.0+}++fn rgb255(rgb: &[f64; 3]) -> [i64; 3] {+    [(rgb[0] * 255.0).round() as i64, (rgb[1] * 255.0).round() as i64, (rgb[2] * 255.0).round() as i64]+}++fn axis_index(a: Axis) -> usize {+    match a {+        Axis::X => 0,+        Axis::Y => 1,+        Axis::Z => 2,     } } +/// The index of the model axis a default plane's normal runs along.+fn normal_axis(f: &PlaneFrame) -> usize {+    (0..3).max_by(|a, b| f.n[*a].abs().partial_cmp(&f.n[*b].abs()).unwrap()).unwrap_or(1)+}++fn touches(alo: V3, ahi: V3, blo: V3, bhi: V3) -> bool {+    (0..3).all(|i| alo[i] <= bhi[i] + 1e-9 && blo[i] <= ahi[i] + 1e-9)+}++fn overlap_len(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 {+    (a1.min(b1) - a0.max(b0)).max(0.0)+}++// ── profiles ────────────────────────────────────────────────────────────────+ fn bounds(e: &Entity) -> (f64, f64, f64, f64) {     match *e {         Entity::Rect { x0, y0, x1, y1 } => (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)),@@ -92,7 +139,14 @@ fn bounds(e: &Entity) -> (f64, f64, f64, f64) { fn area(e: &Entity) -> f64 {     match *e {         Entity::Rect { x0, y0, x1, y1 } => ((x1 - x0) * (y1 - y0)).abs(),-        Entity::Circle { r, .. } => std::f64::consts::PI * r * r,+        Entity::Circle { r, .. } => PI * r * r,+    }+}++fn sides(e: &Entity) -> u32 {+    match e {+        Entity::Rect { .. } => 4,+        Entity::Circle { .. } => 1,     } } @@ -115,44 +169,49 @@ pub fn profile_area(entities: &[Entity]) -> f64 {         .sum() } -/// The profile's 2D bounds.-pub fn profile_bounds(entities: &[Entity]) -> (f64, f64, f64, f64) {-    entities.iter().map(bounds).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, b| (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3)))+/// A closed region of a sketch: an outer entity and the holes directly in it.+struct Region<'a> {+    outer: &'a Entity,+    holes: Vec<&'a Entity>, } -/// The span along the normal an extrude (or, with `cut`, a cut) covers.-/// Extrude: +normal; cut: −normal (measured, issue #1); `reverse` flips;-/// mid-plane is symmetric; both = depth one way and depth2 the other.-/// Through all spans `through` (the part's extent along the normal).-pub fn span(f: &Frame, e: &Extent, cut: bool, through: (f64, f64)) -> (f64, f64) {-    let o = f.offset;-    if e.through_all {-        return match e.direction {-            Direction::One => {-                let up = !cut ^ e.reverse;-                if up { (o, through.1.max(o)) } else { (through.0.min(o), o) }-            }-            _ => (through.0.min(o), through.1.max(o)),-        };+fn regions(entities: &[Entity]) -> Vec<Region<'_>> {+    entities+        .iter()+        .filter(|e| !entities.iter().any(|o| inside(e, o)))+        .map(|outer| Region {+            outer,+            holes: entities+                .iter()+                .filter(|h| inside(h, outer) && entities.iter().filter(|o| inside(h, o)).count() == 1)+                .collect(),+        })+        .collect()+}++impl Region<'_> {+    fn area(&self) -> f64 {+        area(self.outer) - self.holes.iter().map(|h| area(h)).sum::<f64>()     }-    match e.direction {-        Direction::One => {-            let up = !cut ^ e.reverse;-            if up { (o, o + e.depth) } else { (o - e.depth, o) }+    fn faces(&self) -> u32 {+        2 + sides(self.outer) + self.holes.iter().map(|h| sides(h)).sum::<u32>()+    }+    fn shape(&self, axis: usize) -> Shape {+        match (self.outer, self.holes.is_empty()) {+            (Entity::Rect { .. }, true) => Shape::Box,+            (Entity::Circle { r, .. }, true) => Shape::Cylinder { axis, r: *r },+            _ => Shape::Other,         }-        Direction::MidPlane => (o - e.depth / 2.0, o + e.depth / 2.0),-        Direction::Both => (o - e.depth2, o + e.depth),     } } -/// The model-space box of a prism: the profile's bounds swept over `span`.-pub fn prism_box(f: &Frame, entities: &[Entity], span: (f64, f64)) -> ([f64; 3], [f64; 3]) {-    let (u0, v0, u1, v1) = profile_bounds(entities);+fn prism_box(f: &PlaneFrame, e: &Entity, span: (f64, f64)) -> (V3, V3) {+    let (u0, v0, u1, v1) = bounds(e);     let mut lo = [f64::MAX; 3];     let mut hi = [f64::MIN; 3];     for (u, v) in [(u0, v0), (u0, v1), (u1, v0), (u1, v1)] {         for h in [span.0, span.1] {-            let p = f.to_model(u, v, h);+            let p = f.point(u, v, h);             for i in 0..3 {                 lo[i] = lo[i].min(p[i]);                 hi[i] = hi[i].max(p[i]);@@ -162,18 +221,740 @@ pub fn prism_box(f: &Frame, entities: &[Entity], span: (f64, f64)) -> ([f64; 3],     (lo, hi) } +// ── faces and edges, picked ─────────────────────────────────────────────────++/// A face of a body's box: (body, axis, side: −1 lo / +1 hi).+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct FaceRef {+    pub body: usize,+    pub axis: usize,+    pub side: i32,+}++/// An edge of a body's box: (body, the axis it runs along, the two fixed sides).+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct EdgeRef {+    pub body: usize,+    pub along: usize,+    pub sides: [i32; 2],+}++impl Model {+    fn face_coord(&self, f: FaceRef) -> f64 {+        let b = &self.bodies[f.body];+        if f.side > 0 { b.hi[f.axis] } else { b.lo[f.axis] }+    }++    fn all_faces(&self) -> Vec<FaceRef> {+        let mut out = Vec::new();+        for body in 0..self.bodies.len() {+            for axis in 0..3 {+                for side in [-1, 1] {+                    out.push(FaceRef { body, axis, side });+                }+            }+        }+        out+    }++    fn all_edges(&self) -> Vec<EdgeRef> {+        let mut out = Vec::new();+        for body in 0..self.bodies.len() {+            for along in 0..3 {+                for s0 in [-1, 1] {+                    for s1 in [-1, 1] {+                        out.push(EdgeRef { body, along, sides: [s0, s1] });+                    }+                }+            }+        }+        out+    }++    /// The closest point of a box face to `p`, and `p`'s height above it.+    fn face_distance(&self, f: FaceRef, p: V3) -> (f64, f64) {+        let b = &self.bodies[f.body];+        let mut q = p;+        for i in 0..3 {+            if i == f.axis {+                q[i] = self.face_coord(f);+            } else {+                q[i] = p[i].clamp(b.lo[i], b.hi[i]);+            }+        }+        (modeling::norm(modeling::sub(p, q)), (p[f.axis] - q[f.axis]) * f.side as f64)+    }++    fn edge_axes(e: EdgeRef) -> [usize; 2] {+        match e.along {+            0 => [1, 2],+            1 => [0, 2],+            _ => [0, 1],+        }+    }++    fn edge_distance(&self, e: EdgeRef, p: V3) -> f64 {+        let b = &self.bodies[e.body];+        let mut q = p;+        q[e.along] = p[e.along].clamp(b.lo[e.along], b.hi[e.along]);+        for (k, ax) in Self::edge_axes(e).iter().enumerate() {+            q[*ax] = if e.sides[k] > 0 { b.hi[*ax] } else { b.lo[*ax] };+        }+        modeling::norm(modeling::sub(p, q))+    }++    fn edge_len(&self, e: EdgeRef) -> f64 {+        let b = &self.bodies[e.body];+        b.hi[e.along] - b.lo[e.along]+    }++    /// THE GEOMETRIC FACE PICK.+    pub fn pick_face(&self, p: V3) -> Result<FaceRef, String> {+        let faces = self.all_faces();+        let cands: Vec<(usize, f64, f64)> = faces+            .iter()+            .enumerate()+            .map(|(i, f)| {+                let (d, out) = self.face_distance(*f, p);+                (i, d, out)+            })+            .collect();+        modeling::pick(&cands, TOL).map(|i| faces[i]).map_err(|e| match e {+            modeling::PickError::None(near) => format!(+                "no face of the part passes through [{:.3}, {:.3}, {:.3}] mm (nearest {} mm away)",+                p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0,+                near.map(|d| format!("{:.3}", d * 1000.0)).unwrap_or_else(|| "—".into())+            ),+            modeling::PickError::Ambiguous(n) => format!(+                "the point [{:.3}, {:.3}, {:.3}] mm lies on {n} faces (an edge or a corner) — move it inside ONE face",+                p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0+            ),+        })+    }++    /// THE VIEW-DEPENDENT PICK the review warned about (SelectByID2 on a FACE+    /// "uses the specified point as input to the normal user-interface+    /// selection routines"): the first face the eye's ray meets, looking from+    /// the default isometric direction. Modelled so a test can show it picks a+    /// VISIBLE face for a point on a hidden one; the bridge never uses it.+    pub fn view_pick(&self, p: V3) -> Option<FaceRef> {+        // Coming from +infinity along (1, 1, 1): the first slab boundary met is+        // the smallest (hi_k - p_k) over the body that contains the ray.+        let b = self.bodies.iter().position(|b| (0..3).all(|i| p[i] >= b.lo[i] - TOL && p[i] <= b.hi[i] + TOL))?;+        let body = &self.bodies[b];+        let axis = (0..3).min_by(|x, y| (body.hi[*x] - p[*x]).partial_cmp(&(body.hi[*y] - p[*y])).unwrap()).unwrap_or(1);+        Some(FaceRef { body: b, axis, side: 1 })+    }++    pub fn pick_edge(&self, p: V3) -> Result<EdgeRef, String> {+        let edges = self.all_edges();+        let cands: Vec<(usize, f64, f64)> = edges.iter().enumerate().map(|(i, e)| (i, self.edge_distance(*e, p), 0.0)).collect();+        modeling::pick(&cands, TOL).map(|i| edges[i]).map_err(|e| match e {+            modeling::PickError::None(_) => format!("no edge at [{:.3}, {:.3}, {:.3}] mm", p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0),+            modeling::PickError::Ambiguous(n) => format!(+                "the point [{:.3}, {:.3}, {:.3}] mm lies on {n} edges (a corner) — move it along ONE edge",+                p[0] * 1000.0, p[1] * 1000.0, p[2] * 1000.0+            ),+        })+    }++    // ── features ───────────────────────────────────────────────────────────++    fn next_name(&mut self, kind: &str) -> String {+        let n = self.counters.entry(kind.to_string()).or_insert(0);+        *n += 1;+        format!("{kind}{n}")+    }++    fn feature(&self, name: &str) -> Option<&Feature> {+        self.features.iter().find(|f| f.name == name)+    }++    fn through(&self, axis: usize) -> (f64, f64) {+        let lo = self.bodies.iter().map(|b| b.lo[axis]).fold(f64::MAX, f64::min);+        let hi = self.bodies.iter().map(|b| b.hi[axis]).fold(f64::MIN, f64::max);+        if lo > hi { (0.0, 0.0) } else { (lo, hi) }+    }++    /// A sketch: where it is, resolved. Returns the pending sketch and the+    /// step's report.+    pub fn sketch(&mut self, plane: &PlaneRef, entities: &[Entity]) -> Result<(Pending, Value), String> {+        let defaults = PlaneFrame::defaults();+        let (frame, h, sign, resolved, made) = match plane {+            PlaneRef::Named(n) => {+                let i = modeling::default_index(n).ok_or_else(|| format!("no plane named '{n}' in this document"))?;+                (defaults[i], 0.0, 1.0, Value::Null, None)+            }+            PlaneRef::Offset { from, offset } => {+                let i = modeling::default_index(from).ok_or_else(|| format!("no plane named '{from}' to offset from"))?;+                let made = self.next_name("Plane");+                (defaults[i], *offset, 1.0, json!({"offsetFrom": from, "offsetMm": offset * 1000.0}), Some(made))+            }+            PlaneRef::Face { point } => {+                let f = self.pick_face(*point)?;+                let i = modeling::parallel_default(&defaults, {+                    let mut n = [0.0; 3];+                    n[f.axis] = f.side as f64;+                    n+                })+                .ok_or("the face is not parallel to a default plane")?;+                let h = self.face_coord(f);+                let names = ["Front Plane", "Top Plane", "Right Plane"];+                (+                    defaults[i],+                    h,+                    f.side as f64,+                    json!({"offsetFrom": names[i], "offsetMm": h * 1000.0, "outward": f.side, "pickedBy": "geometry"}),+                    None,+                )+            }+        };+        let name = self.next_name("Sketch");+        let mut report = json!({+            "plane": plane.describe(),+            "resolvedPlane": resolved,+            "createdPlane": made,+            "sketch": name,+            "entities": entities.iter().map(|e| e.describe()).collect::<Vec<_>>(),+            "profileAreaMm2": profile_area(entities) * 1.0e6,+        });+        crate::script::single_entity_fields(entities, &mut report);+        Ok((Pending { name, frame, h, sign, entities: entities.to_vec() }, report))+    }++    /// An extrude or a cut of the pending sketch.+    pub fn extrude(&mut self, p: &Pending, ext: &Extent, cut: bool) -> Result<Value, String> {+        let axis = normal_axis(&p.frame);+        // The op's own direction along the frame's normal: a boss along the+        // reference, a cut against it; reverse flips either.+        let own = p.sign * if cut { -1.0 } else { 1.0 } * if ext.reverse { -1.0 } else { 1.0 };+        let span = modeling::extent_span(p.h, own, ext, self.through(axis));+        let kind = if cut { "Cut-Extrude" } else { "Boss-Extrude" };+        let regs = regions(&p.entities);+        let len = span.1 - span.0;+        let mut delta = 0.0;+        let mut flo = [f64::MAX; 3];+        let mut fhi = [f64::MIN; 3];+        let mut body_of: Option<usize> = None;+        if !cut {+            let name = self.next_name(kind);+            for (k, r) in regs.iter().enumerate() {+                let (lo, hi) = prism_box(&p.frame, r.outer, span);+                let vol = r.area() * len;+                delta += vol;+                for i in 0..3 {+                    flo[i] = flo[i].min(lo[i]);+                    fhi[i] = fhi[i].max(hi[i]);+                }+                let hits: Vec<usize> = (0..self.bodies.len()).filter(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).collect();+                if hits.is_empty() {+                    self.bodies.push(Body {+                        name: if k == 0 { name.clone() } else { format!("{name}[{}]", k + 1) },+                        lo,+                        hi,+                        volume: vol,+                        faces: r.faces(),+                        shape: r.shape(axis),+                        appearance: None,+                    });+                    body_of.get_or_insert(self.bodies.len() - 1);+                } else {+                    let keep = hits[0];+                    for other in hits.iter().skip(1).rev() {+                        let o = self.bodies.remove(*other);+                        let b = &mut self.bodies[keep];+                        for i in 0..3 {+                            b.lo[i] = b.lo[i].min(o.lo[i]);+                            b.hi[i] = b.hi[i].max(o.hi[i]);+                        }+                        b.volume += o.volume;+                        b.faces += o.faces;+                    }+                    let b = &mut self.bodies[keep];+                    for i in 0..3 {+                        b.lo[i] = b.lo[i].min(lo[i]);+                        b.hi[i] = b.hi[i].max(hi[i]);+                    }+                    b.volume += vol;+                    b.faces += r.faces() - 1;+                    b.shape = Shape::Other;+                    body_of.get_or_insert(keep);+                }+            }+            self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: delta, lo: Some(flo), hi: Some(fhi), body: body_of });+            return Ok(json!({"feature": name, "spanMm": [span.0 * 1000.0, span.1 * 1000.0], "volumeChangeMm3": delta * 1.0e9,+                             "depthMm": ext.depth * 1000.0, "throughAll": ext.through_all, "reverse": ext.reverse}));+        }+        // A CUT removes only what lies inside a body.+        let mut removed = 0.0;+        let mut touched: Vec<usize> = Vec::new();+        for r in &regs {+            let (lo, hi) = prism_box(&p.frame, r.outer, span);+            let (u0, v0, u1, v1) = bounds(r.outer);+            let reg_bounds_area = ((u1 - u0) * (v1 - v0)).max(1e-30);+            for (bi, b) in self.bodies.iter_mut().enumerate() {+                let along = overlap_len(lo[axis], hi[axis], b.lo[axis], b.hi[axis]);+                let others: Vec<usize> = (0..3).filter(|i| *i != axis).collect();+                let inplane = overlap_len(lo[others[0]], hi[others[0]], b.lo[others[0]], b.hi[others[0]])+                    * overlap_len(lo[others[1]], hi[others[1]], b.lo[others[1]], b.hi[others[1]]);+                let take = r.area() * (inplane / reg_bounds_area).min(1.0) * along;+                if take > 1e-18 {+                    b.volume = (b.volume - take).max(0.0);+                    b.faces += r.faces() - 1;+                    b.shape = Shape::Other;+                    removed += take;+                    touched.push(bi);+                }+            }+        }+        if removed <= 1e-18 {+            return Err(+                "SOLIDWORKS made no cut (the feature came back null) — nothing was removed. A cut goes along its plane's \+                 −NORMAL (measured: a cut from Top Plane removes −Y); if the material is on the other side, pass \+                 reverse:true, or direction:\"both\" / midPlane:true"+                    .into(),+            );+        }+        let name = self.next_name(kind);+        self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: -removed, lo: None, hi: None, body: touched.first().copied() });+        Ok(json!({"feature": name, "spanMm": [span.0 * 1000.0, span.1 * 1000.0], "volumeChangeMm3": -removed * 1.0e9,+                  "depthMm": ext.depth * 1000.0, "throughAll": ext.through_all, "reverse": ext.reverse}))+    }++    /// The edges a fillet / chamfer selects — de-duplicated, then selected with+    /// SOLIDWORKS' toggle semantics (modelled). `dedupe:false` is the bug the+    /// review found; it is a parameter so a test can show what it does.+    pub fn select_edges(&self, sel: &EdgeSel, last: &Option<String>, dedupe: bool) -> Result<(Vec<EdgeRef>, Value), String> {+        let (listed, how): (Vec<EdgeRef>, Value) = match sel {+            EdgeSel::AtPoints(ps) => (ps.iter().map(|p| self.pick_edge(*p)).collect::<Result<_, _>>()?, json!({"atPoints": ps.len()})),+            EdgeSel::AllOfFeature(f) => {+                let name = if f == "last" { last.clone().ok_or("\"last\" names no feature")? } else { f.clone() };+                let feat = self.feature(&name).ok_or_else(|| format!("no feature named '{name}'"))?;+                let body = feat.body.ok_or_else(|| format!("feature '{name}' made no body"))?;+                // Every face lists its edges, so each edge comes up TWICE.+                let mut refs = Vec::new();+                match self.bodies[body].shape {+                    Shape::Cylinder { axis, .. } => {+                        for _face in 0..2 {+                            for s in [-1, 1] {+                                let other = if axis == 0 { [1usize, 2] } else if axis == 1 { [0, 2] } else { [0, 1] };+                                let _ = other;+                                refs.push(EdgeRef { body, along: axis, sides: [s, s] });+                            }+                        }+                    }+                    _ => {+                        for axis in 0..3 {+                            for side in [-1, 1] {+                                // the 4 edges around face (axis, side)+                                for e in self.all_edges().into_iter().filter(|e| e.body == body && e.along != axis) {+                                    let ax = Self::edge_axes(e);+                                    if let Some(k) = ax.iter().position(|a| *a == axis) {+                                        if e.sides[k] == side {+                                            refs.push(e);+                                        }+                                    }+                                }+                            }+                        }+                    }+                }+                (refs, json!({"allOfFeature": name}))+            }+        };+        let listed_n = listed.len();+        let keep: Vec<EdgeRef> = if dedupe { modeling::dedupe(&listed).into_iter().map(|i| listed[i]).collect() } else { listed };+        let selected = modeling::toggled_selection(&keep);+        if selected.is_empty() {+            return Err(format!(+                "nothing ended up selected ({listed_n} edge references; an edge selected twice is DESELECTED) — SOLIDWORKS returned no feature"+            ));+        }+        let mut how = how;+        how["listed"] = json!(listed_n);+        how["selected"] = json!(selected.len());+        Ok((selected, how))+    }++    /// Fillet or chamfer the selected edges. The volume is modelled for a box+    /// (all 12 edges, or single straight edges) and a cylinder's two circular+    /// edges; anything else is reported as not modelled.+    pub fn edge_feature(&mut self, fillet: bool, size: f64, angle: f64, edges: &[EdgeRef]) -> Result<Value, String> {+        let body = edges.first().map(|e| e.body).ok_or("no edges")?;+        let b = self.bodies[body].clone();+        let dims = modeling::sub(b.hi, b.lo);+        let mut delta: Option<f64> = None;+        match (&b.shape, fillet) {+            (Shape::Box, true) if edges.len() == 12 => {+                let r = size;+                let (a, bb, c) = (dims[0] - 2.0 * r, dims[1] - 2.0 * r, dims[2] - 2.0 * r);+                let rounded = a * bb * c + 2.0 * r * (a * bb + bb * c + a * c) + PI * r * r * (a + bb + c) + 4.0 / 3.0 * PI * r.powi(3);+                delta = Some(rounded - b.volume);+            }+            (Shape::Box, _) => {+                let per = if fillet { (1.0 - PI / 4.0) * size * size } else { 0.5 * size * size * angle.tan() };+                delta = Some(-edges.iter().map(|e| per * self.edge_len(*e)).sum::<f64>());+            }+            (Shape::Cylinder { axis, r: rc }, true) if edges.len() == 2 => {+                let h = dims[*axis];+                let r = size;+                let spandrel = (1.0 - PI / 4.0) * r * r;+                let xbar = r * (5.0 / 6.0 - PI / 4.0) / (1.0 - PI / 4.0);+                delta = Some(-2.0 * spandrel * 2.0 * PI * (rc - xbar) + (PI * rc * rc * h - b.volume));+            }+            _ => {}+        }+        let name = self.next_name(if fillet { "Fillet" } else { "Chamfer" });+        if let Some(d) = delta {+            let bm = &mut self.bodies[body];+            bm.volume += d;+            bm.faces += edges.len() as u32;+            bm.shape = Shape::Other;+        }+        self.features.push(Feature { name: name.clone(), kind: if fillet { "Fillet".into() } else { "Chamfer".into() }, volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body) });+        Ok(json!({"feature": name, "edgesSelected": edges.len(), "volumeChangeMm3": delta.map(|d| d * 1.0e9),+                  "simulated": if delta.is_some() { "closed-form" } else { "not modelled for this shape: volume unchanged" }}))+    }++    pub fn shell(&mut self, thickness: f64, points: &[V3], outward: bool) -> Result<Value, String> {+        let picked: Vec<FaceRef> = points.iter().map(|p| self.pick_face(*p)).collect::<Result<_, _>>()?;+        let faces: Vec<FaceRef> = modeling::dedupe(&picked).into_iter().map(|i| picked[i]).collect();+        let body = faces.first().map(|f| f.body).ok_or("no faces to remove")?;+        let b = self.bodies[body].clone();+        let mut delta = None;+        if b.shape == Shape::Box && !outward {+            let mut inner = 1.0;+            for axis in 0..3 {+                let removed = faces.iter().filter(|f| f.axis == axis).count() as f64;+                inner *= (b.hi[axis] - b.lo[axis]) - thickness * (2.0 - removed);+            }+            delta = Some(-inner.max(0.0));+        }+        let name = self.next_name("Shell");+        if let Some(d) = delta {+            let bm = &mut self.bodies[body];+            bm.volume += d;+            bm.shape = Shape::Other;+        }+        self.features.push(Feature { name: name.clone(), kind: "Shell".into(), volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body) });+        Ok(json!({"feature": name, "thicknessMm": thickness * 1000.0, "facesRemoved": faces.len(), "outward": outward,+                  "simulated": if delta.is_some() { "closed-form" } else { "not modelled for this shape" }}))+    }++    fn add_copies(&mut self, seed: &Feature, boxes: Vec<(V3, V3)>) {+        let body = seed.body.unwrap_or(0);+        for (lo, hi) in boxes {+            let target = (0..self.bodies.len()).find(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).unwrap_or(body);+            if let Some(b) = self.bodies.get_mut(target) {+                for i in 0..3 {+                    b.lo[i] = b.lo[i].min(lo[i]);+                    b.hi[i] = b.hi[i].max(hi[i]);+                }+                b.volume += seed.volume;+                b.shape = Shape::Other;+            }+        }+    }++    pub fn linear_pattern(&mut self, seed_name: &str, dirs: &[(Axis, u32, f64)]) -> Result<Value, String> {+        let seed = self.feature(seed_name).cloned().ok_or_else(|| format!("no feature named '{seed_name}' to pattern"))?;+        let (lo, hi) = match (seed.lo, seed.hi) {+            (Some(l), Some(h)) => (l, h),+            _ => return Err(format!("'{seed_name}' added no material to pattern")),+        };+        let (a1, n1, s1) = dirs[0];+        let (a2, n2, s2) = dirs.get(1).copied().unwrap_or((a1, 1, 0.0));+        let mut boxes = Vec::new();+        for i in 0..n1 {+            for j in 0..n2 {+                if i == 0 && j == 0 {+                    continue;+                }+                let mut d = [0.0; 3];+                d[axis_index(a1)] += i as f64 * s1;+                d[axis_index(a2)] += j as f64 * s2;+                boxes.push((modeling::add(lo, d), modeling::add(hi, d)));+            }+        }+        let n = boxes.len() as u32 + 1;+        self.add_copies(&seed, boxes);+        let name = self.next_name("LPattern");+        self.features.push(Feature { name: name.clone(), kind: "LPattern".into(), volume: seed.volume * (n as f64 - 1.0), lo: None, hi: None, body: seed.body });+        Ok(json!({"feature": name, "seed": seed_name, "instances": n}))+    }++    pub fn circular_pattern(&mut self, seed_name: &str, axis: Axis, count: u32, angle: f64) -> Result<Value, String> {+        let seed = self.feature(seed_name).cloned().ok_or_else(|| format!("no feature named '{seed_name}' to pattern"))?;+        let (lo, hi) = match (seed.lo, seed.hi) {+            (Some(l), Some(h)) => (l, h),+            _ => return Err(format!("'{seed_name}' added no material to pattern")),+        };+        let ax = axis_index(axis);+        let step = if angle >= 2.0 * PI - 1e-9 { angle / count as f64 } else { angle / (count.saturating_sub(1).max(1)) as f64 };+        let rot = |p: V3, t: f64| -> V3 {+            let (s, c) = t.sin_cos();+            match ax {+                0 => [p[0], c * p[1] - s * p[2], s * p[1] + c * p[2]],+                1 => [c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2]],+                _ => [c * p[0] - s * p[1], s * p[0] + c * p[1], p[2]],+            }+        };+        let mut boxes = Vec::new();+        for k in 1..count {+            let t = step * k as f64;+            let mut blo = [f64::MAX; 3];+            let mut bhi = [f64::MIN; 3];+            for x in [lo[0], hi[0]] {+                for y in [lo[1], hi[1]] {+                    for z in [lo[2], hi[2]] {+                        let q = rot([x, y, z], t);+                        for i in 0..3 {+                            blo[i] = blo[i].min(q[i]);+                            bhi[i] = bhi[i].max(q[i]);+                        }+                    }+                }+            }+            boxes.push((blo, bhi));+        }+        self.add_copies(&seed, boxes);+        let name = self.next_name("CirPattern");+        self.features.push(Feature { name: name.clone(), kind: "CirPattern".into(), volume: seed.volume * (count as f64 - 1.0), lo: None, hi: None, body: seed.body });+        Ok(json!({"feature": name, "seed": seed_name, "instances": count, "angleDeg": angle.to_degrees()}))+    }++    /// Appearance. A body target on a SINGLE-body part is written at the part+    /// level (IBody2::MaterialPropertyValues2 is documented for non-base bodies).+    pub fn set_appearance(&mut self, target: &AppearanceTarget, rgb: &[f64; 3]) -> Result<Value, String> {+        let c = rgb255(rgb);+        let route = match target {+            AppearanceTarget::Part => {+                self.appearance = Some(c);+                "part"+            }+            AppearanceTarget::Body(name) => {+                if self.bodies.len() <= 1 {+                    self.appearance = Some(c);+                    "part (single-body part)"+                } else {+                    let b = self.bodies.iter_mut().find(|b| &b.name == name).ok_or_else(|| format!("no solid body named '{name}'"))?;+                    b.appearance = Some(c);+                    "body"+                }+            }+            AppearanceTarget::FaceAt(p) => {+                let f = self.pick_face(*p)?;+                let _ = f;+                self.face_appearances.retain(|(q, _)| q != p);+                self.face_appearances.push((*p, c));+                "face"+            }+        };+        Ok(json!({"rgb": c, "route": route, "readBack": c}))+    }++    /// A material: and, as SOLIDWORKS does, the part's (and bodies') own+    /// appearance is GONE afterwards. The caller re-applies it.+    pub fn set_material(&mut self, name: &str) {+        self.material = Some(name.to_string());+        self.appearance = None;+        for b in &mut self.bodies {+            b.appearance = None;+        }+    }++    /// The bodies, in swx_inspect_bodies' shape.+    pub fn bodies_json(&self) -> Vec<Value> {+        self.bodies+            .iter()+            .map(|b| {+                let size = modeling::sub(b.hi, b.lo);+                json!({+                    "name": b.name,+                    "bbox": {"minMm": [mm(b.lo[0]), mm(b.lo[1]), mm(b.lo[2])], "maxMm": [mm(b.hi[0]), mm(b.hi[1]), mm(b.hi[2])]},+                    "sizeMm": {"x": mm(size[0]), "y": mm(size[1]), "z": mm(size[2])},+                    "volumeMm3": (b.volume * 1.0e9 * 1000.0).round() / 1000.0,+                    "areaMm2": (2.0 * (size[0] * size[1] + size[1] * size[2] + size[0] * size[2]) * 1.0e6 * 1000.0).round() / 1000.0,+                    "massKg": Value::Null,+                    "centerOfMassMm": [mm((b.lo[0] + b.hi[0]) / 2.0), mm((b.lo[1] + b.hi[1]) / 2.0), mm((b.lo[2] + b.hi[2]) / 2.0)],+                    "faceCount": b.faces,+                    "visible": true,+                    "isSolid": true,+                    "appearanceRgb": b.appearance.or(self.appearance),+                    "simulated": "mock arithmetic model (mock_model.rs), not a kernel",+                })+            })+            .collect()+    }++    pub fn volume(&self) -> f64 {+        self.bodies.iter().map(|b| b.volume).sum()+    }+}++/// Run one geometry op of a script on the model. `active` is whether the+/// target is SOLIDWORKS' active document (selection works only there);+/// `dedupe` and `reselect` exist so tests can show what the bridge's own+/// safeguards prevent.+pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result<Value, String> {+    let needs_selection = matches!(+        op,+        Op::Sketch { plane: PlaneRef::Face { .. }, .. }+            | Op::Fillet { .. }+            | Op::Chamfer { .. }+            | Op::Shell { .. }+            | Op::LinearPattern { .. }+            | Op::CircularPattern { .. }+            | Op::SetAppearance { target: AppearanceTarget::FaceAt(_), .. }+    );+    if needs_selection && !active {+        return Err("selection works only in the ACTIVE document, and this one is not active — nothing could be selected".into());+    }+    let out = match op {+        Op::Sketch { plane, entities, .. } => {+            let (p, report) = model.sketch(plane, entities)?;+            run.pending = Some(p);+            run.sketch_selected = true;+            report+        }+        Op::Extrude(ext) | Op::Cut(ext) => {+            let p = run.pending.take().ok_or("no sketch to consume")?;+            // A selection-clearing op since the sketch would have left NOTHING+            // selected; the bridge re-selects the sketch by name first.+            if !run.sketch_selected {+                return Err(format!("the sketch '{}' was no longer selected — SOLIDWORKS returned no feature", p.name));+            }+            run.sketch_selected = false;+            let mut r = model.extrude(&p, ext, matches!(op, Op::Cut(_)))?;+            r["sketch"] = json!(p.name);+            r+        }+        Op::Fillet { radius, edges } => {+            run.sketch_selected = false;+            let (sel, how) = model.select_edges(edges, &run.last_feature, true)?;+            let mut r = model.edge_feature(true, *radius, 0.0, &sel)?;+            r["edges"] = how;+            r["route"] = json!("FeatureFillet3");+            r+        }+        Op::Chamfer { distance, angle, edges } => {+            run.sketch_selected = false;+            let (sel, how) = model.select_edges(edges, &run.last_feature, true)?;+            let mut r = model.edge_feature(false, *distance, *angle, &sel)?;+            r["edges"] = how;+            r+        }+        Op::Shell { thickness, faces, outward } => {+            run.sketch_selected = false;+            model.shell(*thickness, faces, *outward)?+        }+        Op::LinearPattern { feature, dirs } => {+            run.sketch_selected = false;+            let seed = if feature == "last" { run.last_feature.clone().ok_or("\"last\" names no feature")? } else { feature.clone() };+            model.linear_pattern(&seed, dirs)?+        }+        Op::CircularPattern { feature, axis, count, angle } => {+            run.sketch_selected = false;+            let seed = if feature == "last" { run.last_feature.clone().ok_or("\"last\" names no feature")? } else { feature.clone() };+            model.circular_pattern(&seed, *axis, *count, *angle)?+        }+        Op::SetAppearance { rgb, target } => {+            if matches!(target, AppearanceTarget::FaceAt(_)) {+                run.sketch_selected = false;+            }+            let r = model.set_appearance(target, rgb)?;+            run.appearances.retain(|(t, _)| t != target);+            run.appearances.push((target.clone(), *rgb));+            r+        }+        Op::SetMaterial { name, .. } => {+            model.set_material(name);+            // RE-APPLY every appearance this script set: a material wipes it.+            let mut again = Vec::new();+            for (t, rgb) in run.appearances.clone() {+                model.set_appearance(&t, &rgb)?;+                again.push(json!({"target": format!("{t:?}"), "rgb": rgb255(&rgb)}));+            }+            json!({"material": name, "appearanceReapplied": again})+        }+        _ => return Ok(Value::Null),+    };+    if op.makes_feature() {+        if let Some(f) = out.get("feature").and_then(|f| f.as_str()) {+            run.last_feature = Some(f.to_string());+        }+    }+    Ok(out)+}+ #[cfg(test)] mod tests {     use super::*;+    use crate::script::{Direction, Extent};++    fn ext(depth: f64) -> Extent {+        Extent { depth, depth2: depth, through_all: false, reverse: false, direction: Direction::One }+    }++    fn block() -> (Model, Run) {+        let mut m = Model::default();+        let mut run = Run::default();+        apply(&mut m, &mut run, &Op::Sketch { shorthand: "sketch_rect", plane: PlaneRef::Named("Top Plane".into()),+            entities: vec![Entity::Rect { x0: -0.01, y0: -0.01, x1: 0.01, y1: 0.01 }] }, true).unwrap();+        apply(&mut m, &mut run, &Op::Extrude(ext(0.01)), true).unwrap();+        (m, run)+    }++    #[test]+    fn a_hidden_face_is_picked_by_geometry_and_the_view_would_pick_another() {+        let (m, _) = block();+        let bottom = [0.0, 0.0, 0.0];+        let g = m.pick_face(bottom).unwrap();+        assert_eq!((g.axis, g.side), (1, -1), "geometry: the BOTTOM face");+        let v = m.view_pick(bottom).unwrap();+        assert_ne!((v.axis, v.side), (1, -1), "the view would pick a visible face instead");+        assert!(m.pick_face([0.0, 0.01, 0.01]).unwrap_err().contains("2 faces"), "an edge point is ambiguous, never guessed");+    }++    #[test]+    fn concentric_circles_are_a_ring_and_cuts_remove_only_material() {+        assert!((profile_area(&[Entity::Circle { cx: 0.0, cy: 0.0, r: 3.255 }, Entity::Circle { cx: 0.0, cy: 0.0, r: 2.4 }])+            - PI * (3.255f64.powi(2) - 2.4f64.powi(2)))+            .abs()+            < 1e-9);+        let (mut m, mut run) = block();+        // Michael's case: a default cut from Top Plane goes −Y, where there is no material.+        apply(&mut m, &mut run, &Op::Sketch { shorthand: "sketch_rect", plane: PlaneRef::Named("Top Plane".into()),+            entities: vec![Entity::Rect { x0: -0.005, y0: -0.005, x1: 0.005, y1: 0.005 }] }, true).unwrap();+        assert!(apply(&mut m, &mut run, &Op::Cut(ext(0.005)), true).unwrap_err().contains("null"));+    }++    #[test]+    fn duplicate_edges_toggle_off_unless_deduplicated() {+        let (m, run) = block();+        let sel = EdgeSel::AllOfFeature("Boss-Extrude1".into());+        assert_eq!(m.select_edges(&sel, &run.last_feature, true).unwrap().0.len(), 12);+        assert!(m.select_edges(&sel, &run.last_feature, false).is_err(), "24 references, each edge twice: nothing stays selected");+    }++    #[test]+    fn a_material_wipes_the_appearance_and_the_run_reapplies_it() {+        let (mut m, mut run) = block();+        apply(&mut m, &mut run, &Op::SetAppearance { rgb: [200.0 / 255.0, 0.0, 0.0], target: AppearanceTarget::Part }, true).unwrap();+        let r = apply(&mut m, &mut run, &Op::SetMaterial { name: "ABS".into(), database: "x".into() }, true).unwrap();+        assert_eq!(m.appearance, Some([200, 0, 0]));+        assert_eq!(r["appearanceReapplied"].as_array().map(|a| a.len()), Some(1));+        let mut bare = m.clone();+        bare.set_material("ABS");+        assert_eq!(bare.appearance, None, "the material alone wipes it");+    }      #[test]-    fn concentric_circles_are_a_ring_and_a_rect_with_holes_is_a_plate() {-        let ring = [Entity::Circle { cx: 0.0, cy: 0.0, r: 3.255 }, Entity::Circle { cx: 0.0, cy: 0.0, r: 2.4 }];-        let a = profile_area(&ring);-        assert!((a - std::f64::consts::PI * (3.255f64.powi(2) - 2.4f64.powi(2))).abs() < 1e-9);-        let side = [Entity::Circle { cx: 10.0, cy: 0.0, r: 1.0 }, Entity::Circle { cx: -10.0, cy: 0.0, r: 1.0 }];-        assert!((profile_area(&side) - 2.0 * std::f64::consts::PI).abs() < 1e-9, "side by side both add");-        let top = Frame { normal: 1, offset: 0.0096 };-        assert_eq!(top.to_model(1.0, 2.0, 0.5), [1.0, 0.5, -2.0], "Top Plane: v -> -Z");+    fn a_boss_from_the_bottom_face_goes_outward() {+        let (mut m, mut run) = block();+        apply(&mut m, &mut run, &Op::Sketch { shorthand: "sketch_circle", plane: PlaneRef::Face { point: [0.0, 0.0, 0.0] },+            entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.003 }] }, true).unwrap();+        apply(&mut m, &mut run, &Op::Extrude(ext(0.003)), true).unwrap();+        assert!((m.bodies[0].lo[1] + 0.003).abs() < 1e-12, "y down to -3 mm: {:?}", m.bodies[0].lo);     } }
src/comhost/mod.rs+8−3
@@ -64,19 +64,23 @@ pub struct SidecarError {     /// HRESULT and EXCEPINFO of the COM call that actually broke. Prose is what a     /// person reads; this is what a person DIAGNOSES from.     pub data: Option<serde_json::Value>,+    /// A DOCUMENT THE CALL CREATED BEFORE IT FAILED (the host's+    /// `createdBeforeFailing`): a script whose new_part ran and whose later step+    /// failed still made that part, and the server must own it too (0.10.2).+    pub created: Option<serde_json::Value>, }  impl SidecarError {     fn new(message: impl Into<String>, detail: Option<String>) -> Self {-        Self { message: message.into(), detail, code: None, timed_out: false, data: None }+        Self { message: message.into(), detail, code: None, timed_out: false, data: None, created: None }     }      fn timeout(message: impl Into<String>, detail: Option<String>) -> Self {-        Self { message: message.into(), detail, code: None, timed_out: true, data: None }+        Self { message: message.into(), detail, code: None, timed_out: true, data: None, created: None }     }      fn coded(message: impl Into<String>, detail: Option<String>, code: Option<String>) -> Self {-        Self { message: message.into(), detail, code, timed_out: false, data: None }+        Self { message: message.into(), detail, code, timed_out: false, data: None, created: None }     } } @@ -274,6 +278,7 @@ impl ComHost {                             resp.get("errorCode").and_then(|v| v.as_str()).map(|s| s.to_string());                         let mut e = SidecarError::coded(message, detail, code);                         e.data = resp.get("errorData").filter(|v| !v.is_null()).cloned();+                        e.created = resp.get("createdBeforeFailing").filter(|v| !v.is_null()).cloned();                         // The HOST may have given up on the call itself.                         e.timed_out = resp.get("timedOut").and_then(|v| v.as_bool()).unwrap_or(false);                         return Err(e);
src/comhost/variant.rs+10−1
@@ -23,7 +23,7 @@ use windows::Win32::System::Ole::{ }; use windows::Win32::System::Variant::{     VariantChangeType, VariantClear, VariantCopy, VARENUM, VARIANT, VT_ARRAY, VT_BOOL, VT_BSTR,-    VT_BYREF, VT_DISPATCH, VT_EMPTY, VT_I4, VT_NULL, VT_R8, VT_UNKNOWN, VT_VARIANT,+    VT_BYREF, VT_DISPATCH, VT_EMPTY, VT_I4, VT_NULL, VT_R8, VT_UI1, VT_UNKNOWN, VT_VARIANT, };  use super::host::HostError;@@ -334,6 +334,15 @@ impl Var {                     {                         out.push(Var::bstr(&b.to_string()));                     }+                } else if elem_vt == VT_UI1.0 {+                    // A byte array: IModelDocExtension::GetPersistReference3's+                    // persistent id (0.10.2), the de-duplication key.+                    let mut b = 0u8;+                    if SafeArrayGetElement(psa, idx.as_ptr(), &mut b as *mut _ as *mut c_void)+                        .is_ok()+                    {+                        out.push(Var::i4(b as i32));+                    }                 } else if elem_vt == VT_I4.0 {                     let mut n = 0i32;                     if SafeArrayGetElement(psa, idx.as_ptr(), &mut n as *mut _ as *mut c_void)
src/lib.rs+1
@@ -37,6 +37,7 @@ pub mod httpc; pub mod jobs; pub mod launch; pub mod launchos;+pub mod modeling; pub mod ledger; pub mod owned; pub mod perflog;
src/modeling.rsadded+404
@@ -0,0 +1,404 @@+//! THE MODELLING DSL'S GEOMETRIC DECISIONS (0.10.2) — pure functions BOTH+//! backends use: the COM host (with numbers read from SOLIDWORKS) and the+//! simulator (with its own model). Pure, so every rule is unit-tested here.+//!+//! An independent review of 0.10.x (2026-10-13) found that the API calls were+//! right and the BEHAVIOUR was the risk: picks made through the view, a face's+//! normal sign dropped, duplicate edges toggling themselves off, a negative+//! offset plane's orientation unverified. The answers live here:+//!   * a face or edge is picked GEOMETRICALLY — the entity whose closest point+//!     to the caller's point is within a tolerance — never through the view;+//!   * a sketch's coordinates are the BASE PLANE's own axes (its IRefPlane+//!     transform), mapped into whatever sketch SOLIDWORKS opened through that+//!     sketch's ModelToSketchTransform — so a flipped offset plane or a face's+//!     own sketch axes cannot mirror the geometry;+//!   * an extrude goes along a REFERENCE direction (the face's outward normal,+//!     the base plane's normal) and a cut against it, whatever way the sketch's+//!     own normal points: `Dir` is flipped when they disagree;+//!   * entities are de-duplicated before they are selected, because selecting an+//!     already-selected entity DESELECTS it.++use crate::script::{Direction, Entity, Extent};++pub type V3 = [f64; 3];++pub fn dot(a: V3, b: V3) -> f64 {+    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]+}++pub fn add(a: V3, b: V3) -> V3 {+    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]+}++pub fn sub(a: V3, b: V3) -> V3 {+    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]+}++pub fn scale(a: V3, k: f64) -> V3 {+    [a[0] * k, a[1] * k, a[2] * k]+}++pub fn norm(a: V3) -> f64 {+    dot(a, a).sqrt()+}++/// An IMathTransform's `ArrayData`: the rotation's ROWS are the images of the+/// local X, Y and Z axes (indices 0-2, 3-5, 6-8), then the translation (9-11)+/// and the scale (12). The same layout `swx_add_component` writes — measured+/// live (component placement, 2026-09).+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct Xform {+    pub rows: [V3; 3],+    pub t: V3,+    pub s: f64,+}++impl Xform {+    pub fn from_array_data(a: &[f64]) -> Option<Xform> {+        if a.len() < 12 || a.iter().take(12).any(|v| !v.is_finite()) {+            return None;+        }+        let s = a.get(12).copied().filter(|s| s.is_finite() && *s != 0.0).unwrap_or(1.0);+        Some(Xform { rows: [[a[0], a[1], a[2]], [a[3], a[4], a[5]], [a[6], a[7], a[8]]], t: [a[9], a[10], a[11]], s })+    }++    pub fn identity() -> Xform {+        Xform { rows: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], t: [0.0; 3], s: 1.0 }+    }++    /// p' = (p.x·row0 + p.y·row1 + p.z·row2)·scale + t.+    pub fn apply(&self, p: V3) -> V3 {+        let r = add(add(scale(self.rows[0], p[0]), scale(self.rows[1], p[1])), scale(self.rows[2], p[2]));+        add(scale(r, self.s), self.t)+    }++    /// The inverse of a rigid transform (orthonormal rows, uniform scale):+    /// p = ((p' − t) / s)·Rᵀ.+    pub fn inverse(&self) -> Xform {+        let r = self.rows;+        // Rows of Rᵀ are the columns of R.+        let rows = [[r[0][0], r[1][0], r[2][0]], [r[0][1], r[1][1], r[2][1]], [r[0][2], r[1][2], r[2][2]]];+        let inv_s = if self.s != 0.0 { 1.0 / self.s } else { 1.0 };+        let neg_t = scale(self.t, -inv_s);+        let t = add(add(scale(rows[0], neg_t[0]), scale(rows[1], neg_t[1])), scale(rows[2], neg_t[2]));+        Xform { rows, t, s: inv_s }+    }++    /// The direction (in the SOURCE space) that this transform maps onto its+    /// target's +Z: for a ModelToSketch transform, the sketch's normal in model+    /// space. With orthonormal rows that is the third COLUMN.+    pub fn source_z(&self) -> V3 {+        [self.rows[0][2], self.rows[1][2], self.rows[2][2]]+    }+}++/// A plane's frame in model space: origin, its own X and Y axes (a sketch on+/// it draws in these), and its normal.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct PlaneFrame {+    pub origin: V3,+    pub x: V3,+    pub y: V3,+    pub n: V3,+}++impl PlaneFrame {+    /// From the plane's IRefPlane::Transform (plane-local -> model).+    pub fn from_plane_xform(x: &Xform) -> PlaneFrame {+        PlaneFrame { origin: x.t, x: x.rows[0], y: x.rows[1], n: x.rows[2] }+    }++    /// The default planes as the documented conventions state them — Front+    /// (x→X, y→Y, normal +Z), Top (x→X, y→−Z, normal +Y: MEASURED, issue #1),+    /// Right (x→−Z, y→Y, normal +X). The simulator's frames; the COM host reads+    /// the real ones from each plane's transform.+    pub fn defaults() -> [PlaneFrame; 3] {+        [+            PlaneFrame { origin: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 1.0, 0.0], n: [0.0, 0.0, 1.0] },+            PlaneFrame { origin: [0.0; 3], x: [1.0, 0.0, 0.0], y: [0.0, 0.0, -1.0], n: [0.0, 1.0, 0.0] },+            PlaneFrame { origin: [0.0; 3], x: [0.0, 0.0, -1.0], y: [0.0, 1.0, 0.0], n: [1.0, 0.0, 0.0] },+        ]+    }++    /// The model point of sketch (u, v) on the plane parallel to this one at+    /// height `h` along its normal.+    pub fn point(&self, u: f64, v: f64, h: f64) -> V3 {+        add(add(add(self.origin, scale(self.n, h)), scale(self.x, u)), scale(self.y, v))+    }++    /// How far `p` is along this plane's normal.+    pub fn height_of(&self, p: V3) -> f64 {+        dot(sub(p, self.origin), self.n)+    }+}++impl PlaneFrame {+    /// Do two frames agree (same axes and normal, origins within 1 µm)?+    pub fn same_as(&self, o: &PlaneFrame) -> bool {+        let close = |a: V3, b: V3, tol: f64| norm(sub(a, b)) < tol;+        close(self.x, o.x, 1e-6) && close(self.y, o.y, 1e-6) && close(self.n, o.n, 1e-6) && close(self.origin, o.origin, 1e-6)+    }+}++/// THE SKETCH MAPPING, with its own check: entities through `m2s`; if they do+/// not land on the sketch plane (|z| > 1e-7 m), through its inverse — an+/// API whose transform runs the other way is caught, not obeyed. Returns the+/// mapped entities and whether the inverse was used; Err when neither lands.+pub fn map_entities(frame: &PlaneFrame, h: f64, entities: &[Entity], m2s: &Xform) -> Result<(Vec<Entity>, bool), f64> {+    let attempt = |t: &Xform| -> (Vec<Entity>, f64) {+        let mut out = Vec::new();+        let mut worst: f64 = 0.0;+        for e in entities {+            let (m, z) = entity_to_sketch(frame, h, e, t);+            worst = worst.max(z);+            out.push(m);+        }+        (out, worst)+    };+    let (fwd, zf) = attempt(m2s);+    if zf <= 1e-7 {+        return Ok((fwd, false));+    }+    let (inv, zi) = attempt(&m2s.inverse());+    if zi <= 1e-7 {+        return Ok((inv, true));+    }+    Err(zf.min(zi))+}++/// Which default plane (0 Front, 1 Top, 2 Right) a caller's name means —+/// English names and their short forms; a localized install's own names are+/// matched by the caller against the first three RefPlane features.+pub fn default_index(name: &str) -> Option<usize> {+    match name.trim().to_ascii_lowercase().as_str() {+        "front plane" | "front" => Some(0),+        "top plane" | "top" => Some(1),+        "right plane" | "right" => Some(2),+        _ => None,+    }+}++/// The default plane parallel to a face normal.+pub fn parallel_default(frames: &[PlaneFrame; 3], n: V3) -> Option<usize> {+    let len = norm(n);+    if len < 1e-9 {+        return None;+    }+    (0..3).find(|i| (dot(frames[*i].n, n) / len).abs() > 0.999)+}++/// An entity drawn on `frame` at height `h`, mapped into a sketch through its+/// model-to-sketch transform. Returns the sketch-space entity and how far off+/// the sketch plane (|z|) the mapping put it — anything but ~0 means the+/// transform is not what it should be, and nothing is drawn.+pub fn entity_to_sketch(frame: &PlaneFrame, h: f64, e: &Entity, m2s: &Xform) -> (Entity, f64) {+    match *e {+        Entity::Rect { x0, y0, x1, y1 } => {+            let a = m2s.apply(frame.point(x0, y0, h));+            let b = m2s.apply(frame.point(x1, y1, h));+            (Entity::Rect { x0: a[0], y0: a[1], x1: b[0], y1: b[1] }, a[2].abs().max(b[2].abs()))+        }+        Entity::Circle { cx, cy, r } => {+            let c = m2s.apply(frame.point(cx, cy, h));+            (Entity::Circle { cx: c[0], cy: c[1], r: r * m2s.s.abs() }, c[2].abs())+        }+    }+}++/// THE `Dir` FLAG for FeatureExtrusion3 / FeatureCut4. SOLIDWORKS extrudes a+/// boss ALONG the sketch normal and cuts AGAINST it ("The default sketch normal+/// is the same as the face or plane normal… boss… along the sketch normal; cut…+/// opposite"). The DSL's rule is about a REFERENCE direction — a face's outward+/// normal, an offset plane's base normal: a boss goes along it, a cut against+/// it. When the sketch's own normal points the other way (a plane SOLIDWORKS+/// flipped), `Dir` is flipped to keep the rule; the caller's `reverse` flips it+/// again. Returns (dir, flipped).+pub fn dir_flag(sketch_normal: Option<V3>, reference: Option<V3>, reverse: bool) -> (bool, bool) {+    let flipped = match (sketch_normal, reference) {+        (Some(s), Some(r)) => dot(s, r) < 0.0,+        _ => false,+    };+    (flipped ^ reverse, flipped)+}++/// Why a geometric pick found no single entity.+#[derive(Clone, Debug, PartialEq)]+pub enum PickError {+    /// Nothing within the tolerance; the nearest was this far (metres).+    None(Option<f64>),+    /// The point lies on several entities (an edge between faces, a vertex+    /// between edges) — which one is meant cannot be decided.+    Ambiguous(usize),+}++/// THE GEOMETRIC PICK: `cands` are (index, distance from the caller's point to+/// the entity's closest point, outward offset — the point's height above the+/// face along its normal, 0 for edges). Within `tol`: one is the pick; several+/// prefer those the point is not BEHIND (outward >= -tol); still several is+/// ambiguous. Never "the first face along the view ray".+pub fn pick(cands: &[(usize, f64, f64)], tol: f64) -> Result<usize, PickError> {+    let near: Vec<&(usize, f64, f64)> = cands.iter().filter(|c| c.1 <= tol).collect();+    match near.len() {+        0 => Err(PickError::None(cands.iter().map(|c| c.1).fold(None, |m: Option<f64>, d| Some(m.map_or(d, |m| m.min(d)))))),+        1 => Ok(near[0].0),+        _ => {+            let front: Vec<&&(usize, f64, f64)> = near.iter().filter(|c| c.2 >= -tol).collect();+            if front.len() == 1 {+                Ok(front[0].0)+            } else {+                Err(PickError::Ambiguous(near.len()))+            }+        }+    }+}++/// Indices of the FIRST occurrence of each key — the de-duplication every+/// multi-entity selection goes through (a box's 6 faces list its 12 edges 24+/// times; an already-selected entity selected again is DESELECTED).+pub fn dedupe<K: PartialEq>(keys: &[K]) -> Vec<usize> {+    let mut out: Vec<usize> = Vec::new();+    for (i, k) in keys.iter().enumerate() {+        if !out.iter().any(|j| keys[*j] == *k) {+            out.push(i);+        }+    }+    out+}++/// What SOLIDWORKS ends up with selected after selecting `keys` one by one with+/// Append: an entity selected twice is deselected (the documented toggle). The+/// simulator MODELS the hazard with this.+pub fn toggled_selection<K: PartialEq + Clone>(keys: &[K]) -> Vec<K> {+    let mut sel: Vec<K> = Vec::new();+    for k in keys {+        if let Some(i) = sel.iter().position(|s| s == k) {+            sel.remove(i);+        } else {+            sel.push(k.clone());+        }+    }+    sel+}++/// The span along a plane's normal an extrude or cut covers, given the+/// direction its DEPTH goes (`own`: +1 or −1 along the normal — the op's own+/// direction after the sketch normal, the boss/cut rule and `reverse`).+/// `direction:"both"`: `depthMm` along the op's own direction, `depth2Mm` the+/// other way (SOLIDWORKS' D1 / D2). Through all spans `through` (the part's+/// extent along the normal) in the op's direction.+pub fn extent_span(o: f64, own: f64, e: &Extent, through: (f64, f64)) -> (f64, f64) {+    let sorted = |a: f64, b: f64| if a <= b { (a, b) } else { (b, a) };+    if e.through_all {+        return match e.direction {+            Direction::One => {+                if own > 0.0 { (o, through.1.max(o)) } else { (through.0.min(o), o) }+            }+            _ => (through.0.min(o), through.1.max(o)),+        };+    }+    match e.direction {+        Direction::One => sorted(o, o + own * e.depth),+        Direction::MidPlane => (o - e.depth / 2.0, o + e.depth / 2.0),+        Direction::Both => sorted(o + own * e.depth, o - own * e.depth2),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn transforms_planes_and_the_sketch_mapping() {+        let id = Xform::identity();+        assert_eq!(id.apply([1.0, 2.0, 3.0]), [1.0, 2.0, 3.0]);+        let [front, top, right] = PlaneFrame::defaults();+        assert_eq!(top.point(1.0, 2.0, 0.5), [1.0, 0.5, -2.0], "Top: v -> -Z (measured)");+        assert_eq!(front.point(1.0, 2.0, 0.5), [1.0, 2.0, 0.5]);+        assert_eq!(right.point(1.0, 2.0, 0.5), [0.5, 2.0, -1.0]);+        // A sketch whose own axes are the plane's: model -> sketch is the+        // inverse of the plane's transform. For Top at height 9.6 (a translated+        // plane), a point drawn at (u, v) comes back as (u, v, 0).+        let h = 9.6;+        let m2s = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, -1.0, 0.0]], t: [0.0, 0.0, 0.0], s: 1.0 };+        // rows: model X -> sketch x; model Y -> sketch -z... build it honestly:+        // sketch = (X, -Z, Y - h). Rows are images of model axes in sketch space.+        let m2s = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]], t: [0.0, 0.0, -h], ..m2s };+        let (e, z) = entity_to_sketch(&top, h, &Entity::Circle { cx: 3.0, cy: 4.0, r: 1.0 }, &m2s);+        assert_eq!(e, Entity::Circle { cx: 3.0, cy: 4.0, r: 1.0 });+        assert!(z < 1e-12);+        assert_eq!(m2s.source_z(), [0.0, 1.0, 0.0], "that sketch's normal is +Y");+        // A FLIPPED plane (normal −Y, x kept, y mirrored): the same model point+        // lands at a mirrored sketch y — and is still drawn where it belongs.+        let flipped = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]], t: [0.0, 0.0, h], s: 1.0 };+        let (e, z) = entity_to_sketch(&top, h, &Entity::Rect { x0: 0.0, y0: 0.0, x1: 4.0, y1: 6.0 }, &flipped);+        assert_eq!(e, Entity::Rect { x0: 0.0, y0: 0.0, x1: 4.0, y1: -6.0 });+        assert!(z < 1e-12);+        assert_eq!(flipped.source_z(), [0.0, -1.0, 0.0]);+        assert_eq!(default_index("Top Plane"), Some(1));+        assert_eq!(default_index("Ebene oben"), None);+        assert_eq!(parallel_default(&PlaneFrame::defaults(), [0.0, -1.0, 0.0]), Some(1));+        assert_eq!(parallel_default(&PlaneFrame::defaults(), [0.7, 0.7, 0.0]), None);+    }++    #[test]+    fn a_transform_that_runs_the_other_way_is_caught_and_inverted() {+        let top = PlaneFrame::defaults()[1];+        let h = 0.01;+        // A sketch-to-model transform handed over by mistake: model = (sx, h, -sy).+        let s2m = Xform { rows: [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]], t: [0.0, h, 0.0], s: 1.0 };+        let es = [Entity::Circle { cx: 0.003, cy: 0.004, r: 0.001 }];+        let (out, inverted) = map_entities(&top, h, &es, &s2m).unwrap();+        assert!(inverted);+        assert_eq!(out[0], Entity::Circle { cx: 0.003, cy: 0.004, r: 0.001 });+        // ...and the forward one is used when it is right.+        let (out, inverted) = map_entities(&top, h, &es, &s2m.inverse()).unwrap();+        assert!(!inverted);+        assert!(matches!(out[0], Entity::Circle { cx, cy, .. } if (cx - 0.003).abs() < 1e-12 && (cy - 0.004).abs() < 1e-12));+        assert!(PlaneFrame::defaults()[0].same_as(&PlaneFrame::defaults()[0]));+        assert!(!PlaneFrame::defaults()[0].same_as(&PlaneFrame::defaults()[1]));+    }++    #[test]+    fn dir_follows_the_reference_whatever_the_sketch_normal() {+        // Sketch normal agrees with the reference: no flip.+        assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, 1.0, 0.0]), false), (false, false));+        // A bottom face's sketch on a plane SOLIDWORKS oriented +Y while the face+        // looks −Y: flipped, so the boss goes OUTWARD.+        assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, -1.0, 0.0]), false), (true, true));+        // ...and the caller's reverse flips it back.+        assert_eq!(dir_flag(Some([0.0, 1.0, 0.0]), Some([0.0, -1.0, 0.0]), true), (false, true));+        assert_eq!(dir_flag(None, None, true), (true, false));+    }++    #[test]+    fn picks_are_geometric_and_never_guess() {+        // One face within tolerance: it.+        assert_eq!(pick(&[(0, 5.0, 0.0), (1, 1e-9, 0.0), (2, 3.0, 0.0)], 1e-6), Ok(1));+        // A point on an edge between two faces, slightly in front of one.+        assert_eq!(pick(&[(0, 1e-9, 1e-9), (1, 1e-9, -1e-3)], 1e-6), Ok(0));+        // Truly on both: ambiguous, never a guess.+        assert_eq!(pick(&[(0, 0.0, 0.0), (1, 0.0, 0.0)], 1e-6), Err(PickError::Ambiguous(2)));+        assert_eq!(pick(&[(0, 0.004, 0.0)], 1e-6), Err(PickError::None(Some(0.004))));+    }++    #[test]+    fn duplicates_are_removed_and_their_toggle_is_modelled() {+        let keys = ["e1", "e2", "e1", "e3", "e2"];+        assert_eq!(dedupe(&keys), vec![0, 1, 3]);+        assert_eq!(toggled_selection(&keys), vec!["e3"], "selected twice = deselected");+        let deduped: Vec<&str> = dedupe(&keys).into_iter().map(|i| keys[i]).collect();+        assert_eq!(toggled_selection(&deduped), vec!["e1", "e2", "e3"]);+    }++    #[test]+    fn spans_follow_the_ops_own_direction() {+        let e = |depth: f64, depth2: f64, direction: Direction, through_all: bool| Extent { depth, depth2, through_all, reverse: false, direction };+        assert_eq!(extent_span(0.0, 1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (0.0, 5.0));+        assert_eq!(extent_span(0.0, -1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (-5.0, 0.0));+        // The audit's T27: a cut on Front Plane (own direction −Z), both 6 / 2.+        assert_eq!(extent_span(0.0, -1.0, &e(6.0, 2.0, Direction::Both, false), (0.0, 10.0)), (-6.0, 2.0));+        assert_eq!(extent_span(0.0, 1.0, &e(5.0, 3.0, Direction::Both, false), (0.0, 10.0)), (-3.0, 5.0));+        assert_eq!(extent_span(0.0, 1.0, &e(10.0, 10.0, Direction::MidPlane, false), (0.0, 10.0)), (-5.0, 5.0));+        assert_eq!(extent_span(0.0, 1.0, &e(0.0, 0.0, Direction::One, true), (0.0, 10.0)), (0.0, 10.0));+    }+}
src/script.rs+2−1
@@ -828,7 +828,8 @@ pub fn validate(ops: &[Op], has_open_doc: bool) -> Result<(), String> {                 if !sketch_pending {                     return Err(format!(                         "step {at} ({}) has no sketch to consume — put a sketch, sketch_rect or \-                         sketch_circle immediately before it",+                         sketch_circle before it (other ops may come between: the bridge re-selects \+                         the sketch by name)",                         op.name()                     ));                 }
src/selftest.rs+457−73
@@ -8,12 +8,13 @@ //! * `level:"read"` (the default) changes NOTHING: status, readiness, the //!   document list, preferences, and the read verbs against whatever document //!   is active.-//! * `level:"write"` ALSO builds a scratch part in the bridge's own scratch-//!   folder (never a user document), exercises every modelling op — one-//!   `swx_run_script` call per op, so each passes or fails on its own — reads-//!   it back, exports a STEP copy into the bridge's exports folder, saves the-//!   scratch file, CLOSES it, and re-activates the document that was active-//!   before.+//! * `level:"write"` ALSO runs the GEOMETRIC PROOF (0.10.2): 31 checks, each+//!   building its own scratch part (never a user document), MEASURED with+//!   swx_inspect_bodies / swx_mass_properties against geometry computed HERE+//!   from closed-form formulas — the same battery as tools/dsl-live-check.py,+//!   each row carrying expected vs measured — then closes what it made. Then,+//!   on one part made by swx_new_document: issue #2's ownership, a dimension, a+//!   STEP export, a save, a close, and the user's active document put back. //! //! Every sub-call goes through the normal dispatcher, gates included: the //! self-test is held to exactly the rules a caller is.@@ -60,11 +61,6 @@ impl Run {     } } -/// The feature a script step reported, by op.-fn feature_of(r: &Value, op: &str) -> Option<String> {-    r.get("steps")?.as_array()?.iter().rev().find(|s| s["op"] == op)?["result"]["feature"].as_str().map(String::from)-}- pub fn selftest(ctx: &Ctx, args: &Value) -> Value {     let level = args.get("level").and_then(|l| l.as_str()).unwrap_or("read");     if !matches!(level, "read" | "write") {@@ -161,10 +157,29 @@ pub fn selftest(ctx: &Ctx, args: &Value) -> Value {         (None, _) => run.skip("the active document's read verbs", "swx_design_info", "no document is open — open any part to include them"),     } -    // ── WRITE: a scratch part the self-test makes, and closes ──────────────+    // ── WRITE: the geometric proof, then the verbs on one scratch part ──────     if level == "write" {+        // THE GEOMETRIC PROOF (0.10.2): every modelling op builds a scratch part+        // and the result is MEASURED against geometry computed here, from+        // closed-form formulas and the documented conventions — never against+        // the bridge's own idea of what it built. A row is a pass only if the+        // geometry matches. The same checks as tools/dsl-live-check.py.+        let only: Vec<String> = args+            .get("only")+            .and_then(|o| o.as_array())+            .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())+            .unwrap_or_default();+        for check in battery() {+            if !only.is_empty() && !only.iter().any(|o| o == check.id) {+                continue;+            }+            geometry_check(ctx, &mut run, &check);+        }++        // THE VERBS on one scratch part made by swx_new_document.         let made = run.call(ctx, "new scratch part", "swx_new_document", json!({"type": "part"}));         let title = made.pointer("/created/title").and_then(|t| t.as_str()).map(String::from);+        let path = made.pointer("/documentIdentity/path").and_then(|t| t.as_str()).map(String::from);         if let Some(title) = title {             let target = |extra: Value| -> Value {                 let mut a = json!({"targetDocument": title});@@ -173,81 +188,38 @@ pub fn selftest(ctx: &Ctx, args: &Value) -> Value {                 }                 a             };-            let script = |steps: Value| target(json!({"script": steps}));-            // One op group per call, so each passes or fails on its own.-            let block = run.call(ctx, "extrude (Top Plane, centred rect)", "swx_run_script", script(json!([+            // Issue #2.b: scripted by PATH, it is the bridge's own.+            let by = match &path {+                Some(p) => json!({"path": p}),+                None => json!({"targetDocument": title}),+            };+            let mut sargs = by.clone();+            sargs["script"] = json!([                 {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},                 {"op": "extrude", "depthMm": 10}-            ])));-            let boss = feature_of(&block, "extrude");-            run.call(ctx, "cut through all, reversed", "swx_run_script", script(json!([-                {"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 2},-                {"op": "cut", "throughAll": true, "reverse": true}-            ])));-            let face = run.call(ctx, "sketch on a face", "swx_run_script", script(json!([-                {"op": "sketch_circle", "face": {"atPointMm": [7, 10, 7]}, "centerMm": [7, -7], "radiusMm": 1.5},-                {"op": "extrude", "depthMm": 1}-            ])));-            let pin = feature_of(&face, "extrude");-            run.call(ctx, "multi-profile sketch on an offset plane (a ring)", "swx_run_script", script(json!([-                {"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": 10}, "entities": [-                    {"circle": {"radiusMm": 4}}, {"circle": {"radiusMm": 2.5}}-                ]},-                {"op": "extrude", "depthMm": 2}-            ])));-            match &pin {-                Some(p) => {-                    run.call(ctx, "fillet (all edges of a feature)", "swx_run_script", script(json!([-                        {"op": "fillet", "radiusMm": 0.2, "edges": {"allOfFeature": p}}-                    ])));-                    run.call(ctx, "linear pattern", "swx_run_script", script(json!([-                        {"op": "linear_pattern", "feature": p, "directions": [{"axis": "x", "count": 2, "spacingMm": -14}]}-                    ])));-                    run.call(ctx, "circular pattern", "swx_run_script", script(json!([-                        {"op": "circular_pattern", "feature": p, "axis": "y", "count": 4}-                    ])));-                }-                None => {-                    for (n, op) in [("fillet (all edges of a feature)", "fillet"), ("linear pattern", "linear_pattern"), ("circular pattern", "circular_pattern")] {-                        run.skip(n, "swx_run_script", &format!("the face sketch made no feature to {op}"));-                    }+            ]);+            let block = run.call(ctx, "issue #2: a script into the bridge's own part (by path) says bridgeCreatedDocument:true", "swx_run_script", sargs);+            if block.get("success").and_then(|v| v.as_bool()) != Some(false) && block.get("bridgeCreatedDocument") != Some(&json!(true)) {+                if let Some(last) = run.results.last_mut() {+                    last["pass"] = json!(false);+                    last["error"] = json!(format!("bridgeCreatedDocument = {} for the bridge's own part", block.get("bridgeCreatedDocument").cloned().unwrap_or(Value::Null)));                 }             }-            run.call(ctx, "chamfer (an edge by point)", "swx_run_script", script(json!([-                {"op": "chamfer", "distanceMm": 0.5, "edges": {"atPointsMm": [[10, 5, 10]]}}-            ])));-            run.call(ctx, "appearance, material, property", "swx_run_script", script(json!([-                {"op": "set_appearance", "rgb": [200, 0, 0]},-                {"op": "set_material", "name": "ABS"},-                {"op": "set_prop", "name": "SelfTest", "value": crate::BRIDGE_VERSION}-            ])));-            let bodies = run.call(ctx, "bodies (read back)", "swx_inspect_bodies", target(json!({})));-            run.note(json!({-                "bodyCount": bodies.get("bodyCount"),-                "massKg": bodies.pointer("/bodies/0/massKg"),-                "densityKgM3": bodies.pointer("/bodies/0/densityKgM3"),-                "appearanceRgb": bodies.pointer("/bodies/0/appearanceRgb"),-                "sizeMm": bodies.pointer("/bodies/0/sizeMm"),-            }));-            let mass = run.call(ctx, "mass properties (read back)", "swx_mass_properties", target(json!({})));-            run.note(json!({"massKg": mass.get("massKg"), "density": mass.get("density")}));-            run.call(ctx, "design info (read back)", "swx_design_info", target(json!({})));-            run.call(ctx, "parameters (read back)", "swx_get_parameters", target(json!({})));+            let boss = block.pointer("/steps/1/result/feature").and_then(|f| f.as_str()).map(String::from);+            run.call(ctx, "design info", "swx_design_info", target(json!({})));+            run.call(ctx, "parameters", "swx_get_parameters", target(json!({})));             match &boss {                 Some(b) => {                     run.call(ctx, "set a dimension", "swx_set_parameter", target(json!({"name": format!("D1@{b}"), "valueMm": 12})));                 }-                None => run.skip("set a dimension", "swx_set_parameter", "the first extrude made no feature"),+                None => run.skip("set a dimension", "swx_set_parameter", "the extrude made no feature"),             }-            run.call(ctx, "shell (remove the bottom face)", "swx_run_script", script(json!([-                {"op": "shell", "thicknessMm": 0.8, "removeFacesAtPointsMm": [[5, 0, -5]]}-            ])));             let out = crate::scratch::exports_dir().join(format!("selftest-{}.step", crate::scratch::next_scratch_seq()));             run.call(ctx, "export a STEP copy", "swx_export_step", target(json!({"outputPath": out.to_string_lossy()})));             run.call(ctx, "save the scratch part", "swx_save", target(json!({})));             run.call(ctx, "close the scratch part", "swx_close_doc", target(json!({"save": false})));         } else {-            run.skip("every write check", "swx_run_script", "the scratch part could not be created (see the row above)");+            run.skip("the verbs on a scratch part", "swx_new_document", "the scratch part could not be created (see the row above)");         }         if let Some(prev) = &active {             run.call(ctx, "re-activate the document that was active before", "swx_activate_document", json!({"name": prev}));@@ -285,3 +257,415 @@ fn finish(level: &str, sw: &Value, ready: bool, running: bool, started: Instant,         "_hint": hint,     })) }++// ── the geometric proof ─────────────────────────────────────────────────────++const PI: f64 = std::f64::consts::PI;+const REL_TOL: f64 = 2e-3; // volumes: 0.2 %+const MM_TOL: f64 = 0.01; // bounding boxes: 0.01 mm++type Box3 = ([f64; 3], [f64; 3]);++fn bx(x0: f64, x1: f64, y0: f64, y1: f64, z0: f64, z1: f64) -> Box3 {+    ([x0, y0, z0], [x1, y1, z1])+}++fn cyl(d: f64, h: f64) -> f64 {+    PI * d * d / 4.0 * h+}++/// A box a x b x c with all 12 edges filleted r: the inner box Minkowski-summed+/// with a sphere of radius r.+fn rounded_box_volume(a: f64, b: f64, c: f64, r: f64) -> f64 {+    let (ai, bi, ci) = (a - 2.0 * r, b - 2.0 * r, c - 2.0 * r);+    ai * bi * ci + 2.0 * r * (ai * bi + bi * ci + ai * ci) + PI * r * r * (ai + bi + ci) + 4.0 / 3.0 * PI * r.powi(3)+}++/// A cylinder d x h with both circular edges filleted r (Pappus on the+/// spandrel, area (1 − π/4) r², centroid r(5/6 − π/4)/(1 − π/4) in from the wall).+fn fillet_cylinder_volume(d: f64, h: f64, r: f64) -> f64 {+    let big_r = d / 2.0;+    let area = (1.0 - PI / 4.0) * r * r;+    let xbar = r * (5.0 / 6.0 - PI / 4.0) / (1.0 - PI / 4.0);+    PI * big_r * big_r * h - 2.0 * area * 2.0 * PI * (big_r - xbar)+}++#[derive(Default)]+struct Expect {+    volume: Option<f64>,+    bbox: Option<Box3>,+    body_count: Option<usize>,+    face_count: Option<u64>,+    appearance: Option<[i64; 3]>,+    mass_kg: Option<f64>,+    density: Option<f64>,+    mass_matches_mp: bool,+    features: Option<Vec<&'static str>>,+    /// The run must FAIL at a cut (or, if it succeeds, must have changed the volume).+    expect_error_unless_volume_changed: Option<f64>,+    z_span: Option<f64>,+    xy: Option<[[f64; 2]; 2]>,+    body_boxes: Option<Vec<Box3>>,+    step_read_back: Option<usize>,+}++struct Check {+    id: &'static str,+    title: &'static str,+    script: Value,+    expect: Expect,+}++fn base() -> Vec<Value> {+    vec![+        json!({"op": "new_part"}),+        json!({"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"}),+        json!({"op": "extrude", "depthMm": 10}),+    ]+}++fn with_base(more: Value) -> Value {+    let mut v = base();+    if let Value::Array(a) = more {+        v.extend(a);+    }+    Value::Array(v)+}++/// The checks — the same as tools/dsl-live-check.py, expectations computed here.+fn battery() -> Vec<Check> {+    let base_box = bx(-10.0, 10.0, 0.0, 10.0, -10.0, 10.0);+    let lego_script = {+        let p = json!([+            {"op": "new_part"},+            {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 31.8, "heightMm": 15.8, "anchor": "center"},+            {"op": "extrude", "depthMm": 9.6},+            {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 29.4, "heightMm": 13.4, "anchor": "center"},+            {"op": "cut", "depthMm": 8.6, "reverse": true},+            {"op": "sketch", "plane": "Top Plane", "entities": [+                {"circle": {"centerMm": [-8, 0], "diameterMm": 6.51}}, {"circle": {"centerMm": [-8, 0], "diameterMm": 4.8}},+                {"circle": {"centerMm": [0, 0], "diameterMm": 6.51}}, {"circle": {"centerMm": [0, 0], "diameterMm": 4.8}},+                {"circle": {"centerMm": [8, 0], "diameterMm": 6.51}}, {"circle": {"centerMm": [8, 0], "diameterMm": 4.8}}+            ]},+            {"op": "extrude", "depthMm": 8.6},+            {"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": 9.6}, "entities": [+                {"circle": {"centerMm": [-12, -4], "diameterMm": 4.8}}, {"circle": {"centerMm": [-12, 4], "diameterMm": 4.8}},+                {"circle": {"centerMm": [-4, -4], "diameterMm": 4.8}}, {"circle": {"centerMm": [-4, 4], "diameterMm": 4.8}},+                {"circle": {"centerMm": [4, -4], "diameterMm": 4.8}}, {"circle": {"centerMm": [4, 4], "diameterMm": 4.8}},+                {"circle": {"centerMm": [12, -4], "diameterMm": 4.8}}, {"circle": {"centerMm": [12, 4], "diameterMm": 4.8}}+            ]},+            {"op": "extrude", "depthMm": 1.7},+            {"op": "set_material", "name": "ABS"},+            {"op": "set_appearance", "rgb": [200, 0, 0]}+        ]);+        p+    };+    let lego_volume = 31.8 * 15.8 * 9.6 - 29.4 * 13.4 * 8.6 + 3.0 * (cyl(6.51, 8.6) - cyl(4.8, 8.6)) + 8.0 * cyl(4.8, 1.7);+    vec![+        Check { id: "T01", title: "rectangle anchor: xMm/yMm is the CORNER by default",+            script: json!([{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 20}, {"op": "extrude", "depthMm": 5}]),+            expect: Expect { volume: Some(1000.0), bbox: Some(bx(0.0, 10.0, 0.0, 20.0, 0.0, 5.0)), ..Default::default() } },+        Check { id: "T02", title: "Top Plane: sketch +Y is model -Z",+            script: json!([{"op": "new_part"}, {"op": "sketch_rect", "plane": "Top Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 20}, {"op": "extrude", "depthMm": 5}]),+            expect: Expect { volume: Some(1000.0), bbox: Some(bx(0.0, 10.0, 0.0, 5.0, -20.0, 0.0)), ..Default::default() } },+        Check { id: "T03", title: "anchor center",+            script: json!([{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 20, "anchor": "center"}, {"op": "extrude", "depthMm": 5}]),+            expect: Expect { volume: Some(1000.0), bbox: Some(bx(-5.0, 5.0, -10.0, 10.0, 0.0, 5.0)), ..Default::default() } },+        Check { id: "T04", title: "sketch on a plane offset +10 mm from Top Plane",+            script: with_base(json!([{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": 10}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 10}}]}, {"op": "extrude", "depthMm": 5}])),+            expect: Expect { volume: Some(4000.0 + cyl(10.0, 5.0)), bbox: Some(bx(-10.0, 10.0, 0.0, 15.0, -10.0, 10.0)), ..Default::default() } },+        Check { id: "T05", title: "sketch on a NEGATIVE offset plane (-5 mm)",+            script: with_base(json!([{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": -5}, "entities": [{"rect": {"widthMm": 20, "heightMm": 20, "anchor": "center"}}]}, {"op": "extrude", "depthMm": 5}])),+            expect: Expect { volume: Some(6000.0), bbox: Some(bx(-10.0, 10.0, -5.0, 10.0, -10.0, 10.0)), ..Default::default() } },+        Check { id: "T06", title: "sketch on a FACE picked by a point (the top face)",+            script: with_base(json!([{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 6}}]}, {"op": "extrude", "depthMm": 4}])),+            expect: Expect { volume: Some(4000.0 + cyl(6.0, 4.0)), bbox: Some(bx(-10.0, 10.0, 0.0, 14.0, -10.0, 10.0)), ..Default::default() } },+        Check { id: "T07", title: "a default cut from Top Plane with the body on +Y fails LOUDLY, never silently",+            script: with_base(json!([{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"}, {"op": "cut", "depthMm": 5}])),+            expect: Expect { expect_error_unless_volume_changed: Some(4000.0), ..Default::default() } },+        Check { id: "T08", title: "cut reverse:true removes material on +Y",+            script: with_base(json!([{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"}, {"op": "cut", "depthMm": 5, "reverse": true}])),+            expect: Expect { volume: Some(3500.0), bbox: Some(base_box), ..Default::default() } },+        Check { id: "T09", title: "cut throughAll + reverse",+            script: with_base(json!([{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"}, {"op": "cut", "throughAll": true, "reverse": true}])),+            expect: Expect { volume: Some(3000.0), bbox: Some(base_box), ..Default::default() } },+        Check { id: "T10", title: "extrude direction both (5 mm one way, 3 mm the other)",+            script: json!([{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"}, {"op": "extrude", "depthMm": 5, "direction": "both", "depth2Mm": 3}]),+            expect: Expect { volume: Some(800.0), z_span: Some(8.0), xy: Some([[-5.0, 5.0], [-5.0, 5.0]]), ..Default::default() } },+        Check { id: "T11", title: "extrude midPlane",+            script: json!([{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"}, {"op": "extrude", "depthMm": 10, "midPlane": true}]),+            expect: Expect { volume: Some(1000.0), bbox: Some(bx(-5.0, 5.0, -5.0, 5.0, -5.0, 5.0)), ..Default::default() } },+        Check { id: "T12", title: "cut midPlane through the block",+            script: with_base(json!([{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 4, "heightMm": 4, "anchor": "center", "yMm": 5}, {"op": "cut", "depthMm": 6, "midPlane": true}])),+            expect: Expect { volume: Some(4000.0 - 96.0), bbox: Some(base_box), ..Default::default() } },+        Check { id: "T13", title: "two concentric circles in ONE sketch extrude as a RING",+            script: json!([{"op": "new_part"}, {"op": "sketch", "plane": "Top Plane", "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 10}}, {"circle": {"centerMm": [0, 0], "diameterMm": 6}}]}, {"op": "extrude", "depthMm": 10}]),+            expect: Expect { volume: Some(cyl(10.0, 10.0) - cyl(6.0, 10.0)), face_count: Some(4), body_count: Some(1), ..Default::default() } },+        Check { id: "T14", title: "two disjoint rectangles in ONE sketch: one feature, two bodies",+            script: json!([{"op": "new_part"}, {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 0, "yMm": 0, "widthMm": 5, "heightMm": 5}}, {"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 5}}]}, {"op": "extrude", "depthMm": 5}]),+            expect: Expect { volume: Some(250.0), body_count: Some(2), ..Default::default() } },+        Check { id: "T15", title: "set_appearance on the part",+            script: with_base(json!([{"op": "set_appearance", "rgb": [200, 0, 0]}])),+            expect: Expect { volume: Some(4000.0), appearance: Some([200, 0, 0]), ..Default::default() } },+        Check { id: "T16", title: "set_appearance on one face picked by a point",+            script: with_base(json!([{"op": "set_appearance", "rgb": [0, 0, 255], "target": {"faceAtPointMm": [0, 10, 0]}}])),+            expect: Expect { volume: Some(4000.0), step_read_back: Some(3), ..Default::default() } },+        Check { id: "T17", title: "fillet every edge of the block, r = 1 (allOfFeature)",+            script: with_base(json!([{"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "Boss-Extrude1"}}])),+            expect: Expect { volume: Some(rounded_box_volume(20.0, 10.0, 20.0, 1.0)), bbox: Some(base_box), ..Default::default() } },+        Check { id: "T18", title: "chamfer ONE edge picked by a point, 1 mm",+            script: with_base(json!([{"op": "chamfer", "distanceMm": 1, "edges": {"atPointsMm": [[0, 10, 10]]}}])),+            expect: Expect { volume: Some(4000.0 - 0.5 * 1.0 * 1.0 * 20.0), bbox: Some(base_box), ..Default::default() } },+        Check { id: "T19", title: "shell 1 mm, top face removed",+            script: with_base(json!([{"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 10, 0]]}])),+            expect: Expect { volume: Some(4000.0 - 18.0 * 18.0 * 9.0), bbox: Some(base_box), ..Default::default() } },+        Check { id: "T20", title: "linear pattern: one boss -> 3 along X, 6 mm apart",+            script: with_base(json!([{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [-6, 0], "diameterMm": 4}}]}, {"op": "extrude", "depthMm": 5},+                                     {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 3, "spacingMm": 6}]}])),+            expect: Expect { volume: Some(4000.0 + 3.0 * cyl(4.0, 5.0)), bbox: Some(bx(-10.0, 10.0, 0.0, 15.0, -10.0, 10.0)), ..Default::default() } },+        Check { id: "T21", title: "circular pattern: one boss -> 6 around Y",+            script: with_base(json!([{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [7, 0], "diameterMm": 3}}]}, {"op": "extrude", "depthMm": 4},+                                     {"op": "circular_pattern", "feature": "Boss-Extrude2", "axis": "y", "count": 6}])),+            expect: Expect { volume: Some(4000.0 + 6.0 * cyl(3.0, 4.0)), ..Default::default() } },+        Check { id: "T22", title: "issue #2: body mass uses the material's density (ABS 1020 kg/m^3)",+            script: with_base(json!([{"op": "set_material", "name": "ABS"}])),+            expect: Expect { volume: Some(4000.0), mass_kg: Some(4000e-9 * 1020.0), density: Some(1020.0), mass_matches_mp: true, ..Default::default() } },+        Check { id: "T25", title: "sketch on the BOTTOM face (hidden in an iso view, normal -Y) extrudes OUTWARD",+            script: with_base(json!([{"op": "sketch", "face": {"atPointMm": [0, 0, 0]}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 6}}]}, {"op": "extrude", "depthMm": 3}])),+            expect: Expect { volume: Some(4000.0 + cyl(6.0, 3.0)), bbox: Some(bx(-10.0, 10.0, -3.0, 10.0, -10.0, 10.0)), ..Default::default() } },+        Check { id: "T26", title: "a NEGATIVE offset plane keeps the base plane's axes (asymmetric sketch, separate body)",+            script: with_base(json!([{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": -5}, "entities": [{"rect": {"xMm": 0, "yMm": 0, "widthMm": 4, "heightMm": 6}}]}, {"op": "extrude", "depthMm": 2}])),+            expect: Expect { volume: Some(4048.0), body_count: Some(2), body_boxes: Some(vec![base_box, bx(0.0, 4.0, -5.0, -3.0, -6.0, 0.0)]), ..Default::default() } },+        Check { id: "T27", title: "an ASYMMETRIC two-way cut: depthMm along the cut's own direction (-normal), depth2Mm the other way",+            script: json!([{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"}, {"op": "extrude", "depthMm": 10},+                           {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 4, "heightMm": 4, "anchor": "center"}, {"op": "cut", "depthMm": 6, "direction": "both", "depth2Mm": 2}]),+            expect: Expect { volume: Some(1000.0 - 4.0 * 4.0 * 2.0), bbox: Some(bx(-5.0, 5.0, -5.0, 5.0, 0.0, 10.0)), ..Default::default() } },+        Check { id: "T28", title: "linear pattern with NEGATIVE spacing goes -X (instances stay on the block)",+            script: with_base(json!([{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [6, 0], "diameterMm": 4}}]}, {"op": "extrude", "depthMm": 5},+                                     {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 3, "spacingMm": -6}]}])),+            expect: Expect { volume: Some(4000.0 + 3.0 * cyl(4.0, 5.0)), body_count: Some(1), bbox: Some(bx(-10.0, 10.0, 0.0, 15.0, -10.0, 10.0)), ..Default::default() } },+        Check { id: "T29", title: "shell removing the BOTTOM face (hidden in an iso view)",+            script: with_base(json!([{"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 0, 0]]}])),+            expect: Expect { volume: Some(4000.0 - 18.0 * 18.0 * 9.0), bbox: Some(base_box), ..Default::default() } },+        Check { id: "T30", title: "fillet ONE feature's edges by allOfFeature on a cylinder (2 circular edges)",+            script: json!([{"op": "new_part"}, {"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 5}, {"op": "extrude", "depthMm": 10},+                           {"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "Boss-Extrude1"}}]),+            expect: Expect { volume: Some(fillet_cylinder_volume(10.0, 10.0, 1.0)), ..Default::default() } },+        Check { id: "T31", title: "a material AFTER an appearance keeps the colour (the bridge re-applies it)",+            script: with_base(json!([{"op": "set_appearance", "rgb": [0, 160, 60]}, {"op": "set_material", "name": "ABS"}])),+            expect: Expect { volume: Some(4000.0), appearance: Some([0, 160, 60]), ..Default::default() } },+        Check { id: "T32", title: "an op between a sketch and its extrude does not lose the sketch",+            script: with_base(json!([{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 6}}]},+                                     {"op": "fillet", "radiusMm": 0.5, "edges": {"atPointsMm": [[0, 0, 10]]}},+                                     {"op": "extrude", "depthMm": 4}])),+            expect: Expect { volume: Some(4000.0 - (1.0 - PI / 4.0) * 0.25 * 20.0 + cyl(6.0, 4.0)), ..Default::default() } },+        Check { id: "T24", title: "the LEGO 2x4 acceptance brick (examples/lego-2x4.json)",+            script: lego_script,+            expect: Expect { volume: Some(lego_volume), bbox: Some(bx(-15.9, 15.9, 0.0, 11.3, -7.9, 7.9)), appearance: Some([200, 0, 0]),+                             mass_kg: Some(lego_volume * 1e-9 * 1020.0), density: Some(1020.0), mass_matches_mp: true,+                             features: Some(vec!["Boss-Extrude1", "Cut-Extrude1", "Boss-Extrude2", "Boss-Extrude3"]), ..Default::default() } },+    ]+}++fn rel_ok(got: f64, want: f64) -> bool {+    (got - want).abs() <= REL_TOL * want.abs().max(1.0)+}++fn union_box(bodies: &[Value]) -> Option<Box3> {+    let mut lo = [f64::MAX; 3];+    let mut hi = [f64::MIN; 3];+    let mut any = false;+    for b in bodies {+        for i in 0..3 {+            if let (Some(l), Some(h)) = (b.pointer(&format!("/bbox/minMm/{i}")).and_then(|v| v.as_f64()), b.pointer(&format!("/bbox/maxMm/{i}")).and_then(|v| v.as_f64())) {+                lo[i] = lo[i].min(l);+                hi[i] = hi[i].max(h);+                any = true;+            }+        }+    }+    any.then_some((lo, hi))+}++/// The titles of the open documents (swx_docs' `docs`).+fn titles(ctx: &Ctx) -> Vec<String> {+    crate::verbs::dispatch(ctx, "swx_docs", &json!({}))+        .get("docs")+        .and_then(|d| d.as_array().cloned())+        .unwrap_or_default()+        .iter()+        .filter_map(|d| d.get("title").and_then(|t| t.as_str()).map(String::from))+        .collect()+}++/// One check: build, MEASURE, compare, close what it made.+fn geometry_check(ctx: &Ctx, run: &mut Run, c: &Check) {+    let t = Instant::now();+    let before = titles(ctx);+    let r = crate::verbs::dispatch(ctx, "swx_run_script", &json!({"script": c.script}));+    let ok = r.get("success").and_then(|v| v.as_bool()) != Some(false);+    let doc = r.get("documentName").and_then(|d| d.as_str()).map(String::from);+    let mut fails: Vec<String> = Vec::new();+    let mut measured = json!({});+    let e = &c.expect;+    let inspect = |doc: &Option<String>| -> Value {+        let a = match doc {+            Some(d) => json!({"targetDocument": d}),+            None => json!({}),+        };+        crate::verbs::dispatch(ctx, "swx_inspect_bodies", &a)+    };+    if let Some(unchanged) = e.expect_error_unless_volume_changed {+        if ok {+            let ib = inspect(&doc);+            let vol: f64 = ib.get("bodies").and_then(|b| b.as_array()).map(|a| a.iter().filter_map(|b| b["volumeMm3"].as_f64()).sum()).unwrap_or(0.0);+            measured = json!({"volumeMm3": vol});+            if rel_ok(vol, unchanged) {+                fails.push(format!("the cut reported success but removed nothing (volume still {vol}) — a silent no-op"));+            }+        } else {+            let msg = r.get("error").and_then(|m| m.as_str()).unwrap_or("").to_string();+            measured = json!({"error": msg});+            if !msg.to_lowercase().contains("cut") {+                fails.push(format!("failed, but the error does not name the cut: {}", msg.chars().take(200).collect::<String>()));+            }+        }+    } else if !ok {+        fails.push(format!("swx_run_script failed: {} {}", r.get("errorCode").cloned().unwrap_or(Value::Null), r.get("error").and_then(|m| m.as_str()).unwrap_or("").chars().take(300).collect::<String>()));+    } else {+        let steps = r.get("steps").and_then(|s| s.as_array()).cloned().unwrap_or_default();+        for st in &steps {+            let op = st["op"].as_str().unwrap_or("");+            if matches!(op, "extrude" | "cut" | "fillet" | "chamfer" | "shell" | "linear_pattern" | "circular_pattern") && st["result"]["feature"].is_null() {+                fails.push(format!("step {} ({op}) returned no feature", st["step"]));+            }+        }+        if let Some(want) = &e.features {+            let made: Vec<String> = steps.iter().filter(|st| matches!(st["op"].as_str(), Some("extrude") | Some("cut"))).map(|st| st["result"]["feature"].as_str().unwrap_or("").to_string()).collect();+            if made != *want {+                fails.push(format!("features {made:?}, expected {want:?}"));+            }+        }+        if let Some(i) = e.step_read_back {+            if steps.get(i).map(|st| st["result"]["readBack"].is_null()).unwrap_or(true) {+                fails.push(format!("step {} has no confirming read-back", i + 1));+            }+        }+        let ib = inspect(&doc);+        let bodies = ib.get("bodies").and_then(|b| b.as_array()).cloned().unwrap_or_default();+        if bodies.is_empty() {+            fails.push(format!("swx_inspect_bodies found no body ({})", ib.get("error").and_then(|m| m.as_str()).unwrap_or("")));+        } else {+            let vol: f64 = bodies.iter().filter_map(|b| b["volumeMm3"].as_f64()).sum();+            let bb = union_box(&bodies);+            measured = json!({+                "volumeMm3": vol,+                "bodyCount": bodies.len(),+                "bboxMm": bb.map(|(l, h)| json!({"min": l, "max": h})),+                "faceCount": bodies.iter().map(|b| b["faceCount"].clone()).collect::<Vec<_>>(),+                "appearanceRgb": bodies[0]["appearanceRgb"],+                "massKg": bodies.iter().filter_map(|b| b["massKg"].as_f64()).sum::<f64>(),+                "densityKgM3": bodies[0]["densityKgM3"],+            });+            if let Some(v) = e.volume {+                if !rel_ok(vol, v) {+                    fails.push(format!("volume {vol:.4} mm^3, expected {v:.4}"));+                }+            }+            if let Some(n) = e.body_count {+                if bodies.len() != n {+                    fails.push(format!("{} bodies, expected {n}", bodies.len()));+                }+            }+            if let Some(f) = e.face_count {+                if bodies[0]["faceCount"].as_u64() != Some(f) {+                    fails.push(format!("faceCount {}, expected {f}", bodies[0]["faceCount"]));+                }+            }+            if let (Some((wl, wh)), Some((gl, gh))) = (e.bbox, bb) {+                for i in 0..3 {+                    if (gl[i] - wl[i]).abs() > MM_TOL || (gh[i] - wh[i]).abs() > MM_TOL {+                        fails.push(format!("bbox {} = [{}, {}], expected [{}, {}]", ["X", "Y", "Z"][i], gl[i], gh[i], wl[i], wh[i]));+                    }+                }+            }+            if let (Some(span), Some((gl, gh))) = (e.z_span, bb) {+                if ((gh[2] - gl[2]) - span).abs() > MM_TOL {+                    fails.push(format!("Z span {}, expected {span}", gh[2] - gl[2]));+                }+            }+            if let (Some(xy), Some((gl, gh))) = (e.xy, bb) {+                for i in 0..2 {+                    if (gl[i] - xy[i][0]).abs() > MM_TOL || (gh[i] - xy[i][1]).abs() > MM_TOL {+                        fails.push(format!("bbox {} = [{}, {}], expected {:?}", ["X", "Y"][i], gl[i], gh[i], xy[i]));+                    }+                }+            }+            if let Some(want) = &e.body_boxes {+                let mut got: Vec<Box3> = bodies.iter().filter_map(|b| union_box(std::slice::from_ref(b))).collect();+                for w in want {+                    match got.iter().position(|g| (0..3).all(|i| (g.0[i] - w.0[i]).abs() <= MM_TOL && (g.1[i] - w.1[i]).abs() <= MM_TOL)) {+                        Some(k) => {+                            got.remove(k);+                        }+                        None => fails.push(format!("no body with bbox {w:?}")),+                    }+                }+            }+            if let Some(rgb) = e.appearance {+                let got: Vec<i64> = bodies[0]["appearanceRgb"].as_array().map(|a| a.iter().filter_map(|v| v.as_i64()).collect()).unwrap_or_default();+                if got.len() != 3 || got.iter().zip(rgb.iter()).any(|(a, b)| (a - b).abs() > 2) {+                    fails.push(format!("appearanceRgb {got:?}, expected {rgb:?}"));+                }+            }+            if let Some(m) = e.mass_kg {+                let got: f64 = bodies.iter().filter_map(|b| b["massKg"].as_f64()).sum();+                if !rel_ok(got, m) {+                    fails.push(format!("massKg {got}, expected {m}"));+                }+            }+            if let Some(d) = e.density {+                if bodies[0]["densityKgM3"].as_f64().map(|g| (g - d).abs() > 0.5).unwrap_or(true) {+                    fails.push(format!("densityKgM3 {}, expected {d}", bodies[0]["densityKgM3"]));+                }+            }+            if e.mass_matches_mp {+                let a = match &doc {+                    Some(d) => json!({"targetDocument": d}),+                    None => json!({}),+                };+                let mp = crate::verbs::dispatch(ctx, "swx_mass_properties", &a);+                let got: f64 = bodies.iter().filter_map(|b| b["massKg"].as_f64()).sum();+                if !rel_ok(got, mp["massKg"].as_f64().unwrap_or(-1.0)) {+                    fails.push(format!("inspect_bodies mass {got} != swx_mass_properties mass {}", mp["massKg"]));+                }+            }+        }+    }+    // Close what this check made (only documents that were not open before it).+    for title in titles(ctx).into_iter().filter(|t| !before.contains(t)) {+        let _ = crate::verbs::dispatch(ctx, "swx_close_doc", &json!({"targetDocument": title, "save": false}));+    }+    let expected = json!({+        "volumeMm3": e.volume,+        "bboxMm": e.bbox.map(|(l, h)| json!({"min": l, "max": h})),+        "bodyCount": e.body_count,+        "faceCount": e.face_count,+        "appearanceRgb": e.appearance,+        "massKg": e.mass_kg,+        "densityKgM3": e.density,+        "zSpanMm": e.z_span,+        "bodyBoxes": e.body_boxes.as_ref().map(|v| v.iter().map(|(l, h)| json!({"min": l, "max": h})).collect::<Vec<_>>()),+        "features": e.features,+        "mustFailOrRemoveMaterial": e.expect_error_unless_volume_changed.is_some(),+    });+    run.results.push(json!({+        "name": format!("{} {}", c.id, c.title),+        "verb": "swx_run_script",+        "pass": fails.is_empty(),+        "ms": t.elapsed().as_millis() as u64,+        "expected": expected,+        "measured": measured,+        "fails": fails,+    }));+}
src/verbs3.rs+21−3
@@ -4590,7 +4590,21 @@ pub fn run_script(ctx: &Ctx, args: &Value) -> Value {     // a running script onto a different model halfway through.     let call_args = crate::writes::with_grant(json!({"script": script, "targetDocument": pinned, "targetPath": pinned_path}), &grant);     match ctx.call("run_script", call_args, 280) {-        Err(e) => sidecar_error(e),+        Err(e) => {+            // A SCRIPT THAT FAILED AFTER ITS new_part STILL MADE THAT PART: the+            // bridge owns it, so swx_close_doc / swx_reset_workspace can close it+            // (0.10.2 — a failed self-test check left one open, un-closable).+            if let Some(t) = e.created.as_ref().and_then(|c| c.get("title")).and_then(|t| t.as_str()) {+                ctx.remember_scratch(t, ownership_context(ctx).0);+                crate::ledger::record("document_created", json!({"title": t, "via": "run_script (before a failed step)"}));+            }+            let created = e.created.clone();+            let mut out = sidecar_error(e);+            if let (Value::Object(m), Some(c)) = (&mut out, created) {+                m.insert("createdBeforeFailing".into(), c);+            }+            out+        }         Ok(r) => {             crate::ledger::record_time("swx_run_script", started.elapsed().as_secs_f64());             if let Some(t) = r@@ -4696,8 +4710,12 @@ fn script_help(message: impl Into<String>) -> Value {                 "Every length is MILLIMETRES; angles are degrees.",                 "A sketch's x/y are its PLANE's own axes. Top Plane: sketch +x = model +X and sketch +y = model −Z (measured on SOLIDWORKS 2021, issue #1). Front Plane: sketch (x, y) = model (X, Y). Right Plane: sketch +x = model −Z, +y = model +Y (unmeasured).",                 "sketch_rect xMm/yMm is the CORNER unless anchor:\"center\" (or centerMm is given).",-                "An offset plane sits offsetMm along its base plane's normal (Top +Y, Front +Z, Right +X; negative = the other side) and keeps the base plane's sketch axes. A face is resolved to the offset plane coincident with it (only faces parallel to Top/Front/Right).",-                "An extrude goes along the plane's +normal; a CUT goes along its −NORMAL (measured: a cut from Top Plane removes −Y). reverse:true flips either; direction:\"both\" / midPlane:true go both ways.",+                "An offset plane sits offsetMm along its base plane's normal (Top +Y, Front +Z, Right +X; negative = the other side) and keeps the base plane's sketch axes. A face sketch (only faces parallel to Top/Front/Right) uses the parallel default plane's sketch axes too; the face is picked by GEOMETRY (the face through your point), never through the view — a hidden face works.",+                "An extrude goes along the plane's +normal; a CUT goes along its −NORMAL (measured: a cut from Top Plane removes −Y). From a FACE: an extrude goes OUTWARD (away from the material), a cut INWARD — on any face, the bottom one included. reverse:true flips either.",+                "direction:\"both\": depthMm goes the op's OWN way (an extrude's +normal / outward, a cut's −normal / inward), depth2Mm the other way. midPlane:true is symmetric, depthMm in total.",+                "A material wipes a part's appearance (SOLIDWORKS behaviour): the bridge RE-APPLIES every colour the script set before a set_material (the step says so) — putting set_material first needs nothing re-applied.",+                "Fillet/chamfer edges are de-duplicated before they are selected (an edge selected twice is deselected); points must lie ON one edge / one face (a corner or an edge between two faces is refused, never guessed).",+                "A sketch need not be the step right before its extrude/cut: the bridge re-selects it by name.",                 "\"last\" is the feature the previous feature-making step of THIS script made.",                 "A step whose feature comes back null is an ERROR that stops the script — it never reports success with nothing built."             ]),
tests/issue1.rs+32−3
@@ -44,7 +44,7 @@ fn the_lego_brick_is_four_features_with_the_new_ops() {     check!(         b,         "the op sequence: block, hollow cut, tubes (one multi-profile sketch), studs (offset plane)",-        ops == ["new_part", "sketch_rect", "extrude", "sketch_rect", "cut", "sketch", "extrude", "sketch", "extrude", "set_appearance", "set_material", "set_prop"],+        ops == ["new_part", "sketch_rect", "extrude", "sketch_rect", "cut", "sketch", "extrude", "sketch", "extrude", "set_material", "set_appearance", "set_prop"],         format!("{ops:?}")     );     let features: Vec<String> = steps.iter().filter_map(|st| st["result"]["feature"].as_str().map(String::from)).collect();@@ -139,7 +139,7 @@ fn faces_offsets_both_ways_and_rings_build_what_they_say() {         {"op": "sketch", "plane": "Front Plane", "entities": [{"circle": {"radiusMm": 4, "centerMm": [0, 5]}}, {"circle": {"radiusMm": 2, "centerMm": [0, 5]}}]},         {"op": "extrude", "depthMm": 15, "direction": "both", "depth2Mm": 15},         {"op": "fillet", "radiusMm": 0.5, "edges": {"allOfFeature": "last"}},-        {"op": "chamfer", "distanceMm": 0.5, "edges": {"atPointsMm": [[10, 5, 10]]}},+        {"op": "chamfer", "distanceMm": 0.5, "edges": {"atPointsMm": [[10, 0, 5]]}},         {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 2, "spacingMm": 6}]},         {"op": "circular_pattern", "feature": "Boss-Extrude2", "axis": "y", "count": 4},         {"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[5, 0, 5]]}@@ -171,7 +171,7 @@ fn faces_offsets_both_ways_and_rings_build_what_they_say() {         {"op": "sketch_circle", "face": {"atPointMm": [0, 3, 0]}, "radiusMm": 1},         {"op": "extrude", "depthMm": 1}     ]}));-    check!(b, "a face point inside the solid is no face", r["success"] == false && r["error"].as_str().unwrap_or("").contains("no planar face"), s(&r));+    check!(b, "a face point inside the solid is no face", r["success"] == false && r["error"].as_str().unwrap_or("").contains("no face of the part passes through"), s(&r)); }  #[test]@@ -259,3 +259,32 @@ fn selftest_with_solidworks_closed_skips_instead_of_failing_and_write_refuses()         format!("{}\n{}", s(&docs), s(&ledger))     ); }++/// Review #8: selection works only in SOLIDWORKS' ACTIVE document. A script+/// pinned to a part that is not active activates it first, and puts the user's+/// active document back afterwards.+#[test]+fn a_script_on_a_document_that_is_not_active_activates_it_and_puts_the_users_back() {+    let (b, _m) = boot("issue1-activate");+    let a = b.call("swx_new_document", json!({"type": "part"}));+    let a_title = a["created"]["title"].as_str().unwrap_or("").to_string();+    b.call("swx_run_script", json!({"targetDocument": a_title, "script": [+        {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},+        {"op": "extrude", "depthMm": 10}+    ]}));+    let other = b.call("swx_new_document", json!({"type": "part"}));+    let other_title = other["created"]["title"].as_str().unwrap_or("").to_string();+    check!(b, "the second part is the active one", b.call("swx_docs", json!({}))["active"]["title"] == other_title.as_str(), s(&other));+    let r = b.call("swx_run_script", json!({"targetDocument": a_title, "script": [+        {"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 3},+        {"op": "extrude", "depthMm": 2},+        {"op": "fillet", "radiusMm": 0.5, "edges": {"atPointsMm": [[0, 0, 10]]}}+    ]}));+    check!(b, "the face sketch and the fillet ran on the INACTIVE part (it was activated)", r["success"] == true, s(&r));+    check!(+        b,+        "...and the user's active document is back",+        b.call("swx_docs", json!({}))["active"]["title"] == other_title.as_str(),+        s(&b.call("swx_docs", json!({})))+    );+}
tools/dsl-live-check.pyadded+411
@@ -0,0 +1,411 @@+#!/usr/bin/env python3+"""dsl-live-check.py - prove the 0.10.x modelling ops on REAL SOLIDWORKS.++Every check builds a scratch part through swx_run_script, reads the result back with+swx_inspect_bodies / swx_mass_properties, and compares it with geometry computed HERE,+independently of the bridge and of its simulator (volumes from closed-form formulas,+bounding boxes from the documented conventions). Nothing is trusted that was not measured.++    python3 tools/dsl-live-check.py [--target adom_drew2] [--only T05,T12] [--keep]++It needs SOLIDWORKS running and ready on the target (swx_status: solidworks.ready). It only+ever touches documents it creates (new_part / swx_new_document), and closes them with+swx_reset_workspace after every check unless --keep is given. The report (every reply,+every expectation, pass/fail) is written to dsl-live-report.json next to where it runs.++Covers wiki issue adom/solidworks-bridge#1 (offset/face sketches, cut direction, multi-profile+sketches, appearance, conventions, fillet/chamfer/shell/patterns) and #2 (per-body mass,+run_script ownership), plus the LEGO 2x4 acceptance brick (examples/lego-2x4.json).+"""+import argparse, json, math, os, subprocess, sys, time++PI = math.pi+TARGET = "adom_drew2"+THREAD = "SolidWorks Bridge"+REL_TOL = 2e-3          # volumes: 0.2 % (SOLIDWORKS mass properties are far tighter than this)+MM_TOL = 0.01           # bounding boxes: 0.01 mm+++def ascii_reason(args):+    """MEASURED 2026-10-13 (ab 2.1.174): a non-ASCII character in `reason` makes ab fail the call+    before it reaches the bridge (adom/adom-bridge issue on header encoding). Keep reasons ASCII."""+    if isinstance(args, dict) and isinstance(args.get("reason"), str):+        args = dict(args, reason=args["reason"].encode("ascii", "replace").decode("ascii"))+    return args+++def call(verb, args, timeout=240):+    """One bridge verb through adom-bridge; returns the reply dict (unwrapped)."""+    args = ascii_reason(args)+    try:+        r = subprocess.run(["adom-bridge", "--target", TARGET, "--ai-thread", THREAD, verb, json.dumps(args)],+                           capture_output=True, text=True, timeout=timeout + 60)+    except subprocess.TimeoutExpired:+        return {"status": "error", "errorCode": "no_answer", "error": f"{verb} did not return in {timeout + 60}s"}+    t = r.stdout+    i = t.find("{")+    if i < 0:+        return {"status": "error", "errorCode": "not_json", "error": (t + r.stderr)[:400]}+    try:+        d = json.loads(t[i:])+    except ValueError:+        return {"status": "error", "errorCode": "not_json", "error": t[i:i + 400]}+    return d["result"] if isinstance(d, dict) and isinstance(d.get("result"), dict) else d+++def ok(reply):+    return reply.get("status") == "ok" and reply.get("success", True) is not False and not reply.get("errorCode")+++# ── the geometry, computed here ──────────────────────────────────────────────++def box(x0, x1, y0, y1, z0, z1):+    return {"min": [x0, y0, z0], "max": [x1, y1, z1]}+++def rounded_box_volume(a, b, c, r):+    """A box a x b x c with all 12 edges filleted r (spherical corners) = the Minkowski sum+    of the inner box with a sphere of radius r."""+    ai, bi, ci = a - 2 * r, b - 2 * r, c - 2 * r+    return ai * bi * ci + 2 * r * (ai * bi + bi * ci + ai * ci) + PI * r * r * (ai + bi + ci) + 4.0 / 3.0 * PI * r ** 3+++def fillet_cylinder_volume(d, h, r):+    """A cylinder d x h with both circular edges filleted r."""+    R = d / 2.0+    area = (1 - PI / 4) * r * r+    xbar = r * (5.0 / 6.0 - PI / 4) / (1 - PI / 4)+    return PI * R * R * h - 2 * area * 2 * PI * (R - xbar)+++def cyl(d, h):+    return PI * d * d / 4.0 * h+++# The base block every "add a feature" check starts from: 20 x 10 x 20 mm, centred on Top Plane,+# extruded +Y: X -10..10, Y 0..10, Z -10..10, volume 4000.+BASE = [+    {"op": "new_part"},+    {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},+    {"op": "extrude", "depthMm": 10},+]+BASE_BOX = box(-10, 10, 0, 10, -10, 10)+++def checks():+    """(id, title, issue, script, expectations). Expectation keys:+    volume (mm^3), bbox, bodyCount, faceCount, appearance [r,g,b], massKg, densityKgM3,+    features [names], expectError (the run must fail at that step index, never succeed silently)."""+    lego = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "examples", "lego-2x4.json")))+    lego_volume = (31.8 * 15.8 * 9.6) - (29.4 * 13.4 * 8.6) + 3 * (cyl(6.51, 8.6) - cyl(4.8, 8.6)) + 8 * cyl(4.8, 1.7)+    return [+        ("T01", "rectangle anchor: xMm/yMm is the CORNER by default", "#1.5",+         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 20},+          {"op": "extrude", "depthMm": 5}],+         {"volume": 1000.0, "bbox": box(0, 10, 0, 20, 0, 5)}),+        ("T02", "Top Plane: sketch +Y is model -Z", "#1.5",+         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Top Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 20},+          {"op": "extrude", "depthMm": 5}],+         {"volume": 1000.0, "bbox": box(0, 10, 0, 5, -20, 0)}),+        ("T03", "anchor center", "#1.5",+         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 20, "anchor": "center"},+          {"op": "extrude", "depthMm": 5}],+         {"volume": 1000.0, "bbox": box(-5, 5, -10, 10, 0, 5)}),+        ("T04", "sketch on a plane offset +10 mm from Top Plane", "#1.1",+         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": 10}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 10}}]},+                 {"op": "extrude", "depthMm": 5}],+         {"volume": 4000 + cyl(10, 5), "bbox": box(-10, 10, 0, 15, -10, 10)}),+        ("T05", "sketch on a NEGATIVE offset plane (-5 mm)", "#1.1",+         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": -5}, "entities": [{"rect": {"widthMm": 20, "heightMm": 20, "anchor": "center"}}]},+                 {"op": "extrude", "depthMm": 5}],+         {"volume": 6000.0, "bbox": box(-10, 10, -5, 10, -10, 10)}),+        ("T06", "sketch on a FACE picked by a point (the block's top face)", "#1.1",+         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 6}}]},+                 {"op": "extrude", "depthMm": 4}],+         {"volume": 4000 + cyl(6, 4), "bbox": box(-10, 10, 0, 14, -10, 10)}),+        ("T07", "Michael's case: a default cut from Top Plane with the body on +Y must FAIL LOUDLY, never succeed silently", "#1.2",+         BASE + [{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},+                 {"op": "cut", "depthMm": 5}],+         {"expectError": 4, "orVolumeChanged": 4000.0}),+        ("T08", "cut reverse:true removes material on +Y", "#1.2",+         BASE + [{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},+                 {"op": "cut", "depthMm": 5, "reverse": True}],+         {"volume": 3500.0, "bbox": BASE_BOX}),+        ("T09", "cut throughAll + reverse", "#1.2",+         BASE + [{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},+                 {"op": "cut", "throughAll": True, "reverse": True}],+         {"volume": 3000.0, "bbox": BASE_BOX}),+        ("T10", "extrude direction both (5 mm one way, 3 mm the other)", "#1.2",+         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},+          {"op": "extrude", "depthMm": 5, "direction": "both", "depth2Mm": 3}],+         {"volume": 800.0, "bboxZSpan": 8.0, "bboxXY": [[-5, 5], [-5, 5]]}),+        ("T11", "extrude midPlane", "#1.2",+         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},+          {"op": "extrude", "depthMm": 10, "midPlane": True}],+         {"volume": 1000.0, "bbox": box(-5, 5, -5, 5, -5, 5)}),+        ("T12", "cut midPlane through the block", "#1.2",+         BASE + [{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 4, "heightMm": 4, "anchor": "center", "yMm": 5},+                 {"op": "cut", "depthMm": 6, "midPlane": True}],+         {"volume": 4000 - 96.0, "bbox": BASE_BOX}),+        ("T13", "two concentric circles in ONE sketch extrude as a RING", "#1.3",+         [{"op": "new_part"}, {"op": "sketch", "plane": "Top Plane", "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 10}}, {"circle": {"centerMm": [0, 0], "diameterMm": 6}}]},+          {"op": "extrude", "depthMm": 10}],+         {"volume": cyl(10, 10) - cyl(6, 10), "faceCount": 4, "bodyCount": 1}),+        ("T14", "two disjoint rectangles in ONE sketch: one feature, two bodies", "#1.3",+         [{"op": "new_part"}, {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 0, "yMm": 0, "widthMm": 5, "heightMm": 5}}, {"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 5}}]},+          {"op": "extrude", "depthMm": 5}],+         {"volume": 250.0, "bodyCount": 2}),+        ("T15", "set_appearance on the part", "#1.4",+         BASE + [{"op": "set_appearance", "rgb": [200, 0, 0]}],+         {"volume": 4000.0, "appearance": [200, 0, 0]}),+        ("T16", "set_appearance on one face picked by a point", "#1.4",+         BASE + [{"op": "set_appearance", "rgb": [0, 0, 255], "target": {"faceAtPointMm": [0, 10, 0]}}],+         {"volume": 4000.0, "stepReadBack": 3}),+        ("T17", "fillet every edge of the block, r = 1", "#1.6",+         BASE + [{"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "Boss-Extrude1"}}],+         {"volume": rounded_box_volume(20, 10, 20, 1), "bbox": BASE_BOX}),+        ("T18", "chamfer ONE edge picked by a point, 1 mm", "#1.6",+         BASE + [{"op": "chamfer", "distanceMm": 1, "edges": {"atPointsMm": [[0, 10, 10]]}}],+         {"volume": 4000 - 0.5 * 1 * 1 * 20, "bbox": BASE_BOX}),+        ("T19", "shell 1 mm, top face removed", "#1.6",+         BASE + [{"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 10, 0]]}],+         {"volume": 4000 - 18 * 18 * 9, "bbox": BASE_BOX}),+        ("T20", "linear pattern: one boss -> 3 along X, 6 mm apart", "#1.6",+         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [-6, 0], "diameterMm": 4}}]},+                 {"op": "extrude", "depthMm": 5},+                 {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 3, "spacingMm": 6}]}],+         {"volume": 4000 + 3 * cyl(4, 5), "bbox": box(-10, 10, 0, 15, -10, 10)}),+        ("T21", "circular pattern: one boss -> 6 around Y", "#1.6",+         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [7, 0], "diameterMm": 3}}]},+                 {"op": "extrude", "depthMm": 4},+                 {"op": "circular_pattern", "feature": "Boss-Extrude2", "axis": "y", "count": 6}],+         {"volume": 4000 + 6 * cyl(3, 4)}),+        ("T22", "issue #2: body mass uses the material's density (ABS 1020 kg/m^3)", "#2.a",+         BASE + [{"op": "set_material", "name": "ABS"}],+         {"volume": 4000.0, "massKg": 4000e-9 * 1020, "densityKgM3": 1020.0, "massMatchesMassProperties": True}),+        ("T25", "audit #1/#5: sketch on the BOTTOM face (hidden in an iso view, normal -Y) extrudes OUTWARD", "#1.1",+         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 0, 0]}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 6}}]},+                 {"op": "extrude", "depthMm": 3}],+         {"volume": 4000 + cyl(6, 3), "bbox": box(-10, 10, -3, 10, -10, 10)}),+        ("T26", "audit unverified #1: a NEGATIVE offset plane keeps the base plane's axes (asymmetric sketch, separate body)", "#1.1",+         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": -5}, "entities": [{"rect": {"xMm": 0, "yMm": 0, "widthMm": 4, "heightMm": 6}}]},+                 {"op": "extrude", "depthMm": 2}],+         {"volume": 4000 + 48.0, "bodyCount": 2, "bodyBoxes": [BASE_BOX, box(0, 4, -5, -3, -6, 0)]}),+        ("T27", "audit #6: an ASYMMETRIC two-way cut: depthMm goes along the cut's own direction (-normal), depth2Mm the other way", "#1.2",+         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},+          {"op": "extrude", "depthMm": 10},+          {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 4, "heightMm": 4, "anchor": "center"},+          {"op": "cut", "depthMm": 6, "direction": "both", "depth2Mm": 2}],+         {"volume": 1000 - 4 * 4 * 2, "bbox": box(-5, 5, -5, 5, 0, 10)}),+        ("T28", "audit unverified #2: linear pattern with NEGATIVE spacing goes -X (instances stay on the block)", "#1.6",+         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [6, 0], "diameterMm": 4}}]},+                 {"op": "extrude", "depthMm": 5},+                 {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 3, "spacingMm": -6}]}],+         {"volume": 4000 + 3 * cyl(4, 5), "bodyCount": 1, "bbox": box(-10, 10, 0, 15, -10, 10)}),+        ("T29", "audit #1: shell removing the BOTTOM face (hidden in an iso view) removes that face, not a side", "#1.6",+         BASE + [{"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 0, 0]]}],+         {"volume": 4000 - 18 * 18 * 9, "bbox": BASE_BOX}),+        ("T30", "audit #3: fillet ONE feature's edges by allOfFeature on a cylinder (2 circular edges)", "#1.6",+         [{"op": "new_part"}, {"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 5}, {"op": "extrude", "depthMm": 10},+          {"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "Boss-Extrude1"}}],+         # a 10 mm-dia cylinder 10 tall with both circular edges rounded r=1. Each edge removes the+         # "spandrel" (square corner minus quarter disc), area (1 - pi/4) r^2, swept round the axis;+         # Pappus with its centroid r(5/6 - pi/4)/(1 - pi/4) in from the wall (Monte Carlo agrees).+         {"volume": fillet_cylinder_volume(10, 10, 1)}),+        ("T24", "the LEGO 2x4 acceptance brick (examples/lego-2x4.json)", "#1 acceptance",+         lego["script"],+         {"volume": lego_volume, "bbox": box(-15.9, 15.9, 0, 11.3, -7.9, 7.9), "appearance": [200, 0, 0],+          "massKg": lego_volume * 1e-9 * 1020, "densityKgM3": 1020.0, "massMatchesMassProperties": True,+          "features": ["Boss-Extrude1", "Cut-Extrude1", "Boss-Extrude2", "Boss-Extrude3"]}),+    ]+++# ── comparing ────────────────────────────────────────────────────────────────++def rel_ok(got, want):+    return got is not None and abs(got - want) <= REL_TOL * max(1.0, abs(want))+++def union_bbox(bodies):+    mins = [b["bbox"]["minMm"] for b in bodies if b.get("bbox")]+    maxs = [b["bbox"]["maxMm"] for b in bodies if b.get("bbox")]+    if not mins:+        return None+    return {"min": [min(m[i] for m in mins) for i in range(3)], "max": [max(m[i] for m in maxs) for i in range(3)]}+++def run_check(cid, title, issue, script, exp):+    rec = {"id": cid, "title": title, "issue": issue, "expect": exp, "fails": []}+    t0 = time.time()+    r = call("swx_run_script", {"script": script, "reason": f"dsl-live-check {cid}: {title}"})+    rec["runScript"] = r+    steps = r.get("steps") or []+    doc = r.get("documentName")+    if "expectError" in exp:+        if ok(r):+            # Succeeding is only acceptable if the cut really removed material.+            ib = call("swx_inspect_bodies", {"targetDocument": doc}) if doc else {}+            vol = sum((b.get("volumeMm3") or 0) for b in ib.get("bodies") or [])+            rec["inspect"] = ib+            if rel_ok(vol, exp["orVolumeChanged"]):+                rec["fails"].append(f"the cut reported success but removed nothing (volume still {vol}) - a silent no-op")+        else:+            msg = json.dumps({k: r.get(k) for k in ("errorCode", "error", "failedStep", "step")})+            rec["observedError"] = msg+            if str(exp["expectError"]) not in msg and "cut" not in msg.lower():+                rec["fails"].append(f"failed, but the error does not name the cut step: {msg[:200]}")+        rec["seconds"] = round(time.time() - t0, 1)+        return rec+    if not ok(r):+        rec["fails"].append(f"swx_run_script failed: {r.get('errorCode')} {str(r.get('error'))[:300]}")+        rec["seconds"] = round(time.time() - t0, 1)+        return rec+    for st in steps:+        res = st.get("result") or {}+        if isinstance(res, dict) and res.get("feature") is None and st.get("op") in ("extrude", "cut", "fillet", "chamfer", "shell", "linear_pattern", "circular_pattern"):+            rec["fails"].append(f"step {st.get('step')} ({st.get('op')}) returned no feature")+    if "features" in exp:+        made = [((st.get("result") or {}).get("feature")) for st in steps if st.get("op") in ("extrude", "cut")]+        if made != exp["features"]:+            rec["fails"].append(f"features {made} != {exp['features']}")+    if "stepReadBack" in exp:+        res = (steps[exp["stepReadBack"]].get("result") or {}) if len(steps) > exp["stepReadBack"] else {}+        rb = res.get("readBack") if isinstance(res, dict) else None+        rec["appearanceStep"] = res+        if rb is None or res.get("applied") is False:+            rec["fails"].append(f"set_appearance on a face has no confirming read-back: {json.dumps(res)[:200]}")+    ib = call("swx_inspect_bodies", {"targetDocument": doc} if doc else {})+    rec["inspect"] = ib+    bodies = ib.get("bodies") or []+    if not ok(ib) or not bodies:+        rec["fails"].append(f"swx_inspect_bodies: {ib.get('errorCode')} {str(ib.get('error'))[:200]} (bodies {len(bodies)})")+    else:+        vol = sum((b.get("volumeMm3") or 0) for b in bodies)+        rec["measured"] = {"volumeMm3": vol, "bodyCount": len(bodies), "bbox": union_bbox(bodies),+                           "faceCount": [b.get("faceCount") for b in bodies],+                           "appearanceRgb": [b.get("appearanceRgb") for b in bodies],+                           "massKg": [b.get("massKg") for b in bodies], "densityKgM3": [b.get("densityKgM3") for b in bodies]}+        if "volume" in exp and not rel_ok(vol, exp["volume"]):+            rec["fails"].append(f"volume {vol:.4f} mm^3, expected {exp['volume']:.4f}")+        if "bodyCount" in exp and len(bodies) != exp["bodyCount"]:+            rec["fails"].append(f"{len(bodies)} bodies, expected {exp['bodyCount']}")+        if "faceCount" in exp and bodies[0].get("faceCount") != exp["faceCount"]:+            rec["fails"].append(f"faceCount {bodies[0].get('faceCount')}, expected {exp['faceCount']}")+        bb = union_bbox(bodies)+        if "bbox" in exp and bb:+            for k in ("min", "max"):+                for i in range(3):+                    if abs(bb[k][i] - exp["bbox"][k][i]) > MM_TOL:+                        rec["fails"].append(f"bbox {k}[{'XYZ'[i]}] = {bb[k][i]}, expected {exp['bbox'][k][i]}")+        if "bboxZSpan" in exp and bb:+            span = bb["max"][2] - bb["min"][2]+            if abs(span - exp["bboxZSpan"]) > MM_TOL:+                rec["fails"].append(f"Z span {span}, expected {exp['bboxZSpan']} (direction both)")+            for i, (lo, hi) in enumerate(exp["bboxXY"]):+                if abs(bb["min"][i] - lo) > MM_TOL or abs(bb["max"][i] - hi) > MM_TOL:+                    rec["fails"].append(f"bbox {'XY'[i]} = [{bb['min'][i]}, {bb['max'][i]}], expected [{lo}, {hi}]")+        if "bodyBoxes" in exp:+            got = [b.get("bbox") for b in bodies]+            unmatched = []+            for want in exp["bodyBoxes"]:+                hit = None+                for gi, g in enumerate(got):+                    if g and all(abs(g["minMm"][i] - want["min"][i]) <= MM_TOL and abs(g["maxMm"][i] - want["max"][i]) <= MM_TOL for i in range(3)):+                        hit = gi+                        break+                if hit is None:+                    unmatched.append(want)+                else:+                    got.pop(hit)+            if unmatched:+                rec["fails"].append(f"no body with bbox {unmatched} (bodies: {[b.get('bbox') for b in bodies]})")+        if "appearance" in exp:+            got = bodies[0].get("appearanceRgb")+            if not got or any(abs(a - b) > 2 for a, b in zip(got, exp["appearance"])):+                rec["fails"].append(f"appearanceRgb {got}, expected {exp['appearance']}")+        if "massKg" in exp:+            m = sum((b.get("massKg") or 0) for b in bodies)+            if not rel_ok(m, exp["massKg"]):+                rec["fails"].append(f"inspect_bodies massKg {m}, expected {exp['massKg']}")+            d = bodies[0].get("densityKgM3")+            if d is None or abs(d - exp["densityKgM3"]) > 0.5:+                rec["fails"].append(f"densityKgM3 {d}, expected {exp['densityKgM3']}")+        if exp.get("massMatchesMassProperties"):+            mp = call("swx_mass_properties", {"targetDocument": doc} if doc else {})+            rec["massProperties"] = mp+            m = sum((b.get("massKg") or 0) for b in bodies)+            if not rel_ok(m, mp.get("massKg") or -1):+                rec["fails"].append(f"inspect_bodies mass {m} != swx_mass_properties mass {mp.get('massKg')}")+    rec["seconds"] = round(time.time() - t0, 1)+    return rec+++def ownership_check():+    """#2.b: a part made by swx_new_document, then scripted by PATH, is the bridge's own."""+    rec = {"id": "T23", "title": "issue #2: run_script on the bridge's own new part (addressed by path) says bridgeCreatedDocument:true", "issue": "#2.b", "fails": []}+    nd = call("swx_new_document", {"type": "part", "reason": "dsl-live-check T23"})+    rec["newDocument"] = nd+    path = (nd.get("created") or {}).get("path") or (nd.get("documentIdentity") or {}).get("path") or nd.get("path")+    title = (nd.get("created") or {}).get("title") or nd.get("title")+    if not ok(nd):+        rec["fails"].append(f"swx_new_document failed: {nd.get('errorCode')} {str(nd.get('error'))[:200]}")+        return rec+    target = {"path": path} if path else {"targetDocument": title}+    r = call("swx_run_script", dict(target, script=[{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10}, {"op": "extrude", "depthMm": 2}],+                                    reason="dsl-live-check T23"))+    rec["runScript"] = r+    if not ok(r):+        rec["fails"].append(f"swx_run_script failed: {r.get('errorCode')} {str(r.get('error'))[:200]}")+    elif r.get("bridgeCreatedDocument") is not True:+        rec["fails"].append(f"bridgeCreatedDocument = {r.get('bridgeCreatedDocument')} for the bridge's own part (addressed by {'path' if path else 'title'})")+    return rec+++def main():+    global TARGET+    ap = argparse.ArgumentParser()+    ap.add_argument("--target", default=TARGET)+    ap.add_argument("--only", default="")+    ap.add_argument("--keep", action="store_true", help="leave the scratch parts open")+    ap.add_argument("--report", default="dsl-live-report.json")+    a = ap.parse_args()+    TARGET = a.target+    st = call("swx_status", {"reason": "dsl-live-check: is SOLIDWORKS ready"})+    sw = st.get("solidworks") or {}+    print(f"bridge {st.get('bridgeVersion')} | SOLIDWORKS running={sw.get('running')} ready={sw.get('ready')} "+          f"version={sw.get('majorVersion')} edition={sw.get('edition')} | waitingOn={st.get('waitingOn')}")+    if not sw.get("running"):+        print("SOLIDWORKS is not running on the target: start it (swx_start) and run again. Nothing was done.")+        return 2+    only = {x.strip() for x in a.only.split(",") if x.strip()}+    results = []+    plan = checks()+    for cid, title, issue, script, exp in plan:+        if only and cid not in only:+            continue+        rec = run_check(cid, title, issue, script, exp)+        results.append(rec)+        print(f"{'PASS' if not rec['fails'] else 'FAIL'}  {cid}  {title}  ({rec.get('seconds')} s)")+        for f in rec["fails"]:+            print(f"        - {f}")+        if not a.keep:+            call("swx_reset_workspace", {"reason": f"dsl-live-check: close the scratch part of {cid}"})+    if not only or "T23" in only:+        rec = ownership_check()+        results.append(rec)+        print(f"{'PASS' if not rec['fails'] else 'FAIL'}  T23  {rec['title']}")+        for f in rec["fails"]:+            print(f"        - {f}")+        if not a.keep:+            call("swx_reset_workspace", {"reason": "dsl-live-check: close the scratch part of T23"})+    passed = sum(1 for r in results if not r["fails"])+    report = {"when": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "target": TARGET,+              "bridgeVersion": st.get("bridgeVersion"), "solidworks": {k: sw.get(k) for k in ("majorVersion", "edition", "versionSupport")},+              "passed": passed, "failed": len(results) - passed, "checks": results}+    json.dump(report, open(a.report, "w"), indent=1)+    print(f"\n{passed}/{len(results)} passed. Full evidence: {a.report}")+    return 0 if passed == len(results) else 1+++if __name__ == "__main__":+    sys.exit(main())
uninstall.sh