app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.9.29 source
15 files changed
+368−36
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.28+0.9.29
Cargo.lock+1−1@@ -122,7 +122,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.28"+version = "0.9.29" dependencies = [ "miniz_oxide", "serde",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.28"+version = "0.9.29" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+16−1@@ -492,7 +492,22 @@ not. ## Roadmap -- v0.9.28 (this): **a launch record that outlived its processes is ended, not+- v0.9.29 (this): **issue #2 fixed: the mass is right, and the bridge knows its own+ part.** Reported by Michael, our first community user, on SOLIDWORKS 2021.+ `swx_inspect_bodies` passed `0.0` to `IBody2::GetMassProperties`, whose+ argument is the DENSITY (help.solidworks.com, Availability 2001Plus). Its+ `massKg` came back as volume x 1, about 1000x small. Each body's mass now uses+ the density of an `IMassProperty` restricted to that body (`AddBodies`), or+ the part's if that is unavailable. The reply carries `densityKgM3` and+ `densitySource`, and the mass matches `swx_mass_properties`. An unreadable+ density gives `massKg: null`. `swx_run_script` decides `bridgeCreatedDocument`+ with the same path-based ownership test as every other verb; it used to+ compare titles. It now also pins its target by PATH, so a user's document+ that shares a title with the bridge's part is never mistaken for it. An audit+ of title-only identity found two more: the registry's forget and rename after+ a close or Save As now match by path, so closing one document no longer+ disowns another of the same name. 565 tests.+- v0.9.28: **a launch record that outlived its processes is ended, not attached.** Measured 2026-10-12: ten days after a launch, with SOLIDWORKS not running, `swx_status.launch` still said "up and COM-attached. Carry on." The record is now checked against the live processes (pid plus creation time, so
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.28",+ "version": "0.9.29", "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",
install.shpackage.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.9.28",+ "version": "0.9.29", "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+8−1@@ -46,7 +46,14 @@ adom-bridge --ai-thread "<thread>" swx_save_as '{"outputPath":"C:\\parts\\bracke and a worked example, so read the error. - **Verify with `swx_inspect_bodies`, not with the step list.** It reports each solid's bounding box, size, volume, area and mass in millimetres — that is- what tells you the model is what you meant.+ what tells you the model is what you meant. Each body's `massKg` is its+ volume times `densityKgM3` (its material's density, `densitySource` says+ where it was read), so it agrees with `swx_mass_properties`; an unreadable+ density gives `massKg: null`, never a guess.+- `bridgeCreatedDocument` on a `swx_run_script` reply is decided the way every+ other verb decides ownership — by PATH when the document has one — so a part+ made with `swx_new_document` (saved under the bridge's scratch folder) is+ `true` whether you address it by path or title. - Throwing it away is `swx_close_doc {"name":"Part1","save":false}`, or `swx_reset_workspace` to sweep every scratch document the bridge created. - **`swx_import` opens one unsaved document and it STAYS OPEN** until you close
src/comhost/com.rs+56−7@@ -3313,6 +3313,19 @@ impl ComBackend { /// `swx_inspect_bodies` — geometry read-back in MILLIMETRES, the Fusion /// shape. `GetBodies2(0, true)` (solid bodies, visible only) is proven live.+ /// ONE BODY'S DENSITY (kg/m³): an IMassProperty restricted to that body+ /// with `AddBodies` (help: "Uses only the specified bodies when calculating+ /// the mass properties"; a multibody part can give each body its own+ /// material). `None` when any step is unavailable.+ fn body_density(doc: &Disp, body: &Disp) -> Option<f64> {+ let mp = doc.obj("Extension", &[]).ok().flatten()?.obj("CreateMassProperty", &[]).ok().flatten()?;+ let list = Var::dispatch_array(&[body.0.clone()]);+ if mp.call("AddBodies", &[&list]).ok()?.as_bool() != Some(true) {+ return None;+ }+ mp.float("Density", &[]).filter(|d| d.is_finite() && *d > 0.0)+ }+ fn cmd_inspect_bodies(&mut self, args: &Value) -> Result<Value, HostError> { let doc = self.resolve(args)?; if doc.int("GetType", &[]).unwrap_or(0) != DOC_PART {@@ -3323,6 +3336,14 @@ impl ComBackend { } let bodies = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?; let filter = args.get("nameContains").and_then(|v| v.as_str()).unwrap_or("");+ // The PART's density: what swx_mass_properties reports.+ let part_density = doc+ .obj("Extension", &[])+ .ok()+ .flatten()+ .and_then(|ext| ext.obj("CreateMassProperty", &[]).ok().flatten())+ .and_then(|mp| mp.float("Density", &[]))+ .filter(|d| d.is_finite() && *d > 0.0); let mut out = Vec::new(); for b in bodies.as_array() { let Some(body) = b.as_dispatch().map(Disp) else { continue };@@ -3342,9 +3363,27 @@ impl ComBackend { } else { (Value::Null, Value::Null) };- // IBody2::GetMassProperties(accuracy) -> [cx,cy,cz, volume, area, mass, ...]+ // THE DENSITY (issue #2, Michael 2026-10-13: massKg came back+ // ~1000x small — exactly volume x 1). IBody2::GetMassProperties'+ // one argument is the DENSITY to use, not an accuracy: "Density to+ // use for the mass property calculations on this body … Mass+ // (Volume*density)" (help.solidworks.com/2021/english/api/+ // sldworksapi/solidworks.interop.sldworks~solidworks.interop.+ // sldworks.ibody2~getmassproperties.html; Availability 2001Plus).+ // The bridge passed 0.0. The density is the body's material's,+ // read through an IMassProperty restricted to THIS body+ // (IMassProperty::AddBodies), else the part's (the IMassProperty+ // swx_mass_properties reads), else unknown — and then no mass.+ let (density, density_source) = match Self::body_density(&doc, &body) {+ Some(d) => (Some(d), "bodyMassProperty"),+ None => match part_density {+ Some(d) => (Some(d), "partMassProperty"),+ None => (None, "unreadable"),+ },+ };+ // IBody2::GetMassProperties(density) -> [cx,cy,cz, volume, area, mass, ...] let mp: Vec<f64> = body- .call("GetMassProperties", &[&Var::r8(0.0)])+ .call("GetMassProperties", &[&Var::r8(density.unwrap_or(1.0))]) .map(|v| v.as_array().iter().filter_map(|e| e.as_f64()).collect()) .unwrap_or_default(); let get = |i: usize| mp.get(i).copied();@@ -3354,7 +3393,9 @@ impl ComBackend { "sizeMm": size, "volumeMm3": get(3).map(|v| (v * 1.0e9 * 1000.0).round() / 1000.0), "areaMm2": get(4).map(|v| (v * 1.0e6 * 1000.0).round() / 1000.0),- "massKg": get(5),+ "massKg": density.and(get(5)),+ "densityKgM3": density,+ "densitySource": density_source, "centerOfMassMm": (mp.len() >= 3).then(|| json!([mm(mp[0]), mm(mp[1]), mm(mp[2])])), "faceCount": body.int("GetFaceCount", &[]), "visible": body.flag("Visible", &[]),@@ -5251,11 +5292,17 @@ impl ComBackend { // script onto a different model halfway through. The server sends the // title it validated ownership of; a `new_part` step replaces it with // the document it just made.- let mut pinned: Option<String> =- args.get("targetDocument").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from);+ // BY PATH when the server knows it (0.9.29): a title is not an+ // identity, and a user's document may share the bridge's title.+ let mut pinned: Option<String> = args+ .get("targetPath")+ .and_then(|v| v.as_str())+ .filter(|s| !s.is_empty())+ .or_else(|| args.get("targetDocument").and_then(|v| v.as_str()).filter(|s| !s.is_empty()))+ .map(String::from); if let Some(want) = &pinned { // Prove the pin resolves BEFORE running anything.- self.resolve(&json!({"name": want})).map_err(|e| {+ self.resolve(&Self::doc_arg(&pinned)).map_err(|e| { HostError::detailed( format!("cannot target document '{want}': {}", e.message), "swx_docs lists the titles that are open. Nothing was run.".to_string(),@@ -5296,7 +5343,7 @@ impl ComBackend { "steps": results, "stepCount": results.len(), "created": created,- "targetDocument": pinned,+ "targetDocument": doc.as_ref().and_then(|d| d.string("GetTitle", &[])), "documentName": doc.as_ref().and_then(|d| d.string("GetTitle", &[])), "doc": doc.as_ref().map(Self::doc_info).unwrap_or(Value::Null), }))@@ -5307,6 +5354,8 @@ impl ComBackend { /// creating its own document). fn doc_arg(pinned: &Option<String>) -> Value { match pinned {+ // A pin with a separator is a PATH (a title never holds one).+ Some(p) if p.contains('\\') || p.contains('/') => json!({"path": p}), Some(name) => json!({"name": name}), None => json!({}), }
src/comhost/mock.rs+55−10@@ -69,6 +69,18 @@ fn mock_body(name: &str, x: f64, y: f64, z: f64) -> Value { }) } +/// The simulated material library's densities (kg/m³) — SOLIDWORKS' own+/// values for the materials the tests name. No material = SOLIDWORKS' default+/// part density, 1000 kg/m³.+fn mock_density(material: Option<&str>) -> f64 {+ match material.map(|m| m.to_ascii_lowercase()) {+ Some(m) if m == "abs" => 1020.0,+ Some(m) if m.contains("6061") => 2700.0,+ Some(m) if m.contains("steel") => 7800.0,+ _ => 1000.0,+ }+}+ struct MockDoc { /// The IDENTITY key. For a document that has never been saved this is a /// synthetic `untitled:Part1` — SOLIDWORKS reports an empty `GetPathName()`@@ -2217,6 +2229,21 @@ impl MockBackend { "mass_properties" => { self.require_running()?; let path = self.resolve(args)?;+ // A part with simulated bodies: computed from them, so this and+ // inspect_bodies agree (issue #2). Otherwise the canned answer.+ if let Some(d) = self.find(&path).filter(|d| !d.bodies.is_empty()) {+ let density = mock_density(d.material.as_deref());+ let vol_m3: f64 = d.bodies.iter().filter_map(|b| b.get("volumeMm3").and_then(|v| v.as_f64())).sum::<f64>() * 1.0e-9;+ return Ok(json!({+ "doc": self.doc_info(&path),+ "massKg": vol_m3 * density,+ "volumeM3": vol_m3,+ "surfaceAreaM2": d.bodies.iter().filter_map(|b| b.get("areaMm2").and_then(|v| v.as_f64())).sum::<f64>() * 1.0e-6,+ "density": density,+ "centerOfMass": [0.0, 0.0, 0.0],+ "boundingBox": Value::Null,+ }));+ } Ok(json!({ "doc": self.doc_info(&path), "massKg": 0.4173,@@ -2617,11 +2644,29 @@ impl MockBackend { } let info = self.doc_info(&path); let filter = args.get("nameContains").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();+ // MASS = VOLUME x THE MATERIAL'S DENSITY (issue #2): the same+ // density swx_mass_properties reports, so the two verbs agree.+ let material = self.find(&path).and_then(|d| d.material.clone());+ let density = mock_density(material.as_deref());+ let unreadable = fault("mock-density-unreadable"); let bodies: Vec<Value> = self .find(&path) .map(|d| d.bodies.clone()) .unwrap_or_default() .into_iter()+ .map(|mut b| {+ let vol = b.get("volumeMm3").and_then(|v| v.as_f64()).unwrap_or(0.0);+ if unreadable {+ b["massKg"] = Value::Null;+ b["densityKgM3"] = Value::Null;+ b["densitySource"] = json!("unreadable");+ } else {+ b["massKg"] = json!(vol * 1.0e-9 * density);+ b["densityKgM3"] = json!(density);+ b["densitySource"] = json!("bodyMassProperty");+ }+ b+ }) .filter(|b| { filter.is_empty() || b.get("name")@@ -3619,17 +3664,20 @@ impl MockBackend { // document the server named (or the one a new_part step made), // never "whatever is active now". let mut pinned: Option<String> = args- .get("targetDocument")+ .get("targetPath") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty())+ .or_else(|| args.get("targetDocument").and_then(|v| v.as_str()).filter(|s| !s.is_empty())) .map(String::from);- if let Some(want) = &pinned {- self.resolve(&json!({"name": want}))?;- }- let target = match &pinned {+ let pin_arg = |p: &Option<String>| match p {+ Some(x) if x.contains('\\') || x.contains('/') => json!({"path": x}), Some(n) => json!({"name": n}), None => json!({}), };+ if pinned.is_some() {+ self.resolve(&pin_arg(&pinned))?;+ }+ let target = pin_arg(&pinned); let mut steps = Vec::new(); let mut created = Value::Null; for (i, op) in ops.iter().enumerate() {@@ -3699,16 +3747,13 @@ impl MockBackend { }; steps.push(json!({"step": i + 1, "op": op.name(), "result": detail})); }- let final_target = match &pinned {- Some(n) => json!({"name": n}),- None => json!({}),- };+ let final_target = pin_arg(&pinned); let doc = self.resolve(&final_target).ok().map(|p| self.doc_info(&p)); Ok(json!({ "steps": steps, "stepCount": steps.len(), "created": created,- "targetDocument": pinned,+ "targetDocument": doc.as_ref().and_then(|d| d.get("title").cloned()), "documentName": doc.as_ref().and_then(|d| d.get("title").cloned()), "doc": doc.unwrap_or(Value::Null), }))
src/comhost/variant.rs+21@@ -146,6 +146,27 @@ impl Var { } } + /// A SAFEARRAY of VARIANTs, each holding one object — the shape a VBA+ /// `Array(body)` has, and what `IMassProperty::AddBodies(BodyListIn)` takes.+ pub fn dispatch_array(items: &[IDispatch]) -> Var {+ unsafe {+ let psa = SafeArrayCreateVector(VT_VARIANT, 0, items.len() as u32);+ if psa.is_null() {+ return Var::empty();+ }+ for (i, d) in items.iter().enumerate() {+ let one = Var::dispatch(d);+ let idx = [i as i32];+ // SafeArrayPutElement COPIES a VARIANT element, so `one` is+ // released on drop without double-freeing the array's copy.+ let _ = SafeArrayPutElement(psa, idx.as_ptr(), &one.0 as *const VARIANT as *const std::ffi::c_void);+ }+ build(VARENUM(VT_ARRAY.0 | VT_VARIANT.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 {
src/verbs.rs+44−2@@ -243,17 +243,44 @@ impl Ctx { self.persist_scratch(); } + #[cfg(test)] pub(crate) fn forget_scratch(&self, title: &str) { self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).retain(|d| d.title != title); self.persist_scratch(); } + /// Forget the ONE document that was closed — matched the way ownership is+ /// decided ([`doc_matches`]: by path when it has one), not by title. A+ /// title-only forget dropped every owned document that shared the closed+ /// one's name, so the bridge disowned a second document it had made+ /// (0.9.29 audit, prompted by issue #2's title-only ownership test).+ pub(crate) fn forget_scratch_doc(&self, title: &str, path: Option<&str>) {+ let path = path.filter(|p| !p.is_empty());+ self.scratch_docs+ .lock()+ .unwrap_or_else(|e| e.into_inner())+ .retain(|d| !doc_matches(&d.title, d.path.as_deref(), title, path));+ self.persist_scratch();+ }+ /// A scratch document that has been saved keeps its identity under its new /// title, so `save_as` does not silently disown it.+ #[cfg(test)] pub(crate) fn rename_scratch(&self, from: &str, to: &str, path: Option<String>) {+ self.rename_scratch_doc(from, None, to, path, true)+ }++ /// Rename the ONE entry that IS the document saved — by path when it had+ /// one (0.9.29 audit). `title_fallback`: an entry for a never-saved+ /// document is found by title, as before.+ pub(crate) fn rename_scratch_doc(&self, from: &str, from_path: Option<&str>, to: &str, path: Option<String>, title_fallback: bool) { { let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());- if let Some(d) = v.iter_mut().find(|d| d.title == from) {+ let from_path = from_path.filter(|p| !p.is_empty());+ let hit = v.iter().position(|d| doc_matches(&d.title, d.path.as_deref(), from, from_path)).or_else(|| {+ if title_fallback { v.iter().position(|d| d.title == from) } else { None }+ });+ if let Some(d) = hit.map(|i| &mut v[i]) { d.title = to.to_string(); d.path = path; }@@ -2177,7 +2204,7 @@ fn swx_close_doc(ctx: &Ctx, args: &Value) -> Value { Err(e) => sidecar_error(e), Ok(r) => { // A scratch document that is gone is no longer ours to sweep.- ctx.forget_scratch(&target.title);+ ctx.forget_scratch_doc(&target.title, target.path.as_deref()); // THE AUDIT TRAIL. Both docs promised one here and there was none — // a document promising an accountability record that does not exist // is worse than one that promises nothing.@@ -3688,6 +3715,21 @@ mod tests { ctx.forget_scratch("bracket.SLDPRT"); assert!(ctx.scratch_titles().is_empty()); ctx.forget_scratch("SomeoneElsesPart1"); // a no-op, not a panic++ // 0.9.29 AUDIT: forgetting / renaming ONE document must not touch+ // another owned document that merely shares its title.+ ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\a\\bracket.sldprt");+ ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\b\\bracket.sldprt");+ ctx.forget_scratch_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"));+ assert!(!ctx.owns_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"), s1), "the closed one is forgotten");+ assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\b\\bracket.sldprt"), s1), "the OTHER one is still ours");+ ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\a\\bracket.sldprt");+ ctx.rename_scratch_doc("bracket.SLDPRT", Some("C:\\b\\bracket.sldprt"), "b2.SLDPRT", Some("C:\\b\\b2.sldprt".into()), true);+ assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"), s1), "the one NOT saved keeps its identity");+ assert!(ctx.owns_doc("b2.SLDPRT", Some("C:\\b\\b2.sldprt"), s1), "the saved one moved with its path");+ ctx.forget_scratch_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"));+ ctx.forget_scratch_doc("b2.SLDPRT", Some("C:\\b\\b2.sldprt"));+ assert!(ctx.scratch_titles().is_empty()); } #[test]
src/verbs3.rs+26−10@@ -2282,7 +2282,7 @@ pub fn close_all_documents(ctx: &Ctx, args: &Value) -> Value { ); match ctx.call("close_doc", call_args, 60) { Ok(_) => {- ctx.forget_scratch(&title);+ ctx.forget_scratch_doc(&title, path.as_deref()); crate::ledger::record( "document_closed", json!({"title": title, "bridgeCreated": owned, "by": "close_all_documents"}),@@ -2641,7 +2641,7 @@ pub fn close_import_temps(ctx: &Ctx, args: &Value) -> Value { match ctx.call("close_doc", json!({"docName": title, "save": false}), 60) { Ok(_) => { owned_closed += 1;- ctx.forget_scratch(&title);+ ctx.forget_scratch_doc(&title, None); } Err(e) => unaddressable.push(json!({ "title": title,@@ -3033,7 +3033,7 @@ pub fn reset_workspace(ctx: &Ctx, args: &Value) -> Value { match ctx.call("close_doc", json!({"docName": title.clone(), "path": path, "save": false}), 60) { Ok(_) => { crate::ledger::record("document_closed", json!({"title": title, "by": "reset_workspace"}));- ctx.forget_scratch(&title);+ ctx.forget_scratch_doc(&title, path.as_deref()); closed.push(title); } Err(e) => skipped.push(json!({@@ -4231,7 +4231,7 @@ pub fn save_as(ctx: &Ctx, args: &Value) -> Value { .filter(|s| !s.is_empty()) .map(String::from) .unwrap_or_else(|| out.clone());- ctx.rename_scratch(&was, now, Some(reported));+ ctx.rename_scratch_doc(&was, target.path.as_deref(), now, Some(reported), true); } } ok_merge(r, json!({@@ -4553,11 +4553,21 @@ pub fn run_script(ctx: &Ctx, args: &Value) -> Value { // document, not just our changes. let mut pinned: Option<String> = None; let mut grant = crate::writes::Grant { host_args: json!({}), user_write: false };+ // WHO MADE THIS DOCUMENT is decided by the SAME gate every other verb uses+ // (`resolve_document` → `owns_doc`, by PATH when it has one). Issue #2+ // (Michael, 2026-10-13): a part made by swx_new_document (scratchFile:true,+ // saved under exports\scratch) was reported bridgeCreatedDocument:false,+ // because this verb compared the reply's TITLE against the registry's+ // titles instead.+ let mut target_owned: Option<bool> = None;+ let mut pinned_path: Option<String> = None; if !makes_its_own_doc { let target = match resolve_document(ctx, args, "swx_run_script") { Ok(t) => t, Err(e) => return e, };+ target_owned = Some(target.owned);+ pinned_path = target.path.clone(); grant = match crate::writes::authorize_in(ctx, args, "swx_run_script", crate::writes::Kind::ModifyDocument, Some(&target), None) { Ok(g) => g, Err(e) => return e,@@ -4578,7 +4588,7 @@ pub fn run_script(ctx: &Ctx, args: &Value) -> Value { // document for every step, instead of re-resolving "whatever is active now" // — a dialog, or the user clicking another window, must not be able to move // a running script onto a different model halfway through.- let call_args = crate::writes::with_grant(json!({"script": script, "targetDocument": pinned}), &grant);+ let call_args = crate::writes::with_grant(json!({"script": script, "targetDocument": pinned, "targetPath": pinned_path}), &grant); match ctx.call("run_script", call_args, 280) { Err(e) => sidecar_error(e), Ok(r) => {@@ -4596,11 +4606,17 @@ pub fn run_script(ctx: &Ctx, args: &Value) -> Value { json!({"title": created.get("created").and_then(|c| c.get("title")).or_else(|| created.get("title")), "via": "run_script"}), ); }- let ours = r- .get("documentName")- .and_then(|t| t.as_str())- .map(|t| ctx.scratch_titles().iter().any(|s| s == t))- .unwrap_or(false);+ let ours = match target_owned {+ Some(owned) => owned,+ // The script made its own document: ours if the registry took it+ // (decided by owns_doc, with the path the reply reports).+ None => {+ let (session, _, _, _) = ownership_context(ctx);+ let title = r.get("documentName").and_then(|t| t.as_str()).unwrap_or("");+ let path = r.get("doc").and_then(|d| d.get("path")).and_then(|p| p.as_str());+ ctx.owns_doc(title, path, session)+ }+ }; ok_merge(r, json!({ "progress": job.finished(), "ops": crate::script::OPS,
tests/issue2.rsadded+137@@ -0,0 +1,137 @@+//! ISSUE #2 on adom/solidworks-bridge (Michael, 2026-10-13, SOLIDWORKS 2021):+//! * swx_inspect_bodies reported massKg ~1000x small — exactly volume x 1:+//! IBody2::GetMassProperties' argument is the DENSITY, and the bridge+//! passed 0.0 as if it were an accuracy;+//! * swx_run_script said bridgeCreatedDocument:false for a part the bridge+//! had just made with swx_new_document (scratchFile:true, saved under+//! exports\scratch) — it compared TITLES instead of asking owns_doc.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++fn boot(tag: &str) -> (Bridge, TempDir) {+ let machine = TempDir::new(tag);+ std::fs::write(machine.join("windows.json"), "[]").unwrap();+ let b = Bridge::start_with(&[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+ (b, machine)+}++#[test]+fn inspect_bodies_mass_is_volume_times_the_materials_density_and_matches_mass_properties() {+ let (b, _m) = boot("issue2-mass");+ let r = b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 31.8, "heightMm": 15.8},+ {"op": "extrude", "depthMm": 11.3}+ ]}));+ check!(b, "the part is built", r["success"] == true, s(&r));+ // No material yet: SOLIDWORKS' default part density, 1000 kg/m³ — NOT 1.+ let ib = b.call("swx_inspect_bodies", json!({}));+ let body = &ib["bodies"][0];+ let vol = body["volumeMm3"].as_f64().unwrap_or(0.0);+ check!(+ b,+ "no material: massKg = volume x 1000 kg/m³ (the measured bug was volume x 1)",+ (body["massKg"].as_f64().unwrap_or(0.0) - vol * 1.0e-9 * 1000.0).abs() < 1e-12+ && body["densityKgM3"] == 1000.0 && body["densitySource"].is_string(),+ s(body)+ );+ b.call("swx_run_script", json!({"script": [{"op": "set_material", "name": "ABS"}], "targetDocument": r["documentName"]}));+ let ib = b.call("swx_inspect_bodies", json!({}));+ let mp = b.call("swx_mass_properties", json!({}));+ let body = &ib["bodies"][0];+ let mass = body["massKg"].as_f64().unwrap_or(0.0);+ check!(+ b,+ "ABS: massKg = volume x 1020 kg/m³, densityKgM3 1020",+ (mass - vol * 1.0e-9 * 1020.0).abs() < 1e-12 && body["densityKgM3"] == 1020.0,+ s(body)+ );+ check!(+ b,+ "swx_inspect_bodies and swx_mass_properties AGREE on a single-body part",+ (mass - mp["massKg"].as_f64().unwrap_or(-1.0)).abs() < 1e-12 && mp["density"] == body["densityKgM3"],+ format!("{}\n{}", s(body), s(&mp))+ );+}++#[test]+fn an_unreadable_density_reports_no_mass_rather_than_a_wrong_one() {+ let (b, _m) = boot("issue2-unreadable");+ b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch_rect", "widthMm": 10, "heightMm": 10},+ {"op": "extrude", "depthMm": 10}+ ]}));+ std::fs::write(b.scratch.join("mock-density-unreadable"), "").unwrap();+ let body = b.call("swx_inspect_bodies", json!({}))["bodies"][0].clone();+ check!(+ b,+ "density unreadable: massKg null, densitySource unreadable — never volume x 1",+ body["massKg"].is_null() && body["densitySource"] == "unreadable" && body["volumeMm3"].as_f64().unwrap_or(0.0) > 0.0,+ s(&body)+ );+}++#[test]+fn a_script_into_a_scratch_part_the_bridge_made_is_bridge_created() {+ // The user-writes switch is ON for this bridge so the last case (a USER's+ // document, under the full opt-in) can run at all; the bridge's own+ // documents never need it.+ let machine = TempDir::new("issue2-owner");+ std::fs::write(machine.join("windows.json"), "[]").unwrap();+ let b = Bridge::start_with(&[("SWX_LAUNCH_FAKE_DIR", &machine.path()), ("SWX_ALLOW_USER_WRITES", "1")]);+ let made = b.call("swx_new_document", json!({"type": "part"}));+ let path = made["created"]["path"].as_str().or_else(|| made["path"].as_str()).unwrap_or("").to_string();+ check!(+ b,+ "swx_new_document saved it as a scratch file under exports\\scratch",+ made["documentIdentity"]["scratchFile"] == true,+ s(&made)+ );+ check!(b, "it has a path", !path.is_empty(), s(&made));+ let r = b.call("swx_run_script", json!({"path": path, "script": [+ {"op": "sketch_rect", "widthMm": 20, "heightMm": 10},+ {"op": "extrude", "depthMm": 5}+ ]}));+ check!(+ b,+ "addressed by PATH: bridgeCreatedDocument true (issue #2 measured false), and the safe-undo hint",+ r["success"] == true && r["bridgeCreatedDocument"] == true+ && r["_hint"].as_str().unwrap_or("").contains("created by the bridge"),+ s(&r)+ );+ let title = r["documentName"].clone();+ let r = b.call("swx_run_script", json!({"targetDocument": title, "script": [+ {"op": "sketch_circle", "radiusMm": 2},+ {"op": "extrude", "depthMm": 1}+ ]}));+ check!(b, "...and addressed by title too", r["bridgeCreatedDocument"] == true, s(&r));++ // A USER's document (opened, not made) that happens to carry THE SAME TITLE+ // as the bridge's scratch part, changed under the full opt-in: false. (A+ // title is not an identity — the title-only test said "true" here.)+ let user = TempDir::new("issue2-user");+ let same_name = title.as_str().unwrap_or("Part1.SLDPRT").to_string();+ let upath = user.join(&same_name);+ std::fs::write(&upath, "a user part").unwrap();+ let opened = b.call("swx_open", json!({"path": upath.to_string_lossy()}));+ check!(b, "the user's part opens", opened["success"] == true, s(&opened));+ let r = b.call("swx_run_script", json!({"path": upath.to_string_lossy(), "allowUserWrites": true, "confirm": true,+ "reason": "the user asked for a boss on their bracket", "script": [+ {"op": "sketch_rect", "widthMm": 5, "heightMm": 5},+ {"op": "extrude", "depthMm": 1}+ ]}));+ check!(+ b,+ "the user's document: bridgeCreatedDocument false, and the no-safe-undo warning",+ r["success"] == true && r["bridgeCreatedDocument"] == false && r["_hint"].as_str().unwrap_or("").contains("NOT create"),+ s(&r)+ );+}
uninstall.sh