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

  
package.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+  }+ ]+}\ No newline at end of file
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+  }+ ]+}\ No newline at end of file
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+  }+ ]+}\ No newline at end of file
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+  }+ ]+}\ No newline at end of file
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+  }+ ]+}\ No newline at end of file
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+  }+ ]+}\ No newline at end of file
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 %"+}\ No newline at end of file
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+  }+ ]+}\ No newline at end of file
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"+}\ No newline at end of file
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, &regs, 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 &regs {-            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 &regs {+            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