← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.10.2+0.10.3
Cargo.lock+1−1
@@ -122,7 +122,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.10.2"+version = "0.10.3" dependencies = [  "miniz_oxide",  "serde",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.10.2"+version = "0.10.3" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+19−1
@@ -492,7 +492,25 @@ not.  ## Roadmap -- v0.10.2 (this): **Michael's features, made to work on real SOLIDWORKS.** An+- v0.10.3 (this): **selection after a geometric pick, fixed on SOLIDWORKS+  2026.** The first live run of `tools/dsl-live-check.py` (2026, major 34)+  passed 20 of 30 checks. All 10 failures were one bug: `MultiSelect2`, given a+  SAFEARRAY of VARIANTs, selected nothing. This broke face sketches, fillet,+  chamfer and shell. Selection is now a LADDER, and each rung is believed only+  when `GetSelectedObjectCount2` grows by what was asked:+  1. `MultiSelect2` with a typed `VT_DISPATCH` array;+  2. `IEntity::Select4`, per entity, through the object's `IEntity` interface+     (the IID is read from the installed interop, and the bridge checks the+     interface is dual first);+  3. `SelectByRay` with the bridge's own ray, from 1 mm outside the point back+     onto it, so it does not depend on the view;+  4. last, `SelectByID2` at the point, which does depend on the view.++  Each step reports the rung that worked as `selectedVia`, and+  `viewDependent:true` when rung 4 was needed. The simulator now models the+  measured install, and a fault per rung shows the ladder falling through.+  `ISimpleFilletFeatureData2.Edges` gets the typed array too. 592 tests.+- v0.10.2: **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:
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.10.2",+  "version": "0.10.3",   "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",
docs/COMPATIBILITY.md+7
@@ -302,6 +302,13 @@ from the gate, so a check held at the gate is reported, not hidden.    They are still asserted only in the simulator and marked `proven:false` in   step results.+- SELECTION after a geometric pick (0.10.3). On SOLIDWORKS 2026 `MultiSelect2`+  with a VARIANT array selected nothing. This was measured, and it broke face+  sketches, fillet, chamfer and shell. The bridge now climbs a verified ladder:+  a typed-array `MultiSelect2`, then `IEntity::Select4`, then `SelectByRay`+  (2017+) with its own ray, then `SelectByID2` at the point. Each step's+  `selectedVia` says which rung worked. Which rung works on 2021 is unmeasured.+  `SelectByRay` is skipped below 2017.  `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
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.10.2",+  "version": "0.10.3",   "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+6
@@ -89,6 +89,12 @@ adom-bridge --ai-thread "<thread>" swx_save_as '{"outputPath":"C:\\parts\\bracke   - 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 picked face or edge is selected through a verified ladder. The rungs are+    a typed `MultiSelect2`, then `IEntity::Select4`, then `SelectByRay` with the+    bridge's own ray, then `SelectByID2` at the point. A rung counts only when+    the selection count grows by what was asked. The step reports the rung that+    worked as `selectedVia`. `viewDependent:true` means only the view-based+    last resort worked, so check that step's geometry.   - 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
src/comhost/com.rs+326−47
@@ -5478,10 +5478,13 @@ impl ComBackend {                         // 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"));+                        let r = self.select_items(&doc, vec![SelItem::face(picked)], 0)?;+                        if !r.all(1) {+                            let _ = Self::clear_selection(&doc);+                            return Err(HostError::msg(format!("the picked face could not be selected ({})", r.notes.join("; "))));                         }+                        resolved["selectedVia"] = r.report();+                        resolved["viewDependent"] = json!(r.view_dependent());                         "the picked face".to_string()                     }                 };@@ -5730,11 +5733,16 @@ impl ComBackend {                 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 want = list.len();+                let edge_disps: Vec<Disp> = list.iter().map(|it| it.ent.clone()).collect();+                let r = self.select_items(&doc, list, if fillet { 1 } else { 0 })?;+                if !r.all(want) {                     let _ = Self::clear_selection(&doc);-                    return Err(HostError::msg(format!("only {n} of {} edges ended up selected — nothing was made", list.len())));+                    return Err(HostError::msg(format!("only {} of {want} edges ended up selected — nothing was made ({})", r.count, r.notes.join("; "))));                 }+                let mut how = how;+                how["selectedVia"] = r.report();+                how["viewDependent"] = json!(r.view_dependent());                 let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;                 let mut route = if fillet { "FeatureFillet3" } else { "InsertFeatureChamfer" };                 let mut f = if fillet {@@ -5779,7 +5787,7 @@ impl ComBackend {                 // 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) {+                    match self.fillet_by_definition(&doc, &fm, &edge_disps, *radius) {                         Ok(v) => {                             f = v;                             route = "CreateFeature(ISimpleFilletFeatureData2)";@@ -5791,13 +5799,13 @@ impl ComBackend {                 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 \+                        "SOLIDWORKS made no {} (the feature came back null) on {want} selected edge(s) — the size may be too \                          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, "edges": how, "sizeMm": radius * 1000.0, "route": route, "proven": false}))+                Ok(json!({"feature": feature.string("Name", &[]), "edgesSelected": want, "edges": how, "sizeMm": radius * 1000.0, "route": route, "proven": false}))             }              Op::Shell { thickness, faces, outward } => {@@ -5807,19 +5815,19 @@ impl ComBackend {                 // 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 picked: Vec<Option<PickedFace>> = 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);+                    picked.push(Some(f));                 }-                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 keep: Vec<SelItem> = crate::modeling::dedupe(&keys).into_iter().filter_map(|i| picked[i].take()).map(SelItem::face).collect();+                let want = keep.len();+                let r = self.select_items(&doc, keep, 1)?;+                if !r.all(want) {                     let _ = Self::clear_selection(&doc);-                    return Err(HostError::msg(format!("only {n} of {} faces ended up selected — no shell was made", keep.len())));+                    return Err(HostError::msg(format!("only {} of {want} faces ended up selected — no shell was made ({})", r.count, r.notes.join("; "))));                 }                 // InsertFeatureShell(Thickness, Outward) returns nothing (2001Plus),                 // so the new feature is found as the tree's new last feature.@@ -5829,7 +5837,8 @@ impl ComBackend {                 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": keep.len(), "outward": outward, "proven": false}))+                Ok(json!({"feature": after, "thicknessMm": thickness * 1000.0, "facesRemoved": want, "outward": outward, "proven": false,+                          "selectedVia": r.report(), "viewDependent": r.view_dependent()}))             }              Op::LinearPattern { feature, dirs } => {@@ -6215,25 +6224,69 @@ impl ComBackend {         (!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);-        }+    /// SELECT PICKED ENTITIES, VERIFIED (0.10.3): the selection LADDER+    /// (`modeling::select_ladder`) — MultiSelect2 with a typed array, then per+    /// entity IEntity::Select4, SelectByRay with the bridge's own ray, and only+    /// then SelectByID2 at the point (view-dependent, and reported so). Every+    /// rung is believed only when GetSelectedObjectCount2 grows by what it was+    /// asked. Starts from an EMPTY selection.+    fn select_items(&mut self, doc: &Disp, items: Vec<SelItem>, mark: i32) -> Result<crate::modeling::Selected, HostError> {+        let n = items.len();         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,-        })+        let exe = self.app(false, true).ok().and_then(|sw| sw.string("GetExecutablePath", &[])).unwrap_or_default();+        let major = self.major_version();+        let mut s = ComSelector { doc: doc.clone(), ext, sel, data, mark, items, exe, major, iid: None };+        Ok(crate::modeling::select_ladder(&mut s, n))+    }++    /// A face's OUTWARD normal at a point: IFace2::Normal for a plane, else the+    /// surface's normal there (ISurface::EvaluateAtPoint, 2001Plus) flipped+    /// when IFace2::FaceInSurfaceSense says the face runs against its surface.+    fn face_outward(face: &Disp, p: crate::modeling::V3) -> Option<crate::modeling::V3> {+        use crate::modeling::{norm, scale};+        let n = Self::floats(&face.call("Normal", &[]).ok()?);+        if n.len() >= 3 && norm([n[0], n[1], n[2]]) > 1e-9 {+            return Some([n[0], n[1], n[2]]);+        }+        let surf = face.obj("GetSurface", &[]).ok().flatten()?;+        let e = Self::floats(&surf.call("EvaluateAtPoint", &[&Var::r8(p[0]), &Var::r8(p[1]), &Var::r8(p[2])]).ok()?);+        if e.len() < 3 || norm([e[0], e[1], e[2]]) < 1e-9 {+            return None;+        }+        let flip = face.call("FaceInSurfaceSense", &[]).ok().and_then(|v| v.as_bool()).unwrap_or(false);+        Some(scale([e[0], e[1], e[2]], if flip { -1.0 } else { 1.0 }))+    }++    /// A point ON an edge, away from its ends: its curve at the middle of its+    /// parameter range (IEdge::GetCurveParams2 [start, end, t0, t1, ...] →+    /// ICurve::Evaluate2); failing that, the edge's closest point to the+    /// middle of its chord.+    fn edge_point(edge: &Disp) -> Option<crate::modeling::V3> {+        let p = Self::floats(&edge.call("GetCurveParams2", &[]).ok()?);+        if p.len() >= 8 {+            if let Some(curve) = edge.obj("GetCurve", &[]).ok().flatten() {+                if let Ok(v) = curve.call("Evaluate2", &[&Var::r8((p[6] + p[7]) / 2.0), &Var::i4(0)]) {+                    let mut f = Self::floats(&v);+                    if f.len() < 3 {+                        f = v.as_array().first().map(Self::floats).unwrap_or_default();+                    }+                    if f.len() >= 3 {+                        return Some([f[0], f[1], f[2]]);+                    }+                }+            }+        }+        if p.len() >= 6 {+            let m = [(p[0] + p[3]) / 2.0, (p[1] + p[4]) / 2.0, (p[2] + p[5]) / 2.0];+            let c = Self::floats(&edge.call("GetClosestPointOn", &[&Var::r8(m[0]), &Var::r8(m[1]), &Var::r8(m[2])]).ok()?);+            if c.len() >= 3 {+                return Some([c[0], c[1], c[2]]);+            }+        }+        None     }      /// THE GEOMETRIC FACE PICK (review #1): every face of every visible solid@@ -6273,10 +6326,10 @@ impl ComBackend {     }      /// THE GEOMETRIC EDGE PICK: IEdge::GetClosestPointOn over every edge.-    fn pick_edge(doc: &Disp, p: crate::modeling::V3) -> Result<Disp, HostError> {+    fn pick_edge(doc: &Disp, p: crate::modeling::V3) -> Result<(Disp, crate::modeling::V3), 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 edges: Vec<(Disp, crate::modeling::V3)> = 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)) {@@ -6285,7 +6338,7 @@ impl ComBackend {                     continue;                 }                 cands.push((edges.len(), norm(sub(p, [c[0], c[1], c[2]])), 0.0));-                edges.push(e);+                edges.push((e, [c[0], c[1], c[2]]));             }         }         match crate::modeling::pick(&cands, PICK_TOL) {@@ -6301,12 +6354,13 @@ impl ComBackend {     /// 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 {+    fn collect_edges(&mut self, doc: &Disp, sel: &crate::script::EdgeSel, st: &ScriptState) -> Result<(Vec<SelItem>, Value), HostError> {+        let (listed, how): (Vec<SelItem>, Value) = match sel {             crate::script::EdgeSel::AtPoints(ps) => {                 let mut v = Vec::new();                 for p in ps {-                    v.push(Self::pick_edge(doc, *p)?);+                    let (e, at) = Self::pick_edge(doc, *p)?;+                    v.push(SelItem::edge(e, Some(at)));                 }                 (v, json!({"atPoints": ps.len()}))             }@@ -6317,7 +6371,7 @@ impl ComBackend {                 // 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.push(SelItem::edge(e, None));                     }                 }                 (v, json!({"allOfFeature": name}))@@ -6329,7 +6383,8 @@ impl ComBackend {         let keys: Vec<String> = listed             .iter()             .enumerate()-            .map(|(i, e)| {+            .map(|(i, it)| {+                let e = &it.ent;                 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 {@@ -6340,12 +6395,14 @@ impl ComBackend {                 })             })             .collect();-        let keep: Vec<Disp> = crate::modeling::dedupe(&keys).into_iter().map(|i| listed[i].clone()).collect();+        let mut listed: Vec<Option<SelItem>> = listed.into_iter().map(Some).collect();+        let n_listed = listed.len();+        let keep: Vec<SelItem> = crate::modeling::dedupe(&keys).into_iter().filter_map(|i| listed[i].take()).collect();         if keep.is_empty() {             return Err(HostError::msg("no edges to select"));         }         let mut how = how;-        how["listed"] = json!(listed.len());+        how["listed"] = json!(n_listed);         how["unique"] = json!(keep.len());         Ok((keep, how))     }@@ -6364,6 +6421,7 @@ impl ComBackend {         };         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 mut via: Option<(Value, bool)> = None;         let (holder, prop, route): (Disp, &str, String) = match at {             AppearanceTarget::Part => (doc.clone(), "MaterialPropertyValues", "part".into()),             AppearanceTarget::Body(name) => {@@ -6387,10 +6445,15 @@ impl ComBackend {                 // (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)?;+                let face = picked.face.clone();+                let r = self.select_items(doc, vec![SelItem::face(picked)], 0)?;+                if !r.all(1) {+                    let _ = Self::clear_selection(doc);+                    return Err(HostError::msg(format!("the face could not be selected to write its colour ({})", r.notes.join("; "))));+                }                 face_selected = true;-                (picked.face, "MaterialPropertyValues", "face".into())+                via = Some((r.report(), r.view_dependent()));+                (face, "MaterialPropertyValues", "face".into())             }         };         let next = merged(read(&holder, prop));@@ -6410,7 +6473,12 @@ impl ComBackend {             )));         }         let _ = doc.call("GraphicsRedraw2", &[]);-        Ok(json!({"rgb": to255(rgb), "target": route, "route": route, "readBack": to255(&back), "proven": false}))+        let mut out = json!({"rgb": to255(rgb), "target": route, "route": route, "readBack": to255(&back), "proven": false});+        if let Some((v, d)) = via {+            out["selectedVia"] = v;+            out["viewDependent"] = json!(d);+        }+        Ok(out)     }      /// WHERE AN OFFSET PLANE GOES, VERIFIED (review #2, #13). The plane is found@@ -6538,7 +6606,12 @@ impl ComBackend {         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<_>>()))?;+        // The typed object array first (the shape MultiSelect2 needed, 0.10.3),+        // the VARIANT-wrapped one if the property refuses it.+        let raw: Vec<_> = edges.iter().map(|d| d.0.clone()).collect();+        if data.put("Edges", &Var::dispatch_typed_array(&raw)).is_err() {+            data.put("Edges", &Var::dispatch_array(&raw))?;+        }         fm.call("CreateFeature", &[&Var::dispatch(&data.0)])     } }@@ -6554,6 +6627,212 @@ struct PickedFace {     normal: crate::modeling::V3, } +/// What a picked entity is, for SelectByRay / SelectByID2.+#[derive(Clone, Copy, PartialEq)]+enum SelKind {+    Face,+    Edge,+}++/// One entity to select, with what the ray rung needs: a point ON it and its+/// outward direction (a face's normal; filled lazily for an edge).+struct SelItem {+    ent: Disp,+    kind: SelKind,+    at: Option<crate::modeling::V3>,+    outward: Option<crate::modeling::V3>,+}++impl SelItem {+    fn face(p: PickedFace) -> SelItem {+        let outward = (crate::modeling::norm(p.normal) > 1e-9).then_some(p.normal).or_else(|| ComBackend::face_outward(&p.face, p.closest));+        SelItem { ent: p.face, kind: SelKind::Face, at: Some(p.closest), outward }+    }+    fn edge(e: Disp, at: Option<crate::modeling::V3>) -> SelItem {+        SelItem { ent: e, kind: SelKind::Edge, at, outward: None }+    }+}++/// The COM host's side of the selection ladder.+struct ComSelector {+    doc: Disp,+    ext: Disp,+    sel: Disp,+    data: Disp,+    mark: i32,+    items: Vec<SelItem>,+    exe: String,+    major: Option<u32>,+    iid: Option<Result<windows::core::GUID, String>>,+}++impl ComSelector {+    /// Where the entity is, and which way is OUT of it — computed only when a+    /// rung that needs it is reached (an edge: its two faces' normals).+    fn locate(&mut self, i: usize) -> Result<(crate::modeling::V3, crate::modeling::V3), String> {+        let it = &mut self.items[i];+        if it.at.is_none() {+            it.at = ComBackend::edge_point(&it.ent);+        }+        let at = it.at.ok_or("no point on the entity could be computed")?;+        if it.outward.is_none() && it.kind == SelKind::Edge {+            let faces: Vec<Disp> = it.ent.call("GetTwoAdjacentFaces2", &[]).map(|v| v.as_array()).unwrap_or_default().iter().filter_map(|f| f.as_dispatch().map(Disp)).collect();+            let normals: Vec<crate::modeling::V3> = faces.iter().filter_map(|f| ComBackend::face_outward(f, at)).collect();+            it.outward = crate::modeling::edge_ray(at, &normals).map(|(_, d)| crate::modeling::scale(d, -1.0));+        }+        let out = it.outward.ok_or("its outward direction could not be computed")?;+        Ok((at, out))+    }++    fn entity_iid(&mut self) -> Result<windows::core::GUID, String> {+        if self.iid.is_none() {+            self.iid = Some(interop_dual_iid(&self.exe, "IEntity"));+        }+        self.iid.clone().unwrap_or_else(|| Err("no IID".into()))+    }++    fn type_name(&self, i: usize) -> (&'static str, i32) {+        // swSelectType_e: swSelFACES 2, swSelEDGES 1.+        match self.items[i].kind {+            SelKind::Face => ("FACE", 2),+            SelKind::Edge => ("EDGE", 1),+        }+    }+}++impl crate::modeling::Selector for ComSelector {+    fn clear(&mut self) {+        let _ = self.doc.call("ClearSelection2", &[&Var::boolean(true)]);+    }++    fn count(&mut self) -> Option<usize> {+        self.sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).map(|c| c.max(0) as usize)+    }++    fn attempt(&mut self, rung: crate::modeling::Rung, items: &[usize]) -> Result<(), String> {+        use crate::modeling::Rung;+        let one = |items: &[usize]| -> Result<usize, String> { items.first().copied().ok_or_else(|| "no entity".to_string()) };+        match rung {+            Rung::MultiSelectVariantArray => {+                let arr = Var::dispatch_array(&items.iter().map(|i| self.items[*i].ent.0.clone()).collect::<Vec<_>>());+                self.ext.call("MultiSelect2", &[&arr, &Var::boolean(true), &Var::dispatch(&self.data.0)]).map(|_| ()).map_err(|e| e.message)+            }+            Rung::MultiSelectTyped => {+                let arr = Var::dispatch_typed_array(&items.iter().map(|i| self.items[*i].ent.0.clone()).collect::<Vec<_>>());+                self.ext.call("MultiSelect2", &[&arr, &Var::boolean(true), &Var::dispatch(&self.data.0)]).map(|_| ()).map_err(|e| e.message)+            }+            Rung::EntitySelect4 => {+                let i = one(items)?;+                let ent = self.items[i].ent.clone();+                // Late binding on an IFace2/IEdge resolves only ITS names; IEntity's+                // Select4 is reached through the object's IEntity interface (QI with+                // the IID read from the installed interop).+                let target = if ent.dispid("Select4").is_ok() {+                    ent+                } else {+                    let iid = self.entity_iid()?;+                    ent.query_dual(&iid).ok_or("QueryInterface for IEntity failed")?+                };+                target.call("Select4", &[&Var::boolean(true), &Var::dispatch(&self.data.0)]).map(|_| ()).map_err(|e| e.message)+            }+            Rung::SelectByRay => {+                if self.major.map(|m| m < 25).unwrap_or(false) {+                    return Err("IModelDocExtension::SelectByRay needs SOLIDWORKS 2017".into());+                }+                let i = one(items)?;+                let (at, out) = self.locate(i)?;+                let (o, d) = crate::modeling::face_ray(at, out).ok_or("no ray")?;+                let (_, t) = self.type_name(i);+                self.ext+                    .call(+                        "SelectByRay",+                        &[+                            &Var::r8(o[0]),+                            &Var::r8(o[1]),+                            &Var::r8(o[2]),+                            &Var::r8(d[0]),+                            &Var::r8(d[1]),+                            &Var::r8(d[2]),+                            &Var::r8(1.0e-4),+                            &Var::i4(t),+                            &Var::boolean(true),+                            &Var::i4(self.mark),+                            &Var::i4(0),+                        ],+                    )+                    .map(|_| ())+                    .map_err(|e| e.message)+            }+            Rung::SelectByIdAtPoint => {+                let i = one(items)?;+                if self.items[i].at.is_none() {+                    self.items[i].at = ComBackend::edge_point(&self.items[i].ent);+                }+                let at = self.items[i].at.ok_or("no point on the entity could be computed")?;+                let (kind, _) = self.type_name(i);+                self.ext+                    .call(+                        "SelectByID2",+                        &[+                            &Var::bstr(""),+                            &Var::bstr(kind),+                            &Var::r8(at[0]),+                            &Var::r8(at[1]),+                            &Var::r8(at[2]),+                            &Var::boolean(true),+                            &Var::i4(self.mark),+                            &Var::null_dispatch(),+                            &Var::i4(0),+                        ],+                    )+                    .map(|_| ())+                    .map_err(|e| e.message)+            }+        }+    }+}++/// The IID of a SOLIDWORKS interface, read from the INSTALLED interop+/// (SolidWorks.Interop.sldworks.dll beside SOLIDWORKS, or api\redist) by+/// reflection — and only if that interface is DUAL, so calling it through+/// IDispatch is sound. Cached.+fn interop_dual_iid(exe_dir: &str, name: &str) -> Result<windows::core::GUID, String> {+    use std::sync::Mutex;+    static CACHE: Mutex<Vec<(String, windows::core::GUID)>> = Mutex::new(Vec::new());+    if let Some((_, g)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(n, _)| n == name) {+        return Ok(*g);+    }+    let base = std::path::Path::new(exe_dir.trim_end_matches(['\\', '/']));+    let dll = [base.join("SolidWorks.Interop.sldworks.dll"), base.join("api").join("redist").join("SolidWorks.Interop.sldworks.dll")]+        .into_iter()+        .find(|p| p.is_file())+        .ok_or_else(|| format!("SolidWorks.Interop.sldworks.dll was not found beside SOLIDWORKS ({exe_dir})"))?;+    // InterfaceTypeAttribute 0 = InterfaceIsDual; TypeLibTypeAttribute flag 0x40 = FDual.+    let script = format!(+        "$ErrorActionPreference='Stop'; $a=[Reflection.Assembly]::LoadFrom('{}'); \+         $t=$a.GetType('SolidWorks.Interop.sldworks.{}'); \+         $it=$t.GetCustomAttributes([Runtime.InteropServices.InterfaceTypeAttribute],$false) | Select-Object -First 1; \+         $tl=$t.GetCustomAttributes([Runtime.InteropServices.TypeLibTypeAttribute],$false) | Select-Object -First 1; \+         [Console]::WriteLine('guid=' + $t.GUID.ToString()); \+         if($it){{ [Console]::WriteLine('itype=' + [int]$it.Value) }}; \+         if($tl){{ [Console]::WriteLine('tlflags=' + [int]$tl.Value) }}",+        dll.display().to_string().replace('\'', "''"),+        name+    );+    let out = std::process::Command::new("powershell.exe")+        .args(["-NoProfile", "-NonInteractive", "-Command", &script])+        .output()+        .map_err(|e| format!("could not run powershell to read the {name} IID: {e}"))?;+    let text = String::from_utf8_lossy(&out.stdout);+    let (guid, dual) = crate::modeling::parse_iid_probe(&text).ok_or_else(|| format!("the {name} IID could not be read from {} ({})", dll.display(), String::from_utf8_lossy(&out.stderr).trim()))?;+    if !dual {+        return Err(format!("{name} is not a dual interface in {} — it cannot be called by name", dll.display()));+    }+    let guid = windows::core::GUID::from_u128(guid);+    CACHE.lock().unwrap_or_else(|e| e.into_inner()).push((name.to_string(), guid));+    Ok(guid)+}+ #[derive(Clone)] struct DefaultPlane {     name: String,
src/comhost/mock.rs+21−2
@@ -275,6 +275,25 @@ fn fault(name: &str) -> bool {     crate::scratch::root().join(name).exists() } +/// A fresh script run on the simulated install. Selection behaves as the first+/// live run on SOLIDWORKS 2026 measured (the VARIANT-array MultiSelect2 selects+/// nothing); `mock-select-dead-<rung>` kills a rung of the ladder too.+fn fresh_run() -> crate::comhost::mock_model::Run {+    use crate::modeling::Rung;+    let mut profile = crate::modeling::SelProfile::measured_2026();+    for (f, r) in [+        ("mock-select-dead-typed", Rung::MultiSelectTyped),+        ("mock-select-dead-select4", Rung::EntitySelect4),+        ("mock-select-dead-ray", Rung::SelectByRay),+        ("mock-select-dead-byid", Rung::SelectByIdAtPoint),+    ] {+        if fault(f) {+            profile.dead.push(r);+        }+    }+    crate::comhost::mock_model::Run { sel_profile: profile, ..Default::default() }+}+ /// THE ORDER OF EVENTS, WRITTEN DOWN. /// /// The safety property of the close sweep is not a fact about the final state —@@ -3703,7 +3722,7 @@ impl MockBackend {                 }                 let mut steps = Vec::new();                 let mut created = Value::Null;-                let mut run = crate::comhost::mock_model::Run::default();+                let mut run = fresh_run();                 let mut failure: Option<HostError> = None;                 for (i, op) in ops.iter().enumerate() {                     let target = pin_arg(&pinned);@@ -3714,7 +3733,7 @@ impl MockBackend {                                 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();+                                run = fresh_run();                                 r                             }                             Op::SetProp { name, value } => {
src/comhost/mock_model.rs+49−6
@@ -96,6 +96,21 @@ pub struct Run {     pub sketch_selected: bool,     pub last_feature: Option<String>,     pub appearances: Vec<(AppearanceTarget, [f64; 3])>,+    /// Which selection calls really select on this simulated install (the+    /// default is what the first live run on SOLIDWORKS 2026 measured).+    pub sel_profile: modeling::SelProfile,+}++/// Select `n` picked entities through THE SAME LADDER as the COM host+/// (modeling::select_ladder), failing with the COM host's words when not all+/// of them end up selected.+fn select_n(run: &Run, n: usize, what: &str, nothing: &str) -> Result<Value, String> {+    let mut s = modeling::ProfileSelector::new(run.sel_profile.clone());+    let r = modeling::select_ladder(&mut s, n);+    if !r.all(n) {+        return Err(format!("only {} of {n} {what} ended up selected — {nothing} ({})", r.count, r.notes.join("; ")));+    }+    Ok(json!({"selectedVia": r.report(), "viewDependent": r.view_dependent()})) }  fn mm(v: f64) -> f64 {@@ -812,7 +827,17 @@ pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result<     }     let out = match op {         Op::Sketch { plane, entities, .. } => {-            let (p, report) = model.sketch(plane, entities)?;+            let via = if let PlaneRef::Face { point } = plane {+                model.pick_face(*point)?;+                Some(select_n(run, 1, "faces", "nothing to sketch on").map_err(|e| format!("the picked face could not be selected ({e})"))?)+            } else {+                None+            };+            let (p, mut report) = model.sketch(plane, entities)?;+            if let Some(v) = via {+                report["resolvedPlane"]["selectedVia"] = v["selectedVia"].clone();+                report["resolvedPlane"]["viewDependent"] = v["viewDependent"].clone();+            }             run.pending = Some(p);             run.sketch_selected = true;             report@@ -831,7 +856,10 @@ pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result<         }         Op::Fillet { radius, edges } => {             run.sketch_selected = false;-            let (sel, how) = model.select_edges(edges, &run.last_feature, true)?;+            let (sel, mut how) = model.select_edges(edges, &run.last_feature, true)?;+            let via = select_n(run, sel.len(), "edges", "nothing was made")?;+            how["selectedVia"] = via["selectedVia"].clone();+            how["viewDependent"] = via["viewDependent"].clone();             let mut r = model.edge_feature(true, *radius, 0.0, &sel)?;             r["edges"] = how;             r["route"] = json!("FeatureFillet3");@@ -839,14 +867,22 @@ pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result<         }         Op::Chamfer { distance, angle, edges } => {             run.sketch_selected = false;-            let (sel, how) = model.select_edges(edges, &run.last_feature, true)?;+            let (sel, mut how) = model.select_edges(edges, &run.last_feature, true)?;+            let via = select_n(run, sel.len(), "edges", "nothing was made")?;+            how["selectedVia"] = via["selectedVia"].clone();+            how["viewDependent"] = via["viewDependent"].clone();             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)?+            let picked: Vec<FaceRef> = faces.iter().map(|p| model.pick_face(*p)).collect::<Result<_, _>>()?;+            let via = select_n(run, modeling::dedupe(&picked).len(), "faces", "no shell was made")?;+            let mut r = model.shell(*thickness, faces, *outward)?;+            r["selectedVia"] = via["selectedVia"].clone();+            r["viewDependent"] = via["viewDependent"].clone();+            r         }         Op::LinearPattern { feature, dirs } => {             run.sketch_selected = false;@@ -859,10 +895,17 @@ pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result<             model.circular_pattern(&seed, *axis, *count, *angle)?         }         Op::SetAppearance { rgb, target } => {-            if matches!(target, AppearanceTarget::FaceAt(_)) {+            let mut via = None;+            if let AppearanceTarget::FaceAt(p) = target {                 run.sketch_selected = false;+                model.pick_face(*p)?;+                via = Some(select_n(run, 1, "faces", "the face could not be selected to write its colour")?);+            }+            let mut r = model.set_appearance(target, rgb)?;+            if let Some(v) = via {+                r["selectedVia"] = v["selectedVia"].clone();+                r["viewDependent"] = v["viewDependent"].clone();             }-            let r = model.set_appearance(target, rgb)?;             run.appearances.retain(|(t, _)| t != target);             run.appearances.push((target.clone(), *rgb));             r
src/comhost/variant.rs+36
@@ -167,6 +167,28 @@ impl Var {         }     } +    /// A TYPED object array: SAFEARRAY of VT_DISPATCH (VT_ARRAY|VT_DISPATCH),+    /// the shape .NET's `object[] Objects` (IModelDocExtension::MultiSelect2)+    /// marshals as. The VARIANT-wrapped form above was MEASURED on SOLIDWORKS+    /// 2026 to select nothing (0.10.3).+    pub fn dispatch_typed_array(items: &[IDispatch]) -> Var {+        unsafe {+            let psa = SafeArrayCreateVector(VT_DISPATCH, 0, items.len() as u32);+            if psa.is_null() {+                return Var::empty();+            }+            for (i, d) in items.iter().enumerate() {+                let idx = [i as i32];+                // For VT_DISPATCH the element IS the interface pointer (no extra+                // indirection); SafeArrayPutElement AddRefs it.+                let _ = SafeArrayPutElement(psa, idx.as_ptr(), d.as_raw() as *const std::ffi::c_void);+            }+            build(VARENUM(VT_ARRAY.0 | VT_DISPATCH.0), |v| {+                (*v.Anonymous.Anonymous).Anonymous.parray = psa;+            })+        }+    }+     /// `[ref] i32` — the byref out-parameter shape SOLIDWORKS uses everywhere     /// (OpenDoc6 errors/warnings, Save3, ...). The i32 must outlive the call.     pub fn byref_i4(p: *mut i32) -> Var {@@ -417,6 +439,20 @@ impl Disp {         Ok(ids[0])     } +    /// QueryInterface for another DUAL interface of the same object (e.g. an+    /// IFace2's IEntity), so late binding resolves THAT interface's names.+    /// The caller must know `iid` is dual — a vtable-only interface called+    /// through IDispatch slots would be a wild call.+    pub fn query_dual(&self, iid: &GUID) -> Option<Disp> {+        let mut p: *mut c_void = std::ptr::null_mut();+        unsafe {+            if self.0.query(iid, &mut p).is_err() || p.is_null() {+                return None;+            }+            Some(Disp(IDispatch::from_raw(p)))+        }+    }+     /// SOLIDWORKS is a single-threaded-apartment server with no message filter     /// of its own: while it is mid-rebuild or pumping its UI it REJECTS incoming     /// calls rather than queueing them. Retrying is the documented answer.
src/modeling.rs+344
@@ -303,6 +303,259 @@ pub fn extent_span(o: f64, own: f64, e: &Extent, through: (f64, f64)) -> (f64, f     } } +// ---------------------------------------------------------------------------+// THE SELECTION LADDER (0.10.3)+//+// The first live run on SOLIDWORKS 2026 showed every selection after a+// geometric pick selecting NOTHING: IModelDocExtension::MultiSelect2 given a+// SAFEARRAY of VARIANTs (each wrapping an IDispatch) returned 0 and listed+// nothing. So no single selection call is trusted: each RUNG is tried in turn+// and is believed only when ISelectionMgr::GetSelectedObjectCount2 GROWS by+// exactly what was asked. The rung that worked is reported (`selectedVia`).+// ---------------------------------------------------------------------------++/// One way of selecting a picked entity.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Rung {+    /// MultiSelect2 with a SAFEARRAY of VARIANT(VT_DISPATCH) — the 0.10.2 call,+    /// MEASURED on SOLIDWORKS 2026 to select nothing. Not on the ladder; named+    /// so the simulator can model what was measured.+    MultiSelectVariantArray,+    /// MultiSelect2 with a TYPED SAFEARRAY of VT_DISPATCH (what .NET's+    /// `object[] Objects` marshals as). The whole batch at once.+    MultiSelectTyped,+    /// IEntity::Select4(append, ISelectData), per entity, through the entity's+    /// IEntity interface.+    EntitySelect4,+    /// IModelDocExtension::SelectByRay (2017+) with the bridge's OWN ray: from+    /// just outside the picked point back onto it. View-independent.+    SelectByRay,+    /// IModelDocExtension::SelectByID2 at the picked point — goes through the+    /// VIEW, so a hidden face can be missed or the wrong one taken. Last resort.+    SelectByIdAtPoint,+}++impl Rung {+    pub fn label(self) -> &'static str {+        match self {+            Rung::MultiSelectVariantArray => "MultiSelect2(VARIANT array)",+            Rung::MultiSelectTyped => "MultiSelect2(typed array)",+            Rung::EntitySelect4 => "IEntity.Select4",+            Rung::SelectByRay => "SelectByRay",+            Rung::SelectByIdAtPoint => "SelectByID2 at the point (view-dependent)",+        }+    }+    pub fn view_dependent(self) -> bool {+        matches!(self, Rung::SelectByIdAtPoint)+    }+}++/// The batch rung, tried once for all entities.+pub const BATCH_RUNG: Rung = Rung::MultiSelectTyped;+/// The per-entity rungs, in order, for whatever the batch did not select.+pub const PER_ENTITY_RUNGS: [Rung; 3] = [Rung::EntitySelect4, Rung::SelectByRay, Rung::SelectByIdAtPoint];++/// What the ladder needs from a selection backend (the COM host, or the+/// simulator). `attempt` APPENDS; its own return value is never believed —+/// only `count` is.+pub trait Selector {+    fn clear(&mut self);+    /// The number of selected objects (GetSelectedObjectCount2(-1)).+    fn count(&mut self) -> Option<usize>;+    /// Try to select `items` (indices into the caller's list) by `rung`.+    /// `Err` = that rung is unavailable for them (no API, no ray, ...).+    fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String>;+}++/// The outcome: how many ended up selected (by the count), and per entity the+/// rung that selected it (`None` = nothing did).+#[derive(Clone, Debug, PartialEq)]+pub struct Selected {+    pub count: usize,+    pub via: Vec<Option<Rung>>,+    pub notes: Vec<String>,+}++impl Selected {+    pub fn all(&self, n: usize) -> bool {+        self.count == n && self.via.iter().all(|v| v.is_some())+    }+    /// The `selectedVia` report: one label when one rung did it all, else the+    /// list per entity.+    pub fn report(&self) -> serde_json::Value {+        let labels: Vec<serde_json::Value> =+            self.via.iter().map(|v| v.map(|r| serde_json::json!(r.label())).unwrap_or(serde_json::Value::Null)).collect();+        match labels.first() {+            Some(first) if labels.iter().all(|l| l == first) => first.clone(),+            _ => serde_json::Value::Array(labels),+        }+    }+    pub fn view_dependent(&self) -> bool {+        self.via.iter().any(|v| v.map(|r| r.view_dependent()).unwrap_or(false))+    }+}++/// Select `n` entities, starting from an EMPTY selection, rung by rung, every+/// rung VERIFIED by the selection count.+pub fn select_ladder<S: Selector>(s: &mut S, n: usize) -> Selected {+    let mut via = vec![None; n];+    let mut notes = Vec::new();+    if n == 0 {+        return Selected { count: 0, via, notes };+    }+    s.clear();+    let Some(base) = s.count() else {+        notes.push("the selection count could not be read, so no selection could be verified".into());+        return Selected { count: 0, via, notes };+    };+    let all: Vec<usize> = (0..n).collect();+    match s.attempt(BATCH_RUNG, &all) {+        Ok(()) if s.count() == Some(base + n) => {+            return Selected { count: n, via: vec![Some(BATCH_RUNG); n], notes };+        }+        Ok(()) => {+            notes.push(format!("{} selected {} of {n}", BATCH_RUNG.label(), s.count().unwrap_or(base).saturating_sub(base)));+            s.clear();+        }+        Err(e) => notes.push(format!("{}: {e}", BATCH_RUNG.label())),+    }+    let mut have = s.count().unwrap_or(base);+    for (i, slot) in via.iter_mut().enumerate() {+        let mut why: Vec<String> = Vec::new();+        for rung in PER_ENTITY_RUNGS {+            match s.attempt(rung, &[i]) {+                Ok(()) => {+                    let now = s.count().unwrap_or(have);+                    if now == have + 1 {+                        *slot = Some(rung);+                        have = now;+                        break;+                    }+                    why.push(format!("{} changed the count by {}", rung.label(), now as i64 - have as i64));+                    have = now;+                }+                Err(e) => why.push(format!("{}: {e}", rung.label())),+            }+        }+        if slot.is_none() {+            notes.push(format!("entity {i}: nothing selected it ({})", why.join("; ")));+        }+    }+    let count = s.count().unwrap_or(have).saturating_sub(base);+    Selected { count, via, notes }+}++/// The ray SelectByRay is given for a FACE: from 1 mm outside the picked point+/// along the outward normal, pointing back in. Its own ray — not the view's.+pub fn face_ray(point: V3, outward: V3) -> Option<(V3, V3)> {+    let l = norm(outward);+    if l < 1e-9 {+        return None;+    }+    let n = scale(outward, 1.0 / l);+    Some((add(point, scale(n, 1.0e-3)), scale(n, -1.0)))+}++/// The ray for an EDGE: from 1 mm out along the bisector of its two faces'+/// outward normals, pointing at the edge point. One normal will do.+pub fn edge_ray(point: V3, normals: &[V3]) -> Option<(V3, V3)> {+    let mut b = [0.0; 3];+    for n in normals {+        let l = norm(*n);+        if l > 1e-9 {+            b = add(b, scale(*n, 1.0 / l));+        }+    }+    face_ray(point, b)+}++/// A simulated install: which rungs really select. `measured_2026` is the+/// first live run — the VARIANT-array MultiSelect2 selected nothing.+#[derive(Clone, Debug)]+pub struct SelProfile {+    pub dead: Vec<Rung>,+}++impl Default for SelProfile {+    fn default() -> Self {+        SelProfile::measured_2026()+    }+}++impl SelProfile {+    pub fn measured_2026() -> Self {+        SelProfile { dead: vec![Rung::MultiSelectVariantArray] }+    }+}++/// The simulator's selection list: a rung on the dead list "succeeds" but+/// selects nothing (exactly what was measured live); a live one appends — and,+/// like SOLIDWORKS, re-selecting a selected entity DESELECTS it.+pub struct ProfileSelector {+    pub profile: SelProfile,+    pub selected: Vec<usize>,+    pub calls: Vec<Rung>,+}++impl ProfileSelector {+    pub fn new(profile: SelProfile) -> Self {+        ProfileSelector { profile, selected: Vec::new(), calls: Vec::new() }+    }+}++impl Selector for ProfileSelector {+    fn clear(&mut self) {+        self.selected.clear();+    }+    fn count(&mut self) -> Option<usize> {+        Some(self.selected.len())+    }+    fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String> {+        self.calls.push(rung);+        if self.profile.dead.contains(&rung) {+            return Ok(());+        }+        for i in items {+            if let Some(k) = self.selected.iter().position(|s| s == i) {+                self.selected.remove(k);+            } else {+                self.selected.push(*i);+            }+        }+        Ok(())+    }+}++/// Read the interop probe's output (`guid=…`, `itype=…`, `tlflags=…`) into+/// the IID and whether the interface can be called through IDispatch:+/// InterfaceType Dual (0) or IDispatch (2), or TypeLibType FDual (0x40) /+/// FDispatchable (0x1000); an IUnknown-only interface (1) never.+pub fn parse_iid_probe(text: &str) -> Option<(u128, bool)> {+    let mut guid = None;+    let mut itype: Option<i64> = None;+    let mut flags: Option<i64> = None;+    for l in text.lines() {+        match l.trim().split_once('=') {+            Some(("guid", v)) => {+                let hex: String = v.trim().chars().filter(|c| *c != '-' && *c != '{' && *c != '}').collect();+                if hex.len() == 32 {+                    guid = u128::from_str_radix(&hex, 16).ok();+                }+            }+            Some(("itype", v)) => itype = v.trim().parse().ok(),+            Some(("tlflags", v)) => flags = v.trim().parse().ok(),+            _ => {}+        }+    }+    let g = guid.filter(|g| *g != 0)?;+    let dual = match itype {+        Some(1) => false,+        Some(0) | Some(2) => true,+        _ => flags.map(|f| f & (0x40 | 0x1000) != 0).unwrap_or(false),+    };+    Some((g, dual))+}+ #[cfg(test)] mod tests {     use super::*;@@ -401,4 +654,95 @@ mod tests {         assert_eq!(extent_span(0.0, 1.0, &e(10.0, 10.0, Direction::MidPlane, false), (0.0, 10.0)), (-5.0, 5.0));         assert_eq!(extent_span(0.0, 1.0, &e(0.0, 0.0, Direction::One, true), (0.0, 10.0)), (0.0, 10.0));     }++    /// A backend whose rungs CLAIM success; only some really select.+    #[test]+    fn the_ladder_believes_the_count_not_the_call() {+        // As measured on SOLIDWORKS 2026 with the typed array ALSO dead: every+        // entity falls through to IEntity::Select4.+        let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectVariantArray, Rung::MultiSelectTyped] });+        let r = select_ladder(&mut s, 3);+        assert!(r.all(3), "{r:?}");+        assert_eq!(r.via, vec![Some(Rung::EntitySelect4); 3]);+        assert_eq!(r.report(), serde_json::json!("IEntity.Select4"));+        assert!(!r.view_dependent());+        assert!(r.notes[0].contains("selected 0 of 3"), "{:?}", r.notes);+        // The typed batch works: one call, nothing else tried.+        let mut s = ProfileSelector::new(SelProfile::measured_2026());+        let r = select_ladder(&mut s, 12);+        assert!(r.all(12));+        assert_eq!(s.calls, vec![Rung::MultiSelectTyped]);+        // Everything but the view-dependent last resort dead: it is used, and SAID.+        let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectTyped, Rung::EntitySelect4, Rung::SelectByRay] });+        let r = select_ladder(&mut s, 1);+        assert!(r.all(1));+        assert!(r.view_dependent());+        assert_eq!(r.report(), serde_json::json!("SelectByID2 at the point (view-dependent)"));+        // Nothing works: nothing is claimed.+        let mut s = ProfileSelector::new(SelProfile { dead: vec![Rung::MultiSelectTyped, Rung::EntitySelect4, Rung::SelectByRay, Rung::SelectByIdAtPoint] });+        let r = select_ladder(&mut s, 2);+        assert_eq!(r.count, 0);+        assert!(!r.all(2));+        assert_eq!(r.report(), serde_json::json!(null));+        assert_eq!(r.notes.len(), 3, "the batch, then one line per entity: {:?}", r.notes);+    }++    /// A batch that half-worked is CLEARED before the per-entity rungs, or+    /// re-selecting its survivors would toggle them OFF.+    #[test]+    fn a_partial_batch_is_cleared_first() {+        struct Half(ProfileSelector);+        impl Selector for Half {+            fn clear(&mut self) {+                self.0.clear()+            }+            fn count(&mut self) -> Option<usize> {+                self.0.count()+            }+            fn attempt(&mut self, rung: Rung, items: &[usize]) -> Result<(), String> {+                if rung == Rung::MultiSelectTyped {+                    return self.0.attempt(rung, &items[..1]);+                }+                self.0.attempt(rung, items)+            }+        }+        let mut s = Half(ProfileSelector::new(SelProfile::measured_2026()));+        let r = select_ladder(&mut s, 2);+        assert!(r.all(2), "{r:?}");+        assert_eq!(r.via, vec![Some(Rung::EntitySelect4); 2]);+        assert_eq!(s.0.selected, vec![0, 1]);+        // The simulator toggles like SOLIDWORKS: selected twice = deselected.+        let mut t = ProfileSelector::new(SelProfile::measured_2026());+        t.attempt(Rung::EntitySelect4, &[0]).unwrap();+        t.attempt(Rung::SelectByRay, &[0]).unwrap();+        assert_eq!(t.count(), Some(0));+    }++    #[test]+    fn rays_start_outside_and_point_back_in() {+        let (o, d) = face_ray([0.0, 0.01, 0.0], [0.0, 2.0, 0.0]).unwrap();+        assert!((o[1] - 0.011).abs() < 1e-12 && d == [0.0, -1.0, 0.0]);+        assert!(face_ray([0.0; 3], [0.0; 3]).is_none(), "no normal, no ray");+        // A box's top-front edge: the bisector of +Y and +Z.+        let (o, d) = edge_ray([0.0, 0.01, 0.01], &[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap();+        let k = 1.0e-3 / 2f64.sqrt();+        assert!((o[1] - (0.01 + k)).abs() < 1e-12 && (o[2] - (0.01 + k)).abs() < 1e-12, "{o:?}");+        assert!((d[1] + 0.5f64.sqrt()).abs() < 1e-12 && (d[2] + 0.5f64.sqrt()).abs() < 1e-12);+        // Unnormalised normals still give the BISECTOR (each counts once).+        let (_, d2) = edge_ray([0.0; 3], &[[0.0, 3.0, 0.0], [0.0, 0.0, 1.0]]).unwrap();+        assert!((d2[1] - d2[2]).abs() < 1e-12, "{d2:?}");+        // One known face is enough.+        assert!(edge_ray([0.0; 3], &[[0.0, 0.0, 3.0], [0.0; 3]]).is_some());+    }++    #[test]+    fn the_iid_probe_is_read_strictly() {+        let g = "guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\nitype=0\n";+        assert_eq!(parse_iid_probe(g), Some((0x4c1e9cc6_3d6d_11d5_9a8c_00c04f80e3e7, true)));+        assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\ntlflags=4160").map(|x| x.1), Some(true));+        assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7\nitype=1\ntlflags=4160").map(|x| x.1), Some(false), "IUnknown-only is never called by name");+        assert_eq!(parse_iid_probe("guid=4c1e9cc6-3d6d-11d5-9a8c-00c04f80e3e7").map(|x| x.1), Some(false), "no evidence, no call");+        assert_eq!(parse_iid_probe("guid=00000000-0000-0000-0000-000000000000\nitype=0"), None);+        assert_eq!(parse_iid_probe("nothing"), None);+    } }
tests/issue1.rs+71
@@ -288,3 +288,74 @@ fn a_script_on_a_document_that_is_not_active_activates_it_and_puts_the_users_bac         s(&b.call("swx_docs", json!({})))     ); }++/// 0.10.3 — THE FIRST LIVE RUN (SOLIDWORKS 2026): every selection after a+/// geometric pick selected NOTHING (MultiSelect2 with a VARIANT array). Now a+/// LADDER, each rung believed only when the selection count grows, the rung+/// used reported as `selectedVia`. The simulator's install is the measured one.+#[test]+fn selections_after_a_pick_climb_the_ladder_and_say_which_rung_worked() {+    let (b, _m) = boot("issue1-ladder");+    let script = json!([+        {"op": "new_part"},+        {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},+        {"op": "extrude", "depthMm": 10},+        {"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 3},+        {"op": "extrude", "depthMm": 2},+        {"op": "fillet", "radiusMm": 0.5, "edges": {"allOfFeature": "Boss-Extrude1"}},+        {"op": "set_appearance", "rgb": [0, 0, 255], "target": {"faceAtPointMm": [0, 5, 10]}},+        {"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[5, 0, 5]]}+    ]);+    let r = b.call("swx_run_script", json!({"script": script}));+    check!(b, "the measured install (VARIANT array dead): the script runs", r["success"] == true, s(&r));+    let st = r["steps"].as_array().cloned().unwrap_or_default();+    let typed = json!("MultiSelect2(typed array)");+    check!(+        b,+        "face sketch, fillet edges, face colour and shell faces each report the rung: the typed array",+        st[3]["result"]["resolvedPlane"]["selectedVia"] == typed && st[5]["result"]["edges"]["selectedVia"] == typed+            && st[6]["result"]["selectedVia"] == typed && st[7]["result"]["selectedVia"] == typed,+        s(&r)+    );+    // The typed array dead too: IEntity::Select4 per entity.+    std::fs::write(b.scratch.join("mock-select-dead-typed"), "").unwrap();+    let r = b.call("swx_run_script", json!({"script": script}));+    let st = r["steps"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "typed array dead: every selection falls through to IEntity.Select4, verified, and says so",+        r["success"] == true && st[5]["result"]["edges"]["selectedVia"] == "IEntity.Select4"+            && st[3]["result"]["resolvedPlane"]["selectedVia"] == "IEntity.Select4",+        s(&r)+    );+    // ...and Select4 dead: the explicit ray.+    std::fs::write(b.scratch.join("mock-select-dead-select4"), "").unwrap();+    let r = b.call("swx_run_script", json!({"script": script}));+    let st = r["steps"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "Select4 dead: SelectByRay, view-independent",+        r["success"] == true && st[7]["result"]["selectedVia"] == "SelectByRay" && st[7]["result"]["viewDependent"] == false,+        s(&r)+    );+    // ...and the ray dead: the view-dependent last resort, FLAGGED.+    std::fs::write(b.scratch.join("mock-select-dead-ray"), "").unwrap();+    let r = b.call("swx_run_script", json!({"script": script}));+    let st = r["steps"].as_array().cloned().unwrap_or_default();+    check!(+        b,+        "only SelectByID2 left: used, and the result says it went through the view",+        r["success"] == true && st[5]["result"]["edges"]["viewDependent"] == true+            && st[5]["result"]["edges"]["selectedVia"].as_str().unwrap_or("").contains("view-dependent"),+        s(&r)+    );+    // Nothing selects: the 0.10.2 live failure, as an honest error.+    std::fs::write(b.scratch.join("mock-select-dead-byid"), "").unwrap();+    let r = b.call("swx_run_script", json!({"script": script}));+    check!(+        b,+        "nothing selects: step 4 fails saying the picked face could not be selected — never a silent success",+        r["success"] == false && r["error"].as_str().unwrap_or("").contains("step 4 (sketch_circle) failed: the picked face could not be selected"),+        s(&r)+    );+}
uninstall.sh