app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.11.0 source
44 files changed
+6137−177
.gitignore+4BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+48−2SKILL.md+6bridge.json+1−1docs/COMPATIBILITY.md+37−4install.shpackage.json+1−1references/modelling-and-export.md+36resources/cswa/cswa-00-lego-2x4.json+200resources/cswa/cswa-01-3dengr-q5-block.json+46resources/cswa/cswa-02-3dengr-q6-hole.json+55resources/cswa/cswa-03-3dengr-q8-hole30.json+50resources/cswa/cswa-04-3dengr-q9-l-bracket.json+74resources/cswa/cswa-05-tool-block-step1.json+311resources/cswa/cswa-06-tool-block-step2.json+15resources/cswa/cswa-07-tool-block-step3.json+131resources/cswa/cswa-08-tool-block-step4.json+14src/comhost/com.rs+534−30src/comhost/mock.rs+40−14src/comhost/mock_csg.rs+622src/comhost/mock_model.rs+1011−70src/comhost/mod.rs+1src/comhost/variant.rs+6−1src/compat.rs+91−4src/cswa.rs+507src/lib.rs+1src/modeling.rs+123−2src/script.rs+1233−35src/selftest.rs+99−4src/verbs.rs+2−2src/verbs3.rs+9−2tests/cswa.rs+162tests/wiring.rs+43−1tools/check-live-report.py+45tools/cswa-check.py+323tools/cswa/README.md+118tools/cswa/tb2.py+49tools/cswa/tb3b.py+24tools/cswa/tb4c.py+42tools/release.sh+20−1uninstall.sh.gitignore+4@@ -6,3 +6,7 @@ C:* /dist/ __pycache__/++# Live-run reports (tools/dsl-live-check.py, tools/cswa-check.py)+dsl-live-report.json+cswa-report.json
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.10.10+0.11.0
Cargo.lock+1−1@@ -122,7 +122,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.10.10"+version = "0.11.0" dependencies = [ "miniz_oxide", "serde",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.10.10"+version = "0.11.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+48−2@@ -14,7 +14,7 @@ about ten milliseconds. | 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` (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). Measured live on SOLIDWORKS 2026 (30 of 30 checks): [`docs/LIVE-RESULTS.md`](docs/LIVE-RESULTS.md). |+| **Model** | `swx_run_script` — a typed modelling DSL: `new_part`, `sketch` (rectangles, circles, lines, polylines, arcs — centre/angles, three-point, tangent — slots, polygons, sketch fillets and chamfers, construction geometry; several closed profiles in ONE sketch, contours checked closed before anything is drawn), `sketch_rect`, `sketch_circle` (on a named plane, an offset plane, a face, or a plane offset from a face), `extrude` / `cut` (reverse, both directions, mid-plane, through all, up to next, up to a face, offset from a face, draft), `revolve` / `revolve_cut`, `mirror` (features or bodies), `hole` (plain, counterbore, countersink), `fillet`, `chamfer`, `shell`, `linear_pattern`, `circular_pattern`, `set_appearance`, `set_material` (with a density), `set_prop`, `save_as`, `rebuild`, `zoom_fit`. Everything in millimetres; a worked LEGO brick is in [`examples/lego-2x4.json`](examples/lego-2x4.json), and CSWA exam parts with their published answers in [`resources/cswa/`](resources/cswa/). Measured live on SOLIDWORKS 2026 (30 of 30 checks) and 2021 (a community self-test): [`docs/LIVE-RESULTS.md`](docs/LIVE-RESULTS.md); the 0.11.0 ops are not yet measured live. | | **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). |@@ -316,6 +316,14 @@ python3 tools/swx-batch.py calls.json # many verbs in ONE rou python3 tools/live-smoke.py # the full live suite ``` +**Every release is also checked against CSWA exam parts** (0.11.0): `resources/cswa/` holds+exam-style parts with their PUBLISHED answers (the official Dassault sample exam's Tool Block,+steps 1-4; the 3dengr practice set; the LEGO brick as spec 0). `python3 tools/cswa-check.py`+builds each one live, weighs it with `swx_mass_properties`, and compares it with the key within+the exam's 1 %; `swx_selftest {"level":"cswa"}` does the same on any install. Then+`tools/release.sh --require-live cswa-report.json --publish` refuses to publish without a passing+report from THIS version, newer than the last code commit. See [`tools/cswa/README.md`](tools/cswa/README.md).+ `live-smoke.py` creates its own scratch part, builds a real 50×30×20 mm solid, reads the geometry back, drives a dimension, exports eight formats, checks the bytes on disk, and then asserts SOLIDWORKS is left with **exactly** the@@ -498,7 +506,45 @@ not. ## Roadmap -- v0.10.10 (this): **SOLIDWORKS 2021, tested — and the one bug it found.**+- v0.11.0 (this): **CSWA phase 1 — exam parts as release validation.** Drew:+ "pull 3-5 random parts from CSWA exams as part of our e2e testing/validation+ for each release … We need this bridge to be a real, useable tool, for more+ than the basics." Every new op is built and simulated, and NOT YET MEASURED+ LIVE (`proven.live:false` until the coordinator's run).+ - **Sketch geometry:** `line`, `centerline`, `polyline`, `arc` (centre and+ angles), `arc3`, `tangentArc`, `slot`, `polygon`, `rect.cornerRadiusMm`,+ `sketchFillet`, `sketchChamfer`, and `construction:true` on any entity.+ Composites expand into lines and arcs (`CreateLine` / `CreateArc` /+ `CreateCenterLine`). Contours are checked CLOSED before anything is drawn,+ and an open end names its gap. Several closed contours are several regions.+ - **Features:** `revolve` / `revolve_cut` (`FeatureRevolve2`; the axis is a+ line of the sketch, two points, or the sketch's x / y; the profile must lie+ on one side). `mirror` of features or bodies about a plane, an offset plane,+ a face, or a plane offset from a face (`InsertMirrorFeature2`; marks 1, 2 and+ 256; `swFeatureScope_e` read from the interop). `hole`: plain, counterbore,+ or countersink (a cut drafted at half its angle), as cuts on the face's+ plane. End conditions `upToNext` (11), `upToSurface` (UpToSelection 10, the+ face at mark 1), `offsetFromSurface` (5), and `draftDeg`. A plane offset from+ a face. `set_material {densityKgM3}`.+ - **`swx_mass_properties`** adds `massG`, `volumeMm3`, `surfaceAreaMm2`,+ `centerOfMassMm`, `densityKgM3` and `materialName`.+ - **The benchmark** is in `resources/cswa/` (in the zip): the LEGO brick+ (spec 0); 3dengr Q5, Q6, Q8 and Q9, with the suite's first centre-of-mass+ keys; and the official sample exam's Tool Block. Steps 1-3 run with spec+ variables and `extends`. Step 4 is skipped: it needs `offsetEntities`.+ - `tools/cswa-check.py` runs it live.+ - `swx_selftest {"level":"cswa"}` runs it anywhere and reports "N of M CSWA+ parts within tolerance".+ - `tools/release.sh --require-live <report>` refuses to publish without a+ passing report from this version, newer than the last code commit.+ - **The simulator integrates the solid by columns** (`mock_csg`): every+ feature is a primitive, added or cut in order. Each column is exact along+ its axis, and Gauss-Legendre runs between profile breakpoints across it. So+ a partial cut, like the Tool Block's R41 slot, weighs right. Every runnable+ spec lands within 0.01 % of its key in the simulator.++ 677 tests.+- v0.10.10: **SOLIDWORKS 2021, tested — and the one bug it found.** Michael ran 0.10.9's `swx_selftest {"level":"write"}` on SOLIDWORKS 2021 (COM 29.5.1, Windows 11): 44 passed, 6 failed. Every modelling check passed, and the LEGO brick came out identical to 2026 (4 features, 2073.362 mm³, 39
SKILL.md+6@@ -125,6 +125,12 @@ BEFORE you do it: 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`.+ 0.11.0 adds exam-grade ops — lines, arcs, polylines, slots, polygons, sketch+ fillets/chamfers, revolve, mirror, hole, up-to end conditions, draft, a+ density — NOT YET MEASURED LIVE (`proven.live:false`); read+ `references/modelling-and-export.md` before using them, and check the result+ with `swx_mass_properties`. `swx_selftest {"level":"cswa"}` runs the CSWA+ exam parts against their published answers. SOLIDWORKS 2021 and later are supported; 2026 is tested by us and 2021 by a community run (identical LEGO numbers). On any install, `swx_selftest {"level":"write"}` returns one JSON of what works — call it
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.10.10",+ "version": "0.11.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",
docs/COMPATIBILITY.md+37−4@@ -98,9 +98,13 @@ available in SOLIDWORKS 2021. | `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) |+| `ISketchSegment::ConstructionGeometry` | not verified (0.11.0) | not verified | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchsegment~constructiongeometry.html) |+| `ISketchManager::CreateArc` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~createarc.html) |+| `ISketchManager::CreateCenterLine` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~createcenterline.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) |+| `ISketchManager::CreateLine` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchmanager~createline.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) |@@ -123,6 +127,7 @@ available in SOLIDWORKS 2021. | `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) |+| `IFeatureManager::FeatureRevolve2` | 2011 FCS, Revision Number 19.0 | yes | FeatureRevolveCut2 is obsolete → FeatureRevolve2 with IsCut | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~featurerevolve2.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) |@@ -140,9 +145,9 @@ available in SOLIDWORKS 2021. | `IEdge::GetCurve` | — (no Availability line: an original-API member) | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iedge~getcurve.html) | | `IEdge::GetCurveParams2` | 2000 FCS, Revision Number 8.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iedge~getcurveparams2.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) |+| `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) | | `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) |@@ -158,6 +163,7 @@ available in SOLIDWORKS 2021. | `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) |+| `ISketchSegment::GetName` | not verified (0.11.0) | not verified | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchsegment~getname.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) |@@ -176,6 +182,7 @@ available in SOLIDWORKS 2021. | `IEdge::GetTwoAdjacentFaces2` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.iedge~gettwoadjacentfaces2.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::GetUserPreferenceDouble` | not verified (0.11.0) | not verified | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~getuserpreferencedouble.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) |@@ -187,6 +194,8 @@ available in SOLIDWORKS 2021. | `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::InsertMirrorFeature` | — (obsolete; used only when swFeatureScope_e cannot be read from the interop) | yes | obsolete → InsertMirrorFeature2 | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~insertmirrorfeature.html) |+| `IFeatureManager::InsertMirrorFeature2` | 2008 FCS, Revision Number 16.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeaturemanager~insertmirrorfeature2.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) |@@ -217,13 +226,16 @@ available in SOLIDWORKS 2021. | `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) | | `IFeature::Select2` | 2001Plus FCS, Revision Number 10.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ifeature~select2.html) |+| `IBody2::Select2` | not verified (0.11.0): a body selected for a mirror (mark 256) | not verified | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.ibody2~select2.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) |+| `ISketchSegment::Select4` | not verified (0.11.0): the revolve axis's fallback selection | not verified | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.isketchsegment~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) | | `IModelDocExtension::SelectByRay` | 2017 FCS, Revision Number 25.0 | yes | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~selectbyray.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::SetUserPreferenceDouble` | not verified (0.11.0): swMaterialPropertyDensity, read from the interop | not verified | | [help](https://help.solidworks.com/2024/english/api/sldworksapi/solidworks.interop.sldworks~solidworks.interop.sldworks.imodeldocextension~setuserpreferencedouble.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) |@@ -235,10 +247,10 @@ available in SOLIDWORKS 2021. | `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) |+| `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) | | `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) |@@ -249,7 +261,10 @@ available in SOLIDWORKS 2021. 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.+- `swEndConditions_e`: Blind 0, ThroughAll 1, OffsetFromSurface 5, MidPlane 6, ThroughAllBoth 9, UpToSelection 10, UpToNext 11 (0.11.0; UpToSurface 4 is the obsolete id that UpToSelection replaces — the face goes at mark 1). Revolves use Blind 0 and MidPlane 6 as Dir1Type.+- `ISketchManager::CreateArc` Direction: +1 counter-clockwise, −1 clockwise (a `short`).+- `InsertMirrorFeature2` marks: features 1, plane or planar face 2, bodies 256. `FeatureRevolve2` marks: sketch 0, axis 4 (the bridge tries 16 when 4 builds nothing).+- `swFeatureScope_e` and `swUserPreferenceDoubleValue_e.swMaterialPropertyDensity` are NOT on the help pages with numbers: they are read from the installed `SolidWorks.Interop.swconst.dll` by reflection, never guessed. - `swRefPlaneReferenceConstraints_e`: Distance 8, OptionFlip 256. - `swFeatureFilletOptions_e`: Propagate 1, UniformRadius 2. - `swFeatureFilletType_e`: Simple 0.@@ -266,6 +281,7 @@ 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+swx_selftest {"level": "cswa"} # builds the CSWA benchmark parts and checks their mass against the published keys ``` - **`read`** runs these and changes nothing:@@ -321,6 +337,23 @@ from the gate, so a check held at the gate is reported, not hidden. `level:"write"` with SOLIDWORKS closed is refused up front (`solidworks_not_running`) and changes nothing. +## Live validation: the CSWA benchmark (0.11.0)++Every release can be checked against EXAM PARTS with published answers, not only against the+bridge's own closed-form checks. The specs live in `resources/cswa/` (shipped in the zip); the+schema and rules are in [`tools/cswa/README.md`](../tools/cswa/README.md).++- `swx_selftest {"level":"cswa"}` on any install: one row per part, and the summary+ `"N of M CSWA parts within tolerance"`. On a release we have not run, this is the most direct+ evidence that a modelled part weighs what SOLIDWORKS says it should.+- `tools/cswa-check.py` from a dev container writes `cswa-report.json`.+- `tools/release.sh --require-live <report.json>` refuses to publish without a report from THIS+ version that passed every part and is newer than the last code commit.++The 0.11.0 ops the specs use (lines and arcs, revolve, mirror, hole, the up-to end conditions,+draft, a density) are NOT YET MEASURED on any release: every step that uses them reports+`proven.live:false` until a live run passes.+ ## What is still unmeasured on 2021 - The classic launch route (`CoCreateInstance`) and the `Setup\SolidWorks
install.shpackage.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.10.10",+ "version": "0.11.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": [
references/modelling-and-export.md+36@@ -126,6 +126,42 @@ adom-bridge --ai-thread "<thread>" swx_save_as '{"outputPath":"C:\\parts\\bracke 4. eight studs on a plane offset 9.6 mm. Run it as `swx_run_script {"script": <its "script">}`.+- **Exam-grade ops (0.11.0, NOT YET MEASURED LIVE — every step says+ `proven.live:false`).** Built for the CSWA benchmark:+ - **Sketch geometry** in the plane's 2D frame, like `rect` and `circle`:+ `line {from, to}`, `centerline`, `polyline {points, close}`,+ `arc {center, radiusMm, startDeg, endDeg, ccw}`, `arc3 {start, mid, end}`,+ `tangentArc {to, radiusMm?}` (continues the entity before it),+ `slot {center, lengthMm, widthMm, angleDeg}`,+ `polygon {center, sides, circumradiusMm | inscribedRadiusMm, angleDeg}`,+ `rect.cornerRadiusMm`, `sketchFillet {at, radiusMm}` and+ `sketchChamfer {at, distanceMm}` (the corner where two lines meet), and+ `construction:true` on any entity. Lines and arcs must form CLOSED contours:+ an open end is refused with the gap named, before anything is drawn.+ Composites are expanded into lines and arcs (`ISketchManager::CreateLine` /+ `CreateArc` / `CreateCenterLine`), so the simulator measures exactly what is+ drawn.+ - **`revolve` / `revolve_cut`** `{axis: "x"|"y" | {entity: i} | {line: [[x,y],[x,y]]}, angleDeg: 360, midPlane, reverse}` —+ the profile must lie on one side of the axis (`FeatureRevolve2`).+ - **`mirror`** `{plane | face, features: "last" | name | [names] | "all", mirrorBody}` (`InsertMirrorFeature2`).+ - **`hole`** `{atMm, diameterMm, depthMm | throughAll, counterbore {diameterMm, depthMm} | countersink {diameterMm, angleDeg}}` —+ cuts of circles on the face's plane (no Hole Wizard); flat-bottomed, depth from the face.+ - **End conditions** on extrude and cut: `upToNext`, `upToSurface {face}`,+ `offsetFromSurface {face, distanceMm, reverseOffset}`, and `draftDeg` (inward;+ `draftOutward:true`).+ - **A plane offset from a face**: `plane: {offsetFrom: {face: {atPointMm}}, offsetMm}`,+ along the face's OUTWARD normal (negative = into the part). The face's outward+ normal stays the reference, so a cut from it goes on inward.+ - **`set_material {densityKgM3}`**: with a name, the material's density must be+ that (`material_density_mismatch`); alone, the part's own density (refused while+ a library material is applied). The step reports the density READ BACK.+ - `swx_mass_properties` now also reports `massG`, `volumeMm3`, `surfaceAreaMm2`,+ `centerOfMassMm` (the document's frame), `densityKgM3` and `materialName`.+- **The CSWA benchmark:** `swx_selftest {"level":"cswa"}` builds the exam parts+ shipped in `resources/cswa/` (the official sample exam's Tool Block, the 3dengr+ practice set, the LEGO brick), weighs each, and compares it with the PUBLISHED key+ within the exam's 1 %: `"N of M CSWA parts within tolerance"`. Call it with+ `responseMode:"stream"` or `"job"`. - **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
resources/cswa/cswa-00-lego-2x4.jsonadded+200@@ -0,0 +1,200 @@+{+ "id": "cswa-00-lego-2x4",+ "title": "LEGO 2x4 brick (spec 0: the acceptance brick, not an exam part)",+ "source": "adom/solidworks-bridge issue #1 (Michael, SOLIDWORKS 2021: volumeM3 2.0733615208e-6 from swx_mass_properties); examples/lego-2x4.json",+ "official": false,+ "units": "MMGS",+ "material": {+ "name": "ABS",+ "densityKgM3": 1020+ },+ "expected": {+ "massG": 2.114829,+ "volumeMm3": 2073.3615208,+ "tolerancePct": 1.0+ },+ "keyBasis": "Michael's measured volume x ABS 1020 kg/m^3 - the volume is a SOLIDWORKS 2021 measurement; the mass follows from it",+ "needsOps": [],+ "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_material",+ "name": "ABS",+ "densityKgM3": 1020+ }+ ]+}
resources/cswa/cswa-01-3dengr-q5-block.jsonadded+46@@ -0,0 +1,46 @@+{+ "id": "cswa-01-3dengr-q5-block",+ "title": "3dengr Q5: a 100 x 20 x 60 block in 6061",+ "source": "https://www.3dengr.com/cswa-practice-questions.html",+ "official": false,+ "units": "MMGS",+ "origin": "a corner of the block at the origin, the block on +X +Y +Z (Front Plane sketch, extruded +Z)",+ "material": {+ "name": "6061 Alloy",+ "densityKgM3": 2700+ },+ "expected": {+ "massG": 324.0,+ "volumeMm3": 120000.0,+ "centerOfMassMm": [+ 50.0,+ 10.0,+ 30.0+ ],+ "tolerancePct": 1.0+ },+ "keyBasis": "3dengr's worked answer (324.00 g); volume and centre of mass by arithmetic on the stated dimensions",+ "needsOps": [],+ "script": [+ {+ "op": "new_part"+ },+ {+ "op": "sketch_rect",+ "plane": "Front Plane",+ "xMm": 0,+ "yMm": 0,+ "widthMm": 100,+ "heightMm": 20+ },+ {+ "op": "extrude",+ "depthMm": 60+ },+ {+ "op": "set_material",+ "name": "6061 Alloy",+ "densityKgM3": 2700+ }+ ]+}
resources/cswa/cswa-02-3dengr-q6-hole.jsonadded+55@@ -0,0 +1,55 @@+{+ "id": "cswa-02-3dengr-q6-hole",+ "title": "3dengr Q6/Q7: the block with a \u00d820 hole through it along Y at X 25, Z 30",+ "source": "https://www.3dengr.com/cswa-practice-questions.html",+ "official": false,+ "units": "MMGS",+ "origin": "a corner of the block at the origin, the block on +X +Y +Z (Front Plane sketch, extruded +Z)",+ "material": {+ "name": "6061 Alloy",+ "densityKgM3": 2700+ },+ "expected": {+ "massG": 307.04,+ "centerOfMassMm": [+ 51.38,+ 10.0,+ 30.0+ ],+ "tolerancePct": 1.0+ },+ "keyBasis": "3dengr's worked answers: Q6 mass 307.04 g, Q7 centre of mass (51.38, 10.00, 30.00) - the suite's first centre-of-mass key",+ "needsOps": [],+ "script": [+ {+ "op": "new_part"+ },+ {+ "op": "sketch_rect",+ "plane": "Front Plane",+ "xMm": 0,+ "yMm": 0,+ "widthMm": 100,+ "heightMm": 20+ },+ {+ "op": "extrude",+ "depthMm": 60+ },+ {+ "op": "hole",+ "atMm": [+ 25,+ 20,+ 30+ ],+ "diameterMm": 20,+ "throughAll": true+ },+ {+ "op": "set_material",+ "name": "6061 Alloy",+ "densityKgM3": 2700+ }+ ]+}
resources/cswa/cswa-03-3dengr-q8-hole30.jsonadded+50@@ -0,0 +1,50 @@+{+ "id": "cswa-03-3dengr-q8-hole30",+ "title": "3dengr Q8: the hole changed to \u00d830",+ "source": "https://www.3dengr.com/cswa-practice-questions.html",+ "official": false,+ "units": "MMGS",+ "origin": "a corner of the block at the origin, the block on +X +Y +Z (Front Plane sketch, extruded +Z)",+ "material": {+ "name": "6061 Alloy",+ "densityKgM3": 2700+ },+ "expected": {+ "massG": 285.83,+ "tolerancePct": 1.0+ },+ "keyBasis": "3dengr's worked answer (285.83 g)",+ "needsOps": [],+ "script": [+ {+ "op": "new_part"+ },+ {+ "op": "sketch_rect",+ "plane": "Front Plane",+ "xMm": 0,+ "yMm": 0,+ "widthMm": 100,+ "heightMm": 20+ },+ {+ "op": "extrude",+ "depthMm": 60+ },+ {+ "op": "hole",+ "atMm": [+ 25,+ 20,+ 30+ ],+ "diameterMm": 30,+ "throughAll": true+ },+ {+ "op": "set_material",+ "name": "6061 Alloy",+ "densityKgM3": 2700+ }+ ]+}
resources/cswa/cswa-04-3dengr-q9-l-bracket.jsonadded+74@@ -0,0 +1,74 @@+{+ "id": "cswa-04-3dengr-q9-l-bracket",+ "title": "3dengr Q9: an L-bracket in AISI 1020, ONE polyline sketch",+ "source": "https://www.3dengr.com/cswa-practice-questions.html",+ "official": false,+ "units": "MMGS",+ "origin": "the base's corner at the origin: base 80 (X) x 10 (Y) x 40 (Z), the upright 10 thick at X 0..10 rising to Y 60",+ "material": {+ "name": "AISI 1020",+ "densityKgM3": 7900+ },+ "expected": {+ "massG": 410.8,+ "centerOfMassMm": [+ 26.54,+ 16.54,+ 20.0+ ],+ "tolerancePct": 1.0+ },+ "keyBasis": "3dengr's worked answers (410.80 g; centre of mass (26.54, 16.54, 20.00))",+ "needsOps": [],+ "script": [+ {+ "op": "new_part"+ },+ {+ "op": "sketch",+ "plane": "Front Plane",+ "entities": [+ {+ "polyline": {+ "points": [+ [+ 0,+ 0+ ],+ [+ 80,+ 0+ ],+ [+ 80,+ 10+ ],+ [+ 10,+ 10+ ],+ [+ 10,+ 60+ ],+ [+ 0,+ 60+ ]+ ],+ "close": true+ }+ }+ ]+ },+ {+ "op": "extrude",+ "depthMm": 40+ },+ {+ "op": "set_material",+ "name": "AISI 1020",+ "densityKgM3": 7900+ }+ ]+}
resources/cswa/cswa-05-tool-block-step1.jsonadded+311@@ -0,0 +1,311 @@+{+ "id": "cswa-05-tool-block-step1",+ "title": "Official Tool Block, Step 1 (Q3): A 81, B 57, C 43",+ "source": "https://www.solidworks.com/sites/default/files/2017-12/CSWASampleExam.pdf (Dassault CSWA Sample Exam, 2017 edition; archive http://web.archive.org/web/20251230104817/https://www.solidworks.com/sites/default/files/2017-12/CSWASampleExam.pdf)",+ "official": true,+ "units": "MMGS",+ "origin": "the \u00d814 hole's centre; x right, y up, the profile on Front Plane extruded +Z (the exam says 'Part origin: Arbitrary' - mass only)",+ "material": {+ "name": "AISI 1020",+ "densityKgM3": 7900+ },+ "recipe": "tools/cswa/SOURCES.md, Part 1: the R19 arc is tangent to the 10\u00b0 line that starts on the hole's centreline at (0, 18) and ends at the right lobe's corner P = (A-43, B-33); the flank runs (A-14, B-57) to (A-43, -33); R29 is centred where the flank (extended) meets y = -14; R5 is tangent to R29 and to y = -14; 45\u00b0 chamfers 7 x 7 (left corners) and 5 x 5 (right lobe).",+ "variables": {+ "A": 81,+ "B": 57,+ "C": 43+ },+ "derived": [+ [+ "xR",+ "A-14"+ ],+ [+ "xRL",+ "A-43"+ ],+ [+ "yRT",+ "B-33"+ ],+ [+ "yRE",+ "B-57"+ ],+ [+ "yB",+ "-14"+ ],+ [+ "yV",+ "-33"+ ],+ [+ "th",+ "rad(10)"+ ],+ [+ "dx",+ "cos(th)"+ ],+ [+ "dy",+ "-sin(th)"+ ],+ [+ "nx",+ "sin(th)"+ ],+ [+ "ny",+ "cos(th)"+ ],+ [+ "ox",+ "19*nx - xRL"+ ],+ [+ "oy",+ "18 + 19*ny - yRT"+ ],+ [+ "bq",+ "2*(ox*dx + oy*dy)"+ ],+ [+ "cq",+ "ox^2 + oy^2 - 361"+ ],+ [+ "t",+ "(-bq - sqrt(bq^2 - 4*cq))/2"+ ],+ [+ "Tx",+ "t*dx"+ ],+ [+ "Ty",+ "18 + t*dy"+ ],+ [+ "fs",+ "(yB - yV)/(yRE - yV)"+ ],+ [+ "c29x",+ "xRL + fs*(xR - xRL)"+ ],+ [+ "c29y",+ "yB"+ ],+ [+ "xv",+ "c29x - sqrt(29^2 - (c29y - yV)^2)"+ ],+ [+ "c5y",+ "yB - 5"+ ],+ [+ "c5x",+ "c29x - sqrt(34^2 - 25)"+ ],+ [+ "tpx",+ "c5x + 5*(c29x - c5x)/34"+ ],+ [+ "tpy",+ "c5y + 5*(c29y - c5y)/34"+ ],+ [+ "a29",+ "(atan2(yV - c29y, xv - c29x) + atan2(tpy - c29y, tpx - c29x))/2"+ ],+ [+ "m29x",+ "c29x + 29*cos(a29)"+ ],+ [+ "m29y",+ "c29y + 29*sin(a29)"+ ],+ [+ "a5",+ "(atan2(tpy - c5y, tpx - c5x) + pi/2)/2"+ ],+ [+ "m5x",+ "c5x + 5*cos(a5)"+ ],+ [+ "m5y",+ "c5y + 5*sin(a5)"+ ]+ ],+ "expected": {+ "massG": 939.54,+ "tolerancePct": 1.0+ },+ "keyBasis": "the official key, option d (939.54 g); the recipe's profile area is 2765.79 mm\u00b2 (the reviewer's rebuild: 0.00 %)",+ "needsOps": [],+ "script": [+ {+ "op": "new_part"+ },+ {+ "op": "sketch",+ "plane": "Front Plane",+ "entities": [+ {+ "line": {+ "from": [+ 0,+ 18+ ],+ "to": [+ "=Tx",+ "=Ty"+ ]+ }+ },+ {+ "tangentArc": {+ "to": [+ "=xRL",+ "=yRT"+ ],+ "radiusMm": 19+ }+ },+ {+ "polyline": {+ "points": [+ [+ "=xRL",+ "=yRT"+ ],+ [+ "=xR",+ "=yRT"+ ],+ [+ "=xR",+ "=yRE"+ ],+ [+ "=xRL",+ "=yV"+ ],+ [+ "=xv",+ "=yV"+ ]+ ]+ }+ },+ {+ "arc3": {+ "start": [+ "=xv",+ "=yV"+ ],+ "mid": [+ "=m29x",+ "=m29y"+ ],+ "end": [+ "=tpx",+ "=tpy"+ ]+ }+ },+ {+ "arc3": {+ "start": [+ "=tpx",+ "=tpy"+ ],+ "mid": [+ "=m5x",+ "=m5y"+ ],+ "end": [+ "=c5x",+ -14+ ]+ }+ },+ {+ "polyline": {+ "points": [+ [+ "=c5x",+ -14+ ],+ [+ -14,+ -14+ ],+ [+ -14,+ 18+ ],+ [+ 0,+ 18+ ]+ ]+ }+ },+ {+ "sketchChamfer": {+ "at": [+ -14,+ 18+ ],+ "distanceMm": 7+ }+ },+ {+ "sketchChamfer": {+ "at": [+ -14,+ -14+ ],+ "distanceMm": 7+ }+ },+ {+ "sketchChamfer": {+ "at": [+ "=xR",+ "=yRT"+ ],+ "distanceMm": 5+ }+ },+ {+ "circle": {+ "centerMm": [+ 0,+ 0+ ],+ "diameterMm": 14+ }+ }+ ]+ },+ {+ "op": "extrude",+ "depthMm": "=C"+ },+ {+ "op": "set_material",+ "name": "AISI 1020",+ "densityKgM3": 7900+ }+ ]+}
resources/cswa/cswa-06-tool-block-step2.jsonadded+15@@ -0,0 +1,15 @@+{+ "id": "cswa-06-tool-block-step2",+ "extends": "cswa-05-tool-block-step1",+ "title": "Official Tool Block, Step 2 (Q4): A 84, B 59, C 45, all other dimensions the same",+ "variables": {+ "A": 84,+ "B": 59,+ "C": 45+ },+ "expected": {+ "massG": 1032.32,+ "tolerancePct": 1.0+ },+ "keyBasis": "the official key (1032.32 g). What floats with B (SOURCES.md, Part 2): the right lobe's top at B-33 and its corner at B-57; the reviewer's rebuild 0.00 %"+}
resources/cswa/cswa-07-tool-block-step3.jsonadded+131@@ -0,0 +1,131 @@+{+ "id": "cswa-07-tool-block-step3",+ "extends": "cswa-05-tool-block-step1",+ "title": "Official Tool Block, Step 3 (Q5): A 86, B 58, C 44, then remove material",+ "variables": {+ "A": 86,+ "B": 58,+ "C": 44+ },+ "expected": {+ "massG": 628.18,+ "tolerancePct": 1.0+ },+ "keyBasis": "the official key (628.18 g). The R41 slot's centre is not dimensioned: on the right edge's extension, through the flank point 36 below the top (SOURCES.md, Part 3; reproduces the key to 0.000 %)",+ "derivedAppend": [+ [+ "Fy",+ "yRT - 36"+ ],+ [+ "Fx",+ "xRL + (xR - xRL)*(Fy - yV)/(yRE - yV)"+ ],+ [+ "scy",+ "Fy + sqrt(41^2 - (xR - Fx)^2)"+ ],+ [+ "xstep",+ "A - 52 - 14"+ ],+ [+ "h11x",+ "xR - 14"+ ],+ [+ "h11y",+ "yRT - 14"+ ]+ ],+ "scriptAppend": [+ {+ "op": "sketch",+ "plane": {+ "offsetFrom": {+ "face": {+ "atPointMm": [+ -10,+ 0,+ 0+ ]+ }+ },+ "offsetMm": -12+ },+ "entities": [+ {+ "circle": {+ "centerMm": [+ "=xR",+ "=scy"+ ],+ "radiusMm": 41+ }+ }+ ]+ },+ {+ "op": "cut",+ "depthMm": 24+ },+ {+ "op": "sketch",+ "face": {+ "atPointMm": [+ -10,+ 0,+ "=C"+ ]+ },+ "entities": [+ {+ "rect": {+ "xMm": -20,+ "yMm": -40,+ "widthMm": "=A + 20",+ "heightMm": 26+ }+ }+ ]+ },+ {+ "op": "cut",+ "depthMm": 19+ },+ {+ "op": "sketch",+ "face": {+ "atPointMm": [+ -10,+ 0,+ "=C"+ ]+ },+ "entities": [+ {+ "rect": {+ "xMm": -20,+ "yMm": -40,+ "widthMm": "=xstep + 20",+ "heightMm": 80+ }+ }+ ]+ },+ {+ "op": "cut",+ "depthMm": 19+ },+ {+ "op": "hole",+ "atMm": [+ "=h11x",+ "=h11y",+ "=C"+ ],+ "diameterMm": 11,+ "throughAll": true+ }+ ]+}
resources/cswa/cswa-08-tool-block-step4.jsonadded+14@@ -0,0 +1,14 @@+{+ "id": "cswa-08-tool-block-step4",+ "extends": "cswa-07-tool-block-step3",+ "title": "Official Tool Block, Step 4 (Q6): a pocket 1 mm inside the front face's outline",+ "expected": {+ "massG": 432.58,+ "tolerancePct": 1.0+ },+ "keyBasis": "the official key (432.58 g); every plausible reading of the drawing lands within \u00b11 % (SOURCES.md, Part 4)",+ "needsOps": [+ "offsetEntities"+ ],+ "plan": "sketch on the front face of the right section: the face's outer loop offset 1 mm inward (Offset Entities) with the R41 silhouette offset 1 mm; cut to offsetFromSurface 12 from the back face; a second cut over the ear inside R41 to offsetFromSurface 1 mm from the slot's front wall"+}
src/comhost/com.rs+534−30@@ -1914,13 +1914,29 @@ impl ComBackend { .call("GetPartBox", &[&Var::boolean(true)]) .map(|v| Value::Array(v.as_array().iter().map(|e| e.to_json()).collect())) .unwrap_or(Value::Null);+ // THE CSWA FIELDS (0.11.0): what an exam answer is stated in — grams,+ // mm³, mm², millimetres in the DOCUMENT's frame (IMassProperty reports+ // in the part's own coordinate system, metres).+ let mass = mp.float("Mass", &[]);+ let volume = mp.float("Volume", &[]);+ let area = mp.float("SurfaceArea", &[]);+ let com_mm: Option<Vec<f64>> = (center.len() >= 3)+ .then(|| center.iter().take(3).filter_map(|v| v.as_f64()).map(|m| (m * 1.0e9).round() / 1.0e6).collect::<Vec<f64>>())+ .filter(|v| v.len() == 3);+ let material = Self::material_of(&doc, ""); Ok(json!({ "doc": Self::doc_info(&doc),- "massKg": mp.float("Mass", &[]),- "volumeM3": mp.float("Volume", &[]),- "surfaceAreaM2": mp.float("SurfaceArea", &[]),+ "massKg": mass,+ "massG": mass.map(|m| m * 1000.0),+ "volumeM3": volume,+ "volumeMm3": volume.map(|v| v * 1.0e9),+ "surfaceAreaM2": area,+ "surfaceAreaMm2": area.map(|a| a * 1.0e6), "density": mp.float("Density", &[]),+ "densityKgM3": mp.float("Density", &[]),+ "materialName": material.get("name").cloned().unwrap_or(Value::Null), "centerOfMass": center,+ "centerOfMassMm": com_mm, "boundingBox": bbox, })) }@@ -5687,6 +5703,20 @@ impl ComBackend { } name }+ crate::script::PlaneRef::FaceOffset { point, offset } => {+ let fo = self.face_offset_plane(&doc, *point, *offset, &defaults, st)?;+ created_plane = fo.made.clone();+ resolved = fo.report.clone();+ mapping = Some((fo.frame, fo.h));+ // The face's OUTWARD normal stays the reference: a cut from+ // a plane 12 inside the back face goes on inward.+ reference = Some(fo.normal);+ let _ = Self::clear_selection(&doc);+ if !Self::select_named(&doc, &fo.name, "PLANE", false, 0)? {+ return Err(HostError::msg(format!("could not select the new plane '{}'", fo.name)));+ }+ fo.name+ } crate::script::PlaneRef::Face { point } => { // GEOMETRIC PICK (review #1): never through the view. let picked = Self::pick_face(&doc, *point)?;@@ -5743,12 +5773,57 @@ impl ComBackend { } // AddToDB: entities go straight into the sketch, without the // snapping/inference that would merge nearby points of several- // profiles (ISketchManager::AddToDB, 2008 FCS).+ // profiles (ISketchManager::AddToDB, 2008 FCS). UNMEASURED for+ // line/arc CONTOURS (0.11.0): whether SOLIDWORKS joins two+ // segments whose ends coincide exactly when they are added this+ // way — a contour that does not join comes back as a null+ // feature, and the error says so. sm.try_put("AddToDB", &Var::boolean(true)); let mut drawn = 0usize;+ // Each drawn line's ISketchSegment and name ("Line3"), so a+ // revolve can select its axis (0.11.0).+ let mut segments: Vec<Option<SketchSeg>> = Vec::new(); if failure.is_none() { for (k, e) in to_draw.iter().enumerate() {+ let seg_of = |v: Var| -> Option<SketchSeg> {+ v.as_dispatch().map(Disp).map(|d| SketchSeg { name: d.string("GetName", &[]), seg: d })+ }; let made = match e {+ // CreateLine / CreateCenterLine (2008 FCS): x1,y1,z1, x2,y2,z2.+ crate::script::Entity::Line { x0, y0, x1, y1, construction } => sm+ .call(+ if *construction { "CreateCenterLine" } else { "CreateLine" },+ &[&Var::r8(*x0), &Var::r8(*y0), &Var::r8(0.0), &Var::r8(*x1), &Var::r8(*y1), &Var::r8(0.0)],+ )+ .map(|v| {+ let s = seg_of(v);+ let ok = s.is_some();+ segments.resize(k, None);+ segments.push(s);+ ok+ }),+ // CreateArc (2008 FCS): XC,YC,Zc, X1,Y1,Z1, X2,Y2,Z2,+ // Direction (short: +1 counter-clockwise, −1 clockwise).+ crate::script::Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => sm+ .call(+ "CreateArc",+ &[+ &Var::r8(*cx), &Var::r8(*cy), &Var::r8(0.0),+ &Var::r8(*x0), &Var::r8(*y0), &Var::r8(0.0),+ &Var::r8(*x1), &Var::r8(*y1), &Var::r8(0.0),+ &Var::i2(if *ccw { 1 } else { -1 }),+ ],+ )+ .map(|v| match seg_of(v) {+ Some(s) => {+ if *construction {+ // ISketchSegment::ConstructionGeometry (2001Plus).+ s.seg.try_put("ConstructionGeometry", &Var::boolean(true));+ }+ true+ }+ None => false,+ }), crate::script::Entity::Rect { x0, y0, x1, y1 } => sm .call( "CreateCornerRectangle",@@ -5786,7 +5861,8 @@ impl ComBackend { // The sketch's own name, so the extrude can RE-SELECT it (review // #7: an op in between clears the selection). let sketch_name = Self::last_feature_name(&doc);- st.pending = Some(PendingSketch { name: sketch_name.clone().unwrap_or_default(), normal, reference });+ segments.resize(to_draw.len(), None);+ st.pending = Some(PendingSketch { name: sketch_name.clone().unwrap_or_default(), normal, reference, segments }); let mut out = json!({ "plane": plane.describe(), "sketchedOn": on,@@ -5813,26 +5889,54 @@ impl ComBackend { // selection, and FeatureExtrusion3 / FeatureCut4 consume what is // selected. let _ = Self::clear_selection(&doc);- if pending.name.is_empty() || !Self::select_named(&doc, &pending.name, "SKETCH", false, 0)? {+ // AN UP-TO FACE (0.11.0) is the end condition's reference entity,+ // selected with MARK 1 ("Mark = 1 for end condition reference+ // entity", FeatureExtrusion3/FeatureCut4 remarks). The selection+ // ladder starts from an empty selection, so the face goes FIRST+ // and the sketch is APPENDED after it (mark 0).+ let mut up_to_face: Value = Value::Null;+ if let Some(crate::script::UpTo::Surface { point } | crate::script::UpTo::OffsetFrom { point, .. }) = &ext.up_to {+ let picked = Self::pick_face(&doc, *point)?;+ let r = self.select_items(&doc, vec![SelItem::face(picked)], 1)?;+ if !r.all(1) {+ let _ = Self::clear_selection(&doc);+ return Err(HostError::msg(format!("the up-to face could not be selected ({})", r.notes.join("; "))));+ }+ up_to_face = json!({"selectedVia": r.report(), "viewDependent": r.view_dependent(), "mark": 1});+ }+ let append = ext.up_to.as_ref().map(|u| !matches!(u, crate::script::UpTo::Next)).unwrap_or(false);+ if pending.name.is_empty() || !Self::select_named(&doc, &pending.name, "SKETCH", append, 0)? {+ let _ = Self::clear_selection(&doc); return Err(HostError::msg(format!("could not re-select the sketch '{}' to {}", pending.name, if cut { "cut" } else { "extrude" }))); } // swEndConditions_e (help.solidworks.com/2024/english/api/swconst/ // SOLIDWORKS.Interop.swconst~SOLIDWORKS.Interop.swconst.- // swEndConditions_e.html): Blind 0, ThroughAll 1, MidPlane 6,- // ThroughAllBoth 9.+ // swEndConditions_e.html): Blind 0, ThroughAll 1, OffsetFromSurface+ // 5, MidPlane 6, ThroughAllBoth 9, UpToSelection 10, UpToNext 11+ // (UpToSurface 4 is the obsolete id; UpToSelection replaces it). 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),+ let (sd, t1, t2, d1, d2) = match (&ext.up_to, ext.direction, ext.through_all) {+ (Some(u), _, _) => (true, crate::modeling::end_condition_id(u), BLIND, ext.depth.max(0.0), 0.01),+ (None, Direction::One, false) => (true, BLIND, BLIND, ext.depth, 0.01),+ (None, Direction::One, true) => (true, THROUGH_ALL, BLIND, 0.01, 0.01),+ (None, Direction::MidPlane, _) => (true, MID_PLANE, BLIND, ext.depth, 0.01),+ (None, Direction::Both, false) => (false, BLIND, BLIND, ext.depth, ext.depth2), // UNMEASURED: Through All - Both as Sd=true, T1=9.- (Direction::Both, true) => (true, THROUGH_ALL_BOTH, BLIND, 0.01, 0.01),+ (None, Direction::Both, true) => (true, THROUGH_ALL_BOTH, BLIND, 0.01, 0.01),+ };+ // OffsetReverse1: the offset goes BEYOND the face instead of short of it.+ let offset_reverse = matches!(ext.up_to, Some(crate::script::UpTo::OffsetFrom { reverse_offset: true, .. }));+ // DRAFT (0.11.0): Dchk1 on, Ddir1 TRUE = INWARD, Dang1 radians — and+ // the same on side 2 when the extent goes two ways.+ let (dchk, ddir, dang) = match ext.draft {+ Some(d) => (true, !d.outward, d.angle),+ None => (false, false, 0.0174533), };+ let dchk2 = dchk && !sd; // THE DIRECTION (review #5): along the reference for a boss, // against it for a cut, whatever way the sketch's own normal // points; the caller's reverse flips it.@@ -5854,13 +5958,13 @@ impl ComBackend { &Var::i4(t2), &Var::r8(d1), &Var::r8(d2),- &Var::boolean(false),- &Var::boolean(false),- &Var::boolean(false),- &Var::boolean(false),- &Var::r8(0.0174533),- &Var::r8(0.0174533),- &Var::boolean(false),+ &Var::boolean(dchk),+ &Var::boolean(dchk2),+ &Var::boolean(ddir),+ &Var::boolean(ddir),+ &Var::r8(dang),+ &Var::r8(dang),+ &Var::boolean(offset_reverse), &Var::boolean(false), &Var::boolean(false), &Var::boolean(false),@@ -5891,13 +5995,13 @@ impl ComBackend { &Var::i4(t2), &Var::r8(d1), &Var::r8(d2),- &Var::boolean(false),- &Var::boolean(false),- &Var::boolean(false),- &Var::boolean(false),- &Var::r8(0.0174533),- &Var::r8(0.0174533),- &Var::boolean(false),+ &Var::boolean(dchk),+ &Var::boolean(dchk2),+ &Var::boolean(ddir),+ &Var::boolean(ddir),+ &Var::r8(dang),+ &Var::r8(dang),+ &Var::boolean(offset_reverse), &Var::boolean(false), &Var::boolean(false), &Var::boolean(false),@@ -5922,6 +6026,10 @@ impl ComBackend { "SOLIDWORKS made no cut (the feature came back null) — nothing was removed. A cut goes along its \ plane's −NORMAL (measured: a cut from Top Plane removes −Y) and INTO a face it is sketched on; \ if the material is on the other side, pass reverse:true, or direction:\"both\" / midPlane:true"+ } else if pending.segments.iter().any(|s| s.is_some()) {+ "SOLIDWORKS built no extrusion (the feature came back null) — this sketch's profile is made of lines/arcs, \+ drawn with AddToDB on (UNMEASURED: whether SOLIDWORKS joins segment ends that coincide when added that \+ way); otherwise it may be self-intersecting, or the extrude would add nothing" } else { "SOLIDWORKS built no extrusion (the feature came back null) — the sketch may be open, unclosed, \ self-intersecting, or the extrude would add nothing"@@ -5937,6 +6045,255 @@ impl ComBackend { "reverse": ext.reverse, "dir": {"value": dir, "flippedToFollowTheReference": flipped, "sketchNormal": pending.normal, "reference": pending.reference}, "endCondition": t1,+ "upToFace": up_to_face,+ "draftDeg": ext.draft.map(|d| d.angle.to_degrees()),+ "draftInward": ext.draft.map(|d| !d.outward),+ }))+ }++ Op::Revolve(rv) | Op::RevolveCut(rv) => {+ let cut = matches!(op, Op::RevolveCut(_));+ let doc = self.resolve(&target)?;+ self.ensure_active(&doc, st)?;+ self.requires(19, "IFeatureManager::FeatureRevolve2 (SOLIDWORKS 2011)")?;+ let pending = st.pending.take().ok_or_else(|| HostError::msg("no sketch to consume"))?;+ let seg = pending.segments.get(rv.axis).cloned().flatten().ok_or_else(|| {+ HostError::msg(format!("the revolve axis (entity {} of the sketch) was not drawn as a line SOLIDWORKS returned", rv.axis))+ })?;+ let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;+ const BLIND: i32 = 0;+ const MID_PLANE: i32 = 6;+ // THE SELECTION: the sketch (mark 0) and its axis line. The help+ // names the axis mark 4 in one place and 16 in another; both are+ // tried, each believed only when SOLIDWORKS returns a feature (a+ // null feature changes nothing, so a retry is safe).+ let mut tried: Vec<String> = Vec::new();+ let mut made: Option<(Disp, i32, &'static str)> = None;+ for mark in [4, 16] {+ let _ = Self::clear_selection(&doc);+ if pending.name.is_empty() || !Self::select_named(&doc, &pending.name, "SKETCH", false, 0)? {+ return Err(HostError::msg(format!("could not re-select the sketch '{}' to revolve", pending.name)));+ }+ let by_name = match &seg.name {+ Some(n) => Self::select_named(&doc, &format!("{n}@{}", pending.name), "EXTSKETCHSEGMENT", true, mark).unwrap_or(false),+ None => false,+ };+ let via = if by_name {+ "SelectByID2 EXTSKETCHSEGMENT"+ } else if Self::select_with_mark(&doc, &seg.seg, "Select4", mark) {+ "ISketchSegment::Select4"+ } else {+ tried.push(format!("mark {mark}: the axis line could not be selected"));+ continue;+ };+ // FeatureRevolve2 (2011): SingleDir, IsSolid, IsThin, IsCut,+ // ReverseDir, BothDirectionUpToSameEntity, Dir1Type, Dir2Type,+ // Dir1Angle, Dir2Angle, OffsetReverse1, OffsetReverse2,+ // OffsetDistance1, OffsetDistance2, ThinType, ThinThickness1,+ // ThinThickness2, Merge, UseFeatScope, UseAutoSelect.+ let f = fm.call(+ "FeatureRevolve2",+ &[+ &Var::boolean(true),+ &Var::boolean(true),+ &Var::boolean(false),+ &Var::boolean(cut),+ &Var::boolean(rv.reverse),+ &Var::boolean(false),+ &Var::i4(if rv.mid_plane { MID_PLANE } else { BLIND }),+ &Var::i4(BLIND),+ &Var::r8(rv.angle),+ &Var::r8(0.0),+ &Var::boolean(false),+ &Var::boolean(false),+ &Var::r8(0.0),+ &Var::r8(0.0),+ &Var::i4(0),+ &Var::r8(0.0),+ &Var::r8(0.0),+ &Var::boolean(true),+ &Var::boolean(true),+ &Var::boolean(true),+ ],+ )?;+ match f.as_dispatch() {+ Some(d) => {+ made = Some((Disp(d), mark, via));+ break;+ }+ None => tried.push(format!("mark {mark} ({via}): FeatureRevolve2 returned null")),+ }+ }+ let _ = Self::clear_selection(&doc);+ let Some((feature, mark, via)) = made else {+ return Err(HostError::msg(format!(+ "SOLIDWORKS made no {} (the feature came back null): {} — the profile may be open (do its line/arc ends join?), cross the axis, or {}",+ if cut { "revolve-cut" } else { "revolve" },+ tried.join("; "),+ if cut { "lie outside the material" } else { "be empty" }+ )));+ };+ Ok(json!({+ "feature": feature.string("Name", &[]),+ "sketch": pending.name,+ "angleDeg": rv.angle.to_degrees(),+ "midPlane": rv.mid_plane,+ "reverse": rv.reverse,+ "axis": {"entity": rv.axis, "segment": seg.name, "mark": mark, "selectedVia": via},+ "route": "FeatureRevolve2",+ }))+ }++ Op::Mirror { features, bodies, plane } => {+ let doc = self.resolve(&target)?;+ self.ensure_active(&doc, st)?;+ let defaults = self.default_planes(&doc, st)?;+ let _ = Self::clear_selection(&doc);+ // THE PLANE, mark 2 ("plane or planar face", InsertMirrorFeature2+ // remarks). A face goes through the selection ladder, which starts+ // from an empty selection — so the plane is selected FIRST and+ // everything else is appended.+ let plane_report = match plane {+ crate::script::PlaneRef::Named(n) => {+ let real = Self::plane_name(&doc, n, &defaults)?;+ if !Self::select_named(&doc, &real, "PLANE", false, 2)? {+ return Err(HostError::msg(format!("could not select the mirror plane '{real}'")));+ }+ json!(real)+ }+ crate::script::PlaneRef::Offset { from, offset } => {+ let base = Self::plane_name(&doc, from, &defaults)?;+ let frame = match defaults.iter().position(|d| d.name == base) {+ Some(i) => defaults[i].frame,+ None => Self::plane_frame(&doc, &base)+ .ok_or_else(|| HostError::msg(format!("the plane '{base}' gave no transform to offset from")))?,+ };+ let (name, made, verified) = self.ensure_offset_plane(&doc, &base, &frame, *offset, st)?;+ let _ = Self::clear_selection(&doc);+ if !Self::select_named(&doc, &name, "PLANE", false, 2)? {+ return Err(HostError::msg(format!("could not select the mirror plane '{name}'")));+ }+ json!({"offsetFrom": from, "offsetMm": offset * 1000.0, "plane": name, "createdPlane": made, "verified": verified})+ }+ crate::script::PlaneRef::FaceOffset { point, offset } => {+ let fo = self.face_offset_plane(&doc, *point, *offset, &defaults, st)?;+ let _ = Self::clear_selection(&doc);+ if !Self::select_named(&doc, &fo.name, "PLANE", false, 2)? {+ return Err(HostError::msg(format!("could not select the mirror plane '{}'", fo.name)));+ }+ fo.report+ }+ crate::script::PlaneRef::Face { point } => {+ let picked = Self::pick_face(&doc, *point)?;+ let r = self.select_items(&doc, vec![SelItem::face(picked)], 2)?;+ if !r.all(1) {+ let _ = Self::clear_selection(&doc);+ return Err(HostError::msg(format!("the mirror face could not be selected ({})", r.notes.join("; "))));+ }+ json!({"face": {"atPointMm": [point[0] * 1000.0, point[1] * 1000.0, point[2] * 1000.0]}, "selectedVia": r.report(), "pickedBy": "geometry"})+ }+ };+ // WHAT IS MIRRORED: features by name (mark 1), or every solid body (mark 256).+ let mut mirrored: Vec<String> = Vec::new();+ if *bodies {+ let list = doc.call("GetBodies2", &[&Var::i4(0), &Var::boolean(true)])?;+ for b in list.as_array().iter().filter_map(|b| b.as_dispatch().map(Disp)) {+ let name = b.string("Name", &[]).unwrap_or_default();+ let ok = Self::select_with_mark(&doc, &b, "Select2", 256)+ || (!name.is_empty() && Self::select_named(&doc, &name, "SOLIDBODY", true, 256).unwrap_or(false));+ if !ok {+ let _ = Self::clear_selection(&doc);+ return Err(HostError::msg(format!("the body '{name}' could not be selected to mirror")));+ }+ mirrored.push(name);+ }+ if mirrored.is_empty() {+ let _ = Self::clear_selection(&doc);+ return Err(HostError::msg("the part has no solid body to mirror"));+ }+ } else {+ for f in features {+ let name = st.resolve_feature(f)?;+ if !Self::select_named(&doc, &name, "BODYFEATURE", true, 1)? {+ let _ = Self::clear_selection(&doc);+ return Err(HostError::msg(format!("could not select the feature '{name}' to mirror")));+ }+ mirrored.push(name);+ }+ }+ let fm = doc.obj("FeatureManager", &[])?.ok_or_else(|| HostError::msg("no FeatureManager"))?;+ // ScopeOptions (swFeatureScope_e) is READ from the installed+ // interop, never guessed: its "all bodies" member. Without it,+ // the obsolete four-argument InsertMirrorFeature is the route.+ let exe = self.app(false, true).ok().and_then(|sw| sw.string("GetExecutablePath", &[])).unwrap_or_default();+ let scope = swconst_members(&exe, "swFeatureScope_e").ok().and_then(|m| {+ let all: Vec<(String, i32)> = m.into_iter().filter(|(k, _)| k.to_ascii_lowercase().contains("all")).collect();+ (all.len() == 1).then(|| all[0].clone())+ });+ // InsertMirrorFeature2 (2008): BMirrorBody, BGeometryPattern, BMerge, BKnit, ScopeOptions.+ let (f, route) = match &scope {+ Some((_, id)) => (+ fm.call("InsertMirrorFeature2", &[&Var::boolean(*bodies), &Var::boolean(false), &Var::boolean(true), &Var::boolean(false), &Var::i4(*id)])?,+ "InsertMirrorFeature2",+ ),+ None => (+ fm.call("InsertMirrorFeature", &[&Var::boolean(*bodies), &Var::boolean(false), &Var::boolean(true), &Var::boolean(false)])?,+ "InsertMirrorFeature (swFeatureScope_e unreadable)",+ ),+ };+ let _ = Self::clear_selection(&doc);+ let Some(feature) = f.as_dispatch().map(Disp) else {+ return Err(HostError::msg(format!(+ "SOLIDWORKS made no mirror (the feature came back null) of {} about {plane_report}",+ mirrored.join(", ")+ )));+ };+ Ok(json!({+ "feature": feature.string("Name", &[]),+ "mirrored": mirrored,+ "mirrorBody": bodies,+ "plane": plane_report,+ "scope": scope.map(|(k, v)| json!({"member": k, "id": v})),+ "route": route,+ }))+ }++ Op::Hole { at, diameter, depth, counterbore, countersink } => {+ // A HOLE IS CUTS (0.11.0): the face at `at`, picked geometrically,+ // resolved to a default plane at a height; circles on that plane+ // cut into it (script::hole_plan — the simulator runs the same plan).+ let doc = self.resolve(&target)?;+ self.ensure_active(&doc, st)?;+ let defaults = self.default_planes(&doc, st)?;+ let picked = Self::pick_face(&doc, *at)?;+ let frames = [defaults[0].frame, defaults[1].frame, defaults[2].frame];+ let i = crate::modeling::parallel_default(&frames, picked.normal).ok_or_else(|| {+ HostError::msg(format!("the face at the hole's point is not planar and parallel to a default plane (normal {:?})", picked.normal))+ })?;+ let frame = frames[i];+ let h = frame.height_of(picked.closest);+ let outward = crate::modeling::dot(picked.normal, frame.n) > 0.0;+ let plan = crate::script::hole_plan(&frame, &defaults[i].name, h, outward, *at, *diameter, *depth, *counterbore, *countersink);+ let mut steps = Vec::new();+ let mut made: Vec<Value> = Vec::new();+ for sub in &plan {+ let r = self.run_op(sub, pinned, st).map_err(|mut e| {+ e.message = format!("hole, its {} ({} of {}): {}", sub.name(), steps.len() + 1, plan.len(), e.message);+ e+ })?;+ if let Some(f) = r.get("feature").filter(|f| !f.is_null()) {+ made.push(f.clone());+ }+ steps.push(json!({"op": sub.name(), "result": r}));+ }+ Ok(json!({+ "feature": made.last(),+ "features": made,+ "face": {"atPointMm": [at[0] * 1000.0, at[1] * 1000.0, at[2] * 1000.0], "normal": picked.normal, "plane": defaults[i].name, "heightMm": h * 1000.0, "pickedBy": "geometry"},+ "diameterMm": diameter * 1000.0,+ "depthMm": depth.map(|d| d * 1000.0),+ "throughAll": depth.is_none(),+ "steps": steps, })) } @@ -6165,12 +6522,62 @@ impl ComBackend { Ok(json!({"feature": made.string("Name", &[]), "seed": seed, "axis": ax, "instances": count, "angleDeg": angle.to_degrees()})) } - Op::SetMaterial { name, database } => {+ Op::SetMaterial { name, database, density } => { let doc = self.resolve(&target)?;+ let part_density = |doc: &Disp| {+ doc.obj("Extension", &[]).ok().flatten()+ .and_then(|ext| ext.obj("CreateMassProperty", &[]).ok().flatten())+ .and_then(|mp| mp.float("Density", &[]))+ .filter(|d| d.is_finite() && *d > 0.0)+ };+ if name.is_empty() {+ // A DENSITY ALONE (0.11.0): the document's own material+ // density (Document Properties > Material Properties), which+ // counts only where no library material is applied.+ let want = density.unwrap_or(1000.0);+ if let Some(m) = Self::material_of(&doc, "").get("name").and_then(|n| n.as_str()) {+ return Err(HostError::coded(+ "material_applied",+ format!("the library material '{m}' is applied, and its density wins over the document's — densityKgM3 alone cannot change it; name the material whose density you need"),+ ));+ }+ let exe = self.app(false, true).ok().and_then(|sw| sw.string("GetExecutablePath", &[])).unwrap_or_default();+ let ids = swconst_ids(&exe, &[("swUserPreferenceDoubleValue_e", "swMaterialPropertyDensity")])+ .map_err(|e| HostError::coded("density_unsettable", e))?;+ let id = ids.get("swMaterialPropertyDensity").copied().unwrap_or(-1);+ let ext = doc.obj("Extension", &[])?.ok_or_else(|| HostError::msg("no ModelDocExtension"))?;+ // IModelDocExtension::SetUserPreferenceDouble(pref, swDetailingNoOptionSpecified 0, value).+ let set = ext.call("SetUserPreferenceDouble", &[&Var::i4(id), &Var::i4(0), &Var::r8(want)])?.as_bool();+ let _ = doc.call("ForceRebuild3", &[&Var::boolean(false)]);+ let pref = ext.float("GetUserPreferenceDouble", &[&Var::i4(id), &Var::i4(0)]);+ let read = part_density(&doc);+ if read.map(|r| (r - want).abs() > 0.005 * want).unwrap_or(false) {+ return Err(HostError::coded(+ "density_not_applied",+ format!("the part's density reads {} kg/m³ after setting {want} (preference reads {pref:?})", read.unwrap_or(0.0)),+ ));+ }+ return Ok(json!({"material": Value::Null, "densityKgM3": read, "densityRequested": want, "preference": pref,+ "set": set, "route": "SetUserPreferenceDouble(swMaterialPropertyDensity)"}));+ } doc.call( "SetMaterialPropertyName2", &[&Var::bstr(""), &Var::bstr(database), &Var::bstr(name)], )?;+ // A density WITH a material: the material's density must be it.+ let read = part_density(&doc);+ if let Some(want) = density {+ match read {+ Some(r) if (r - want).abs() <= 0.005 * want => {}+ Some(r) => {+ return Err(HostError::coded(+ "material_density_mismatch",+ format!("the material '{name}' has a density of {r} kg/m³, not the {want} asked for — apply the material whose density matches, or give densityKgM3 alone (no name) to set the part's own density"),+ ))+ }+ None => return Err(HostError::coded("density_unreadable", "the part's density could not be read back after applying the material")),+ }+ } // A MATERIAL WIPES THE PART'S APPEARANCE ("when you apply a // material… your customized part appearance is gone" — review #4). // Every appearance this script set is applied again, and said so.@@ -6179,7 +6586,8 @@ impl ComBackend { let r = self.apply_appearance(&doc, &t, &rgb, st)?; again.push(json!({"target": r.get("target"), "rgb": r.get("rgb"), "readBack": r.get("readBack")})); }- Ok(json!({"material": name, "database": database, "current": Self::material_of(&doc, ""), "appearanceReapplied": again}))+ Ok(json!({"material": name, "database": database, "current": Self::material_of(&doc, ""), "appearanceReapplied": again,+ "densityKgM3": read, "densityRequested": density})) } Op::SetProp { name, value } => {@@ -6304,6 +6712,18 @@ impl ComBackend { .unwrap_or(false)) } + /// Select an object through its own `method` (ISketchSegment::Select4,+ /// IBody2::Select2: Append, ISelectData) with a MARK, appended; true when+ /// the selection count grew.+ fn select_with_mark(doc: &Disp, obj: &Disp, method: &str, mark: i32) -> bool {+ let Some(sel) = doc.obj("SelectionManager", &[]).ok().flatten() else { return false };+ let before = sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).unwrap_or(0);+ let Some(data) = sel.obj("CreateSelectData", &[]).ok().flatten() else { return false };+ data.try_put("Mark", &Var::i4(mark));+ let _ = obj.call(method, &[&Var::boolean(true), &Var::dispatch(&data.0)]);+ sel.int("GetSelectedObjectCount2", &[&Var::i4(-1)]).unwrap_or(0) > before+ }+ /// The TOP-LEVEL features after the origin (FirstFeature / GetNextFeature; /// the origin is the `OriginProfileFeature`): what a script built. fn features_after_origin(doc: &Disp) -> Result<Vec<Disp>, HostError> {@@ -6737,6 +7157,39 @@ impl ComBackend { /// READ BACK through its transform; a negative offset tries /// swRefPlaneReferenceConstraint_OptionFlip (256) and then _OptionReferenceFlip /// (8192), deleting a plane that landed on the wrong side.+ /// A plane `offset` along the OUTWARD normal of the face through `point`+ /// (0.11.0): the face picked geometrically, resolved to the parallel default+ /// plane at the face's height, then an offset plane of THAT default plane at+ /// `height + offset·(outward · normal)` — the same planes a hole and an+ /// offset sketch make, cached per script.+ fn face_offset_plane(+ &mut self,+ doc: &Disp,+ point: crate::modeling::V3,+ offset: f64,+ defaults: &[DefaultPlane; 3],+ st: &mut ScriptState,+ ) -> Result<FaceOffset, HostError> {+ let picked = Self::pick_face(doc, point)?;+ let frames = [defaults[0].frame, defaults[1].frame, defaults[2].frame];+ let i = crate::modeling::parallel_default(&frames, picked.normal).ok_or_else(|| {+ HostError::msg(format!("the face at that point is not planar and parallel to a default plane (normal {:?})", picked.normal))+ })?;+ let frame = frames[i];+ let along = if crate::modeling::dot(picked.normal, frame.n) >= 0.0 { 1.0 } else { -1.0 };+ let h = frame.height_of(picked.closest) + offset * along;+ let (name, made, verified) = self.ensure_offset_plane(doc, &defaults[i].name, &frame, h, st)?;+ Ok(FaceOffset {+ report: json!({"offsetFrom": defaults[i].name, "offsetMm": h * 1000.0, "fromFace": true, "faceNormal": picked.normal,+ "faceOffsetMm": offset * 1000.0, "plane": name, "verified": verified, "pickedBy": "geometry"}),+ name,+ made,+ frame,+ h,+ normal: picked.normal,+ })+ }+ fn ensure_offset_plane( &mut self, doc: &Disp,@@ -7090,6 +7543,16 @@ struct DefaultPlane { frame: crate::modeling::PlaneFrame, } +/// A plane offset from a face, made (or reused) for a sketch or a mirror.+struct FaceOffset {+ name: String,+ made: Option<String>,+ frame: crate::modeling::PlaneFrame,+ h: f64,+ normal: crate::modeling::V3,+ report: Value,+}+ /// A sketch closed and not yet consumed: its name (to re-select it), its /// normal, and the direction a boss from it should go. #[derive(Clone)]@@ -7097,6 +7560,16 @@ struct PendingSketch { name: String, normal: Option<crate::modeling::V3>, reference: Option<crate::modeling::V3>,+ /// Each drawn entity's line segment (None for anything else), in the+ /// order drawn — a revolve's axis is selected from here.+ segments: Vec<Option<SketchSeg>>,+}++/// A drawn sketch line: its ISketchSegment and its name within the sketch.+#[derive(Clone)]+struct SketchSeg {+ name: Option<String>,+ seg: Disp, } /// What a running script remembers between ops: the feature "last" names, the@@ -7187,6 +7660,37 @@ fn swconst_ids(exe_dir: &str, want: &[(&str, &str)]) -> Result<std::collections: Ok(c.clone()) } +/// EVERY MEMBER of a swconst enum, read from the installed interop by+/// reflection (0.11.0) — for an enum whose member names the help does not+/// publish (swFeatureScope_e). UNMEASURED as a code path.+fn swconst_members(exe_dir: &str, enum_name: &str) -> Result<Vec<(String, i32)>, String> {+ let base = std::path::Path::new(exe_dir.trim_end_matches(['\\', '/']));+ let dll = [base.join("SolidWorks.Interop.swconst.dll"), base.join("api").join("redist").join("SolidWorks.Interop.swconst.dll")]+ .into_iter()+ .find(|p| p.is_file())+ .ok_or_else(|| format!("SolidWorks.Interop.swconst.dll was not found beside SOLIDWORKS ({exe_dir})"))?;+ let script = format!(+ "$ErrorActionPreference='Stop'; $a=[Reflection.Assembly]::LoadFrom('{}'); \+ $t=$a.GetTypes() | Where-Object {{ $_.Name -eq '{}' }} | Select-Object -First 1; \+ foreach($m in [Enum]::GetNames($t)){{ [Console]::WriteLine($m + '=' + [int][Enum]::Parse($t, $m)) }}",+ dll.display().to_string().replace('\'', "''"),+ enum_name.replace('\'', "")+ );+ let out = std::process::Command::new("powershell.exe")+ .args(["-NoProfile", "-NonInteractive", "-Command", &script])+ .output()+ .map_err(|e| format!("could not run powershell to read {enum_name}: {e}"))?;+ let text = String::from_utf8_lossy(&out.stdout);+ let got: Vec<(String, i32)> = text+ .lines()+ .filter_map(|l| l.trim().split_once('=').and_then(|(k, v)| v.trim().parse::<i32>().ok().map(|n| (k.trim().to_string(), n))))+ .collect();+ if got.is_empty() {+ return Err(format!("{enum_name} could not be read from {} ({})", dll.display(), String::from_utf8_lossy(&out.stderr).trim()));+ }+ Ok(got)+}+ /// Preferences this bridge is willing to name, as (name, swUserPreferenceIntegerValue_e id). /// Small on purpose: `swx_set_preference` reads every value back after writing /// it, so anything listed here is something the bridge can PROVE it changed.
src/comhost/mock.rs+40−14@@ -81,12 +81,13 @@ fn mock_body(name: &str, x: f64, y: f64, z: f64) -> Value { /// values for the materials the tests name. No material = SOLIDWORKS' default /// part density, 1000 kg/m³. fn mock_density(material: Option<&str>) -> f64 {- match material.map(|m| m.to_ascii_lowercase()) {- Some(m) if m == "abs" => 1020.0,- Some(m) if m.contains("6061") => 2700.0,- Some(m) if m.contains("steel") => 7800.0,- _ => 1000.0,- }+ material.and_then(crate::comhost::mock_model::library_density).unwrap_or(1000.0)+}++/// A document's density: what set_material stored in its model (a density+/// alone, 0.11.0), else its material's.+fn doc_density(d: &MockDoc) -> f64 {+ d.model.get("density").and_then(|v| v.as_f64()).unwrap_or_else(|| mock_density(d.material.as_deref())) } struct MockDoc {@@ -2343,25 +2344,47 @@ impl MockBackend { // A part with simulated bodies: computed from them, so this and // inspect_bodies agree (issue #2). Otherwise the canned answer. if let Some(d) = self.find(&path).filter(|d| !d.bodies.is_empty()) {- let density = mock_density(d.material.as_deref());+ let density = doc_density(d); let vol_m3: f64 = d.bodies.iter().filter_map(|b| b.get("volumeMm3").and_then(|v| v.as_f64())).sum::<f64>() * 1.0e-9;+ let model: crate::comhost::mock_model::Model = serde_json::from_value(Value::Object(d.model.clone())).unwrap_or_default();+ // THE COLUMN-INTEGRATED SOLID where the part allows it (0.11.0):+ // exact for partial cuts the box model can only approximate.+ let (vol_m3, com, how) = match model.csg_mass() {+ Ok((v, c)) => (v, Some(c), json!({"volume": "column integration of the feature primitives (mock_csg; grid error ~0.1 %)"})),+ Err(why) => (vol_m3, model.center_of_mass(), json!({"volume": "the box model's arithmetic", "columnModelDeclined": why})),+ };+ let area_mm2 = d.bodies.iter().filter_map(|b| b.get("areaMm2").and_then(|v| v.as_f64())).sum::<f64>(); return Ok(json!({ "doc": self.doc_info(&path), "massKg": vol_m3 * density,+ "massG": vol_m3 * density * 1000.0, "volumeM3": vol_m3,- "surfaceAreaM2": d.bodies.iter().filter_map(|b| b.get("areaMm2").and_then(|v| v.as_f64())).sum::<f64>() * 1.0e-6,+ "volumeMm3": vol_m3 * 1.0e9,+ "surfaceAreaM2": area_mm2 * 1.0e-6,+ "surfaceAreaMm2": area_mm2, "density": density,- "centerOfMass": [0.0, 0.0, 0.0],+ "densityKgM3": density,+ "materialName": d.material,+ "centerOfMass": com.map(|c| json!(c)).unwrap_or(json!([0.0, 0.0, 0.0])),+ "centerOfMassMm": com.map(|c| json!([c[0] * 1000.0, c[1] * 1000.0, c[2] * 1000.0])), "boundingBox": Value::Null,+ "simulated": "mock model: surface area is the bodies' boxes', never compared",+ "simulatedBy": how, })); } Ok(json!({ "doc": self.doc_info(&path), "massKg": 0.4173,+ "massG": 417.3, "volumeM3": 5.31e-05,+ "volumeMm3": 53100.0, "surfaceAreaM2": 0.0234,+ "surfaceAreaMm2": 23400.0, "density": 7860.0,+ "densityKgM3": 7860.0,+ "materialName": "Plain Carbon Steel", "centerOfMass": [0.012, 0.0, 0.0345],+ "centerOfMassMm": [12.0, 0.0, 34.5], "boundingBox": [-0.05, -0.02, 0.0, 0.05, 0.02, 0.09], })) }@@ -2850,8 +2873,7 @@ impl MockBackend { let filter = args.get("nameContains").and_then(|v| v.as_str()).unwrap_or("").to_lowercase(); // MASS = VOLUME x THE MATERIAL'S DENSITY (issue #2): the same // density swx_mass_properties reports, so the two verbs agree.- let material = self.find(&path).and_then(|d| d.material.clone());- let density = mock_density(material.as_deref());+ let density = self.find(&path).map(doc_density).unwrap_or(1000.0); let unreadable = fault("mock-density-unreadable"); let bodies: Vec<Value> = self .find(&path)@@ -4019,7 +4041,9 @@ impl MockBackend { if matches!(op, Op::Sketch { .. }) && fault("mock-sketch-fails") { return Err(HostError::msg("SOLIDWORKS drew nothing")); }- if matches!(op, Op::Extrude(_) | Op::Cut(_)) && fault(&format!("mock-feature-null-{}", op.name())) {+ if matches!(op, Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_) | Op::Mirror { .. } | Op::Hole { .. })+ && fault(&format!("mock-feature-null-{}", op.name()))+ { return Err(HostError::msg( "SOLIDWORKS returned no feature (nothing was cut — check the direction: a cut goes along the \ plane's −normal unless reverse:true)",@@ -4027,7 +4051,7 @@ impl MockBackend { } // RE-SELECT the pending sketch by name before it is // consumed (review #7): an op in between cleared it.- if matches!(op, Op::Extrude(_) | Op::Cut(_)) && run.pending.is_some() && !fault("mock-no-reselect") {+ if matches!(op, Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_)) && run.pending.is_some() && !fault("mock-no-reselect") { run.sketch_selected = true; } let path = self.resolve(&target)?;@@ -4045,7 +4069,9 @@ impl MockBackend { } let r = applied.map_err(HostError::msg)?; if let Op::SetMaterial { name, .. } = op {- d.material = Some(name.clone());+ if !name.is_empty() {+ d.material = Some(name.clone());+ } } if let (Op::Extrude(e), Some(f)) = (op, r.get("feature").and_then(|f| f.as_str())) { d.dimensions.insert(format!("D1@{f}"), json!(e.depth));
src/comhost/mock_csg.rsadded+622@@ -0,0 +1,622 @@+//! THE SIMULATOR'S SOLID, BY COLUMNS (0.11.0) — so an exam part's mass comes+//! out of the simulator to the key, partial cuts included.+//!+//! The arithmetic model (mock_model.rs) keeps a box and a volume per body:+//! exact for a boss, a cut wholly inside the material and a revolve, and only+//! approximate for a cut that is partly outside (the CSWA Tool Block's R41+//! slot, its under-notch). This model keeps every feature as a PRIMITIVE —+//! a prism (a sketch's closed profiles between two heights, optionally+//! drafted) or a full revolve — added or subtracted in order (CSG), and+//! integrates the part by COLUMNS: along one model axis every primitive's+//! extent is an exact set of intervals (the profile sliced by a line —+//! lines and arcs intersected in closed form), and the columns sample the+//! other two axes on a grid. The error is the grid's, at internal edges only+//! (the grid is aligned to the part's box): about 0.1 % on the Tool Block,+//! well inside the exam's 1 %.+//!+//! What it cannot represent (fillets, chamfers, shells, circular patterns,+//! partial revolves, a revolve whose axis is not the column axis) makes it+//! decline, saying why; the arithmetic model answers then.++use serde::{Deserialize, Serialize};++use crate::modeling::{self, PlaneFrame, V3};+use crate::script::Entity;++/// One feature's solid, added or removed.+#[derive(Clone, Debug, Serialize, Deserialize)]+pub struct Prim {+ pub add: bool,+ /// The feature that made it (mirror and patterns copy by feature).+ pub feature: String,+ pub kind: Kind,+}++#[derive(Clone, Debug, Serialize, Deserialize)]+pub enum Kind {+ /// The closed profiles of `entities` (plane coordinates of `frame`; nested+ /// profiles alternate solid / hole) between heights `lo` and `hi` along the+ /// frame's normal. `draft`: (k, h0) — the profile is offset INWARD by+ /// k·|h − h0| at height h (k < 0: outward); rectangles and circles only.+ Prism { frame: PlaneFrame, lo: f64, hi: f64, entities: Vec<Entity>, draft: Option<(f64, f64)> },+ /// A FULL revolve of `entities` (in `frame` at height `h`) about the line+ /// entity `axis`.+ Revolve { frame: PlaneFrame, h: f64, entities: Vec<Entity>, axis: usize },+}++/// The model axis (0 X, 1 Y, 2 Z) a unit vector of the default frames runs+/// along, and its sign.+fn axis_of(v: V3) -> (usize, f64) {+ let i = (0..3).max_by(|a, b| v[*a].abs().partial_cmp(&v[*b].abs()).unwrap()).unwrap_or(0);+ (i, v[i].signum())+}++/// Where the line `p + s·e` (plane coordinates) crosses the boundary of the+/// profiles: the sorted parameters `s`. Between consecutive pairs the line is+/// inside (even-odd: a hole inside a profile is outside).+pub fn crossings(entities: &[Entity], p: (f64, f64), e: (f64, f64), shrink: f64) -> Vec<f64> {+ let mut out = Vec::new();+ let seg = |a: (f64, f64), b: (f64, f64), out: &mut Vec<f64>| {+ let d = (b.0 - a.0, b.1 - a.1);+ let den = e.0 * d.1 - e.1 * d.0;+ if den.abs() < 1e-18 {+ return;+ }+ let w = (a.0 - p.0, a.1 - p.1);+ let s = (w.0 * d.1 - w.1 * d.0) / den;+ let t = (w.0 * e.1 - w.1 * e.0) / den;+ if (0.0..1.0).contains(&t) {+ out.push(s);+ }+ };+ let circle = |c: (f64, f64), r: f64, sweep: Option<(f64, f64)>, out: &mut Vec<f64>| {+ if r <= 0.0 {+ return;+ }+ let w = (p.0 - c.0, p.1 - c.1);+ let b = w.0 * e.0 + w.1 * e.1;+ let cc = w.0 * w.0 + w.1 * w.1 - r * r;+ let disc = b * b - cc;+ if disc <= 0.0 {+ return;+ }+ for s in [-b - disc.sqrt(), -b + disc.sqrt()] {+ let q = (p.0 + s * e.0 - c.0, p.1 + s * e.1 - c.1);+ match sweep {+ None => out.push(s),+ Some((t0, sw)) => {+ let phi = q.1.atan2(q.0);+ // Half-open at the END of the arc, as segments are.+ let tau = std::f64::consts::TAU;+ let rel = if sw >= 0.0 { (phi - t0).rem_euclid(tau) } else { (t0 - phi).rem_euclid(tau) };+ if rel < sw.abs() {+ out.push(s);+ }+ }+ }+ }+ };+ for en in entities {+ match *en {+ Entity::Rect { x0, y0, x1, y1 } => {+ let (a0, b0, a1, b1) = (x0.min(x1) + shrink, y0.min(y1) + shrink, x0.max(x1) - shrink, y0.max(y1) - shrink);+ if a0 >= a1 || b0 >= b1 {+ continue;+ }+ for (a, b) in [((a0, b0), (a1, b0)), ((a1, b0), (a1, b1)), ((a1, b1), (a0, b1)), ((a0, b1), (a0, b0))] {+ seg(a, b, &mut out);+ }+ }+ Entity::Circle { cx, cy, r } => circle((cx, cy), r - shrink, None, &mut out),+ Entity::Line { x0, y0, x1, y1, construction: false } => seg((x0, y0), (x1, y1), &mut out),+ Entity::Arc { construction: false, .. } => {+ if let Some((cx, cy, r, t0, sw)) = crate::script::arc_geom(en) {+ circle((cx, cy), r, Some((t0, sw)), &mut out);+ }+ }+ _ => {}+ }+ }+ out.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));+ out+}++/// Pairs of crossings as intervals (an odd count drops the last: a tangency).+fn pairs(c: &[f64]) -> Vec<(f64, f64)> {+ c.chunks_exact(2).map(|w| (w[0], w[1])).filter(|(a, b)| b > a).collect()+}++fn union(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {+ let mut all: Vec<(f64, f64)> = a.iter().chain(b.iter()).copied().collect();+ all.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));+ let mut out: Vec<(f64, f64)> = Vec::new();+ for (s, e) in all {+ match out.last_mut() {+ Some(l) if s <= l.1 => l.1 = l.1.max(e),+ _ => out.push((s, e)),+ }+ }+ out+}++fn minus(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {+ let mut out = Vec::new();+ for &(s, e) in a {+ let mut cur = vec![(s, e)];+ for &(bs, be) in b {+ let mut next = Vec::new();+ for (cs, ce) in cur {+ if be <= cs || bs >= ce {+ next.push((cs, ce));+ } else {+ if bs > cs {+ next.push((cs, bs));+ }+ if be < ce {+ next.push((be, ce));+ }+ }+ }+ cur = next;+ }+ out.extend(cur);+ }+ out+}++fn intersect(a: &[(f64, f64)], lo: f64, hi: f64) -> Vec<(f64, f64)> {+ a.iter().filter_map(|&(s, e)| {+ let (s, e) = (s.max(lo), e.min(hi));+ (e > s).then_some((s, e))+ }).collect()+}++/// A rectangle or circle alone (no holes): what a draft is modelled on.+fn single_shape(entities: &[Entity]) -> Option<&Entity> {+ let solid: Vec<&Entity> = entities.iter().filter(|e| !e.is_construction()).collect();+ match solid.as_slice() {+ [e @ (Entity::Rect { .. } | Entity::Circle { .. })] => Some(e),+ _ => None,+ }+}++/// Can this primitive be represented? (Why not, if not.)+pub fn supported(kind: &Kind) -> Result<(), String> {+ match kind {+ Kind::Prism { draft: Some(_), entities, .. } if single_shape(entities).is_none() => {+ Err("a draft on a profile other than one rectangle or one circle".into())+ }+ _ => Ok(()),+ }+}++/// 8-point Gauss-Legendre nodes and weights on [-1, 1].+const GAUSS: [(f64, f64); 8] = [+ (-0.960_289_856_497_536_2, 0.101_228_536_290_376_26),+ (-0.796_666_477_413_626_7, 0.222_381_034_453_374_47),+ (-0.525_532_409_916_329_0, 0.313_706_645_877_887_3),+ (-0.183_434_642_495_649_8, 0.362_683_783_378_362_0),+ (0.183_434_642_495_649_8, 0.362_683_783_378_362_0),+ (0.525_532_409_916_329_0, 0.313_706_645_877_887_3),+ (0.796_666_477_413_626_7, 0.222_381_034_453_374_47),+ (0.960_289_856_497_536_2, 0.101_228_536_290_376_26),+];++/// Quadrature nodes on [a, b]: Gauss-Legendre after the substitution+/// x = mid + half·sin φ, which smooths the square-root behaviour a circle's+/// chord length has at its ends (a band ends exactly there) — and integrates+/// a straight edge's linear length to ~1e-11.+fn nodes(a: f64, b: f64) -> Vec<(f64, f64)> {+ let (mid, half) = ((a + b) / 2.0, (b - a) / 2.0);+ let h = std::f64::consts::FRAC_PI_2;+ GAUSS.iter().map(|(x, w)| {+ let phi = x * h;+ (mid + half * phi.sin(), w * h * half * phi.cos())+ }).collect()+}++/// The coordinates along one plane direction where a profile has a vertex or+/// an extreme: where the slices of it change shape.+fn profile_coords(entities: &[Entity], along_u: bool) -> Vec<f64> {+ let pick = |x: f64, y: f64| if along_u { x } else { y };+ let mut out = Vec::new();+ for e in entities.iter().filter(|e| !e.is_construction()) {+ match *e {+ Entity::Rect { x0, y0, x1, y1 } => out.extend([pick(x0, y0), pick(x1, y1)]),+ Entity::Circle { cx, cy, r } => out.extend([pick(cx, cy) - r, pick(cx, cy), pick(cx, cy) + r]),+ Entity::Line { x0, y0, x1, y1, .. } => out.extend([pick(x0, y0), pick(x1, y1)]),+ Entity::Arc { x0, y0, x1, y1, .. } => {+ out.extend([pick(x0, y0), pick(x1, y1)]);+ let (lo, hi) = if along_u { crate::script::extent_along(e, 1.0, 0.0) } else { crate::script::extent_along(e, 0.0, 1.0) };+ out.extend([lo, hi]);+ }+ }+ }+ out+}++/// The column axis: a revolve's axis if there is one (every revolve must+/// share it), else the axis most prisms are extruded along.+fn column_axis(prims: &[Prim]) -> Result<usize, String> {+ let mut col: Option<usize> = None;+ for p in prims {+ if let Kind::Revolve { frame, entities, axis, .. } = &p.kind {+ let ((ax, ay), (bx, by)) = entities.get(*axis).and_then(|e| e.ends()).ok_or("a revolve without its axis line")?;+ let d3 = modeling::sub(frame.point(bx, by, 0.0), frame.point(ax, ay, 0.0));+ let (i, _) = axis_of(d3);+ if d3[i].abs() / modeling::norm(d3) < 1.0 - 1e-9 {+ return Err("a revolve axis that is not along a model axis".into());+ }+ if col.map(|c| c != i).unwrap_or(false) {+ return Err("revolves about different axes".into());+ }+ col = Some(i);+ }+ }+ Ok(col.unwrap_or_else(|| {+ let mut count = [0usize; 3];+ for p in prims {+ if let Kind::Prism { frame, .. } = &p.kind {+ count[axis_of(frame.n).0] += 1;+ }+ }+ (0..3).max_by_key(|i| (count[*i], 3 - *i)).unwrap_or(2)+ }))+}++/// The plane coordinate (and sign) of model axis `m` in `frame`, if `m` lies+/// in the plane: (true for u / false for v, sign).+fn in_plane(frame: &PlaneFrame, m: usize) -> Option<(bool, f64)> {+ let (xa, xs) = axis_of(frame.x);+ let (ya, ys) = axis_of(frame.y);+ if xa == m {+ Some((true, xs))+ } else if ya == m {+ Some((false, ys))+ } else {+ None+ }+}++/// Breakpoints of a revolve along model axis `m` (≠ the column axis): where+/// the distance from its axis crosses a profile radius.+fn revolve_radii(frame: &PlaneFrame, h: f64, entities: &[Entity], axis: usize) -> Option<(V3, Vec<f64>)> {+ let ((ax, ay), (bx, by)) = entities.get(axis)?.ends()?;+ let l = (bx - ax).hypot(by - ay);+ let nrm = (-(by - ay) / l, (bx - ax) / l);+ let o = nrm.0 * ax + nrm.1 * ay;+ let mut radii = Vec::new();+ for (i, e) in entities.iter().enumerate() {+ if i == axis || e.is_construction() {+ continue;+ }+ let (lo, hi) = crate::script::extent_along(e, nrm.0, nrm.1);+ radii.extend([(lo - o).abs(), (hi - o).abs()]);+ if let Some(((x0, y0), (x1, y1))) = e.ends() {+ radii.extend([(nrm.0 * x0 + nrm.1 * y0 - o).abs(), (nrm.0 * x1 + nrm.1 * y1 - o).abs()]);+ }+ }+ Some((frame.point(ax, ay, h), radii))+}++/// THE INTEGRATION. Along the column axis every primitive is an exact set of+/// intervals. Across it, the part is cut into BANDS at every coordinate where+/// a profile has a vertex or an extreme (and at every primitive's own ends),+/// and each band is integrated by 8-point Gauss-Legendre — so a polygon's+/// straight edges carry no grid error at all, and arcs converge fast. Returns+/// the volume (m³) and the centre of mass.+pub fn integrate(prims: &[Prim], bounds: (V3, V3), _n: usize) -> Result<(f64, V3), String> {+ if prims.is_empty() {+ return Err("nothing to integrate".into());+ }+ let c = column_axis(prims)?;+ let (r_ax, q_ax) = match c {+ 0 => (1, 2),+ 1 => (0, 2),+ _ => (0, 1),+ };+ let (lo, hi) = bounds;+ // Breakpoints along q, for the whole part.+ let mut qb: Vec<f64> = vec![lo[q_ax], hi[q_ax]];+ for p in prims {+ match &p.kind {+ Kind::Prism { frame, lo: h0, hi: h1, entities, .. } => {+ if axis_of(frame.n).0 == q_ax {+ qb.extend([*h0, *h1]);+ } else if let Some((u, sg)) = in_plane(frame, q_ax) {+ qb.extend(profile_coords(entities, u).into_iter().map(|x| sg * x));+ }+ }+ Kind::Revolve { frame, h, entities, axis } => {+ if let Some((a3, radii)) = revolve_radii(frame, *h, entities, *axis) {+ for r in radii {+ qb.extend([a3[q_ax] - r, a3[q_ax] + r]);+ }+ qb.push(a3[q_ax]);+ }+ }+ }+ }+ let clean = |mut v: Vec<f64>, a: f64, b: f64| -> Vec<f64> {+ v.retain(|x| x.is_finite() && *x >= a && *x <= b);+ v.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));+ v.dedup_by(|x, y| (*x - *y).abs() < 1e-12);+ v+ };+ let qb = clean(qb, lo[q_ax], hi[q_ax]);+ let continuous = prims.iter().any(|p| match &p.kind {+ Kind::Revolve { .. } => true,+ Kind::Prism { frame, draft, .. } => draft.is_some() || axis_of(frame.n).0 == q_ax,+ });+ let mut vol = 0.0;+ let mut mom = [0.0; 3];+ for w in qb.windows(2) {+ let (q0, q1) = (w[0], w[1]);+ if q1 - q0 < 1e-15 {+ continue;+ }+ for (q, wq) in nodes(q0, q1) {+ // Breakpoints along r, on this row.+ let mut rb: Vec<f64> = vec![lo[r_ax], hi[r_ax]];+ for p in prims {+ match &p.kind {+ Kind::Prism { frame, lo: h0, hi: h1, entities, .. } => {+ let pa = axis_of(frame.n).0;+ if pa == r_ax {+ rb.extend([*h0, *h1]);+ } else if pa == c {+ // The row's line in the plane: q fixed, r varying.+ if let (Some((uq, sq)), Some((_, sr))) = (in_plane(frame, q_ax), in_plane(frame, r_ax)) {+ let (pt, dir) = if uq { ((sq * q, 0.0), (0.0, 1.0)) } else { ((0.0, sq * q), (1.0, 0.0)) };+ rb.extend(crossings(entities, pt, dir, 0.0).into_iter().map(|s| sr * s));+ }+ } else if let Some((u, sg)) = in_plane(frame, r_ax) {+ rb.extend(profile_coords(entities, u).into_iter().map(|x| sg * x));+ }+ }+ Kind::Revolve { frame, h, entities, axis } => {+ if let Some((a3, radii)) = revolve_radii(frame, *h, entities, *axis) {+ let dq = q - a3[q_ax];+ for r in radii {+ if r * r > dq * dq {+ let d = (r * r - dq * dq).sqrt();+ rb.extend([a3[r_ax] - d, a3[r_ax] + d]);+ }+ }+ }+ }+ }+ }+ let rb = clean(rb, lo[r_ax], hi[r_ax]);+ for v in rb.windows(2) {+ let (r0, r1) = (v[0], v[1]);+ if r1 - r0 < 1e-15 {+ continue;+ }+ let at: Vec<(f64, f64)> = if continuous { nodes(r0, r1) } else { vec![((r0 + r1) / 2.0, r1 - r0)] };+ for (r, wr) in at {+ let mut point = [0.0; 3];+ point[r_ax] = r;+ point[q_ax] = q;+ let mut set: Vec<(f64, f64)> = Vec::new();+ for p in prims {+ let iv = column(&p.kind, c, point);+ set = if p.add { union(&set, &iv) } else { minus(&set, &iv) };+ }+ let cell = wq * wr;+ for (a, b) in set {+ let len = b - a;+ vol += len * cell;+ mom[c] += (b * b - a * a) / 2.0 * cell;+ mom[r_ax] += len * r * cell;+ mom[q_ax] += len * q * cell;+ }+ }+ }+ }+ }+ if vol <= 0.0 {+ return Ok((0.0, [0.0; 3]));+ }+ Ok((vol, modeling::scale(mom, 1.0 / vol)))+}++/// One primitive's extent along the column through `point` (axis `c`).+fn column(kind: &Kind, c: usize, point: V3) -> Vec<(f64, f64)> {+ match kind {+ Kind::Prism { frame, lo, hi, entities, draft } => {+ let (pa, _) = axis_of(frame.n);+ if pa == c {+ // In-plane point; along the column, the prism's own heights.+ let (u, v) = (modeling::dot(point, frame.x), modeling::dot(point, frame.y));+ if let (Some((k, h0)), Some(shape)) = (draft, single_shape(entities)) {+ // How far from the sketch plane this point stays inside.+ let room = match *shape {+ Entity::Circle { cx, cy, r } => r - (u - cx).hypot(v - cy),+ Entity::Rect { x0, y0, x1, y1 } => {+ ((x1 - x0).abs() / 2.0 - (u - (x0 + x1) / 2.0).abs()).min((y1 - y0).abs() / 2.0 - (v - (y0 + y1) / 2.0).abs())+ }+ _ => return Vec::new(),+ };+ if *k > 0.0 {+ if room <= 0.0 {+ return Vec::new();+ }+ let m = room / k;+ return intersect(&[(h0 - m, h0 + m)], *lo, *hi);+ }+ let m = (-room / -k).max(0.0);+ return minus(&[(*lo, *hi)], &[(h0 - m, h0 + m)]);+ }+ let xs = crossings(entities, (u, v), (1.0, 0.0), 0.0);+ let inside = xs.iter().filter(|s| **s > 0.0).count() % 2 == 1;+ if inside { vec![(*lo, *hi)] } else { Vec::new() }+ } else {+ // The column runs IN the sketch plane: inside the prism's+ // heights, the profile sliced by the column's line.+ let h = point[pa];+ if h < *lo || h > *hi {+ return Vec::new();+ }+ let (xa, xs) = axis_of(frame.x);+ let (ya, ys) = axis_of(frame.y);+ let (p, e, sign) = if xa == c {+ ((0.0, ys * point[ya]), (1.0, 0.0), xs)+ } else if ya == c {+ ((xs * point[xa], 0.0), (0.0, 1.0), ys)+ } else {+ return Vec::new();+ };+ let (shrink, ents): (f64, std::borrow::Cow<[Entity]>) = match (draft, single_shape(entities)) {+ (Some((k, h0)), Some(_)) => (k * (h - h0).abs(), std::borrow::Cow::Borrowed(entities.as_slice())),+ _ => (0.0, std::borrow::Cow::Borrowed(entities.as_slice())),+ };+ pairs(&crossings(&ents, p, e, shrink))+ .into_iter()+ .map(|(a, b)| if sign > 0.0 { (a, b) } else { (-b, -a) })+ .collect()+ }+ }+ Kind::Revolve { frame, h, entities, axis } => {+ let Some(((ax, ay), (bx, by))) = entities.get(*axis).and_then(|e| e.ends()) else { return Vec::new() };+ let l = (bx - ax).hypot(by - ay);+ let dir = ((bx - ax) / l, (by - ay) / l);+ let nrm = (-dir.1, dir.0);+ let a3 = frame.point(ax, ay, *h);+ let d3 = modeling::scale(modeling::sub(frame.point(bx, by, *h), a3), 1.0 / l);+ // The column's distance from the axis.+ let mut off = modeling::sub(point, a3);+ off[c] = 0.0;+ let rho = modeling::norm(off);+ // Which side of the axis the profile lies on.+ let side = entities.iter().enumerate().filter(|(i, e)| *i != *axis && !e.is_construction()).map(|(_, e)| {+ let (lo, hi) = crate::script::extent_along(e, nrm.0, nrm.1);+ let o = nrm.0 * ax + nrm.1 * ay;+ (lo - o) + (hi - o)+ }).sum::<f64>().signum();+ let p = (ax + nrm.0 * side * rho, ay + nrm.1 * side * rho);+ let profile: Vec<Entity> = entities.iter().enumerate().filter(|(i, _)| *i != *axis || !entities[*i].is_construction()).map(|(_, e)| e.clone()).collect();+ let base = a3[c];+ let sgn = d3[c].signum();+ pairs(&crossings(&profile, p, dir, 0.0))+ .into_iter()+ .map(|(s0, s1)| {+ let (u0, u1) = (base + sgn * s0, base + sgn * s1);+ (u0.min(u1), u0.max(u1))+ })+ .collect()+ }+ }+}++/// A primitive reflected across the model plane `coord` along `axis`.+pub fn reflect(kind: &Kind, axis: usize, coord: f64) -> Kind {+ let flip = |es: &[Entity], along_u: bool, about: f64| -> Vec<Entity> {+ let m = |x: f64| 2.0 * about - x;+ es.iter()+ .map(|e| match *e {+ Entity::Rect { x0, y0, x1, y1 } => {+ if along_u { Entity::Rect { x0: m(x1), y0, x1: m(x0), y1 } } else { Entity::Rect { x0, y0: m(y1), x1, y1: m(y0) } }+ }+ Entity::Circle { cx, cy, r } => if along_u { Entity::Circle { cx: m(cx), cy, r } } else { Entity::Circle { cx, cy: m(cy), r } },+ Entity::Line { x0, y0, x1, y1, construction } => {+ if along_u { Entity::Line { x0: m(x0), y0, x1: m(x1), y1, construction } } else { Entity::Line { x0, y0: m(y0), x1, y1: m(y1), construction } }+ }+ Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => {+ if along_u {+ Entity::Arc { cx: m(cx), cy, x0: m(x0), y0, x1: m(x1), y1, ccw: !ccw, construction }+ } else {+ Entity::Arc { cx, cy: m(cy), x0, y0: m(y0), x1, y1: m(y1), ccw: !ccw, construction }+ }+ }+ })+ .collect()+ };+ let in_plane = |frame: &PlaneFrame, es: &[Entity]| -> Option<Vec<Entity>> {+ let (xa, xs) = axis_of(frame.x);+ let (ya, ys) = axis_of(frame.y);+ if xa == axis {+ Some(flip(es, true, xs * coord))+ } else if ya == axis {+ Some(flip(es, false, ys * coord))+ } else {+ None+ }+ };+ match kind {+ Kind::Prism { frame, lo, hi, entities, draft } => {+ if axis_of(frame.n).0 == axis {+ Kind::Prism { frame: *frame, lo: 2.0 * coord - hi, hi: 2.0 * coord - lo, entities: entities.clone(), draft: draft.map(|(k, h0)| (k, 2.0 * coord - h0)) }+ } else {+ Kind::Prism { frame: *frame, lo: *lo, hi: *hi, entities: in_plane(frame, entities).unwrap_or_else(|| entities.clone()), draft: *draft }+ }+ }+ Kind::Revolve { frame, h, entities, axis: a } => {+ if axis_of(frame.n).0 == axis {+ Kind::Revolve { frame: *frame, h: 2.0 * coord - h, entities: entities.clone(), axis: *a }+ } else {+ Kind::Revolve { frame: *frame, h: *h, entities: in_plane(frame, entities).unwrap_or_else(|| entities.clone()), axis: *a }+ }+ }+ }+}++/// A primitive moved by `d` along model axis `axis`.+pub fn translate(kind: &Kind, axis: usize, d: f64) -> Kind {+ // A translation is two reflections: about 0, then about d/2.+ reflect(&reflect(kind, axis, 0.0), axis, d / 2.0)+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::script::Entity;++ fn front() -> PlaneFrame {+ PlaneFrame::defaults()[0]+ }++ fn sq(x0: f64, y0: f64, x1: f64, y1: f64) -> Entity {+ Entity::Rect { x0, y0, x1, y1 }+ }++ #[test]+ fn a_partial_cut_is_integrated_where_the_box_model_guesses() {+ // A 40 x 20 x 10 block; a Ø30 circle centred on its right edge cut through.+ let block = Prim { add: true, feature: "B".into(), kind: Kind::Prism { frame: front(), lo: 0.0, hi: 0.010, entities: vec![sq(0.0, 0.0, 0.040, 0.020)], draft: None } };+ let cut = Prim { add: false, feature: "C".into(), kind: Kind::Prism { frame: front(), lo: 0.0, hi: 0.010, entities: vec![Entity::Circle { cx: 0.040, cy: 0.010, r: 0.015 }], draft: None } };+ let (v, _) = integrate(&[block.clone(), cut], ([0.0, 0.0, 0.0], [0.040, 0.020, 0.010]), 800).unwrap();+ // The disc ∩ rectangle: the half disc clipped to |y| ≤ 10 of a r 15 circle.+ let r: f64 = 15.0;+ let seg = |h: f64| r * r * (h / r).asin() + h * (r * r - h * h).sqrt(); // ∫_{-h}^{h} sqrt(r²−y²) dy+ let removed = seg(10.0) * 10.0; // (half of the band area: x from 25 to 40) × depth+ let want = 40.0 * 20.0 * 10.0 - removed;+ assert!((v * 1e9 - want).abs() / want < 1e-6, "{} vs {want}", v * 1e9);+ // The block alone: exact (edges on the grid), centroid at its middle.+ let (v, c) = integrate(&[block], ([0.0, 0.0, 0.0], [0.040, 0.020, 0.010]), 200).unwrap();+ assert!((v * 1e9 - 8000.0).abs() < 1e-6 && (c[0] - 0.020).abs() < 1e-12 && (c[2] - 0.005).abs() < 1e-12);+ }++ #[test]+ fn a_prism_across_the_columns_is_sliced_exactly() {+ // A cylinder along Y (Top Plane) through a block extruded along Z: the+ // columns run along Z, the cylinder is sliced by each one.+ let top = PlaneFrame::defaults()[1];+ let block = Prim { add: true, feature: "B".into(), kind: Kind::Prism { frame: front(), lo: 0.0, hi: 0.060, entities: vec![sq(0.0, 0.0, 0.100, 0.020)], draft: None } };+ let hole = Prim { add: false, feature: "H".into(), kind: Kind::Prism { frame: top, lo: 0.0, hi: 0.020, entities: vec![Entity::Circle { cx: 0.025, cy: -0.030, r: 0.010 }], draft: None } };+ let (v, c) = integrate(&[block, hole], ([0.0, 0.0, 0.0], [0.100, 0.020, 0.060]), 600).unwrap();+ let want = 120000.0 - std::f64::consts::PI * 100.0 * 20.0;+ assert!((v * 1e9 - want).abs() / want < 1e-7, "{} vs {want}", v * 1e9);+ assert!((c[0] * 1e3 - 51.38).abs() < 0.005 && (c[1] * 1e3 - 10.0).abs() < 1e-9 && (c[2] * 1e3 - 30.0).abs() < 0.01, "{c:?}");+ }++ #[test]+ fn interval_sets_union_and_subtract() {+ assert_eq!(union(&[(0.0, 2.0)], &[(1.0, 3.0), (5.0, 6.0)]), vec![(0.0, 3.0), (5.0, 6.0)]);+ assert_eq!(minus(&[(0.0, 10.0)], &[(2.0, 3.0), (5.0, 12.0)]), vec![(0.0, 2.0), (3.0, 5.0)]);+ assert_eq!(intersect(&[(0.0, 10.0)], 4.0, 20.0), vec![(4.0, 10.0)]);+ // A ring: crossings of a line through two concentric circles alternate.+ let ring = [Entity::Circle { cx: 0.0, cy: 0.0, r: 2.0 }, Entity::Circle { cx: 0.0, cy: 0.0, r: 1.0 }];+ assert_eq!(pairs(&crossings(&ring, (-5.0, 0.0), (1.0, 0.0), 0.0)), vec![(3.0, 4.0), (6.0, 7.0)]);+ }+}
src/comhost/mock_model.rs+1011−70@@ -26,7 +26,7 @@ use serde_json::{json, Value}; use std::f64::consts::PI; use crate::modeling::{self, PlaneFrame, V3};-use crate::script::{AppearanceTarget, Axis, EdgeSel, Entity, Extent, Op, PlaneRef};+use crate::script::{AppearanceTarget, Axis, EdgeSel, Entity, Extent, Op, PlaneRef, UpTo}; const TOL: f64 = 1e-6; @@ -40,6 +40,18 @@ pub struct Model { /// Face-level appearances: (a point on the face, rgb). pub face_appearances: Vec<(V3, [i64; 3])>, pub material: Option<String>,+ /// The part's density, kg/m³, when set by set_material (0.11.0): the+ /// library material's, or the document's own (`densityKgM3` alone).+ #[serde(default)]+ pub density: Option<f64>,+ /// THE SOLID AS PRIMITIVES (0.11.0, mock_csg): every feature's prism or+ /// revolve, in order — integrated by columns for the mass of a part the+ /// box model can only approximate.+ #[serde(default)]+ pub csg: Vec<crate::comhost::mock_csg::Prim>,+ /// Why `csg` no longer describes the part (a fillet, a shell…).+ #[serde(default)]+ pub csg_unsupported: Option<String>, /// SKETCHES LEFT IN THE TREE UNCONSUMED (0.10.8): a feature that failed /// (T07's cut that removed nothing) or a script that ended on a sketch /// leaves its sketch behind, unabsorbed. MEASURED: the first@@ -67,6 +79,9 @@ pub struct Body { pub faces: u32, pub shape: Shape, pub appearance: Option<[i64; 3]>,+ /// ∭ p dV (m⁴): the body's first moment, for its centre of mass (0.11.0).+ #[serde(default)]+ pub moment: V3, } #[derive(Clone, Debug, Serialize, Deserialize)]@@ -79,6 +94,9 @@ pub struct Feature { pub lo: Option<V3>, pub hi: Option<V3>, pub body: Option<usize>,+ /// The first moment of the material it added (m⁴), for mirrors and patterns.+ #[serde(default)]+ pub moment: V3, } /// A sketch closed and not yet consumed.@@ -150,84 +168,155 @@ fn overlap_len(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 { // ── profiles ──────────────────────────────────────────────────────────────── -fn bounds(e: &Entity) -> (f64, f64, f64, f64) {- match *e {- Entity::Rect { x0, y0, x1, y1 } => (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)),- Entity::Circle { cx, cy, r } => (cx - r, cy - r, cx + r, cy + r),- }+/// What kind of closed profile: the simulator models drafts and fillets on+/// rectangles and circles only.+#[derive(Clone, Copy, Debug, PartialEq)]+enum ProfKind {+ Rect { w: f64, h: f64 },+ Circle { r: f64 },+ Contour, } -fn area(e: &Entity) -> f64 {- match *e {- Entity::Rect { x0, y0, x1, y1 } => ((x1 - x0) * (y1 - y0)).abs(),- Entity::Circle { r, .. } => PI * r * r,- }+/// One closed profile of a sketch (0.11.0): a rectangle, a circle, or a+/// contour of lines and arcs — its EXACT area and centroid (Green's theorem+/// for contours, `modeling::contour_moments`), its bounds and side count.+#[derive(Clone, Debug)]+struct Prof {+ id: usize,+ area: f64,+ cx: f64,+ cy: f64,+ bounds: (f64, f64, f64, f64),+ sides: u32,+ kind: ProfKind, } -fn sides(e: &Entity) -> u32 {- match e {- Entity::Rect { .. } => 4,- Entity::Circle { .. } => 1,+fn profiles(entities: &[Entity]) -> Vec<Prof> {+ let mut out = Vec::new();+ for e in entities {+ match *e {+ Entity::Rect { x0, y0, x1, y1 } => {+ let b = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));+ out.push(Prof { id: out.len(), area: ((x1 - x0) * (y1 - y0)).abs(), cx: (x0 + x1) / 2.0, cy: (y0 + y1) / 2.0, bounds: b, sides: 4,+ kind: ProfKind::Rect { w: b.2 - b.0, h: b.3 - b.1 } });+ }+ Entity::Circle { cx, cy, r } => {+ out.push(Prof { id: out.len(), area: PI * r * r, cx, cy, bounds: (cx - r, cy - r, cx + r, cy + r), sides: 1, kind: ProfKind::Circle { r } });+ }+ _ => {}+ }+ }+ for c in crate::script::contours(entities).unwrap_or_default() {+ let (a, sx, sy) = modeling::contour_moments(entities, &c);+ let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ for (i, _) in &c {+ let (lo, hi) = crate::script::extent_along(&entities[*i], 1.0, 0.0);+ let (vlo, vhi) = crate::script::extent_along(&entities[*i], 0.0, 1.0);+ x0 = x0.min(lo);+ x1 = x1.max(hi);+ y0 = y0.min(vlo);+ y1 = y1.max(vhi);+ }+ if a.abs() > 1e-30 {+ out.push(Prof { id: out.len(), area: a.abs(), cx: sx / a, cy: sy / a, bounds: (x0, y0, x1, y1), sides: c.len() as u32, kind: ProfKind::Contour });+ } }+ out } -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)+fn inside(a: &Prof, b: &Prof) -> bool {+ let (ax0, ay0, ax1, ay1) = a.bounds;+ let (bx0, by0, bx1, by1) = b.bounds;+ a.id != b.id && ax0 >= bx0 - 1e-12 && ay0 >= by0 - 1e-12 && ax1 <= bx1 + 1e-12 && ay1 <= by1 + 1e-12 && a.area < b.area } -/// The profile's area: entities at an even nesting depth add, odd subtract —+/// The profile's area: profiles 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()+ let ps = profiles(entities);+ ps.iter() .map(|e| {- let depth = entities.iter().filter(|o| inside(e, o)).count();- if depth % 2 == 0 { area(e) } else { -area(e) }+ let depth = ps.iter().filter(|o| inside(e, o)).count();+ if depth % 2 == 0 { e.area } else { -e.area } }) .sum() } -/// A closed region of a sketch: an outer entity and the holes directly in it.-struct Region<'a> {- outer: &'a Entity,- holes: Vec<&'a Entity>,+/// A closed region of a sketch: an outer profile and the holes directly in it.+struct Region {+ outer: Prof,+ holes: Vec<Prof>, } -fn regions(entities: &[Entity]) -> Vec<Region<'_>> {- entities- .iter()- .filter(|e| !entities.iter().any(|o| inside(e, o)))+fn regions(entities: &[Entity]) -> Vec<Region> {+ let ps = profiles(entities);+ ps.iter()+ .filter(|e| ps.iter().filter(|o| inside(e, o)).count() % 2 == 0) .map(|outer| Region {- outer,- holes: entities+ outer: outer.clone(),+ holes: ps .iter()- .filter(|h| inside(h, outer) && entities.iter().filter(|o| inside(h, o)).count() == 1)+ .filter(|h| inside(h, outer) && ps.iter().filter(|o| inside(h, o)).count() == ps.iter().filter(|o| inside(outer, o)).count() + 1)+ .cloned() .collect(), }) .collect() } -impl Region<'_> {+impl Region { fn area(&self) -> f64 {- area(self.outer) - self.holes.iter().map(|h| area(h)).sum::<f64>()+ self.outer.area - self.holes.iter().map(|h| h.area).sum::<f64>()+ }+ /// The region's centroid (u, v): the outer's first moment less the holes'.+ fn centroid(&self) -> (f64, f64) {+ let a = self.area();+ if a <= 0.0 {+ return (self.outer.cx, self.outer.cy);+ }+ let sx = self.outer.area * self.outer.cx - self.holes.iter().map(|h| h.area * h.cx).sum::<f64>();+ let sy = self.outer.area * self.outer.cy - self.holes.iter().map(|h| h.area * h.cy).sum::<f64>();+ (sx / a, sy / a) } fn faces(&self) -> u32 {- 2 + sides(self.outer) + self.holes.iter().map(|h| sides(h)).sum::<u32>()+ 2 + self.outer.sides + self.holes.iter().map(|h| h.sides).sum::<u32>() } fn shape(&self, axis: usize) -> Shape {- match (self.outer, self.holes.is_empty()) {- (Entity::Rect { .. }, true) => Shape::Box,- (Entity::Circle { r, .. }, true) => Shape::Cylinder { axis, r: *r },+ match (self.outer.kind, self.holes.is_empty()) {+ (ProfKind::Rect { .. }, true) => Shape::Box,+ (ProfKind::Circle { r }, true) => Shape::Cylinder { axis, r }, _ => Shape::Other, } }+ /// THE PRISM'S VOLUME over `len`, with a draft of `k` = tan(angle)+ /// (positive inward: the profile shrinks as it goes). Exact for a+ /// rectangle ((w−2kt)(h−2kt)) or a circle (π(r−kt)²) with no holes —+ /// both integrands are quadratic, so Simpson's rule is exact. Returns the+ /// volume and the distance of its centroid from the sketch plane; None for+ /// a drafted profile the simulator does not model.+ fn prism(&self, len: f64, k: f64) -> Option<(f64, f64)> {+ if k == 0.0 {+ return Some((self.area() * len, len / 2.0));+ }+ if !self.holes.is_empty() {+ return None;+ }+ let a = |t: f64| match self.outer.kind {+ ProfKind::Rect { w, h } => ((w - 2.0 * k * t).max(0.0)) * ((h - 2.0 * k * t).max(0.0)),+ ProfKind::Circle { r } => PI * (r - k * t).max(0.0).powi(2),+ ProfKind::Contour => f64::NAN,+ };+ if a(0.0).is_nan() {+ return None;+ }+ let vol = len / 6.0 * (a(0.0) + 4.0 * a(len / 2.0) + a(len));+ let first = len / 6.0 * (0.0 + 4.0 * (len / 2.0) * a(len / 2.0) + len * a(len));+ Some((vol, if vol > 0.0 { first / vol } else { len / 2.0 }))+ } } -fn prism_box(f: &PlaneFrame, e: &Entity, span: (f64, f64)) -> (V3, V3) {- let (u0, v0, u1, v1) = bounds(e);+fn prism_box(f: &PlaneFrame, e: &Prof, span: (f64, f64)) -> (V3, V3) {+ let (u0, v0, u1, v1) = e.bounds; let mut lo = [f64::MAX; 3]; let mut hi = [f64::MIN; 3]; for (u, v) in [(u0, v0), (u0, v1), (u1, v0), (u1, v1)] {@@ -411,6 +500,22 @@ impl Model { let made = self.next_name("Plane"); (defaults[i], *offset, 1.0, json!({"offsetFrom": from, "offsetMm": offset * 1000.0}), Some(made)) }+ PlaneRef::FaceOffset { point, offset } => {+ let f = self.pick_face(*point)?;+ let mut n = [0.0; 3];+ n[f.axis] = f.side as f64;+ let i = modeling::parallel_default(&defaults, n).ok_or("the face is not parallel to a default plane")?;+ let h = self.face_coord(f) + offset * f.side as f64;+ let names = ["Front Plane", "Top Plane", "Right Plane"];+ let made = self.next_name("Plane");+ (+ defaults[i],+ h,+ f.side as f64,+ json!({"offsetFrom": names[i], "offsetMm": h * 1000.0, "fromFace": true, "outward": f.side, "pickedBy": "geometry"}),+ Some(made),+ )+ } PlaneRef::Face { point } => { let f = self.pick_face(*point)?; let i = modeling::parallel_default(&defaults, {@@ -443,25 +548,110 @@ impl Model { Ok((Pending { name, frame, h, sign, entities: entities.to_vec() }, report)) } + /// Where an UP-TO end condition stops, as a coordinate along the sketch's+ /// normal axis (0.11.0). The simulator models faces parallel to the sketch+ /// plane only.+ fn up_to_target(&self, p: &Pending, regs: &[Region], axis: usize, own: f64, u: &UpTo) -> Result<f64, String> {+ let null = |why: &str| format!("SOLIDWORKS made no feature (it came back null): {why}");+ let target = match u {+ UpTo::Next => {+ let others: Vec<usize> = (0..3).filter(|i| *i != axis).collect();+ let mut best: Option<f64> = None;+ for r in regs {+ let (lo, hi) = prism_box(&p.frame, &r.outer, (p.h, p.h));+ for b in &self.bodies {+ let overlaps = others.iter().all(|i| overlap_len(lo[*i], hi[*i], b.lo[*i], b.hi[*i]) > 1e-12);+ if !overlaps {+ continue;+ }+ for c in [b.lo[axis], b.hi[axis]] {+ let ahead = (c - p.h) * own;+ if ahead > 1e-9 && best.map(|x| ahead < (x - p.h) * own).unwrap_or(true) {+ best = Some(c);+ }+ }+ }+ }+ best.ok_or_else(|| null("there is no next face in that direction (up to next)"))?+ }+ UpTo::Surface { point } | UpTo::OffsetFrom { point, .. } => {+ let f = self.pick_face(*point)?;+ if f.axis != axis {+ return Err("the simulator models an up-to face PARALLEL to the sketch plane only (SOLIDWORKS takes any face)".into());+ }+ let c = self.face_coord(f);+ match u {+ UpTo::OffsetFrom { offset, reverse_offset, .. } => c - own * offset * if *reverse_offset { -1.0 } else { 1.0 },+ _ => c,+ }+ }+ };+ if (target - p.h) * own <= 1e-9 {+ return Err(null("the face is BEHIND the sketch for this direction — a boss goes along the plane's normal (outward from a face), a cut against it; pass reverse:true"));+ }+ Ok(target)+ }+ /// An extrude or a cut of the pending sketch. pub fn extrude(&mut self, p: &Pending, ext: &Extent, cut: bool) -> Result<Value, String> { let axis = normal_axis(&p.frame); // The op's own direction along the frame's normal: a boss along the // reference, a cut against it; reverse flips either. let own = p.sign * if cut { -1.0 } else { 1.0 } * if ext.reverse { -1.0 } else { 1.0 };- let span = modeling::extent_span(p.h, own, ext, self.through(axis)); let kind = if cut { "Cut-Extrude" } else { "Boss-Extrude" }; let regs = regions(&p.entities);+ // An UP-TO end condition is a blind depth once its face is found.+ let mut eff = ext.clone();+ let mut ends_at: Option<f64> = None;+ if let Some(u) = &ext.up_to {+ let t = self.up_to_target(p, ®s, axis, own, u)?;+ eff = Extent { depth: (t - p.h) * own, up_to: None, ..Extent::blind(0.0) };+ eff.draft = ext.draft;+ ends_at = Some(t);+ }+ let span = modeling::extent_span(p.h, own, &eff, self.through(axis)); let len = span.1 - span.0;+ // A DRAFT tapers from the sketch plane outward on each side it goes.+ let k = ext.draft.map(|d| d.angle.tan() * if d.outward { -1.0 } else { 1.0 }).unwrap_or(0.0);+ let sides = [(p.h - span.0, -1.0), (span.1 - p.h, 1.0)];+ let csg_draft = (k != 0.0).then_some((k, p.h));+ let mut draft_modelled = true;+ let solid = |r: &Region, draft_modelled: &mut bool| -> (f64, f64) {+ let mut vol = 0.0;+ let mut first = 0.0;+ for (l, sgn) in sides {+ if l <= 1e-15 {+ continue;+ }+ let (v, c) = r.prism(l, k).unwrap_or_else(|| {+ *draft_modelled = false;+ (r.area() * l, l / 2.0)+ });+ vol += v;+ first += v * (p.h + sgn * c);+ }+ (vol, if vol > 0.0 { first / vol } else { (span.0 + span.1) / 2.0 })+ }; let mut delta = 0.0; let mut flo = [f64::MAX; 3]; let mut fhi = [f64::MIN; 3]; let mut body_of: Option<usize> = None;+ let mut fmoment = [0.0; 3];+ let report = |name: &str, change: f64, draft_modelled: bool| {+ json!({"feature": name, "spanMm": [span.0 * 1000.0, span.1 * 1000.0], "volumeChangeMm3": change * 1.0e9,+ "depthMm": eff.depth * 1000.0, "throughAll": ext.through_all, "reverse": ext.reverse,+ "endsAtMm": ends_at.map(|t| t * 1000.0),+ "draftDeg": ext.draft.map(|d| d.angle.to_degrees()),+ "simulated": if draft_modelled { "closed-form" } else { "the draft is not modelled for this profile: volume without it" }})+ }; if !cut { let name = self.next_name(kind);- for (k, r) in regs.iter().enumerate() {- let (lo, hi) = prism_box(&p.frame, r.outer, span);- let vol = r.area() * len;+ for (k_reg, r) in regs.iter().enumerate() {+ let (lo, hi) = prism_box(&p.frame, &r.outer, span);+ let (vol, hc) = solid(r, &mut draft_modelled);+ let (cu, cv) = r.centroid();+ let moment = modeling::scale(p.frame.point(cu, cv, hc), vol);+ fmoment = modeling::add(fmoment, moment); delta += vol; for i in 0..3 { flo[i] = flo[i].min(lo[i]);@@ -470,13 +660,14 @@ impl Model { let hits: Vec<usize> = (0..self.bodies.len()).filter(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).collect(); if hits.is_empty() { self.bodies.push(Body {- name: if k == 0 { name.clone() } else { format!("{name}[{}]", k + 1) },+ name: if k_reg == 0 { name.clone() } else { format!("{name}[{}]", k_reg + 1) }, lo, hi, volume: vol, faces: r.faces(),- shape: r.shape(axis),+ shape: if k == 0.0 { r.shape(axis) } else { Shape::Other }, appearance: None,+ moment, }); body_of.get_or_insert(self.bodies.len() - 1); } else {@@ -490,6 +681,7 @@ impl Model { } b.volume += o.volume; b.faces += o.faces;+ b.moment = modeling::add(b.moment, o.moment); } let b = &mut self.bodies[keep]; for i in 0..3 {@@ -497,33 +689,45 @@ impl Model { b.hi[i] = b.hi[i].max(hi[i]); } b.volume += vol;+ b.moment = modeling::add(b.moment, moment); b.faces += r.faces() - 1; b.shape = Shape::Other; body_of.get_or_insert(keep); } }- self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: delta, lo: Some(flo), hi: Some(fhi), body: body_of });- return Ok(json!({"feature": name, "spanMm": [span.0 * 1000.0, span.1 * 1000.0], "volumeChangeMm3": delta * 1.0e9,- "depthMm": ext.depth * 1000.0, "throughAll": ext.through_all, "reverse": ext.reverse}));+ self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: delta, lo: Some(flo), hi: Some(fhi), body: body_of, moment: fmoment });+ self.csg_push(true, &name, crate::comhost::mock_csg::Kind::Prism { frame: p.frame, lo: span.0, hi: span.1, entities: p.entities.clone(), draft: csg_draft });+ return Ok(report(&name, delta, draft_modelled)); }- // A CUT removes only what lies inside a body.+ // A CUT removes only what lies inside a body (exact when the cut lies+ // wholly inside the material; a partial overlap is scaled by the+ // overlap of the boxes). let mut removed = 0.0;+ let mut cut_moment = [0.0; 3]; let mut touched: Vec<usize> = Vec::new(); for r in ®s {- let (lo, hi) = prism_box(&p.frame, r.outer, span);- let (u0, v0, u1, v1) = bounds(r.outer);+ let (lo, hi) = prism_box(&p.frame, &r.outer, span);+ let (u0, v0, u1, v1) = r.outer.bounds; let reg_bounds_area = ((u1 - u0) * (v1 - v0)).max(1e-30);+ let (vol, hc) = solid(r, &mut draft_modelled);+ let (cu, cv) = r.centroid(); for (bi, b) in self.bodies.iter_mut().enumerate() { let along = overlap_len(lo[axis], hi[axis], b.lo[axis], b.hi[axis]); let others: Vec<usize> = (0..3).filter(|i| *i != axis).collect(); let inplane = overlap_len(lo[others[0]], hi[others[0]], b.lo[others[0]], b.hi[others[0]]) * overlap_len(lo[others[1]], hi[others[1]], b.lo[others[1]], b.hi[others[1]]);- let take = r.area() * (inplane / reg_bounds_area).min(1.0) * along;+ let take = vol * (inplane / reg_bounds_area).min(1.0) * if len > 0.0 { (along / len).min(1.0) } else { 0.0 }; if take > 1e-18 {+ let whole = along >= len - 1e-12;+ let h_mid = if whole { hc } else { (lo[axis].max(b.lo[axis]) + hi[axis].min(b.hi[axis])) / 2.0 };+ let mut c = p.frame.point(cu, cv, 0.0);+ c[axis] = h_mid; b.volume = (b.volume - take).max(0.0);+ b.moment = modeling::sub(b.moment, modeling::scale(c, take)); b.faces += r.faces() - 1; b.shape = Shape::Other; removed += take;+ cut_moment = modeling::sub(cut_moment, modeling::scale(c, take)); touched.push(bi); } }@@ -537,9 +741,9 @@ impl Model { ); } let name = self.next_name(kind);- self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: -removed, lo: None, hi: None, body: touched.first().copied() });- Ok(json!({"feature": name, "spanMm": [span.0 * 1000.0, span.1 * 1000.0], "volumeChangeMm3": -removed * 1.0e9,- "depthMm": ext.depth * 1000.0, "throughAll": ext.through_all, "reverse": ext.reverse}))+ self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: -removed, lo: None, hi: None, body: touched.first().copied(), moment: cut_moment });+ self.csg_push(false, &name, crate::comhost::mock_csg::Kind::Prism { frame: p.frame, lo: span.0, hi: span.1, entities: p.entities.clone(), draft: csg_draft });+ Ok(report(&name, -removed, draft_modelled)) } /// The edges a fillet / chamfer selects — de-duplicated, then selected with@@ -632,7 +836,8 @@ impl Model { bm.faces += edges.len() as u32; bm.shape = Shape::Other; }- self.features.push(Feature { name: name.clone(), kind: if fillet { "Fillet".into() } else { "Chamfer".into() }, volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body) });+ self.features.push(Feature { name: name.clone(), kind: if fillet { "Fillet".into() } else { "Chamfer".into() }, volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body), moment: [0.0; 3] });+ self.csg_decline(if fillet { "a fillet" } else { "a chamfer" }); Ok(json!({"feature": name, "edgesSelected": edges.len(), "volumeChangeMm3": delta.map(|d| d * 1.0e9), "simulated": if delta.is_some() { "closed-form" } else { "not modelled for this shape: volume unchanged" }})) }@@ -657,14 +862,23 @@ impl Model { bm.volume += d; bm.shape = Shape::Other; }- self.features.push(Feature { name: name.clone(), kind: "Shell".into(), volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body) });+ self.csg_decline("a shell");+ self.features.push(Feature { name: name.clone(), kind: "Shell".into(), volume: delta.unwrap_or(0.0), lo: None, hi: None, body: Some(body), moment: [0.0; 3] }); Ok(json!({"feature": name, "thicknessMm": thickness * 1000.0, "facesRemoved": faces.len(), "outward": outward, "simulated": if delta.is_some() { "closed-form" } else { "not modelled for this shape" }})) } fn add_copies(&mut self, seed: &Feature, boxes: Vec<(V3, V3)>) { let body = seed.body.unwrap_or(0);+ // Each copy's centroid: the seed's, moved as its box moved.+ let mid = |lo: V3, hi: V3| modeling::scale(modeling::add(lo, hi), 0.5);+ let seed_mid = match (seed.lo, seed.hi) {+ (Some(l), Some(h)) => mid(l, h),+ _ => [0.0; 3],+ };+ let seed_c = if seed.volume.abs() > 0.0 && seed.moment != [0.0; 3] { modeling::scale(seed.moment, 1.0 / seed.volume) } else { seed_mid }; for (lo, hi) in boxes {+ let c = modeling::add(seed_c, modeling::sub(mid(lo, hi), seed_mid)); let target = (0..self.bodies.len()).find(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).unwrap_or(body); if let Some(b) = self.bodies.get_mut(target) { for i in 0..3 {@@ -672,6 +886,7 @@ impl Model { b.hi[i] = b.hi[i].max(hi[i]); } b.volume += seed.volume;+ b.moment = modeling::add(b.moment, modeling::scale(c, seed.volume)); b.shape = Shape::Other; } }@@ -700,7 +915,21 @@ impl Model { let n = boxes.len() as u32 + 1; self.add_copies(&seed, boxes); let name = self.next_name("LPattern");- self.features.push(Feature { name: name.clone(), kind: "LPattern".into(), volume: seed.volume * (n as f64 - 1.0), lo: None, hi: None, body: seed.body });+ // The seed's primitives, translated to every instance.+ let seeds: Vec<crate::comhost::mock_csg::Prim> = self.csg.iter().filter(|p| p.feature == seed_name).cloned().collect();+ for i in 0..n1 {+ for j in 0..n2 {+ if i == 0 && j == 0 {+ continue;+ }+ for sp in &seeds {+ let k1 = crate::comhost::mock_csg::translate(&sp.kind, axis_index(a1), i as f64 * s1);+ let k2 = crate::comhost::mock_csg::translate(&k1, axis_index(a2), j as f64 * s2);+ self.csg.push(crate::comhost::mock_csg::Prim { add: sp.add, feature: seed_name.to_string(), kind: k2 });+ }+ }+ }+ self.features.push(Feature { name: name.clone(), kind: "LPattern".into(), volume: seed.volume * (n as f64 - 1.0), lo: None, hi: None, body: seed.body, moment: [0.0; 3] }); Ok(json!({"feature": name, "seed": seed_name, "instances": n})) } @@ -740,7 +969,8 @@ impl Model { } self.add_copies(&seed, boxes); let name = self.next_name("CirPattern");- self.features.push(Feature { name: name.clone(), kind: "CirPattern".into(), volume: seed.volume * (count as f64 - 1.0), lo: None, hi: None, body: seed.body });+ self.csg_decline("a circular pattern");+ self.features.push(Feature { name: name.clone(), kind: "CirPattern".into(), volume: seed.volume * (count as f64 - 1.0), lo: None, hi: None, body: seed.body, moment: [0.0; 3] }); Ok(json!({"feature": name, "seed": seed_name, "instances": count, "angleDeg": angle.to_degrees()})) } @@ -774,9 +1004,288 @@ impl Model { Ok(json!({"rgb": c, "route": route, "readBack": c})) } + fn csg_push(&mut self, add: bool, feature: &str, kind: crate::comhost::mock_csg::Kind) {+ if let Err(why) = crate::comhost::mock_csg::supported(&kind) {+ self.csg_unsupported.get_or_insert(why);+ }+ self.csg.push(crate::comhost::mock_csg::Prim { add, feature: feature.to_string(), kind });+ }++ /// A body mirror whose history has cuts: appended in order, the copy's cuts+ /// would cut the original too — the column model declines rather than lie.+ fn csg_decline_if_cuts_mirrored_as_body(&mut self) {+ if self.csg.iter().any(|p| !p.add) {+ self.csg_decline("a body mirror of a body with cuts");+ }+ }++ fn csg_decline(&mut self, why: &str) {+ self.csg_unsupported.get_or_insert_with(|| why.to_string());+ }++ /// The part's volume (m³) and centre of mass by COLUMN INTEGRATION of its+ /// primitives, or why that cannot be done for this part.+ pub fn csg_mass(&self) -> Result<(f64, V3), String> {+ if let Some(why) = &self.csg_unsupported {+ return Err(why.clone());+ }+ let mut lo = [f64::MAX; 3];+ let mut hi = [f64::MIN; 3];+ for b in &self.bodies {+ for i in 0..3 {+ lo[i] = lo[i].min(b.lo[i]);+ hi[i] = hi[i].max(b.hi[i]);+ }+ }+ if self.bodies.is_empty() {+ return Err("no bodies".into());+ }+ crate::comhost::mock_csg::integrate(&self.csg, (lo, hi), 700)+ }++ /// Material DEPOSITED by a boss-like feature: merged into every body its+ /// box touches, or a new body. Returns the body's index.+ #[allow(clippy::too_many_arguments)]+ fn deposit(&mut self, name: &str, lo: V3, hi: V3, vol: f64, moment: V3, faces: u32, shape: Shape) -> usize {+ let hits: Vec<usize> = (0..self.bodies.len()).filter(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi)).collect();+ if hits.is_empty() {+ self.bodies.push(Body { name: name.to_string(), lo, hi, volume: vol, faces, shape, appearance: None, moment });+ return self.bodies.len() - 1;+ }+ let keep = hits[0];+ for other in hits.iter().skip(1).rev() {+ let o = self.bodies.remove(*other);+ let b = &mut self.bodies[keep];+ for i in 0..3 {+ b.lo[i] = b.lo[i].min(o.lo[i]);+ b.hi[i] = b.hi[i].max(o.hi[i]);+ }+ b.volume += o.volume;+ b.faces += o.faces;+ b.moment = modeling::add(b.moment, o.moment);+ }+ let b = &mut self.bodies[keep];+ for i in 0..3 {+ b.lo[i] = b.lo[i].min(lo[i]);+ b.hi[i] = b.hi[i].max(hi[i]);+ }+ b.volume += vol;+ b.moment = modeling::add(b.moment, moment);+ b.faces += faces;+ b.shape = Shape::Other;+ keep+ }++ /// A REVOLVE (0.11.0) of the pending sketch about its axis line, by+ /// PAPPUS: volume = Σ (region area × its centroid's distance from the+ /// axis) × angle — EXACT for any profile on one side of the axis (the+ /// parser refused any other). The centre of mass is exact for a full+ /// revolution of a profile symmetric along the axis; the bounding box is+ /// the full revolution's.+ pub fn revolve(&mut self, p: &Pending, rv: &crate::script::Revolution, cut: bool) -> Result<Value, String> {+ let ((ax, ay), (bx, by)) = p.entities.get(rv.axis).and_then(|e| e.ends()).ok_or("the revolve axis is not a line of the sketch")?;+ let l = (bx - ax).hypot(by - ay);+ let (dx, dy) = ((bx - ax) / l, (by - ay) / l);+ let (nx, ny) = (-dy, dx);+ let regs = regions(&p.entities);+ let a3 = p.frame.point(ax, ay, p.h);+ let d3 = modeling::sub(p.frame.point(bx, by, p.h), a3);+ let d3 = modeling::scale(d3, 1.0 / modeling::norm(d3));+ let mut vol = 0.0;+ let mut moment = [0.0; 3];+ let (mut t0, mut t1, mut rmax) = (f64::MAX, f64::MIN, 0.0f64);+ for r in ®s {+ let (cu, cv) = r.centroid();+ let d = ((cu - ax) * nx + (cv - ay) * ny).abs();+ let v = r.area() * d * rv.angle;+ vol += v;+ let t = (cu - ax) * dx + (cv - ay) * dy;+ moment = modeling::add(moment, modeling::scale(modeling::add(a3, modeling::scale(d3, t)), v));+ let (u0, v0, u1, v1) = r.outer.bounds;+ for (u, w) in [(u0, v0), (u0, v1), (u1, v0), (u1, v1)] {+ let t = (u - ax) * dx + (w - ay) * dy;+ t0 = t0.min(t);+ t1 = t1.max(t);+ rmax = rmax.max(((u - ax) * nx + (w - ay) * ny).abs());+ }+ }+ if vol <= 1e-18 {+ return Err("SOLIDWORKS made no revolve (the feature came back null) — the profile has no area away from the axis".into());+ }+ let mut lo = [0.0; 3];+ let mut hi = [0.0; 3];+ for i in 0..3 {+ let (e0, e1) = (a3[i] + d3[i] * t0, a3[i] + d3[i] * t1);+ let rad = rmax * (1.0 - d3[i] * d3[i]).max(0.0).sqrt();+ lo[i] = e0.min(e1) - rad;+ hi[i] = e0.max(e1) + rad;+ }+ let full = rv.angle >= 2.0 * PI - 1e-9;+ let faces: u32 = regs.iter().map(|r| r.faces() - 2 + if full { 0 } else { 2 }).sum();+ let kind = if cut { "Cut-Revolve" } else { "Revolve" };+ let name = self.next_name(kind);+ let note = if full { "Pappus (exact volume)" } else { "Pappus (exact volume; the box is the full revolution's)" };+ if !cut {+ let body = self.deposit(&name, lo, hi, vol, moment, faces, Shape::Other);+ if full {+ self.csg_push(true, &name, crate::comhost::mock_csg::Kind::Revolve { frame: p.frame, h: p.h, entities: p.entities.clone(), axis: rv.axis });+ } else {+ self.csg_decline("a revolve through less than 360°");+ }+ self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: vol, lo: Some(lo), hi: Some(hi), body: Some(body), moment });+ return Ok(json!({"feature": name, "volumeChangeMm3": vol * 1.0e9, "angleDeg": rv.angle.to_degrees(), "simulated": note}));+ }+ let target = (0..self.bodies.len()).find(|i| touches(lo, hi, self.bodies[*i].lo, self.bodies[*i].hi))+ .ok_or("SOLIDWORKS made no cut (the feature came back null) — the revolved profile touches no material")?;+ if full {+ self.csg_push(false, &name, crate::comhost::mock_csg::Kind::Revolve { frame: p.frame, h: p.h, entities: p.entities.clone(), axis: rv.axis });+ } else {+ self.csg_decline("a revolve-cut through less than 360°");+ }+ let b = &mut self.bodies[target];+ b.volume = (b.volume - vol).max(0.0);+ b.moment = modeling::sub(b.moment, moment);+ b.faces += faces;+ b.shape = Shape::Other;+ self.features.push(Feature { name: name.clone(), kind: kind.into(), volume: -vol, lo: None, hi: None, body: Some(target), moment: modeling::scale(moment, -1.0) });+ Ok(json!({"feature": name, "volumeChangeMm3": -vol * 1.0e9, "angleDeg": rv.angle.to_degrees(),+ "simulated": format!("{note}; exact when the cut lies wholly inside the material")}))+ }++ /// The mirror plane, as (model axis, coordinate) — default planes, offsets+ /// of them, and planar faces parallel to them.+ fn mirror_plane(&mut self, plane: &PlaneRef) -> Result<(usize, f64, Value), String> {+ let defaults = PlaneFrame::defaults();+ match plane {+ PlaneRef::Named(n) => {+ let i = modeling::default_index(n).ok_or_else(|| format!("no plane named '{n}' in this document"))?;+ Ok((normal_axis(&defaults[i]), 0.0, json!(n)))+ }+ PlaneRef::Offset { from, offset } => {+ let i = modeling::default_index(from).ok_or_else(|| format!("no plane named '{from}' to offset from"))?;+ let made = self.next_name("Plane");+ Ok((normal_axis(&defaults[i]), *offset, json!({"offsetFrom": from, "offsetMm": offset * 1000.0, "createdPlane": made})))+ }+ PlaneRef::Face { point } => {+ let f = self.pick_face(*point)?;+ Ok((f.axis, self.face_coord(f), json!({"face": {"atPointMm": [point[0] * 1000.0, point[1] * 1000.0, point[2] * 1000.0]}, "pickedBy": "geometry"})))+ }+ PlaneRef::FaceOffset { point, offset } => {+ let f = self.pick_face(*point)?;+ let made = self.next_name("Plane");+ Ok((f.axis, self.face_coord(f) + offset * f.side as f64, json!({"fromFace": true, "offsetMm": offset * 1000.0, "createdPlane": made})))+ }+ }+ }++ /// A MIRROR (0.11.0): the features' material (or every body) reflected+ /// across a plane and merged. Exact when the copy does not overlap the+ /// original; an overlap is estimated from the boxes (and said so).+ pub fn mirror(&mut self, features: &[String], bodies: bool, plane: &PlaneRef, last: &Option<String>) -> Result<Value, String> {+ let (ax, c, how) = self.mirror_plane(plane)?;+ let refl = |p: V3| {+ let mut q = p;+ q[ax] = 2.0 * c - p[ax];+ q+ };+ let refl_moment = |m: V3, v: f64| {+ let mut q = m;+ q[ax] = 2.0 * c * v - m[ax];+ q+ };+ let box_vol = |lo: V3, hi: V3| (0..3).map(|i| (hi[i] - lo[i]).max(0.0)).product::<f64>();+ let overlap_vol = |alo: V3, ahi: V3, blo: V3, bhi: V3| (0..3).map(|i| overlap_len(alo[i], ahi[i], blo[i], bhi[i])).product::<f64>();+ let mut added = 0.0;+ let mut exact = true;+ let name = self.next_name(if bodies { "Mirror-Body" } else { "Mirror" });+ if bodies {+ let originals = self.bodies.clone();+ for (bi, b) in originals.iter().enumerate() {+ let (p0, p1) = (refl(b.lo), refl(b.hi));+ let (lo, hi) = ([p0[0].min(p1[0]), p0[1].min(p1[1]), p0[2].min(p1[2])], [p0[0].max(p1[0]), p0[1].max(p1[1]), p0[2].max(p1[2])]);+ let ov = overlap_vol(lo, hi, b.lo, b.hi);+ let share = if ov > 1e-18 { exact = false; b.volume * ov / box_vol(b.lo, b.hi).max(1e-30) } else { 0.0 };+ let v = b.volume - share;+ added += v;+ let m = modeling::scale(refl_moment(b.moment, b.volume), v / b.volume.max(1e-30));+ let t = &mut self.bodies[bi];+ for i in 0..3 {+ t.lo[i] = t.lo[i].min(lo[i]);+ t.hi[i] = t.hi[i].max(hi[i]);+ }+ t.volume += v;+ t.moment = modeling::add(t.moment, m);+ t.faces = t.faces * 2;+ t.shape = Shape::Other;+ }+ } else {+ for f in features {+ let fname = if f == "last" { last.clone().ok_or("\"last\" names no feature")? } else { f.clone() };+ let seed = self.feature(&fname).cloned().ok_or_else(|| format!("no feature named '{fname}' to mirror"))?;+ let body = seed.body.ok_or_else(|| format!("feature '{fname}' changed no body"))?;+ match (seed.lo, seed.hi) {+ (Some(slo), Some(shi)) if seed.volume > 0.0 => {+ let (p0, p1) = (refl(slo), refl(shi));+ let lo = [p0[0].min(p1[0]), p0[1].min(p1[1]), p0[2].min(p1[2])];+ let hi = [p0[0].max(p1[0]), p0[1].max(p1[1]), p0[2].max(p1[2])];+ let ov = overlap_vol(lo, hi, slo, shi);+ let share = if ov > 1e-18 { exact = false; seed.volume * ov / box_vol(slo, shi).max(1e-30) } else { 0.0 };+ let v = seed.volume - share;+ let m = modeling::scale(refl_moment(seed.moment, seed.volume), v / seed.volume);+ self.deposit(&name, lo, hi, v, m, 0, Shape::Other);+ added += v;+ }+ _ => {+ // A CUT mirrored: the same volume removed from the body+ // (assumed wholly inside the material, not overlapping).+ let v = seed.volume;+ let b = self.bodies.get_mut(body).ok_or("the mirrored feature's body is gone")?;+ b.volume = (b.volume + v).max(0.0);+ b.moment = modeling::add(b.moment, refl_moment(seed.moment, -v));+ b.shape = Shape::Other;+ added += v;+ }+ }+ }+ }+ // The primitives, reflected: every one (bodies) or the features' own.+ let copies: Vec<crate::comhost::mock_csg::Prim> = self+ .csg+ .iter()+ .filter(|p| bodies || features.iter().any(|f| {+ let f = if f == "last" { last.clone().unwrap_or_default() } else { f.clone() };+ p.feature == f+ }))+ .map(|p| crate::comhost::mock_csg::Prim { add: p.add, feature: name.clone(), kind: crate::comhost::mock_csg::reflect(&p.kind, ax, c) })+ .collect();+ if bodies {+ // A mirrored BODY is the body's whole history reflected and UNIONED:+ // its cuts must not remove from the original.+ self.csg_decline_if_cuts_mirrored_as_body();+ self.csg.extend(copies);+ } else {+ self.csg.extend(copies);+ }+ let body = self.bodies.iter().position(|_| true);+ self.features.push(Feature { name: name.clone(), kind: "Mirror".into(), volume: added, lo: None, hi: None, body, moment: [0.0; 3] });+ Ok(json!({"feature": name, "plane": how, "volumeChangeMm3": added * 1.0e9, "mirrorBody": bodies,+ "simulated": if exact { "exact (the copy does not overlap the original)" } else { "the copy overlaps the original: overlap estimated from boxes" }}))+ }++ /// The CENTRE OF MASS (uniform density): Σ body moments / Σ volumes.+ pub fn center_of_mass(&self) -> Option<V3> {+ let v = self.volume();+ if v <= 0.0 {+ return None;+ }+ let m = self.bodies.iter().fold([0.0; 3], |a, b| modeling::add(a, b.moment));+ Some(modeling::scale(m, 1.0 / v))+ }+ /// A material: and, as SOLIDWORKS does, the part's (and bodies') own /// appearance is GONE afterwards. The caller re-applies it. pub fn set_material(&mut self, name: &str) {+ self.density = library_density(name); self.material = Some(name.to_string()); self.appearance = None; for b in &mut self.bodies {@@ -793,6 +1302,8 @@ impl Model { self.bodies.clear(); self.features.clear(); self.face_appearances.clear();+ self.csg.clear();+ self.csg_unsupported = None; } /// The top-level tree after the origin, as clear_part walks it.@@ -814,7 +1325,12 @@ impl Model { "volumeMm3": (b.volume * 1.0e9 * 1000.0).round() / 1000.0, "areaMm2": (2.0 * (size[0] * size[1] + size[1] * size[2] + size[0] * size[2]) * 1.0e6 * 1000.0).round() / 1000.0, "massKg": Value::Null,- "centerOfMassMm": [mm((b.lo[0] + b.hi[0]) / 2.0), mm((b.lo[1] + b.hi[1]) / 2.0), mm((b.lo[2] + b.hi[2]) / 2.0)],+ "centerOfMassMm": if b.volume > 0.0 && b.moment != [0.0; 3] {+ let c = modeling::scale(b.moment, 1.0 / b.volume);+ [mm(c[0]), mm(c[1]), mm(c[2])]+ } else {+ [mm((b.lo[0] + b.hi[0]) / 2.0), mm((b.lo[1] + b.hi[1]) / 2.0), mm((b.lo[2] + b.hi[2]) / 2.0)]+ }, "faceCount": b.faces, "visible": true, "isSolid": true,@@ -830,6 +1346,32 @@ impl Model { } } +/// The simulated material library (kg/m³) — SOLIDWORKS' own values for the+/// materials the tests and the CSWA specs name.+pub fn library_density(name: &str) -> Option<f64> {+ let m = name.to_ascii_lowercase();+ let table: &[(&str, f64)] = &[+ ("abs pc", 1070.0),+ ("abs", 1020.0),+ ("6061", 2700.0),+ ("1060", 2700.0),+ ("aisi 1020", 7900.0),+ ("aisi 304", 8000.0),+ ("alloy steel", 7700.0),+ ("cast alloy steel", 7300.0),+ ("plain carbon steel", 7800.0),+ ("cast carbon steel", 7800.0),+ ("gray cast iron", 7200.0),+ ("copper", 8900.0),+ ("brass", 8500.0),+ ("nylon 6/10", 1400.0),+ ("pvc", 1300.0),+ ("steel", 7800.0),+ ];+ // The longest name that matches wins ("cast alloy steel" over "alloy steel").+ table.iter().filter(|(k, _)| m.contains(k)).max_by_key(|(k, _)| k.len()).map(|(_, d)| *d)+}+ /// Run one geometry op of a script on the model. `active` is whether the /// target is SOLIDWORKS' active document (selection works only there); /// `dedupe` and `reselect` exist so tests can show what the bridge's own@@ -837,12 +1379,14 @@ impl Model { pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result<Value, String> { let needs_selection = matches!( op,- Op::Sketch { plane: PlaneRef::Face { .. }, .. }+ Op::Sketch { plane: PlaneRef::Face { .. } | PlaneRef::FaceOffset { .. }, .. } | Op::Fillet { .. } | Op::Chamfer { .. } | Op::Shell { .. } | Op::LinearPattern { .. } | Op::CircularPattern { .. }+ | Op::Mirror { .. }+ | Op::Hole { .. } | Op::SetAppearance { target: AppearanceTarget::FaceAt(_), .. } ); if needs_selection && !active {@@ -882,6 +1426,28 @@ pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result< r["sketch"] = json!(p.name); r }+ Op::Revolve(rv) | Op::RevolveCut(rv) => {+ let p = run.pending.take().ok_or("no sketch to consume")?;+ if !run.sketch_selected {+ return Err(format!("the sketch '{}' was no longer selected — SOLIDWORKS returned no feature", p.name));+ }+ run.sketch_selected = false;+ let made = model.revolve(&p, rv, matches!(op, Op::RevolveCut(_)));+ if made.is_err() {+ model.loose_sketches.push(p.name.clone());+ }+ let mut r = made?;+ r["sketch"] = json!(p.name);+ r["route"] = json!("FeatureRevolve2");+ r+ }+ Op::Mirror { features, bodies, plane } => {+ run.sketch_selected = false;+ let mut r = model.mirror(features, *bodies, plane, &run.last_feature)?;+ r["route"] = json!("InsertMirrorFeature2");+ r+ }+ Op::Hole { at, diameter, depth, counterbore, countersink } => hole(model, run, *at, *diameter, *depth, *counterbore, *countersink, active)?, Op::Fillet { radius, edges } => { run.sketch_selected = false; let (sel, mut how) = model.select_edges(edges, &run.last_feature, true)?;@@ -940,15 +1506,35 @@ pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result< run.appearances.push((target.clone(), *rgb)); r }- Op::SetMaterial { name, .. } => {- model.set_material(name);+ Op::SetMaterial { name, density, .. } => {+ // THE SAME RULES as the COM host: a library material's density is+ // the material's; a density alone is the document's own, and only+ // where no library material is applied.+ if !name.is_empty() {+ let lib = library_density(name);+ if let (Some(want), Some(have)) = (density, lib) {+ if (want - have).abs() > 0.005 * have {+ return Err(format!(+ "the material '{name}' has a density of {have} kg/m³, not the {want} asked for — apply the material whose density \+ matches, or give densityKgM3 alone (no name) to set the part's own density"+ ));+ }+ }+ model.set_material(name);+ } else if let Some(m) = model.material.clone().filter(|m| !m.is_empty()) {+ return Err(format!("the library material '{m}' is applied, and its density wins over the document's — densityKgM3 alone cannot change it"));+ }+ if let (true, Some(d)) = (name.is_empty(), density) {+ model.density = Some(*d);+ } // RE-APPLY every appearance this script set: a material wipes it. let mut again = Vec::new(); for (t, rgb) in run.appearances.clone() { model.set_appearance(&t, &rgb)?; again.push(json!({"target": format!("{t:?}"), "rgb": rgb255(&rgb)})); }- json!({"material": name, "appearanceReapplied": again})+ json!({"material": if name.is_empty() { Value::Null } else { json!(name) }, "appearanceReapplied": again,+ "densityKgM3": model.density, "densityRequested": density}) } _ => return Ok(Value::Null), };@@ -960,13 +1546,47 @@ pub fn apply(model: &mut Model, run: &mut Run, op: &Op, active: bool) -> Result< Ok(out) } +/// A HOLE (0.11.0) as the COM host builds it: the face at `at` is picked+/// geometrically, its plane resolved to a default plane at a height, and+/// circles are sketched on that plane (an offset plane, which survives the+/// cuts) and cut INTO the face — a counterbore or a countersink (a cut drafted+/// at half its included angle) first, then the hole from where it ends.+#[allow(clippy::too_many_arguments)]+pub fn hole(model: &mut Model, run: &mut Run, at: V3, diameter: f64, depth: Option<f64>, counterbore: Option<(f64, f64)>,+ countersink: Option<(f64, f64)>, active: bool) -> Result<Value, String> {+ let defaults = PlaneFrame::defaults();+ let f = model.pick_face(at)?;+ let mut n = [0.0; 3];+ n[f.axis] = f.side as f64;+ let i = modeling::parallel_default(&defaults, n).ok_or("the face is not parallel to a default plane")?;+ let frame = defaults[i];+ let h = model.face_coord(f);+ let names = ["Front Plane", "Top Plane", "Right Plane"];+ let plan = crate::script::hole_plan(&frame, names[i], h, modeling::dot(n, frame.n) > 0.0, at, diameter, depth, counterbore, countersink);+ let mut steps = Vec::new();+ let mut features = Vec::new();+ for op in &plan {+ let r = apply(model, run, op, active)?;+ if let Some(fe) = r.get("feature").and_then(|f| f.as_str()) {+ features.push(fe.to_string());+ }+ steps.push(json!({"op": op.name(), "result": r}));+ }+ Ok(json!({"feature": features.last(), "features": features, "face": {"atPointMm": [at[0] * 1000.0, at[1] * 1000.0, at[2] * 1000.0], "pickedBy": "geometry"},+ "diameterMm": diameter * 1000.0, "depthMm": depth.map(|d| d * 1000.0), "throughAll": depth.is_none(),+ "counterbore": counterbore.map(|(d, t)| json!({"diameterMm": d * 1000.0, "depthMm": t * 1000.0})),+ "countersink": countersink.map(|(d, a)| json!({"diameterMm": d * 1000.0, "angleDeg": a.to_degrees(),+ "depthMm": crate::script::countersink_depth(diameter, d, a) * 1000.0})),+ "steps": steps}))+}+ #[cfg(test)] mod tests { use super::*; use crate::script::{Direction, Extent}; fn ext(depth: f64) -> Extent {- Extent { depth, depth2: depth, through_all: false, reverse: false, direction: Direction::One }+ Extent { depth, depth2: depth, through_all: false, reverse: false, direction: Direction::One , up_to: None, draft: None } } fn block() -> (Model, Run) {@@ -1014,7 +1634,7 @@ mod tests { fn a_material_wipes_the_appearance_and_the_run_reapplies_it() { let (mut m, mut run) = block(); apply(&mut m, &mut run, &Op::SetAppearance { rgb: [200.0 / 255.0, 0.0, 0.0], target: AppearanceTarget::Part }, true).unwrap();- let r = apply(&mut m, &mut run, &Op::SetMaterial { name: "ABS".into(), database: "x".into() }, true).unwrap();+ let r = apply(&mut m, &mut run, &Op::SetMaterial { name: "ABS".into(), database: "x".into(), density: None }, true).unwrap(); assert_eq!(m.appearance, Some([200, 0, 0])); assert_eq!(r["appearanceReapplied"].as_array().map(|a| a.len()), Some(1)); let mut bare = m.clone();@@ -1030,4 +1650,325 @@ mod tests { apply(&mut m, &mut run, &Op::Extrude(ext(0.003)), true).unwrap(); assert!((m.bodies[0].lo[1] + 0.003).abs() < 1e-12, "y down to -3 mm: {:?}", m.bodies[0].lo); }++ // ── 0.11.0: CSWA phase 1, against CLOSED FORMS ─────────────────────────++ fn script(steps: Value) -> Vec<Op> {+ crate::script::parse(&steps).unwrap()+ }++ /// Run ops on a fresh model; every step must succeed.+ fn build(steps: Value) -> (Model, Run, Vec<Value>) {+ let mut m = Model::default();+ let mut run = Run::default();+ let mut out = Vec::new();+ for op in script(steps) {+ if matches!(op, Op::NewPart { .. }) {+ continue;+ }+ if matches!(op, Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_)) {+ run.sketch_selected = true;+ }+ out.push(apply(&mut m, &mut run, &op, true).unwrap_or_else(|e| panic!("{}: {e}", op.name())));+ }+ (m, run, out)+ }++ fn close(a: f64, b: f64, rel: f64) -> bool {+ (a - b).abs() <= rel * b.abs().max(1e-30)+ }++ #[test]+ fn a_closed_polyline_and_a_rounded_rect_have_their_exact_areas() {+ let square = script(json!([{"op": "sketch", "entities": [{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]], "close": true}}]}]));+ let Op::Sketch { entities, .. } = &square[0] else { panic!() };+ assert!(close(profile_area(entities), 1.0e-4, 1e-12), "{}", profile_area(entities));+ // A rounded rect: w·h − (4 − π)·rc².+ let rr = script(json!([{"op": "sketch_rect", "widthMm": 40, "heightMm": 20, "cornerRadiusMm": 5}]));+ let Op::Sketch { entities, .. } = &rr[0] else { panic!() };+ assert_eq!(entities.len(), 8, "four lines, four arcs");+ assert!(close(profile_area(entities), (40.0 * 20.0 - (4.0 - PI) * 25.0) * 1e-6, 1e-12), "{}", profile_area(entities));+ // A slot: L·W + π(W/2)²; a hexagon by its inscribed radius: 2√3·r².+ let sl = script(json!([{"op": "sketch", "entities": [{"slot": {"center": [5, 5], "lengthMm": 30, "widthMm": 10, "angleDeg": 30}}]}]));+ let Op::Sketch { entities, .. } = &sl[0] else { panic!() };+ assert!(close(profile_area(entities), (300.0 + PI * 25.0) * 1e-6, 1e-12));+ let hx = script(json!([{"op": "sketch", "entities": [{"polygon": {"center": [0, 0], "sides": 6, "inscribedRadiusMm": 10}}]}]));+ let Op::Sketch { entities, .. } = &hx[0] else { panic!() };+ assert!(close(profile_area(entities), 2.0 * 3f64.sqrt() * 100.0 * 1e-6, 1e-12));+ }++ #[test]+ fn a_contour_with_a_hole_is_a_region_and_extrudes_to_its_exact_volume() {+ // A 30 x 20 polyline plate with a 4 mm-radius circle inside, 5 mm thick.+ let (m, _, _) = build(json!([+ {"op": "sketch", "plane": "Front Plane", "entities": [+ {"polyline": {"points": [[0, 0], [30, 0], [30, 20], [0, 20]], "close": true}},+ {"circle": {"centerMm": [15, 10], "radiusMm": 4}}]},+ {"op": "extrude", "depthMm": 5}+ ]));+ assert!(close(m.volume(), (600.0 - PI * 16.0) * 5.0 * 1e-9, 1e-12), "{}", m.volume() * 1e9);+ let c = m.center_of_mass().unwrap();+ assert!((c[0] - 0.015).abs() < 1e-12 && (c[1] - 0.010).abs() < 1e-12 && (c[2] - 0.0025).abs() < 1e-12, "{c:?}");+ }++ #[test]+ fn a_revolved_rectangle_is_a_ring_exactly() {+ // Front Plane, the rectangle x 10..15, y 0..20, about the sketch's y axis:+ // π(R² − r²)·h, and its centre of mass on the axis at mid-height.+ let (m, _, out) = build(json!([+ {"op": "sketch", "plane": "Front Plane", "entities": [{"polyline": {"points": [[10, 0], [15, 0], [15, 20], [10, 20]], "close": true}}]},+ {"op": "revolve", "axis": "y"}+ ]));+ let ring = PI * (15.0f64.powi(2) - 10.0f64.powi(2)) * 20.0;+ assert!(close(m.volume() * 1e9, ring, 1e-12), "{} vs {ring}", m.volume() * 1e9);+ let c = m.center_of_mass().unwrap();+ assert!(c[0].abs() < 1e-12 && (c[1] - 0.010).abs() < 1e-12 && c[2].abs() < 1e-12, "{c:?}");+ assert_eq!(out[1]["route"], "FeatureRevolve2");+ assert!((m.bodies[0].hi[0] - 0.015).abs() < 1e-12 && (m.bodies[0].lo[2] + 0.015).abs() < 1e-12, "the box is the ring's");+ // A quarter turn is a quarter of it.+ let (q, _, _) = build(json!([+ {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 20}}]},+ {"op": "revolve", "axis": "y", "angleDeg": 90}+ ]));+ assert!(close(q.volume() * 1e9, ring / 4.0, 1e-12));+ }++ #[test]+ fn pappus_a_half_disc_about_its_diameter_is_a_sphere() {+ // A semicircle (an arc and the diameter line) revolved about that line:+ // area πr²/2, centroid 4r/3π from it → 4/3·πr³ (Pappus, exact).+ let (m, _, _) = build(json!([+ {"op": "sketch", "plane": "Top Plane", "entities": [+ {"line": {"from": [-10, 0], "to": [10, 0]}},+ {"arc": {"center": [0, 0], "radiusMm": 10, "startDeg": 0, "endDeg": 180}}]},+ {"op": "revolve", "axis": {"entity": 0}}+ ]));+ assert!(close(m.volume() * 1e9, 4.0 / 3.0 * PI * 1000.0, 1e-12), "{}", m.volume() * 1e9);+ let c = m.center_of_mass().unwrap();+ assert!(modeling::norm(c) < 1e-12, "the sphere's centre: {c:?}");+ // A triangle (1,0) (2,0) (1,1) about y: A = ½, x̄ = 4/3 → V = ½·4/3·2π.+ let (t, _, _) = build(json!([+ {"op": "sketch", "plane": "Front Plane", "entities": [{"polyline": {"points": [[1, 0], [2, 0], [1, 1]], "close": true}}]},+ {"op": "revolve", "axis": {"line": [[0, -5], [0, 5]]}}+ ]));+ assert!(close(t.volume() * 1e9, 0.5 * 4.0 / 3.0 * 2.0 * PI, 1e-12), "{}", t.volume() * 1e9);+ }++ #[test]+ fn a_revolve_cut_grooves_a_shaft_exactly() {+ // A Ø20 x 40 shaft along Y, a 2 x 3 rectangle at radius 8..10 revolved out.+ let (m, _, _) = build(json!([+ {"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 10},+ {"op": "extrude", "depthMm": 40},+ {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 8, "yMm": 20, "widthMm": 2, "heightMm": 3}}]},+ {"op": "revolve_cut", "axis": "y"}+ ]));+ let v = PI * 100.0 * 40.0 - PI * (100.0 - 64.0) * 3.0;+ assert!(close(m.volume() * 1e9, v, 1e-12), "{} vs {v}", m.volume() * 1e9);+ }++ #[test]+ fn a_mirrored_boss_doubles_and_a_mirrored_body_is_whole() {+ // A 10 mm cube at x 20..30 mirrored about Right Plane: 2000 mm³, centred.+ let (m, _, out) = build(json!([+ {"op": "sketch_rect", "plane": "Front Plane", "xMm": 20, "yMm": 0, "widthMm": 10, "heightMm": 10},+ {"op": "extrude", "depthMm": 10},+ {"op": "mirror", "plane": "Right Plane"}+ ]));+ assert!(close(m.volume() * 1e9, 2000.0, 1e-12));+ assert_eq!(m.bodies.len(), 2, "apart, two bodies");+ assert!(m.center_of_mass().unwrap()[0].abs() < 1e-12);+ assert_eq!(out[2]["route"], "InsertMirrorFeature2");+ // A body touching the plane mirrors into ONE body twice the size.+ let (b, _, _) = build(json!([+ {"op": "sketch_rect", "plane": "Front Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 10},+ {"op": "extrude", "depthMm": 10},+ {"op": "mirror", "plane": "Right Plane", "featureOrBody": "all"}+ ]));+ assert!(close(b.volume() * 1e9, 2000.0, 1e-12) && b.bodies.len() == 1, "{} {}", b.volume() * 1e9, b.bodies.len());+ assert!((b.bodies[0].lo[0] + 0.010).abs() < 1e-12);+ // A mirrored HOLE removes the same again.+ let (h, _, _) = build(json!([+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 60, "heightMm": 20, "anchor": "center"},+ {"op": "extrude", "depthMm": 10},+ {"op": "hole", "atMm": [20, 10, 0], "diameterMm": 5, "throughAll": true},+ {"op": "mirror", "plane": "Right Plane"}+ ]));+ assert!(close(h.volume() * 1e9, 60.0 * 20.0 * 10.0 - 2.0 * PI * 6.25 * 10.0, 1e-12), "{}", h.volume() * 1e9);+ }++ #[test]+ fn holes_plain_counterbored_and_countersunk_remove_their_exact_volumes() {+ let block = json!([{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 40, "heightMm": 40, "anchor": "center"}, {"op": "extrude", "depthMm": 20}]);+ let with = |hole: Value| {+ let mut steps = block.as_array().unwrap().clone();+ steps.push(hole);+ build(Value::Array(steps))+ };+ let full = 40.0 * 40.0 * 20.0;+ let (m, _, out) = with(json!({"op": "hole", "atMm": [5, 20, 5], "diameterMm": 6, "depthMm": 12}));+ assert!(close(m.volume() * 1e9, full - PI * 9.0 * 12.0, 1e-12), "{}", m.volume() * 1e9);+ assert_eq!(out[2]["features"].as_array().map(|a| a.len()), Some(1));+ let (m, _, out) = with(json!({"op": "hole", "atMm": [0, 20, 0], "diameterMm": 6, "throughAll": true, "counterbore": {"diameterMm": 11, "depthMm": 6}}));+ assert!(close(m.volume() * 1e9, full - PI * (30.25 * 6.0 + 9.0 * 14.0), 1e-12), "{}", m.volume() * 1e9);+ assert_eq!(out[2]["features"].as_array().map(|a| a.len()), Some(2));+ // A 90° countersink Ø12 on a Ø6 hole: a 3 mm deep frustum, then the hole.+ let (m, _, _) = with(json!({"op": "hole", "atMm": [0, 20, 0], "diameterMm": 6, "throughAll": true, "countersink": {"diameterMm": 12, "angleDeg": 90}}));+ let frustum = PI * 3.0 / 3.0 * (36.0 + 18.0 + 9.0);+ assert!(close(m.volume() * 1e9, full - frustum - PI * 9.0 * 17.0, 1e-9), "{} vs {}", m.volume() * 1e9, full - frustum - PI * 9.0 * 17.0);+ // From the BOTTOM face the cuts go up, into the material.+ let (m, _, _) = with(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 6, "depthMm": 5}));+ assert!(close(m.volume() * 1e9, full - PI * 9.0 * 5.0, 1e-12));+ }++ #[test]+ fn up_to_next_up_to_a_face_and_offset_from_it() {+ // Two blocks on Top Plane: y 0..10, and a slab y 30..35 above. A boss+ // from the first's top face up to the next face stops at y 30.+ let base = json!([+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},+ {"op": "extrude", "depthMm": 10},+ {"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 30}, "widthMm": 20, "heightMm": 20, "anchor": "center"},+ {"op": "extrude", "depthMm": 5}+ ]);+ let with = |more: Value| {+ let mut steps = base.as_array().unwrap().clone();+ steps.extend(more.as_array().unwrap().iter().cloned());+ build(Value::Array(steps))+ };+ let (m, _, out) = with(json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 2}, {"op": "extrude", "upToNext": true}]));+ assert!(close(m.volume() * 1e9, 4000.0 + 2000.0 + PI * 4.0 * 20.0, 1e-12), "{}", m.volume() * 1e9);+ assert!((out[5]["endsAtMm"].as_f64().unwrap() - 30.0).abs() < 1e-9);+ let (m, _, _) = with(json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 2}, {"op": "extrude", "upToSurface": {"face": {"atPointMm": [0, 35, 0]}}}]));+ assert!(close(m.volume() * 1e9, 6000.0 + PI * 4.0 * 25.0, 1e-12));+ let (m, _, _) = with(json!([{"op": "sketch_circle", "face": {"atPointMm": [0, 10, 0]}, "radiusMm": 2}, {"op": "extrude", "offsetFromSurface": {"face": {"atPointMm": [0, 30, 0]}, "distanceMm": 4}}]));+ assert!(close(m.volume() * 1e9, 6000.0 + PI * 4.0 * 16.0, 1e-12));+ // Nothing ahead: up to next has no face, and says so (a null feature).+ let mut m2 = Model::default();+ let mut run = Run::default();+ for op in script(json!([{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20}, {"op": "extrude", "depthMm": 10},+ {"op": "sketch_circle", "face": {"atPointMm": [10, 10, -10]}, "radiusMm": 2}])) {+ run.sketch_selected = true;+ apply(&mut m2, &mut run, &op, true).unwrap();+ }+ run.sketch_selected = true;+ let e = apply(&mut m2, &mut run, &script(json!([{"op": "extrude", "upToNext": true}]))[0], true).unwrap_err();+ assert!(e.contains("no next face"), "{e}");+ }++ #[test]+ fn a_draft_tapers_a_box_and_a_cylinder_by_their_closed_forms() {+ // A 20 x 10 rect drafted 5° inward over 8 mm: ∫(w−2kt)(h−2kt)dt.+ let k = 5f64.to_radians().tan();+ let (w, h, l) = (20.0, 10.0, 8.0);+ let want = w * h * l - (w + h) * k * l * l + 4.0 * k * k * l * l * l / 3.0;+ let (m, _, _) = build(json!([{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 20, "heightMm": 10}, {"op": "extrude", "depthMm": 8, "draftDeg": 5}]));+ assert!(close(m.volume() * 1e9, want, 1e-12), "{} vs {want}", m.volume() * 1e9);+ // Outward: the same with k → −k.+ let want_out = w * h * l + (w + h) * k * l * l + 4.0 * k * k * l * l * l / 3.0;+ let (m, _, _) = build(json!([{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 20, "heightMm": 10}, {"op": "extrude", "depthMm": 8, "draftDeg": 5, "draftOutward": true}]));+ assert!(close(m.volume() * 1e9, want_out, 1e-12));+ // A circle r 5 drafted 10° over 6 mm is a frustum.+ let k = 10f64.to_radians().tan();+ let r2 = 5.0 - 6.0 * k;+ let frustum = PI * 6.0 / 3.0 * (25.0 + 5.0 * r2 + r2 * r2);+ let (m, _, _) = build(json!([{"op": "sketch_circle", "plane": "Front Plane", "radiusMm": 5}, {"op": "extrude", "depthMm": 6, "draftDeg": 10}]));+ assert!(close(m.volume() * 1e9, frustum, 1e-12), "{} vs {frustum}", m.volume() * 1e9);+ }++ #[test]+ fn a_density_alone_sets_the_part_and_a_mismatched_one_is_refused() {+ let (m, _, out) = build(json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 10}, {"op": "extrude", "depthMm": 10}, {"op": "set_material", "densityKgM3": 1250}]));+ assert_eq!(m.density, Some(1250.0));+ assert_eq!(out[2]["densityKgM3"], 1250.0);+ let (m, _, _) = build(json!([{"op": "set_material", "name": "AISI 1020", "densityKgM3": 7900}]));+ assert_eq!(m.density, Some(7900.0));+ let mut m = Model::default();+ let mut run = Run::default();+ let e = apply(&mut m, &mut run, &script(json!([{"op": "set_material", "name": "6061 Alloy", "densityKgM3": 7900}]))[0], true).unwrap_err();+ assert!(e.contains("2700") && e.contains("7900"), "{e}");+ apply(&mut m, &mut run, &script(json!([{"op": "set_material", "name": "6061 Alloy"}]))[0], true).unwrap();+ let e = apply(&mut m, &mut run, &script(json!([{"op": "set_material", "densityKgM3": 1000}]))[0], true).unwrap_err();+ assert!(e.contains("density wins"), "{e}");+ assert_eq!(library_density("Cast Alloy Steel"), Some(7300.0), "the longest name wins");+ }++ #[test]+ fn a_cut_from_a_plane_offset_inside_a_face_goes_on_inward() {+ // A 40 x 20 x 44 block on Front Plane (z 0..44); a plane 12 inside the+ // BACK face (z = 0, outward −Z) sits at z = 12; a cut from it goes +Z.+ let (m, _, out) = build(json!([+ {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 40, "heightMm": 20},+ {"op": "extrude", "depthMm": 44},+ {"op": "sketch_rect", "plane": {"offsetFrom": {"face": {"atPointMm": [5, 5, 0]}}, "offsetMm": -12}, "xMm": 10, "yMm": 5, "widthMm": 10, "heightMm": 10},+ {"op": "cut", "depthMm": 24}+ ]));+ assert!((out[2]["resolvedPlane"]["offsetMm"].as_f64().unwrap() - 12.0).abs() < 1e-9, "{}", out[2]);+ assert!((out[3]["spanMm"][0].as_f64().unwrap() - 12.0).abs() < 1e-9 && (out[3]["spanMm"][1].as_f64().unwrap() - 36.0).abs() < 1e-9, "{}", out[3]);+ assert!(close(m.volume() * 1e9, 40.0 * 20.0 * 44.0 - 100.0 * 24.0, 1e-12));+ // The column model agrees, and says where the material is.+ let (v, c) = m.csg_mass().unwrap();+ assert!(close(v * 1e9, 40.0 * 20.0 * 44.0 - 100.0 * 24.0, 1e-9), "{}", v * 1e9);+ assert!(c[2] > 0.0 && c[2] < 0.044);+ }++ #[test]+ fn the_column_model_declines_what_it_cannot_represent() {+ let (m, _, _) = build(json!([+ {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},+ {"op": "extrude", "depthMm": 10},+ {"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "last"}}+ ]));+ assert_eq!(m.csg_mass().unwrap_err(), "a fillet");+ let (m, _, _) = build(json!([+ {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 20}}]},+ {"op": "revolve", "axis": "y", "angleDeg": 90}+ ]));+ assert!(m.csg_mass().unwrap_err().contains("less than 360"));+ // A full revolve IS represented: the ring, by columns along its axis.+ let (m, _, _) = build(json!([+ {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 20}}]},+ {"op": "revolve", "axis": "y"},+ {"op": "linear_pattern", "feature": "last", "directions": [{"axis": "x", "count": 2, "spacingMm": 50}]}+ ]));+ let (v, c) = m.csg_mass().unwrap();+ let ring = PI * (225.0 - 100.0) * 20.0;+ assert!(close(v * 1e9, 2.0 * ring, 1e-7), "{} vs {}", v * 1e9, 2.0 * ring);+ assert!((c[0] - 0.025).abs() < 1e-6 && (c[1] - 0.010).abs() < 1e-9, "{c:?}");+ }++ #[test]+ fn a_mirror_about_a_face_or_an_offset_plane_lands_beyond_it() {+ // A 10 mm cube at x 0..10 mirrored about its own RIGHT face (x = 10):+ // the copy at x 10..20, one body, its centre of mass at x = 10.+ let (m, _, _) = build(json!([+ {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10},+ {"op": "extrude", "depthMm": 10},+ {"op": "mirror", "face": {"atPointMm": [10, 5, 5]}}+ ]));+ assert!(close(m.volume() * 1e9, 2000.0, 1e-12) && (m.bodies[0].hi[0] - 0.020).abs() < 1e-12, "{:?}", m.bodies);+ let (v, c) = m.csg_mass().unwrap();+ assert!(close(v * 1e9, 2000.0, 1e-9) && (c[0] - 0.010).abs() < 1e-9, "{c:?}");+ // About a plane offset 30 from Right Plane: the copy at x 50..60.+ let (m, _, _) = build(json!([+ {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10},+ {"op": "extrude", "depthMm": 10},+ {"op": "mirror", "plane": {"offsetFrom": "Right Plane", "offsetMm": 30}}+ ]));+ let (_, c) = m.csg_mass().unwrap();+ assert!((c[0] - 0.030).abs() < 1e-9, "{c:?}");+ }++ #[test]+ fn offset_from_surface_can_go_beyond_the_face() {+ // From Top Plane (y 0) up to 4 mm BEYOND a face at y 30 (reverseOffset).+ let (m, _, out) = build(json!([+ {"op": "sketch_rect", "plane": {"offsetFrom": "Top Plane", "offsetMm": 30}, "widthMm": 20, "heightMm": 20, "anchor": "center"},+ {"op": "extrude", "depthMm": 5},+ {"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 2},+ {"op": "extrude", "offsetFromSurface": {"face": {"atPointMm": [0, 30, 0]}, "distanceMm": 4, "reverseOffset": true}}+ ]));+ assert!((out[3]["endsAtMm"].as_f64().unwrap() - 34.0).abs() < 1e-9, "{}", out[3]);+ let _ = m;+ } }
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_csg; pub mod mock_model; #[cfg(windows)]
src/comhost/variant.rs+6−1@@ -23,7 +23,7 @@ use windows::Win32::System::Ole::{ }; use windows::Win32::System::Variant::{ VariantChangeType, VariantClear, VariantCopy, VARENUM, VARIANT, VT_ARRAY, VT_BOOL, VT_BSTR,- VT_BYREF, VT_DISPATCH, VT_EMPTY, VT_I4, VT_NULL, VT_R8, VT_UI1, VT_UNKNOWN, VT_VARIANT,+ VT_BYREF, VT_DISPATCH, VT_EMPTY, VT_I2, VT_I4, VT_NULL, VT_R8, VT_UI1, VT_UNKNOWN, VT_VARIANT, }; use super::host::HostError;@@ -61,6 +61,11 @@ impl Var { unsafe { build(VT_I4, |v| (*v.Anonymous.Anonymous).Anonymous.lVal = n) } } + /// A `short` (VT_I2) — ISketchManager::CreateArc's Direction is one.+ pub fn i2(n: i16) -> Var {+ unsafe { build(VT_I2, |v| (*v.Anonymous.Anonymous).Anonymous.iVal = n) }+ }+ pub fn r8(n: f64) -> Var { unsafe { build(VT_R8, |v| (*v.Anonymous.Anonymous).Anonymous.dblVal = n) } }
src/compat.rs+91−4@@ -195,6 +195,37 @@ pub const OP_FACTS: &[OpFact] = &[ OpFact { id: "set_material", what: "a material from the SOLIDWORKS library (the body's density follows it)", checks: "T22 T24", measured: M2026_2021 }, OpFact { id: "set_material.reappliesAppearance", what: "a material after a colour: the bridge re-applies the colour", checks: "T31 (swx_selftest)", measured: M2026_2021 }, OpFact { id: "set_prop", what: "a custom property", checks: "T24", measured: M2026_2021 },+ // ── 0.11.0, CSWA phase 1: built and simulated, NOT YET RUN LIVE ──+ OpFact { id: "sketch.line", what: "lines of a closed contour (line, polyline, polygon, slot, rounded rect): ISketchManager::CreateLine", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "sketch.arc", what: "arcs of a closed contour (arc, arc3, tangentArc, slot, rounded rect): ISketchManager::CreateArc", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "sketch.faceOffset", what: "a sketch on a plane offset from a picked face (along its outward normal)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.features.faceOffset", what: "features mirrored about a plane offset from a face", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.bodies.faceOffset", what: "every body mirrored about a plane offset from a face", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "sketch.construction", what: "construction geometry (CreateCenterLine; arcs with ConstructionGeometry)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "extrude.upToNext", what: "a boss up to the next face (swEndCondUpToNext 11)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "extrude.upToSurface", what: "a boss up to a picked face (swEndCondUpToSelection 10, the face at mark 1)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "extrude.offsetFromSurface", what: "a boss to a distance from a picked face (swEndCondOffsetFromSurface 5)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "extrude.draft", what: "a boss with draft (Dchk1/Ddir1/Dang1)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "cut.upToNext", what: "a cut up to the next face", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "cut.upToSurface", what: "a cut up to a picked face", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "cut.offsetFromSurface", what: "a cut to a distance from a picked face", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "cut.draft", what: "a cut with draft (a countersink is one)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "revolve.360", what: "a full revolve about a line of the sketch (FeatureRevolve2)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "revolve.angle", what: "a revolve through less than 360°", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "revolve.midPlane", what: "a revolve symmetric about the sketch plane", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "revolve_cut.360", what: "a full revolve-cut (FeatureRevolve2 with IsCut)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "revolve_cut.angle", what: "a revolve-cut through less than 360°", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "revolve_cut.midPlane", what: "a revolve-cut symmetric about the sketch plane", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.features.plane", what: "features mirrored about a named plane (InsertMirrorFeature2, features mark 1, plane mark 2)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.features.offset", what: "features mirrored about an offset plane", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.features.face", what: "features mirrored about a planar face", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.bodies.plane", what: "every body mirrored about a named plane (BMirrorBody, bodies mark 256)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.bodies.offset", what: "every body mirrored about an offset plane", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "mirror.bodies.face", what: "every body mirrored about a planar face", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "hole.simple", what: "a plain hole: a circle on the face's plane, cut blind or through all", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "hole.counterbore", what: "a counterbored hole (two cuts)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "hole.countersink", what: "a countersunk hole (a drafted cut, then the hole)", checks: "not run live (0.11.0)", measured: UNMEASURED },+ OpFact { id: "set_material.density", what: "a density in kg/m³ (the document's own, or checked against the material's)", checks: "not run live (0.11.0)", measured: UNMEASURED }, OpFact { id: "save_as", what: "save_as as a script step", checks: "not in the live battery (swx_save_as, the verb, is)", measured: UNMEASURED }, OpFact { id: "rebuild", what: "rebuild as a script step", checks: "not in the live battery", measured: UNMEASURED }, OpFact { id: "zoom_fit", what: "zoom_fit as a script step", checks: "not in the live battery", measured: UNMEASURED },@@ -281,6 +312,7 @@ pub fn op_variants(op: &crate::script::Op, result: &serde_json::Value) -> Vec<St "named" => "sketch.offset.named".into(), base => format!("sketch.offset.{base}.{}", if *offset < 0.0 { "negative" } else { "positive" }), },+ PlaneRef::FaceOffset { .. } => "sketch.faceOffset".into(), PlaneRef::Face { .. } => { let from = result.pointer("/resolvedPlane/offsetFrom").and_then(|f| f.as_str()).unwrap_or(""); match (plane_key(from), face_side(result, plane_key(from))) {@@ -298,6 +330,15 @@ pub fn op_variants(op: &crate::script::Op, result: &serde_json::Value) -> Vec<St if entities.len() > 1 { v.push("sketch.multiProfile".into()); }+ if entities.iter().any(|e| matches!(e, crate::script::Entity::Line { construction: false, .. })) {+ v.push("sketch.line".into());+ }+ if entities.iter().any(|e| matches!(e, crate::script::Entity::Arc { construction: false, .. })) {+ v.push("sketch.arc".into());+ }+ if entities.iter().any(|e| e.is_construction()) {+ v.push("sketch.construction".into());+ } } Op::Extrude(e) | Op::Cut(e) => { let kind = if matches!(op, Op::Cut(_)) { "cut" } else { "extrude" };@@ -309,7 +350,12 @@ pub fn op_variants(op: &crate::script::Op, result: &serde_json::Value) -> Vec<St // The flag SOLIDWORKS was given (the COM host reports it); the // caller's `reverse` where a backend does not. let reversed = result.pointer("/dir/value").and_then(|d| d.as_bool()).unwrap_or(e.reverse);- let mut id = format!("{kind}.{dir}");+ let mut id = match &e.up_to {+ Some(crate::script::UpTo::Next) => format!("{kind}.upToNext"),+ Some(crate::script::UpTo::Surface { .. }) => format!("{kind}.upToSurface"),+ Some(crate::script::UpTo::OffsetFrom { .. }) => format!("{kind}.offsetFromSurface"),+ None => format!("{kind}.{dir}"),+ }; if e.through_all { id.push_str(".throughAll"); }@@ -317,6 +363,9 @@ pub fn op_variants(op: &crate::script::Op, result: &serde_json::Value) -> Vec<St id.push_str(".reversed"); } v.push(id);+ if e.draft.is_some() {+ v.push(format!("{kind}.draft"));+ } } Op::Fillet { edges, .. } | Op::Chamfer { edges, .. } => { let kind = if matches!(op, Op::Fillet { .. }) { "fillet" } else { "chamfer" };@@ -348,8 +397,30 @@ pub fn op_variants(op: &crate::script::Op, result: &serde_json::Value) -> Vec<St } } }),- Op::SetMaterial { .. } => {- v.push("set_material".into());+ Op::Revolve(r) | Op::RevolveCut(r) => {+ let kind = if matches!(op, Op::RevolveCut(_)) { "revolve_cut" } else { "revolve" };+ let how = if r.mid_plane { "midPlane" } else if r.angle >= std::f64::consts::TAU - 1e-9 { "360" } else { "angle" };+ v.push(format!("{kind}.{how}"));+ }+ Op::Mirror { bodies, plane, .. } => {+ let on = match plane {+ PlaneRef::Named(_) => "plane",+ PlaneRef::Offset { .. } => "offset",+ PlaneRef::Face { .. } => "face",+ PlaneRef::FaceOffset { .. } => "faceOffset",+ };+ v.push(format!("mirror.{}.{on}", if *bodies { "bodies" } else { "features" }));+ }+ Op::Hole { counterbore, countersink, .. } => v.push(+ if counterbore.is_some() { "hole.counterbore" } else if countersink.is_some() { "hole.countersink" } else { "hole.simple" }.into(),+ ),+ Op::SetMaterial { name, density, .. } => {+ if !name.is_empty() {+ v.push("set_material".into());+ }+ if density.is_some() {+ v.push("set_material.density".into());+ } if result.get("appearanceReapplied").and_then(|a| a.as_array()).map(|a| !a.is_empty()).unwrap_or(false) { v.push("set_material.reappliesAppearance".into()); }@@ -549,8 +620,24 @@ mod tests { // ...and EXACTLY what is not: its "not yet measured" list (throughAll+both // on a boss, the ladder's later rungs, the CreateDefinition fillet) plus // the variants no live check built — a reversed boss among them.- let mut unmeasured: Vec<&str> = OP_FACTS.iter().filter(|f| f.measured.is_empty()).map(|f| f.id).collect();+ let mut unmeasured: Vec<&str> =+ OP_FACTS.iter().filter(|f| f.measured.is_empty() && f.checks != "not run live (0.11.0)").map(|f| f.id).collect(); unmeasured.sort_unstable();+ // 0.11.0's CSWA ops: every one of them NOT YET RUN LIVE, and listed.+ let mut new_ops: Vec<&str> = OP_FACTS.iter().filter(|f| f.checks == "not run live (0.11.0)").map(|f| f.id).collect();+ assert!(OP_FACTS.iter().filter(|f| f.checks == "not run live (0.11.0)").all(|f| f.measured.is_empty()));+ new_ops.sort_unstable();+ assert_eq!(+ new_ops,+ [+ "cut.draft", "cut.offsetFromSurface", "cut.upToNext", "cut.upToSurface", "extrude.draft", "extrude.offsetFromSurface",+ "extrude.upToNext", "extrude.upToSurface", "hole.counterbore", "hole.countersink", "hole.simple", "mirror.bodies.face",+ "mirror.bodies.faceOffset", "mirror.bodies.offset", "mirror.bodies.plane", "mirror.features.face",+ "mirror.features.faceOffset", "mirror.features.offset", "mirror.features.plane",+ "revolve.360", "revolve.angle", "revolve.midPlane", "revolve_cut.360", "revolve_cut.angle", "revolve_cut.midPlane",+ "set_material.density", "sketch.arc", "sketch.construction", "sketch.faceOffset", "sketch.line",+ ]+ ); assert_eq!( unmeasured, [
src/cswa.rsadded+507@@ -0,0 +1,507 @@+//! THE CSWA BENCHMARK (0.11.0) — exam parts as release validation.+//!+//! Drew (2026-10-15): "pull 3-5 random parts from CSWA exams as part of our+//! e2e testing/validation for each release … use those as benchmarks for new+//! feature developments." Each spec in `resources/cswa/` (shipped in the zip)+//! is one part: the published answer (mass in grams, sometimes volume, centre+//! of mass, surface area), the material, and the `swx_run_script` that builds+//! it. `swx_selftest {level:"cswa"}` and `tools/cswa-check.py` run them and+//! compare within the exam's tolerance; `tools/release.sh --require-live`+//! refuses to publish without a passing live report.+//!+//! The answer key is the PUBLISHED one, never the bridge's own idea: a spec+//! whose key was not published says where its number came from (`keyBasis`).++use serde_json::{json, Value};++/// Every shipped spec, embedded so the self-test needs no file next to the+/// exe (the same files ship in the zip for tools/cswa-check.py). A test pins+/// this list to the directory.+pub const SPECS: &[(&str, &str)] = &[+ ("cswa-00-lego-2x4.json", include_str!("../resources/cswa/cswa-00-lego-2x4.json")),+ ("cswa-01-3dengr-q5-block.json", include_str!("../resources/cswa/cswa-01-3dengr-q5-block.json")),+ ("cswa-02-3dengr-q6-hole.json", include_str!("../resources/cswa/cswa-02-3dengr-q6-hole.json")),+ ("cswa-03-3dengr-q8-hole30.json", include_str!("../resources/cswa/cswa-03-3dengr-q8-hole30.json")),+ ("cswa-04-3dengr-q9-l-bracket.json", include_str!("../resources/cswa/cswa-04-3dengr-q9-l-bracket.json")),+ ("cswa-05-tool-block-step1.json", include_str!("../resources/cswa/cswa-05-tool-block-step1.json")),+ ("cswa-06-tool-block-step2.json", include_str!("../resources/cswa/cswa-06-tool-block-step2.json")),+ ("cswa-07-tool-block-step3.json", include_str!("../resources/cswa/cswa-07-tool-block-step3.json")),+ ("cswa-08-tool-block-step4.json", include_str!("../resources/cswa/cswa-08-tool-block-step4.json")),+];++// ── SPEC VARIABLES (0.11.0) ────────────────────────────────────────────────+// An exam part is often the SAME drawing with new values (the Tool Block's+// Steps 1 and 2: A, B, C change, "all other dimensions the same"). A spec may+// therefore name `variables` (numbers), `derived` ([name, expression] pairs,+// evaluated in order) and write any number in its script as "=expression";+// `extends: "<id>"` takes another spec's script and derived values and+// overrides its variables. This lives in the BENCHMARK (here and in+// tools/cswa-check.py, which implements the same grammar), never in the DSL:+// the bridge is always sent plain numbers.++/// Evaluate an arithmetic expression: numbers, variables, + − * / ^,+/// parentheses, unary minus, `pi`, and sqrt sin cos tan asin acos atan atan2+/// abs min max rad deg (angles in RADIANS; rad()/deg() convert).+pub fn eval(expr: &str, vars: &std::collections::HashMap<String, f64>) -> Result<f64, String> {+ struct P<'a> {+ s: &'a [u8],+ i: usize,+ vars: &'a std::collections::HashMap<String, f64>,+ }+ impl P<'_> {+ fn ws(&mut self) {+ while self.i < self.s.len() && (self.s[self.i] as char).is_whitespace() {+ self.i += 1;+ }+ }+ fn peek(&mut self) -> Option<char> {+ self.ws();+ self.s.get(self.i).map(|c| *c as char)+ }+ fn expr(&mut self) -> Result<f64, String> {+ let mut v = self.term()?;+ while let Some(c) = self.peek() {+ match c {+ '+' => { self.i += 1; v += self.term()?; }+ '-' => { self.i += 1; v -= self.term()?; }+ _ => break,+ }+ }+ Ok(v)+ }+ fn term(&mut self) -> Result<f64, String> {+ let mut v = self.power()?;+ while let Some(c) = self.peek() {+ match c {+ '*' => { self.i += 1; v *= self.power()?; }+ '/' => { self.i += 1; v /= self.power()?; }+ _ => break,+ }+ }+ Ok(v)+ }+ fn power(&mut self) -> Result<f64, String> {+ let base = self.unary()?;+ if self.peek() == Some('^') {+ self.i += 1;+ return Ok(base.powf(self.power()?));+ }+ Ok(base)+ }+ fn unary(&mut self) -> Result<f64, String> {+ match self.peek() {+ Some('-') => { self.i += 1; Ok(-self.unary()?) }+ Some('+') => { self.i += 1; self.unary() }+ _ => self.atom(),+ }+ }+ fn atom(&mut self) -> Result<f64, String> {+ match self.peek() {+ Some('(') => {+ self.i += 1;+ let v = self.expr()?;+ if self.peek() != Some(')') {+ return Err("missing )".into());+ }+ self.i += 1;+ Ok(v)+ }+ Some(c) if c.is_ascii_digit() || c == '.' => {+ let st = self.i;+ while self.i < self.s.len() && ((self.s[self.i] as char).is_ascii_digit() || self.s[self.i] == b'.' || self.s[self.i] == b'e'+ || ((self.s[self.i] == b'-' || self.s[self.i] == b'+') && self.i > st && self.s[self.i - 1] == b'e')) {+ self.i += 1;+ }+ std::str::from_utf8(&self.s[st..self.i]).unwrap_or("").parse::<f64>().map_err(|e| format!("bad number: {e}"))+ }+ Some(c) if c.is_ascii_alphabetic() || c == '_' => {+ let st = self.i;+ while self.i < self.s.len() && ((self.s[self.i] as char).is_ascii_alphanumeric() || self.s[self.i] == b'_') {+ self.i += 1;+ }+ let name = std::str::from_utf8(&self.s[st..self.i]).unwrap_or("").to_string();+ if self.peek() == Some('(') {+ self.i += 1;+ let mut args = vec![self.expr()?];+ while self.peek() == Some(',') {+ self.i += 1;+ args.push(self.expr()?);+ }+ if self.peek() != Some(')') {+ return Err(format!("missing ) after {name}("));+ }+ self.i += 1;+ let one = |f: fn(f64) -> f64| if args.len() == 1 { Ok(f(args[0])) } else { Err(format!("{name} takes one argument")) };+ let two = |f: fn(f64, f64) -> f64| if args.len() == 2 { Ok(f(args[0], args[1])) } else { Err(format!("{name} takes two arguments")) };+ return match name.as_str() {+ "sqrt" => one(f64::sqrt),+ "sin" => one(f64::sin),+ "cos" => one(f64::cos),+ "tan" => one(f64::tan),+ "asin" => one(f64::asin),+ "acos" => one(f64::acos),+ "atan" => one(f64::atan),+ "abs" => one(f64::abs),+ "rad" => one(f64::to_radians),+ "deg" => one(f64::to_degrees),+ "atan2" => two(f64::atan2),+ "min" => two(f64::min),+ "max" => two(f64::max),+ _ => Err(format!("unknown function {name}")),+ };+ }+ if name == "pi" {+ return Ok(std::f64::consts::PI);+ }+ self.vars.get(&name).copied().ok_or_else(|| format!("unknown variable {name}"))+ }+ other => Err(format!("unexpected {other:?}")),+ }+ }+ }+ let mut p = P { s: expr.as_bytes(), i: 0, vars };+ let v = p.expr().map_err(|e| format!("{e} in {expr:?}"))?;+ if p.peek().is_some() {+ return Err(format!("unexpected text at {} in {expr:?}", p.i));+ }+ if !v.is_finite() {+ return Err(format!("{expr:?} is not a finite number (with these variables)"));+ }+ Ok(v)+}++/// A spec with its `extends` chain merged in (the base's script, derived values+/// and variables, overridden or appended to by each child in turn).+fn resolve_extends(v: &Value, all: &[Value], depth: usize) -> Result<Value, String> {+ let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("?").to_string();+ let Some(base_id) = v.get("extends").and_then(|e| e.as_str()) else { return Ok(v.clone()) };+ if depth > 8 {+ return Err(format!("{id}: extends goes more than 8 deep (a cycle?)"));+ }+ let base = all.iter().find(|b| b.get("id").and_then(|i| i.as_str()) == Some(base_id)).ok_or_else(|| format!("{id}: extends {base_id:?}, which is not a spec"))?;+ let mut merged = resolve_extends(base, all, depth + 1)?;+ let mut vars = merged.get("variables").cloned().unwrap_or(json!({}));+ if let (Value::Object(m), Some(Value::Object(o))) = (&mut vars, v.get("variables")) {+ for (k, x) in o {+ m.insert(k.clone(), x.clone());+ }+ }+ if let (Value::Object(m), Value::Object(o)) = (&mut merged, v) {+ m.remove("needsOps");+ for (k, x) in o {+ if k != "extends" && k != "variables" && k != "scriptAppend" && k != "derivedAppend" {+ m.insert(k.clone(), x.clone());+ }+ }+ m.insert("variables".into(), vars);+ // More steps after the base part's (the Tool Block's Steps 3 and 4+ // cut the Step 2 part), and more derived values for them.+ for (key, into) in [("scriptAppend", "script"), ("derivedAppend", "derived")] {+ if let Some(extra) = o.get(key) {+ let more = extra.as_array().ok_or_else(|| format!("{id}: {key} must be a list"))?;+ let list = m.entry(into.to_string()).or_insert_with(|| json!([]));+ if let Value::Array(a) = list {+ a.extend(more.iter().cloned());+ }+ }+ }+ }+ Ok(merged)+}++/// Resolve `extends`, `variables` and `derived`, and replace every+/// "=expression" string in the script with its value. A spec without them is+/// returned as it is.+pub fn expand(v: &Value, all: &[Value]) -> Result<Value, String> {+ let id = v.get("id").and_then(|i| i.as_str()).unwrap_or("?").to_string();+ let mut spec = resolve_extends(v, all, 0)?;+ let mut vars = std::collections::HashMap::new();+ if let Some(Value::Object(m)) = spec.get("variables") {+ for (k, x) in m {+ vars.insert(k.clone(), x.as_f64().ok_or_else(|| format!("{id}: variable {k} must be a number"))?);+ }+ }+ if let Some(d) = spec.get("derived") {+ for pair in d.as_array().ok_or_else(|| format!("{id}: derived is a list of [name, expression]"))? {+ let (name, e) = match pair.as_array().map(|a| a.as_slice()) {+ Some([Value::String(n), Value::String(e)]) => (n.clone(), e.clone()),+ _ => return Err(format!("{id}: derived is a list of [name, expression]")),+ };+ let x = eval(&e, &vars).map_err(|m| format!("{id}: derived {name}: {m}"))?;+ vars.insert(name, x);+ }+ }+ fn subst(v: &mut Value, vars: &std::collections::HashMap<String, f64>) -> Result<(), String> {+ match v {+ Value::String(s) if s.starts_with('=') => {+ *v = json!(eval(&s[1..], vars)?);+ }+ Value::Array(a) => {+ for x in a {+ subst(x, vars)?;+ }+ }+ Value::Object(m) => {+ for (_, x) in m.iter_mut() {+ subst(x, vars)?;+ }+ }+ _ => {}+ }+ Ok(())+ }+ for key in ["script", "expected"] {+ if let Some(x) = spec.get_mut(key) {+ subst(x, &vars).map_err(|m| format!("{id}: {key}: {m}"))?;+ }+ }+ Ok(spec)+}++/// One benchmark part.+#[derive(Debug, Clone, PartialEq)]+pub struct Spec {+ pub id: String,+ pub title: String,+ pub official: bool,+ pub material: Option<String>,+ pub density: Option<f64>,+ pub mass_g: f64,+ pub volume_mm3: Option<f64>,+ pub com_mm: Option<[f64; 3]>,+ pub area_mm2: Option<f64>,+ pub tol_pct: f64,+ pub script: Value,+ /// Ops the part needs that the bridge does not have yet: the part is+ /// SKIPPED (and said so), never half-built.+ pub needs_ops: Vec<String>,+}++pub fn parse_spec(v: &Value) -> Result<Spec, String> {+ let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(String::from);+ let id = s("id").ok_or("a spec needs an id")?;+ if v.get("units").and_then(|u| u.as_str()).unwrap_or("MMGS") != "MMGS" {+ return Err(format!("{id}: units must be MMGS (the DSL is millimetres; convert an IPS part's dimensions first)"));+ }+ let e = v.get("expected").ok_or_else(|| format!("{id}: expected is required"))?;+ let f = |k: &str| e.get(k).and_then(|x| x.as_f64()).filter(|x| x.is_finite());+ let mass_g = f("massG").filter(|m| *m > 0.0).ok_or_else(|| format!("{id}: expected.massG (grams, > 0) is required"))?;+ let tol_pct = f("tolerancePct").unwrap_or(1.0);+ if !(tol_pct > 0.0 && tol_pct <= 5.0) {+ return Err(format!("{id}: tolerancePct must be in (0, 5]"));+ }+ let com_mm = match e.get("centerOfMassMm") {+ None | Some(Value::Null) => None,+ Some(c) => {+ let a: Vec<f64> = c.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();+ if a.len() != 3 {+ return Err(format!("{id}: expected.centerOfMassMm must be [x, y, z]"));+ }+ Some([a[0], a[1], a[2]])+ }+ };+ let script = v.get("script").cloned().ok_or_else(|| format!("{id}: script is required"))?;+ let needs_ops: Vec<String> = v.get("needsOps").and_then(|n| n.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+ if needs_ops.is_empty() {+ // A runnable spec must PARSE: a typo is caught here, not on a live run.+ crate::script::parse(&script).map_err(|m| format!("{id}: its script does not parse: {m}"))?;+ }+ Ok(Spec {+ title: s("title").unwrap_or_else(|| id.clone()),+ official: v.get("official").and_then(|o| o.as_bool()).unwrap_or(false),+ material: v.pointer("/material/name").and_then(|m| m.as_str()).map(String::from),+ density: v.pointer("/material/densityKgM3").and_then(|m| m.as_f64()),+ mass_g,+ volume_mm3: f("volumeMm3"),+ com_mm,+ area_mm2: f("surfaceAreaMm2"),+ tol_pct,+ script,+ needs_ops,+ id,+ })+}++/// Every shipped spec, parsed.+pub fn specs() -> Vec<Result<Spec, String>> {+ let raw: Vec<Result<Value, String>> = SPECS.iter().map(|(name, text)| serde_json::from_str::<Value>(text).map_err(|e| format!("{name}: {e}"))).collect();+ let all: Vec<Value> = raw.iter().filter_map(|r| r.as_ref().ok().cloned()).collect();+ raw.into_iter().map(|r| r.and_then(|v| expand(&v, &all)).and_then(|v| parse_spec(&v))).collect()+}++/// How far off `got` is, in percent of `want`.+pub fn pct_off(got: f64, want: f64) -> f64 {+ if want == 0.0 {+ return if got == 0.0 { 0.0 } else { f64::INFINITY };+ }+ (got - want).abs() / want.abs() * 100.0+}++/// A centre-of-mass coordinate is within tolerance when it is within+/// `tol_pct` of the key, or within 0.05 mm (a coordinate of 0 has no percent).+pub fn coord_ok(got: f64, want: f64, tol_pct: f64) -> bool {+ (got - want).abs() <= (want.abs() * tol_pct / 100.0).max(0.05)+}++/// COMPARE a swx_mass_properties reply with the key. Mass always; volume,+/// centre of mass and surface area when the key has them — except that a+/// SIMULATED reply (the mock) is compared on mass and volume only, the two+/// quantities its arithmetic model gets exactly. Returns (pass, the checks).+pub fn compare(spec: &Spec, mp: &Value) -> (bool, Value) {+ let simulated = mp.get("simulated").is_some();+ // The simulator's centre of mass is compared when its column model made+ // it (exact to the integration); its surface area never is.+ let com_simulated_ok = mp.pointer("/simulatedBy/volume").and_then(|v| v.as_str()).map(|v| v.contains("column")).unwrap_or(false);+ let mut checks = Vec::new();+ let mut pass = true;+ let mass = mp.get("massG").and_then(|m| m.as_f64()).or_else(|| mp.get("massKg").and_then(|m| m.as_f64()).map(|k| k * 1000.0));+ let mut one = |what: &str, got: Option<f64>, want: f64| {+ let off = got.map(|g| pct_off(g, want));+ let ok = off.map(|o| o <= spec.tol_pct).unwrap_or(false);+ pass &= ok;+ checks.push(json!({"quantity": what, "expected": want, "measured": got, "offPct": off, "pass": ok}));+ };+ one("massG", mass, spec.mass_g);+ if let Some(v) = spec.volume_mm3 {+ let got = mp.get("volumeMm3").and_then(|m| m.as_f64()).or_else(|| mp.get("volumeM3").and_then(|m| m.as_f64()).map(|v| v * 1.0e9));+ one("volumeMm3", got, v);+ }+ if let Some(a) = spec.area_mm2 {+ if simulated {+ checks.push(json!({"quantity": "surfaceAreaMm2", "expected": a, "pass": Value::Null, "skipped": "not compared in the simulator"}));+ } else {+ let got = mp.get("surfaceAreaMm2").and_then(|m| m.as_f64()).or_else(|| mp.get("surfaceAreaM2").and_then(|m| m.as_f64()).map(|v| v * 1.0e6));+ one("surfaceAreaMm2", got, a);+ }+ }+ if let Some(c) = spec.com_mm {+ if simulated && !com_simulated_ok {+ checks.push(json!({"quantity": "centerOfMassMm", "expected": c, "pass": Value::Null, "skipped": "not compared in the simulator"}));+ } else {+ let got: Vec<f64> = mp.get("centerOfMassMm").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();+ let ok = got.len() == 3 && (0..3).all(|i| coord_ok(got[i], c[i], spec.tol_pct));+ pass &= ok;+ checks.push(json!({"quantity": "centerOfMassMm", "expected": c, "measured": got, "pass": ok}));+ }+ }+ (pass, Value::Array(checks))+}++/// The one-line verdict: "N of M CSWA parts within tolerance".+pub fn summary(passed: usize, ran: usize, skipped: usize) -> String {+ let mut s = format!("{passed} of {ran} CSWA parts within tolerance");+ if skipped > 0 {+ s.push_str(&format!(" ({skipped} skipped: they need ops the bridge does not have yet)"));+ }+ s+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn every_shipped_spec_parses_and_the_list_is_the_directory() {+ for s in specs() {+ assert!(s.is_ok(), "{s:?}");+ }+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/cswa");+ let mut on_disk: Vec<String> = std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string())+ .filter(|n| n.ends_with(".json")).collect();+ on_disk.sort();+ let mut listed: Vec<String> = SPECS.iter().map(|(n, _)| n.to_string()).collect();+ listed.sort();+ assert_eq!(on_disk, listed, "every resources/cswa/*.json is embedded, and nothing else");+ // Spec 0 is the LEGO brick: Michael's measured volume.+ let lego = specs().remove(0).unwrap();+ assert_eq!(lego.id, "cswa-00-lego-2x4");+ assert_eq!(lego.volume_mm3, Some(2073.3615208));+ assert!((lego.mass_g - 2073.3615208 * 1.02e-3).abs() < 1e-6);+ }++ #[test]+ fn variables_and_extends_expand_and_the_python_checker_agrees() {+ let vars: std::collections::HashMap<String, f64> = [("A".to_string(), 81.0)].into_iter().collect();+ assert_eq!(eval("A - 43", &vars).unwrap(), 38.0);+ assert_eq!(eval("2^3^2", &vars).unwrap(), 512.0, "^ is right-associative");+ assert_eq!(eval("-2^2", &vars).unwrap(), 4.0, "unary minus binds tighter, as in the Python twin");+ assert!((eval("atan2(1, 1) - pi/4", &vars).unwrap()).abs() < 1e-15);+ assert!((eval("sqrt(29^2 - 19^2) + rad(180) - pi", &vars).unwrap() - (29f64 * 29.0 - 361.0).sqrt()).abs() < 1e-12);+ assert!(eval("B", &vars).unwrap_err().contains("unknown variable B"));+ assert!(eval("sqrt(-1)", &vars).is_err(), "not finite");+ assert!(eval("1 +", &vars).is_err() && eval("foo(1)", &vars).is_err() && eval("(1", &vars).is_err());+ // Step 2 is Step 1's script with other variables; Step 3 appends cuts.+ let all: Vec<Value> = SPECS.iter().map(|(_, t)| serde_json::from_str(t).unwrap()).collect();+ let by = |id: &str| all.iter().find(|v| v["id"] == id).unwrap();+ let s1 = expand(by("cswa-05-tool-block-step1"), &all).unwrap();+ let s2 = expand(by("cswa-06-tool-block-step2"), &all).unwrap();+ let s3 = expand(by("cswa-07-tool-block-step3"), &all).unwrap();+ assert_eq!(s1["script"][2]["depthMm"], 43.0);+ assert_eq!(s2["script"][2]["depthMm"], 45.0);+ assert_eq!(s2["script"].as_array().unwrap().len(), s1["script"].as_array().unwrap().len());+ assert_eq!(s3["script"].as_array().unwrap().len(), s1["script"].as_array().unwrap().len() + 7);+ // The recipe's R19 centre (21.54, 33.49) and tangent point (18.24, 14.78):+ // the tangent arc continues the 10° line that ends at T.+ let t = &s1["script"][1]["entities"][0]["line"]["to"];+ assert!((t[0].as_f64().unwrap() - 18.243084987875623).abs() < 1e-9 && (t[1].as_f64().unwrap() - 14.783251905279968).abs() < 1e-9, "{t}");+ // tools/cswa-check.py implements the same grammar: it must expand+ // every spec to the same numbers (when python3 is there to ask).+ let out = std::process::Command::new("python3")+ .arg(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tools/cswa-check.py"))+ .arg("--expand-only")+ .output();+ if let Some(o) = out.ok().filter(|o| o.status.success()) {+ let py: Value = serde_json::from_slice(&o.stdout).unwrap();+ for v in &all {+ let id = v["id"].as_str().unwrap();+ let rs = expand(v, &all).unwrap();+ let close = |a: &Value, b: &Value| -> bool {+ fn walk(a: &Value, b: &Value) -> bool {+ match (a, b) {+ (Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() <= 1e-9 * x.as_f64().unwrap().abs().max(1.0),+ (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| walk(p, q)),+ (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, p)| y.get(k).map(|q| walk(p, q)).unwrap_or(false)),+ _ => a == b,+ }+ }+ walk(a, b)+ };+ assert!(close(&rs["script"], &py[id]["script"]), "{id}: the Python checker expands the script differently");+ assert!(close(&rs["expected"], &py[id]["expected"]), "{id}: ...and the key");+ }+ }+ }++ #[test]+ fn a_reply_is_compared_within_the_tolerance() {+ let spec = parse_spec(&json!({"id": "t", "expected": {"massG": 100.0, "volumeMm3": 1000.0, "centerOfMassMm": [10.0, 0.0, -5.0], "surfaceAreaMm2": 500.0},+ "script": [{"op": "new_part"}]})).unwrap();+ let good = json!({"massG": 100.9, "volumeMm3": 1009.0, "centerOfMassMm": [10.09, 0.04, -5.04], "surfaceAreaMm2": 504.0});+ assert!(compare(&spec, &good).0, "{}", compare(&spec, &good).1);+ let off = json!({"massG": 101.1, "volumeMm3": 1000.0, "centerOfMassMm": [10.0, 0.0, -5.0], "surfaceAreaMm2": 500.0});+ assert!(!compare(&spec, &off).0, "1.1 % off is out");+ let com = json!({"massG": 100.0, "volumeMm3": 1000.0, "centerOfMassMm": [10.0, 0.06, -5.0], "surfaceAreaMm2": 500.0});+ assert!(!compare(&spec, &com).0, "0.06 mm off a zero coordinate is out");+ // massKg is read when massG is missing.+ let kg = json!({"massKg": 0.1, "volumeM3": 1.0e-6, "centerOfMassMm": [10.0, 0.0, -5.0], "surfaceAreaM2": 5.0e-4});+ assert!(compare(&spec, &kg).0, "{}", compare(&spec, &kg).1);+ // The simulator is held to mass and volume only.+ let sim = json!({"massG": 100.0, "volumeMm3": 1000.0, "simulated": "mock", "centerOfMassMm": [0, 0, 0], "surfaceAreaMm2": 1.0});+ assert!(compare(&spec, &sim).0);+ // ...except that the column model's centre of mass IS compared.+ let col = json!({"massG": 100.0, "volumeMm3": 1000.0, "simulated": "mock", "simulatedBy": {"volume": "column integration"}, "centerOfMassMm": [0, 0, 0], "surfaceAreaMm2": 1.0});+ assert!(!compare(&spec, &col).0, "a wrong centre of mass from the column model fails");+ assert!(!compare(&spec, &json!({})).0, "nothing measured is not a pass");+ assert_eq!(summary(3, 4, 1), "3 of 4 CSWA parts within tolerance (1 skipped: they need ops the bridge does not have yet)");+ }++ #[test]+ fn a_spec_is_refused_for_what_would_make_it_meaningless() {+ assert!(parse_spec(&json!({"id": "a", "expected": {}, "script": []})).unwrap_err().contains("massG"));+ assert!(parse_spec(&json!({"id": "a", "units": "IPS", "expected": {"massG": 1}, "script": []})).unwrap_err().contains("MMGS"));+ assert!(parse_spec(&json!({"id": "a", "expected": {"massG": 1}, "script": [{"op": "frobnicate"}]})).unwrap_err().contains("does not parse"));+ // A spec that needs a missing op is not parsed as a script: it is skipped.+ assert!(parse_spec(&json!({"id": "a", "expected": {"massG": 1}, "needsOps": ["loft"], "script": [{"op": "loft"}]})).is_ok());+ assert!(parse_spec(&json!({"id": "a", "expected": {"massG": 1, "tolerancePct": 9}, "script": [{"op": "new_part"}]})).unwrap_err().contains("tolerancePct"));+ }+}
src/lib.rs+1@@ -30,6 +30,7 @@ pub mod abwin; pub mod bom; pub mod caller; pub mod compat;+pub mod cswa; pub mod comhost; pub mod detect; pub mod findfiles;
src/modeling.rs+123−2@@ -94,7 +94,7 @@ impl Xform { /// A plane's frame in model space: origin, its own X and Y axes (a sketch on /// it draws in these), and its normal.-#[derive(Clone, Copy, Debug, PartialEq)]+#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PlaneFrame { pub origin: V3, pub x: V3,@@ -202,9 +202,75 @@ pub fn entity_to_sketch(frame: &PlaneFrame, h: f64, e: &Entity, m2s: &Xform) -> let c = m2s.apply(frame.point(cx, cy, h)); (Entity::Circle { cx: c[0], cy: c[1], r: r * m2s.s.abs() }, c[2].abs()) }+ Entity::Line { x0, y0, x1, y1, construction } => {+ let a = m2s.apply(frame.point(x0, y0, h));+ let b = m2s.apply(frame.point(x1, y1, h));+ (Entity::Line { x0: a[0], y0: a[1], x1: b[0], y1: b[1], construction }, a[2].abs().max(b[2].abs()))+ }+ Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => {+ let c = m2s.apply(frame.point(cx, cy, h));+ let a = m2s.apply(frame.point(x0, y0, h));+ let b = m2s.apply(frame.point(x1, y1, h));+ // A MIRRORING map turns counter-clockwise into clockwise: the arc+ // keeps its ends and centre, so its direction must flip with it.+ let ccw = if mapping_reflects(frame, h, m2s) { !ccw } else { ccw };+ (Entity::Arc { cx: c[0], cy: c[1], x0: a[0], y0: a[1], x1: b[0], y1: b[1], ccw, construction }, c[2].abs().max(a[2].abs()).max(b[2].abs()))+ } } } +/// Does the map from the plane's (u, v) into the sketch's (x, y) MIRROR (its+/// 2D determinant negative)? Then an arc's turning direction flips.+pub fn mapping_reflects(frame: &PlaneFrame, h: f64, m2s: &Xform) -> bool {+ let o = m2s.apply(frame.point(0.0, 0.0, h));+ let ex = sub(m2s.apply(frame.point(1.0, 0.0, h)), o);+ let ey = sub(m2s.apply(frame.point(0.0, 1.0, h)), o);+ ex[0] * ey[1] - ex[1] * ey[0] < 0.0+}++/// THE MOMENTS OF ONE CLOSED CONTOUR (0.11.0), exactly, by Green's theorem:+/// (A, Sx, Sy) = (∬dA, ∬x dA, ∬y dA), SIGNED — positive when the contour runs+/// counter-clockwise. Per segment:+/// * a line P→Q: ∮x dy − y dx = px·qy − qx·py; ∮x² dy = (qy−py)(px²+px·qx+qx²)/3;+/// ∮y² dx = (qx−px)(py²+py·qy+qy²)/3;+/// * an arc about (cx, cy) of radius r from θ0 sweeping Δ: the same three+/// integrals in closed form over θ (sin/cos, sin², cos², sin³, cos³).+/// A = ½∮(x dy − y dx), Sx = ½∮x² dy, Sy = −½∮y² dx.+pub fn contour_moments(entities: &[Entity], contour: &[(usize, bool)]) -> (f64, f64, f64) {+ let (mut cross, mut x2dy, mut y2dx) = (0.0, 0.0, 0.0);+ for &(i, rev) in contour {+ let e = &entities[i];+ match *e {+ Entity::Line { x0, y0, x1, y1, .. } => {+ let (px, py, qx, qy) = if rev { (x1, y1, x0, y0) } else { (x0, y0, x1, y1) };+ cross += px * qy - qx * py;+ x2dy += (qy - py) * (px * px + px * qx + qx * qx) / 3.0;+ y2dx += (qx - px) * (py * py + py * qy + qy * qy) / 3.0;+ }+ Entity::Arc { .. } => {+ let Some((cx, cy, r, t0, sweep)) = crate::script::arc_geom(e) else { continue };+ let (a, d) = if rev { (t0 + sweep, -sweep) } else { (t0, sweep) };+ let b = a + d;+ let (s0, c0) = a.sin_cos();+ let (s1, c1) = b.sin_cos();+ let int_c = s1 - s0;+ let int_s = -(c1 - c0);+ let int_c2 = d / 2.0 + ((2.0 * b).sin() - (2.0 * a).sin()) / 4.0;+ let int_s2 = d / 2.0 - ((2.0 * b).sin() - (2.0 * a).sin()) / 4.0;+ let int_c3 = (s1 - s1.powi(3) / 3.0) - (s0 - s0.powi(3) / 3.0);+ let int_s3 = (-c1 + c1.powi(3) / 3.0) - (-c0 + c0.powi(3) / 3.0);+ // x = cx + r cosθ, y = cy + r sinθ, dx = −r sinθ dθ, dy = r cosθ dθ.+ // x dy − y dx = (r² + cx·r·cosθ + cy·r·sinθ) dθ.+ cross += r * r * d + cx * r * int_c + cy * r * int_s;+ x2dy += cx * cx * r * int_c + 2.0 * cx * r * r * int_c2 + r * r * r * int_c3;+ y2dx += -(cy * cy * r * int_s + 2.0 * cy * r * r * int_s2 + r * r * r * int_s3);+ }+ _ => {}+ }+ }+ (cross / 2.0, x2dy / 2.0, -y2dx / 2.0)+}+ /// THE `Dir` FLAG for FeatureExtrusion3 / FeatureCut4. SOLIDWORKS extrudes a /// boss ALONG the sketch normal and cuts AGAINST it ("The default sketch normal /// is the same as the face or plane normal… boss… along the sketch normal; cut…@@ -221,6 +287,17 @@ pub fn dir_flag(sketch_normal: Option<V3>, reference: Option<V3>, reverse: bool) (flipped ^ reverse, flipped) } +/// The swEndConditions_e id of an up-to end condition (verified on+/// help.solidworks.com, swconst): UpToNext 11, UpToSelection 10 (the face+/// selected with mark 1 — UpToSurface 4 is the obsolete id), OffsetFromSurface 5.+pub fn end_condition_id(u: &crate::script::UpTo) -> i32 {+ match u {+ crate::script::UpTo::Next => 11,+ crate::script::UpTo::Surface { .. } => 10,+ crate::script::UpTo::OffsetFrom { .. } => 5,+ }+}+ /// Why a geometric pick found no single entity. #[derive(Clone, Debug, PartialEq)] pub enum PickError {@@ -690,7 +767,7 @@ mod tests { #[test] fn spans_follow_the_ops_own_direction() {- let e = |depth: f64, depth2: f64, direction: Direction, through_all: bool| Extent { depth, depth2, through_all, reverse: false, direction };+ let e = |depth: f64, depth2: f64, direction: Direction, through_all: bool| Extent { depth, depth2, through_all, reverse: false, direction , up_to: None, draft: None }; assert_eq!(extent_span(0.0, 1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (0.0, 5.0)); assert_eq!(extent_span(0.0, -1.0, &e(5.0, 5.0, Direction::One, false), (0.0, 10.0)), (-5.0, 0.0)); // The audit's T27: a cut on Front Plane (own direction −Z), both 6 / 2.@@ -828,4 +905,48 @@ mod tests { assert_eq!(parse_iid_probe("guid=00000000-0000-0000-0000-000000000000\nitype=0"), None); assert_eq!(parse_iid_probe("nothing"), None); }++ #[test]+ fn contour_moments_are_greens_theorem_exactly() {+ use crate::script::Entity;+ let l = |x0: f64, y0: f64, x1: f64, y1: f64| Entity::Line { x0, y0, x1, y1, construction: false };+ // The unit square counter-clockwise: A 1, centroid (½, ½).+ let sq = [l(0.0, 0.0, 1.0, 0.0), l(1.0, 0.0, 1.0, 1.0), l(1.0, 1.0, 0.0, 1.0), l(0.0, 1.0, 0.0, 0.0)];+ let c: Vec<(usize, bool)> = (0..4).map(|i| (i, false)).collect();+ let (a, sx, sy) = contour_moments(&sq, &c);+ assert!((a - 1.0).abs() < 1e-15 && (sx - 0.5).abs() < 1e-15 && (sy - 0.5).abs() < 1e-15, "{a} {sx} {sy}");+ // Walked backwards: the same moments, negated.+ let back: Vec<(usize, bool)> = (0..4).rev().map(|i| (i, true)).collect();+ let (b, bx, by) = contour_moments(&sq, &back);+ assert!((b + 1.0).abs() < 1e-15 && (bx + 0.5).abs() < 1e-15 && (by + 0.5).abs() < 1e-15);+ // A half disc r 2 about (3, 1), the arc on top: A = 2π, ȳ = 1 + 8/(3π).+ let hd = [+ Entity::Arc { cx: 3.0, cy: 1.0, x0: 5.0, y0: 1.0, x1: 1.0, y1: 1.0, ccw: true, construction: false },+ l(1.0, 1.0, 5.0, 1.0),+ ];+ let (a, sx, sy) = contour_moments(&hd, &[(0, false), (1, false)]);+ assert!((a - 2.0 * std::f64::consts::PI).abs() < 1e-12, "{a}");+ assert!((sx / a - 3.0).abs() < 1e-12 && (sy / a - (1.0 + 8.0 / (3.0 * std::f64::consts::PI))).abs() < 1e-12, "{} {}", sx / a, sy / a);+ // The same arc clockwise is the lower half: ȳ = 1 − 8/(3π), and the+ // contour (arc then line back) runs clockwise, so A is negative.+ let lo = [Entity::Arc { cx: 3.0, cy: 1.0, x0: 5.0, y0: 1.0, x1: 1.0, y1: 1.0, ccw: false, construction: false }, l(1.0, 1.0, 5.0, 1.0)];+ let (a, _, sy) = contour_moments(&lo, &[(0, false), (1, false)]);+ assert!((a + 2.0 * std::f64::consts::PI).abs() < 1e-12 && (sy / a - (1.0 - 8.0 / (3.0 * std::f64::consts::PI))).abs() < 1e-12);+ }++ #[test]+ fn a_mirroring_sketch_map_flips_an_arc() {+ use crate::script::Entity;+ let front = PlaneFrame::defaults()[0];+ let arc = Entity::Arc { cx: 0.0, cy: 0.0, x0: 1.0, y0: 0.0, x1: 0.0, y1: 1.0, ccw: true, construction: false };+ let (same, _) = entity_to_sketch(&front, 0.0, &arc, &Xform::identity());+ assert!(matches!(same, Entity::Arc { ccw: true, .. }));+ // A sketch whose x runs the other way (a plane SOLIDWORKS flipped).+ let flip = Xform { rows: [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], t: [0.0; 3], s: 1.0 };+ assert!(mapping_reflects(&front, 0.0, &flip));+ let (m, z) = entity_to_sketch(&front, 0.0, &arc, &flip);+ assert!(matches!(m, Entity::Arc { ccw: false, x0, .. } if x0 == -1.0) && z == 0.0, "{m:?}");+ let line = Entity::Line { x0: 1.0, y0: 2.0, x1: 3.0, y1: 4.0, construction: true };+ assert_eq!(entity_to_sketch(&front, 0.0, &line, &flip).0, Entity::Line { x0: -1.0, y0: 2.0, x1: -3.0, y1: 4.0, construction: true });+ } }
src/script.rs+1233−35@@ -57,6 +57,12 @@ pub enum PlaneRef { Offset { from: String, offset: f64 }, /// The planar face of the part that passes through `point` (metres). Face { point: [f64; 3] },+ /// A plane `offset` metres from the planar face through `point`, along the+ /// face's OUTWARD normal (negative = into the part) — 0.11.0, CSWA Tool Block+ /// step 3's "plane 12 from the back face". Built as an offset of the+ /// parallel default plane; a sketch on it keeps that plane's axes, and the+ /// face's outward normal stays the reference (a cut goes inward).+ FaceOffset { point: [f64; 3], offset: f64 }, } impl PlaneRef {@@ -65,17 +71,31 @@ impl PlaneRef { 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]}}),+ PlaneRef::FaceOffset { point, offset } => serde_json::json!({"offsetFrom": {"face": {"atPointMm": [point[0] / MM, point[1] / MM, point[2] / MM]}}, "offsetMm": offset / MM}), } } } -/// One closed profile in a sketch, in the plane's 2D coordinates (metres).+/// One sketch entity, 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)]+///+/// LINES AND ARCS (0.11.0) are the segments of closed CONTOURS: every+/// composite the caller writes — `polyline`, `slot`, `polygon`, a rectangle+/// with `cornerRadiusMm`, `arc3`, `tangentArc` — is EXPANDED at parse time into+/// these two, so both backends draw (and the simulator measures) exactly the+/// same segments, and the contours are checked closed before anything is drawn+/// (`contours`). A `construction` segment is drawn as construction geometry: it+/// is never part of a profile (a revolve axis, a reference line).+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum Entity { Rect { x0: f64, y0: f64, x1: f64, y1: f64 }, Circle { cx: f64, cy: f64, r: f64 },+ /// A straight segment from (x0, y0) to (x1, y1).+ Line { x0: f64, y0: f64, x1: f64, y1: f64, construction: bool },+ /// A circular arc about (cx, cy) from (x0, y0) to (x1, y1), counter-clockwise+ /// when `ccw` (in the plane's own 2D frame). Its radius is |start − centre|.+ Arc { cx: f64, cy: f64, x0: f64, y0: f64, x1: f64, y1: f64, ccw: bool, construction: bool }, } impl Entity {@@ -83,8 +103,117 @@ impl Entity { 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}}),+ Entity::Line { x0, y0, x1, y1, construction } => {+ let mut v = serde_json::json!({"line": {"fromMm": [x0 / MM, y0 / MM], "toMm": [x1 / MM, y1 / MM]}});+ if *construction {+ v["line"]["construction"] = Value::Bool(true);+ }+ v+ }+ Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, construction } => {+ let mut v = serde_json::json!({"arc": {"centerMm": [cx / MM, cy / MM], "startMm": [x0 / MM, y0 / MM],+ "endMm": [x1 / MM, y1 / MM], "radiusMm": ((x0 - cx).hypot(y0 - cy)) / MM, "ccw": ccw}});+ if *construction {+ v["arc"]["construction"] = Value::Bool(true);+ }+ v+ }+ }+ }++ /// A segment of a contour: a line or an arc that is not construction.+ pub fn is_segment(&self) -> bool {+ matches!(self, Entity::Line { construction: false, .. } | Entity::Arc { construction: false, .. })+ }++ pub fn is_construction(&self) -> bool {+ matches!(self, Entity::Line { construction: true, .. } | Entity::Arc { construction: true, .. })+ }++ /// A line's or an arc's two ends (start, end).+ pub fn ends(&self) -> Option<((f64, f64), (f64, f64))> {+ match *self {+ Entity::Line { x0, y0, x1, y1, .. } | Entity::Arc { x0, y0, x1, y1, .. } => Some(((x0, y0), (x1, y1))),+ _ => None,+ }+ }+}++/// How close two segment ends must be to JOIN (1e-6 mm, in metres).+pub const JOIN_TOL: f64 = 1e-9;++/// THE CLOSED CONTOURS of a sketch's lines and arcs (0.11.0): each contour is+/// its segments in order, as (entity index, reversed). A segment end that+/// joins nothing is an OPEN profile and is refused, naming the gap; a point+/// where three or more segments meet is a branch and is refused too.+/// Rectangles and circles are closed by themselves and are not listed;+/// construction geometry is never part of a contour.+pub fn contours(entities: &[Entity]) -> Result<Vec<Vec<(usize, bool)>>, (usize, String)> {+ let segs: Vec<usize> = (0..entities.len()).filter(|i| entities[*i].is_segment()).collect();+ let ends = |i: usize| entities[i].ends().unwrap_or(((0.0, 0.0), (0.0, 0.0)));+ let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).hypot(a.1 - b.1) <= JOIN_TOL;+ let fmt = |p: (f64, f64)| format!("[{}, {}] mm", trim_mm(p.0), trim_mm(p.1));+ for &i in &segs {+ for p in [ends(i).0, ends(i).1] {+ let n: usize = segs.iter().map(|&j| [ends(j).0, ends(j).1].iter().filter(|q| close(p, **q)).count()).sum();+ if n > 2 {+ return Err((i, format!("{n} segment ends meet at {} — a profile's corner joins exactly two (a branch cannot be extruded)", fmt(p))));+ } } }+ let mut used = vec![false; entities.len()];+ let mut out = Vec::new();+ for &first in &segs {+ if used[first] {+ continue;+ }+ used[first] = true;+ let start = ends(first).0;+ let mut cur = ends(first).1;+ let mut contour = vec![(first, false)];+ while !close(cur, start) {+ let next = segs.iter().copied().find(|&j| !used[j] && (close(ends(j).0, cur) || close(ends(j).1, cur)));+ match next {+ Some(j) => {+ used[j] = true;+ let rev = !close(ends(j).0, cur);+ contour.push((j, rev));+ cur = if rev { ends(j).0 } else { ends(j).1 };+ }+ None => {+ // The gap: from the dangling end to the nearest end it could+ // have joined (the contour's own start included).+ let mut best: Option<((f64, f64), f64)> = Some((start, (cur.0 - start.0).hypot(cur.1 - start.1)));+ for &j in segs.iter().filter(|j| !used[**j]) {+ for q in [ends(j).0, ends(j).1] {+ let d = (cur.0 - q.0).hypot(cur.1 - q.1);+ if best.map(|b| d < b.1).unwrap_or(true) {+ best = Some((q, d));+ }+ }+ }+ let (q, d) = best.unwrap_or((start, 0.0));+ return Err((+ contour.last().map(|c| c.0).unwrap_or(first),+ format!(+ "the contour is OPEN: it ends at {} and no segment continues from there — the nearest free end is {}, a gap of {} mm. A profile must close (its last point equal to its first within 1e-6 mm); mark a reference line construction:true",+ fmt(cur),+ fmt(q),+ trim_mm(d)+ ),+ ));+ }+ }+ }+ out.push(contour);+ }+ Ok(out)+}++/// Metres as millimetres for a message, without float noise.+fn trim_mm(m: f64) -> String {+ let v = (m / MM * 1.0e6).round() / 1.0e6;+ if v == v.trunc() { format!("{v:.1}") } else { format!("{v}") } } /// The one-entity shorthands' result fields (`widthMm`/`heightMm`,@@ -100,6 +229,7 @@ pub fn single_entity_fields(entities: &[Entity], out: &mut Value) { Entity::Circle { r, .. } => { m.insert("radiusMm".into(), serde_json::json!(r / MM)); }+ _ => {} } } }@@ -124,6 +254,64 @@ pub struct Extent { pub through_all: bool, pub reverse: bool, pub direction: Direction,+ /// An end condition other than a depth (0.11.0): up to the next face, up+ /// to a face, or a distance short of a face.+ pub up_to: Option<UpTo>,+ /// A taper (0.11.0): `angle` radians from the extrude direction; inward+ /// (the profile shrinks as it goes) unless `outward`.+ pub draft: Option<Draft>,+}++impl Extent {+ /// A plain blind extent `depth` one way.+ pub fn blind(depth: f64) -> Extent {+ Extent { depth, depth2: depth, through_all: false, reverse: false, direction: Direction::One, up_to: None, draft: None }+ }+}++/// END CONDITIONS beyond blind / through all (swEndConditions_e, verified on+/// help.solidworks.com: UpToNext 11, UpToSelection 10 — UpToSurface 4 is the+/// deprecated id — and OffsetFromSurface 5).+#[derive(Debug, Clone, PartialEq)]+pub enum UpTo {+ /// Up to the next face the profile meets.+ Next,+ /// Up to the face through `point` (metres), picked geometrically.+ Surface { point: [f64; 3] },+ /// `offset` metres SHORT of the face through `point` (`reverse_offset`:+ /// beyond it).+ OffsetFrom { point: [f64; 3], offset: f64, reverse_offset: bool },+}++#[derive(Debug, Clone, Copy, PartialEq)]+pub struct Draft {+ pub angle: f64,+ pub outward: bool,+}++/// A revolve's axis as the caller wrote it (0.11.0): a line entity of the+/// sketch, a line given by two points, or the sketch's own x / y axis.+#[derive(Debug, Clone, PartialEq)]+pub enum AxisSpec {+ /// The caller's entity `i` (0-based, as written) of the sketch — a `line`.+ Entity(usize),+ Line([f64; 2], [f64; 2]),+ SketchX,+ SketchY,+}++/// A revolve or a revolve-cut of the sketch just closed.+#[derive(Debug, Clone, PartialEq)]+pub struct Revolution {+ /// The axis, resolved at parse time to a LINE entity of the sketch (an+ /// axis given by points or as x / y is added to the sketch as a+ /// construction line).+ pub axis: usize,+ pub axis_spec: AxisSpec,+ /// Radians, (0, 2π].+ pub angle: f64,+ pub mid_plane: bool,+ pub reverse: bool, } /// A model axis (patterns, directions).@@ -190,7 +378,12 @@ pub enum Op { Extrude(Extent), /// Cut-extrude through the sketch just closed. Cut(Extent),- SetMaterial { name: String, database: String },+ /// A material from a library, and/or the part's density (0.11.0): with+ /// `density` alone (kg/m³) the document's own material density is set+ /// (no library material may be applied); with both, the material is+ /// applied and its density must be the one asked for. `name` is empty+ /// when only a density is given.+ SetMaterial { name: String, database: String, density: Option<f64> }, /// Colour, as 0..1 RGB (the caller writes 0..255). SetAppearance { rgb: [f64; 3], target: AppearanceTarget }, Fillet { radius: f64, edges: EdgeSel },@@ -198,6 +391,16 @@ pub enum Op { Chamfer { distance: f64, angle: f64, edges: EdgeSel }, /// Hollow the part, removing the faces through `faces`. Shell { thickness: f64, faces: Vec<[f64; 3]>, outward: bool },+ /// Revolve the sketch just closed about a line of it (0.11.0).+ Revolve(Revolution),+ /// Revolve-cut the sketch just closed (FeatureRevolve2 with IsCut).+ RevolveCut(Revolution),+ /// Mirror features (or every body) about a plane or a planar face (0.11.0).+ Mirror { features: Vec<String>, bodies: bool, plane: PlaneRef },+ /// A HOLE at a point on a planar face (0.11.0): cuts of circles sketched on+ /// that face's plane — a counterbore or a countersink first, then the+ /// hole from where it ends. `depth` None = through all.+ Hole { at: [f64; 3], diameter: f64, depth: Option<f64>, counterbore: Option<(f64, f64)>, countersink: Option<(f64, f64)> }, /// 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).@@ -223,6 +426,10 @@ impl Op { Op::Fillet { .. } => "fillet", Op::Chamfer { .. } => "chamfer", Op::Shell { .. } => "shell",+ Op::Revolve(_) => "revolve",+ Op::RevolveCut(_) => "revolve_cut",+ Op::Mirror { .. } => "mirror",+ Op::Hole { .. } => "hole", Op::LinearPattern { .. } => "linear_pattern", Op::CircularPattern { .. } => "circular_pattern", Op::SetProp { .. } => "set_prop",@@ -246,6 +453,10 @@ impl Op { | Op::Fillet { .. } | Op::Chamfer { .. } | Op::Shell { .. }+ | Op::Revolve(_)+ | Op::RevolveCut(_)+ | Op::Mirror { .. }+ | Op::Hole { .. } | Op::LinearPattern { .. } | Op::CircularPattern { .. } | Op::ClearPart@@ -259,6 +470,7 @@ impl Op { self, Op::Extrude(_) | Op::Cut(_) | Op::Fillet { .. } | Op::Chamfer { .. } | Op::Shell { .. } | Op::LinearPattern { .. } | Op::CircularPattern { .. }+ | Op::Revolve(_) | Op::RevolveCut(_) | Op::Mirror { .. } | Op::Hole { .. } ) } }@@ -278,6 +490,10 @@ pub const OPS: &[&str] = &[ "fillet", "chamfer", "shell",+ "revolve",+ "revolve_cut",+ "mirror",+ "hole", "linear_pattern", "circular_pattern", "set_prop",@@ -556,9 +772,99 @@ pub fn parse(script: &Value) -> Result<Vec<Op>, String> { for (i, step) in steps.iter().enumerate() { ops.push(parse_step(step).map_err(|e| format!("step {}: {e}", i + 1))?); }+ // A REVOLVE'S AXIS, resolved into the sketch it consumes (0.11.0).+ let mut last_sketch: Option<usize> = None;+ for i in 0..ops.len() {+ match &ops[i] {+ Op::Sketch { .. } => last_sketch = Some(i),+ Op::Revolve(_) | Op::RevolveCut(_) => {+ if let Some(j) = last_sketch.take() {+ resolve_axis(&mut ops, j, i, &steps[j]).map_err(|e| format!("step {}: {e}", i + 1))?;+ }+ }+ Op::Extrude(_) | Op::Cut(_) | Op::NewPart { .. } | Op::NewAssembly { .. } | Op::ClearPart | Op::Hole { .. } => last_sketch = None,+ _ => {}+ }+ } Ok(ops) } +/// Put revolve `r`'s axis into sketch `j`: the line entity it names, or a+/// construction line added for an axis given by points or as x / y. Then+/// check the profile lies on ONE side of it (SOLIDWORKS revolves nothing else).+fn resolve_axis(ops: &mut [Op], j: usize, r: usize, sketch_step: &Value) -> Result<(), String> {+ let spec = match &ops[r] {+ Op::Revolve(rv) | Op::RevolveCut(rv) => rv.axis_spec.clone(),+ _ => return Ok(()),+ };+ let Op::Sketch { entities, .. } = &mut ops[j] else { return Ok(()) };+ let line_from = |a: (f64, f64), b: (f64, f64)| Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction: true };+ let span = |ents: &[Entity], a: f64, b: f64| {+ let (lo, hi) = ents.iter().map(|e| extent_along(e, a, b)).fold((f64::MAX, f64::MIN), |m, x| (m.0.min(x.0), m.1.max(x.1)));+ if lo > hi { (-0.01, 0.01) } else { (lo - 0.001, hi + 0.001) }+ };+ let axis = match spec {+ AxisSpec::Entity(k) => {+ let ranges = match sketch_step.get("entities").and_then(|e| e.as_array()) {+ Some(list) => sketch_entities(list)?.1,+ None => vec![0..entities.len()],+ };+ let range = ranges.get(k).cloned().ok_or_else(|| format!("axis.entity {k} — the sketch has {} entities (0-based)", ranges.len()))?;+ if range.len() != 1 || !matches!(entities[range.start], Entity::Line { .. }) {+ return Err(format!("axis.entity {k} is not a line — a revolve axis is a `line` or `centerline` entity"));+ }+ range.start+ }+ AxisSpec::Line(a, b) => {+ entities.push(line_from((a[0], a[1]), (b[0], b[1])));+ entities.len() - 1+ }+ AxisSpec::SketchX => {+ let (lo, hi) = span(entities, 1.0, 0.0);+ entities.push(line_from((lo, 0.0), (hi, 0.0)));+ entities.len() - 1+ }+ AxisSpec::SketchY => {+ let (lo, hi) = span(entities, 0.0, 1.0);+ entities.push(line_from((0.0, lo), (0.0, hi)));+ entities.len() - 1+ }+ };+ profile_one_side(entities, axis)?;+ if let Op::Revolve(rv) | Op::RevolveCut(rv) = &mut ops[r] {+ rv.axis = axis;+ }+ Ok(())+}++/// The profile (every entity but construction and the axis itself) on ONE+/// side of the axis line; touching it is allowed.+pub fn profile_one_side(entities: &[Entity], axis: usize) -> Result<(), String> {+ let Some(((ax, ay), (bx, by))) = entities.get(axis).and_then(|e| e.ends()) else {+ return Err("the revolve axis is not a line".into());+ };+ let l = (bx - ax).hypot(by - ay);+ // Signed distance from the axis: n·p − n·a with n the axis's left normal.+ let (nx, ny) = (-(by - ay) / l, (bx - ax) / l);+ let off = nx * ax + ny * ay;+ let (mut lo, mut hi) = (f64::MAX, f64::MIN);+ for (i, e) in entities.iter().enumerate() {+ if i == axis || e.is_construction() {+ continue;+ }+ let (a, b) = extent_along(e, nx, ny);+ lo = lo.min(a - off);+ hi = hi.max(b - off);+ }+ if lo < -JOIN_TOL && hi > JOIN_TOL {+ return Err(format!(+ "the profile crosses the revolve axis (it reaches {} mm on one side and {} mm on the other) — SOLIDWORKS revolves only a profile on ONE side of its axis",+ trim_mm(-lo), trim_mm(hi)+ ));+ }+ Ok(())+}+ 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];@@ -594,6 +900,19 @@ fn plane_ref(step: &Value) -> Result<PlaneRef, String> { 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(_)) if o.get("offsetFrom").map(|f| f.is_object()).unwrap_or(false) => {+ let f = &o["offsetFrom"];+ let point = point3(+ f.get("face").and_then(|x| x.get("atPointMm")).or_else(|| f.get("atPointMm"))+ .ok_or_else(|| "plane.offsetFrom as an object is a face: {\"face\": {\"atPointMm\": [x, y, z]}}".to_string())?,+ "plane.offsetFrom.face.atPointMm",+ )?;+ let off = mm(o, "offsetMm", None).map_err(|e| format!("plane.{e} (along the face's OUTWARD normal; negative = into the part)"))?;+ if off == 0.0 {+ return Ok(PlaneRef::Face { point });+ }+ Ok(PlaneRef::FaceOffset { point, offset: off })+ } 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())?;@@ -609,7 +928,7 @@ fn plane_ref(step: &Value) -> Result<PlaneRef, String> { /// A centre given as `centerMm: [x, y]`, if present. fn center2(v: &Value) -> Result<Option<(f64, f64)>, String> {- match v.get("centerMm") {+ match v.get("centerMm").or_else(|| v.get("center")) { 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())?;@@ -635,6 +954,410 @@ fn rect(v: &Value) -> Result<Entity, String> { Ok(Entity::Rect { x0, y0, x1: x0 + w, y1: y0 + h }) } +/// A rectangle as the entities it is drawn with: one `Rect`, or — with+/// `cornerRadiusMm` (0.11.0) — four lines and four quarter arcs, or four+/// construction lines with `construction:true`.+fn rect_entities(v: &Value) -> Result<Vec<Entity>, String> {+ let r = rect(v)?;+ let Entity::Rect { x0, y0, x1, y1 } = r else { return Ok(vec![r]) };+ let construction = flag(v, "construction", false);+ let rc = match v.get("cornerRadiusMm").filter(|c| !c.is_null()) {+ None => 0.0,+ Some(_) => {+ let rc = positive_mm(v, "cornerRadiusMm")?;+ let half = ((x1 - x0).min(y1 - y0)) / 2.0;+ if rc > half + JOIN_TOL {+ return Err(format!("cornerRadiusMm {} is more than half the rectangle's shorter side ({} mm)", trim_mm(rc), trim_mm(half)));+ }+ rc.min(half)+ }+ };+ if rc == 0.0 && !construction {+ return Ok(vec![r]);+ }+ let mut out = Vec::new();+ let line = |a: (f64, f64), b: (f64, f64), out: &mut Vec<Entity>| {+ if (a.0 - b.0).hypot(a.1 - b.1) > JOIN_TOL {+ out.push(Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction });+ }+ };+ let arc = |c: (f64, f64), a: (f64, f64), b: (f64, f64), out: &mut Vec<Entity>| {+ if rc > 0.0 {+ out.push(Entity::Arc { cx: c.0, cy: c.1, x0: a.0, y0: a.1, x1: b.0, y1: b.1, ccw: true, construction });+ }+ };+ line((x0 + rc, y0), (x1 - rc, y0), &mut out);+ arc((x1 - rc, y0 + rc), (x1 - rc, y0), (x1, y0 + rc), &mut out);+ line((x1, y0 + rc), (x1, y1 - rc), &mut out);+ arc((x1 - rc, y1 - rc), (x1, y1 - rc), (x1 - rc, y1), &mut out);+ line((x1 - rc, y1), (x0 + rc, y1), &mut out);+ arc((x0 + rc, y1 - rc), (x0 + rc, y1), (x0, y1 - rc), &mut out);+ line((x0, y1 - rc), (x0, y0 + rc), &mut out);+ arc((x0 + rc, y0 + rc), (x0, y0 + rc), (x0 + rc, y0), &mut out);+ Ok(out)+}++/// A circle as drawn: one `Circle`, or two construction half arcs.+fn circle_entities(v: &Value) -> Result<Vec<Entity>, String> {+ let c = circle(v)?;+ match (c.clone(), flag(v, "construction", false)) {+ (Entity::Circle { cx, cy, r }, true) => Ok(vec![+ Entity::Arc { cx, cy, x0: cx + r, y0: cy, x1: cx - r, y1: cy, ccw: true, construction: true },+ Entity::Arc { cx, cy, x0: cx - r, y0: cy, x1: cx + r, y1: cy, ccw: true, construction: true },+ ]),+ _ => Ok(vec![c]),+ }+}++/// A 2D point under the first of `keys` present, as [x, y] millimetres.+fn pt2(v: &Value, keys: &[&str]) -> Result<Option<(f64, f64)>, String> {+ for k in keys {+ if let Some(c) = v.get(*k).filter(|c| !c.is_null()) {+ let a = c.as_array().filter(|a| a.len() == 2).ok_or_else(|| format!("{k} must be [x, y] in millimetres"))?;+ let f = |i: usize| a[i].as_f64().filter(|f| f.is_finite()).map(|f| f * MM).ok_or_else(|| format!("{k} must be two finite numbers (mm)"));+ return Ok(Some((f(0)?, f(1)?)));+ }+ }+ Ok(None)+}++fn need_pt2(v: &Value, keys: &[&str]) -> Result<(f64, f64), String> {+ pt2(v, keys)?.ok_or_else(|| format!("{} ([x, y] in millimetres) is required", keys[0]))+}++/// Degrees under `key` (default `default`), as radians.+fn degrees(v: &Value, key: &str, default: f64) -> Result<f64, String> {+ match v.get(key).filter(|d| !d.is_null()) {+ None => Ok(default.to_radians()),+ Some(d) => d.as_f64().filter(|f| f.is_finite()).map(|f| f.to_radians()).ok_or_else(|| format!("{key} must be a finite number of degrees")),+ }+}++fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {+ (a.0 - b.0).hypot(a.1 - b.1)+}++/// `line {from, to}` (`centerline` = a construction line).+fn line_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {+ let a = need_pt2(v, &["from", "fromMm"])?;+ let b = need_pt2(v, &["to", "toMm"])?;+ if dist(a, b) <= JOIN_TOL {+ return Err("a line's from and to are the same point".into());+ }+ Ok(vec![Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction }])+}++/// `polyline {points, close}`: a line between each pair of points.+fn polyline_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {+ let pts = v.get("points").or_else(|| v.get("pointsMm")).and_then(|p| p.as_array()).filter(|a| a.len() >= 2)+ .ok_or_else(|| "polyline needs points: at least two [x, y] (mm)".to_string())?;+ let mut ps = Vec::new();+ for (i, p) in pts.iter().enumerate() {+ ps.push(need_pt2(&serde_json::json!({"point": p}), &["point"]).map_err(|e| format!("point {}: {e}", i + 1))?);+ }+ if flag(v, "close", false) && dist(ps[0], ps[ps.len() - 1]) > JOIN_TOL {+ ps.push(ps[0]);+ }+ let mut out = Vec::new();+ for (i, w) in ps.windows(2).enumerate() {+ if dist(w[0], w[1]) <= JOIN_TOL {+ return Err(format!("points {} and {} are the same point", i + 1, i + 2));+ }+ out.push(Entity::Line { x0: w[0].0, y0: w[0].1, x1: w[1].0, y1: w[1].1, construction });+ }+ Ok(out)+}++/// `arc {center, radiusMm, startDeg, endDeg, ccw}` (CreateArc's direction:+/// counter-clockwise unless `ccw:false`).+fn arc_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {+ let c = need_pt2(v, &["center", "centerMm"])?;+ let r = positive_mm(v, "radiusMm")?;+ let a0 = degrees(v, "startDeg", f64::NAN)?;+ let a1 = degrees(v, "endDeg", f64::NAN)?;+ if a0.is_nan() || a1.is_nan() {+ return Err("arc needs startDeg and endDeg".into());+ }+ let sweep = (a1 - a0).rem_euclid(std::f64::consts::TAU);+ if sweep < 1e-9 || sweep > std::f64::consts::TAU - 1e-9 {+ return Err("an arc's startDeg and endDeg are the same angle — for a full circle use circle".into());+ }+ let (s0, c0) = a0.sin_cos();+ let (s1, c1) = a1.sin_cos();+ Ok(vec![Entity::Arc { cx: c.0, cy: c.1, x0: c.0 + r * c0, y0: c.1 + r * s0, x1: c.0 + r * c1, y1: c.1 + r * s1, ccw: flag(v, "ccw", true), construction }])+}++/// `arc3 {start, mid, end}`: the arc through three points.+fn arc3_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {+ let a = need_pt2(v, &["start", "startMm"])?;+ let b = need_pt2(v, &["mid", "midMm"])?;+ let c = need_pt2(v, &["end", "endMm"])?;+ let cross = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);+ if cross.abs() <= 1e-9 * dist(a, b) * dist(a, c) || dist(a, c) <= JOIN_TOL {+ return Err("arc3's three points are in a line (or start equals end) — no arc passes through them".into());+ }+ let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));+ let (a2, b2, c2) = (a.0 * a.0 + a.1 * a.1, b.0 * b.0 + b.1 * b.1, c.0 * c.0 + c.1 * c.1);+ let ux = (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d;+ let uy = (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d;+ Ok(vec![Entity::Arc { cx: ux, cy: uy, x0: a.0, y0: a.1, x1: c.0, y1: c.1, ccw: cross > 0.0, construction }])+}++/// The direction a line or an arc LEAVES its end in.+fn end_tangent(e: &Entity) -> Option<((f64, f64), (f64, f64))> {+ match *e {+ Entity::Line { x0, y0, x1, y1, .. } => {+ let l = (x1 - x0).hypot(y1 - y0);+ Some(((x1, y1), ((x1 - x0) / l, (y1 - y0) / l)))+ }+ Entity::Arc { cx, cy, x1, y1, ccw, .. } => {+ let (rx, ry) = (x1 - cx, y1 - cy);+ let l = rx.hypot(ry);+ Some(((x1, y1), if ccw { (-ry / l, rx / l) } else { (ry / l, -rx / l) }))+ }+ _ => None,+ }+}++/// `tangentArc {to, radiusMm?}`: the arc that leaves the PREVIOUS entity's end+/// along its direction and ends at `to` — there is exactly one; `radiusMm`, if+/// given, must be its radius.+fn tangent_arc_entities(v: &Value, construction: bool, prev: Option<&Entity>) -> Result<Vec<Entity>, String> {+ let (p, t) = prev.and_then(end_tangent).ok_or_else(|| "tangentArc continues from the entity before it, which must be a line or an arc".to_string())?;+ let q = need_pt2(v, &["to", "toMm"])?;+ let n = (-t.1, t.0);+ let d = (q.0 - p.0, q.1 - p.1);+ let s = d.0 * n.0 + d.1 * n.1;+ let len2 = d.0 * d.0 + d.1 * d.1;+ if len2.sqrt() <= JOIN_TOL || s.abs() <= 1e-9 * len2.sqrt() {+ return Err("tangentArc's `to` lies on the tangent line (or is the start point) — that is a straight line, not an arc".into());+ }+ let r = len2 / (2.0 * s);+ if let Some(want) = v.get("radiusMm").filter(|r| !r.is_null()) {+ let want = want.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| "radiusMm must be greater than zero".to_string())? * MM;+ if (want - r.abs()).abs() > JOIN_TOL {+ return Err(format!(+ "a tangent arc from [{}, {}] to [{}, {}] mm has radius {} mm, not the radiusMm given — omit radiusMm or move `to`",+ trim_mm(p.0), trim_mm(p.1), trim_mm(q.0), trim_mm(q.1), trim_mm(r.abs())+ ));+ }+ }+ Ok(vec![Entity::Arc { cx: p.0 + n.0 * r, cy: p.1 + n.1 * r, x0: p.0, y0: p.1, x1: q.0, y1: q.1, ccw: s > 0.0, construction }])+}++/// `slot {center, lengthMm (centre to centre), widthMm, angleDeg}` — the+/// straight slot ISketchManager::CreateSketchSlot draws, drawn here as its two+/// lines and two half arcs so both backends and the closure check see the+/// same segments.+fn slot_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {+ let c = need_pt2(v, &["center", "centerMm"])?;+ let len = positive_mm(v, "lengthMm")?;+ let r = positive_mm(v, "widthMm")? / 2.0;+ let (s, co) = degrees(v, "angleDeg", 0.0)?.sin_cos();+ let (u, n) = ((co, s), (-s, co));+ let c1 = (c.0 - u.0 * len / 2.0, c.1 - u.1 * len / 2.0);+ let c2 = (c.0 + u.0 * len / 2.0, c.1 + u.1 * len / 2.0);+ let at = |o: (f64, f64), k: f64| (o.0 + n.0 * k, o.1 + n.1 * k);+ let (a, b, cc, d) = (at(c1, -r), at(c2, -r), at(c2, r), at(c1, r));+ Ok(vec![+ Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction },+ Entity::Arc { cx: c2.0, cy: c2.1, x0: b.0, y0: b.1, x1: cc.0, y1: cc.1, ccw: true, construction },+ Entity::Line { x0: cc.0, y0: cc.1, x1: d.0, y1: d.1, construction },+ Entity::Arc { cx: c1.0, cy: c1.1, x0: d.0, y0: d.1, x1: a.0, y1: a.1, ccw: true, construction },+ ])+}++/// `polygon {center, sides, circumradiusMm | inscribedRadiusMm, angleDeg}` —+/// a regular polygon; `angleDeg` is the direction of its first VERTEX.+fn polygon_entities(v: &Value, construction: bool) -> Result<Vec<Entity>, String> {+ let c = need_pt2(v, &["center", "centerMm"])?;+ let n = v.get("sides").and_then(|s| s.as_u64()).filter(|n| (3..=200).contains(n)).ok_or_else(|| "sides must be a whole number from 3 to 200".to_string())?;+ let r = match (v.get("circumradiusMm").filter(|x| !x.is_null()), v.get("inscribedRadiusMm").filter(|x| !x.is_null())) {+ (Some(_), None) => positive_mm(v, "circumradiusMm")?,+ (None, Some(_)) => positive_mm(v, "inscribedRadiusMm")? / (std::f64::consts::PI / n as f64).cos(),+ _ => return Err("polygon needs ONE of circumradiusMm (centre to vertex) or inscribedRadiusMm (centre to the middle of a side)".into()),+ };+ let a0 = degrees(v, "angleDeg", 0.0)?;+ let vtx = |k: u64| {+ let (s, co) = (a0 + std::f64::consts::TAU * k as f64 / n as f64).sin_cos();+ (c.0 + r * co, c.1 + r * s)+ };+ Ok((0..n).map(|k| {+ let (a, b) = (vtx(k), vtx(k + 1));+ Entity::Line { x0: a.0, y0: a.1, x1: b.0, y1: b.1, construction }+ }).collect())+}++/// A SKETCH FILLET or CHAMFER (0.11.0) on the corner where two LINES drawn+/// before it meet at `at`: both lines are trimmed, and the corner becomes a+/// tangent arc of `radius` (fillet) or a line `distance` along each (chamfer).+/// Computed here — not ISketchManager::CreateFillet / CreateChamfer — so both+/// backends draw, and the closure check sees, the same segments.+fn corner_entities(v: &Value, out: &mut [Entity], fillet: bool, construction: bool) -> Result<Vec<Entity>, String> {+ let at = need_pt2(v, &["at", "atMm"])?;+ let size = if fillet { positive_mm(v, "radiusMm")? } else { positive_mm(v, "distanceMm")? };+ let mut hits: Vec<(usize, bool)> = Vec::new();+ for (i, e) in out.iter().enumerate() {+ if let Entity::Line { x0, y0, x1, y1, construction: false } = *e {+ if dist((x0, y0), at) <= JOIN_TOL {+ hits.push((i, false));+ } else if dist((x1, y1), at) <= JOIN_TOL {+ hits.push((i, true));+ }+ }+ }+ if hits.len() != 2 {+ return Err(format!(+ "{} lines end at [{}, {}] mm — a sketch {} takes the corner where exactly TWO lines drawn before it meet",+ hits.len(), trim_mm(at.0), trim_mm(at.1), if fillet { "fillet" } else { "chamfer" }+ ));+ }+ // Each line's direction AWAY from the corner, and its length.+ let away = |e: &Entity, at_end: bool| -> ((f64, f64), f64) {+ let ((x0, y0), (x1, y1)) = e.ends().unwrap_or(((0.0, 0.0), (0.0, 0.0)));+ let (fx, fy, tx, ty) = if at_end { (x1, y1, x0, y0) } else { (x0, y0, x1, y1) };+ let l = (tx - fx).hypot(ty - fy);+ (((tx - fx) / l, (ty - fy) / l), l)+ };+ let (a, la) = away(&out[hits[0].0], hits[0].1);+ let (b, lb) = away(&out[hits[1].0], hits[1].1);+ let cos = (a.0 * b.0 + a.1 * b.1).clamp(-1.0, 1.0);+ let theta = cos.acos();+ if theta < 1e-6 || theta > std::f64::consts::PI - 1e-6 {+ return Err("the two lines at that corner are in line — there is no corner to round or cut".into());+ }+ let t = if fillet { size / (theta / 2.0).tan() } else { size };+ if t >= la - JOIN_TOL || t >= lb - JOIN_TOL {+ return Err(format!("the {} needs {} mm along each line, and a line at that corner is shorter", if fillet { "fillet" } else { "chamfer" }, trim_mm(t)));+ }+ let pa = (at.0 + a.0 * t, at.1 + a.1 * t);+ let pb = (at.0 + b.0 * t, at.1 + b.1 * t);+ for (k, p) in [(hits[0], pa), (hits[1], pb)] {+ if let Entity::Line { x0, y0, x1, y1, .. } = &mut out[k.0] {+ if k.1 {+ *x1 = p.0;+ *y1 = p.1;+ } else {+ *x0 = p.0;+ *y0 = p.1;+ }+ }+ }+ if !fillet {+ return Ok(vec![Entity::Line { x0: pa.0, y0: pa.1, x1: pb.0, y1: pb.1, construction }]);+ }+ let w = {+ let (x, y) = (a.0 + b.0, a.1 + b.1);+ let l = x.hypot(y);+ (x / l, y / l)+ };+ let c = (at.0 + w.0 * size / (theta / 2.0).sin(), at.1 + w.1 * size / (theta / 2.0).sin());+ let mid = (c.0 - w.0 * size, c.1 - w.1 * size);+ let cross = (mid.0 - pa.0) * (pb.1 - pa.1) - (mid.1 - pa.1) * (pb.0 - pa.0);+ Ok(vec![Entity::Arc { cx: c.0, cy: c.1, x0: pa.0, y0: pa.1, x1: pb.0, y1: pb.1, ccw: cross > 0.0, construction }])+}++/// The entity kinds a sketch takes.+pub const ENTITY_KINDS: &[&str] = &[+ "rect", "circle", "line", "centerline", "polyline", "arc", "arc3", "tangentArc", "slot", "polygon", "sketchFillet", "sketchChamfer",+];++/// EVERY ENTITY OF A SKETCH, expanded (0.11.0): the entities drawn, and for+/// each entity the caller wrote, the range of drawn entities it became. Lines+/// and arcs must form closed contours (`contours`), checked here so an open+/// profile is refused before anything is drawn.+pub fn sketch_entities(list: &[Value]) -> Result<(Vec<Entity>, Vec<std::ops::Range<usize>>), String> {+ let mut out: Vec<Entity> = Vec::new();+ let mut ranges = Vec::with_capacity(list.len());+ for (i, e) in list.iter().enumerate() {+ let start = out.len();+ let found = ENTITY_KINDS.iter().find_map(|k| e.get(*k).filter(|b| b.is_object()).map(|b| (*k, b)));+ let (kind, body) = match found {+ Some(f) => f,+ None => match e.get("type").and_then(|t| t.as_str()).and_then(|t| ENTITY_KINDS.iter().find(|k| **k == t)) {+ Some(k) => (*k, e),+ None => return Err(format!("entity {}: each entity is one of {}, e.g. {{\"line\": {{\"from\": [0, 0], \"to\": [10, 0]}}}}", i + 1, ENTITY_KINDS.join(", "))),+ },+ };+ let construction = flag(body, "construction", false) || kind == "centerline";+ let made = match kind {+ "rect" => rect_entities(body),+ "circle" => circle_entities(body),+ "line" | "centerline" => line_entities(body, construction),+ "polyline" => polyline_entities(body, construction),+ "arc" => arc_entities(body, construction),+ "arc3" => arc3_entities(body, construction),+ "tangentArc" => tangent_arc_entities(body, construction, out.last()),+ "slot" => slot_entities(body, construction),+ "sketchFillet" => corner_entities(body, &mut out, true, construction),+ "sketchChamfer" => corner_entities(body, &mut out, false, construction),+ _ => polygon_entities(body, construction),+ }+ .map_err(|m| format!("entity {} ({kind}): {m}", i + 1))?;+ out.extend(made);+ ranges.push(start..out.len());+ }+ if out.len() > 5000 {+ return Err(format!("a sketch may draw at most 5000 entities; this one expands to {}", out.len()));+ }+ if let Err((at, msg)) = contours(&out) {+ let caller = ranges.iter().position(|r| r.contains(&at)).unwrap_or(0);+ return Err(format!("entity {}: {msg}", caller + 1));+ }+ Ok((out, ranges))+}++/// An arc's centre, radius, start angle and SIGNED sweep (radians; positive+/// counter-clockwise).+pub fn arc_geom(e: &Entity) -> Option<(f64, f64, f64, f64, f64)> {+ let Entity::Arc { cx, cy, x0, y0, x1, y1, ccw, .. } = *e else { return None };+ let r = (x0 - cx).hypot(y0 - cy);+ let t0 = (y0 - cy).atan2(x0 - cx);+ let t1 = (y1 - cy).atan2(x1 - cx);+ let tau = std::f64::consts::TAU;+ let mut sweep = if ccw { (t1 - t0).rem_euclid(tau) } else { -((t0 - t1).rem_euclid(tau)) };+ if sweep.abs() < 1e-12 {+ sweep = if ccw { tau } else { -tau };+ }+ Some((cx, cy, r, t0, sweep))+}++/// Is the angle `phi` on an arc that starts at `t0` and sweeps `sweep`?+pub fn on_sweep(phi: f64, t0: f64, sweep: f64) -> bool {+ let tau = std::f64::consts::TAU;+ if sweep >= 0.0 { (phi - t0).rem_euclid(tau) <= sweep + 1e-12 } else { (t0 - phi).rem_euclid(tau) <= -sweep + 1e-12 }+}++/// The least and greatest of `f(x, y) = a·x + b·y` over an entity (its+/// extent across a direction): a revolve's side check, a bounding box.+pub fn extent_along(e: &Entity, a: f64, b: f64) -> (f64, f64) {+ let f = |x: f64, y: f64| a * x + b * y;+ match *e {+ Entity::Rect { x0, y0, x1, y1 } => {+ let v = [f(x0, y0), f(x0, y1), f(x1, y0), f(x1, y1)];+ (v.iter().cloned().fold(f64::MAX, f64::min), v.iter().cloned().fold(f64::MIN, f64::max))+ }+ Entity::Circle { cx, cy, r } => {+ let k = r * a.hypot(b);+ (f(cx, cy) - k, f(cx, cy) + k)+ }+ Entity::Line { x0, y0, x1, y1, .. } => (f(x0, y0).min(f(x1, y1)), f(x0, y0).max(f(x1, y1))),+ Entity::Arc { x0, y0, x1, y1, .. } => {+ let (cx, cy, r, t0, sweep) = arc_geom(e).unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0));+ let mut lo = f(x0, y0).min(f(x1, y1));+ let mut hi = f(x0, y0).max(f(x1, y1));+ let phi = b.atan2(a);+ if on_sweep(phi, t0, sweep) {+ hi = hi.max(f(cx, cy) + r * a.hypot(b));+ }+ if on_sweep(phi + std::f64::consts::PI, t0, sweep) {+ lo = lo.min(f(cx, cy) - r * a.hypot(b));+ }+ (lo, hi)+ }+ }+}+ 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))@@ -646,7 +1369,72 @@ fn circle(v: &Value) -> Result<Entity, String> { Ok(Entity::Circle { cx, cy, r }) } +/// A face given as `{"face": {"atPointMm": [...]}}` or `{"atPointMm": [...]}`.+fn face_point(v: &Value, what: &str) -> Result<[f64; 3], String> {+ let p = v.get("face").and_then(|f| f.get("atPointMm")).or_else(|| v.get("atPointMm"))+ .ok_or_else(|| format!("{what} needs the face: {{\"face\": {{\"atPointMm\": [x, y, z]}}}} (mm, a point ON the face)"))?;+ point3(p, &format!("{what}.atPointMm"))+}++/// The end condition other than a depth, if one is given.+fn up_to(step: &Value) -> Result<Option<UpTo>, String> {+ let mut found: Vec<UpTo> = Vec::new();+ match step.get("endCondition").and_then(|e| e.as_str()) {+ None | Some("blind") | Some("throughAll") => {}+ Some("upToNext") => found.push(UpTo::Next),+ Some(other) => {+ return Err(format!(+ "endCondition must be \"blind\", \"throughAll\" or \"upToNext\" (got {other:?}); up to a face is upToSurface: {{\"face\": {{\"atPointMm\": [x,y,z]}}}}"+ ))+ }+ }+ if flag(step, "upToNext", false) {+ found.push(UpTo::Next);+ }+ if let Some(u) = step.get("upToSurface").filter(|u| !u.is_null()) {+ found.push(UpTo::Surface { point: face_point(u, "upToSurface")? });+ }+ if let Some(o) = step.get("offsetFromSurface").filter(|u| !u.is_null()) {+ let offset = mm(o, "distanceMm", None).map_err(|e| format!("offsetFromSurface.{e}"))?;+ if offset < 0.0 {+ return Err("offsetFromSurface.distanceMm must not be negative (reverseOffset:true puts it beyond the face)".into());+ }+ found.push(UpTo::OffsetFrom { point: face_point(o, "offsetFromSurface")?, offset, reverse_offset: flag(o, "reverseOffset", false) });+ }+ found.dedup();+ match found.len() {+ 0 => Ok(None),+ 1 => Ok(found.pop()),+ _ => Err("give ONE end condition: upToNext, upToSurface or offsetFromSurface".into()),+ }+}++fn draft(step: &Value) -> Result<Option<Draft>, String> {+ match step.get("draftDeg").filter(|d| !d.is_null()) {+ None => Ok(None),+ Some(d) => {+ let a = d.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 90.0).ok_or_else(|| "draftDeg must be between 0 and 90 (exclusive)".to_string())?;+ Ok(Some(Draft { angle: a.to_radians(), outward: flag(step, "draftOutward", false) }))+ }+ }+}+ fn extent(step: &Value, is_cut: bool) -> Result<Extent, String> {+ let up = up_to(step)?;+ let draft = draft(step)?;+ if let Some(u) = up {+ if flag(step, "throughAll", false) {+ return Err("throughAll and an up-to end condition exclude each other".into());+ }+ if step.get("depthMm").map(|d| !d.is_null()).unwrap_or(false) {+ return Err("depthMm has no meaning with an up-to end condition — the face decides where it ends".into());+ }+ if step.get("direction").and_then(|d| d.as_str()).map(|d| d != "one").unwrap_or(false) || flag(step, "midPlane", false) {+ return Err("an up-to end condition goes ONE way (the bridge does not build two-direction up-to extents)".into());+ }+ let depth = if let UpTo::OffsetFrom { offset, .. } = u { offset } else { 0.0 };+ return Ok(Extent { depth, depth2: depth, through_all: false, reverse: flag(step, "reverse", false), direction: Direction::One, up_to: Some(u), draft });+ } 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()) {@@ -674,7 +1462,7 @@ fn extent(step: &Value, is_cut: bool) -> Result<Extent, String> { Some(_) => positive_mm(step, "depth2Mm")?, None => depth, };- Ok(Extent { depth, depth2, through_all, reverse: flag(step, "reverse", false), direction })+ Ok(Extent { depth, depth2, through_all, reverse: flag(step, "reverse", false), direction, up_to: None, draft }) } fn edges(step: &Value) -> Result<EdgeSel, String> {@@ -716,6 +1504,166 @@ 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() } +/// `revolve` / `revolve_cut`: `axis` is "x" | "y" (the sketch's own axes),+/// {"entity": i} (the sketch's i-th entity as written, 0-based — a line) or+/// {"line": [[x, y], [x, y]]}; `angleDeg` (default 360), `midPlane`, `reverse`.+fn revolution(step: &Value) -> Result<Revolution, String> {+ let angle = match step.get("angleDeg").filter(|a| !a.is_null()) {+ None => 360.0,+ Some(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())?,+ };+ let axis_spec = match step.get("axis") {+ Some(Value::String(a)) if a.eq_ignore_ascii_case("x") => AxisSpec::SketchX,+ Some(Value::String(a)) if a.eq_ignore_ascii_case("y") => AxisSpec::SketchY,+ Some(o @ Value::Object(_)) if o.get("entity").is_some() => AxisSpec::Entity(+ o.get("entity").and_then(|e| e.as_u64()).ok_or_else(|| "axis.entity is the 0-based index of a line in the sketch".to_string())? as usize,+ ),+ Some(o @ Value::Object(_)) if o.get("line").is_some() => {+ let l = o.get("line").and_then(|l| l.as_array()).filter(|l| l.len() == 2).ok_or_else(|| "axis.line must be [[x, y], [x, y]] (mm)".to_string())?;+ let a = need_pt2(&serde_json::json!({"p": l[0]}), &["p"]).map_err(|e| format!("axis.line: {e}"))?;+ let b = need_pt2(&serde_json::json!({"p": l[1]}), &["p"]).map_err(|e| format!("axis.line: {e}"))?;+ if dist(a, b) <= JOIN_TOL {+ return Err("axis.line's two points are the same point".into());+ }+ AxisSpec::Line([a.0, a.1], [b.0, b.1])+ }+ _ => return Err("axis is required: \"x\" or \"y\" (the sketch's own axes), {\"entity\": i} (a line of the sketch, 0-based) or {\"line\": [[x, y], [x, y]]} (mm)".into()),+ };+ let mid_plane = flag(step, "midPlane", false) || step.get("direction").and_then(|d| d.as_str()) == Some("midPlane");+ Ok(Revolution { axis: usize::MAX, axis_spec, angle: angle.to_radians(), mid_plane, reverse: flag(step, "reverse", false) })+}++fn mirror(step: &Value) -> Result<Op, String> {+ if step.get("plane").map(|p| p.is_null()).unwrap_or(true) && step.get("face").map(|p| p.is_null()).unwrap_or(true) {+ return Err("mirror needs the plane: \"plane\": \"Right Plane\" | {offsetFrom, offsetMm}, or \"face\": {\"atPointMm\": [x,y,z]} (a planar face)".into());+ }+ let plane = plane_ref(step)?;+ let what = step.get("featureOrBody").or_else(|| step.get("features")).or_else(|| step.get("feature"));+ let mut features: Vec<String> = match what {+ None | Some(Value::Null) => vec!["last".into()],+ Some(Value::String(f)) if !f.trim().is_empty() => vec![f.clone()],+ Some(Value::Array(a)) if !a.is_empty() => a+ .iter()+ .map(|f| f.as_str().filter(|s| !s.trim().is_empty()).map(String::from).ok_or_else(|| "features must be feature names".to_string()))+ .collect::<Result<_, _>>()?,+ _ => return Err("features is \"last\", a feature name, a list of names, or \"all\" (every body)".into()),+ };+ let mut bodies = flag(step, "mirrorBody", false);+ if features.iter().any(|f| f == "all") {+ bodies = true;+ }+ if bodies {+ features.clear();+ }+ Ok(Op::Mirror { features, bodies, plane })+}++fn hole(step: &Value) -> Result<Op, String> {+ let at = point3(step.get("atMm").or_else(|| step.get("at")).ok_or_else(|| "hole needs atMm: [x, y, z] (mm), a point ON a planar face — the hole's centre".to_string())?, "atMm")?;+ let diameter = positive_mm(step, "diameterMm")?;+ let through = flag(step, "throughAll", false);+ let depth = match (through, step.get("depthMm").filter(|d| !d.is_null())) {+ (true, None) => None,+ (false, Some(_)) => Some(positive_mm(step, "depthMm")?),+ (true, Some(_)) => return Err("give depthMm OR throughAll:true, not both".into()),+ (false, None) => return Err("hole needs depthMm (mm), or throughAll:true".into()),+ };+ let counterbore = match step.get("counterbore").filter(|c| !c.is_null()) {+ None => None,+ Some(c) => {+ let d = positive_mm(c, "diameterMm").map_err(|e| format!("counterbore.{e}"))?;+ let h = positive_mm(c, "depthMm").map_err(|e| format!("counterbore.{e}"))?;+ if d <= diameter {+ return Err("counterbore.diameterMm must be larger than the hole's diameterMm".into());+ }+ if depth.map(|t| h >= t).unwrap_or(false) {+ return Err("counterbore.depthMm must be less than the hole's depthMm".into());+ }+ Some((d, h))+ }+ };+ let countersink = match step.get("countersink").filter(|c| !c.is_null()) {+ None => None,+ Some(c) => {+ let d = positive_mm(c, "diameterMm").map_err(|e| format!("countersink.{e}"))?;+ if d <= diameter {+ return Err("countersink.diameterMm must be larger than the hole's diameterMm".into());+ }+ let a = c.get("angleDeg").filter(|a| !a.is_null()).map(|a| a.as_f64().filter(|a| a.is_finite() && *a > 0.0 && *a < 180.0)+ .ok_or_else(|| "countersink.angleDeg (the included angle, e.g. 82 or 90) must be in (0, 180)".to_string())).transpose()?.unwrap_or(90.0).to_radians();+ if let Some(t) = depth {+ if countersink_depth(diameter, d, a) >= t {+ return Err("the countersink is deeper than the hole".into());+ }+ }+ Some((d, a))+ }+ };+ if counterbore.is_some() && countersink.is_some() {+ return Err("a hole has a counterbore OR a countersink, not both".into());+ }+ Ok(Op::Hole { at, diameter, depth, counterbore, countersink })+}++/// THE HOLE AS FEATURES (0.11.0), shared by both backends: the face's plane+/// is `base` (a default plane) at height `h` along its normal, the face looks+/// along +normal when `outward_is_normal`. Circles at the hole's centre are+/// sketched on that plane (an OFFSET plane where h ≠ 0 — it outlives the cuts,+/// unlike the face) and cut into the material: a counterbore (a blind cut) or+/// a countersink (a cut drafted INWARD at half its included angle, so it+/// narrows to the hole's diameter) first, then the hole from where that ends.+#[allow(clippy::too_many_arguments)]+pub fn hole_plan(+ frame: &crate::modeling::PlaneFrame,+ base: &str,+ h: f64,+ outward_is_normal: bool,+ at: [f64; 3],+ diameter: f64,+ depth: Option<f64>,+ counterbore: Option<(f64, f64)>,+ countersink: Option<(f64, f64)>,+) -> Vec<Op> {+ use crate::modeling::{dot, sub};+ let rel = sub(at, frame.origin);+ let (u, v) = (dot(rel, frame.x), dot(rel, frame.y));+ // A cut from a plane goes against the plane's normal: reversed when the+ // face looks the other way.+ let reverse = !outward_is_normal;+ let inward = if outward_is_normal { -1.0 } else { 1.0 };+ let plane_at = |hh: f64| if hh.abs() < 1e-12 { PlaneRef::Named(base.to_string()) } else { PlaneRef::Offset { from: base.to_string(), offset: hh } };+ let sketch = |hh: f64, r: f64| Op::Sketch { shorthand: "sketch", plane: plane_at(hh), entities: vec![Entity::Circle { cx: u, cy: v, r }] };+ let blind = |t: f64| Extent { reverse, ..Extent::blind(t) };+ let mut ops = Vec::new();+ let mut start = h;+ let mut left = depth;+ if let Some((dcb, hcb)) = counterbore {+ ops.push(sketch(h, dcb / 2.0));+ ops.push(Op::Cut(blind(hcb)));+ start = h + inward * hcb;+ left = left.map(|t| t - hcb);+ }+ if let Some((dcs, angle)) = countersink {+ let hcs = countersink_depth(diameter, dcs, angle);+ ops.push(sketch(h, dcs / 2.0));+ ops.push(Op::Cut(Extent { draft: Some(Draft { angle: angle / 2.0, outward: false }), ..blind(hcs) }));+ start = h + inward * hcs;+ left = left.map(|t| t - hcs);+ }+ ops.push(sketch(start, diameter / 2.0));+ ops.push(Op::Cut(match left {+ Some(t) => blind(t),+ None => Extent { through_all: true, ..blind(0.0) },+ }));+ ops+}++/// How deep a countersink of diameter `dcs` and included angle `angle`+/// (radians) goes on a hole of diameter `d`: (dcs − d) / (2·tan(angle/2)).+pub fn countersink_depth(d: f64, dcs: f64, angle: f64) -> f64 {+ (dcs - d) / (2.0 * (angle / 2.0).tan())+}+ fn parse_step(step: &Value) -> Result<Op, String> { let op = step .get("op")@@ -725,42 +1673,48 @@ fn parse_step(step: &Value) -> Result<Op, String> { "new_part" => Op::NewPart { visible: visible_arg(step)? }, "new_assembly" => Op::NewAssembly { visible: visible_arg(step)? }, "clear_part" => Op::ClearPart,- "sketch_rect" => Op::Sketch { shorthand: "sketch_rect", plane: plane_ref(step)?, entities: vec![rect(step)?] },+ "sketch_rect" => Op::Sketch { shorthand: "sketch_rect", plane: plane_ref(step)?, entities: rect_entities(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())?;+ .ok_or_else(|| format!("sketch needs entities: a non-empty list, each one of {}", ENTITY_KINDS.join(", ")))?; 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))?);+ let (entities, _) = sketch_entities(list)?;+ if !entities.iter().any(|e| !e.is_construction()) {+ return Err("the sketch has only construction geometry — there is no profile".into()); } 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- .get("database")- .and_then(|v| v.as_str())- .filter(|s| !s.is_empty())- .unwrap_or("SOLIDWORKS Materials.sldmat")- .to_string(),- },+ "revolve" => Op::Revolve(revolution(step)?),+ "revolve_cut" => Op::RevolveCut(revolution(step)?),+ "mirror" => mirror(step)?,+ "hole" => hole(step)?,+ "set_material" => {+ let density = match step.get("densityKgM3").filter(|d| !d.is_null()) {+ None => None,+ Some(d) => Some(d.as_f64().filter(|d| d.is_finite() && *d > 0.0 && *d < 100_000.0)+ .ok_or_else(|| "densityKgM3 must be a positive number of kg/m³ (e.g. 2700 for aluminium)".to_string())?),+ };+ let name = match (text(step, "name"), density) {+ (Ok(n), _) => n,+ (Err(_), Some(_)) => String::new(),+ (Err(e), None) => return Err(format!("{e} (or densityKgM3 alone, to set the part's density)")),+ };+ Op::SetMaterial {+ name,+ database: step+ .get("database")+ .and_then(|v| v.as_str())+ .filter(|s| !s.is_empty())+ .unwrap_or("SOLIDWORKS Materials.sldmat")+ .to_string(),+ density,+ }+ } "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())?;@@ -896,7 +1850,16 @@ pub fn validate(ops: &[Op], has_open_doc: bool) -> Result<(), String> { )) } Op::Sketch { .. } => sketch_pending = true,- Op::Extrude(_) | Op::Cut(_) => {+ Op::Hole { .. } if sketch_pending => {+ return Err(format!(+ "step {at} (hole) comes between a sketch and the feature that consumes it — a hole draws its own sketches; \+ extrude, cut or revolve the pending sketch first"+ ));+ }+ Op::Revolve(rv) | Op::RevolveCut(rv) if sketch_pending && rv.axis == usize::MAX => {+ return Err(format!("step {at} ({}): its axis could not be placed in the sketch", op.name()));+ }+ Op::Extrude(_) | Op::Cut(_) | Op::Revolve(_) | Op::RevolveCut(_) => { if !sketch_pending { return Err(format!( "step {at} ({}) has no sketch to consume — put a sketch, sketch_rect or \@@ -913,6 +1876,9 @@ pub fn validate(ops: &[Op], has_open_doc: bool) -> Result<(), String> { { return Err(format!("step {at} ({}) refers to the \"last\" feature, and no feature comes before it", op.name())); }+ Op::Mirror { features, .. } if features.iter().any(|f| f == "last") && !have_feature => {+ return Err(format!("step {at} (mirror) refers to the \"last\" feature, and no feature comes before it"));+ } _ => {} } if op.makes_feature() {@@ -949,11 +1915,11 @@ mod tests { ); assert_eq!( ops[2],- Op::Extrude(Extent { depth: 0.02, depth2: 0.02, through_all: false, reverse: false, direction: Direction::One })+ Op::Extrude(Extent { depth: 0.02, depth2: 0.02, through_all: false, reverse: false, direction: Direction::One , up_to: None, draft: None }) ); assert_eq!( ops[3],- Op::SetMaterial { name: "6061 Alloy".into(), database: "SOLIDWORKS Materials.sldmat".into() }+ Op::SetMaterial { name: "6061 Alloy".into(), database: "SOLIDWORKS Materials.sldmat".into(), density: None } ); // a non-string prop value is stringified rather than refused assert_eq!(parse(&json!([{"op":"set_prop","name":"Qty","value":4}])).unwrap()[0],@@ -1251,8 +2217,8 @@ mod tests { ])) .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_eq!(ops[1], Op::Extrude(Extent { depth: 0.0086, depth2: 0.002, through_all: false, reverse: false, direction: Direction::Both , up_to: None, draft: None }));+ assert_eq!(ops[2], Op::Cut(Extent { depth: 0.0, depth2: 0.0, through_all: true, reverse: true, direction: Direction::One , up_to: None, draft: None })); 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));@@ -1293,4 +2259,236 @@ mod tests { {"op": "fillet", "radiusMm": 0.1, "edges": {"allOfFeature": "last"}}])).unwrap(); assert!(validate(&good, false).is_ok()); }++ // ── 0.11.0: sketch geometry, end conditions, revolve, mirror, hole ─────++ fn sketch_of(entities: Value) -> Result<Vec<Entity>, String> {+ match parse(&json!([{"op": "sketch", "plane": "Front Plane", "entities": entities}]))?.remove(0) {+ Op::Sketch { entities, .. } => Ok(entities),+ _ => unreachable!(),+ }+ }++ #[test]+ fn an_open_contour_is_refused_naming_the_gap() {+ let e = sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]]}}])).unwrap_err();+ assert!(e.contains("entity 1") && e.contains("OPEN") && e.contains("[0.0, 10.0] mm") && e.contains("gap of 10.0 mm"), "{e}");+ // Closed by its own `close`, or by a line back to the start: accepted.+ assert_eq!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 10]], "close": true}}])).unwrap().len(), 4);+ let joined = sketch_of(json!([+ {"line": {"from": [0, 0], "to": [10, 0]}}, {"line": {"from": [10, 10], "to": [10, 0]}},+ {"line": {"from": [10, 10], "to": [0, 0]}}+ ]))+ .unwrap();+ assert_eq!(contours(&joined).unwrap(), vec![vec![(0, false), (1, true), (2, false)]], "a reversed segment joins too");+ // A gap of 1e-5 mm is a gap; 1e-7 mm is not.+ assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 0.00001]]}}])).unwrap_err().contains("gap of 0.00001 mm"));+ assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10], [0, 0.0000001]]}}])).is_ok());+ // A branch: three ends at one point.+ let e = sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10]], "close": true}}, {"line": {"from": [0, 0], "to": [-5, 0]}}])).unwrap_err();+ assert!(e.contains("3 segment ends meet"), "{e}");+ // Construction geometry is never a contour, and a sketch of only it is refused.+ assert!(sketch_of(json!([{"rect": {"widthMm": 10, "heightMm": 10}}, {"line": {"from": [0, 0], "to": [5, 5], "construction": true}}])).is_ok());+ assert!(sketch_of(json!([{"centerline": {"from": [0, 0], "to": [5, 5]}}])).unwrap_err().contains("only construction"));+ }++ #[test]+ fn arcs_three_ways_land_where_they_should() {+ // arc: centre, radius, angles — counter-clockwise by default.+ let a = sketch_of(json!([{"arc": {"center": [0, 0], "radiusMm": 10, "startDeg": 0, "endDeg": 180}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();+ let Entity::Arc { x0, y0, x1, y1, ccw, .. } = a[0] else { panic!() };+ assert!((x0 - 0.01).abs() < 1e-15 && y0.abs() < 1e-15 && (x1 + 0.01).abs() < 1e-15 && y1.abs() < 1e-15 && ccw);+ // arc3: the circumcentre, and the direction from the three points.+ let b = sketch_of(json!([{"arc3": {"start": [10, 0], "mid": [0, 10], "end": [-10, 0]}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();+ let Entity::Arc { cx, cy, ccw, .. } = b[0] else { panic!() };+ assert!(cx.abs() < 1e-15 && cy.abs() < 1e-15 && ccw);+ let c = sketch_of(json!([{"arc3": {"start": [10, 0], "mid": [0, -10], "end": [-10, 0]}}, {"line": {"from": [-10, 0], "to": [10, 0]}}])).unwrap();+ assert!(matches!(c[0], Entity::Arc { ccw: false, .. }), "below the chord: clockwise");+ assert!(sketch_of(json!([{"arc3": {"start": [0, 0], "mid": [5, 0], "end": [10, 0]}}])).unwrap_err().contains("in a line"));+ // tangentArc: from a line along +x ending at (20, 10): the quarter circle about (10, 10).+ let t = sketch_of(json!([+ {"line": {"from": [0, 0], "to": [10, 0]}}, {"tangentArc": {"to": [20, 10]}},+ {"line": {"from": [20, 10], "to": [0, 10]}}, {"line": {"from": [0, 10], "to": [0, 0]}}+ ]))+ .unwrap();+ let Entity::Arc { cx, cy, ccw, .. } = t[1] else { panic!() };+ assert!((cx - 0.01).abs() < 1e-15 && (cy - 0.01).abs() < 1e-15 && ccw, "{:?}", t[1]);+ let e = sketch_of(json!([{"line": {"from": [0, 0], "to": [10, 0]}}, {"tangentArc": {"to": [20, 10], "radiusMm": 12}}])).unwrap_err();+ assert!(e.contains("radius 10.0 mm"), "{e}");+ assert!(sketch_of(json!([{"tangentArc": {"to": [20, 10]}}])).unwrap_err().contains("continues from the entity before it"));+ assert!(sketch_of(json!([{"arc": {"center": [0, 0], "radiusMm": 5, "startDeg": 10, "endDeg": 370}}])).unwrap_err().contains("same angle"));+ }++ #[test]+ fn composites_expand_to_lines_and_arcs() {+ let s = sketch_of(json!([{"slot": {"center": [0, 0], "lengthMm": 20, "widthMm": 6}}])).unwrap();+ assert_eq!(s.iter().filter(|e| matches!(e, Entity::Line { .. })).count(), 2);+ assert_eq!(s.iter().filter(|e| matches!(e, Entity::Arc { .. })).count(), 2);+ let p = sketch_of(json!([{"polygon": {"center": [0, 0], "sides": 6, "circumradiusMm": 10, "angleDeg": 90}}])).unwrap();+ assert_eq!(p.len(), 6);+ let Entity::Line { x0, y0, .. } = p[0] else { panic!() };+ assert!(x0.abs() < 1e-15 && (y0 - 0.01).abs() < 1e-15, "the first vertex at 90°");+ assert!(sketch_of(json!([{"polygon": {"center": [0, 0], "sides": 6}}])).unwrap_err().contains("ONE of circumradiusMm"));+ // A construction rect: four construction lines; a construction circle: two arcs.+ let c = sketch_of(json!([{"circle": {"radiusMm": 9}}, {"rect": {"widthMm": 4, "heightMm": 4, "construction": true}}, {"circle": {"radiusMm": 2, "construction": true}}])).unwrap();+ assert_eq!(c.iter().filter(|e| e.is_construction()).count(), 6);+ assert!(parse(&json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 4, "cornerRadiusMm": 2.5}])).unwrap_err().contains("more than half"));+ // sketch_rect with cornerRadiusMm keeps its shorthand name.+ assert_eq!(parse(&json!([{"op": "sketch_rect", "widthMm": 10, "heightMm": 4, "cornerRadiusMm": 1}])).unwrap()[0].name(), "sketch_rect");+ }++ #[test]+ fn a_revolve_axis_is_resolved_into_its_sketch() {+ let ops = parse(&json!([+ {"op": "new_part"},+ {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 5, "yMm": 0, "widthMm": 5, "heightMm": 10}}]},+ {"op": "revolve", "axis": "y"}+ ]))+ .unwrap();+ let Op::Sketch { entities, .. } = &ops[1] else { panic!() };+ let Op::Revolve(rv) = &ops[2] else { panic!() };+ assert_eq!(rv.axis, 1);+ assert!(matches!(entities[1], Entity::Line { x0, x1, construction: true, .. } if x0 == 0.0 && x1 == 0.0), "{:?}", entities[1]);+ assert_eq!(rv.angle, std::f64::consts::TAU);+ // {entity: i} is the caller's i-th entity, after any expansion before it.+ let ops = parse(&json!([+ {"op": "new_part"},+ {"op": "sketch", "entities": [{"polyline": {"points": [[0, 0], [5, 0], [5, 5], [0, 5]], "close": true}}, {"centerline": {"from": [-1, -2], "to": [-1, 9]}}]},+ {"op": "revolve_cut", "axis": {"entity": 1}, "angleDeg": 90}+ ]))+ .unwrap();+ let Op::RevolveCut(rv) = &ops[2] else { panic!() };+ assert_eq!(rv.axis, 4);+ assert!(parse(&json!([{"op": "new_part"}, {"op": "sketch", "entities": [{"rect": {"widthMm": 5, "heightMm": 5}}]}, {"op": "revolve", "axis": {"entity": 0}}]))+ .unwrap_err()+ .contains("not a line"));+ // A profile across the axis is refused before anything runs.+ let e = parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 10, "heightMm": 5, "anchor": "center"}, {"op": "revolve", "axis": "y"}])).unwrap_err();+ assert!(e.contains("step 3") && e.contains("crosses the revolve axis") && e.contains("5.0 mm"), "{e}");+ // ...an ARC bulging across it too (its ends are both on one side).+ let e = parse(&json!([{"op": "new_part"}, {"op": "sketch", "entities": [+ {"line": {"from": [2, -5], "to": [2, 5]}}, {"arc": {"center": [4, 0], "radiusMm": 5.385164807134504, "startDeg": 111.80140948635182, "endDeg": 248.19859051364818}}]},+ {"op": "revolve", "axis": "y"}])).unwrap_err();+ assert!(e.contains("crosses"), "{e}");+ assert!(parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 5, "heightMm": 5}, {"op": "revolve"}])).unwrap_err().contains("axis is required"));+ assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "revolve", "axis": "x"}])).unwrap(), false).unwrap_err().contains("no sketch to consume"));+ }++ #[test]+ fn end_conditions_and_draft_parse_and_refuse_their_conflicts() {+ let e = |v: Value| parse(&json!([v])).map(|mut o| o.remove(0));+ assert_eq!(e(json!({"op": "extrude", "upToNext": true})).unwrap(), Op::Extrude(Extent { up_to: Some(UpTo::Next), depth: 0.0, depth2: 0.0, ..Extent::blind(0.0) }));+ assert_eq!(e(json!({"op": "cut", "endCondition": "upToNext"})).unwrap(), Op::Cut(Extent { up_to: Some(UpTo::Next), ..Extent::blind(0.0) }));+ assert_eq!(+ e(json!({"op": "extrude", "upToSurface": {"face": {"atPointMm": [1, 2, 3]}}})).unwrap(),+ Op::Extrude(Extent { up_to: Some(UpTo::Surface { point: [0.001, 0.002, 0.003] }), ..Extent::blind(0.0) })+ );+ assert_eq!(+ e(json!({"op": "cut", "offsetFromSurface": {"atPointMm": [0, 0, 0], "distanceMm": 2, "reverseOffset": true}, "reverse": true})).unwrap(),+ Op::Cut(Extent { up_to: Some(UpTo::OffsetFrom { point: [0.0; 3], offset: 0.002, reverse_offset: true }), reverse: true, ..Extent::blind(0.002) })+ );+ assert!(e(json!({"op": "extrude", "upToNext": true, "throughAll": true})).unwrap_err().contains("exclude"));+ assert!(e(json!({"op": "extrude", "upToNext": true, "depthMm": 5})).unwrap_err().contains("no meaning"));+ assert!(e(json!({"op": "extrude", "upToNext": true, "direction": "both"})).unwrap_err().contains("ONE way"));+ assert!(e(json!({"op": "extrude", "upToNext": true, "upToSurface": {"atPointMm": [0, 0, 0]}})).unwrap_err().contains("ONE end condition"));+ assert!(e(json!({"op": "extrude", "endCondition": "upToVertex"})).unwrap_err().contains("endCondition"));+ assert_eq!(e(json!({"op": "extrude", "depthMm": 5, "draftDeg": 3})).unwrap(), Op::Extrude(Extent { draft: Some(Draft { angle: 3f64.to_radians(), outward: false }), ..Extent::blind(0.005) }));+ assert!(e(json!({"op": "extrude", "depthMm": 5, "draftDeg": 90})).unwrap_err().contains("draftDeg"));+ assert_eq!(crate::modeling::end_condition_id(&UpTo::Next), 11);+ assert_eq!(crate::modeling::end_condition_id(&UpTo::Surface { point: [0.0; 3] }), 10);+ assert_eq!(crate::modeling::end_condition_id(&UpTo::OffsetFrom { point: [0.0; 3], offset: 0.0, reverse_offset: false }), 5);+ }++ #[test]+ fn mirror_hole_and_density_parse() {+ let one = |v: Value| parse(&json!([v])).map(|mut o| o.remove(0));+ assert_eq!(one(json!({"op": "mirror", "plane": "Right Plane"})).unwrap(), Op::Mirror { features: vec!["last".into()], bodies: false, plane: PlaneRef::Named("Right Plane".into()) });+ assert_eq!(one(json!({"op": "mirror", "plane": "Right Plane", "featureOrBody": "all"})).unwrap(), Op::Mirror { features: vec![], bodies: true, plane: PlaneRef::Named("Right Plane".into()) });+ assert_eq!(+ one(json!({"op": "mirror", "face": {"atPointMm": [0, 5, 0]}, "features": ["Boss-Extrude1", "Cut-Extrude1"]})).unwrap(),+ Op::Mirror { features: vec!["Boss-Extrude1".into(), "Cut-Extrude1".into()], bodies: false, plane: PlaneRef::Face { point: [0.0, 0.005, 0.0] } }+ );+ assert!(one(json!({"op": "mirror"})).unwrap_err().contains("needs the plane"));+ assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "mirror", "plane": "Top Plane"}])).unwrap(), false).unwrap_err().contains("\"last\""));+ assert_eq!(+ one(json!({"op": "hole", "atMm": [1, 2, 3], "diameterMm": 5, "depthMm": 10, "counterbore": {"diameterMm": 9, "depthMm": 4}})).unwrap(),+ Op::Hole { at: [0.001, 0.002, 0.003], diameter: 0.005, depth: Some(0.01), counterbore: Some((9.0 * MM, 0.004)), countersink: None }+ );+ assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5})).unwrap_err().contains("depthMm"));+ assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "throughAll": true, "counterbore": {"diameterMm": 4, "depthMm": 1}})).unwrap_err().contains("larger"));+ assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "depthMm": 3, "counterbore": {"diameterMm": 9, "depthMm": 3}})).unwrap_err().contains("less than"));+ assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "depthMm": 2, "countersink": {"diameterMm": 10, "angleDeg": 90}})).unwrap_err().contains("deeper"));+ assert!(one(json!({"op": "hole", "atMm": [0, 0, 0], "diameterMm": 5, "throughAll": true, "countersink": {"diameterMm": 10}, "counterbore": {"diameterMm": 9, "depthMm": 1}})).unwrap_err().contains("not both"));+ assert!((countersink_depth(0.006, 0.012, 90f64.to_radians()) - 0.003).abs() < 1e-15);+ assert_eq!(one(json!({"op": "set_material", "densityKgM3": 1250})).unwrap(), Op::SetMaterial { name: String::new(), database: "SOLIDWORKS Materials.sldmat".into(), density: Some(1250.0) });+ assert!(one(json!({"op": "set_material"})).unwrap_err().contains("densityKgM3 alone"));+ assert!(one(json!({"op": "set_material", "densityKgM3": -3})).unwrap_err().contains("positive"));+ // A hole between a sketch and its extrude would take the sketch's place.+ assert!(validate(&parse(&json!([{"op": "new_part"}, {"op": "sketch_rect", "widthMm": 5, "heightMm": 5},+ {"op": "hole", "atMm": [0, 0, 0], "diameterMm": 1, "throughAll": true}, {"op": "extrude", "depthMm": 1}])).unwrap(), false)+ .unwrap_err().contains("between a sketch"));+ }++ #[test]+ fn a_hole_plan_cuts_into_the_face_from_where_the_counterbore_ends() {+ let top = crate::modeling::PlaneFrame::defaults()[1];+ let plan = hole_plan(&top, "Top Plane", 0.02, true, [0.005, 0.02, -0.003], 0.006, None, Some((0.011, 0.004)), None);+ assert_eq!(plan.iter().map(|o| o.name()).collect::<Vec<_>>(), ["sketch", "cut", "sketch", "cut"]);+ // Top Plane: sketch +y = model −Z, so model z −3 is sketch v +3.+ assert_eq!(plan[0], Op::Sketch { shorthand: "sketch", plane: PlaneRef::Offset { from: "Top Plane".into(), offset: 0.02 }, entities: vec![Entity::Circle { cx: 0.005, cy: 0.003, r: 0.0055 }] });+ assert_eq!(plan[1], Op::Cut(Extent::blind(0.004)));+ assert!(matches!(&plan[2], Op::Sketch { plane: PlaneRef::Offset { offset, .. }, .. } if (offset - 0.016).abs() < 1e-15));+ assert_eq!(plan[3], Op::Cut(Extent { through_all: true, ..Extent::blind(0.0) }));+ // From a face looking down (−normal) at height 0: Top Plane itself, reversed.+ let plan = hole_plan(&top, "Top Plane", 0.0, false, [0.0; 3], 0.006, Some(0.005), None, Some((0.012, std::f64::consts::FRAC_PI_2)));+ assert_eq!(plan[0], Op::Sketch { shorthand: "sketch", plane: PlaneRef::Named("Top Plane".into()), entities: vec![Entity::Circle { cx: 0.0, cy: 0.0, r: 0.006 }] });+ let Op::Cut(cs) = &plan[1] else { panic!() };+ assert!(cs.reverse && (cs.depth - 0.003).abs() < 1e-15 && cs.draft == Some(Draft { angle: std::f64::consts::FRAC_PI_4, outward: false }));+ let Op::Cut(main) = &plan[3] else { panic!() };+ assert!(main.reverse && (main.depth - 0.002).abs() < 1e-15);+ assert!(matches!(&plan[2], Op::Sketch { plane: PlaneRef::Offset { offset, .. }, .. } if (offset - 0.003).abs() < 1e-15));+ }++ #[test]+ fn sketch_fillets_and_chamfers_round_and_cut_a_corner_of_two_lines() {+ // A 20 x 10 rectangle as a polyline; the corner at (20, 10) filleted R3,+ // the corner at (0, 0) chamfered 2.+ let e = sketch_of(json!([+ {"polyline": {"points": [[0, 0], [20, 0], [20, 10], [0, 10]], "close": true}},+ {"sketchFillet": {"at": [20, 10], "radiusMm": 3}},+ {"sketchChamfer": {"at": [0, 0], "distanceMm": 2}}+ ]))+ .unwrap();+ assert_eq!(e.len(), 6, "four lines, an arc, a chamfer line");+ let Entity::Arc { cx, cy, ccw, .. } = e[4] else { panic!("{:?}", e[4]) };+ assert!((cx - 0.017).abs() < 1e-15 && (cy - 0.007).abs() < 1e-15 && ccw, "the fillet's centre 3 in from both edges");+ assert_eq!(e[5], Entity::Line { x0: 0.002, y0: 0.0, x1: 0.0, y1: 0.002, construction: false });+ assert!(contours(&e).is_ok(), "still one closed contour");+ let a = contours(&e).unwrap();+ let (area, _, _) = crate::modeling::contour_moments(&e, &a[0]);+ let want = (200.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) - 2.0) * 1e-6;+ assert!((area.abs() - want).abs() < 1e-15, "{} vs {want}", area.abs());+ // A 60° corner: the tangent length is r / tan(30°).+ let t = sketch_of(json!([+ {"polyline": {"points": [[0, 0], [40, 0], [20, 34.64101615137755]], "close": true}},+ {"sketchFillet": {"at": [40, 0], "radiusMm": 2}}+ ]))+ .unwrap();+ let Entity::Line { x1, .. } = t[0] else { panic!() };+ assert!((x1 - (0.040 - 0.002 / (30f64).to_radians().tan())).abs() < 1e-12);+ assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [10, 0], [10, 10]], "close": true}}, {"sketchFillet": {"at": [5, 5], "radiusMm": 1}}]))+ .unwrap_err().contains("0 lines end at"));+ assert!(sketch_of(json!([{"polyline": {"points": [[0, 0], [4, 0], [4, 4], [0, 4]], "close": true}}, {"sketchChamfer": {"at": [4, 4], "distanceMm": 5}}]))+ .unwrap_err().contains("shorter"));+ }++ #[test]+ fn a_plane_offset_from_a_face_parses() {+ let o = parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"face": {"atPointMm": [1, 2, 3]}}, "offsetMm": -12}}])).unwrap();+ let Op::Sketch { plane, .. } = &o[0] else { panic!() };+ assert_eq!(*plane, PlaneRef::FaceOffset { point: [0.001, 0.002, 0.003], offset: -0.012 });+ let z = parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"atPointMm": [1, 2, 3]}, "offsetMm": 0}}])).unwrap();+ assert!(matches!(&z[0], Op::Sketch { plane: PlaneRef::Face { .. }, .. }), "offset 0 is the face itself");+ assert!(parse(&json!([{"op": "sketch_circle", "radiusMm": 2, "plane": {"offsetFrom": {"nope": 1}, "offsetMm": 3}}])).is_err());+ } }
src/selftest.rs+99−4@@ -152,10 +152,10 @@ impl Run { 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") {+ if !matches!(level, "read" | "write" | "cswa") { 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:?}"),+ format!("level must be \"read\" (changes nothing), \"write\" (also builds, exports and closes a scratch part) or \"cswa\" (builds the CSWA benchmark parts and checks their mass), got {level:?}"), "Run level:\"read\" first; level:\"write\" only touches documents the self-test creates in the bridge's scratch folder.", ); }@@ -205,11 +205,11 @@ pub fn selftest(ctx: &Ctx, args: &Value) -> Value { let start_hint = format!( "Start SOLIDWORKS (swx_start, or by hand) and run this again — with it closed only {ran} checks can run." );- if level == "write" {+ if level != "read" { // Refused UP FRONT: nothing was created or changed. let mut out = crate::verbs::err_code( "solidworks_not_running",- format!("swx_selftest level:\"write\" needs SOLIDWORKS: {why}. Nothing was created or changed."),+ format!("swx_selftest level:\"{level}\" needs SOLIDWORKS: {why}. Nothing was created or changed."), start_hint, ); if let Value::Object(m) = &mut out {@@ -260,6 +260,11 @@ pub fn selftest(ctx: &Ctx, args: &Value) -> Value { (None, _) => run.skip("the active document's read verbs", "swx_design_info", "no document is open — open any part to include them"), } + // ── CSWA: the benchmark parts, each built and weighed (0.11.0) ─────────+ if level == "cswa" {+ return cswa_level(ctx, args, &sw, ready, started, run, active);+ }+ // ── WRITE: the geometric proof, then the verbs on one scratch part ────── if level == "write" { // THE GEOMETRIC PROOF (0.10.2): every modelling op builds a scratch part@@ -446,6 +451,96 @@ pub fn selftest(ctx: &Ctx, args: &Value) -> Value { finish(level, &sw, ready, true, started, run, None) } +/// `level:"cswa"` (0.11.0): every shipped CSWA spec (resources/cswa) built in+/// a part of its own (each sets its material, which outlives clear_part),+/// weighed with swx_mass_properties, compared with the published key within+/// its tolerance, and closed. The same presence and memory rules as the write+/// level stop the run between parts. `only:[ids]` runs a subset.+fn cswa_level(ctx: &Ctx, args: &Value, sw: &Value, ready: bool, started: Instant, mut run: Run, active: Option<String>) -> Value {+ let only: Vec<String> = args+ .get("only")+ .and_then(|o| o.as_array())+ .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())+ .unwrap_or_default();+ let memory_override = crate::memory::override_reason(args).ok().flatten();+ let idle_wait = crate::presence::wait_for_idle_secs(args);+ let mut memory_log = MemoryLog::default();+ let mut none: Option<String> = None;+ let mut last: Option<String> = None;+ let (mut passed, mut ran, mut skipped) = (0usize, 0usize, 0usize);+ let mut parts: Vec<Value> = Vec::new();+ for spec in crate::cswa::specs() {+ let spec = match spec {+ Ok(s) => s,+ Err(e) => {+ run.results.push(json!({"name": "a CSWA spec", "verb": "swx_run_script", "pass": false, "error": e}));+ continue;+ }+ };+ if !only.is_empty() && !only.iter().any(|o| *o == spec.id) {+ continue;+ }+ let name = format!("CSWA {}: {}", spec.id, spec.title);+ if !spec.needs_ops.is_empty() {+ skipped += 1;+ run.skip(&name, "swx_run_script", &format!("needs ops the bridge does not have yet: {}", spec.needs_ops.join(", ")));+ continue;+ }+ let id = spec.id.as_str();+ if let Err(stop) = presence_between(ctx, last.as_deref(), id, idle_wait, &mut none) {+ memory_log.stopped_for = Some("userPresent");+ memory_log.stopped = Some(stop);+ break;+ }+ if let Err(stop) = memory_between(ctx, last.as_deref(), id, memory_override.as_deref(), &mut memory_log, &mut none) {+ memory_log.stopped = Some(stop);+ break;+ }+ ran += 1;+ let t = Instant::now();+ let mut sargs = json!({"script": spec.script});+ if let Some(r) = &memory_override {+ sargs["ignoreMemoryPressure"] = json!(true);+ sargs["reason"] = json!(r);+ }+ let built = crate::verbs::dispatch(ctx, "swx_run_script", &sargs);+ memory_log.documents_created += 1;+ let doc = built.get("documentName").and_then(|d| d.as_str()).map(String::from)+ .or_else(|| built.pointer("/created/title").and_then(|d| d.as_str()).map(String::from));+ let mut row = json!({"name": name, "verb": "swx_run_script", "official": spec.official, "tolerancePct": spec.tol_pct});+ if built.get("success").and_then(|v| v.as_bool()) == Some(false) {+ row["pass"] = json!(false);+ row["errorCode"] = built.get("errorCode").cloned().unwrap_or(Value::Null);+ row["error"] = json!(built.get("error").and_then(|e| e.as_str()).unwrap_or("").chars().take(400).collect::<String>());+ } else {+ let mp = crate::verbs::dispatch(ctx, "swx_mass_properties", &json!({"targetDocument": doc}));+ let (ok, checks) = crate::cswa::compare(&spec, &mp);+ if ok {+ passed += 1;+ }+ row["pass"] = json!(ok);+ row["checks"] = checks;+ row["densityKgM3"] = mp.get("densityKgM3").or_else(|| mp.get("density")).cloned().unwrap_or(Value::Null);+ row["materialName"] = mp.get("materialName").cloned().unwrap_or(Value::Null);+ }+ row["ms"] = json!(t.elapsed().as_millis() as u64);+ parts.push(json!({"id": spec.id, "pass": row["pass"].clone()}));+ run.results.push(row);+ if let Some(d) = doc {+ let _ = crate::verbs::dispatch(ctx, "swx_close_doc", &json!({"targetDocument": d, "save": false}));+ }+ last = Some(spec.id.clone());+ }+ if let Some(prev) = &active {+ run.call(ctx, "re-activate the document that was active before", "swx_activate_document", json!({"name": prev}));+ }+ let verdict = crate::cswa::summary(passed, ran, skipped);+ let mut extra = memory_log.to_json(memory_override.as_deref());+ extra["cswa"] = json!({"summary": verdict, "passed": passed, "ran": ran, "skipped": skipped, "parts": parts,+ "allPassed": ran > 0 && passed == ran && memory_log.stopped.is_none()});+ finish_with("cswa", sw, ready, true, started, run, Some(format!("{verdict}.")), extra)+}+ /// What the memory checks between the geometry checks did. #[derive(Default)] struct MemoryLog {
src/verbs.rs+2−2@@ -3175,8 +3175,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 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", "LONG-RUNNING: call it with responseMode:\"stream\" or \"job\" — level write runs 5-8 minutes, and a client's default 120 s budget cuts it off (a cut-off run's parts are closed by the next swx_selftest, and swx_status lists them). 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_run_script", "exec", "the modeling DSL in millimetres: new_part, sketch (rect/circle/line/polyline/arc/arc3/tangentArc/slot/polygon, sketch fillets and chamfers, construction geometry — closed contours checked before anything is drawn — on a named plane, an offset plane {offsetFrom, offsetMm}, a face {atPointMm} or a plane offset from a face), sketch_rect/sketch_circle (anchor corner|center), extrude/cut (reverse, direction one|both|midPlane, throughAll, upToNext, upToSurface, offsetFromSurface, draftDeg), revolve/revolve_cut, mirror (features or bodies), hole (plain, counterbore, countersink), fillet, chamfer, shell, linear_pattern, circular_pattern, set_appearance (rgb), set_material (densityKgM3), 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", "LONG-RUNNING: call it with responseMode:\"stream\" or \"job\" — level write runs 5-8 minutes, and a client's default 120 s budget cuts it off (a cut-off run's parts are closed by the next swx_selftest, and swx_status lists them). 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; level:\"cswa\" builds the CSWA benchmark parts shipped in resources/cswa, weighs each with swx_mass_properties and compares it with the published key (\"N of M CSWA parts within tolerance\")"), ("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"),
src/verbs3.rs+9−2@@ -4974,18 +4974,23 @@ fn script_help(message: impl Into<String>) -> Value { "new_part": {"visible": "true (default) | false — created HIDDEN (ISldWorks::DocumentVisible). A hidden part CANNOT BE MODELLED (SOLIDWORKS selects only in the active document, and a hidden one cannot be activated — measured): a script that models it is refused document_hidden. Use reuseDocument, not hiding, to keep memory flat"}, "new_assembly": {"visible": "as new_part"}, "clear_part": {"(none)": "deletes EVERY feature after the origin of a part the BRIDGE created — never anyone else's; N checks then cost one document"},- "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": {"plane": "Front Plane|Top Plane|Right Plane|<any plane name> | {offsetFrom, offsetMm} | {offsetFrom: {face: {atPointMm:[x,y,z]}}, offsetMm} (offsetMm along the face's OUTWARD normal; negative = into the part)", "face": "{atPointMm:[x,y,z]} instead of plane", "entities": "[{rect:{widthMm, heightMm, xMm, yMm, anchor:corner|center, centerMm:[x,y], cornerRadiusMm?}}, {circle:{radiusMm|diameterMm, xMm, yMm | centerMm}}, {line:{from:[x,y], to:[x,y]}}, {centerline:{from, to}}, {polyline:{points:[[x,y],...], close:bool}}, {arc:{center:[x,y], radiusMm, startDeg, endDeg, ccw:true}}, {arc3:{start, mid, end}}, {tangentArc:{to:[x,y], radiusMm?}} (continues the entity before it), {slot:{center, lengthMm (centre to centre), widthMm, angleDeg}}, {polygon:{center, sides, circumradiusMm | inscribedRadiusMm, angleDeg (first vertex)}}, {sketchFillet:{at:[x,y], radiusMm}} / {sketchChamfer:{at:[x,y], distanceMm}} (round or cut the corner where two LINES drawn before it meet)] — ALL in one sketch: concentric circles make a ring, a rect with circles inside makes a plate with holes. Lines and arcs must CLOSE (each end meets exactly one other within 1e-6 mm — an open contour is refused naming the gap); several closed contours are several regions. Any entity takes construction:true (never part of a profile). 0.11.0, not yet run live"}, "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},+ "extrude / cut end conditions (0.11.0, not yet run live)": {"upToNext": "true — to the next face (swEndCondUpToNext 11)", "upToSurface": "{face:{atPointMm:[x,y,z]}} — to that face (swEndCondUpToSelection 10)", "offsetFromSurface": "{face:{atPointMm}, distanceMm, reverseOffset:false} — distanceMm short of that face (swEndCondOffsetFromSurface 5)", "draftDeg": "a taper, 0..90", "draftOutward": "false (default: the profile shrinks as it goes)"},+ "revolve": {"axis": "\"x\" | \"y\" (the sketch's own axes) | {entity: i} (the sketch's i-th entity as written, 0-based: a line or centerline) | {line: [[x,y],[x,y]]} (added as a centerline)", "angleDeg": 360, "midPlane": false, "reverse": false, "_": "the profile must lie on ONE side of the axis; FeatureRevolve2. 0.11.0, not yet run live"},+ "revolve_cut": {"_": "as revolve, removing material (FeatureRevolve2 IsCut)"},+ "mirror": {"plane": "a plane name | {offsetFrom, offsetMm}", "face": "{atPointMm:[x,y,z]} — a planar face, instead of plane", "features": "\"last\" (default) | a name | [names] | \"all\" (= mirrorBody)", "mirrorBody": "true: every solid body", "_": "InsertMirrorFeature2 (features mark 1, plane mark 2, bodies mark 256). 0.11.0, not yet run live"},+ "hole": {"atMm": "[x,y,z] ON a planar face: the hole's centre", "diameterMm": "required", "depthMm": "required unless throughAll (flat bottom, measured from the face)", "throughAll": false, "counterbore": "{diameterMm, depthMm}", "countersink": "{diameterMm, angleDeg: 90 (included)}", "_": "built as cuts of circles on the face's plane (no Hole Wizard); one or the other of counterbore/countersink. 0.11.0, not yet run live"}, "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_material": {"name": "required unless densityKgM3", "database": "SOLIDWORKS Materials.sldmat", "densityKgM3": "with name: the material's density must be this (else material_density_mismatch); alone: the part's own density (no library material may be applied). The step reports densityKgM3 READ BACK"}, "set_prop": {"name": "required", "value": "required"}, "save_as": {"path": "required, absolute"}, "rebuild": {},@@ -5005,6 +5010,8 @@ fn script_help(message: impl Into<String>) -> Value { "Fillet/chamfer edges are de-duplicated before they are selected (an edge selected twice is deselected); points must lie ON one edge / one face (a corner or an edge between two faces is refused, never guessed).", "A sketch need not be the step right before its extrude/cut: the bridge re-selects it by name.", "\"last\" is the feature the previous feature-making step of THIS script made.",+ "Lines and arcs (0.11.0) are in the sketch's 2D frame like rect and circle; an arc's ccw is counter-clockwise IN THAT FRAME. Composites (polyline, slot, polygon, rounded rect, arc3, tangentArc) are expanded into lines and arcs before anything is drawn, and the result says what was drawn.",+ "A hole's depth is measured from the face, flat-bottomed (no drill point); a counterbore's or countersink's depth counts within it.", "A step whose feature comes back null is an ERROR that stops the script — it never reports success with nothing built." ]), );
tests/cswa.rsadded+162@@ -0,0 +1,162 @@+//! CSWA PHASE 1 (0.11.0) — Drew, 2026-10-15: "pull 3-5 random parts from CSWA+//! exams as part of our e2e testing/validation for each release … We need this+//! bridge to be a real, useable tool, for more than the basics."+//!+//! The new DSL (lines and arcs, revolve, mirror, hole, end conditions, draft,+//! density) through the WHOLE bridge, and the benchmark (swx_selftest+//! {level:"cswa"}). EVERYTHING HERE RUNS IN THE SIMULATOR: the geometry is the+//! mock's arithmetic model against closed forms, NOT a SOLIDWORKS measurement —+//! every new op says `proven.live:false` until a live run measures it.++mod common;++use common::{Bridge, TempDir};+use serde_json::{json, Value};++const PI: f64 = std::f64::consts::PI;++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.call("swx_start", json!({}));+ (b, machine)+}++fn near(a: &Value, b: f64, rel: f64) -> bool {+ a.as_f64().map(|x| (x - b).abs() <= rel * b.abs().max(1e-9)).unwrap_or(false)+}++#[test]+fn a_flanged_bushing_revolved_holed_and_mirrored_weighs_what_the_closed_forms_say() {+ let (b, _m) = boot("cswa-bushing");+ // A flanged bushing, revolved from ONE closed polyline about the sketch's+ // y axis: a Ø40 x 8 flange and a Ø24 x 30 barrel, Ø12 bore.+ let r = b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch", "plane": "Front Plane", "entities": [+ {"polyline": {"points": [[6, 0], [20, 0], [20, 8], [12, 8], [12, 30], [6, 30]], "close": true}}]},+ {"op": "revolve", "axis": "y"},+ // From the flange's BOTTOM face (the simulator picks faces of a body's+ // box; SOLIDWORKS takes any planar face), 8 mm: through the flange.+ {"op": "hole", "atMm": [16, 0, 0], "diameterMm": 3, "depthMm": 8},+ {"op": "mirror", "plane": "Right Plane"},+ {"op": "set_material", "name": "AISI 1020", "densityKgM3": 7900}+ ]}));+ check!(b, "the 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(|x| x["op"].as_str().unwrap_or("")).collect();+ check!(b, "revolve, hole, mirror, set_material ran in order", ops == ["new_part", "sketch", "revolve", "hole", "mirror", "set_material"], format!("{ops:?}"));+ for (i, op) in [(2, "revolve"), (3, "hole"), (4, "mirror"), (5, "set_material")] {+ check!(+ b,+ &format!("{op} says proven.live:false, naming its unmeasured variant"),+ steps[i]["result"]["proven"]["live"] == false && steps[i]["result"]["proven"]["unmeasured"].as_array().map(|a| !a.is_empty()).unwrap_or(false),+ s(&steps[i]["result"]["proven"])+ );+ }+ check!(b, "the sketch's lines are named as sketch.line", steps[1]["result"]["proven"]["variants"].as_array().map(|a| a.iter().any(|v| v == "sketch.line")).unwrap_or(false), s(&steps[1]["result"]["proven"]));+ let solid = PI * ((400.0 - 36.0) * 8.0 + (144.0 - 36.0) * 22.0);+ let holes = 2.0 * PI * 2.25 * 8.0;+ let ib = b.call("swx_inspect_bodies", json!({}));+ check!(+ b,+ "the volume is the revolved profile less two Ø3 holes through the flange (SIMULATED by the arithmetic model: exact)",+ near(&ib["bodies"][0]["volumeMm3"], solid - holes, 1e-7),+ format!("want {} mm3\n{}", solid - holes, s(&ib))+ );+ let mp = b.call("swx_mass_properties", json!({}));+ check!(+ b,+ "swx_mass_properties integrates the same solid by columns (within 0.1 %): mass = volume x 7900, the material named",+ near(&mp["volumeMm3"], solid - holes, 1e-3) && near(&mp["massG"], (solid - holes) * 7.9e-3, 1e-3) && mp["densityKgM3"] == 7900.0+ && mp["materialName"] == "AISI 1020" && mp["simulatedBy"]["volume"].as_str().unwrap_or("").contains("column"),+ format!("want {} mm3\n{}", solid - holes, s(&mp))+ );+}++#[test]+fn an_open_profile_and_a_crossing_axis_are_refused_before_anything_is_built() {+ let (b, _m) = boot("cswa-refusals");+ let before = b.call("swx_docs", json!({}))["count"].clone();+ let r = b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch", "entities": [{"polyline": {"points": [[0, 0], [10, 0], [10, 10]]}}]},+ {"op": "extrude", "depthMm": 5}+ ]}));+ check!(b, "an open polyline is refused, naming the gap", r["success"] == false && r["error"].as_str().unwrap_or("").contains("gap of"), s(&r));+ let r = b.call("swx_run_script", json!({"script": [+ {"op": "new_part"},+ {"op": "sketch_rect", "widthMm": 10, "heightMm": 4, "anchor": "center"},+ {"op": "revolve", "axis": "y"}+ ]}));+ check!(b, "a profile across the revolve axis is refused", r["success"] == false && r["error"].as_str().unwrap_or("").contains("crosses the revolve axis"), s(&r));+ check!(b, "...and neither created a document", b.call("swx_docs", json!({}))["count"] == before, "");+}++#[test]+fn the_cswa_level_builds_weighs_and_closes_every_shipped_part() {+ let (b, _m) = boot("cswa-selftest");+ let before = b.call("swx_docs", json!({}))["count"].clone();+ let r = b.call("swx_selftest", json!({"level": "cswa"}));+ let parts = r["cswa"]["parts"].as_array().cloned().unwrap_or_default();+ let n = parts.len();+ check!(+ b,+ "every runnable spec is within tolerance in the simulator, and the summary says so",+ r["success"] == true && r["level"] == "cswa" && n >= 1 && r["cswa"]["allPassed"] == true+ && r["cswa"]["summary"].as_str().unwrap_or("").starts_with(&format!("{n} of {n} CSWA parts within tolerance")),+ s(&r["cswa"])+ );+ check!(b, "the hint LEADS with the verdict", r["_hint"].as_str().unwrap_or("").starts_with(&format!("{n} of {n} CSWA parts")), s(&r["_hint"]));+ let rows = r["results"].as_array().cloned().unwrap_or_default();+ if std::env::var("CSWA_PRINT").is_ok() {+ for x in rows.iter().filter(|x| x["name"].as_str().unwrap_or("").starts_with("CSWA")) {+ eprintln!("{} pass={} {} {}", x["name"], x["pass"], x["checks"], x.get("skipped").cloned().unwrap_or_default());+ }+ }+ let lego = rows.iter().find(|x| x["name"].as_str().unwrap_or("").contains("cswa-00-lego-2x4")).cloned().unwrap_or(Value::Null);+ check!(+ b,+ "spec 0 (the LEGO brick): mass and volume compared against Michael's measured volume, ABS",+ lego["pass"] == true && lego["checks"].as_array().map(|a| a.iter().any(|c| c["quantity"] == "volumeMm3" && c["pass"] == true)).unwrap_or(false),+ s(&lego)+ );+ check!(b, "every part it built was CLOSED", b.call("swx_docs", json!({}))["count"] == before, "");+ let one = b.call("swx_selftest", json!({"level": "cswa", "only": ["cswa-00-lego-2x4"]}));+ check!(b, "only:[id] runs one part", one["cswa"]["ran"] == 1, s(&one["cswa"]));+}++#[test]+fn the_cswa_level_refuses_up_front_with_solidworks_closed() {+ let machine = TempDir::new("cswa-closed");+ std::fs::write(machine.join("windows.json"), "[]").unwrap();+ let b = Bridge::start_with(&[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+ let r = b.call("swx_selftest", json!({"level": "cswa"}));+ check!(b, "refused: SOLIDWORKS is not running, nothing created", r["success"] == false && r["errorCode"] == "solidworks_not_running", s(&r));+}++#[test]+fn the_script_help_teaches_the_cswa_ops() {+ let (b, _m) = boot("cswa-help");+ let r = b.call("swx_run_script", json!({"script": "make me a bracket"}));+ let ops: Vec<String> = r["ops"].as_array().cloned().unwrap_or_default().iter().filter_map(|o| o.as_str().map(String::from)).collect();+ check!(+ b,+ "swx_run_script's help lists revolve, revolve_cut, mirror and hole, with their arguments",+ ["revolve", "revolve_cut", "mirror", "hole"].iter().all(|o| ops.iter().any(|x| x == o) && r["opArgs"].get(*o).is_some()),+ s(&r)+ );+ let ents = r["opArgs"]["sketch"]["entities"].as_str().unwrap_or("");+ check!(+ b,+ "...and every sketch entity kind, and the face-offset plane",+ ["line:", "polyline:", "arc:", "arc3:", "tangentArc:", "slot:", "polygon:", "sketchFillet:", "sketchChamfer:", "construction:true"].iter().all(|k| ents.contains(k))+ && r["opArgs"]["sketch"]["plane"].as_str().unwrap_or("").contains("{offsetFrom: {face:"),+ s(&r["opArgs"]["sketch"])+ );+}
tests/wiring.rs+43−1@@ -1181,7 +1181,10 @@ fn the_readme_verb_table_matches_bridge_json() { fn the_release_script_syncs_the_wiki_in_order_and_verifies() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let sh = std::fs::read_to_string(root.join("tools/release.sh")).unwrap();- let publish = &sh[sh.find("--publish\" ]; then").expect("the publish block")..];+ let publish = &sh[sh.find("if [ \"$PUBLISH\" = 1 ]; then").expect("the publish block")..];+ // --require-live (0.11.0) is checked BEFORE anything is built or published.+ let live = sh.find("tools/check-live-report.py").expect("the live gate");+ assert!(live < sh.find("if [ \"$PUBLISH\" = 1 ]; then").unwrap() && live < sh.find("zip -qr").unwrap(), "the live gate runs first"); let order = [ "adom-wiki release create", "adom-wiki release upload",@@ -1258,3 +1261,42 @@ fn the_memory_skill_is_shipped_as_the_third_skill() { let main = std::fs::read_to_string(root.join("SKILL.md")).unwrap(); assert!(main.contains("Long runs: read swx_status.memory first; see the solidworks-memory skill.")); }++/// THE LIVE GATE (0.11.0): `release.sh --require-live <report>` runs+/// tools/check-live-report.py, which passes ONLY a cswa-check report from this+/// version that ran every part, passed them all, did not stop, and is newer+/// than the last code commit. (Skipped where python3 is not installed.)+#[test]+fn the_live_gate_accepts_only_a_complete_passing_report_newer_than_the_code() {+ let root = Path::new(env!("CARGO_MANIFEST_DIR"));+ let dir = std::env::temp_dir().join(format!("swx-live-gate-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let good = serde_json::json!({"tool": "cswa-check", "bridgeVersion": "9.9.9", "ran": 8, "passed": 8, "allPassed": true,+ "stopped": null, "whenEpoch": 2000, "when": "t", "summary": "8 of 8 CSWA parts within tolerance"});+ let run = |name: &str, report: &serde_json::Value, ver: &str, code_at: &str| -> Option<bool> {+ let p = dir.join(name);+ std::fs::write(&p, report.to_string()).unwrap();+ std::process::Command::new("python3")+ .arg(root.join("tools/check-live-report.py"))+ .args([p.to_string_lossy().as_ref(), ver, code_at])+ .output()+ .ok()+ .map(|o| o.status.success())+ };+ let Some(ok) = run("good.json", &good, "9.9.9", "1000") else { return };+ assert!(ok, "a complete passing report from this version, newer than the code, passes");+ let with = |k: &str, v: serde_json::Value| {+ let mut r = good.clone();+ r[k] = v;+ r+ };+ assert_eq!(run("ver.json", &good, "9.9.10", "1000"), Some(false), "another version's report is refused");+ assert_eq!(run("old.json", &good, "9.9.9", "2000"), Some(false), "a report no newer than the last code commit is refused");+ assert_eq!(run("fail.json", &with("passed", serde_json::json!(7)), "9.9.9", "1000"), Some(false), "7 of 8 is refused");+ assert_eq!(run("none.json", &with("ran", serde_json::json!(0)), "9.9.9", "1000"), Some(false), "a run of nothing is refused");+ assert_eq!(run("stop.json", &with("stopped", serde_json::json!("user_present")), "9.9.9", "1000"), Some(false), "a stopped run is refused");+ assert_eq!(run("tool.json", &with("tool", serde_json::json!("dsl-live-check")), "9.9.9", "1000"), Some(false), "another tool's report is refused");+ let missing = std::process::Command::new("python3").arg(root.join("tools/check-live-report.py")).args([dir.join("nope.json").to_string_lossy().as_ref(), "9.9.9", "0"]).output().unwrap();+ assert!(!missing.status.success(), "no report, no release");+ let _ = std::fs::remove_dir_all(&dir);+}
tools/check-live-report.pyadded+45@@ -0,0 +1,45 @@+#!/usr/bin/env python3+"""check-live-report.py <report.json> <version> <last-code-commit-epoch> - the gate behind+tools/release.sh --require-live (0.11.0). Exit 0 only for a tools/cswa-check.py report that ran every+CSWA part and passed them all, was made by THIS bridge version, did not stop, and is NEWER than the+last commit that touched code."""+import json, os, sys+++def problems(r, ver, code_at):+ bad = []+ if r.get("tool") != "cswa-check":+ bad.append(f"it is not a cswa-check report (tool={r.get('tool')!r})")+ if r.get("bridgeVersion") != ver:+ bad.append(f"it was made by bridge {r.get('bridgeVersion')}, not {ver}")+ if not r.get("ran"):+ bad.append("it ran no part")+ if r.get("passed") != r.get("ran") or not r.get("allPassed"):+ bad.append(f"{r.get('summary')} - not 100 %")+ if r.get("stopped"):+ bad.append(f"the run STOPPED ({r.get('stopped')})")+ if int(r.get("whenEpoch") or 0) <= code_at:+ bad.append(f"it is OLDER than the last code commit ({r.get('when')} vs epoch {code_at})")+ return bad+++def main(argv):+ if len(argv) != 4:+ print(__doc__)+ return 2+ path, ver, code_at = argv[1], argv[2], int(argv[3] or 0)+ if not os.path.isfile(path):+ print(f" the report {path} does not exist")+ return 1+ r = json.load(open(path))+ bad = problems(r, ver, code_at)+ for b in bad:+ print(" " + b)+ if bad:+ return 1+ print(f"live CSWA report OK: {r.get('summary')} on SOLIDWORKS {(r.get('solidworks') or {}).get('majorVersion')}, {r.get('when')}")+ return 0+++if __name__ == "__main__":+ sys.exit(main(sys.argv))
tools/cswa-check.pyadded+323@@ -0,0 +1,323 @@+#!/usr/bin/env python3+"""cswa-check.py - the CSWA benchmark on REAL SOLIDWORKS (0.11.0).++Every spec in resources/cswa/*.json is one exam-style part: the published answer key (mass in grams,+sometimes volume / centre of mass / surface area), the material, and the swx_run_script that builds+it. Each part is built through the bridge, weighed with swx_mass_properties, and compared with the+key within the spec's tolerance (CSWA accepts about 1 %). Nothing is trusted that was not measured.++ python3 tools/cswa-check.py [--target adom_drew2] [--only cswa-00-lego-2x4] [--fresh] [--keep]++Same rules as tools/dsl-live-check.py: every creation says purpose:"bridge"; by default the parts are+built in ONE bridge-made part (reuseDocument: its leading new_part becomes clear_part), so N parts+cost one document; the run STOPS on a user_present / low_commit_memory / dialog refusal and never+creates another part after one. The report (every reply, every expectation, pass/fail) is written+to cswa-report.json; tools/release.sh --require-live <report> refuses to publish without a passing+one newer than the last code commit.+"""+import argparse, glob, json, math, os, subprocess, sys, time++TARGET = "adom_drew2"+THREAD = "SolidWorks Bridge"+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))+STOP_CODES = ("user_present", "low_commit_memory", "stopped_by_dialog", "dialog_open", "user_present_refusal")+++def ascii_reason(args):+ """A non-ASCII `reason` makes ab fail the call before it reaches the bridge (measured 2026-10-13)."""+ if isinstance(args, dict) and isinstance(args.get("reason"), str):+ args = dict(args, reason=args["reason"].encode("ascii", "replace").decode("ascii"))+ return args+++def call(verb, args, timeout=300):+ args = ascii_reason(args)+ try:+ r = subprocess.run(["adom-bridge", "--target", TARGET, "--ai-thread", THREAD, verb, json.dumps(args)],+ capture_output=True, text=True, timeout=timeout + 60)+ except subprocess.TimeoutExpired:+ return {"status": "error", "errorCode": "no_answer", "error": f"{verb} did not return in {timeout + 60}s"}+ t = r.stdout+ i = t.find("{")+ if i < 0:+ return {"status": "error", "errorCode": "not_json", "error": (t + r.stderr)[:400]}+ try:+ d = json.loads(t[i:])+ except ValueError:+ return {"status": "error", "errorCode": "not_json", "error": t[i:i + 400]}+ return d["result"] if isinstance(d, dict) and isinstance(d.get("result"), dict) else d+++# ── spec variables: the SAME grammar as src/cswa.rs (eval / expand) ──────────+FUNCS1 = {"sqrt": math.sqrt, "sin": math.sin, "cos": math.cos, "tan": math.tan, "asin": math.asin, "acos": math.acos,+ "atan": math.atan, "abs": abs, "rad": math.radians, "deg": math.degrees}+FUNCS2 = {"atan2": math.atan2, "min": min, "max": max}+++def evaluate(expr, vars_):+ """+ - * / ^ (right-assoc), unary minus, parentheses, pi, the functions above. No eval()."""+ s, i = expr, [0]++ def ws():+ while i[0] < len(s) and s[i[0]].isspace():+ i[0] += 1++ def peek():+ ws()+ return s[i[0]] if i[0] < len(s) else None++ def e():+ v = t()+ while peek() in ("+", "-"):+ op = s[i[0]]; i[0] += 1+ v = v + t() if op == "+" else v - t()+ return v++ def t():+ v = pw()+ while peek() in ("*", "/"):+ op = s[i[0]]; i[0] += 1+ v = v * pw() if op == "*" else v / pw()+ return v++ def pw():+ b = un()+ if peek() == "^":+ i[0] += 1+ return b ** pw()+ return b++ def un():+ c = peek()+ if c == "-":+ i[0] += 1+ return -un()+ if c == "+":+ i[0] += 1+ return un()+ return atom()++ def atom():+ c = peek()+ if c == "(":+ i[0] += 1+ v = e()+ if peek() != ")":+ raise ValueError(f"missing ) in {expr!r}")+ i[0] += 1+ return v+ if c is not None and (c.isdigit() or c == "."):+ st = i[0]+ while i[0] < len(s) and (s[i[0]].isdigit() or s[i[0]] in ".e" or (s[i[0]] in "+-" and i[0] > st and s[i[0] - 1] == "e")):+ i[0] += 1+ return float(s[st:i[0]])+ if c is not None and (c.isalpha() or c == "_"):+ st = i[0]+ while i[0] < len(s) and (s[i[0]].isalnum() or s[i[0]] == "_"):+ i[0] += 1+ name = s[st:i[0]]+ if peek() == "(":+ i[0] += 1+ args = [e()]+ while peek() == ",":+ i[0] += 1+ args.append(e())+ if peek() != ")":+ raise ValueError(f"missing ) after {name}( in {expr!r}")+ i[0] += 1+ if name in FUNCS1 and len(args) == 1:+ return float(FUNCS1[name](args[0]))+ if name in FUNCS2 and len(args) == 2:+ return float(FUNCS2[name](args[0], args[1]))+ raise ValueError(f"unknown function {name}/{len(args)} in {expr!r}")+ if name == "pi":+ return math.pi+ if name not in vars_:+ raise ValueError(f"unknown variable {name} in {expr!r}")+ return vars_[name]+ raise ValueError(f"unexpected {c!r} in {expr!r}")++ v = e()+ if peek() is not None:+ raise ValueError(f"unexpected text in {expr!r}")+ if not math.isfinite(v):+ raise ValueError(f"{expr!r} is not finite")+ return v+++def resolve_extends(spec, by_id, depth=0):+ base_id = spec.get("extends")+ if not base_id:+ return json.loads(json.dumps(spec))+ if depth > 8:+ raise ValueError(f"{spec['id']}: extends goes more than 8 deep")+ merged = resolve_extends(by_id[base_id], by_id, depth + 1)+ merged.pop("needsOps", None)+ variables = dict(merged.get("variables") or {})+ variables.update(spec.get("variables") or {})+ for k, v in spec.items():+ if k not in ("extends", "variables", "scriptAppend", "derivedAppend"):+ merged[k] = v+ merged["variables"] = variables+ for key, into in (("scriptAppend", "script"), ("derivedAppend", "derived")):+ if key in spec:+ merged.setdefault(into, []).extend(spec[key])+ return merged+++def expand(spec, by_id):+ spec = resolve_extends(spec, by_id)+ vars_ = {k: float(v) for k, v in (spec.get("variables") or {}).items()}+ for name, ex in spec.get("derived") or []:+ vars_[name] = evaluate(ex, vars_)++ def sub(v):+ if isinstance(v, str) and v.startswith("="):+ return evaluate(v[1:], vars_)+ if isinstance(v, list):+ return [sub(x) for x in v]+ if isinstance(v, dict):+ return {k: sub(x) for k, x in v.items()}+ return v++ for key in ("script", "expected"):+ if key in spec:+ spec[key] = sub(spec[key])+ return spec+++def ok(r):+ return isinstance(r, dict) and r.get("success") is not False and r.get("status") != "error"+++def pct_off(got, want):+ if want == 0:+ return 0.0 if got == 0 else float("inf")+ return abs(got - want) / abs(want) * 100.0+++def compare(spec, mp):+ """The same rules as src/cswa.rs::compare (the self-test's): mass always; volume, surface area and+ centre of mass when the key has them; a coordinate passes within tol % or 0.05 mm."""+ exp = spec["expected"]+ tol = exp.get("tolerancePct", 1.0)+ checks = []+ mass = mp.get("massG")+ if mass is None and mp.get("massKg") is not None:+ mass = mp["massKg"] * 1000.0++ def one(q, got, want):+ off = None if got is None else pct_off(got, want)+ checks.append({"quantity": q, "expected": want, "measured": got, "offPct": off, "pass": off is not None and off <= tol})++ one("massG", mass, exp["massG"])+ if exp.get("volumeMm3") is not None:+ v = mp.get("volumeMm3")+ if v is None and mp.get("volumeM3") is not None:+ v = mp["volumeM3"] * 1e9+ one("volumeMm3", v, exp["volumeMm3"])+ if exp.get("surfaceAreaMm2") is not None:+ a = mp.get("surfaceAreaMm2")+ if a is None and mp.get("surfaceAreaM2") is not None:+ a = mp["surfaceAreaM2"] * 1e6+ one("surfaceAreaMm2", a, exp["surfaceAreaMm2"])+ if exp.get("centerOfMassMm") is not None:+ got = mp.get("centerOfMassMm") or []+ want = exp["centerOfMassMm"]+ good = len(got) == 3 and all(abs(g - w) <= max(abs(w) * tol / 100.0, 0.05) for g, w in zip(got, want))+ checks.append({"quantity": "centerOfMassMm", "expected": want, "measured": got, "pass": good})+ return all(c["pass"] for c in checks), checks+++def main():+ global TARGET+ ap = argparse.ArgumentParser()+ ap.add_argument("--target", default=TARGET)+ ap.add_argument("--only", default="")+ ap.add_argument("--fresh", action="store_true", help="a fresh part per spec instead of one reused part")+ ap.add_argument("--keep", action="store_true", help="leave the parts open")+ ap.add_argument("--report", default="cswa-report.json")+ ap.add_argument("--expand-only", action="store_true", help="print every spec's script with its variables substituted, and stop (no bridge call)")+ a = ap.parse_args()+ TARGET = a.target+ raw = [json.load(open(p)) for p in sorted(glob.glob(os.path.join(ROOT, "resources", "cswa", "*.json")))]+ by_id = {s["id"]: s for s in raw}+ specs = [expand(s, by_id) for s in raw]+ if a.expand_only:+ print(json.dumps({s["id"]: {"script": s.get("script"), "expected": s.get("expected"), "needsOps": s.get("needsOps") or []} for s in specs}))+ return 0+ only = {x.strip() for x in a.only.split(",") if x.strip()}+ st = call("swx_status", {"reason": "cswa-check: is SOLIDWORKS ready"})+ sw = st.get("solidworks") or {}+ print(f"bridge {st.get('bridgeVersion')} | SOLIDWORKS running={sw.get('running')} ready={sw.get('ready')} "+ f"version={sw.get('majorVersion')} edition={sw.get('edition')}")+ if not sw.get("running"):+ print("SOLIDWORKS is not running on the target: start it (swx_start) and run again. Nothing was done.")+ return 2+ shared = None+ if not a.fresh:+ nd = call("swx_new_document", {"type": "part", "purpose": "bridge", "reason": "cswa-check: the one part every CSWA spec is built in"})+ if nd.get("errorCode") in STOP_CODES:+ print(f"STOP the bridge refused to create the shared part ({nd.get('errorCode')}). Nothing was built.")+ return 3+ shared = (nd.get("documentIdentity") or {}).get("path") or (nd.get("created") or {}).get("path") or (nd.get("created") or {}).get("title")+ results, stopped = [], None+ for spec in specs:+ sid = spec["id"]+ if only and sid not in only:+ continue+ rec = {"id": sid, "title": spec.get("title"), "official": spec.get("official"), "source": spec.get("source"),+ "expected": spec["expected"], "material": spec.get("material")}+ if spec.get("needsOps"):+ rec["skipped"] = f"needs ops the bridge does not have yet: {', '.join(spec['needsOps'])}"+ results.append(rec)+ print(f"SKIP {sid} {rec['skipped']}")+ continue+ t0 = time.time()+ req = {"script": spec["script"], "purpose": "bridge", "reason": f"cswa-check {sid}"}+ if shared:+ req["reuseDocument"] = shared+ r = call("swx_run_script", req)+ rec["runScript"] = r+ doc = r.get("documentName")+ if not ok(r):+ rec["pass"] = False+ rec["fails"] = [f"swx_run_script failed: {r.get('errorCode')} {str(r.get('error'))[:300]}"]+ if r.get("errorCode") in STOP_CODES:+ stopped = r.get("errorCode")+ else:+ mp = call("swx_mass_properties", {"targetDocument": doc, "reason": f"cswa-check {sid}: weigh it"})+ rec["massProperties"] = mp+ rec["pass"], rec["checks"] = compare(spec, mp)+ rec["fails"] = [f"{c['quantity']}: {c.get('measured')} vs {c['expected']} ({c.get('offPct')} % off)" for c in rec["checks"] if not c["pass"]]+ rec["seconds"] = round(time.time() - t0, 1)+ results.append(rec)+ print(f"{'PASS' if rec['pass'] else 'FAIL'} {sid} {spec.get('title')} ({rec['seconds']} s)")+ for f in rec.get("fails", []):+ print(f" - {f}")+ if stopped:+ print(f"STOP the bridge refused ({stopped}); no more parts will be built. Re-run when the user is idle / memory is back / the dialog is answered.")+ break+ if not shared and not a.keep and doc:+ rw = call("swx_close_doc", {"targetDocument": doc, "save": False, "reason": f"cswa-check: close {sid}"})+ if not ok(rw):+ stopped = rw.get("errorCode") or "close_refused"+ print(f"STOP the close after {sid} was refused ({stopped}); no more parts will be built.")+ break+ if shared and not a.keep:+ call("swx_close_doc", {"path" if ("\\" in shared or "/" in shared) else "targetDocument": shared, "save": False, "reason": "cswa-check: close the shared part"})+ ran = [r for r in results if "skipped" not in r]+ passed = sum(1 for r in ran if r.get("pass"))+ report = {"tool": "cswa-check", "when": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "whenEpoch": int(time.time()),+ "target": TARGET, "bridgeVersion": st.get("bridgeVersion"),+ "solidworks": {k: sw.get(k) for k in ("majorVersion", "edition", "versionSupport")},+ "summary": f"{passed} of {len(ran)} CSWA parts within tolerance", "passed": passed, "ran": len(ran),+ "skipped": len(results) - len(ran), "stopped": stopped, "allPassed": bool(ran) and passed == len(ran) and not stopped,+ "parts": results}+ json.dump(report, open(a.report, "w"), indent=1)+ print(f"\n{report['summary']}{' (STOPPED: ' + stopped + ')' if stopped else ''}. Full evidence: {a.report}")+ return 0 if report["allPassed"] else 1+++if __name__ == "__main__":+ sys.exit(main())
tools/cswa/README.mdadded+118@@ -0,0 +1,118 @@+# The CSWA benchmark (0.11.0)++> "Pull 3-5 random parts from CSWA exams as part of our e2e testing/validation for+> each release. We can also use those as benchmarks for new feature developments."+> — Drew, 2026-10-15++Each spec in [`resources/cswa/`](../../resources/cswa/) is ONE exam-style part: the+published answer key, the material, and the `swx_run_script` that builds it. The specs+ship in the release zip (`resources/cswa/`) and are embedded in the exe.++## Running it++| Where | Command | What it compares |+|---|---|---|+| Live, from a dev container | `python3 tools/cswa-check.py [--target adom_drew2] [--only <id>] [--fresh] [--keep]` | mass, and volume / surface area / centre of mass when the key has them; writes `cswa-report.json` |+| On the desktop, through the bridge | `swx_selftest {"level":"cswa"}` (`only:[ids]`) | the same, with the same presence and memory rules as `level:"write"`; summary `"N of M CSWA parts within tolerance"` |+| In the simulator | `cargo test --test cswa` | mass and volume only (the arithmetic model's surface area and centre of mass are not exact for every shape) |++`cswa-check.py` follows `tools/dsl-live-check.py`'s rules:++- every creation says `purpose:"bridge"`;+- by default every part is built in ONE bridge-made part (`reuseDocument`, whose leading+ `new_part` becomes `clear_part`); `--fresh` gives each spec its own part;+- it STOPS on a `user_present`, `low_commit_memory` or dialog refusal, and never creates+ another part after one.++## The release gate++```bash+python3 tools/cswa-check.py --report /tmp/cswa-report.json # on the live target, with THIS build+tools/release.sh --require-live /tmp/cswa-report.json --publish+```++`--require-live` refuses to go on unless the report (`tools/check-live-report.py`):++- comes from `cswa-check`;+- was made by THIS bridge version;+- ran at least one part and passed every part it ran;+- did not stop;+- is newer than the last commit that touched `src/`, `resources/` or `Cargo.toml`.++## Spec schema++```json+{+ "id": "cswa-05-…", // also the file name+ "title": "…",+ "source": "the URL of the drawing and the key",+ "official": true, // Dassault's own sample exam; false = community+ "units": "MMGS", // the DSL is millimetres; convert IPS parts first+ "material": {"name": "AISI 1020", "densityKgM3": 7900},+ "expected": {+ "massG": 939.54, // REQUIRED: the published answer+ "volumeMm3": null, // optional+ "centerOfMassMm": [x, y, z], // optional, in the part's own frame+ "surfaceAreaMm2": null, // optional+ "tolerancePct": 1.0 // CSWA accepts about 1 %+ },+ "keyBasis": "where the numbers came from, when it is not simply the published key",+ "needsOps": [], // ops the bridge lacks: the part is SKIPPED, never half-built+ "variables": {"A": 81}, "derived": [["xR", "A-14"]], // optional, see below+ "extends": "<another spec's id>", // optional, see below+ "script": [ … swx_run_script steps … ]+}+```++Rules:++- **The key is the PUBLISHED number,** never one the bridge computed. A spec whose key was+ derived (spec 0's mass is Michael's measured volume times the density of ABS) says so+ in `keyBasis`.+- **A runnable spec must parse** — `cargo test` parses every one (`src/cswa.rs`).+- **A spec that needs a missing op** lists it in `needsOps`. That is the backlog: when the+ op lands, the spec runs.++## Variables, derived values and `extends`++An exam often reuses one drawing with new values (the Tool Block's steps). A spec may give+`variables` (numbers), `derived` (`[name, expression]` pairs, evaluated in order) and write any+number of its script or key as `"=expression"`. `extends: "<id>"` takes another spec whole and+overrides its `variables`; `scriptAppend` / `derivedAppend` add steps and values after the base's.+Expressions: `+ - * / ^`, parentheses, `pi`, `sqrt sin cos tan asin acos atan atan2 abs min max+rad deg`. This is the BENCHMARK's grammar, in `src/cswa.rs` and `tools/cswa-check.py` (a test holds+the two to the same numbers); the bridge itself is only ever sent numbers.++## The parts++| Spec | Source | Key | Ops it exercises |+|---|---|---|---|+| `cswa-00-lego-2x4` | issue #1 (spec 0) | 2073.3615 mm³ (Michael's 2021 measurement; ours on 2026 identical), ABS | rectangles, multi-profile sketch, offset plane, cut reversed |+| `cswa-01-3dengr-q5-block` | 3dengr, community | 324.00 g, CoM (50, 10, 30) | rectangle, extrude, density |+| `cswa-02-3dengr-q6-hole` | 3dengr, community | 307.04 g, CoM (51.38, 10.00, 30.00) | `hole` through all, a centre of mass |+| `cswa-03-3dengr-q8-hole30` | 3dengr, community | 285.83 g | `hole` |+| `cswa-04-3dengr-q9-l-bracket` | 3dengr, community | 410.80 g, CoM (26.54, 16.54, 20.00) | ONE `polyline` profile, AISI 1020 |+| `cswa-05-tool-block-step1` | official sample exam, Q3 | 939.54 g | `line`, `tangentArc` (R19), `polyline`, `arc3` (R29, R5), `sketchChamfer`, a hole in the profile; variables A B C |+| `cswa-06-tool-block-step2` | official, Q4 | 1032.32 g | the same script, `extends` with A 84, B 59, C 45 |+| `cswa-07-tool-block-step3` | official, Q5 | 628.18 g | a plane offset 12 inside the BACK face, the R41 slot, two face cuts, an Ø11 `hole` |+| `cswa-08-tool-block-step4` | official, Q6 | 432.58 g | SKIPPED: `needsOps: ["offsetEntities"]` (the pocket is the front face's outline offset 1 mm) |++The recipes, ambiguities and the reviewer's numeric rebuilds are in [`SOURCES.md`](SOURCES.md)+(`tb2.py`, `tb3b.py`, `tb4c.py` beside it). In the simulator every runnable part lands within+0.01 % of its key (`cargo test --test cswa`, `CSWA_PRINT=1` prints the table).++## Roadmap (drawings only — not transcribed yet)++| Candidate | Key | Needs |+|---|---|---|+| 2007 sample Q1 (copper, A 63 B 50 C 100) | 1280 g | multi-sketch, angled cuts, concentric bores — a human must read the drawing |+| 2007 sample Q6 (6061, A 100) | 2040.57 g | counterbored hole, R50 saddle, lugs — a human must read the drawing |+| Planchard 3-3 (6061, A 50 B 50 C 120) | 302.40 g, CoM (0, 19.20, 0) | `mirror`, mid-plane extrude, a through-all cut |+| Official chain-link assembly (2017 sample) | CoM (348.66, −88.48, −91.40) at A 25° B 125° C 130° | assemblies with mates (not in the DSL) |+| Tool Block step 4 | 432.58 g | `offsetEntities` (Offset Entities of a face's loop) |++## Spec 0++`cswa-00-lego-2x4`: the LEGO 2x4 acceptance brick from issue #1. It is not an exam part. Its+volume, 2073.3615208 mm³, is the one SOLIDWORKS 2021 measured for Michael's brick; the+mass is that volume in ABS (1020 kg/m³).
tools/cswa/tb2.pyadded+49@@ -0,0 +1,49 @@+import math+def arc(cx,cy,r,a0,a1,n=4000):+ return [(cx+r*math.cos(a0+(a1-a0)*i/n), cy+r*math.sin(a0+(a1-a0)*i/n)) for i in range(n+1)]+def area(pts):+ s=0+ for i in range(len(pts)):+ x0,y0=pts[i]; x1,y1=pts[(i+1)%len(pts)]+ s+=x0*y1-x1*y0+ return abs(s)/2+def toolblock(A,B,C, top=24.0, vdepth=19.0, redge=0.0, hole_y=14.0, line_start_x=0.0, lobe_h=32.0, r29_mode="flank"):+ xL=-14.0; yB=-hole_y; yT=yB+lobe_h+ xR=A-14.0; xRL=xR-29.0+ yRT=top; yV=yB-vdepth; yRE=redge+ th=math.radians(10); d=(math.cos(th),-math.sin(th)); nrm=(math.sin(th),math.cos(th))+ P0=(line_start_x,yT); P=(xRL,yRT)+ ox=P0[0]+19*nrm[0]-P[0]; oy=P0[1]+19*nrm[1]-P[1]+ b=2*(ox*d[0]+oy*d[1]); c=ox*ox+oy*oy-361; disc=b*b-4*c+ assert disc>=0+ sols=[]+ for t in [(-b-math.sqrt(disc))/2,(-b+math.sqrt(disc))/2]:+ cxx=P0[0]+t*d[0]+19*nrm[0]; cyy=P0[1]+t*d[1]+19*nrm[1]+ if cxx<P[0]: sols.append((t,cxx,cyy,(P0[0]+t*d[0],P0[1]+t*d[1])))+ t,c19x,c19y,T=sols[0]+ fx0,fy0=xRL,yV; fx1,fy1=xR,yRE+ s=(yB-fy0)/(fy1-fy0); c29x=fx0+s*(fx1-fx0); c29y=yB+ xv=c29x-math.sqrt(29**2-(c29y-yV)**2)+ c5y=yB-5; c5x=c29x-math.sqrt(34**2-25)+ ux,uy=(c29x-c5x)/34,(c29y-c5y)/34; tp=(c5x+5*ux,c5y+5*uy)+ pts=[(xL,yB+7),(xL,yT-7),(-7.0,yT),(line_start_x,yT)]+ a0=math.atan2(T[1]-c19y,T[0]-c19x); a1=math.atan2(P[1]-c19y,P[0]-c19x); pts+=arc(c19x,c19y,19,a0,a1)+ pts+=[(xR-5,yRT),(xR,yRT-5),(xR,yRE),(xRL,yV),(xv,yV)]+ a0=math.atan2(yV-c29y,xv-c29x); a1=math.atan2(tp[1]-c29y,tp[0]-c29x); pts+=arc(c29x,c29y,29,a0,a1)+ a0=math.atan2(tp[1]-c5y,tp[0]-c5x); a1=math.pi/2+ if a1<a0: a1+=2*math.pi+ if a1-a0>math.pi: a1-=2*math.pi+ pts+=arc(c5x,c5y,5,a0,a1)+ pts+=[(c5x,yB),(-7.0,yB)]+ prof=area(pts)-math.pi*49+ return prof, prof*C*0.0079, dict(T=T,c19=(c19x,c19y),c29=(c29x,c29y),xv=xv,c5=(c5x,c5y),tp=tp), pts+p,m,info,pts=toolblock(81,57,43)+print("STEP1 corrected: area=%.2f mass=%.2f (key 939.54, diff %.2f%%)"%(p,m,(m/939.54-1)*100)); print(info)+# top boundary samples vs measured (x from left edge, drop from right-lobe top)+meas={14:6.15,20:7.18,25:8.06,30:9.01,35:9.52,40:9.01,45:7.03,50:2.86}+for xm,v in meas.items():+ x=xm-14; ys=[y for (xx,y) in pts if abs(xx-x)<0.3] or [24-99]+ print(" x=%d measured drop %.2f model drop %.2f"%(xm,v,24-max(ys)))+print("STEP2 target area %.2f"%(1032.32/0.0079/45))+for name,kw in [("top=26",dict(top=26.0)),("top=26,redge=2",dict(top=26.0,redge=2.0)),("vdepth=21",dict(vdepth=21.0)),("hole_y=16,lobe_h=34",dict(hole_y=16.0,lobe_h=34.0)),("hole_y=16,lobe_h=32",dict(hole_y=16.0)),("top=25,vdepth=20",dict(top=25.0,vdepth=20.0))]:+ p,m,info,_=toolblock(84,59,45,**kw); print(" %-22s area=%.2f mass=%.2f diff=%.2f%%"%(name,p,m,(m/1032.32-1)*100))
tools/cswa/tb3b.pyadded+24@@ -0,0 +1,24 @@+import math, sys, io, contextlib+sys.path.insert(0,'.')+with contextlib.redirect_stdout(io.StringIO()):+ from tb2 import toolblock, area+ from tb34 import clip_halfplane, clip_disc+A,B,C=86,58,44+prof,_,info,P=toolblock(A,B,C,top=B-33.0,redge=B-57.0)+Aprof=area(P); hole14=math.pi*49; hole11=math.pi*5.5**2+xstep=(A-52)-14.0+Astep=area(clip_halfplane(P,1,0,xstep))+Abelow=area(clip_halfplane(P,0,1,-14.0)) # region y<=-14 (below lobe-bottom line)+Abelow_right=area(clip_halfplane(clip_halfplane(P,0,1,-14.0),-1,0,-xstep)) # y<=-14 and x>=xstep (not already step-cut)+xR=A-14.0+F=(43+29*(22/34.0),-11.0); yc=-11+math.sqrt(41**2-(xR-F[0])**2); cx,cy=xR,yc+Aslot=area(clip_disc(P,cx,cy,41.0))+print("areas: profile %.2f step(x<=%.0f) %.2f below(y<=-14) %.2f below&right-of-step %.2f slot %.2f"%(Aprof,xstep,Astep,Abelow,Abelow_right,Aslot))+V=Aprof*C - Astep*19 - Abelow_right*19 - Aslot*24 - hole14*(C-19) - hole11*(C-24)+t3=628.18/0.0079+print("STEP3 with 19x19 under-notch: V=%.1f mass=%.2f (key 628.18, diff %+.3f%%)"%(V,V*0.0079,(V/t3-1)*100))+# sensitivity: R41 center variants+for name,(ccx,ccy) in [("corner (72,25)",(xR,25.0)),("(72,28.0)",(xR,28.0)),("(72,29.5)",(xR,29.5)),("pixel (72.4,28.6)",(72.4,28.6))]:+ As=area(clip_disc(P,ccx,ccy,41.0)); Vv=Aprof*C - Astep*19 - Abelow_right*19 - As*24 - hole14*(C-19) - hole11*(C-24)+ print(" R41 center %-18s slotA=%.1f mass=%.2f (%+.2f%%)"%(name,As,Vv*0.0079,(Vv/t3-1)*100))+import json; json.dump(dict(V3=V,Aprof=Aprof,Astep=Astep,Abelow_right=Abelow_right,Aslot=Aslot,cx=cx,cy=cy),open("step3.json","w"))
tools/cswa/tb4c.pyadded+42@@ -0,0 +1,42 @@+import math, sys, io, contextlib, json+sys.path.insert(0,'.')+with contextlib.redirect_stdout(io.StringIO()):+ from tb2 import toolblock, area+ from tb34 import clip_halfplane, clip_disc+A,B,C=86,58,44+prof,_,info,P=toolblock(A,B,C,top=B-33.0,redge=B-57.0)+S=json.load(open("step3.json")); V3=S['V3']; cx,cy=S['cx'],S['cy']+c19x,c19y=info['c19']; T=info['T']+hole11=math.pi*5.5**2; ring=math.pi*6.5**2+xstep=(A-52)-14.0; xR=A-14.0; top=B-33.0; yRE=B-57.0+th=math.radians(10); lnx,lny=math.sin(th),math.cos(th) # outward normal of the 10deg line+def K1(d):+ big=[(-200,-200),(200,-200),(200,200),(-200,200)]+ K=clip_halfplane(big,-1,0,-(xstep+d)); K=clip_halfplane(K,0,-1,14.0-d); K=clip_halfplane(K,0,1,top-d)+ s2=math.sqrt(0.5); K=clip_halfplane(K,s2,s2,s2*(xR-5)+s2*top-d); K=clip_halfplane(K,1,0,xR-d)+ fx,fy=29.0,(yRE+33.0); L=math.hypot(fx,fy); nx,ny=fy/L,-fx/L+ K=clip_halfplane(K,nx,ny,nx*(xR-29)+ny*(-33.0)-d)+ return K+def face_area(poly,d):+ if not poly: return 0.0+ r=19.0+d; Tx=T[0]-d*lnx+ a=area(poly)-area(clip_disc(poly,c19x,c19y,r))+ W=clip_halfplane(poly,1,0,Tx); W=clip_halfplane(W,-lnx,-lny,-(lny*18.0-d)) # x<=Tx and above the (shifted) line+ if W: a-=area(W)-area(clip_disc(W,c19x,c19y,r))+ return a+A0=face_area(K1(0.0),0.0); A1=face_area(K1(1.0),1.0)+chk=area(clip_halfplane(clip_halfplane(P,-1,0,-xstep),0,-1,14.0))+print("front-face area (no offset) %.2f vs profile-derived check %.2f ; offset-1 outline area %.2f"%(A0,chk,A1))+t4=432.58/0.0079+def Ain(d,r): return face_area(clip_disc(K1(d),cx,cy,r),d)+rows=[]+for wall_out in (0.0,1.0,2.0):+ for wall_in in (0.0,1.0,2.0):+ for ringflag in (False,True):+ for skin in (1.0,0.0):+ ai=Ain(1.0,41.0-wall_in); ao=A1-Ain(1.0,41.0+wall_out)+ Vp=ao*32+(ai-(ring if ringflag else hole11))*(8.0-skin)+ V4=V3-Vp; rows.append(((V4/t4-1)*100,wall_out,wall_in,ringflag,skin,V4*0.0079,ao,ai))+rows.sort(key=lambda r:abs(r[0]))+for d,wo,wi,rf,sk,m,ao,ai in rows[:10]:+ print("wall_out=%.0f wall_in=%.0f ring=%-5s skin=%.0f Aout=%.1f Ain=%.1f -> mass %.2f (diff %+.3f%%)"%(wo,wi,rf,sk,ao,ai,m,d))
tools/release.sh+20−1@@ -4,6 +4,7 @@ # # tools/release.sh # stage + zip + manifest (dist/), no network # tools/release.sh --publish # ...then release create/upload + manifest push+# tools/release.sh --require-live cswa-report.json --publish # ...only with a passing LIVE CSWA run # # Versions are read from BRIDGE_VERSION and MUST match bridge.json ("version") # and the manifest; the script refuses to ship a drifted triple.@@ -30,6 +31,24 @@ EXE="target/x86_64-pc-windows-gnu/release/swx-bridge.exe" # --publish puts this build on every machine that runs the bridge. There is no # later step at which a bad build can still be stopped. MAN="adom-bridge-solidworks-manifest.json"+# ARGUMENTS: --publish, and --require-live <report.json> (0.11.0): REFUSE to go on unless a LIVE+# tools/cswa-check.py report is given that (a) ran every CSWA part and passed them all, (b) was made+# by THIS version, and (c) is NEWER than the last commit that touched code (src/, resources/,+# Cargo.toml) - a passing report from before the last code change proves nothing about this build.+PUBLISH=0; LIVE=""+while [ $# -gt 0 ]; do+ case "$1" in+ --publish) PUBLISH=1 ;;+ --require-live) LIVE="${2:-}"; [ -n "$LIVE" ] || { echo "ERROR: --require-live needs the cswa-check report path"; exit 1; }; shift ;;+ *) echo "ERROR: unknown argument $1 (use --publish and/or --require-live <report.json>)"; exit 1 ;;+ esac+ shift+done+if [ -n "$LIVE" ]; then+ CODE_AT="$(git log -1 --format=%ct -- src resources Cargo.toml 2>/dev/null || echo 0)"+ python3 tools/check-live-report.py "$LIVE" "$VER" "${CODE_AT:-0}" \+ || { echo "ERROR: --require-live: no passing live CSWA report for this build - run tools/cswa-check.py on the target first"; exit 1; }+fi ZIP="swx-bridge-v${VER}.zip" [ -f "$EXE" ] || { echo "ERROR: $EXE missing - run: cargo build --release --target x86_64-pc-windows-gnu"; exit 1; }@@ -56,7 +75,7 @@ echo "OK: $DIST/$ZIP size=$SIZE sha256=$SHA" unzip -l "$DIST/$ZIP" | sort -k1 -n -r | head -6 [ "$SIZE" -lt 3000000 ] || { echo "ERROR: zip > 3 MB - something non-runtime got in"; exit 1; } -if [ "${1:-}" = "--publish" ]; then+if [ "$PUBLISH" = 1 ]; then # ── ONE COMMAND, IN ORDER (0.9.12). The wiki sync drifted when its steps were # run by hand (a hero/title/brief unlinked by a pkg publish; a README verb # count three behind). Every step below runs here, in this order, and the
uninstall.sh