← Commit history
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.sh

  
package.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