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.0 source
28 files changed
+2831−203
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+38−4SKILL.md+8−1bridge.json+12−3docs/COMPATIBILITY.md+273docs/ECOSYSTEM-TABLE.md+1examples/lego-2x4.json+238install.shpackage.json+2−1references/modelling-and-export.md+42−1src/comhost/com.rs+612−112src/comhost/mock.rs+211−20src/comhost/mock_model.rs+179src/comhost/mod.rs+1src/compat.rs+90src/detect.rs+56−2src/lib.rs+2src/script.rs+561−48src/selftest.rs+230src/verbs.rs+15−3src/verbs3.rs+25−5tests/e2e.rs+11tests/issue1.rs+219tests/readiness.rs+1tests/wiring.rs+1uninstall.shBRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.29+0.10.0
Cargo.lock+1−1@@ -122,7 +122,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.29"+version = "0.10.0" dependencies = [ "miniz_oxide", "serde",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.29"+version = "0.10.0" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+38−4@@ -9,12 +9,12 @@ One native binary. No Python, no PowerShell, no interpreter to provision: ab spawns `swx-bridge.exe` directly (`spawn.kind: "exe"`) and it is serving in about ten milliseconds. -## What it does — 69 verbs+## What it does — 70 verbs | Area | Verbs | |---|---| | **Create & open** | `swx_new_document` (part/assembly/drawing from the stock template), `swx_open`, `swx_import` (STEP/IGES/Parasolid/STL), `swx_save`, `swx_save_as`, `swx_close_doc`, `swx_find_files`, `swx_docs` |-| **Model** | `swx_run_script` — a typed modelling DSL: `new_part`, `sketch_rect`, `sketch_circle`, `extrude`, `cut`, `set_material`, `set_prop`, `save_as`, `rebuild`, `zoom_fit`. Everything in millimetres. |+| **Model** | `swx_run_script` — a typed modelling DSL: `new_part`, `sketch` (several rectangles/circles in ONE sketch — rings, walls, hole patterns), `sketch_rect`, `sketch_circle` (on a named plane, an offset plane, or a face), `extrude` / `cut` (reverse, both directions, mid-plane, through all), `fillet`, `chamfer`, `shell`, `linear_pattern`, `circular_pattern`, `set_appearance`, `set_material`, `set_prop`, `save_as`, `rebuild`, `zoom_fit`. Everything in millimetres; a worked LEGO brick is in [`examples/lego-2x4.json`](examples/lego-2x4.json). | | **Macros** | `swx_run_macro` — `ISldWorks::RunMacro2` on a `.swp`/`.swb`/`.dll`. Arbitrary code, so it needs the write opt-in every time. Scaffolding: not yet run live. | | **Assemble** | `swx_add_component` — insert a part or sub-assembly into the active assembly, positioned and rotated. | | **Close & quit safely** | `swx_close_all_safely` (saves every open document to a recovery folder, verifies each file, then closes), `swx_shutdown` (that sweep, then a graceful quit), `swx_recover_list` (what was preserved and where), `swx_close_import_temps`, `swx_user_presence` (is someone working in this session? the bridge refuses to end a session a person is using). |@@ -26,7 +26,7 @@ about ten milliseconds. | App & documents | `swx_get_app_state`, `swx_state`, `swx_status`, `swx_readiness`, `swx_progress`, `swx_start`, `swx_stop`, `swx_kill`, `swx_activate_document`, `swx_close_all_documents`, `swx_reset_workspace`, `swx_show_part/_assembly/_drawing` | | Windows & etiquette | `swx_window_info`, `swx_dismiss_blocking_dialogs`, `swx_close_window`, `swx_check_recovery`, `swx_screenshot`, `swx_set_driving_badge`, `swx_action_ledger`, `swx_verb_times` | | Preferences & cloud | `swx_get_preferences`, `swx_set_preference`, `swx_cloud_status` |-| Bridge | `swx_describe`, `swx_com_eval`, `swx_uninstall` |+| Bridge | `swx_describe`, `swx_com_eval`, `swx_uninstall`, `swx_selftest` (one JSON saying what works on THIS SOLIDWORKS install — see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md)) | Verb names and arg names deliberately mirror the Adom **Fusion 360** bridge (`outputPath`, `filePath`, `name`, `parameters`, `bodies`, `current`,@@ -492,7 +492,41 @@ not. ## Roadmap -- v0.9.29 (this): **issue #2 fixed: the mass is right, and the bridge knows its own+- v0.10.0 (this): **issue #1, the modelling DSL Michael asked for, and+ SOLIDWORKS 2021 as a supported release.**+ - **Sketches.** One `sketch` holds several profiles, so concentric circles are+ a ring. A sketch can go on an offset plane+ (`{offsetFrom, offsetMm}`, made by the script) or on a face+ (`{atPointMm}`). `anchor:"corner"|"center"` and `centerMm` place+ rectangles.+ - **Extrude and cut.** Both take `reverse`, `direction:"both"` and+ `midPlane`. A feature that comes back null is now an ERROR; it used to be+ reported as success with nothing built.+ - **New ops.** `fillet`, `chamfer`, `shell`, `linear_pattern`,+ `circular_pattern` and `set_appearance`. `swx_inspect_bodies` reads the+ colour back.+ - **Documented conventions.** The help, the reference and the skill state+ the corner anchor, Top Plane +y → model −Z, and that a cut goes along the+ −normal.+ - **The brick.** `examples/lego-2x4.json` builds Michael's LEGO 2x4 brick in+ 4 features (his build took 22). The simulator's arithmetic gives+ 2073.3615 mm³, the volume SOLIDWORKS 2021 measured for his brick. It is+ still UNMEASURED live.+ - **2021 support.** `docs/COMPATIBILITY.md` audits all 149 COM members the+ bridge calls against help.solidworks.com. None is newer than 2017, so+ `bridge.json` now declares `>=2021` (supported) and 2026 (tested).+ - **Version facts.** `swx_status.solidworks` carries `majorVersion` and+ `versionSupport`. Version-gated calls refuse `needs_solidworks_version` by+ name.+ - **Classic installs.** Detection recognises a classic desktop install from+ its `SOLIDWORKS <year>` registry key, and `SWX_PROGID` picks one release+ when several are installed.+ - **`swx_selftest`.** It returns one JSON of per-verb pass/fail on any+ install. `read` changes nothing; `write` uses only a scratch part that it+ closes. It is how a user on a release we have not run gets first-class+ support.+ - 575 tests.+- v0.9.29: **issue #2 fixed: the mass is right, and the bridge knows its own part.** Reported by Michael, our first community user, on SOLIDWORKS 2021. `swx_inspect_bodies` passed `0.0` to `IBody2::GetMassProperties`, whose argument is the DENSITY (help.solidworks.com, Availability 2001Plus). Its
SKILL.md+8−1@@ -105,7 +105,14 @@ BEFORE you do it: `swx_get_parameters`, `swx_export {"format":"step","outputPath":"..."}`. 5. Make something new: `swx_new_document {"type":"part"}`, then `swx_run_script` with a JSON array of steps in millimetres, then verify with- `swx_inspect_bodies` — see `references/modelling-and-export.md`.+ `swx_inspect_bodies` — see `references/modelling-and-export.md`. One `sketch`+ can hold several profiles (rings, walls, hole patterns), on an offset plane or+ a face. `extrude`/`cut` take `reverse`, `direction:"both"` and `midPlane`.+ There are also `fillet`, `chamfer`, `shell`, `linear_pattern`,+ `circular_pattern` and `set_appearance`. A cut goes along its plane's+ −normal. A worked LEGO brick in 4 features is in `examples/lego-2x4.json`.+ SOLIDWORKS 2021 and later are supported (only 2026 is tested); on any+ install, `swx_selftest {"level":"write"}` returns one JSON of what works. 6. `swx_state {}` any time — open documents, the active one, the dialog census and a background screenshot, in one call. 7. Done: `swx_reset_workspace` closes only what the bridge created and has not
bridge.json+12−3@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.29",+ "version": "0.10.0", "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",@@ -73,6 +73,7 @@ "swx_save", "swx_save_as", "swx_screenshot",+ "swx_selftest", "swx_set_driving_badge", "swx_set_parameter", "swx_set_preference",@@ -149,7 +150,8 @@ "close_import_temps": 600, "stop": 420, "sign_in": 240,- "render_image": 600+ "render_image": 600,+ "selftest": 900 } }, "detect": {@@ -177,7 +179,14 @@ ] }, "dependencies": {- "solidworks": ">=2024"+ "solidworks": ">=2021"+ },+ "compatibility": {+ "solidworks": {+ "supported": ">=2021",+ "tested": ["2026 (3DEXPERIENCE SOLIDWORKS, Makers)"],+ "basis": "every COM member the bridge calls is available in SOLIDWORKS 2017 or earlier (help.solidworks.com Availability; docs/COMPATIBILITY.md); swx_selftest reports per-verb results on any install"+ } }, "category": "MCAD", "tags": [
docs/COMPATIBILITY.mdadded+273@@ -0,0 +1,273 @@+# SOLIDWORKS version compatibility++The bridge's first community user, Michael, filed issues #1 and #2. He runs+**SOLIDWORKS 2021** (COM `RevisionNumber` 29.5.1, Windows 11). The bridge has+only ever **run** against **3DEXPERIENCE SOLIDWORKS 2026** (Makers, revision+34). This page says what that means. Every claim is marked:++- **[audited]** read from help.solidworks.com, with a link;+- **[measured]** seen on a real machine;+- **[unmeasured]** a reasoned expectation that nobody has run yet.++## The answer++- **[audited] Supported from SOLIDWORKS 2021.** Every COM member the bridge calls+ (149 rows below, covering `com.rs`, the modelling DSL and the renderer) was+ looked up on help.solidworks.com. The newest is **SOLIDWORKS 2017 (revision+ 25)**: `IFeatureManager::FeatureCut4` and `ISldWorks::GetCurrentLicenseType`.+ Nothing is newer than 2021, so nothing needs a fallback on 2021.+- **[measured] Tested on SOLIDWORKS 2026 only.** Michael's reports show the+ bridge installing and working on 2021. That is evidence, not a test.+- `bridge.json` declares `"solidworks": ">=2021"`. It said `>=2024`, which was+ untrue in both directions: it never stopped Michael installing, and nothing in+ the API needs 2024.+- `swx_status.solidworks` reports the version it reads at attach:+ - `majorVersion`, for example 29;+ - `versionSupport`, which holds `{revision, majorVersion, year, supported,+ tested, supportedFrom: 2021, testedOn: [2026], lacks}`. `supported` and+ `tested` are separate claims.++## What breaks on 2021, and what the bridge does++| Area | On 2021 | What the bridge does |+|---|---|---|+| Every COM member called | All available (table below) | Calls them as on 2026 |+| `FeatureCut4` / `FeatureFillet3` / `FeatureLinearPattern4` / `FeatureCircularPattern4` / `FeatureExtrusion3` | Available (2013–2017) | Version gate (`requires`): on a SOLIDWORKS older than the member, the step is refused `needs_solidworks_version` with the member's name, instead of a COM "unknown name" partway through a script. This never fires on 2021. |+| Obsolete members (`SelectByID`, `SaveAs3`, `LoadFile2`, `GraphicsRedraw2`, `CreateMassProperty`, `FeatureFillet3` for constant radius, `FeatureLinearPattern4/5`, `FeatureCircularPattern4/5`, `Get4`, `AddComponent4`, `IDimension::SystemValue`) | Present: SOLIDWORKS keeps obsolete members callable | Kept, because these are the spellings proven live. The replacements are listed in the table. Each replacement is from 2004–2017, so all are on 2021 too. |+| The 3DEXPERIENCE sign-in, platform shortcut, `SWXDesktopUpdate`, Visualize | Do not exist on a classic install | The edition is `desktop`, so those paths are never taken. See the next section. |++## Classic desktop vs 3DEXPERIENCE (Connected)++| | 3DEXPERIENCE SOLIDWORKS (2026, measured) | Classic desktop (2021, unmeasured) |+|---|---|---|+| How it is detected | An install path or registry entry naming 3DEXPERIENCE, or the 3DEXPERIENCE Launcher | An install path under `SOLIDWORKS Corp`, OR a registry key `HKLM\SOFTWARE\SolidWorks\SOLIDWORKS <year>` with an exe found and no 3DEXPERIENCE marker. The exe is also looked up from `...\SOLIDWORKS <year>\Setup` value `SolidWorks Folder` [unmeasured]. `swx_status.solidworks.classicYears` lists the years. |+| `edition` / `launchRoute` | `connected` / `platformShortcut` | `desktop` / `com` |+| Launch | The Platform's own shortcut (CATSTART, then SWXDesktopLauncher), never the exe | `CoCreateInstance("SldWorks.Application")`, which starts `SLDWORKS.exe` itself. With several releases installed, set `SWX_PROGID=SldWorks.Application.29` to pick 2021 [unmeasured]. |+| Sign-in | 3DEXPERIENCE ID (three measured hosts) | None. The licence is standalone or SolidNetWork; the bridge never sees a licence prompt and never answers one. |+| Add-ins | The 3DEXPERIENCE add-in; readiness waits for its marker | No platform add-in. Readiness falls back to `StartupProcessCompleted` (2015) and the performance-log markers [unmeasured]. |+| Templates | Resolved from the user's settings (`GetDocumentTemplate`, 2001Plus SP4) | The same call [unmeasured]. |+| Visualize | A separate app started through its platform shortcut | Not part of a classic install. `swx_start {app:"visualize"}` reports `visualize_shortcut_missing`. |++## Every COM member the bridge calls++Generated from the calls in `src/comhost/com.rs` and `src/render.rs`. The+Availability text comes from each member's help page (the 2024 help, which+records the first release). "—" means the page has no Availability line. Those+are original-API members older than any line the help records. "2021?" means+available in SOLIDWORKS 2021.++| Member | Availability | 2021? | Obsolete → replacement | Source |+|---|---|---|---|---|+| `ISldWorks::ActivateDoc3` | 2012 FCS, Revision Number 20.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~activatedoc3.html) |+| `IConfigurationManager::ActiveConfiguration` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iconfigurationmanager~activeconfiguration.html) |+| `ISldWorks::ActiveDoc` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~activedoc.html) |+| `IModelDoc2::ActiveView` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~activeview.html) |+| `ICustomPropertyManager::Add3` | 2014 FCS, Revision Number 22.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icustompropertymanager~add3.html) |+| `IMassProperty::AddBodies` | 2003 SP2, Revision Number 11.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imassproperty~addbodies.html) |+| `IAssemblyDoc::AddComponent` | — (no Availability line: an original-API member) | yes | obsolete → IAssemblyDoc::AddComponent4 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iassemblydoc~addcomponent.html) |+| `IAssemblyDoc::AddComponent3` | does not exist on IAssemblyDoc (only the multi-insert `AddComponents3`); the last spelling in the insert fallback chain, never reached while `AddComponent5` answers | yes | | — |+| `IAssemblyDoc::AddComponent4` | 2003 SP5, Revision Number 11.5 | yes | obsolete → IAssemblyDoc::AddComponent5 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iassemblydoc~addcomponent4.html) |+| `IAssemblyDoc::AddComponent5` | 2010 FCS, Revision Number 18.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iassemblydoc~addcomponent5.html) |+| `ISketchManager::AddToDB` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~addtodb.html) |+| `IMassProperty::CenterOfMass` | 2003 SP2, Revision Number 11.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imassproperty~centerofmass.html) |+| `IModelDoc2::ClearSelection2` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~clearselection2.html) |+| `ISldWorks::CloseAllDocuments` | 2006 SP2, Revision Number 14.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~closealldocuments.html) |+| `ISldWorks::CloseDoc` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~closedoc.html) |+| `ISldWorks::CommandInProgress` | 2006 SP2, Revision Number 14.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~commandinprogress.html) |+| `IInterference::Components` | 2007 FCS, Revision Number 15 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iinterference~components.html) |+| `IModelDoc2::ConfigurationManager` | 2003 FCS, Revision Number 11.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~configurationmanager.html) |+| `ISketchManager::CreateCircle` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~createcircle.html) |+| `ISketchManager::CreateCircleByRadius` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~createcirclebyradius.html) |+| `ISketchManager::CreateCornerRectangle` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~createcornerrectangle.html) |+| `IModelDocExtension::CreateMassProperty` | 2003 SP2, Revision Number 11.2 | yes | obsolete → IModelDocExtension::CreateMassProperty2 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~createmassproperty.html) |+| `ISelectionMgr::CreateSelectData` | 2004 FCS, Revision Number 12 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iselectionmgr~createselectdata.html) |+| `IMathUtility::CreateTransform` | 2001 FCS, Revision Number 9.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imathutility~createtransform.html) |+| `IModelDocExtension::CustomPropertyManager` | 2007 FCS, Revision Number 15.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~custompropertymanager.html) |+| `IMassProperty::Density` | 2003 SP2, Revision Number 11.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imassproperty~density.html) |+| `IModelView::DisplayMode` | 2004 SP1, Revision Number 12.1 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodelview~displaymode.html) |+| `IInterferenceDetectionMgr::Done` | 2007 FCS, Revision Number 15.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iinterferencedetectionmgr~done.html) |+| `IModelDoc2::EditRebuild3` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~editrebuild3.html) |+| `IEquationMgr::Equation` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iequationmgr~equation.html) |+| `ISldWorks::ExitApp` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~exitapp.html) |+| `IModelDoc2::Extension` | 2003 FCS, Revision Number 11.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~extension.html) |+| `IFeatureManager::FeatureCircularPattern4` | 2013 FCS, Revision Number 21.0 | yes | obsolete → IFeatureManager::FeatureCircularPattern5 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~featurecircularpattern4.html) |+| `IFeatureManager::FeatureCut4` | 2017 FCS, Revision Number 25.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~featurecut4.html) |+| `IFeatureManager::FeatureExtrusion3` | 2014 FCS, Revision Number 22.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~featureextrusion3.html) |+| `IFeatureManager::FeatureFillet3` | 2015 FCS, Revision Number 23.0 | yes | obsolete → IFeatureManager::CreateDefinition/CreateFeature + ISimpleFilletFeatureData2 / IPartialEdgeFilletData | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~featurefillet3.html) |+| `IFeatureManager::FeatureLinearPattern4` | 2015 FCS, Revision Number 23.0 | yes | obsolete → IFeatureManager::FeatureLinearPattern5 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~featurelinearpattern4.html) |+| `IModelDoc2::FeatureManager` | 2003 FCS, Revision Number 11.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~featuremanager.html) |+| `IModelDoc2::FirstFeature` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~firstfeature.html) |+| `IAssemblyDoc::FixComponent` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iassemblydoc~fixcomponent.html) |+| `IModelDoc2::ForceRebuild3` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~forcerebuild3.html) |+| `ISldWorks::Frame` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~frame.html) |+| `ICustomPropertyManager::Get4` | 2011 SP4, Revision Number 19.4 | yes | obsolete → ICustomPropertyManager::Get5 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icustompropertymanager~get4.html) |+| `IPartDoc::GetBodies2` | 2003 FCS, Revision Number 11.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ipartdoc~getbodies2.html) |+| `IBody2::GetBodyBox` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ibody2~getbodybox.html) |+| `IAssemblyDoc::GetComponentCount` | 2006 FCS, Revision Number 14 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iassemblydoc~getcomponentcount.html) |+| `IAssemblyDoc::GetComponents` | 2006 FCS, Revision Number 14 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iassemblydoc~getcomponents.html) |+| `IModelDoc2::GetConfigurationNames` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~getconfigurationnames.html) |+| `IEquationMgr::GetCount` | 99, datecode 1999207 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iequationmgr~getcount.html) |+| `ISldWorks::GetCurrentLicenseType` | 2017 FCS, Revision Number 25.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getcurrentlicensetype.html) |+| `ISldWorks::GetDocumentCount` | 2009 FCS, Revision Number 17.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getdocumentcount.html) |+| `IPackAndGo::GetDocumentSaveToNames` | 2011 FCS, Revision Number 19.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ipackandgo~getdocumentsavetonames.html) |+| `ISldWorks::GetDocumentTemplate` | 2001Plus SP4, Revision Number 10.4 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getdocumenttemplate.html) |+| `ISldWorks::GetDocuments` | 2009 FCS, Revision Number 17.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getdocuments.html) |+| `IFace2::GetEdges` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iface2~getedges.html) |+| `IModelDoc2::GetEquationMgr` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~getequationmgr.html) |+| `IFeature::GetErrorCode` | 2001Plus FCS, Revision Number 10 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeature~geterrorcode.html) |+| `ISldWorks::GetExecutablePath` | 2003 SP3, Revision Number 11.3 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getexecutablepath.html) |+| `IBody2::GetFaceCount` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ibody2~getfacecount.html) |+| `IFeature::GetFaces` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeature~getfaces.html) |+| `ISldWorks::GetFirstDocument` | 98Plus, datecode 1998319 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getfirstdocument.html) |+| `IFrame::GetHWnd` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iframe~gethwnd.html) |+| `ISldWorks::GetImportFileData` | 2005 SP3, Revision Number 13.3 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getimportfiledata.html) |+| `IInterferenceDetectionMgr::GetInterferenceCount` | 2007 FCS, Revision Number 15.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iinterferencedetectionmgr~getinterferencecount.html) |+| `IInterferenceDetectionMgr::GetInterferences` | 2007 FCS, Revision Number 15.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iinterferencedetectionmgr~getinterferences.html) |+| `IBody2::GetMassProperties` | 2001Plus FCS Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ibody2~getmassproperties.html) |+| `IPartDoc::GetMaterialPropertyName2` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ipartdoc~getmaterialpropertyname2.html) |+| `ISldWorks::GetMathUtility` | 2001 FCS, Revision Number 9.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getmathutility.html) |+| `IComponent2::GetModelDoc2` | 2009 FCS, Revision Number 17.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icomponent2~getmodeldoc2.html) |+| `ICustomPropertyManager::GetNames` | 2004 FCS, Revision number 12.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icustompropertymanager~getnames.html) |+| `IModelDoc2::GetNext` | 2001Plus FCS, Revision Number 10 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~getnext.html) |+| `IFeature::GetNextFeature` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeature~getnextfeature.html) |+| `ISldWorks::GetOpenDocumentByName` | 2000 FCS, Revision Number 8.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getopendocumentbyname.html) |+| `IModelDocExtension::GetPackAndGo` | 2011 FCS, Revision Number 19.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~getpackandgo.html) |+| `IPartDoc::GetPartBox` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ipartdoc~getpartbox.html) |+| `IModelDoc2::GetPathName` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~getpathname.html) |+| `IComponent2::GetPathName` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icomponent2~getpathname.html) |+| `ISldWorks::GetProcessID` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getprocessid.html) |+| `IModelDoc2::GetSaveFlag` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~getsaveflag.html) |+| `ISelectionMgr::GetSelectedObject6` | 2006 FCS, Revision Number 14 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iselectionmgr~getselectedobject6.html) |+| `IComponent2::GetSuppression` | 2001Plus FCS, Revision Number 10.0 | yes | obsolete → IComponent2::GetSuppression2 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icomponent2~getsuppression.html) |+| `IModelDoc2::GetTitle` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~gettitle.html) |+| `IModelDoc2::GetType` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~gettype.html) |+| `IFeature::GetTypeName2` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeature~gettypename2.html) |+| `IModelDocExtension::GetUserPreferenceInteger` | 2009 FCS, Revision Number 17.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~getuserpreferenceinteger.html) |+| `ISldWorks::GetUserPreferenceIntegerValue` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getuserpreferenceintegervalue.html) |+| `ISldWorks::GetUserPreferenceStringValue` | 2000 FCS, Revision Number 8.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getuserpreferencestringvalue.html) |+| `ISldWorks::GetUserPreferenceToggle` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~getuserpreferencetoggle.html) |+| `IModelDocExtension::GetWhatsWrongCount` | 2009 SP3, Revision Number 17.3 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~getwhatswrongcount.html) |+| `IModelDoc2::GraphicsRedraw2` | 2001Plus FCS, Revision Number 10.0 | yes | obsolete → IModelView::GraphicsRedraw | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~graphicsredraw2.html) |+| `IModelView::HasPerspective` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodelview~hasperspective.html) |+| `IPackAndGo::IncludeDrawings` | 2011 FCS, Revision Number 19.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ipackandgo~includedrawings.html) |+| `IModelDoc2::InsertAxis2` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~insertaxis2.html) |+| `IFeatureManager::InsertFeatureChamfer` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~insertfeaturechamfer.html) |+| `IModelDoc2::InsertFeatureShell` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~insertfeatureshell.html) |+| `IFeatureManager::InsertRefPlane` | 2010 FCS, Revision Number 18.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~insertrefplane.html) |+| `ISketchManager::InsertSketch` | 2007 FCS, Revision Number 15.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~insertsketch.html) |+| `IModelDoc2::InsertSketch` | — (no Availability line: an original-API member) | yes | obsolete → ISketchManager::InsertSketch | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~insertsketch.html) |+| `IAssemblyDoc::InterferenceDetectionManager` | 2007 FCS, Revision Number 15 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iassemblydoc~interferencedetectionmanager.html) |+| `ISldWorks::LoadFile2` | — (no Availability line: an original-API member) | yes | obsolete → ISldWorks::LoadFile4 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~loadfile2.html) |+| `ISldWorks::LoadFile4` | 2006 SP1, Revision Number 14.1 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~loadfile4.html) |+| `IInterferenceDetectionMgr::MakeInterferingPartsTransparent` | 2007 FCS, Revision Number 15.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iinterferencedetectionmgr~makeinterferingpartstransparent.html) |+| `ISelectData::Mark` | 2004 FCS, Revision Number 12 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iselectdata~mark.html) |+| `IMassProperty::Mass` | 2003 SP2, Revision Number 11.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imassproperty~mass.html) |+| `IFeature::Name` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeature~name.html) |+| `IBody2::Name` | 2005 FCS, Revision Number 13 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ibody2~name.html) |+| `IConfiguration::Name` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iconfiguration~name.html) |+| `IComponent2::Name2` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icomponent2~name2.html) |+| `IModelDocExtension::NeedsRebuild2` | 2012 FCS, Revision Number 20.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~needsrebuild2.html) |+| `ISldWorks::NewDocument` | 2000 FCS, Revision Number 8.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~newdocument.html) |+| `IFace2::Normal` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iface2~normal.html) |+| `ISldWorks::OpenDoc6` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~opendoc6.html) |+| `IModelView::Orientation3` | 2001Plus SP2, Revision Number 10.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodelview~orientation3.html) |+| `IModelDoc2::Parameter` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~parameter.html) |+| `IComponent2::ReferencedConfiguration` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icomponent2~referencedconfiguration.html) |+| `ISldWorks::RevisionNumber` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~revisionnumber.html) |+| `ISldWorks::RunCommand` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~runcommand.html) |+| `ISldWorks::RunMacro2` | 2009 FCS, Revision Number 17.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~runmacro2.html) |+| `IModelDoc2::Save3` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~save3.html) |+| `IModelDoc2::SaveAs3` | — (no Availability line: an original-API member) | yes | obsolete → IModelDocExtension::SaveAs3 (chain: SaveAs3 -> IModelDocExtension::SaveAs -> SaveAs2 -> SaveAs3) | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~saveas3.html) |+| `IModelDoc2::SaveBMP` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~savebmp.html) |+| `IModelDocExtension::SavePackAndGo` | 2011 FCS, Revision Number 19.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~savepackandgo.html) |+| `IModelView::Scale2` | 99, datecode 1999207 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodelview~scale2.html) |+| `IEntity::Select4` | 2004 FCS, Revision Number 12.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ientity~select4.html) |+| `IModelDoc2::SelectByID` | — (no Availability line: an original-API member) | yes | obsolete → IModelDocExtension::SelectByID2 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~selectbyid.html) |+| `IModelDocExtension::SelectByID2` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~selectbyid2.html) |+| `IModelDoc2::SelectionManager` | 2001Plus FCS, Revision Number 10 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~selectionmanager.html) |+| `IPartDoc::SetMaterialPropertyName2` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ipartdoc~setmaterialpropertyname2.html) |+| `IPackAndGo::SetSaveToName` | 2011 FCS, Revision Number 19.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ipackandgo~setsavetoname.html) |+| `IModelDocExtension::SetUserPreferenceInteger` | 2009 FCS, Revision Number 17.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~setuserpreferenceinteger.html) |+| `ISldWorks::SetUserPreferenceIntegerValue` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~setuserpreferenceintegervalue.html) |+| `ISldWorks::SetUserPreferenceToggle` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~setuserpreferencetoggle.html) |+| `IModelDoc2::ShowNamedView2` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~shownamedview2.html) |+| `IModelDoc2::SketchManager` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~sketchmanager.html) |+| `ISldWorks::StartupProcessCompleted` | 2015 FCS, Revision Number 23.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~startupprocesscompleted.html) |+| `IMassProperty::SurfaceArea` | 2003 SP2, Revision Number 11.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imassproperty~surfacearea.html) |+| `IDimension::SystemValue` | — (no Availability line: an original-API member) | yes | obsolete → IDimension::GetSystemValue3 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.idimension~systemvalue.html) |+| `IComponent2::Transform2` | 2003 FCS, Revision Number 11.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.icomponent2~transform2.html) |+| `IModelView::Translation3` | 2001Plus SP2, Revision Number 10.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodelview~translation3.html) |+| `IInterferenceDetectionMgr::TreatCoincidenceAsInterference` | 2007 FCS, Revision Number 15.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iinterferencedetectionmgr~treatcoincidenceasinterference.html) |+| `IMassProperty::UseSystemUnits` | 2003 SP2, Revision Number 11.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imassproperty~usesystemunits.html) |+| `IInterferenceDetectionMgr::UseTransform` | 2015 FCS, Revision Number 23.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iinterferencedetectionmgr~usetransform.html) |+| `ISldWorks::UserControl` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~usercontrol.html) |+| `ISldWorks::UserControlBackground` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isldworks~usercontrolbackground.html) |+| `IEquationMgr::Value` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iequationmgr~value.html) |+| `IModelDocExtension::ViewDisplayRealView` | 2005 FCS, Revision Number 13.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~viewdisplayrealview.html) |+| `IModelDoc2::ViewZoomtofit2` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldoc2~viewzoomtofit2.html) |+| `IBody2::Visible` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ibody2~visible.html) |+| `IMassProperty::Volume` | 2003 SP2, Revision Number 11.2 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imassproperty~volume.html) |++Enum values the DSL passes are read from the published swconst pages (cited+in `com.rs`):++- `swEndConditions_e`: Blind 0, ThroughAll 1, MidPlane 6, ThroughAllBoth 9.+- `swRefPlaneReferenceConstraints_e`: Distance 8, OptionFlip 256.+- `swFeatureFilletOptions_e`: Propagate 1, UniformRadius 2.+- `swFeatureFilletType_e`: Simple 0.+- `swChamferType_e`: AngleDistance 1.+- `swFeatureChamferOption_e`: TangentPropagation 4.++All are unchanged since well before 2021.++## How to test a release we do not have: `swx_selftest`++We have no 2021 machine. `swx_selftest` is how Michael, or anyone, sends back+one JSON that says, verb by verb, what works on their install:++```+swx_selftest {} # level "read": changes nothing+swx_selftest {"level": "write"} # also builds, reads back, exports, saves and closes a scratch part+```++- **`read`** runs these and changes nothing:+ - `swx_status`, including the version facts above;+ - `swx_readiness`, `swx_docs` and `swx_get_preferences`;+ - 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+`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.++## What is still unmeasured on 2021++- 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.
docs/ECOSYSTEM-TABLE.md+1@@ -115,6 +115,7 @@ templates**, alisamsam **22**. | 52 | **Never ends a session the user is working in** (a measured user-presence guard on every quit and sweep) | ✗ | **✓ 0.9.9, from an event MEASURED 2026-10-01**: Drew took over a bridge-launched session — a "leave as found" quit met his File Open dialog, and minutes later the session held his 34-document crate assembly. Every quit, sweep and priming now measures presence (SOLIDWORKS foreground + input under 120 s, documents the bridge did not open, an earlier take-over), refuses `user_is_active` with the evidence, fails closed on an unreadable measurement, and marks the session taken over for good; only the user's explicit override proceeds, and the gates never pass it. 0.9.10: a document is the bridge's only if a bridge verb opened or created it — in a session the user launched, every document is theirs. No MCP server has a presence guard at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 53 | **Signs the user in, unattended, without ever seeing the password** (3DEXPERIENCE ID sign-in from the user's own local vault) | ✗ | **✓ 0.9.13, from Drew's request 2026-10-02**: the login window's accessible inventory is captured every time it appears; `swx_sign_in` recognizes the form from that inventory alone, takes the login from the user's pup vault on their laptop through ab, types it through UI Automation, ticks "Remember me", verifies by the window closing, and scrubs the password from every reply, log and ledger line; it fails closed on a missing login, an unknown form or a second step, and never retries a rejected password. No MCP server signs in at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 54 | **Renders a product shot at any size, and leaves the user's view as it was** (high-resolution image export from the viewport) | ✗ | **✓ 0.9.18, MEASURED LIVE 2026-10-05** (3840×2160 of the 110-document Moveo assembly in 11.6 s, every setting restored; 0.9.19 accounts for every requested setting and adds `zoomFactor`; 0.9.20 reads every set back, so a setting SOLIDWORKS ignores — RealView on an uncertified GPU, measured — is reported `setIgnored` with a note, never "applied"): `swx_render_image` sets view, style, RealView, ambient occlusion, shadows, perspective and a plain background, exports through `IModelDoc2::SaveBMP` at up to 8192 px a side, converts to PNG and reads the size back from the file (`capped:true` when only the window size came out); every changed setting is captured, restored on every path, read back and listed; the swconst ids are read from the installed interop DLL, never guessed; a render that changes the screen respects the user-presence guard. The Fusion bridge has no render verb, and no MCP server renders at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 55 | **Supports the user's OWN SOLIDWORKS release, with an audit and a self-test** (not "whatever version the author has") | ✗ | **✓ 0.10.0, from issue #1/#2 (SOLIDWORKS 2021)**: all 149 COM members the bridge calls were checked against help.solidworks.com (newest: 2017), so `>=2021` is declared as SUPPORTED with 2026 as TESTED (`docs/COMPATIBILITY.md`); `swx_status.solidworks.versionSupport` reads the release at attach; a version-gated call refuses `needs_solidworks_version` by name; `swx_selftest` returns per-verb pass/fail on any install (read-only, or a scratch part it closes) | ✗ | ✗ | ✗ | ✗ | ✗ | | 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ LIVE-PROVEN 2026-10-01 on 0.9.4** — with the 3DEXPERIENCE "Connected" notice on screen, `swx_stop` quit in 5.1 s and reported it as a notification with its evidence. **0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. **0.9.5 (adversary pass 43): the window's WORDS count — a save/recovery/import kind in its text blocks whatever the evidence, two or more button-like captions in any control class block, and measured notification evidence overrides a licence-type title alone; 0.9.7: swx_dismiss_blocking_dialogs uses the same title-and-words classifier before it closes anything; 0.9.8, from a live measurement: an owner frame read disabled for an instant (SOLIDWORKS busy) is re-read before a notice is called a modal.** No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; every verb that reaches SOLIDWORKS refuses `host_app_starting` with `retryAfterSeconds` until then — **0.9.5: gated by default with a pinned 16-verb exemption list, the performance log bound to this session's process creation time (an earlier session's log is never "complete"), COM alone never enough on a machine that writes markers, an on-demand add-in told from a start by quiet + which add-in, the grace unlocking reads only, and the window-message quit rungs ungated under userAuthorized; LIVE 2026-10-01 a document verb was refused during startup. 0.9.6: every gated reply carries the readiness its gate evaluated, the log is read from its per-start header, and the on-demand rule keys on the platform DLL SWK3DCompassAddin.dll; 0.9.7 (LIVE-PROVEN 0.9.6): no marker history — a disabled platform add-in never locks the bridge out, and a log still being written holds even a machine's first start; 0.9.11, from a live lock-out: `UNLOAD_ADDIN` is never read as a load, a session older than ten minutes with COM's start complete is never held by a log rule unless startup lines are still changing, and the ready latch survives a bridge restart**; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ |
examples/lego-2x4.jsonadded+238@@ -0,0 +1,238 @@+{+ "title": "LEGO 2x4 brick",+ "about": "The brick Michael built in issue #1 (adom/solidworks-bridge#1) with 19 boss extrudes and 3 cuts, in 4 features with the 0.10.0 DSL. Run it with swx_run_script {\"script\": <this file's script>}.",+ "dimensionsMm": {+ "length": 31.8,+ "width": 15.8,+ "height": 11.3,+ "bodyHeight": 9.6,+ "stud": {+ "diameter": 4.8,+ "height": 1.7,+ "pitch": 8.0+ },+ "wall": 1.2,+ "top": 1.0,+ "tube": {+ "outerDiameter": 6.51,+ "innerDiameter": 4.8+ }+ },+ "features": [+ "Boss-Extrude1: the 31.8 x 15.8 x 9.6 block",+ "Cut-Extrude1: the hollow underside (walls 1.2, top 1.0), cut UP from Top Plane",+ "Boss-Extrude2: three tubes from ONE sketch of three concentric circle pairs",+ "Boss-Extrude3: eight studs on a plane offset 9.6 mm above Top Plane"+ ],+ "expected": {+ "bboxMm": {+ "min": [+ -15.9,+ 0.0,+ -7.9+ ],+ "max": [+ 15.9,+ 11.3,+ 7.9+ ]+ },+ "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.",+ "script": [+ {+ "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_appearance",+ "rgb": [+ 200,+ 0,+ 0+ ]+ },+ {+ "op": "set_material",+ "name": "ABS"+ },+ {+ "op": "set_prop",+ "name": "Description",+ "value": "LEGO 2x4 brick"+ }+ ]+}
install.shpackage.json+2−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.9.29",+ "version": "0.10.0", "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": [@@ -28,6 +28,7 @@ "references/writes-and-dialogs.md", "references/modelling-and-export.md", "skills/solidworks-sign-in/SKILL.md",+ "examples/lego-2x4.json", "package.json", "install.sh", "uninstall.sh"
references/modelling-and-export.md+42−1@@ -43,7 +43,48 @@ adom-bridge --ai-thread "<thread>" swx_save_as '{"outputPath":"C:\\parts\\bracke - `swx_run_script` takes a JSON **array of steps**, not a string of code — SOLIDWORKS has no in-process scripting host the way Fusion has Python. Every dimension is in **millimetres**. A rejected script returns the full op list- and a worked example, so read the error.+ with every op's arguments (`opArgs`), the coordinate `conventions` and a+ worked example, so read the error.+- **The ops (0.10.0).** Michael asked for these in issue #1; the API calls are+ audited but UNMEASURED live, and each reports `proven:false`.+ - **Sketching.**+ - `sketch` holds SEVERAL profiles in ONE sketch:+ `{"op":"sketch","plane":"Top Plane","entities":[{"rect":{...}},{"circle":{...}}]}`.+ Concentric circles extrude into a ring, and a rectangle with circles+ inside it into a plate with holes. `sketch_rect` and `sketch_circle` are+ one-entity shorthands.+ - A sketch goes on a named plane, on an offset plane+ `"plane":{"offsetFrom":"Top Plane","offsetMm":9.6}` (the script makes it),+ or on a face `"face":{"atPointMm":[x,y,z]}`. A face must be parallel to+ Top, Front or Right, and is resolved to the offset plane that coincides+ with it.+ - A rectangle's `xMm`/`yMm` is its CORNER; `anchor:"center"` or+ `centerMm:[x,y]` centres it.+ - **Extrude and cut.** `extrude` and `cut` take `reverse`, `direction:"both"`+ (with `depth2Mm`), `midPlane:true` and `throughAll:true`.+ - **Other features.**+ - `fillet {radiusMm, edges}` and `chamfer {distanceMm, angleDeg, edges}`.+ `edges` is `{"atPointsMm":[[x,y,z]]}` or+ `{"allOfFeature":"Boss-Extrude1"|"last"}`.+ - `shell {thicknessMm, removeFacesAtPointsMm}`.+ - `linear_pattern {feature, directions:[{axis, count, spacingMm}]}`.+ - `circular_pattern {feature, axis, count, angleDeg}`.+ - `set_appearance {rgb:[r,g,b], target}`. `swx_inspect_bodies` reads it back+ as `appearanceRgb`.+- **Conventions.**+ - On Top Plane, sketch +x is model +X and sketch +y is model **−Z** (measured+ 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.+ - A feature SOLIDWORKS returns null is an **error** that stops the script. It+ is never a quiet success.+- `examples/lego-2x4.json` is a LEGO 2x4 brick in **4 features**:+ 1. the block;+ 2. a hollow cut upward;+ 3. three tubes from one sketch of concentric circles;+ 4. eight studs on a plane offset 9.6 mm.++ Run it as `swx_run_script {"script": <its "script">}`. - **Verify with `swx_inspect_bodies`, not with the step list.** It reports each solid's bounding box, size, volume, area and mass in millimetres — that is what tells you the model is what you meant. Each body's `massKg` is its
src/comhost/com.rs+612−112@@ -177,7 +177,11 @@ impl ComBackend { } fn clsid() -> Result<GUID, HostError> {- let wide: Vec<u16> = PROG_ID.encode_utf16().chain(std::iter::once(0)).collect();+ // SWX_PROGID (0.10.0): a machine with SEVERAL classic releases picks+ // one by its versioned ProgID — "SldWorks.Application.29" is+ // SOLIDWORKS 2021. Unset: the version-independent ProgID.+ let prog_id = std::env::var("SWX_PROGID").ok().filter(|p| p.trim().starts_with("SldWorks.Application")).unwrap_or_else(|| PROG_ID.to_string());+ let wide: Vec<u16> = prog_id.encode_utf16().chain(std::iter::once(0)).collect(); unsafe { CLSIDFromProgID(windows::core::PCWSTR(wide.as_ptr())).map_err(|e| { HostError::detailed(@@ -3313,6 +3317,14 @@ impl ComBackend { /// `swx_inspect_bodies` — geometry read-back in MILLIMETRES, the Fusion /// shape. `GetBodies2(0, true)` (solid bodies, visible only) is proven live.+ /// A MaterialPropertyValues array's colour as 0..255 RGB; None when the+ /// holder has no colour of its own (an empty array or negative entries).+ fn rgb_of(holder: &Disp, prop: &str) -> Option<Value> {+ let v: Vec<f64> = holder.call(prop, &[]).ok()?.as_array().iter().filter_map(|e| e.as_f64()).collect();+ (v.len() >= 3 && v[..3].iter().all(|c| (0.0..=1.0).contains(c)))+ .then(|| json!([(v[0] * 255.0).round() as i64, (v[1] * 255.0).round() as i64, (v[2] * 255.0).round() as i64]))+ }+ /// ONE BODY'S DENSITY (kg/m³): an IMassProperty restricted to that body /// with `AddBodies` (help: "Uses only the specified bodies when calculating /// the mass properties"; a multibody part can give each body its own@@ -3396,6 +3408,10 @@ impl ComBackend { "massKg": density.and(get(5)), "densityKgM3": density, "densitySource": density_source,+ // THE APPEARANCE (0.10.0, issue #1): the body's own colour+ // (IBody2::MaterialPropertyValues2) or, if it has none, the+ // part's (IModelDoc2::MaterialPropertyValues), as 0..255 RGB.+ "appearanceRgb": Self::rgb_of(&body, "MaterialPropertyValues2").or_else(|| Self::rgb_of(&doc, "MaterialPropertyValues")), "centerOfMassMm": (mp.len() >= 3).then(|| json!([mm(mp[0]), mm(mp[1]), mm(mp[2])])), "faceCount": body.int("GetFaceCount", &[]), "visible": body.flag("Visible", &[]),@@ -5312,9 +5328,10 @@ impl ComBackend { let mut results = Vec::new(); let mut created: Option<Value> = None;+ let mut st = ScriptState::default(); for (i, op) in ops.iter().enumerate() { let step = i + 1;- let detail = self.run_op(op, &pinned).map_err(|e| {+ let detail = self.run_op(op, &pinned, &mut st).map_err(|e| { HostError::detailed( format!("step {step} ({}) failed: {}", op.name(), e.message), e.detail.unwrap_or_else(|| {@@ -5328,7 +5345,13 @@ impl ComBackend { }), ) })?;+ if let Some(f) = detail.get("feature").and_then(|f| f.as_str()) {+ if op.makes_feature() {+ st.last_feature = Some(f.to_string());+ }+ } if matches!(op, Op::NewPart | Op::NewAssembly) {+ st = ScriptState::default(); pinned = detail .get("created") .and_then(|c| c.get("title"))@@ -5363,100 +5386,112 @@ impl ComBackend { /// One DSL op, as the live-proven COM call sequence, against the PINNED /// document.- fn run_op(&mut self, op: &crate::script::Op, pinned: &Option<String>) -> Result<Value, HostError> {+ fn run_op(&mut self, op: &crate::script::Op, pinned: &Option<String>, st: &mut ScriptState) -> Result<Value, HostError> { use crate::script::Op; let target = Self::doc_arg(pinned); match op { Op::NewPart => self.cmd_new_document(&json!({"type": "part"})), Op::NewAssembly => self.cmd_new_document(&json!({"type": "assembly"})), - Op::SketchRect { plane, x, y, width, height } => {+ Op::Sketch { plane, entities, .. } => { let doc = self.resolve(&target)?;- let sm = Self::open_sketch(&doc, plane)?;+ // 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)?; // 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 made = sm.call(- "CreateCornerRectangle",- &[- &Var::r8(*x),- &Var::r8(*y),- &Var::r8(0.0),- &Var::r8(x + width),- &Var::r8(y + height),- &Var::r8(0.0),- ],- );- Self::close_sketch(&sm)?;- let segs = made?.as_array().len();- if segs == 0 {- return Err(HostError::msg(- "SOLIDWORKS drew no rectangle — check that widthMm and heightMm are \- non-zero",- ));+ // 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)],+ )+ })+ .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;+ }+ } }- Ok(json!({- "plane": plane,- "segments": segs,- "widthMm": width * 1000.0,- "heightMm": height * 1000.0,- }))- }-- Op::SketchCircle { plane, x, y, radius } => {- let doc = self.resolve(&target)?;- let sm = Self::open_sketch(&doc, plane)?;- // CreateCircleByRadius is the modern form; CreateCircle (centre- // plus a rim point) is what every older macro uses. Try both- // rather than betting the op on one spelling — and close the- // sketch either way.- let made = sm- .call(- "CreateCircleByRadius",- &[&Var::r8(*x), &Var::r8(*y), &Var::r8(0.0), &Var::r8(*radius)],- )- .or_else(|_| {- sm.call(- "CreateCircle",- &[- &Var::r8(*x),- &Var::r8(*y),- &Var::r8(0.0),- &Var::r8(x + radius),- &Var::r8(*y),- &Var::r8(0.0),- ],- )- });+ sm.try_put("AddToDB", &Var::boolean(false)); Self::close_sketch(&sm)?;- let made = made?;- Ok(json!({- "plane": plane,- "radiusMm": radius * 1000.0,- "created": !made.is_null(),- }))+ if let Some(f) = failure {+ return Err(HostError::msg(format!("the sketch was closed with {} of {} entities: {f}", drawn.len(), entities.len())));+ }+ let mut out = json!({+ "plane": plane.describe(),+ "sketchedOn": on,+ "resolvedPlane": resolved,+ "createdPlane": created_plane,+ "entities": drawn,+ });+ crate::script::single_entity_fields(entities, &mut out);+ Ok(out) } - Op::Extrude { depth, reverse } => {+ Op::Extrude(ext) | Op::Cut(ext) => {+ let cut = matches!(op, Op::Cut(_)); let doc = self.resolve(&target)?;- // FeatureExtrusion3's 23 arguments, in the order proven live:- // Sd, Flip, Dir, T1, T2, D1, D2, Dchk1, Dchk2, Ddir1, Ddir2,- // Dang1, Dang2, OffsetReverse1, OffsetReverse2, TranslateSurface1,- // TranslateSurface2, Merge, UseFeatScope, UseAutoSelect, T0,- // StartOffset, FlipStartOffset.- let f = doc- .obj("FeatureManager", &[])?- .ok_or_else(|| HostError::msg("no FeatureManager"))?- .call(+ // swEndConditions_e (help.solidworks.com/2024/english/api/swconst/+ // SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.+ // swEndConditions_e.html): Blind 0, ThroughAll 1, MidPlane 6,+ // ThroughAllBoth 9.+ const BLIND: i32 = 0;+ const THROUGH_ALL: i32 = 1;+ const MID_PLANE: i32 = 6;+ const THROUGH_ALL_BOTH: i32 = 9;+ use crate::script::Direction;+ let (sd, t1, t2, d1, d2) = match (ext.direction, ext.through_all) {+ (Direction::One, false) => (true, BLIND, BLIND, ext.depth, 0.01),+ (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),+ (Direction::Both, true) => (true, THROUGH_ALL_BOTH, BLIND, 0.01, 0.01),+ };+ 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,+ // Dchk1, Dchk2, Ddir1, Ddir2, Dang1, Dang2, OffsetReverse1,+ // OffsetReverse2, TranslateSurface1, TranslateSurface2, Merge,+ // UseFeatScope, UseAutoSelect, T0, StartOffset, FlipStartOffset+ // — the order proven live.+ fm.call( "FeatureExtrusion3", &[- &Var::boolean(true),+ &Var::boolean(sd), &Var::boolean(false),- &Var::boolean(*reverse),- &Var::i4(0),- &Var::i4(0),- &Var::r8(*depth),- &Var::r8(0.01),+ &Var::boolean(ext.reverse),+ &Var::i4(t1),+ &Var::i4(t2),+ &Var::r8(d1),+ &Var::r8(d2), &Var::boolean(false), &Var::boolean(false), &Var::boolean(false),@@ -5474,38 +5509,26 @@ impl ComBackend { &Var::r8(0.0), &Var::boolean(false), ],- )?;- let feature = f.as_dispatch().map(Disp);- Self::clear_selection(&doc);- if feature.is_none() {- return Err(HostError::msg(- "SOLIDWORKS built no extrusion — the sketch may be open, unclosed, or \- self-intersecting",- ));- }- Ok(json!({- "feature": feature.and_then(|d| d.string("Name", &[])),- "depthMm": depth * 1000.0,- }))- }-- Op::Cut { depth, through_all } => {- let doc = self.resolve(&target)?;- // NOT PROVEN LIVE. swEndConditions_e: 1 = Through All, 0 = Blind.- let end = if *through_all { 1 } else { 0 };- let f = doc- .obj("FeatureManager", &[])?- .ok_or_else(|| HostError::msg("no FeatureManager"))?- .call(+ )?+ } else {+ self.requires(25, "IFeatureManager::FeatureCut4 (SOLIDWORKS 2017)")?;+ // FeatureCut4 (2017 FCS): Sd, Flip (the side to cut), Dir+ // (reverse the direction), T1, T2, D1, D2, Dchk1, Dchk2,+ // Ddir1, Ddir2, Dang1, Dang2, OffsetReverse1, OffsetReverse2,+ // TranslateSurface1, TranslateSurface2, NormalCut,+ // UseFeatScope, UseAutoSelect, AssemblyFeatureScope,+ // AutoSelectComponents, PropagateFeatureToParts, T0,+ // StartOffset, FlipStartOffset, OptimizeGeometry.+ fm.call( "FeatureCut4", &[- &Var::boolean(true),+ &Var::boolean(sd), &Var::boolean(false),- &Var::boolean(false),- &Var::i4(end),- &Var::i4(0),- &Var::r8(*depth),- &Var::r8(0.0),+ &Var::boolean(ext.reverse),+ &Var::i4(t1),+ &Var::i4(t2),+ &Var::r8(d1),+ &Var::r8(d2), &Var::boolean(false), &Var::boolean(false), &Var::boolean(false),@@ -5527,16 +5550,258 @@ impl ComBackend { &Var::r8(0.0), &Var::boolean(false), ],- )?;+ )?+ };+ 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 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"+ } else {+ "SOLIDWORKS built no extrusion (the feature came back null) — the sketch may be \+ open, unclosed, or self-intersecting"+ }));+ };+ let name = feature.string("Name", &[]); Ok(json!({- "feature": f.as_dispatch().map(Disp).and_then(|d| d.string("Name", &[])),- "throughAll": through_all,- "depthMm": depth * 1000.0,- "proven": false,+ "feature": 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,+ "endCondition": t1,+ "proven": !cut && ext.direction == Direction::One && !ext.through_all, })) } + 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}))+ }++ Op::Fillet { radius, edges } | Op::Chamfer { distance: radius, edges, .. } => {+ let fillet = matches!(op, Op::Fillet { .. });+ let doc = self.resolve(&target)?;+ 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)?;+ let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;+ let 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",+ &[+ &Var::i4(1 | 2),+ &Var::r8(*radius),+ &Var::r8(0.0),+ &Var::r8(0.0),+ &Var::i4(0),+ &Var::i4(0),+ &Var::i4(0),+ &Var::empty(),+ &Var::empty(),+ &Var::empty(),+ &Var::empty(),+ &Var::empty(),+ &Var::empty(),+ &Var::empty(),+ ],+ )?+ } else {+ let angle = match op {+ Op::Chamfer { angle, .. } => *angle,+ _ => std::f64::consts::FRAC_PI_4,+ };+ // InsertFeatureChamfer (2005 FCS): Options+ // (swFeatureChamferTangentPropagation 4), ChamferType+ // (swChamferAngleDistance 1), Width, Angle, OtherDist,+ // VertexChamDist1..3.+ fm.call(+ "InsertFeatureChamfer",+ &[&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)],+ )?+ };+ let feature = f.as_dispatch().map(Disp);+ 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" }+ ))+ })?;+ Ok(json!({"feature": feature.string("Name", &[]), "edgesSelected": n, "sizeMm": radius * 1000.0, "proven": false}))+ }++ Op::Shell { thickness, faces, outward } => {+ let doc = self.resolve(&target)?;+ 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()+ )));+ }+ }+ // 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 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}))+ }++ Op::LinearPattern { feature, dirs } => {+ let doc = self.resolve(&target)?;+ 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);+ // Marks: 1 = Direction 1, 2 = Direction 2, 4 = the features to+ // pattern (FeatureLinearPattern4's remarks).+ for (k, ax) in axes.iter().enumerate() {+ if !Self::select_named(&doc, ax, "AXIS", k > 0, (k + 1) as i32)? {+ 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);+ 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));+ 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.+ let f = fm.call(+ "FeatureLinearPattern4",+ &[+ &Var::i4(n1 as i32),+ &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::bstr(""),+ &Var::bstr(""),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::r8(0.0),+ &Var::r8(0.0),+ ],+ )?;+ let made = f.as_dispatch().map(Disp);+ 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}))+ }++ Op::CircularPattern { feature, axis, count, angle } => {+ let doc = self.resolve(&target)?;+ 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);+ // 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);+ 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.+ 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)],+ )?;+ let made = f.as_dispatch().map(Disp);+ 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}))+ }+ Op::SetMaterial { name, database } => { let doc = self.resolve(&target)?; doc.call(@@ -5655,6 +5920,241 @@ impl ComBackend { fn clear_selection(doc: &Disp) { let _ = doc.call("ClearSelection2", &[&Var::boolean(true)]); }++ /// SOLIDWORKS' major version: RevisionNumber "29.5.1" -> 29 (= 2021;+ /// year = major + 1992). `None` when it cannot be read.+ fn major_version(&mut self) -> Option<u32> {+ let sw = self.app(false, true).ok()?;+ crate::compat::major_of(&sw.string("RevisionNumber", &[])?)+ }++ /// REFUSE a call this SOLIDWORKS is too old to have, by name, instead of a+ /// COM "unknown name" error halfway through a script. An unreadable version+ /// is let through (the call itself then answers).+ fn requires(&mut self, min_major: u32, member: &str) -> Result<(), HostError> {+ match self.major_version() {+ Some(m) if m < min_major => Err(HostError::coded(+ "needs_solidworks_version",+ format!(+ "{member} needs SOLIDWORKS {} or later; this is SOLIDWORKS {} (RevisionNumber {m}) — see docs/COMPATIBILITY.md",+ min_major + 1992,+ m + 1992+ ),+ )),+ _ => Ok(()),+ }+ }++ /// 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").+ 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+ .call(+ "SelectByID2",+ &[&Var::bstr(name), &Var::bstr(kind), &Var::r8(0.0), &Var::r8(0.0), &Var::r8(0.0), &Var::boolean(append), &Var::i4(mark), &Var::null_dispatch(), &Var::i4(0)],+ )?+ .as_bool()+ .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()?;+ let mut guard = 0;+ loop {+ guard += 1;+ match f.obj("GetNextFeature", &[]).ok().flatten() {+ Some(next) if guard < 100_000 => f = next,+ _ => return f.string("Name", &[]),+ }+ }+ }++ /// A feature by name.+ fn feature_named(doc: &Disp, name: &str) -> Option<Disp> {+ let mut f = doc.obj("FirstFeature", &[]).ok().flatten()?;+ for _ in 0..100_000 {+ if f.string("Name", &[]).as_deref() == Some(name) {+ return Some(f);+ }+ f = f.obj("GetNextFeature", &[]).ok().flatten()?;+ }+ 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)+ } 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 (name, made) = self.ensure_offset_plane(doc, base, off, st)?;+ Ok((name, json!({"offsetFrom": base, "offsetMm": off * 1000.0, "faceNormal": n}), made))+ }+ }+ }++ /// 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);+ 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")));+ }+ 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)))+ }++ /// 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> {+ 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);+ 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 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)+ }++ /// 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)+ }+ }+ }+}++/// What a running script remembers between ops: the feature "last" names, and+/// the reference planes / axes it has already made (reused, not duplicated).+#[derive(Default)]+struct ScriptState {+ last_feature: Option<String>,+ planes: std::collections::HashMap<String, String>,+ axes: std::collections::HashMap<crate::script::Axis, String>,+}++impl ScriptState {+ fn resolve_feature(&self, f: &str) -> Result<String, HostError> {+ if f == "last" {+ self.last_feature.clone().ok_or_else(|| HostError::msg("\"last\" names no feature: none was made earlier in this script — name the feature (swx_design_info lists them)"))+ } else {+ Ok(f.to_string())+ }+ } } /// THE swconst IDS, READ FROM THE INSTALLED INTEROP ASSEMBLY (0.9.18). The
src/comhost/mock.rs+211−20@@ -81,6 +81,69 @@ 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()`@@ -114,6 +177,9 @@ struct MockDoc { /// be tested without a live SOLIDWORKS. import_temp: bool, temp_path: Option<String>,+ /// The simulated solid model (0.10.0): features, bounding box, volume,+ /// appearance — see `mock_model`.+ model: Map<String, Value>, } impl MockDoc {@@ -138,6 +204,7 @@ impl MockDoc { "components": self.components.iter().map(|(f, c)| json!([f, c])).collect::<Vec<_>>(), "importTemp": self.import_temp, "tempPath": self.temp_path,+ "model": Value::Object(self.model.clone()), }) } @@ -176,6 +243,7 @@ impl MockDoc { .unwrap_or_default(), import_temp: v.get("importTemp").and_then(|x| x.as_bool()).unwrap_or(false), temp_path: v.get("tempPath").and_then(|x| x.as_str()).map(String::from),+ model: v.get("model").and_then(|x| x.as_object()).cloned().unwrap_or_default(), }) } @@ -196,6 +264,7 @@ impl MockDoc { components: Vec::new(), import_temp: false, temp_path: None,+ model: Map::new(), } } }@@ -285,7 +354,17 @@ fn journal(event: &str, path: &str) { } } -const MOCK_VERSION: &str = "mock 33.0.0 (2025 SP0)";+const MOCK_VERSION_DEFAULT: &str = "mock 33.0.0 (2025 SP0)";++/// The simulated RevisionNumber. `mock-revision` in the data root overrides it+/// (e.g. "29.5.1" for the SOLIDWORKS 2021 issue #1/#2 were filed on).+fn mock_version() -> String {+ std::fs::read_to_string(crate::scratch::root().join("mock-revision"))+ .ok()+ .map(|t| t.trim().to_string())+ .filter(|t| !t.is_empty())+ .unwrap_or_else(|| MOCK_VERSION_DEFAULT.to_string())+} /// The `%TEMP%` the SIMULATED launcher writes under. ///@@ -1063,7 +1142,7 @@ impl MockBackend { self.publish_session(); Ok(json!({ "running": self.running,- "version": if self.running { Value::from(MOCK_VERSION) } else { Value::Null },+ "version": if self.running { Value::from(mock_version()) } else { Value::Null }, "openDocs": self.docs.len(), "attached": self.running, // THE PID COM WOULD REPORT: the simulated SOLIDWORKS process,@@ -1134,7 +1213,7 @@ impl MockBackend { } Ok(json!({ "started": true,- "version": MOCK_VERSION,+ "version": mock_version(), "wasLaunched": was_launched, "mainHwnd": 4242, "processId": self.session(),@@ -1244,7 +1323,7 @@ impl MockBackend { "route": route, "attached": attaches, "shortcut": shortcut,- "version": if attaches { Value::from(MOCK_VERSION) } else { Value::Null },+ "version": if attaches { Value::from(mock_version()) } else { Value::Null }, })) } @@ -3414,7 +3493,7 @@ impl MockBackend { let active = self.active.as_ref().map(|p| self.doc_info(p)); Ok(json!({ "running": self.running,- "version": if self.running { Value::from(MOCK_VERSION) } else { Value::Null },+ "version": if self.running { Value::from(mock_version()) } else { Value::Null }, "activeDocument": active.clone().map(|a| a.get("title").cloned().unwrap_or(Value::Null)).unwrap_or(Value::Null), "activeDoc": active, "documents": docs,@@ -3464,7 +3543,7 @@ impl MockBackend { "EnumDocuments2": false, }, "licenseType": 8,- "version": MOCK_VERSION,+ "version": mock_version(), })) } @@ -3531,7 +3610,7 @@ impl MockBackend { Ok(json!({ "current": Value::Object(current), "documentScoped": !self.docs.is_empty(),- "app": {"visible": self.running, "licenseType": 8, "version": MOCK_VERSION},+ "app": {"visible": self.running, "licenseType": 8, "version": mock_version()}, })) } @@ -3680,6 +3759,8 @@ impl MockBackend { let target = pin_arg(&pinned); 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; for (i, op) in ops.iter().enumerate() { let detail = match op { Op::NewPart | Op::NewAssembly => {@@ -3689,26 +3770,136 @@ impl MockBackend { pinned = created.get("title").and_then(|t| t.as_str()).map(String::from); r }- Op::SketchRect { plane, width, height, .. } => {- json!({"plane": plane, "segments": 4, "widthMm": width * 1000.0, "heightMm": height * 1000.0})+ Op::Sketch { plane, entities, .. } => {+ if fault("mock-sketch-fails") {+ return Err(HostError::msg(format!("step {} ({}) failed: SOLIDWORKS drew nothing", i + 1, op.name())));+ }+ 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(_)) {+ 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}"));+ }+ 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::SketchCircle { plane, radius, .. } => {- json!({"plane": plane, "radiusMm": radius * 1000.0, "created": true})+ 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" }+ )));+ }+ 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::Extrude { depth, .. } => {+ Op::SetAppearance { rgb, target: at } => { let path = self.resolve(&target)?;- let mut made = String::from("Boss-Extrude1");+ 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) {- let n = d.bodies.len() + 1;- made = format!("Boss-Extrude{n}");- d.bodies.push(mock_body(&made, 50.0, 30.0, depth * 1000.0));- d.dimensions.insert(format!("D1@{made}"), json!(*depth));+ 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}));+ }+ }+ }+ rebuild_body(d); d.dirty = true; }- json!({"feature": made, "depthMm": depth * 1000.0})+ 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})+ }+ };+ 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::Cut { depth, through_all } => {- json!({"feature": "Cut-Extrude1", "throughAll": through_all, "depthMm": depth * 1000.0, "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)?;
src/comhost/mock_model.rsadded+179@@ -0,0 +1,179 @@+//! 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.+//!+//! 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.++use crate::script::{Direction, Entity, Extent, PlaneRef};++/// 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+ ))+}++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())),+ }+}++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)),+ Entity::Circle { cx, cy, r } => (cx - r, cy - r, cx + r, cy + r),+ }+}++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,+ }+}++fn inside(a: &Entity, b: &Entity) -> bool {+ let (ax0, ay0, ax1, ay1) = bounds(a);+ let (bx0, by0, bx1, by1) = bounds(b);+ a != b && ax0 >= bx0 && ay0 >= by0 && ax1 <= bx1 && ay1 <= by1 && area(a) < area(b)+}++/// The profile's area: entities at an even nesting depth add, odd subtract —+/// two concentric circles are a RING (issue #1 measured a solid r 2.4 cylinder+/// when each circle had its own sketch).+pub fn profile_area(entities: &[Entity]) -> f64 {+ entities+ .iter()+ .map(|e| {+ let depth = entities.iter().filter(|o| inside(e, o)).count();+ if depth % 2 == 0 { area(e) } else { -area(e) }+ })+ .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)))+}++/// 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)),+ };+ }+ match e.direction {+ Direction::One => {+ let up = !cut ^ e.reverse;+ if up { (o, o + e.depth) } else { (o - e.depth, o) }+ }+ 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);+ 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);+ for i in 0..3 {+ lo[i] = lo[i].min(p[i]);+ hi[i] = hi[i].max(p[i]);+ }+ }+ }+ (lo, hi)+}++#[cfg(test)]+mod tests {+ use super::*;++ #[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");+ }+}
src/comhost/mod.rs+1@@ -24,6 +24,7 @@ pub mod created_store; pub mod expr; pub mod host; pub mod mock;+pub mod mock_model; #[cfg(windows)] pub mod com;
src/compat.rsadded+90@@ -0,0 +1,90 @@+//! SOLIDWORKS VERSION FACTS (0.10.0) — what the bridge needs from each+//! release, and how it reads which release it is talking to.+//!+//! Issue #1/#2's reporter (Michael) runs SOLIDWORKS 2021 (COM RevisionNumber+//! 29.5.1). Every COM member this bridge calls was checked against its+//! help.solidworks.com "Availability" line (docs/COMPATIBILITY.md); the+//! newest is SOLIDWORKS 2017 (Revision 25), so 2021 is SUPPORTED. Only the+//! 3DEXPERIENCE 2026 install has been TESTED.++/// The oldest SOLIDWORKS this bridge supports, by the API audit.+pub const SUPPORTED_MIN_MAJOR: u32 = 29; // SOLIDWORKS 2021+/// The release the bridge has actually run against.+pub const TESTED_MAJOR: &[u32] = &[34]; // SOLIDWORKS 2026 (3DEXPERIENCE, Makers)++/// `RevisionNumber` "29.5.1" -> 29.+pub fn major_of(revision: &str) -> Option<u32> {+ // The first run of digits ("29.5.1", or the mock's "mock 33.0.0 (...)").+ let start = revision.find(|c: char| c.is_ascii_digit())?;+ let digits: String = revision[start..].chars().take_while(|c| c.is_ascii_digit()).collect();+ digits.parse().ok().filter(|m: &u32| *m >= 5 && *m < 200)+}++/// The marketing year of a major version: 29 -> 2021 (SOLIDWORKS 2001Plus was+/// revision 10, 2021 is 29).+pub fn year_of(major: u32) -> u32 {+ major + 1992+}++/// The major version of a marketing year: 2021 -> 29.+pub fn major_of_year(year: u32) -> u32 {+ year.saturating_sub(1992)+}++/// The members whose availability is newer than the oldest SOLIDWORKS the+/// bridge would otherwise run on, with the major version each needs. Kept as+/// data so `swx_selftest` and `swx_status` can say what a given install lacks.+pub const GATED: &[(&str, u32)] = &[+ ("IFeatureManager::FeatureCut4", 25),+ ("ISldWorks::GetCurrentLicenseType", 25),+ ("IFeatureManager::FeatureFillet3", 23),+ ("IFeatureManager::FeatureLinearPattern4", 23),+ ("ISldWorks::StartupProcessCompleted", 23),+ ("IInterferenceDetectionMgr::UseTransform", 23),+ ("IFeatureManager::FeatureExtrusion3", 22),+ ("ICustomPropertyManager::Add3", 22),+ ("IFeatureManager::FeatureCircularPattern4", 21),+];++/// What a given major version lacks (empty for anything >= 2017).+pub fn missing_on(major: u32) -> Vec<&'static str> {+ GATED.iter().filter(|(_, need)| major < *need).map(|(m, _)| *m).collect()+}++/// The `solidworks` block's version facts.+pub fn describe(revision: Option<&str>) -> serde_json::Value {+ let major = revision.and_then(major_of);+ serde_json::json!({+ "revision": revision,+ "majorVersion": major,+ "year": major.map(year_of),+ "supported": major.map(|m| m >= SUPPORTED_MIN_MAJOR),+ "tested": major.map(|m| TESTED_MAJOR.contains(&m)),+ "supportedFrom": year_of(SUPPORTED_MIN_MAJOR),+ "testedOn": TESTED_MAJOR.iter().map(|m| year_of(*m)).collect::<Vec<_>>(),+ "lacks": major.map(missing_on),+ })+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn revision_numbers_map_to_years() {+ assert_eq!(major_of("29.5.1"), Some(29));+ assert_eq!(year_of(29), 2021);+ assert_eq!(major_of("34.3.0"), Some(34));+ assert_eq!(year_of(34), 2026);+ assert_eq!(major_of_year(2021), 29);+ assert_eq!(major_of(""), None);+ assert_eq!(major_of("garbage"), None);+ assert_eq!(major_of("mock 33.0.0 (2025 SP0)"), Some(33));+ assert!(missing_on(29).is_empty(), "2021 lacks nothing the bridge calls");+ assert!(missing_on(24).contains(&"IFeatureManager::FeatureCut4"));+ let d = describe(Some("29.5.1"));+ assert_eq!(d["supported"], true);+ assert_eq!(d["tested"], false);+ assert_eq!(d["year"], 2021);+ }+}
src/detect.rs+56−2@@ -43,6 +43,10 @@ impl Detection { "canLaunchProgrammatically": self.can_launch(), "shortcut": self.shortcut, "launchRoute": self.launch_route().name(),+ // CLASSIC DESKTOP RELEASES in the registry (0.10.0): SOLIDWORKS 2021+ // is major version 29. Launched through COM (the exe), with no+ // 3DEXPERIENCE sign-in; see docs/COMPATIBILITY.md.+ "classicYears": classic_years(&self.registry_versions), }) } @@ -111,6 +115,13 @@ pub fn edition_from( if up.contains("SOLIDWORKS CORP") { return "desktop"; }+ // THE CLASSIC REGISTRY LAYOUT (0.10.0, issue #1/#2: SOLIDWORKS 2021):+ // `HKLM\SOFTWARE\SolidWorks\SOLIDWORKS 2021`, a year and nothing else, with no+ // 3DEXPERIENCE marker anywhere (checked above) and an exe found. UNMEASURED+ // on a classic machine; it is the documented layout of the desktop installer.+ if exe.is_some() && !classic_years(registry_versions).is_empty() {+ return "desktop";+ } if exe.is_none() { return "unknown"; }@@ -120,6 +131,22 @@ pub fn edition_from( "unknown" } +/// The classic desktop releases named in the registry: "SOLIDWORKS 2021" ->+/// 2021. A 3DEXPERIENCE entry ("SOLIDWORKS 3DEXPERIENCE R2026x") is not one.+pub fn classic_years(registry_versions: &[String]) -> Vec<u32> {+ let mut out: Vec<u32> = registry_versions+ .iter()+ .filter_map(|v| {+ let rest = v.trim().strip_prefix("SOLIDWORKS ").or_else(|| v.trim().strip_prefix("SolidWorks "))?;+ let year: u32 = rest.trim().parse().ok()?;+ (2000..2100).contains(&year).then_some(year)+ })+ .collect();+ out.sort();+ out.dedup();+ out+}+ /// How this bridge may start SOLIDWORKS. /// /// LIVE FINDING (2026-09-03) — THE LAUNCH ANSWER. The earlier conclusion that@@ -510,8 +537,28 @@ mod windows_impl { .unwrap_or(false) } + /// A classic install's own folder: `HKLM\SOFTWARE\SolidWorks\SOLIDWORKS+ /// <year>\Setup` value "SolidWorks Folder" (newest year first). UNMEASURED.+ fn classic_setup_exe(versions: &[String]) -> Option<String> {+ let hive = RegKey::predef(HKEY_LOCAL_MACHINE);+ let mut years = super::classic_years(versions);+ years.reverse();+ for y in years {+ if let Ok(key) = hive.open_subkey(format!(r"SOFTWARE\SolidWorks\SOLIDWORKS {y}\Setup")) {+ if let Ok(dir) = key.get_value::<String, _>("SolidWorks Folder") {+ let exe = std::path::Path::new(dir.trim_end_matches(['\\', '/'])).join("SLDWORKS.exe");+ if exe.is_file() {+ return Some(exe.to_string_lossy().to_string());+ }+ }+ }+ }+ None+ }+ pub fn detect() -> Detection {- let mut exe = app_paths_lookup();+ let versions = registry_versions();+ let mut exe = app_paths_lookup().or_else(|| classic_setup_exe(&versions)); if exe.is_none() { for pattern in CANDIDATE_GLOBS { let hits = expand_glob(pattern);@@ -524,7 +571,7 @@ mod windows_impl { Detection { installed: exe.is_some(), exe,- registry_versions: registry_versions(),+ registry_versions: versions, running: is_running(), launcher_present: super::launcher_present(), shortcut: super::find_shortcut(),@@ -672,6 +719,13 @@ mod tests { // COM launch that produces the refusal modal. assert_eq!(edition_from(Some(r"C:\Program Files\Dassault Systemes\SOLIDWORKS\SLDWORKS.exe"), &none, false), "unknown"); assert_eq!(edition_from(Some(r"D:\weird\SLDWORKS.exe"), &none, false), "unknown");+ // THE CLASSIC REGISTRY (issue #1/#2's SOLIDWORKS 2021): a year key, an exe,+ // no 3DEXPERIENCE anywhere -> desktop; with a 3DEXPERIENCE marker -> connected.+ let y2021 = vec!["SOLIDWORKS 2021".to_string()];+ assert_eq!(edition_from(Some(r"D:\SW2021\SLDWORKS.exe"), &y2021, false), "desktop");+ assert_eq!(edition_from(Some(r"D:\SW2021\SLDWORKS.exe"), &y2021, true), "connected");+ assert_eq!(edition_from(None, &y2021, false), "unknown", "no exe: nothing to launch");+ assert_eq!(classic_years(&["SOLIDWORKS 2021".into(), "SOLIDWORKS 3DEXPERIENCE R2026x".into(), "SOLIDWORKS 2019".into()]), vec![2019, 2021]); assert_eq!(edition_from(None, &none, false), "unknown"); }
src/lib.rs+2@@ -29,6 +29,7 @@ pub mod abwin; pub mod bom; pub mod caller;+pub mod compat; pub mod comhost; pub mod detect; pub mod findfiles;@@ -44,6 +45,7 @@ pub mod quit; pub mod readiness; pub mod render; pub mod disk;+pub mod selftest; pub mod scratch; pub mod signin; pub mod sha256;
src/script.rs+561−48@@ -35,21 +35,167 @@ use serde_json::Value; /// Millimetres to metres — the single place the unit boundary is crossed. pub const MM: f64 = 0.001; +/// WHERE A SKETCH LIVES (0.10.0, issue #1).+///+/// COORDINATE CONVENTIONS — stated, because Michael (issue #1) had to work them+/// out by testing:+/// * a sketch's (x, y) are the PLANE's own 2D axes. On **Top Plane**, sketch+/// +X is model +X and sketch +Y is model **−Z** (measured by Michael on+/// SOLIDWORKS 2021). On Front Plane sketch (x, y) is model (X, Y).+/// * an OFFSET plane keeps its base plane's axes; it sits `offsetMm` along the+/// base plane's normal (Top +Y, Front +Z, Right +X).+/// * a FACE is resolved to the offset plane that coincides with it, so its+/// sketch axes are the matching default plane's axes too (only faces parallel+/// to a default plane are accepted).+#[derive(Debug, Clone, PartialEq)]+pub enum PlaneRef {+ /// A plane by name: "Front Plane", "Top Plane", "Right Plane", or any+ /// reference plane in the part.+ Named(String),+ /// A reference plane `offset` metres along `from`'s normal (negative = the+ /// other side). Created by the script and owned by it.+ Offset { from: String, offset: f64 },+ /// The planar face of the part that passes through `point` (metres).+ Face { point: [f64; 3] },+}++impl PlaneRef {+ pub fn describe(&self) -> Value {+ match self {+ PlaneRef::Named(n) => Value::String(n.clone()),+ PlaneRef::Offset { from, offset } => serde_json::json!({"offsetFrom": from, "offsetMm": offset / MM}),+ PlaneRef::Face { point } => serde_json::json!({"face": {"atPointMm": [point[0] / MM, point[1] / MM, point[2] / MM]}}),+ }+ }+}++/// One closed profile in a sketch, in the plane's 2D coordinates (metres).+/// A rectangle is stored by its two CORNERS — the `anchor` was resolved at+/// parse time, so both backends draw exactly the same thing.+#[derive(Debug, Clone, PartialEq)]+pub enum Entity {+ Rect { x0: f64, y0: f64, x1: f64, y1: f64 },+ Circle { cx: f64, cy: f64, r: f64 },+}++impl Entity {+ pub fn describe(&self) -> Value {+ match self {+ Entity::Rect { x0, y0, x1, y1 } => serde_json::json!({"rect": {"cornersMm": [[x0 / MM, y0 / MM], [x1 / MM, y1 / MM]]}}),+ Entity::Circle { cx, cy, r } => serde_json::json!({"circle": {"centerMm": [cx / MM, cy / MM], "radiusMm": r / MM}}),+ }+ }+}++/// The one-entity shorthands' result fields (`widthMm`/`heightMm`,+/// `radiusMm`), kept so a sketch_rect / sketch_circle step reports what it+/// always reported.+pub fn single_entity_fields(entities: &[Entity], out: &mut Value) {+ if let (Some(e), true, Value::Object(m)) = (entities.first(), entities.len() == 1, out) {+ match *e {+ Entity::Rect { x0, y0, x1, y1 } => {+ m.insert("widthMm".into(), serde_json::json!((x1 - x0).abs() / MM));+ m.insert("heightMm".into(), serde_json::json!((y1 - y0).abs() / MM));+ }+ Entity::Circle { r, .. } => {+ m.insert("radiusMm".into(), serde_json::json!(r / MM));+ }+ }+ }+}++/// Which way(s) an extrude or cut goes from its sketch plane.+#[derive(Debug, Clone, Copy, PartialEq)]+pub enum Direction {+ /// One direction: along the plane's normal for an extrude, along its+ /// −normal for a cut (measured, issue #1); `reverse` flips it.+ One,+ /// Two directions: `depth` one way and `depth2` the other.+ Both,+ /// Symmetric about the sketch plane, `depth` in total.+ MidPlane,+}++/// The extent of an extrude or a cut.+#[derive(Debug, Clone, PartialEq)]+pub struct Extent {+ pub depth: f64,+ pub depth2: f64,+ pub through_all: bool,+ pub reverse: bool,+ pub direction: Direction,+}++/// A model axis (patterns, directions).+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]+pub enum Axis {+ X,+ Y,+ Z,+}++impl Axis {+ pub fn name(&self) -> &'static str {+ match self {+ Axis::X => "x",+ Axis::Y => "y",+ Axis::Z => "z",+ }+ }+ /// The two default planes whose intersection IS this axis.+ pub fn planes(&self) -> (&'static str, &'static str) {+ match self {+ Axis::X => ("Top Plane", "Front Plane"),+ Axis::Y => ("Front Plane", "Right Plane"),+ Axis::Z => ("Top Plane", "Right Plane"),+ }+ }+}++/// Which edges a fillet or chamfer takes.+#[derive(Debug, Clone, PartialEq)]+pub enum EdgeSel {+ /// The edge passing through each point (metres).+ AtPoints(Vec<[f64; 3]>),+ /// Every edge of a feature's faces ("last" = the previous feature).+ AllOfFeature(String),+}++/// What an appearance is applied to.+#[derive(Debug, Clone, PartialEq)]+pub enum AppearanceTarget {+ Part,+ Body(String),+ FaceAt([f64; 3]),+}+ #[derive(Debug, Clone, PartialEq)] pub enum Op { /// Create a new part document from the resolved part template. NewPart, /// Create a new assembly document from the resolved assembly template. NewAssembly,- /// A corner rectangle on a named plane, in metres, relative to the origin.- SketchRect { plane: String, x: f64, y: f64, width: f64, height: f64 },- /// A circle on a named plane, in metres.- SketchCircle { plane: String, x: f64, y: f64, radius: f64 },- /// Extrude the sketch that is currently selected (the one just closed).- Extrude { depth: f64, reverse: bool },- /// Cut-extrude through the selected sketch.- Cut { depth: f64, through_all: bool },+ /// ONE sketch holding every entity listed (0.10.0) — several closed+ /// profiles in one sketch extrude into rings, walls or hole patterns in ONE+ /// feature. `shorthand` is the op the caller wrote: "sketch", "sketch_rect"+ /// or "sketch_circle" (the last two are one-entity shorthands).+ Sketch { shorthand: &'static str, plane: PlaneRef, entities: Vec<Entity> },+ /// Extrude the sketch just closed.+ Extrude(Extent),+ /// Cut-extrude through the sketch just closed.+ Cut(Extent), SetMaterial { name: String, database: String },+ /// Colour, as 0..1 RGB (the caller writes 0..255).+ SetAppearance { rgb: [f64; 3], target: AppearanceTarget },+ Fillet { radius: f64, edges: EdgeSel },+ /// An angle-distance chamfer: `distance` metres, `angle` radians.+ Chamfer { distance: f64, angle: f64, edges: EdgeSel },+ /// Hollow the part, removing the faces through `faces`.+ Shell { thickness: f64, faces: Vec<[f64; 3]>, outward: bool },+ /// Pattern a feature along one or two model axes.+ LinearPattern { feature: String, dirs: Vec<(Axis, u32, f64)> },+ /// Pattern a feature about a model axis; `angle` is the TOTAL angle (radians).+ CircularPattern { feature: String, axis: Axis, count: u32, angle: f64 }, SetProp { name: String, value: String }, SaveAs { path: String }, Rebuild,@@ -62,28 +208,49 @@ impl Op { match self { Op::NewPart => "new_part", Op::NewAssembly => "new_assembly",- Op::SketchRect { .. } => "sketch_rect",- Op::SketchCircle { .. } => "sketch_circle",- Op::Extrude { .. } => "extrude",- Op::Cut { .. } => "cut",+ Op::Sketch { shorthand, .. } => shorthand,+ Op::Extrude(_) => "extrude",+ Op::Cut(_) => "cut", Op::SetMaterial { .. } => "set_material",+ Op::SetAppearance { .. } => "set_appearance",+ Op::Fillet { .. } => "fillet",+ Op::Chamfer { .. } => "chamfer",+ Op::Shell { .. } => "shell",+ Op::LinearPattern { .. } => "linear_pattern",+ Op::CircularPattern { .. } => "circular_pattern", Op::SetProp { .. } => "set_prop", Op::SaveAs { .. } => "save_as", Op::Rebuild => "rebuild", Op::ZoomFit => "zoom_fit", } }++ /// Does this op make a FEATURE in the tree (the one "last" refers to)?+ pub fn makes_feature(&self) -> bool {+ matches!(+ self,+ Op::Extrude(_) | Op::Cut(_) | Op::Fillet { .. } | Op::Chamfer { .. } | Op::Shell { .. }+ | Op::LinearPattern { .. } | Op::CircularPattern { .. }+ )+ } } /// Every op slug, for the unknown-op hint and for `swx_describe`. pub const OPS: &[&str] = &[ "new_part", "new_assembly",+ "sketch", "sketch_rect", "sketch_circle", "extrude", "cut", "set_material",+ "set_appearance",+ "fillet",+ "chamfer",+ "shell",+ "linear_pattern",+ "circular_pattern", "set_prop", "save_as", "rebuild",@@ -363,39 +530,188 @@ pub fn parse(script: &Value) -> Result<Vec<Op>, String> { Ok(ops) } +fn point3(v: &Value, what: &str) -> Result<[f64; 3], String> {+ let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| format!("{what} must be [x, y, z] in millimetres"))?;+ let mut out = [0.0; 3];+ for (i, x) in a.iter().enumerate() {+ out[i] = x.as_f64().filter(|f| f.is_finite()).ok_or_else(|| format!("{what} must be three finite numbers (mm)"))? * MM;+ }+ Ok(out)+}++fn points3(v: Option<&Value>, what: &str) -> Result<Vec<[f64; 3]>, String> {+ let a = v.and_then(|v| v.as_array()).filter(|a| !a.is_empty()).ok_or_else(|| format!("{what} must be a non-empty list of [x, y, z] points in millimetres"))?;+ a.iter().map(|p| point3(p, what)).collect()+}++fn positive_mm(v: &Value, key: &str) -> Result<f64, String> {+ let x = mm(v, key, None)?;+ if x <= 0.0 {+ return Err(format!("{key} must be greater than zero"));+ }+ Ok(x)+}++/// `"plane": "Top Plane"` | `"plane": {"offsetFrom": "Top Plane", "offsetMm": 9.6}`+/// | `"face": {"atPointMm": [x, y, z]}`.+fn plane_ref(step: &Value) -> Result<PlaneRef, String> {+ if let Some(face) = step.get("face").filter(|f| !f.is_null()) {+ if step.get("plane").map(|p| !p.is_null()).unwrap_or(false) {+ return Err("give `plane` OR `face`, not both".into());+ }+ let p = face.get("atPointMm").ok_or_else(|| "face needs atPointMm: [x, y, z] (mm), a point ON the planar face".to_string())?;+ return Ok(PlaneRef::Face { point: point3(p, "face.atPointMm")? });+ }+ match step.get("plane") {+ None | Some(Value::Null) => Ok(PlaneRef::Named("Front Plane".into())),+ Some(Value::String(s)) if !s.trim().is_empty() => Ok(PlaneRef::Named(s.clone())),+ Some(o @ Value::Object(_)) => {+ let from = o.get("offsetFrom").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty())+ .ok_or_else(|| "plane.offsetFrom (a plane name, e.g. \"Top Plane\") is required for an offset plane".to_string())?;+ let off = mm(o, "offsetMm", None).map_err(|e| format!("plane.{e}"))?;+ if off == 0.0 {+ return Ok(PlaneRef::Named(from.to_string()));+ }+ Ok(PlaneRef::Offset { from: from.to_string(), offset: off })+ }+ _ => Err("plane must be a plane name or {offsetFrom, offsetMm}".into()),+ }+}++/// A centre given as `centerMm: [x, y]`, if present.+fn center2(v: &Value) -> Result<Option<(f64, f64)>, String> {+ match v.get("centerMm") {+ None | Some(Value::Null) => Ok(None),+ Some(c) => {+ let a = c.as_array().filter(|a| a.len() == 2).ok_or_else(|| "centerMm must be [x, y] in millimetres".to_string())?;+ let f = |i: usize| a[i].as_f64().filter(|f| f.is_finite()).map(|f| f * MM).ok_or_else(|| "centerMm must be two finite numbers".to_string());+ Ok(Some((f(0)?, f(1)?)))+ }+ }+}++/// A rectangle: `widthMm`, `heightMm`, and its position — `xMm`/`yMm` are the+/// CORNER by default (`anchor:"corner"`), the CENTRE with `anchor:"center"`, or+/// `centerMm:[x, y]`.+fn rect(v: &Value) -> Result<Entity, String> {+ let w = positive_mm(v, "widthMm")?;+ let h = positive_mm(v, "heightMm")?;+ let anchor = v.get("anchor").and_then(|a| a.as_str()).unwrap_or("corner");+ let (x0, y0) = match (center2(v)?, anchor) {+ (Some((cx, cy)), _) => (cx - w / 2.0, cy - h / 2.0),+ (None, "center" | "centre") => (mm(v, "xMm", Some(0.0))? - w / 2.0, mm(v, "yMm", Some(0.0))? - h / 2.0),+ (None, "corner") => (mm(v, "xMm", Some(0.0))?, mm(v, "yMm", Some(0.0))?),+ (None, other) => return Err(format!("anchor must be \"corner\" or \"center\", got {other:?}")),+ };+ Ok(Entity::Rect { x0, y0, x1: x0 + w, y1: y0 + h })+}++fn circle(v: &Value) -> Result<Entity, String> {+ let r = positive_mm(v, "radiusMm").or_else(|e| {+ v.get("diameterMm").map(|_| positive_mm(v, "diameterMm").map(|d| d / 2.0)).unwrap_or(Err(e))+ })?;+ let (cx, cy) = match center2(v)? {+ Some(c) => c,+ None => (mm(v, "xMm", Some(0.0))?, mm(v, "yMm", Some(0.0))?),+ };+ Ok(Entity::Circle { cx, cy, r })+}++fn extent(step: &Value, is_cut: bool) -> Result<Extent, String> {+ let through_all = flag(step, "throughAll", false);+ let mid = flag(step, "midPlane", false);+ let mut direction = match step.get("direction").and_then(|d| d.as_str()) {+ None => Direction::One,+ Some("one") => Direction::One,+ Some("both") => Direction::Both,+ Some("midPlane") | Some("mid_plane") | Some("midplane") => Direction::MidPlane,+ Some(other) => return Err(format!("direction must be \"one\", \"both\" or \"midPlane\", got {other:?}")),+ };+ if mid {+ direction = Direction::MidPlane;+ }+ if through_all && direction == Direction::MidPlane {+ // "Through All - Both" is SOLIDWORKS' own name for this.+ direction = Direction::Both;+ }+ let depth = if through_all {+ mm(step, "depthMm", Some(0.0))?+ } else if is_cut && step.get("depthMm").is_none() {+ return Err("cut needs depthMm (mm), or throughAll:true".into());+ } else {+ positive_mm(step, "depthMm")?+ };+ let depth2 = match step.get("depth2Mm") {+ Some(_) => positive_mm(step, "depth2Mm")?,+ None => depth,+ };+ Ok(Extent { depth, depth2, through_all, reverse: flag(step, "reverse", false), direction })+}++fn edges(step: &Value) -> Result<EdgeSel, String> {+ let e = step.get("edges").ok_or_else(|| "edges is required: {\"atPointsMm\": [[x,y,z], ...]} or {\"allOfFeature\": \"Boss-Extrude1\" | \"last\"}".to_string())?;+ if let Some(f) = e.get("allOfFeature").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {+ return Ok(EdgeSel::AllOfFeature(f.to_string()));+ }+ Ok(EdgeSel::AtPoints(points3(e.get("atPointsMm"), "edges.atPointsMm")?))+}++fn axis(v: Option<&Value>, what: &str) -> Result<Axis, String> {+ match v.and_then(|a| a.as_str()).map(|a| a.to_ascii_lowercase()) {+ Some(a) if a == "x" => Ok(Axis::X),+ Some(a) if a == "y" => Ok(Axis::Y),+ Some(a) if a == "z" => Ok(Axis::Z),+ _ => Err(format!("{what} must be \"x\", \"y\" or \"z\" (a model axis)")),+ }+}++fn count(v: &Value, key: &str) -> Result<u32, String> {+ let n = v.get(key).and_then(|c| c.as_u64()).ok_or_else(|| format!("{key} (a whole number of instances, including the original) is required"))?;+ if !(2..=1000).contains(&n) {+ return Err(format!("{key} must be between 2 and 1000"));+ }+ Ok(n as u32)+}++fn feature_ref(step: &Value) -> String {+ step.get("feature").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()).unwrap_or("last").to_string()+}+ fn parse_step(step: &Value) -> Result<Op, String> { let op = step .get("op") .and_then(|v| v.as_str()) .ok_or_else(|| format!("missing `op` (one of: {})", OPS.join(", ")))?;- let plane = || {- step.get("plane")- .and_then(|v| v.as_str())- .filter(|s| !s.is_empty())- .unwrap_or("Front Plane")- .to_string()- }; Ok(match op { "new_part" => Op::NewPart, "new_assembly" => Op::NewAssembly,- "sketch_rect" => Op::SketchRect {- plane: plane(),- x: mm(step, "xMm", Some(0.0))?,- y: mm(step, "yMm", Some(0.0))?,- width: mm(step, "widthMm", None)?,- height: mm(step, "heightMm", None)?,- },- "sketch_circle" => Op::SketchCircle {- plane: plane(),- x: mm(step, "xMm", Some(0.0))?,- y: mm(step, "yMm", Some(0.0))?,- radius: mm(step, "radiusMm", None)?,- },- "extrude" => Op::Extrude { depth: mm(step, "depthMm", None)?, reverse: flag(step, "reverse", false) },- "cut" => Op::Cut {- depth: mm(step, "depthMm", Some(0.0))?,- through_all: flag(step, "throughAll", false),- },+ "sketch_rect" => Op::Sketch { shorthand: "sketch_rect", plane: plane_ref(step)?, entities: vec![rect(step)?] },+ "sketch_circle" => Op::Sketch { shorthand: "sketch_circle", plane: plane_ref(step)?, entities: vec![circle(step)?] },+ "sketch" => {+ let list = step.get("entities").and_then(|e| e.as_array()).filter(|a| !a.is_empty())+ .ok_or_else(|| "sketch needs entities: a non-empty list of {\"rect\": {...}} / {\"circle\": {...}}".to_string())?;+ if list.len() > 500 {+ return Err("a sketch may hold at most 500 entities".into());+ }+ let mut entities = Vec::with_capacity(list.len());+ for (i, e) in list.iter().enumerate() {+ let one = if let Some(r) = e.get("rect") {+ rect(r)+ } else if let Some(c) = e.get("circle") {+ circle(c)+ } else {+ match e.get("type").and_then(|t| t.as_str()) {+ Some("rect") => rect(e),+ Some("circle") => circle(e),+ _ => Err("each entity is {\"rect\": {...}} or {\"circle\": {...}}".into()),+ }+ };+ entities.push(one.map_err(|m| format!("entity {}: {m}", i + 1))?);+ }+ Op::Sketch { shorthand: "sketch", plane: plane_ref(step)?, entities }+ }+ "extrude" => Op::Extrude(extent(step, false)?),+ "cut" => Op::Cut(extent(step, true)?), "set_material" => Op::SetMaterial { name: text(step, "name")?, database: step@@ -405,6 +721,65 @@ fn parse_step(step: &Value) -> Result<Op, String> { .unwrap_or("SOLIDWORKS Materials.sldmat") .to_string(), },+ "set_appearance" => {+ let rgb = step.get("rgb").and_then(|v| v.as_array()).filter(|a| a.len() == 3)+ .ok_or_else(|| "rgb must be [r, g, b], each 0..255".to_string())?;+ let mut c = [0.0; 3];+ for (i, v) in rgb.iter().enumerate() {+ let x = v.as_f64().filter(|x| x.is_finite() && (0.0..=255.0).contains(x)).ok_or_else(|| "rgb values must be 0..255".to_string())?;+ c[i] = x / 255.0;+ }+ let target = match step.get("target") {+ None | Some(Value::Null) => AppearanceTarget::Part,+ Some(t) => {+ if let Some(b) = t.get("bodyName").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {+ AppearanceTarget::Body(b.to_string())+ } else if let Some(p) = t.get("faceAtPointMm") {+ AppearanceTarget::FaceAt(point3(p, "target.faceAtPointMm")?)+ } else if t.as_str() == Some("part") {+ AppearanceTarget::Part+ } else {+ return Err("target is \"part\", {\"bodyName\": ...} or {\"faceAtPointMm\": [x,y,z]}".into());+ }+ }+ };+ Op::SetAppearance { rgb: c, target }+ }+ "fillet" => Op::Fillet { radius: positive_mm(step, "radiusMm")?, edges: edges(step)? },+ "chamfer" => {+ let angle = step.get("angleDeg").map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 90.0)+ .ok_or_else(|| "angleDeg must be between 0 and 90".to_string())).transpose()?.unwrap_or(45.0);+ Op::Chamfer { distance: positive_mm(step, "distanceMm")?, angle: angle.to_radians(), edges: edges(step)? }+ }+ "shell" => Op::Shell {+ thickness: positive_mm(step, "thicknessMm")?,+ faces: points3(step.get("removeFacesAtPointsMm"), "removeFacesAtPointsMm")?,+ outward: flag(step, "outward", false),+ },+ "linear_pattern" => {+ let dirs = step.get("directions").and_then(|d| d.as_array()).filter(|a| !a.is_empty() && a.len() <= 2)+ .ok_or_else(|| "directions: one or two of {\"axis\": \"x\"|\"y\"|\"z\", \"count\": n, \"spacingMm\": s}".to_string())?;+ let mut out = Vec::new();+ for (i, d) in dirs.iter().enumerate() {+ let one = (|| -> Result<(Axis, u32, f64), String> {+ Ok((axis(d.get("axis"), "axis")?, count(d, "count")?, mm(d, "spacingMm", None)?))+ })()+ .map_err(|m| format!("direction {}: {m}", i + 1))?;+ if one.2 == 0.0 {+ return Err(format!("direction {}: spacingMm must not be zero", i + 1));+ }+ out.push(one);+ }+ if out.len() == 2 && out[0].0 == out[1].0 {+ return Err("the two directions must be different axes".into());+ }+ Op::LinearPattern { feature: feature_ref(step), dirs: out }+ }+ "circular_pattern" => {+ let angle = step.get("angleDeg").map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a <= 360.0)+ .ok_or_else(|| "angleDeg must be in (0, 360]".to_string())).transpose()?.unwrap_or(360.0);+ Op::CircularPattern { feature: feature_ref(step), axis: axis(step.get("axis"), "axis")?, count: count(step, "count")?, angle: angle.to_radians() }+ } "set_prop" => Op::SetProp { name: text(step, "name")?, value: match step.get("value") {@@ -422,17 +797,25 @@ fn parse_step(step: &Value) -> Result<Op, String> { }) } -/// A sketch op must come before the feature that consumes it, and a feature-/// needs a document. Checked BEFORE the first COM call so a script that cannot-/// work never half-runs on the user's live session — the difference between a-/// clean refusal and a stray unsaved document on their screen.+/// A sketch op must come before the feature that consumes it, a feature needs+/// a document, and "last" needs a feature before it. Checked BEFORE the first+/// COM call so a script that cannot work never half-runs on the user's live+/// session — the difference between a clean refusal and a stray unsaved+/// document on their screen. pub fn validate(ops: &[Op], has_open_doc: bool) -> Result<(), String> { let mut doc = has_open_doc; let mut sketch_pending = false;+ // A feature to call "last": one made EARLIER IN THIS SCRIPT. (On an+ // existing document, name the feature — swx_design_info lists them.)+ let mut have_feature = false; for (i, op) in ops.iter().enumerate() { let at = i + 1; match op {- Op::NewPart | Op::NewAssembly => doc = true,+ Op::NewPart | Op::NewAssembly => {+ doc = true;+ have_feature = false;+ continue;+ } _ if !doc => { return Err(format!( "step {at} ({}) needs a document: start the script with new_part, or open one \@@ -440,19 +823,28 @@ pub fn validate(ops: &[Op], has_open_doc: bool) -> Result<(), String> { op.name() )) }- Op::SketchRect { .. } | Op::SketchCircle { .. } => sketch_pending = true,- Op::Extrude { .. } | Op::Cut { .. } => {+ Op::Sketch { .. } => sketch_pending = true,+ Op::Extrude(_) | Op::Cut(_) => { if !sketch_pending { return Err(format!(- "step {at} ({}) has no sketch to consume — put a sketch_rect or \+ "step {at} ({}) has no sketch to consume — put a sketch, sketch_rect or \ sketch_circle immediately before it", op.name() )); } sketch_pending = false; }+ Op::Fillet { edges: EdgeSel::AllOfFeature(f), .. } | Op::Chamfer { edges: EdgeSel::AllOfFeature(f), .. }+ | Op::LinearPattern { feature: f, .. } | Op::CircularPattern { feature: f, .. }+ if f == "last" && !have_feature =>+ {+ return Err(format!("step {at} ({}) refers to the \"last\" feature, and no feature comes before it", op.name()));+ } _ => {} }+ if op.makes_feature() {+ have_feature = true;+ } } Ok(()) }@@ -476,9 +868,16 @@ mod tests { assert_eq!(ops.len(), 6); assert_eq!( ops[1],- Op::SketchRect { plane: "Top Plane".into(), x: 0.0, y: 0.0, width: 0.05, height: 0.03 }+ Op::Sketch {+ shorthand: "sketch_rect",+ plane: PlaneRef::Named("Top Plane".into()),+ entities: vec![Entity::Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.03 }]+ }+ );+ assert_eq!(+ ops[2],+ Op::Extrude(Extent { depth: 0.02, depth2: 0.02, through_all: false, reverse: false, direction: Direction::One }) );- assert_eq!(ops[2], Op::Extrude { depth: 0.02, reverse: false }); assert_eq!( ops[3], Op::SetMaterial { name: "6061 Alloy".into(), database: "SOLIDWORKS Materials.sldmat".into() }@@ -491,7 +890,14 @@ mod tests { #[test] fn defaults_the_plane_and_the_sketch_origin() { let ops = parse(&json!([{"op": "sketch_circle", "radiusMm": 5}])).unwrap();- assert_eq!(ops[0], Op::SketchCircle { plane: "Front Plane".into(), x: 0.0, y: 0.0, radius: 0.005 });+ assert_eq!(+ ops[0],+ Op::Sketch {+ shorthand: "sketch_circle",+ plane: PlaneRef::Named("Front Plane".into()),+ entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.005 }]+ }+ ); } #[test]@@ -707,4 +1113,111 @@ mod tests { assert_eq!(ops[0].name(), "rebuild"); assert!(OPS.contains(&"rebuild")); }++ // ── 0.10.0: issue #1 (Michael) ──────────────────────────────────────────++ fn near(a: f64, b: f64) -> bool {+ (a - b).abs() < 1e-12+ }++ #[test]+ fn offset_planes_faces_and_anchors_parse_into_exact_geometry() {+ let ops = parse(&json!([+ {"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 9.6}, "widthMm": 31.8, "heightMm": 15.8, "anchor": "center"},+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 4, "centerMm": [5, 2]},+ {"op": "sketch_circle", "face": {"atPointMm": [0, 9.6, 0]}, "diameterMm": 4.8},+ {"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 0}, "widthMm": 1, "heightMm": 1}+ ]))+ .unwrap();+ match &ops[0] {+ Op::Sketch { plane: PlaneRef::Offset { from, offset }, entities, .. } => {+ assert_eq!(from, "Top Plane");+ assert!(near(*offset, 0.0096));+ match entities[0] {+ Entity::Rect { x0, y0, x1, y1 } => assert!(near(x0, -0.0159) && near(y0, -0.0079) && near(x1, 0.0159) && near(y1, 0.0079)),+ _ => panic!(),+ }+ }+ other => panic!("{other:?}"),+ }+ match &ops[1] {+ Op::Sketch { entities, .. } => match entities[0] {+ Entity::Rect { x0, y0, x1, y1 } => assert!(near(x0, 0.0) && near(y0, 0.0) && near(x1, 0.01) && near(y1, 0.004)),+ _ => panic!(),+ },+ _ => panic!(),+ }+ match &ops[2] {+ Op::Sketch { plane: PlaneRef::Face { point }, entities, .. } => {+ assert!(near(point[1], 0.0096));+ assert_eq!(entities[0], Entity::Circle { cx: 0.0, cy: 0.0, r: 0.0024 });+ }+ other => panic!("{other:?}"),+ }+ // an offset of zero IS the base plane+ assert!(matches!(&ops[3], Op::Sketch { plane: PlaneRef::Named(p), .. } if p == "Top Plane"));+ // refusals+ assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 1, "heightMm": 1, "anchor": "middle"}])).unwrap_err().contains("anchor"));+ assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 0, "heightMm": 1}])).unwrap_err().contains("greater than zero"));+ assert!(parse(&json!([{"op": "sketch_rect", "plane": "Top Plane", "face": {"atPointMm": [0, 0, 0]}, "widthMm": 1, "heightMm": 1}])).unwrap_err().contains("not both"));+ assert!(parse(&json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 0]}, "radiusMm": 1}])).unwrap_err().contains("[x, y, z]"));+ }++ #[test]+ fn one_sketch_holds_many_profiles_and_extents_read_every_direction() {+ let ops = parse(&json!([+ {"op": "sketch", "plane": "Top Plane", "entities": [+ {"rect": {"widthMm": 10, "heightMm": 10, "anchor": "center"}},+ {"circle": {"radiusMm": 3.255}},+ {"type": "circle", "radiusMm": 2.4}+ ]},+ {"op": "extrude", "depthMm": 8.6, "direction": "both", "depth2Mm": 2},+ {"op": "cut", "throughAll": true, "reverse": true},+ {"op": "cut", "depthMm": 4, "midPlane": true},+ {"op": "extrude", "throughAll": true, "midPlane": true}+ ]))+ .unwrap();+ assert!(matches!(&ops[0], Op::Sketch { shorthand: "sketch", entities, .. } if entities.len() == 3));+ assert_eq!(ops[1], Op::Extrude(Extent { depth: 0.0086, depth2: 0.002, through_all: false, reverse: false, direction: Direction::Both }));+ assert_eq!(ops[2], Op::Cut(Extent { depth: 0.0, depth2: 0.0, through_all: true, reverse: true, direction: Direction::One }));+ assert!(matches!(&ops[3], Op::Cut(e) if e.direction == Direction::MidPlane && near(e.depth, 0.004)));+ // through all + mid plane is SOLIDWORKS' "Through All - Both"+ assert!(matches!(&ops[4], Op::Extrude(e) if e.direction == Direction::Both && e.through_all));+ assert!(parse(&json!([{"op": "cut"}])).unwrap_err().contains("throughAll"));+ assert!(parse(&json!([{"op": "sketch", "entities": []}])).unwrap_err().contains("non-empty"));+ assert!(parse(&json!([{"op": "sketch", "entities": [{"triangle": {}}]}])).unwrap_err().starts_with("step 1: entity 1:"));+ assert!(parse(&json!([{"op": "extrude", "depthMm": 1, "direction": "sideways"}])).unwrap_err().contains("direction"));+ }++ #[test]+ fn appearance_fillet_chamfer_shell_and_patterns_parse() {+ let ops = parse(&json!([+ {"op": "set_appearance", "rgb": [200, 0, 0]},+ {"op": "set_appearance", "rgb": [0, 0, 255], "target": {"faceAtPointMm": [0, 1, 0]}},+ {"op": "fillet", "radiusMm": 0.5, "edges": {"allOfFeature": "last"}},+ {"op": "chamfer", "distanceMm": 0.3, "edges": {"atPointsMm": [[1, 2, 3]]}},+ {"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 0, 0]]},+ {"op": "linear_pattern", "directions": [{"axis": "x", "count": 4, "spacingMm": 8}, {"axis": "z", "count": 2, "spacingMm": -8}]},+ {"op": "circular_pattern", "feature": "Boss-Extrude2", "axis": "y", "count": 6}+ ]))+ .unwrap();+ assert!(matches!(&ops[0], Op::SetAppearance { rgb, target: AppearanceTarget::Part } if near(rgb[0], 200.0 / 255.0) && rgb[1] == 0.0));+ assert!(matches!(&ops[1], Op::SetAppearance { target: AppearanceTarget::FaceAt(p), .. } if near(p[1], 0.001)));+ assert!(matches!(&ops[2], Op::Fillet { radius, edges: EdgeSel::AllOfFeature(f) } if near(*radius, 0.0005) && f == "last"));+ assert!(matches!(&ops[3], Op::Chamfer { angle, .. } if near(*angle, std::f64::consts::FRAC_PI_4)));+ assert!(matches!(&ops[4], Op::Shell { outward: false, .. }));+ assert!(matches!(&ops[5], Op::LinearPattern { feature, dirs } if feature == "last" && dirs.len() == 2 && dirs[1].0 == Axis::Z && near(dirs[1].2, -0.008)));+ assert!(matches!(&ops[6], Op::CircularPattern { axis: Axis::Y, count: 6, angle, .. } if near(*angle, std::f64::consts::TAU)));+ assert!(ops.iter().filter(|o| o.makes_feature()).count() == 5);+ assert!(parse(&json!([{"op": "set_appearance", "rgb": [300, 0, 0]}])).unwrap_err().contains("0..255"));+ assert!(parse(&json!([{"op": "linear_pattern", "directions": [{"axis": "x", "count": 1, "spacingMm": 8}]}])).unwrap_err().contains("count"));+ assert!(parse(&json!([{"op": "linear_pattern", "directions": [{"axis": "x", "count": 2, "spacingMm": 8}, {"axis": "x", "count": 2, "spacingMm": 8}]}])).unwrap_err().contains("different axes"));+ assert!(parse(&json!([{"op": "fillet", "radiusMm": 1}])).unwrap_err().contains("edges"));+ // "last" needs a feature before it+ let bad = parse(&json!([{"op": "new_part"}, {"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "last"}}])).unwrap();+ assert!(validate(&bad, false).unwrap_err().contains("\"last\""));+ let good = parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 1, "heightMm": 1}, {"op": "extrude", "depthMm": 1},+ {"op": "fillet", "radiusMm": 0.1, "edges": {"allOfFeature": "last"}}])).unwrap();+ assert!(validate(&good, false).is_ok());+ } }
src/selftest.rsadded+230@@ -0,0 +1,230 @@+//! `swx_selftest` (0.10.0) — FIRST-CLASS SUPPORT WITHOUT THE MACHINE.+//!+//! Our first community user (Michael, issues #1 and #2) runs SOLIDWORKS 2021;+//! the bridge has only ever run against 3DEXPERIENCE SOLIDWORKS 2026. This verb+//! is how he — or anyone on a release we have not seen — can send back ONE JSON+//! that says, verb by verb, what works on their install.+//!+//! * `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.+//!+//! Every sub-call goes through the normal dispatcher, gates included: the+//! self-test is held to exactly the rules a caller is.++use serde_json::{json, Value};+use std::time::Instant;++use crate::verbs::Ctx;++struct Run {+ results: Vec<Value>,+}++impl Run {+ /// One verb, recorded. Returns the reply.+ fn call(&mut self, ctx: &Ctx, name: &str, verb: &str, args: Value) -> Value {+ let t = Instant::now();+ let r = crate::verbs::dispatch(ctx, verb, &args);+ let pass = r.get("success").and_then(|v| v.as_bool()) != Some(false);+ let mut row = json!({+ "name": name,+ "verb": verb,+ "pass": pass,+ "ms": t.elapsed().as_millis() as u64,+ });+ if !pass {+ row["errorCode"] = r.get("errorCode").cloned().unwrap_or(Value::Null);+ row["error"] = json!(r.get("error").and_then(|e| e.as_str()).unwrap_or("").chars().take(400).collect::<String>());+ row["hostError"] = r.get("hostError").cloned().unwrap_or(Value::Null);+ }+ self.results.push(row);+ r+ }++ fn skip(&mut self, name: &str, verb: &str, why: &str) {+ self.results.push(json!({"name": name, "verb": verb, "pass": Value::Null, "skipped": why}));+ }++ /// Attach a small detail to the last row.+ fn note(&mut self, detail: Value) {+ if let Some(last) = self.results.last_mut() {+ last["detail"] = detail;+ }+ }+}++/// 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") {+ return crate::verbs::err_code(+ "bad_level",+ format!("level must be \"read\" (changes nothing) or \"write\" (also builds, exports and closes a scratch part), got {level:?}"),+ "Run level:\"read\" first; level:\"write\" only touches documents the self-test creates in the bridge's scratch folder.",+ );+ }+ let started = Instant::now();+ let mut run = Run { results: Vec::new() };++ // ── READ: changes nothing ───────────────────────────────────────────────+ let status = run.call(ctx, "status", "swx_status", json!({}));+ let sw = status.get("solidworks").cloned().unwrap_or(Value::Null);+ run.note(json!({+ "edition": sw.get("edition"),+ "launchRoute": sw.get("launchRoute"),+ "versionSupport": sw.get("versionSupport"),+ "classicYears": sw.get("classicYears"),+ "registryVersions": sw.get("registryVersions"),+ }));+ let readiness = run.call(ctx, "readiness", "swx_readiness", json!({}));+ let ready = readiness.get("ready").and_then(|v| v.as_bool()) == Some(true)+ || readiness.pointer("/solidworks/ready").and_then(|v| v.as_bool()) == Some(true);+ let docs = run.call(ctx, "document list", "swx_docs", json!({}));+ let active = docs.pointer("/active/title").and_then(|t| t.as_str()).map(String::from);+ let active_type = docs.pointer("/active/type").and_then(|t| t.as_str()).map(String::from);+ run.call(ctx, "preferences", "swx_get_preferences", json!({}));+ match (&active, active_type.as_deref()) {+ (Some(t), kind) => {+ let a = json!({"targetDocument": t});+ run.call(ctx, "active document info", "swx_document_info", a.clone());+ run.call(ctx, "design info", "swx_design_info", a.clone());+ run.call(ctx, "parameters", "swx_get_parameters", a.clone());+ run.call(ctx, "custom properties", "swx_props_get", a.clone());+ if kind != Some("drawing") {+ run.call(ctx, "mass properties", "swx_mass_properties", a.clone());+ }+ match kind {+ Some("part") => {+ run.call(ctx, "bodies", "swx_inspect_bodies", a.clone());+ }+ Some("assembly") => {+ run.call(ctx, "bill of materials", "swx_bom", a.clone());+ }+ _ => {}+ }+ }+ (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 ──────────────+ if level == "write" {+ 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);+ if let Some(title) = title {+ let target = |extra: Value| -> Value {+ let mut a = json!({"targetDocument": title});+ if let (Value::Object(m), Value::Object(e)) = (&mut a, extra) {+ m.extend(e);+ }+ 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!([+ {"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}"));+ }+ }+ }+ 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!({})));+ 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"),+ }+ 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)");+ }+ if let Some(prev) = &active {+ run.call(ctx, "re-activate the document that was active before", "swx_activate_document", json!({"name": prev}));+ }+ }++ let passed = run.results.iter().filter(|r| r["pass"] == true).count();+ let failed = run.results.iter().filter(|r| r["pass"] == false).count();+ let skipped = run.results.iter().filter(|r| r["pass"].is_null()).count();+ crate::verbs::ok(json!({+ "level": level,+ "bridgeVersion": crate::BRIDGE_VERSION,+ "solidworks": {+ "versionSupport": sw.get("versionSupport"),+ "edition": sw.get("edition"),+ "launchRoute": sw.get("launchRoute"),+ "classicYears": sw.get("classicYears"),+ "ready": ready,+ },+ "summary": {"passed": passed, "failed": failed, "skipped": skipped, "seconds": started.elapsed().as_secs_f64()},+ "results": run.results,+ "_hint": "Send this WHOLE reply back (for example on the bridge's wiki issue): each row is one verb or one modelling op on your install, with the error SOLIDWORKS gave when it failed. level:\"read\" changed nothing; level:\"write\" built, exported, saved and closed a scratch part in the bridge's own scratch folder and re-activated the document that was active before.",+ }))+}
src/verbs.rs+15−3@@ -390,7 +390,7 @@ pub(crate) fn ok_merge(result: Value, extras: Value) -> Value { Value::Object(out) } -pub(crate) fn ok(extras: Value) -> Value {+pub fn ok(extras: Value) -> Value { ok_merge(json!({}), extras) } @@ -398,7 +398,7 @@ pub(crate) fn err(message: impl Into<String>, hint: impl Into<String>) -> Value json!({"success": false, "error": message.into(), "_hint": hint.into()}) } -pub(crate) fn err_code(code: &str, message: impl Into<String>, hint: impl Into<String>) -> Value {+pub fn err_code(code: &str, message: impl Into<String>, hint: impl Into<String>) -> Value { json!({"success": false, "errorCode": code, "error": message.into(), "_hint": hint.into()}) } @@ -603,6 +603,15 @@ fn swx_status(ctx: &Ctx, _args: &Value) -> Value { map.insert(key.clone(), v.clone()); } }+ // THE VERSION, read at attach (0.10.0): RevisionNumber "29.5.1" is+ // SOLIDWORKS 2021 — the release issue #1/#2 were filed on. Supported vs+ // tested are different claims, and both are said (docs/COMPATIBILITY.md).+ if let Value::Object(map) = &mut solidworks {+ let revision = com.get("version").and_then(|v| v.as_str());+ let v = crate::compat::describe(revision);+ map.insert("majorVersion".into(), v["majorVersion"].clone());+ map.insert("versionSupport".into(), v);+ } ok(json!({ "bridgeVersion": crate::BRIDGE_VERSION, "mock": crate::is_mock(),@@ -3059,7 +3068,8 @@ pub const CATALOG: &[(&str, &str, &str)] = &[ ("swx_set_parameter", "write", "drive one named dimension (valueMm or value) and rebuild"), ("swx_inspect_bodies", "read", "per-body geometry read-back in millimetres (bbox, volume, area, faces)"), ("swx_run_macro", "exec", "run a SOLIDWORKS VBA macro (.swp/.swb/.dll) — arbitrary code, so it needs the write opt-in every time"),- ("swx_run_script", "exec", "the modeling DSL: new_part/sketch_rect/extrude/set_material/save_as (refuses to build into a document the bridge did not create)"),+ ("swx_run_script", "exec", "the modeling DSL in millimetres: new_part, sketch (several rect/circle profiles in one sketch, on a named plane, an offset plane {offsetFrom, offsetMm} or a face {atPointMm}), sketch_rect/sketch_circle (anchor corner|center), extrude/cut (reverse, direction one|both|midPlane, throughAll), fillet, chamfer, shell, linear_pattern, circular_pattern, set_appearance (rgb), set_material, set_prop, save_as; a feature SOLIDWORKS returns null is an error (refuses to build into a document the bridge did not create)"),+ ("swx_selftest", "exec", "one JSON that says, verb by verb, what works on THIS SOLIDWORKS install: level:\"read\" (default) changes nothing; level:\"write\" also builds, reads back, exports, saves and CLOSES a scratch part in the bridge's own scratch folder, one modelling op per call — how a user on a release we have not run (e.g. SOLIDWORKS 2021) gets first-class support"), ("swx_add_component", "write", "insert part/assembly files as components of an assembly, positioned in mm (the verb swx_bom needs)"), ("swx_get_preferences", "read", "the small set of preferences this bridge can round-trip"), ("swx_set_preference", "write", "write one preference and READ IT BACK to verify"),@@ -3191,6 +3201,7 @@ pub const STARTUP_EXEMPT: &[(&str, &str)] = &[ ("swx_kill", "the last resort for a wedged start (reason + confirm); reads the document count only to say what is lost"), ("swx_sign_in", "acts on the LAUNCHER's sign-in window, never through COM — it is how a start gets past the sign-in at all"), ("swx_user_presence", "a read about the USER, needed most during a start; asks COM for documents only on a verified-ready session"),+ ("swx_selftest", "never reaches the com host itself: every check it runs goes through this same gate, and a check held at it is REPORTED — a self-test that refused to run while SOLIDWORKS starts could not say so"), ]; /// swx_stop is gated with one carve-out: under `userAuthorized`, a plan that@@ -3407,6 +3418,7 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value { "swx_inspect_bodies" => crate::verbs3::inspect_bodies, "swx_run_macro" => crate::verbs3::run_macro, "swx_run_script" => crate::verbs3::run_script,+ "swx_selftest" => crate::selftest::selftest, "swx_add_component" => crate::verbs3::add_component, "swx_get_preferences" => crate::verbs3::get_preferences, "swx_set_preference" => crate::verbs3::set_preference,
src/verbs3.rs+25−5@@ -4663,7 +4663,8 @@ fn script_help(message: impl Into<String>) -> Value { {"op": "set_material", "name": "6061 Alloy"}, {"op": "set_prop", "name": "PartNo", "value": "BRK-100"}, {"op": "save_as", "path": "C:\\parts\\bracket.sldprt"}- ]+ ],+ "moreExamples": "examples/lego-2x4.json on the bridge page: a LEGO 2x4 brick in 4 features (offset-plane studs, a multi-profile sketch for the tubes)" }), ); map.insert(@@ -4671,10 +4672,17 @@ fn script_help(message: impl Into<String>) -> Value { json!({ "new_part": {}, "new_assembly": {},- "sketch_rect": {"plane": "Front Plane|Top Plane|Right Plane", "widthMm": "required", "heightMm": "required", "xMm": 0, "yMm": 0},- "sketch_circle": {"plane": "Front Plane", "radiusMm": "required", "xMm": 0, "yMm": 0},- "extrude": {"depthMm": "required", "reverse": false},- "cut": {"depthMm": 0, "throughAll": false},+ "sketch": {"plane": "Front Plane|Top Plane|Right Plane|<any plane name> | {offsetFrom, offsetMm}", "face": "{atPointMm:[x,y,z]} instead of plane", "entities": "[{rect:{widthMm, heightMm, xMm, yMm, anchor:corner|center, centerMm:[x,y]}}, {circle:{radiusMm|diameterMm, xMm, yMm | centerMm}}] — ALL in one sketch: concentric circles make a ring, a rect with circles inside makes a plate with holes"},+ "sketch_rect": {"plane": "as sketch", "face": "as sketch", "widthMm": "required", "heightMm": "required", "xMm": 0, "yMm": 0, "anchor": "corner (default: xMm/yMm is the CORNER) | center", "centerMm": "[x, y] (implies center)"},+ "sketch_circle": {"plane": "as sketch", "face": "as sketch", "radiusMm": "required (or diameterMm)", "xMm": 0, "yMm": 0, "centerMm": "[x, y]"},+ "extrude": {"depthMm": "required unless throughAll", "reverse": false, "direction": "one|both|midPlane", "depth2Mm": "direction both: the other side (default = depthMm)", "midPlane": false, "throughAll": false},+ "cut": {"depthMm": "required unless throughAll", "throughAll": false, "reverse": false, "direction": "one|both|midPlane", "depth2Mm": "as extrude", "midPlane": false},+ "fillet": {"radiusMm": "required", "edges": "{atPointsMm:[[x,y,z],...]} | {allOfFeature: \"Boss-Extrude1\" | \"last\"}"},+ "chamfer": {"distanceMm": "required", "angleDeg": 45, "edges": "as fillet"},+ "shell": {"thicknessMm": "required", "removeFacesAtPointsMm": "[[x,y,z], ...] — a point ON each face to remove", "outward": false},+ "linear_pattern": {"feature": "\"last\" (default) | a feature name", "directions": "[{axis: x|y|z, count: n (incl. the original), spacingMm: s (negative = the other way)}] — one or two"},+ "circular_pattern": {"feature": "\"last\" | a feature name", "axis": "x|y|z", "count": "n (incl. the original)", "angleDeg": "360 (total, equally spaced)"},+ "set_appearance": {"rgb": "[r, g, b] 0..255", "target": "part (default) | {bodyName} | {faceAtPointMm:[x,y,z]}"}, "set_material": {"name": "required", "database": "SOLIDWORKS Materials.sldmat"}, "set_prop": {"name": "required", "value": "required"}, "save_as": {"path": "required, absolute"},@@ -4682,6 +4690,18 @@ fn script_help(message: impl Into<String>) -> Value { "zoom_fit": {}, }), );+ map.insert(+ "conventions".into(),+ json!([+ "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.",+ "\"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."+ ]),+ ); } v }
tests/e2e.rs+11@@ -219,6 +219,17 @@ fn every_verb_over_http_against_the_real_binary() { let r = b.call("swx_bom", json!({})); check!(b, "bom rejects a part", r["success"] == false, s(&r)); + // ── the self-test (0.10.0): read level changes nothing ───────────────────+ let docs_before = b.call("swx_docs", json!({}))["count"].clone();+ let r = b.call("swx_selftest", json!({}));+ check!(+ b,+ "swx_selftest (read) reports one row per check, with the version facts, and changes nothing",+ r["success"] == true && r["level"] == "read" && r["results"].as_array().map(|a| a.len() >= 4).unwrap_or(false)+ && r["solidworks"]["versionSupport"].is_object() && b.call("swx_docs", json!({}))["count"] == docs_before,+ s(&r)+ );+ // ── analysis ──────────────────────────────────────────────────────────── let r = b.call("swx_mass_properties", json!({})); check!(
tests/issue1.rsadded+219@@ -0,0 +1,219 @@+//! ISSUE #1 on adom/solidworks-bridge (Michael, 2026-10-13, SOLIDWORKS 2021):+//! "19 boss extrudes and 3 cuts for a part that takes 4-5 features by hand".+//! The 0.10.0 DSL — offset/face sketches, cut direction, multi-profile+//! sketches, appearance, documented conventions, fillet/chamfer/shell/patterns+//! — and the brick he cares about, examples/lego-2x4.json, in 4 features.+//!+//! EVERYTHING HERE RUNS IN THE SIMULATOR. The geometry it asserts is the mock's+//! arithmetic model (src/comhost/mock_model.rs), NOT a SOLIDWORKS measurement:+//! the brick is UNMEASURED until it is run live.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++fn s(v: &Value) -> String {+ serde_json::to_string_pretty(v).unwrap_or_default()+}++fn boot(tag: &str) -> (Bridge, TempDir) {+ let machine = TempDir::new(tag);+ std::fs::write(machine.join("windows.json"), "[]").unwrap();+ let b = Bridge::start_with(&[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+ (b, machine)+}++fn lego() -> Value {+ let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/lego-2x4.json");+ serde_json::from_str(&std::fs::read_to_string(p).expect("examples/lego-2x4.json")).expect("it parses")+}++fn near(a: &Value, b: f64, tol: f64) -> bool {+ a.as_f64().map(|x| (x - b).abs() <= tol).unwrap_or(false)+}++#[test]+fn the_lego_brick_is_four_features_with_the_new_ops() {+ let (b, _m) = boot("issue1-lego");+ let brick = lego();+ let r = b.call("swx_run_script", json!({"script": brick["script"]}));+ check!(b, "the brick script runs end to end", r["success"] == true, s(&r));+ let steps = r["steps"].as_array().cloned().unwrap_or_default();+ let ops: Vec<&str> = steps.iter().map(|st| st["op"].as_str().unwrap_or("")).collect();+ 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"],+ format!("{ops:?}")+ );+ let features: Vec<String> = steps.iter().filter_map(|st| st["result"]["feature"].as_str().map(String::from)).collect();+ check!(+ b,+ "<= 6 features (Michael's build took 22), every one non-null",+ features.len() == 4 && features.len() <= 6 && features == ["Boss-Extrude1", "Cut-Extrude1", "Boss-Extrude2", "Boss-Extrude3"],+ format!("{features:?}")+ );+ check!(+ b,+ "the studs' sketch resolved onto a plane 9.6 mm above Top Plane, made by the script",+ steps[7]["result"]["resolvedPlane"]["offsetMm"].as_f64().map(|v| (v - 9.6).abs() < 1e-9).unwrap_or(false)+ && steps[7]["result"]["createdPlane"].is_string(),+ s(&steps[7])+ );+ check!(+ b,+ "the hollow is cut UPWARD from Top Plane (reverse:true): 0 .. 8.6 mm",+ near(&steps[4]["result"]["spanMm"][0], 0.0, 1e-9) && near(&steps[4]["result"]["spanMm"][1], 8.6, 1e-9),+ s(&steps[4])+ );+ let ib = b.call("swx_inspect_bodies", json!({}));+ let body = &ib["bodies"][0];+ check!(+ b,+ "ONE solid body, 31.8 x 11.3 x 15.8 mm, centred on the origin, on +Y (SIMULATED geometry)",+ ib["bodyCount"] == 1 && near(&body["sizeMm"]["x"], 31.8, 1e-6) && near(&body["sizeMm"]["y"], 11.3, 1e-6)+ && near(&body["sizeMm"]["z"], 15.8, 1e-6) && near(&body["bbox"]["minMm"][0], -15.9, 1e-6) && near(&body["bbox"]["minMm"][1], 0.0, 1e-6),+ s(body)+ );+ check!(+ b,+ "volume 2073.3615 mm³ by the script's arithmetic — the very volume SOLIDWORKS 2021 measured for Michael's 22-feature brick (2073.3615208) — simulated here, not measured",+ near(&body["volumeMm3"], 2073.3615, 0.001),+ s(body)+ );+ check!(+ b,+ "red, read back; ABS density applied to the mass",+ body["appearanceRgb"] == json!([200, 0, 0]) && body["densityKgM3"] == 1020.0+ && near(&body["massKg"], 2073.3615e-9 * 1020.0, 1e-8),+ s(body)+ );+ check!(b, "the example says it is UNMEASURED", brick["expected"]["status"].as_str().unwrap_or("").starts_with("UNMEASURED"), s(&brick["expected"]));+}++#[test]+fn a_cut_or_extrude_that_builds_nothing_is_an_error_never_a_silent_success() {+ let (b, _m) = boot("issue1-null");+ // A cut with no body at all: SOLIDWORKS has nothing to cut.+ let r = b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10},+ {"op": "cut", "depthMm": 5}+ ]}));+ check!(+ b,+ "a cut that returns no feature fails the step, by number, saying nothing was cut",+ r["success"] == false && r["error"].as_str().unwrap_or("").contains("step 3 (cut)"),+ s(&r)+ );+ // The measured case: SOLIDWORKS returns feature:null (simulated).+ std::fs::write(b.scratch.join("mock-feature-null-cut"), "").unwrap();+ let r = b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},+ {"op": "extrude", "depthMm": 5},+ {"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 2},+ {"op": "cut", "throughAll": true}+ ]}));+ check!(+ b,+ "feature:null is an ERROR naming the step and the direction rule — never success:true with nothing removed",+ r["success"] == false && r["error"].as_str().unwrap_or("").contains("step 5 (cut)")+ && r["error"].as_str().unwrap_or("").contains("normal"),+ s(&r)+ );+}++#[test]+fn faces_offsets_both_ways_and_rings_build_what_they_say() {+ let (b, _m) = boot("issue1-ops");+ let r = b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},+ {"op": "extrude", "depthMm": 10},+ // a boss on the TOP FACE, found by a point on it+ {"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 3},+ {"op": "extrude", "depthMm": 2},+ // a ring, both directions from Front Plane+ {"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": "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]]}+ ]}));+ check!(b, "every new op runs", r["success"] == true, s(&r));+ let st = r["steps"].as_array().cloned().unwrap_or_default();+ check!(+ b,+ "the face resolved to the plane 10 mm above Top Plane",+ st[3]["result"]["resolvedPlane"]["offsetFrom"] == "Top Plane" && near(&st[3]["result"]["resolvedPlane"]["offsetMm"], 10.0, 1e-9),+ s(&st[3])+ );+ check!(+ b,+ "both directions: -15 .. +15 mm about Front Plane, and the two circles are a RING (area pi(4²-2²))",+ near(&st[6]["result"]["spanMm"][0], -15.0, 1e-9) && near(&st[6]["result"]["spanMm"][1], 15.0, 1e-9)+ && near(&st[5]["result"]["profileAreaMm2"], std::f64::consts::PI * 12.0, 1e-6),+ format!("{}\n{}", s(&st[5]), s(&st[6]))+ );+ check!(+ b,+ "fillet, chamfer, both patterns and the shell each report a feature",+ ["Fillet1", "Chamfer1", "LPattern1", "CirPattern1", "Shell1"].iter().enumerate().all(|(i, f)| st[7 + i]["result"]["feature"] == *f),+ s(&r["steps"])+ );+ check!(b, "the fillet took the edges of the feature before it", st[7]["result"]["edges"]["allOfFeature"] == "Boss-Extrude3", s(&st[7]));+ // A face point that is on no face is refused, by step.+ let r = b.call("swx_run_script", json!({"targetDocument": r["documentName"], "script": [+ {"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));+}++#[test]+fn the_script_help_teaches_the_new_grammar_and_the_conventions() {+ let (b, _m) = boot("issue1-help");+ let r = b.call("swx_run_script", json!({"script": [{"op": "fly"}]}));+ let conv = r["conventions"].to_string();+ check!(+ b,+ "a rejected script returns every op, its args, and the coordinate conventions (corner anchor, Top Plane -Z, cut along -normal)",+ r["errorCode"] == "bad_script"+ && ["sketch", "fillet", "chamfer", "shell", "linear_pattern", "circular_pattern", "set_appearance"].iter().all(|o| r["opArgs"].get(*o).is_some())+ && conv.contains("CORNER") && conv.contains("−Z") && conv.contains("−NORMAL"),+ s(&r)+ );+}++#[test]+fn selftest_reports_every_check_and_cleans_up_after_itself() {+ let (b, _m) = boot("issue1-selftest");+ std::fs::write(b.scratch.join("mock-revision"), "29.5.1").unwrap();+ b.call("swx_start", json!({}));+ let r = b.call("swx_selftest", json!({}));+ check!(+ b,+ "read level: version facts (29 = SOLIDWORKS 2021: supported, not tested), rows, nothing failed",+ r["success"] == true && r["level"] == "read" && r["solidworks"]["versionSupport"]["year"] == 2021+ && r["solidworks"]["versionSupport"]["supported"] == true && r["solidworks"]["versionSupport"]["tested"] == false+ && r["summary"]["failed"] == 0 && r["results"].as_array().map(|a| a.len() >= 4).unwrap_or(false),+ s(&r)+ );+ let before = b.call("swx_docs", json!({}))["count"].clone();+ let r = b.call("swx_selftest", json!({"level": "write"}));+ let failed: Vec<Value> = r["results"].as_array().cloned().unwrap_or_default().into_iter().filter(|x| x["pass"] == false).collect();+ check!(+ b,+ "write level: every modelling op and read-back passes in the simulator, one row each",+ r["success"] == true && failed.is_empty() && r["summary"]["passed"].as_u64().unwrap_or(0) >= 20,+ format!("{}\nFAILED: {}", s(&r["summary"]), s(&Value::Array(failed)))+ );+ let after = b.call("swx_docs", json!({}))["count"].clone();+ check!(b, "the scratch part was CLOSED: the open-document count is unchanged", before == after, format!("{before} vs {after}"));+ let bad = b.call("swx_selftest", json!({"level": "everything"}));+ check!(b, "an unknown level is refused", bad["errorCode"] == "bad_level", s(&bad));+}
tests/readiness.rs+1@@ -375,6 +375,7 @@ const EXEMPT: &[&str] = &[ "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files", "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs", "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence", "swx_sign_in",+ "swx_selftest", ]; #[test]
tests/wiring.rs+1@@ -1069,6 +1069,7 @@ fn the_startup_exemption_list_is_pinned_name_by_name() { "swx_action_ledger", "swx_verb_times", "swx_uninstall", "swx_find_files", "swx_recover_list", "swx_window_info", "swx_dismiss_blocking_dialogs", "swx_close_window", "swx_screenshot", "swx_set_driving_badge", "swx_kill", "swx_user_presence", "swx_sign_in",+ "swx_selftest", ]; pinned.sort(); assert_eq!(
uninstall.sh