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