app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.10.3 source
16 files changed
+884−61
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+19−1bridge.json+1−1docs/COMPATIBILITY.md+7install.shpackage.json+1−1references/modelling-and-export.md+6src/comhost/com.rs+326−47src/comhost/mock.rs+21−2src/comhost/mock_model.rs+49−6src/comhost/variant.rs+36src/modeling.rs+344tests/issue1.rs+71uninstall.shBRIDGE_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.shpackage.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