From 737c9358d070c233c15cbe8eff69c7d04bfa4d2b Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:26:28 +0200 Subject: [PATCH] Add three-axis milling with independent CAM and Fusion validation (#1682) Thanks @CypherNaught-0x. --- .github/workflows/ci.yml | 11 + ATTRIBUTION.md | 2 +- Cargo.lock | 192 ++- Cargo.toml | 1 + ROADMAP.md | 16 +- assets/icons/ui/source/icons.rs | 13 + assets/locales/en.json | 20 + assets/locales/ja.json | 20 + assets/locales/zh.json | 20 + assets/styles/ui/palettes.rs | 22 + crates/cam/Cargo.toml | 17 + crates/cam/src/collision.rs | 483 ++++++++ crates/cam/src/compare.rs | 489 ++++++++ crates/cam/src/corpus.rs | 388 +++++++ crates/cam/src/corpus/channel.rs | 98 ++ crates/cam/src/corpus/extended.rs | 220 ++++ crates/cam/src/corpus/exterior.rs | 84 ++ crates/cam/src/corpus/through.rs | 94 ++ crates/cam/src/coverage.rs | 215 ++++ crates/cam/src/dimensions.rs | 554 +++++++++ crates/cam/src/generate.rs | 680 +++++++++++ crates/cam/src/hashing.rs | 150 +++ crates/cam/src/lib.rs | 51 + crates/cam/src/model.rs | 523 +++++++++ crates/cam/src/nc.rs | 712 ++++++++++++ crates/cam/src/playback.rs | 73 ++ crates/cam/src/region.rs | 233 ++++ crates/cam/src/spatial.rs | 65 ++ crates/cam/src/stock_mesh.rs | 728 ++++++++++++ crates/cam/src/surface.rs | 308 +++++ crates/cam/src/verify.rs | 644 ++++++++++ crates/cam/tests/machine_state.rs | 69 ++ crates/cam/tests/milling_direction.rs | 112 ++ crates/cam/tests/nc_incremental.rs | 116 ++ crates/doc/Cargo.toml | 1 + crates/doc/src/document.rs | 10 + .../doc/src/format/pending/manufacturing.rs | 4 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/cam.rs | 603 ++++++++++ crates/engine/src/cmd/cam_holes_tests.rs | 201 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 6 + crates/engine/src/manufacturing.rs | 461 ++++++++ crates/engine/src/manufacturing/exports.rs | 119 ++ crates/engine/src/manufacturing/holes.rs | 179 +++ crates/engine/src/manufacturing/snapshot.rs | 422 +++++++ crates/io/Cargo.toml | 1 + crates/io/src/cam.rs | 62 + crates/io/src/cam/incremental.rs | 276 +++++ crates/io/src/lib.rs | 1 + crates/kernel/src/body.rs | 6 + crates/kernel/src/body_verification.rs | 58 + crates/kernel/src/body_verification_tests.rs | 94 ++ crates/kernel/src/lib.rs | 2 +- crates/kernel/src/surface_kind.rs | 15 + crates/ui-egui/src/colour_vision_tests.rs | 15 +- crates/ui-egui/src/context_menu.rs | 29 +- crates/ui-egui/src/control.rs | 31 +- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/documents.rs | 3 + crates/ui-egui/src/file_ui_tests.rs | 108 ++ crates/ui-egui/src/lib.rs | 38 +- crates/ui-egui/src/manufacturing.rs | 1033 +++++++++++++++++ crates/ui-egui/src/manufacturing/scheduler.rs | 96 ++ crates/ui-egui/src/manufacturing/view.rs | 385 ++++++ crates/ui-egui/src/theme.rs | 6 + crates/ui-egui/src/toolbar.rs | 27 +- crates/ui-egui/src/viewport.rs | 59 +- crates/ui-egui/tests/cam_workflow.rs | 99 ++ docs/cam-acceptance.md | 102 ++ docs/cam-crates.md | 113 ++ docs/cam-next-milestone.md | 92 ++ docs/cam.md | 598 ++++++++++ docs/control-protocol.md | 2 +- vendor/README.md | 6 + vendor/truck-meshalgo/Cargo.toml | 2 +- .../src/tessellation/boundary_index.rs | 206 ++++ vendor/truck-meshalgo/src/tessellation/mod.rs | 18 + .../src/tessellation/verification.rs | 876 ++++++++++++++ xtask/Cargo.toml | 1 + xtask/src/cam.rs | 352 ++++++ xtask/src/cam/reference.rs | 104 ++ xtask/src/cam/tests.rs | 173 +++ xtask/src/layers.rs | 1 + xtask/src/main.rs | 7 + 85 files changed, 14487 insertions(+), 43 deletions(-) create mode 100644 crates/cam/Cargo.toml create mode 100644 crates/cam/src/collision.rs create mode 100644 crates/cam/src/compare.rs create mode 100644 crates/cam/src/corpus.rs create mode 100644 crates/cam/src/corpus/channel.rs create mode 100644 crates/cam/src/corpus/extended.rs create mode 100644 crates/cam/src/corpus/exterior.rs create mode 100644 crates/cam/src/corpus/through.rs create mode 100644 crates/cam/src/coverage.rs create mode 100644 crates/cam/src/dimensions.rs create mode 100644 crates/cam/src/generate.rs create mode 100644 crates/cam/src/hashing.rs create mode 100644 crates/cam/src/lib.rs create mode 100644 crates/cam/src/model.rs create mode 100644 crates/cam/src/nc.rs create mode 100644 crates/cam/src/playback.rs create mode 100644 crates/cam/src/region.rs create mode 100644 crates/cam/src/spatial.rs create mode 100644 crates/cam/src/stock_mesh.rs create mode 100644 crates/cam/src/surface.rs create mode 100644 crates/cam/src/verify.rs create mode 100644 crates/cam/tests/machine_state.rs create mode 100644 crates/cam/tests/milling_direction.rs create mode 100644 crates/cam/tests/nc_incremental.rs create mode 100644 crates/doc/src/format/pending/manufacturing.rs create mode 100644 crates/engine/src/cmd/cam.rs create mode 100644 crates/engine/src/cmd/cam_holes_tests.rs create mode 100644 crates/engine/src/manufacturing.rs create mode 100644 crates/engine/src/manufacturing/exports.rs create mode 100644 crates/engine/src/manufacturing/holes.rs create mode 100644 crates/engine/src/manufacturing/snapshot.rs create mode 100644 crates/io/src/cam.rs create mode 100644 crates/io/src/cam/incremental.rs create mode 100644 crates/kernel/src/body_verification.rs create mode 100644 crates/kernel/src/body_verification_tests.rs create mode 100644 crates/ui-egui/src/file_ui_tests.rs create mode 100644 crates/ui-egui/src/manufacturing.rs create mode 100644 crates/ui-egui/src/manufacturing/scheduler.rs create mode 100644 crates/ui-egui/src/manufacturing/view.rs create mode 100644 crates/ui-egui/tests/cam_workflow.rs create mode 100644 docs/cam-acceptance.md create mode 100644 docs/cam-crates.md create mode 100644 docs/cam-next-milestone.md create mode 100644 docs/cam.md create mode 100644 vendor/truck-meshalgo/src/tessellation/boundary_index.rs create mode 100644 vendor/truck-meshalgo/src/tessellation/verification.rs create mode 100644 xtask/src/cam.rs create mode 100644 xtask/src/cam/reference.rs create mode 100644 xtask/src/cam/tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b523826..7d1f990b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,8 @@ jobs: run: cargo xtask assets - name: Licences run: cargo xtask licences --check + - name: Analytic CAM acceptance + run: cargo xtask cam-validate - name: Dependency layers run: cargo xtask layers - name: Web app offline use and updates (headless Chrome) @@ -210,6 +212,7 @@ jobs: # would skip for want of an adapter fails instead. SOLIDCRAFT_GPU: software SOLIDCRAFT_REQUIRE_GPU: "1" + CAM_UI_EVIDENCE: ${{ github.workspace }}/target/cam-ui-evidence steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install a software Vulkan driver (lavapipe) @@ -248,6 +251,14 @@ jobs: cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \ --profile ci --ignore-default-filter \ -E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))" + - name: Upload original CAM review images + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: cam-ui-evidence-${{ matrix.shard }} + path: target/cam-ui-evidence + if-no-files-found: ignore + retention-days: 14 - name: Upload test timings if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6221864b..663095e2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -176,7 +176,7 @@ copied into SolidCraft. | Directory | Source | Licence | Changes | |---|---|---|---| -| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) | +| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: fixed-tolerance CAM verification path and conservative boundary index; bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) | | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | | vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) | | vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) | diff --git a/Cargo.lock b/Cargo.lock index 684cb308..30f3b62d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.5.0" @@ -752,6 +761,18 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cavalier_contours" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e06038a969c5e4d93565f12b044814d3b1a48d50c4ef868d588e79956fea51" +dependencies = [ + "ahash", + "num-traits", + "smallvec", + "static_aabb2d_index", +] + [[package]] name = "cbc" version = "0.1.2" @@ -800,7 +821,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" dependencies = [ - "approx", + "approx 0.4.0", "num-traits", "serde", ] @@ -1166,6 +1187,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -1327,6 +1354,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1690,6 +1726,28 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.33.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37bfe73dc8ec21f54d181e5a60554327ec3f1c600c455a75ccbfc65d2106a6c4" +dependencies = [ + "approx 0.5.1", + "libm", +] + +[[package]] +name = "glamx" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d74079318e3a077bd3d15faa90fde24fa37bb9f157f92f853e6019a15fd413" +dependencies = [ + "approx 0.5.1", + "glam", + "num-traits", + "simba", +] + [[package]] name = "glifo" version = "0.2.0" @@ -1867,6 +1925,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1887,6 +1954,16 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2107,6 +2184,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2574,6 +2653,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -3026,6 +3116,34 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parry3d-f64" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb9e04261f1783d8f66f36767b2b73132a63c9b30b12a5ed47e1faa240f7591" +dependencies = [ + "approx 0.5.1", + "arrayvec", + "bitflags 2.13.2", + "downcast-rs 2.0.2", + "either", + "ena", + "foldhash", + "glamx", + "hashbrown 0.17.1", + "indexmap", + "log", + "num-derive", + "num-traits", + "ordered-float 5.5.0", + "rstar", + "simba", + "slab", + "smallvec", + "spade", + "thiserror 2.0.21", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3558,6 +3676,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "rstar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3685,6 +3814,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3915,6 +4053,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7" +dependencies = [ + "approx 0.5.1", + "libm", + "num-complex", + "num-traits", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4078,6 +4229,20 @@ dependencies = [ "winit", ] +[[package]] +name = "solidcraft-cam" +version = "0.14.0" +dependencies = [ + "cavalier_contours", + "parry3d-f64", + "robust", + "serde", + "serde_json", + "sha2 0.10.9", + "solidcraft-geom", + "thiserror 2.0.21", +] + [[package]] name = "solidcraft-cli" version = "0.14.0" @@ -4106,6 +4271,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "solidcraft-cam", "solidcraft-derive", "solidcraft-geom", "solidcraft-kernel", @@ -4122,6 +4288,7 @@ dependencies = [ "log", "serde", "serde_json", + "solidcraft-cam", "solidcraft-doc", "solidcraft-geom", "solidcraft-io", @@ -4169,6 +4336,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "solidcraft-cam", "solidcraft-doc", "solidcraft-geom", "solidcraft-kernel", @@ -4293,6 +4461,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_aabb2d_index" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a753368e4dd146a347d0ea692f0f8a37df18df7eee5659d36dd75eb287c6454" +dependencies = [ + "num-traits", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4948,7 +5125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.5", "scoped-tls", "smallvec", @@ -5310,6 +5487,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -5755,6 +5942,7 @@ version = "0.14.0" dependencies = [ "serde_json", "sha2 0.10.9", + "solidcraft-cam", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 080e0184..fa7dda4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/bherbruck/solidcraft" [workspace.dependencies] # internal +solidcraft-cam = { path = "crates/cam" } solidcraft-geom = { path = "crates/geom" } solidcraft-sketch = { path = "crates/sketch" } solidcraft-kernel = { path = "crates/kernel" } diff --git a/ROADMAP.md b/ROADMAP.md index 67e1ba71..11aba9cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,7 +39,21 @@ In scope, in priority order: 5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit. Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D -PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. +PCB / Package, Render, Animation, Simulation, Drawings. + +## Milling + +The first Manufacture workflow implements rectangular stock, setups and fixtures, +flat mills and drills, facing, contouring, pockets with islands and simple/peck drilling. +It includes generation, playback, conservative stock/collision verification and five +exports through shared commands. The reference post is not qualified for a physical +controller. See [implementation and validation](docs/cam.md), +[acceptance evidence](docs/cam-acceptance.md) and [dependency assessment](docs/cam-crates.md). + +[Adaptive clearing and 3D roughing/finishing](docs/cam-next-milestone.md) are the next milestone. They require their +own analytic fixtures, independent engagement/surface checks and complete local Fusion +comparisons at the existing dimensional budgets before parity is declared. Indexed and +multi-axis machining follow later; turning, additive and inspection remain excluded. ## Milestones diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 2f342a8c..4af4b117 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -142,6 +142,19 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) }; let a = pen.accent; match name { + "cam_path" => { + pen.closed(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], ink); + pen.line(&[(5.0, 7.0), (18.0, 7.0), (18.0, 11.0), (6.0, 11.0), (6.0, 15.0), (18.0, 15.0)], a); + } + "cam_generate" => { + pen.line(&[(3.0, 5.0), (10.0, 5.0), (10.0, 11.0), (3.0, 11.0), (3.0, 18.0), (10.0, 18.0)], ink); + pen.poly(&[(14.0, 5.0), (22.0, 12.0), (14.0, 19.0)], a, a); + } + "cam_verify" => { + pen.iso_box(2.0, 9.0, 9.0, 6.0, 7.0); + pen.line(&[(12.0, 16.0), (15.0, 19.0), (22.0, 9.0)], a); + } + "sketch" => { pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink); pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a); diff --git a/assets/locales/en.json b/assets/locales/en.json index b2238047..fad39f95 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -305,6 +305,22 @@ "cmd.browser_rename_group": "Rename Group", "cmd.browser_ungroup": "Ungroup", "cmd.browser_visibility": "Show/Hide", + "cmd.cam_boundary_from_profile": "Use Sketch Profile", + "cmd.cam_delete": "Delete Manufacturing Item", + "cmd.cam_export": "Export CAM Artifact", + "cmd.cam_export_result": "Collect CAM Export", + "cmd.cam_generate": "Generate Toolpaths", + "cmd.cam_holes_from_selection": "Use Selected Holes", + "cmd.cam_inspect": "Inspect Manufacturing", + "cmd.cam_job_cancel": "Cancel CAM Job", + "cmd.cam_job_status": "CAM Job Status", + "cmd.cam_operation_order": "Reorder Operations", + "cmd.cam_operation_put": "Milling Operation", + "cmd.cam_post": "Post Reference G-code", + "cmd.cam_program_put": "NC Program", + "cmd.cam_setup_put": "Milling Setup", + "cmd.cam_simulate": "Simulate Toolpaths", + "cmd.cam_tool_put": "Tool Library", "cmd.canvas_calibrate": "Calibrate Canvas", "cmd.canvas_decal": "Decal", "cmd.canvas_delete": "Delete Canvas", @@ -1113,6 +1129,7 @@ "ui.heading_design": "DESIGN", "ui.heading_draft": "DRAFT", "ui.heading_edit": "EDIT", + "ui.heading_export": "EXPORT", "ui.heading_extrude": "EXTRUDE", "ui.heading_fillet": "FILLET", "ui.heading_finish_sketch": "FINISH SKETCH", @@ -1125,6 +1142,7 @@ "ui.heading_make": "MAKE", "ui.heading_measure": "MEASURE", "ui.heading_mesh": "MESH", + "ui.heading_milling": "MILLING", "ui.heading_mirror": "MIRROR", "ui.heading_modify": "MODIFY", "ui.heading_move": "MOVE", @@ -1159,10 +1177,12 @@ "ui.heading_surface": "SURFACE", "ui.heading_sweep": "SWEEP", "ui.heading_thread": "THREAD", + "ui.heading_toolpaths": "TOOLPATHS", "ui.heading_top": "TOP", "ui.heading_torus": "TORUS", "ui.heading_utilities": "UTILITIES", "ui.heading_utility": "UTILITY", + "ui.heading_verify": "VERIFY", "ui.help": "Help", "ui.hide": "Hide", "ui.hideAxes": "Hide Axes", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 9086717a..f51ed2e5 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -305,6 +305,22 @@ "cmd.browser_rename_group": "グループの名前を変更", "cmd.browser_ungroup": "グループ解除", "cmd.browser_visibility": "表示/非表示", + "cmd.cam_boundary_from_profile": "スケッチプロファイルを使用", + "cmd.cam_delete": "加工項目を削除", + "cmd.cam_export": "CAM 成果物をエクスポート", + "cmd.cam_export_result": "CAM 出力を取得", + "cmd.cam_generate": "工具経路を生成", + "cmd.cam_holes_from_selection": "選択した穴を使用", + "cmd.cam_inspect": "加工を確認", + "cmd.cam_job_cancel": "CAM ジョブをキャンセル", + "cmd.cam_job_status": "CAM ジョブの状態", + "cmd.cam_operation_order": "加工順序を変更", + "cmd.cam_operation_put": "切削加工", + "cmd.cam_post": "参照 G コードを出力", + "cmd.cam_program_put": "NC プログラム", + "cmd.cam_setup_put": "切削セットアップ", + "cmd.cam_simulate": "工具経路をシミュレーション", + "cmd.cam_tool_put": "工具ライブラリ", "cmd.canvas_calibrate": "キャンバスの尺度を設定", "cmd.canvas_decal": "デカール", "cmd.canvas_delete": "キャンバスを削除", @@ -1113,6 +1129,7 @@ "ui.heading_design": "デザイン", "ui.heading_draft": "勾配", "ui.heading_edit": "編集", + "ui.heading_export": "出力", "ui.heading_extrude": "押し出し", "ui.heading_fillet": "フィレット", "ui.heading_finish_sketch": "スケッチを終了", @@ -1125,6 +1142,7 @@ "ui.heading_make": "製造", "ui.heading_measure": "計測", "ui.heading_mesh": "メッシュ", + "ui.heading_milling": "切削", "ui.heading_mirror": "ミラー", "ui.heading_modify": "修正", "ui.heading_move": "移動", @@ -1159,10 +1177,12 @@ "ui.heading_surface": "サーフェス", "ui.heading_sweep": "スイープ", "ui.heading_thread": "ねじ", + "ui.heading_toolpaths": "工具経路", "ui.heading_top": "上面", "ui.heading_torus": "トーラス", "ui.heading_utilities": "ユーティリティ", "ui.heading_utility": "ユーティリティ", + "ui.heading_verify": "検証", "ui.help": "ヘルプ", "ui.hide": "非表示", "ui.hideAxes": "軸を非表示", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index e9acd94e..885028db 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -305,6 +305,22 @@ "cmd.browser_rename_group": "重命名组", "cmd.browser_ungroup": "取消编组", "cmd.browser_visibility": "显示/隐藏", + "cmd.cam_boundary_from_profile": "使用草图轮廓", + "cmd.cam_delete": "删除制造项目", + "cmd.cam_export": "导出 CAM 工件", + "cmd.cam_export_result": "获取 CAM 导出", + "cmd.cam_generate": "生成刀具路径", + "cmd.cam_holes_from_selection": "使用选定孔", + "cmd.cam_inspect": "检查制造", + "cmd.cam_job_cancel": "取消 CAM 作业", + "cmd.cam_job_status": "CAM 作业状态", + "cmd.cam_operation_order": "重新排序操作", + "cmd.cam_operation_put": "铣削操作", + "cmd.cam_post": "输出参考 G 代码", + "cmd.cam_program_put": "NC 程序", + "cmd.cam_setup_put": "铣削设置", + "cmd.cam_simulate": "模拟刀具路径", + "cmd.cam_tool_put": "刀具库", "cmd.canvas_calibrate": "校准画布", "cmd.canvas_decal": "贴花", "cmd.canvas_delete": "删除画布", @@ -1113,6 +1129,7 @@ "ui.heading_design": "设计", "ui.heading_draft": "拔模", "ui.heading_edit": "编辑", + "ui.heading_export": "导出", "ui.heading_extrude": "拉伸", "ui.heading_fillet": "圆角", "ui.heading_finish_sketch": "完成草图", @@ -1125,6 +1142,7 @@ "ui.heading_make": "制作", "ui.heading_measure": "测量", "ui.heading_mesh": "网格", + "ui.heading_milling": "铣削", "ui.heading_mirror": "镜像", "ui.heading_modify": "修改", "ui.heading_move": "移动", @@ -1159,10 +1177,12 @@ "ui.heading_surface": "曲面", "ui.heading_sweep": "扫掠", "ui.heading_thread": "螺纹", + "ui.heading_toolpaths": "刀具路径", "ui.heading_top": "上视", "ui.heading_torus": "圆环体", "ui.heading_utilities": "工具", "ui.heading_utility": "实用工具", + "ui.heading_verify": "验证", "ui.help": "帮助", "ui.hide": "隐藏", "ui.hideAxes": "隐藏轴", diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 2236e5ff..5c697cbe 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -46,6 +46,11 @@ impl Tokens { preview_add: Color32::from_rgb(112, 160, 226), preview_sketch: Color32::from_rgb(32, 190, 96), preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110), + cam_stock: Color32::from_rgb(166, 127, 65), + cam_fixture: Color32::from_rgb(150, 90, 180), + cam_cut: Color32::from_rgb(20, 110, 160), + cam_rapid: Color32::from_rgb(185, 115, 25), + cam_tool: Color32::from_rgb(40, 150, 70), cube_face: Color32::from_rgb(236, 239, 244), cube_border: Color32::from_rgb(85, 94, 110), cube_text: Color32::from_rgb(38, 42, 50), @@ -141,6 +146,11 @@ impl Tokens { t.hover = Color32::from_rgb(54, 61, 74); t.hover_edge_halo = Color32::from_rgb(203, 203, 203); t.origin_point = Color32::from_rgb(214, 215, 218); + t.cam_stock = Color32::from_rgb(190, 154, 92); + t.cam_fixture = Color32::from_rgb(177, 119, 209); + t.cam_cut = Color32::from_rgb(50, 180, 240); + t.cam_rapid = Color32::from_rgb(230, 170, 65); + t.cam_tool = Color32::from_rgb(135, 220, 145); t.viewport_top = Color32::from_rgb(46, 52, 64); t.viewport_bottom = Color32::from_rgb(46, 52, 64); t.grid = Color32::from_rgb(59, 65, 76); @@ -209,6 +219,12 @@ impl Tokens { t.bend_down = Color32::from_rgb(200, 120, 0); t.sketch_accent = Color32::from_rgb(60, 150, 105); } + // Use the measured five-colour semantic set for stock, fixtures and motions. + t.cam_stock = t.sketch_fixed; + t.cam_fixture = t.sketch_projected; + t.cam_cut = t.sketch; + t.cam_rapid = t.sketch_lost; + t.cam_tool = t.sketch_locked; t } @@ -225,6 +241,12 @@ impl Tokens { t.sketch_locked = Color32::from_rgb(20, 165, 70); t.box_crossing = Color32::from_rgb(200, 60, 120); } + // Use the measured five-colour semantic set for stock, fixtures and motions. + t.cam_stock = t.sketch_fixed; + t.cam_fixture = t.sketch_projected; + t.cam_cut = t.sketch; + t.cam_rapid = t.sketch_lost; + t.cam_tool = t.sketch_locked; t } } diff --git a/crates/cam/Cargo.toml b/crates/cam/Cargo.toml new file mode 100644 index 00000000..cf056af5 --- /dev/null +++ b/crates/cam/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "solidcraft-cam" +description = "Portable milling toolpaths and independent NC verification" +version.workspace = true +edition.workspace = true +license.workspace = true +[lints] +workspace = true +[dependencies] +solidcraft-geom.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +sha2.workspace = true +cavalier_contours = "=0.9.0" +parry3d-f64 = { version = "=0.31.1", default-features = false, features = ["required-features", "std", "alloc", "enhanced-determinism"] } +robust = "=1.2.0" diff --git a/crates/cam/src/collision.rs b/crates/cam/src/collision.rs new file mode 100644 index 00000000..fbcb9460 --- /dev/null +++ b/crates/cam/src/collision.rs @@ -0,0 +1,483 @@ +//! Independent fixture collision adapter. No toolpath generation code is used. +//! Surface casts and solid containment are both needed: a surface-only query can +//! miss a tool that starts wholly inside a closed fixture. +use crate::{ + JobInput, POST_ERROR, Result, Tool, ToolKind, invalid, + nc::Segment, + verify::{Finding, Limits, Report, Status}, +}; +use parry3d_f64::{ + math::{Pose, Vector}, + query::{ShapeCastOptions, ShapeCastStatus, cast_shapes, distance}, + shape::{Cone, Cylinder, Shape, Triangle}, +}; +use solidcraft_geom::Vec3; +use std::collections::BTreeMap; + +#[derive(Clone, Copy)] +struct Bounds { + min: Vec3, + max: Vec3, +} +impl Bounds { + fn point(p: Vec3) -> Self { + Self { min: p, max: p } + } + fn include(&mut self, p: Vec3) { + self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z)); + self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z)); + } + fn overlaps(self, b: Self) -> bool { + self.min.x <= b.max.x + && self.max.x >= b.min.x + && self.min.y <= b.max.y + && self.max.y >= b.min.y + && self.min.z <= b.max.z + && self.max.z >= b.min.z + } +} + +struct Component { + body: usize, + triangles: Vec, + bounds: Bounds, + closed: bool, +} +struct Edge { + first: usize, + forward: bool, + count: usize, + consistent: bool, +} +type Vertex = [u64; 3]; +fn vertex(p: Vec3) -> Vertex { + [p.x, p.y, p.z].map(|v| if v == 0.0 { 0 } else { v.to_bits() }) +} +fn root(parents: &mut [usize], mut i: usize) -> usize { + while let Some(&p) = parents.get(i) { + if p == i { + break; + } + let next = parents.get(p).copied().unwrap_or(p); + if let Some(slot) = parents.get_mut(i) { + *slot = next; + } + i = next; + } + i +} + +/// Parry's primitives have a Y axis. This orthogonal mapping puts SolidCraft's +/// downward drill tip on +Y without introducing a rounded rotation transform. +fn vector(p: Vec3) -> Vector { + Vector::new(p.x, -p.z, p.y) +} +struct Part { + radius: f64, + low: f64, + high: f64, + cone: bool, + label: &'static str, +} +fn parts(t: &Tool) -> Vec { + let tip = t.tip_length(); + let mut out = Vec::with_capacity(4); + if t.kind == ToolKind::TwistDrill { + out.push(Part { radius: t.diameter / 2.0, low: 0.0, high: tip, cone: true, label: "cutter" }); + } + if t.cutting_length > tip { + out.push(Part { radius: t.diameter / 2.0, low: tip, high: t.cutting_length, cone: false, label: "cutter" }); + } + if t.overall_length > t.cutting_length { + out.push(Part { radius: t.shank_diameter / 2.0, low: t.cutting_length, high: t.overall_length, cone: false, label: "shank" }); + } + out.push(Part { radius: t.holder_diameter / 2.0, low: t.overall_length, high: t.overall_length + t.holder_length, cone: false, label: "holder" }); + out +} +impl Part { + fn center(&self, p: Vec3) -> Vec3 { + p + Vec3::new(0.0, 0.0, (self.low + self.high) / 2.0) + } + fn bounds(&self, s: &Segment, margin: f64) -> Bounds { + let radius = self.radius + margin; + Bounds { + min: Vec3::new(s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius, s.from.z.min(s.to.z) + self.low - margin), + max: Vec3::new(s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius, s.from.z.max(s.to.z) + self.high + margin), + } + } + fn cast(&self, s: &Segment, tri: &[Vec3; 3], margin: f64) -> Status { + let center = vector(self.center(s.from)); + let position = Pose::translation(center.x, center.y, center.z); + let triangle = Triangle::new(vector(tri[0]), vector(tri[1]), vector(tri[2])); + let cylinder = Cylinder::new((self.high - self.low) / 2.0, self.radius); + let cone = Cone::new((self.high - self.low) / 2.0, self.radius); + let shape: &dyn Shape = if self.cone { &cone } else { &cylinder }; + let query = |distance| { + cast_shapes( + &position, + vector(s.to - s.from), + shape, + &Pose::IDENTITY, + Vector::ZERO, + &triangle, + ShapeCastOptions { + max_time_of_impact: 1.0, + target_distance: distance, + stop_at_penetration: true, + compute_impact_geometry_on_penetration: true, + }, + ) + }; + // Third-party numerical errors or panics never certify clearance. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // A zero-velocity shape cast may report no impact even for initial + // penetration. Check the initial pose independently before sweeping. + let initial = match distance(&position, shape, &Pose::IDENTITY, &triangle).map(|d| d.distance) { + Ok(d) if d.is_finite() && d <= 1e-10 => return Status::Fail, + Ok(d) if d.is_finite() && d > margin => Status::Pass, + _ => Status::Inconclusive, + }; + match query(margin) { + Ok(None) => initial, + Ok(Some(_)) => match query(0.0) { + Ok(Some(hit)) + if matches!(hit.status, ShapeCastStatus::Converged | ShapeCastStatus::PenetratingOrWithinTargetDist) + && hit.time_of_impact.is_finite() + && (0.0..=1.0).contains(&hit.time_of_impact) => + { + Status::Fail + } + _ => Status::Inconclusive, + }, + Err(_) => Status::Inconclusive, + } + })) + .unwrap_or(Status::Inconclusive) + } +} + +fn finding(report: &mut Report, status: Status, s: &Segment, part: &Part, index: usize, detail: &str) { + if status == Status::Fail || report.status == Status::Pass { + report.status = status; + } + report.finding(Finding { + severity: if status == Status::Fail { crate::verify::FindingSeverity::Error } else { crate::verify::FindingSeverity::Unresolved }, + record: None, + source: None, + kind: if status == Status::Fail { format!("fixture_{}_collision", part.label) } else { "fixture_clearance_unresolved".into() }, + line: Some(s.line), + at: s.from, + detail: format!("{} of tool {} at motion {}: {detail}", part.label, s.tool, index + 1), + }); +} + +pub(crate) async fn fixtures>( + input: &JobInput, + segments: &[Segment], + limits: Limits, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + report: &mut Report, +) -> Result<()> { + let tris = &input.geometry.fixtures; + if tris.is_empty() { + return Ok(()); + } + let mut parents: Vec<_> = (0..tris.len()).collect(); + let mut edges: BTreeMap<(usize, [Vertex; 2]), Edge> = BTreeMap::new(); + let bodies: Vec = if input.geometry.fixture_groups.is_empty() { + vec![0; tris.len()] + } else { + input.geometry.fixture_groups.iter().enumerate().flat_map(|(body, &count)| std::iter::repeat_n(body, count)).collect() + }; + let mut degenerate = Vec::new(); + for (i, tri) in tris.iter().enumerate() { + if i.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + report.work += 3; + if report.work > limits.work { + return Ok(()); + } + if (tri[1] - tri[0]).cross(tri[2] - tri[0]).len() < 1e-12 { + degenerate.push(i); + } + for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] { + let a = vertex(a); + let b = vertex(b); + let forward = a < b; + let key = (bodies.get(i).copied().unwrap_or(0), if forward { [a, b] } else { [b, a] }); + if let Some(e) = edges.get_mut(&key) { + let a = root(&mut parents, e.first); + let b = root(&mut parents, i); + if let Some(slot) = parents.get_mut(b) { + *slot = a; + } + e.count += 1; + e.consistent &= e.forward != forward; + } else { + edges.insert(key, Edge { first: i, forward, count: 1, consistent: true }); + } + } + } + let mut components: BTreeMap = BTreeMap::new(); + for (i, tri) in tris.iter().enumerate() { + let key = root(&mut parents, i); + let c = components.entry(key).or_insert_with(|| Component { + body: bodies.get(i).copied().unwrap_or(0), + triangles: Vec::new(), + bounds: Bounds::point(tri[0]), + closed: true, + }); + c.triangles.push(i); + for p in tri { + c.bounds.include(*p); + } + if i.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + } + for (index, e) in edges.values().enumerate() { + if index.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if (e.count != 2 || !e.consistent) + && let Some(c) = components.get_mut(&root(&mut parents, e.first)) + { + c.closed = false; + } + } + for i in degenerate { + if let Some(c) = components.get_mut(&root(&mut parents, i)) { + c.closed = false; + } + } + let mut body_closed = BTreeMap::new(); + for c in components.values() { + let closed = body_closed.entry(c.body).or_insert(true); + *closed &= c.closed; + } + let margin = POST_ERROR + limits.uncertainty; + for (index, s) in segments.iter().enumerate() { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + let tool = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| crate::Error("unknown simulation tool".into()))?; + for part in parts(tool) { + let swept = part.bounds(s, margin); + let mut windings: BTreeMap = BTreeMap::new(); + for c in components.values() { + report.work += 1; + if report.work.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if report.work > limits.work { + return Ok(()); + } + if !swept.overlaps(c.bounds) { + continue; + } + let center = part.center(s.from); + let containment = Bounds::point(center).overlaps(c.bounds); + let mut solid_angle = 0.0; + let mut status = if c.closed { Status::Pass } else { Status::Inconclusive }; + for &i in &c.triangles { + report.work += 1; + if report.work.is_multiple_of(1024) { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if report.work > limits.work { + return Ok(()); + } + let Some(tri) = tris.get(i) else { + return invalid("invalid fixture triangle index"); + }; + let mut bounds = Bounds::point(tri[0]); + bounds.include(tri[1]); + bounds.include(tri[2]); + if swept.overlaps(bounds) { + let cast = part.cast(s, tri, margin); + if cast == Status::Fail { + status = Status::Fail; + break; + } + if cast == Status::Inconclusive { + status = Status::Inconclusive; + } + } + if c.closed && containment { + let [a, b, d] = tri.map(|v| v - center); + let denominator = a.len() * b.len() * d.len() + a.dot(b) * d.len() + b.dot(d) * a.len() + d.dot(a) * b.len(); + solid_angle += 2.0 * a.dot(b.cross(d)).atan2(denominator); + } + } + if status != Status::Fail && c.closed && containment { + *windings.entry(c.body).or_default() += solid_angle / (4.0 * std::f64::consts::PI); + } + if status != Status::Pass { + finding( + report, + status, + s, + &part, + index, + if status == Status::Fail { + "swept shape contacts the fixture surface" + } else { + "clearance is within the error margin, the query did not converge, or fixture topology is open/degenerate" + }, + ); + } + } + for (body, winding) in &windings { + let status = if !body_closed.get(body).copied().unwrap_or(false) { + Status::Inconclusive + } else if winding.is_finite() && winding.abs() > 1.0 - 1e-6 { + Status::Fail + } else if !winding.is_finite() || winding.abs() > 1e-6 { + Status::Inconclusive + } else { + Status::Pass + }; + if status != Status::Pass { + finding(report, status, s, &part, index, "initial tool volume is inside a fixture or its solid winding is unresolved"); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::{corpus, nc, verify}; + + fn box_mesh(min: Vec3, max: Vec3) -> Vec<[Vec3; 3]> { + let p = [ + min, + Vec3::new(max.x, min.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + max, + Vec3::new(min.x, max.y, max.z), + ]; + [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]] + .into_iter() + .flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]]) + .collect() + } + fn run(mesh: Vec<[Vec3; 3]>, motion: &str) -> Report { + run_groups(mesh, vec![], motion) + } + fn run_groups(mesh: Vec<[Vec3; 3]>, groups: Vec, motion: &str) -> Report { + let mut input = corpus::fixture("face").unwrap(); + input.geometry.fixtures = mesh; + input.geometry.fixture_groups = groups; + let src = format!("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\n{motion}\nM30"); + let parsed = nc::parse(&src, &input.tools).unwrap(); + verify::simulate(&input, &parsed, Limits::default()).unwrap() + } + #[test] + fn near_fixture_is_clear_below_wide_holder() { + // Old whole-tool AABB overlaps this fixture. Cutter/shank radius is 3; + // the 10-radius holder starts at Z65 and cannot touch this low block. + let r = run(box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0)), "G0 X1"); + assert_eq!(r.status, Status::Pass, "{:?}", r.findings); + } + #[test] + fn thin_fixture_between_endpoints_is_a_collision() { + let r = run(box_mesh(Vec3::new(5.0, -1.0, 16.0), Vec3::new(5.01, 1.0, 17.0)), "G0 X10"); + assert_eq!(r.status, Status::Fail); + assert!(r.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(4))); + } + #[test] + fn holder_and_shank_collisions_are_distinguished() { + for (z, kind) in [(40.0, "shank"), (70.0, "holder")] { + let r = run(box_mesh(Vec3::new(5.0, -1.0, z), Vec3::new(5.1, 1.0, z + 1.0)), "G0 X10"); + assert_eq!(r.status, Status::Fail); + assert!(r.findings.iter().any(|f| f.kind == format!("fixture_{kind}_collision"))); + } + } + #[test] + fn contained_tool_is_not_missed_and_winding_is_irrelevant() { + let cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + for reverse in [false, true] { + let mesh = cube.iter().map(|t| if reverse { [t[2], t[1], t[0]] } else { *t }).collect(); + let r = run(mesh, "G0 X1"); + assert_eq!(r.status, Status::Fail, "{:?}", r.findings); + } + } + #[test] + fn overlapping_components_with_opposite_winding_do_not_cancel() { + let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + a.extend(box_mesh(Vec3::new(-21.0, -21.0, 9.0), Vec3::new(21.0, 21.0, 101.0)).into_iter().map(|t| [t[2], t[1], t[0]])); + assert_eq!(run_groups(a, vec![12, 12], "G0 X1").status, Status::Fail); + } + #[test] + fn cavity_in_one_fixture_body_remains_empty() { + let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + a.extend(box_mesh(Vec3::new(-15.0, -15.0, 11.0), Vec3::new(15.0, 15.0, 99.0)).into_iter().map(|t| [t[2], t[1], t[0]])); + assert_eq!(run_groups(a, vec![24], "G0 X1").status, Status::Pass); + } + #[test] + fn malformed_fixture_group_counts_are_rejected() { + let mut input = corpus::fixture("face").unwrap(); + input.geometry.fixture_groups = vec![usize::MAX, 1]; + assert!(input.validate().is_err()); + input.geometry.fixture_groups = vec![0]; + assert!(input.validate().is_err()); + } + #[test] + fn open_containing_shell_is_inconclusive() { + let mut cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0)); + cube.pop(); + assert_eq!(run(cube, "G0 X1").status, Status::Inconclusive); + } + #[test] + fn cylinder_corners_and_margin_are_resolved() { + let part = Part { radius: 1.0, low: 0.0, high: 2.0, cone: false, label: "cutter" }; + let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: true, feed: 0.0, tool: 1, line: 4 }; + let triangle = |x, y| [Vec3::new(x, y, 0.5), Vec3::new(x + 0.05, y, 1.5), Vec3::new(x, y + 0.05, 1.5)]; + assert_eq!(part.cast(&s, &triangle(0.8, 0.8), 0.0035), Status::Pass); + assert_eq!(part.cast(&s, &triangle(0.5, 0.5), 0.0035), Status::Fail); + assert_ne!(part.cast(&s, &triangle(1.001, 0.0), 0.0035), Status::Pass); + } + #[test] + fn drill_point_shape_has_clearance_outside_its_cone() { + let part = Part { radius: 1.0, low: 0.0, high: 1.0, cone: true, label: "cutter" }; + let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: false, feed: 100.0, tool: 1, line: 4 }; + let clear = [Vec3::new(0.8, 0.0, 0.1), Vec3::new(0.9, 0.0, 0.1), Vec3::new(0.8, 0.1, 0.2)]; + assert_eq!(part.cast(&s, &clear, 0.0035), Status::Pass); + let contact = clear.map(|p| p + Vec3::new(0.0, 0.0, 0.8)); + assert_eq!(part.cast(&s, &contact, 0.0035), Status::Fail); + } + #[test] + fn fixture_budget_and_cancellation_never_pass() { + let mut input = corpus::fixture("face").unwrap(); + input.geometry.fixtures = box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0)); + let parsed = nc::parse("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X1\nM30", &input.tools).unwrap(); + let r = verify::simulate(&input, &parsed, Limits { work: 1, ..Limits::default() }).unwrap(); + assert_eq!(r.status, Status::Inconclusive); + assert!(verify::simulate_cancellable(&input, &parsed, Limits::default(), || true).is_err()); + } +} diff --git a/crates/cam/src/compare.rs b/crates/cam/src/compare.rs new file mode 100644 index 00000000..978a9397 --- /dev/null +++ b/crates/cam/src/compare.rs @@ -0,0 +1,489 @@ +//! Conservative artifact comparison, separate from toolpath generation. +use crate::spatial::Index; +use crate::{ + verify::{Report, Status}, + *, +}; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Comparison { + pub status: Status, + pub maximum_possible_error: f64, + pub maximum_definite_error: f64, + pub checked_regions: usize, + pub detail: String, +} +fn gaps(a: [f64; 2], b: [f64; 2]) -> (f64, f64) { + ((a[0] - b[1]).max(b[0] - a[1]).max(0.0), (a[0] - b[1]).abs().max((a[1] - b[0]).abs())) +} +fn result(detail: &str) -> Comparison { + Comparison { status: Status::Pass, maximum_possible_error: 0., maximum_definite_error: 0., checked_regions: 0, detail: detail.into() } +} +fn inconclusive(detail: &str) -> Comparison { + Comparison { status: Status::Inconclusive, ..result(detail) } +} +fn tolerance(value: f64) -> Result<()> { + if !value.is_finite() || value <= 0. || value > 1e6 { + return invalid("comparison tolerance must be positive and bounded"); + } + Ok(()) +} +/// Compare both remaining material and removed space, in both directions. +/// Each passing region is a whole-column distance certificate, not a sample. +pub fn stock_reports(a: &Report, b: &Report, tol: f64) -> Result { + stock_reports_budget(a, b, tol, &mut 0, 30_000_000) +} +fn stock_reports_budget(a: &Report, b: &Report, tol: f64, work: &mut usize, budget: usize) -> Result { + tolerance(tol)?; + let (Some(stock), Some(other)) = (a.stock, b.stock) else { + return Ok(inconclusive("missing declared stock bounds; regenerate legacy reports")); + }; + if stock != other { + return invalid("stock bounds differ; verify input units and coordinate frames"); + } + let complete_a = crate::coverage::cells(&a.cells, stock)?; + let complete_b = crate::coverage::cells(&b.cells, stock)?; + if !complete_a || !complete_b || !a.stock_complete || !b.stock_complete || a.status != Status::Pass || b.status != Status::Pass { + return Ok(inconclusive("stock verification or exact cell coverage is incomplete")); + } + let guard = 128. * f64::EPSILON * stock.min.len().max(stock.max.len()).max(1.); + let mut r = result("Bidirectional whole-column distances for remaining material and removed space in declared setup coordinates"); + for (a, b) in [(a, b), (b, a)] { + let index = Index::new(b.cells.iter().enumerate().map(|(id, c)| (id, c.min, c.max)).collect()); + for x in &a.cells { + let mut ids = Vec::new(); + index.query(x.min, x.max, &mut ids); + for id in ids { + *work += 1; + if *work > budget { + r.status = Status::Inconclusive; + r.detail = "stock comparison work budget exhausted".into(); + return Ok(r); + } + let y = &b.cells[id]; + let min = [x.min[0].max(y.min[0]), x.min[1].max(y.min[1])]; + let max = [x.max[0].min(y.max[0]), x.max[1].min(y.max[1])]; + if (0..2).any(|k| min[k] >= max[k]) { + continue; + } + r.checked_regions += 1; + let mut possible = gaps([x.low, x.high], [y.low, y.high]).1 + guard; + if possible > tol { + let mut candidates = Vec::new(); + index.query(min.map(|v| v - tol - guard), max.map(|v| v + tol + guard), &mut candidates); + let mut material = (x.high - y.low).max(0.); + // Outside the declared XY stock domain is guaranteed void. + let exterior = (max[0] - stock.min.x).min(stock.max.x - min[0]).min(max[1] - stock.min.y).min(stock.max.y - min[1]); + let mut removed = (y.high - x.low).max(0.).min(exterior); + for id in candidates { + *work += 1; + if *work > budget { + r.status = Status::Inconclusive; + r.detail = "stock distance certificate budget exhausted".into(); + return Ok(r); + } + let c = &b.cells[id]; + // The farthest rectangle corner bounds the distance of + // every XY point to this guaranteed material/void prism. + let xy = [0, 1].map(|k| (c.min[k] - min[k]).max(max[k] - c.max[k]).max(0.)); + let lateral = xy[0].hypot(xy[1]); + material = material.min(lateral.hypot((x.high - c.low).max(0.))); + removed = removed.min(lateral.hypot((c.high - x.low).max(0.))); + } + possible = material.max(removed) + guard; + if possible > tol { + let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + // A positive failure requires a definitely present + // material/void point farther than tolerance from ALL + // possibly corresponding prisms, not just a Z gap. + let radius = 2. * tol + guard; + let mut candidates = Vec::new(); + index.query(center.map(|v| v - radius), center.map(|v| v + radius), &mut candidates); + let mut material_distance = radius; + let mut removed_distance = radius + .min(center[0] - stock.min.x) + .min(stock.max.x - center[0]) + .min(center[1] - stock.min.y) + .min(stock.max.y - center[1]); + for id in candidates { + *work += 1; + if *work > budget { + r.status = Status::Inconclusive; + r.detail = "stock failure certificate budget exhausted".into(); + return Ok(r); + } + let c = &b.cells[id]; + let dx = (c.min[0] - center[0]).max(center[0] - c.max[0]).max(0.); + let dy = (c.min[1] - center[1]).max(center[1] - c.max[1]).max(0.); + material_distance = material_distance.min(dx.hypot(dy).hypot((x.low - c.high).max(0.))); + removed_distance = removed_distance.min(dx.hypot(dy).hypot((c.low - x.high).max(0.))); + } + let definite = (material_distance.max(removed_distance) - guard).max(0.); + r.maximum_definite_error = r.maximum_definite_error.max(definite); + if definite > tol { + r.status = Status::Fail; + r.detail = + format!("Certified local stock difference near X{} Y{}; lower distance bound {} mm", center[0], center[1], definite); + r.maximum_possible_error = r.maximum_possible_error.max(possible); + return Ok(r); + } + r.status = Status::Inconclusive; + } + } + r.maximum_possible_error = r.maximum_possible_error.max(possible); + } + } + } + Ok(r) +} + +pub fn stock_mesh(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64) -> Result { + stock_mesh_budget(report, mesh, bottom, tol, 250_000, 30_000_000) +} +fn stock_mesh_budget(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64, cell_budget: usize, budget: usize) -> Result { + tolerance(tol)?; + if mesh.is_empty() || mesh.len() > 500_000 || !bottom.is_finite() { + return invalid("reference stock mesh empty or too large, or invalid bottom"); + } + let Some(stock) = report.stock else { + return Ok(inconclusive("missing declared stock bounds; regenerate legacy report")); + }; + if bottom != stock.min.z { + return invalid("reference stock bottom differs from declared stock"); + } + let field = crate::coverage::MeshField::new(mesh, stock)?; + let mut cells = Vec::new(); + if !crate::coverage::cells(&report.cells, stock)? || !report.stock_complete || report.status != Status::Pass { + return Ok(inconclusive("stock verification or exact cell coverage is incomplete")); + } + // Reuse only the spatial partition, never the report's heights as mesh + // evidence. Every reference interval is calculated from its own triangles. + let mut pending: Vec<_> = report.cells.iter().map(|c| (c.min, c.max, [c.low, c.high])).collect(); + let mut work = 0; + while let Some((min, max, source)) = pending.pop() { + if cells.len() + pending.len() >= cell_budget { + return Ok(inconclusive("reference stock refinement cell budget exhausted")); + } + let Some([low, high]) = field.bounds(min, max, &mut work, budget) else { + return Ok(inconclusive("reference stock refinement work budget exhausted")); + }; + let size = (max[0] - min[0]).hypot(max[1] - min[1]); + if gaps(source, [low, high]).1 > tol && high - low > VERIFY_ERROR.min(tol / 4.) && size > VERIFY_ERROR.min(tol / 4.) { + let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + if (0..2).any(|k| center[k] <= min[k] || center[k] >= max[k]) { + return Ok(inconclusive("reference refinement reached coordinate precision limit")); + } + for (a, b) in [(min, center), ([center[0], min[1]], [max[0], center[1]]), (center, max), ([min[0], center[1]], [center[0], max[1]])] { + pending.push((a, b, source)); + } + } else { + cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high }); + } + } + let reference = Report { cells, status: Status::Pass, stock_complete: true, ..report.clone() }; + stock_reports_budget(report, &reference, tol, &mut work, budget) +} +/// Strict STL decoding: malformed triangles are rejected, never silently dropped. +pub fn read_stl(bytes: &[u8]) -> Result> { + if bytes.len() > 128 * 1024 * 1024 { + return invalid("STL exceeds 128 MiB"); + } + let mut triangles = Vec::new(); + let count = bytes.get(80..84).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes).map(|n| n as usize); + if let Some(count) = count.filter(|n| 84usize.checked_add(n.saturating_mul(50)) == Some(bytes.len())) { + if count > 500_000 { + return invalid("STL triangle limit"); + } + for chunk in bytes.get(84..).unwrap_or_default().as_chunks::<50>().0 { + let mut pts = [Vec3::ZERO; 3]; + for (k, p) in pts.iter_mut().enumerate() { + let mut xyz = [0.0; 3]; + for (j, v) in xyz.iter_mut().enumerate() { + let start = 12 + k * 12 + j * 4; + let b = + chunk.get(start..start + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).ok_or_else(|| Error("truncated STL vertex".into()))?; + *v = f32::from_le_bytes(b) as f64; + } + *p = Vec3::new(xyz[0], xyz[1], xyz[2]); + crate::model::point(*p)?; + } + triangles.push(pts); + } + } else { + let text = std::str::from_utf8(bytes).map_err(|_| Error("invalid STL encoding/length".into()))?; + let mut lines = text.lines().map(|line| line.split_whitespace().collect::>()).filter(|line| !line.is_empty()); + if lines.next().and_then(|line| line.first().copied()) != Some("solid") { + return invalid("invalid ASCII STL header"); + } + loop { + let header = lines.next().ok_or_else(|| Error("missing STL terminator".into()))?; + if header.first() == Some(&"endsolid") { + if lines.next().is_some() { + return invalid("data after STL terminator"); + } + break; + } + if header.len() != 5 || header[..2] != ["facet", "normal"] || header[2..].iter().any(|v| !v.parse::().is_ok_and(f64::is_finite)) { + return invalid("invalid STL facet header"); + } + if lines.next().as_deref() != Some(&["outer", "loop"]) { + return invalid("missing STL outer loop"); + } + let mut points = [Vec3::ZERO; 3]; + for point in &mut points { + let words = lines.next().ok_or_else(|| Error("incomplete STL triangle".into()))?; + if words.len() != 4 || words[0] != "vertex" { + return invalid("invalid STL vertex"); + } + let mut xyz = [0.0; 3]; + for (i, word) in words[1..].iter().enumerate() { + xyz[i] = word.parse::().map_err(|_| Error("invalid STL number".into()))?; + } + *point = Vec3::new(xyz[0], xyz[1], xyz[2]); + crate::model::point(*point)?; + } + if lines.next().as_deref() != Some(&["endloop"]) || lines.next().as_deref() != Some(&["endfacet"]) { + return invalid("incomplete STL facet"); + } + triangles.push(points); + if triangles.len() > 500_000 { + return invalid("STL triangle limit"); + } + } + } + if triangles.is_empty() { + return invalid("empty STL"); + } + Ok(triangles) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn cell(x0: f64, x1: f64, low: f64, high: f64) -> verify::Cell { + verify::Cell { min: [x0, 0.], max: [x1, 2.], low, high, target_low: low, target_high: high } + } + fn report(cells: Vec) -> Report { + Report { + version: VERSION.into(), + fingerprint: "analytic".into(), + status: Status::Pass, + finished_dimensions: Status::Pass, + post_version: "analytic".into(), + tolerance: TOLERANCE, + uncertainty: VERIFY_ERROR, + maximum_height_interval: 0., + stock_complete: true, + stock: Some(Stock { min: Vec3::ZERO, max: Vec3::new(2., 2., 3.) }), + cells, + findings: vec![], + cutting_distance: 0., + rapid_distance: 0., + cutting_minutes: 0., + remaining_volume_low: 0., + remaining_volume_high: 0., + work: 0, + } + } + fn mesh(report: &Report) -> Vec<[Vec3; 3]> { + read_stl(verify::stock_stl(report, 0.).unwrap().as_bytes()).unwrap() + } + #[test] + fn duplicate_open_and_misoriented_reference_triangles_cannot_pass() { + let r = report(vec![cell(0., 2., 1., 1.)]); + let tri = [Vec3::new(0., 0., 1.), Vec3::new(2., 0., 1.), Vec3::new(2., 2., 1.)]; + assert!(stock_mesh(&r, &[tri, tri], 0., TOLERANCE).is_err()); + let mut m = mesh(&r); + m.pop(); + assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err()); + let mut m = mesh(&r); + for t in &mut m { + t.swap(0, 1); + } + assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err()); + } + #[test] + fn cell_coverage_has_no_area_epsilon_and_rejects_duplicates() { + let full = report(vec![cell(0., 2., 1., 1.)]); + let duplicate = report(vec![cell(0., 1., 1., 1.), cell(0., 1., 1., 1.)]); + assert!(stock_reports(&full, &duplicate, TOLERANCE).is_err()); + let gap = report(vec![cell(0., 1., 1., 1.), cell(1. + 1e-10, 2., 1., 1.)]); + assert_eq!(stock_reports(&full, &gap, TOLERANCE).unwrap().status, Status::Inconclusive); + let mut legacy = full.clone(); + legacy.stock = None; + assert_eq!(stock_reports(&full, &legacy, TOLERANCE).unwrap().status, Status::Inconclusive); + let mut shifted = full.clone(); + shifted.stock.as_mut().unwrap().min.x = -1.; + assert!(stock_reports(&full, &shifted, TOLERANCE).is_err()); + let mut bad = full.clone(); + bad.cells[0].high = f64::NAN; + assert!(stock_reports(&full, &bad, TOLERANCE).is_err()); + let mut bad = full.clone(); + bad.cells[0].min[0] = 1e-200; + assert!(stock_reports(&full, &bad, TOLERANCE).is_err()); + } + #[test] + fn whole_column_certificates_resolve_wall_uncertainty_and_detect_wall_shift() { + let exact = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]); + let band = report(vec![cell(0., 0.999, 1., 1.), cell(0.999, 1.001, 1., 2.), cell(1.001, 2., 2., 2.)]); + let r = stock_reports(&exact, &band, TOLERANCE).unwrap(); + assert_eq!(r.status, Status::Pass, "{r:?}"); + assert!(r.maximum_possible_error < 0.003); + let shifted = report(vec![cell(0., 1.1, 1., 1.), cell(1.1, 2., 2., 2.)]); + assert_eq!(stock_reports(&exact, &shifted, TOLERANCE).unwrap().status, Status::Fail); + let wide = report(vec![cell(0., 2., 1., 2.)]); + assert_eq!(stock_reports(&wide, &wide, TOLERANCE).unwrap().status, Status::Inconclusive); + assert_eq!(stock_reports_budget(&exact, &exact, TOLERANCE, &mut 0, 1).unwrap().status, Status::Inconclusive); + } + #[test] + fn removed_space_detects_a_narrow_deep_missing_hole() { + let full = report(vec![cell(0., 2., 2., 2.)]); + // A solid-only Hausdorff test could hide this narrow cut because all + // missing material is within tolerance of a neighboring solid column. + let hole = report(vec![cell(0., 0.999, 2., 2.), cell(0.999, 1.001, 0., 0.), cell(1.001, 2., 2., 2.)]); + assert_eq!(stock_reports(&full, &hole, TOLERANCE).unwrap().status, Status::Fail); + let rim = report(vec![cell(0., 0.001, 0., 0.), cell(0.001, 2., 2., 2.)]); + assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Pass); + let rim = report(vec![cell(0., 0.1, 0., 0.), cell(0.1, 2., 2., 2.)]); + assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Fail); + } + #[test] + fn closed_stock_mesh_compares_at_vertical_walls_and_holes() { + let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]); + let m = mesh(&step); + let comparison = stock_mesh(&step, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + let hole = report(vec![cell(0., 0.9, 2., 2.), cell(0.9, 1.1, 0., 0.), cell(1.1, 2., 2., 2.)]); + let comparison = stock_mesh(&hole, &mesh(&hole), 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 1, 100).unwrap().status, Status::Inconclusive); + } + #[test] + fn shared_mesh_edges_do_not_refine_flat_cells_or_hide_thin_overlaps() { + let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]); + let m = mesh(&step); + assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 3, 100_000).unwrap().status, Status::Pass); + let field = crate::coverage::MeshField::new(&m, step.stock.unwrap()).unwrap(); + let flat = field.bounds([0., 0.], [1., 2.], &mut 0, 1000).unwrap(); + assert!((flat[0] - 1.).abs() < 1e-10 && (flat[1] - 1.).abs() < 1e-10); + // Positive-area overlap has no area epsilon: even this tiny strip must + // retain both heights, rather than treating it as a shared edge. + let sliver = field.bounds([0., 0.], [1. + 1e-10, 2.], &mut 0, 1000).unwrap(); + assert!(sliver[0] <= 1. && sliver[1] >= 2.); + } + #[test] + fn overlapping_closed_components_cannot_counterfeit_full_coverage() { + let expected = report(vec![cell(0., 2., 1., 1.)]); + let mut m = mesh(&report(vec![cell(0., 1., 1., 1.)])); + m.extend(mesh(&report(vec![cell(0.1, 1.1, 1., 1.)]))); + let comparison = stock_mesh(&expected, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Fail, "{comparison:?}"); + let mut extra = mesh(&expected); + for t in &mut extra { + for p in t { + p.x += 1.; + } + } + assert!(stock_mesh(&expected, &extra, 0., TOLERANCE).is_err()); + } + #[test] + fn generated_drill_stock_round_trip_is_spatially_certified() { + let input = corpus::fixture("drill").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let r = verify::simulate(&input, &parsed, Default::default()).unwrap(); + let m = mesh(&r); + let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + } + #[test] + fn analytic_round_hole_matches_independently_faceted_closed_stock() { + // Original analytic solid: 2x2 plate, top Z2, radius .25 bore, a + // cone from tip Z1 to Z1.5, and a vertical wall to the plate top. + // Intervals below use only radial extrema; no toolpath or STL exporter. + let height = |d: f64| if d < 0.25 { 1. + 2. * d } else { 2. }; + let mut cells = Vec::new(); + let mut pending = vec![([0., 0.], [2., 2.])]; + while let Some((min, max)) = pending.pop() { + let near: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).max(1. - max[k]).max(0.)); + let far: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).abs().max((max[k] - 1.).abs())); + let low = height(near[0].hypot(near[1])); + let high = height(far[0].hypot(far[1])); + if high - low > 0.002 && (max[0] - min[0]).hypot(max[1] - min[1]) > VERIFY_ERROR { + let c = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + pending.extend([(min, c), ([c[0], min[1]], [max[0], c[1]]), (c, max), ([min[0], c[1]], [c[0], max[1]])]); + } else { + cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high }); + } + } + let r = report(cells); + let mut m = Vec::new(); + let ring = |i: usize| { + let a = (i % 64) as f64 * std::f64::consts::TAU / 64.; + let (y, x) = a.sin_cos(); + let outer = + if x.abs() >= y.abs() { Vec3::new(1. + x.signum(), 1. + y / x.abs(), 2.) } else { Vec3::new(1. + x / y.abs(), 1. + y.signum(), 2.) }; + [Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 2.), Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 1.5), outer, Vec3::new(outer.x, outer.y, 0.)] + }; + for i in 0..64 { + let [a, lo, outer, base] = ring(i); + let [b, lo_b, outer_b, base_b] = ring(i + 1); + m.extend([ + [a, outer, outer_b], + [a, outer_b, b], + [a, b, lo_b], + [a, lo_b, lo], + [Vec3::new(1., 1., 1.), lo, lo_b], + [outer, base, base_b], + [outer, base_b, outer_b], + [Vec3::new(1., 1., 0.), base_b, base], + ]); + } + let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap(); + assert_eq!(comparison.status, Status::Pass, "{comparison:?}"); + // Different facet decomposition must preserve the result. + let split: Vec<_> = m + .iter() + .flat_map(|t| { + if (t[1] - t[0]).cross(t[2] - t[0]).z == 0. { + return vec![*t]; + } + let c = (t[0] + t[1] + t[2]) / 3.; + vec![[t[0], t[1], c], [t[1], t[2], c], [t[2], t[0], c]] + }) + .collect(); + assert_eq!(stock_mesh(&r, &split, 0., TOLERANCE).unwrap().status, Status::Pass); + let missed = report(vec![cell(0., 2., 2., 2.)]); + assert_eq!(stock_mesh(&missed, &m, 0., TOLERANCE).unwrap().status, Status::Fail); + } + #[test] + fn invalid_stl_is_not_partially_accepted() { + assert!(read_stl(b"solid x\nvertex NaN 0 0\nvertex 1 0 0\nvertex 0 1 0\nendsolid").is_err()); + assert!(read_stl(&[0; 84]).is_err()); + } + #[test] + fn interval_difference_catches_local_gouge() { + assert!((gaps([4.0, 4.001], [5.0, 5.001]).0 - 0.999).abs() < 1e-10); + assert!(gaps([4.99, 5.01], [5.0, 5.0]).1 > 0.009); + } + #[test] + fn incomplete_coverage_is_symmetric_and_stl_is_bounded() { + let input = corpus::fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let full = verify::simulate(&input, &parsed, Default::default()).unwrap(); + let mut partial = full.clone(); + partial.cells.truncate(1); + assert_eq!(stock_reports(&partial, &full, TOLERANCE).unwrap().status, Status::Inconclusive); + assert_eq!(stock_reports(&full, &partial, TOLERANCE).unwrap().status, Status::Inconclusive); + let stl = verify::stock_stl(&full, 0.0).unwrap(); + let mesh = read_stl(stl.as_bytes()).unwrap(); + assert_eq!(mesh.len(), 12); // exact coplanar merge, conforming boundary + let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum(); + assert!((volume - 2000.0).abs() < 1e-9); + assert!(read_stl(stl.replace("endfacet", "unknown").as_bytes()).is_err()); + } +} diff --git a/crates/cam/src/corpus.rs b/crates/cam/src/corpus.rs new file mode 100644 index 00000000..bee8007d --- /dev/null +++ b/crates/cam/src/corpus.rs @@ -0,0 +1,388 @@ +//! Original analytic fixtures; never generated by a proprietary CAD application. +use crate::*; +use solidcraft_geom::Vec3; +mod channel; +mod extended; +mod exterior; +mod through; + +pub const CASES: &[&str] = + &["face", "contour", "pocket", "drill", "concave-islands", "multi-operation", "clamped-through-holes", "external-contour", "narrow-channel"]; + +/// Independent point expectations, indexed by the completed operation count. +pub fn probes(name: &str, completed: usize) -> Result> { + match name { + "face" | "pocket" | "drill" => Ok(vec![([10., 10.], 5.)]), + "contour" => Ok(vec![([10., 10.], 6.)]), + "narrow-channel" if completed == 1 => Ok(vec![([10., 10.], 5.), ([4., 10.], 5.), ([16., 10.], 5.), ([10., 11.2], 6.), ([3.8, 10.], 6.)]), + "concave-islands" => Ok(vec![([6., 6.], 6.), ([6., 13.], 6.), ([14., 14.], 6.), ([15., 6.], 4.), ([6., 9.], 4.)]), + "clamped-through-holes" if (1..=2).contains(&completed) => { + Ok(vec![([6., 10.], 0.), ([14., 10.], if completed == 2 { 0. } else { 6. }), ([10., 10.], 6.), ([6.6, 10.], 6.)]) + } + "external-contour" if (1..=2).contains(&completed) => { + Ok(vec![([10., 10.], 6.), ([5.1, 10.], 6.), ([4.5, 10.], if completed == 1 { 4.2 } else { 4. }), ([1., 1.], 6.)]) + } + "multi-operation" if (1..=4).contains(&completed) => { + let floor = [5.5, 4.2, 4., 4.].get(completed - 1).copied().ok_or_else(|| Error("unknown stage".into()))?; + Ok(vec![([1., 1.], 5.5), ([6., 6.], 5.5), ([14., 14.], 5.5), ([6., 9.], floor), ([15., 6.], if completed == 4 { 3. } else { floor })]) + } + _ => invalid("unknown analytic stage"), + } +} + +pub fn fixture(strategy: &str) -> Result { + if strategy == "narrow-channel" { + return channel::fixture(); + } + if strategy == "external-contour" { + return exterior::fixture(); + } + if strategy == "clamped-through-holes" { + return through::fixture(); + } + if matches!(strategy, "concave-islands" | "multi-operation") { + return extended::fixture(strategy); + } + let tool = Tool { + id: 1, + name: "6 mm center-cutting end mill".into(), + number: 1, + kind: ToolKind::FlatEndMill, + diameter: 6.0, + cutting_length: 20.0, + shank_diameter: 6.0, + overall_length: 50.0, + holder_diameter: 20.0, + holder_length: 30.0, + tip_angle: 118_f64.to_radians(), + }; + let setup = Setup { + id: 1, + name: "Analytic plate".into(), + stock: Stock { min: Vec3::ZERO, max: Vec3::new(20.0, 20.0, 6.0) }, + frame: Frame::default(), + models: vec![BodyRef { body: "Plate".into(), occurrence: vec![] }], + fixtures: vec![], + clearance: 15.0, + retract: 10.0, + work_offset: 54, + }; + let boundary = Boundary { + outer: (0..128) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 128.; + [10. + 8. * a.cos(), 10. + 8. * a.sin()] + }) + .collect(), + islands: vec![], + }; + let geometry = match strategy { + "face" => Strategy::Face, + "contour" => Strategy::Contour { boundary, inside: true }, + "pocket" => Strategy::Pocket { boundary }, + "drill" => Strategy::Drill { holes: vec![[10.0, 10.0]], peck: Some(1.0), full_diameter: false }, + _ => return invalid("unknown analytic fixture"), + }; + let mut tool = tool; + if strategy == "drill" { + tool.kind = ToolKind::TwistDrill; + tool.diameter = 1.0; + tool.shank_diameter = 1.0; + tool.name = "1 mm twist drill".into(); + } + let operation = Operation { + id: 1, + name: strategy.into(), + setup: 1, + tool: 1, + geometry, + source: None, + hole_sources: vec![], + top: 6.0, + bottom: 5.0, + stepdown: 1.0, + stepover: if strategy == "drill" { 0.5 } else { 2.0 }, + feed: 300.0, + plunge_feed: 100.0, + spindle: 6000.0, + allowance: 0.0, + climb: true, + tolerance: TOLERANCE, + coolant: false, + ramp_angle: None, + lead_distance: 0.0, + }; + let a = Vec3::new(0.0, 0.0, 5.0); + let b = Vec3::new(20.0, 0.0, 5.0); + let c = Vec3::new(20.0, 20.0, 5.0); + let d = Vec3::new(0.0, 20.0, 5.0); + let mut input = JobInput { + setup, + tools: vec![tool], + operations: vec![operation], + geometry: Geometry { target: vec![[a, b, c], [a, c, d]], fixtures: vec![], fixture_groups: vec![] }, + fingerprint: String::new(), + }; + if strategy == "drill" { + input.geometry.target = drilled_plate_target(); + } + if matches!(strategy, "contour" | "pocket") { + input.geometry.target = round_pocket_target(); + } + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +// Original circular pocket with the same faceted boundary as its input recipe. +// Chord error at radius 8 with 128 segments is 0.002410 mm; the recipe itself is +// this polygon, not an unrecorded exact circle. +fn round_pocket_target() -> Vec<[Vec3; 3]> { + let mut triangles = Vec::new(); + let ring = |i: usize| { + let a = (i % 128) as f64 * std::f64::consts::TAU / 128.; + let (y, x) = a.sin_cos(); + let outer = if x.abs() >= y.abs() { + [10. + 10. * x.signum(), 10. + 10. * y / x.abs(), 6.] + } else { + [10. + 10. * x / y.abs(), 10. + 10. * y.signum(), 6.] + }; + ([10. + 8. * x, 10. + 8. * y, 6.], outer) + }; + for i in 0..128 { + let (a, c) = ring(i); + let (b, d) = ring(i + 1); + triangles.extend([[a.into(), c.into(), d.into()], [a.into(), d.into(), b.into()]]); + let e = [a[0], a[1], 5.]; + let f = [b[0], b[1], 5.]; + triangles.extend([[[10., 10., 5.].into(), e.into(), f.into()], [a.into(), b.into(), f.into()], [a.into(), f.into(), e.into()]]); + } + triangles +} + +// Original faceted plate with a blind drilled hole, vertical wall and cone. +fn drilled_plate_target() -> Vec<[Vec3; 3]> { + let mut triangles = Vec::new(); + let n = 48; + let slope = 1.0 / (59_f64.to_radians().tan()); + for k in 0..n { + let point = |index: usize, r: f64, z: f64| { + let a = index as f64 * std::f64::consts::TAU / n as f64; + [10. + r * a.cos(), 10. + r * a.sin(), z] + }; + let outer = |index: usize| { + let a = index as f64 * std::f64::consts::TAU / n as f64; + point(index, 10. / a.cos().abs().max(a.sin().abs()), 6.) + }; + let a = point(k, 0.5, 6.); + let b = point(k + 1, 0.5, 6.); + let c = outer(k); + let d = outer(k + 1); + triangles.push([a.into(), c.into(), d.into()]); + triangles.push([a.into(), d.into(), b.into()]); + let e = point(k, 0.5, 5. + 0.5 * slope); + let f = point(k + 1, 0.5, 5. + 0.5 * slope); + triangles.push([[10., 10., 5.].into(), e.into(), f.into()]); + triangles.push([a.into(), b.into(), f.into()]); + triangles.push([a.into(), f.into(), e.into()]); + } + triangles +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn all_strategies_round_trip() { + for name in ["face", "contour", "pocket", "drill"] { + let input = fixture(name).unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + assert!(!nc::segments(&parsed, &input.setup).unwrap().is_empty()); + } + } + #[test] + fn missing_modes_and_unknown_words_fail() { + let input = fixture("face").unwrap(); + for code in [ + "G0 X0 Y0 Z10\nM30", + "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG43 H1\nM30", + "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG0 XNaN\nM30", + "G21 G90 G91\nM30", + ] { + assert!(nc::parse(code, &input.tools).is_err(), "{code}"); + } + } + #[test] + fn cancelled_job_cannot_publish() { + let mut g = generate::Generator::new(fixture("face").unwrap()).unwrap(); + g.cancel(); + assert!(g.step().is_err()); + assert!(g.finish().is_err()); + } + #[test] + fn malformed_geometry_rejected() { + let mut i = fixture("pocket").unwrap(); + if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry { + boundary.outer = vec![[0.0, 0.0], [10.0, 10.0], [0.0, 10.0], [10.0, 0.0]]; + } + assert!(generate::generate(i).is_err()); + } + #[test] + fn zero_budget_cannot_pass() { + let i = fixture("face").unwrap(); + let p = generate::generate(i.clone()).unwrap(); + let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap(); + let r = verify::simulate(&i, &nc, verify::Limits { work: 1, ..Default::default() }).unwrap(); + assert_ne!(r.status, verify::Status::Pass); + } + #[test] + fn equivalent_units() { + let i = fixture("face").unwrap(); + let a = nc::parse("G21 G90 G17 G94 G54\nG0 X25.4 Y0 Z25.4\nM30", &i.tools).unwrap(); + let b = nc::parse("G20 G90 G17 G94 G54\nG0 X1 Y0 Z1\nM30", &i.tools).unwrap(); + assert_eq!(a.records[0].motion, b.records[0].motion); + } + #[test] + fn direction_changes_arc_sweep() { + let i = fixture("face").unwrap(); + let src = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X0 Y1 I-1 J0 F100\nM5\nM30"; + let a = nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).unwrap(); + let b = nc::segments(&nc::parse(&src.replace("G3 ", "G2 "), &i.tools).unwrap(), &i.setup).unwrap(); + let len = |s: Vec| s.iter().map(|x| x.from.dist(x.to)).sum::(); + assert!((len(b) / len(a) - 3.0).abs() < 0.01); + } + #[test] + fn speed_selection_does_not_start_a_stopped_spindle() { + let i = fixture("face").unwrap(); + let src = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000\nG1 X2 F100\nM30"; + assert!(nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).is_err()); + let running = src.replace("S6000", "S6000 M3"); + let parsed = nc::parse(&running, &i.tools).unwrap(); + assert_eq!(parsed.records.last().unwrap().motion, Motion::Spindle { rpm: 0.0 }); + } + #[test] + fn unsafe_rapid_and_wide_shank_do_not_pass() { + let mut i = fixture("face").unwrap(); + let rapid = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X10 Y10 Z2\nM30"; + let p = nc::parse(rapid, &i.tools).unwrap(); + assert_ne!( + verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap().status, + verify::Status::Pass + ); + i.tools[0].diameter = 1.0; + i.tools[0].cutting_length = 1.0; + i.operations[0].stepover = 0.5; + let shank = "G21 G90 G17 G94 G54\nG0 X-1 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z3 F100\nM5\nM30"; + let p = nc::parse(shank, &i.tools).unwrap(); + let r = verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap(); + assert_ne!(r.status, verify::Status::Pass); + assert!(r.findings.iter().any(|f| f.kind.contains("shank") || f.kind.contains("holder") || f.kind == "budget_exhausted")); + } + #[test] + fn gouge_and_fixture_overlap_are_blocked() { + let mut i = fixture("face").unwrap(); + let path = generate::generate(i.clone()).unwrap(); + let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z4.000000"); + let p = nc::parse(&code, &i.tools).unwrap(); + let r = verify::simulate(&i, &p, Default::default()).unwrap(); + assert_eq!(r.status, verify::Status::Fail); + i.geometry.fixtures.push([Vec3::new(9.0, 9.0, 6.0), Vec3::new(11.0, 9.0, 6.0), Vec3::new(10.0, 11.0, 10.0)]); + let p = nc::round_trip(&path, &i.setup, &i.tools).unwrap(); + let r = verify::simulate(&i, &p, Default::default()).unwrap(); + assert_ne!(r.status, verify::Status::Pass); + } + #[test] + fn simulation_observes_cancellation() { + let i = fixture("face").unwrap(); + let p = generate::generate(i.clone()).unwrap(); + let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap(); + assert!(verify::simulate_cancellable(&i, &nc, Default::default(), || true).is_err()); + } + #[test] + fn islands_winding_ramps_and_tool_fit() { + let mut i = fixture("pocket").unwrap(); + if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry { + boundary.outer.reverse(); + boundary.islands.push(vec![[9.0, 9.0], [11.0, 9.0], [11.0, 11.0], [9.0, 11.0]]); + } + i.tools[0].diameter = 2.0; + i.operations[0].stepover = 0.5; + i.operations[0].ramp_angle = Some(5_f64.to_radians()); + let path = generate::generate(i.clone()).unwrap(); + nc::round_trip(&path, &i.setup, &i.tools).unwrap(); + i.tools[0].diameter = 30.0; + assert!(generate::generate(i).is_err()); + } + #[test] + fn hand_calculated_stock_and_drill_cone() { + let i = fixture("face").unwrap(); + let path = generate::generate(i.clone()).unwrap(); + let nc = nc::round_trip(&path, &i.setup, &i.tools).unwrap(); + let report = verify::simulate(&i, &nc, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Pass); + assert!((report.remaining_volume_low - 2000.0).abs() < 1e-6); + assert!((report.remaining_volume_high - 2000.0).abs() < 1e-6); + let i = fixture("drill").unwrap(); + // Authored NC, independent of the generator. A 118-degree cone rises + // r / tan(59 degrees) from its tip at every radial position r. + let source = "G21 G90 G17 G94 G54\nG0 X10 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z5 F100\nG0 Z15\nM5\nM30"; + let nc = nc::parse(source, &i.tools).unwrap(); + let actual = verify::probe_height(&i, &nc, [10.25, 10.0]).unwrap(); + assert!((actual - (5.0 + 0.25 / 59_f64.to_radians().tan())).abs() < 1e-9); + assert_eq!(verify::probe_height(&i, &nc, [11.0, 10.0]).unwrap(), 6.0); + } + + #[test] + fn climb_pocket_follows_counterclockwise_outer_wall() { + let i = fixture("pocket").unwrap(); + let path = generate::generate(i).unwrap(); + let points: Vec<_> = path + .blocks + .iter() + .filter_map(|b| match b.motion { + Motion::Linear { to, feed: 300.0 } => Some(to), + _ => None, + }) + .collect(); + // With M3, the pocket wall lies to the right of a counterclockwise cut. + let last = &points[points.len() - 128..]; + let area: f64 = last.iter().zip(last.iter().cycle().skip(1)).take(128).map(|(a, b)| a.x * b.y - a.y * b.x).sum(); + assert!(area > 0.0); + } + #[test] + fn cooperative_verification_yields_and_cancels() { + use std::future::Future; + use std::task::{Context, Poll, Waker}; + let input = fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let nc = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let cancel = std::cell::Cell::new(false); + let yield_once = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut future = std::pin::pin!(verify::simulate_cooperative(&input, &nc, Default::default(), || cancel.get(), yield_once)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + cancel.set(true); + assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_)))); + } + #[test] + fn excess_facing_stock_is_a_dimensional_failure() { + let i = fixture("face").unwrap(); + let path = generate::generate(i.clone()).unwrap(); + let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z5.100000"); + let nc = nc::parse(&code, &i.tools).unwrap(); + let report = verify::simulate(&i, &nc, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Pass); // no gouge or collision + assert_eq!(report.finished_dimensions, verify::Status::Fail); + } +} diff --git a/crates/cam/src/corpus/channel.rs b/crates/cam/src/corpus/channel.rs new file mode 100644 index 00000000..7f0f1b52 --- /dev/null +++ b/crates/cam/src/corpus/channel.rs @@ -0,0 +1,98 @@ +//! Original narrow-channel recipes with independently triangulated protected stock. +use crate::*; + +fn capsule() -> Boundary { + let mut outer = Vec::new(); + for (x, start) in [(5., 90_f64), (15., 270_f64)] { + for i in 0..=64 { + let a = (start + 180. * i as f64 / 64.).to_radians(); + outer.push([x + 1.1 * a.cos(), 10. + 1.1 * a.sin()]); + } + } + Boundary { outer, islands: vec![] } +} +fn with_boundary(boundary: Boundary) -> Result { + let mut input = super::fixture("pocket")?; + input.setup.name = "2.2 mm capsule channel".into(); + input.tools[0].name = "2 mm center-cutting end mill".into(); + input.tools[0].diameter = 2.; + input.tools[0].shank_diameter = 2.; + input.operations[0].name = "Finish narrow channel".into(); + input.operations[0].stepover = 0.75; + input.operations[0].geometry = Strategy::Pocket { boundary: boundary.clone() }; + input.geometry.target.clear(); + super::extended::flat(&mut input.geometry.target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), 6.); + super::extended::flat(&mut input.geometry.target, &boundary.outer, &[], 5.); + super::extended::wall(&mut input.geometry.target, &boundary.outer, 6., 5.); + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} +pub(super) fn fixture() -> Result { + with_boundary(capsule()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::verify::Status; + + fn program(input: &JobInput) -> nc::Parsed { + let path = generate::generate(input.clone()).unwrap(); + nc::round_trip(&path, &input.setup, &input.tools).unwrap() + } + + #[test] + fn narrow_channel_finishes_protected_walls_and_floor() { + let input = fixture().unwrap(); + // A rectangle plus two polygonal semicircles, each with 64 segments. + let area = 22. + 64. * 1.1_f64.powi(2) * (std::f64::consts::PI / 64.).sin(); + let volume: f64 = + input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + assert!((volume - (2400. - area)).abs() < 1e-8); + let parsed = program(&input); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!((report.status, report.finished_dimensions), (Status::Pass, Status::Pass), "{:?}", report.findings); + for (xy, expected) in super::super::probes("narrow-channel", 1).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE); + } + } + + #[test] + fn oversized_tool_and_inaccessible_neck_cannot_succeed() { + let mut input = fixture().unwrap(); + input.tools[0].diameter = 2.4; + input.tools[0].shank_diameter = 2.4; + assert!(generate::generate(input).unwrap_err().0.contains("tool cannot fit pocket")); + + // R2 lobes linked by a 1.6 mm wide, >6 mm long neck. A 2 mm tool + // can enter both lobes but cannot reach the center of the neck. + let angle = 0.4_f64.asin(); + let mut outer = Vec::new(); + for (x, start) in [(5., angle), (15., std::f64::consts::PI + angle)] { + for i in 0..=128 { + let a = start + (std::f64::consts::TAU - 2. * angle) * i as f64 / 128.; + outer.push([x + 2. * a.cos(), 10. + 2. * a.sin()]); + } + } + let input = with_boundary(Boundary { outer, islands: vec![] }).unwrap(); + let parsed = program(&input); + assert_eq!(verify::probe_height(&input, &parsed, [10., 10.]).unwrap(), 6.); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.finished_dimensions, Status::Fail, "{:?}", report.findings); + assert!(report.findings.iter().any(|f| f.kind == "finished_region_error")); + } + + #[test] + fn unsafe_lead_is_rejected_against_original_protected_geometry() { + let mut input = fixture().unwrap(); + input.operations[0].lead_distance = 5.; + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input).unwrap(); + let parsed = program(&input); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, Status::Fail, "{:?}", report.findings); + assert!(report.findings.iter().any(|f| f.severity == verify::FindingSeverity::Error && f.source.is_some())); + } +} diff --git a/crates/cam/src/corpus/extended.rs b/crates/cam/src/corpus/extended.rs new file mode 100644 index 00000000..40854062 --- /dev/null +++ b/crates/cam/src/corpus/extended.rs @@ -0,0 +1,220 @@ +//! Original dimensioned recipes. Analytic surfaces are triangulated independently +//! of the CAM offset/generation algorithms; no proprietary artifacts are used. +use crate::*; +use solidcraft_geom::{Loop2, Region2, Seg2, Vec2, Vec3}; + +fn circle(center: [f64; 2], radius: f64) -> Vec<[f64; 2]> { + (0..96) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 96.; + [center[0] + radius * a.cos(), center[1] + radius * a.sin()] + }) + .collect() +} + +// An L with R2 convex corners and an R2 reentrant corner. The sampled polygon +// itself is the recipe, so there is no unstated curve approximation in its input. +fn boundary() -> Boundary { + let mut outer = Vec::new(); + for (center, start, sweep) in + [([4., 4.], 180., 90.), ([16., 4.], 270., 90.), ([16., 8.], 0., 90.), ([12., 12.], 270., -90.), ([8., 16.], 0., 90.), ([4., 16.], 90., 90.)] + { + for i in 0..=32 { + let a = (start + sweep * i as f64 / 32.) * std::f64::consts::PI / 180.; + outer.push([center[0] + 2. * a.cos(), center[1] + 2. * a.sin()]); + } + } + Boundary { outer, islands: vec![circle([6., 6.], 0.8), circle([6., 13.], 0.8)] } +} +fn ring(points: &[[f64; 2]]) -> Loop2 { + Loop2 { + segs: points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(points.len()) + .map(|(a, b)| Seg2::Line { a: Vec2::new(a[0], a[1]), b: Vec2::new(b[0], b[1]) }) + .collect(), + } +} +pub(super) fn flat(out: &mut Vec<[Vec3; 3]>, outer: &[[f64; 2]], holes: &[Vec<[f64; 2]>], z: f64) { + let region = Region2 { outer: ring(outer), holes: holes.iter().map(|h| ring(h)).collect() }; + for t in region.triangulate(1e-6) { + if (t[1] - t[0]).cross(t[2] - t[0]).abs() > 1e-14 { + out.push(t.map(|p| Vec3::new(p.x, p.y, z))); + } + } +} +pub(super) fn wall(out: &mut Vec<[Vec3; 3]>, points: &[[f64; 2]], top: f64, bottom: f64) { + for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) { + let (a, b) = (Vec3::new(a[0], a[1], top), Vec3::new(b[0], b[1], top)); + let (c, d) = (Vec3::new(a.x, a.y, bottom), Vec3::new(b.x, b.y, bottom)); + out.extend([[a, b, d], [a, d, c]]); + } +} +fn target(boundary: &Boundary, top: f64, drilled: bool) -> Vec<[Vec3; 3]> { + let mut out = Vec::new(); + flat(&mut out, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), top); + let mut holes = boundary.islands.clone(); + let drill = circle([15., 6.], 0.5); + if drilled { + holes.push(drill.clone()); + } + flat(&mut out, &boundary.outer, &holes, 4.); + wall(&mut out, &boundary.outer, top, 4.); + for island in &boundary.islands { + flat(&mut out, island, &[], top); + wall(&mut out, island, top, 4.); + } + if drilled { + let shoulder = 3. + 0.5 / 59_f64.to_radians().tan(); + wall(&mut out, &drill, 4., shoulder); + for (a, b) in drill.iter().zip(drill.iter().cycle().skip(1)).take(drill.len()) { + out.push([Vec3::new(15., 6., 3.), Vec3::new(a[0], a[1], shoulder), Vec3::new(b[0], b[1], shoulder)]); + } + } + out +} + +pub(super) fn fixture(name: &str) -> Result { + let mut input = super::fixture("pocket")?; + let mut boundary = boundary(); + input.setup.name = "Rounded L with two islands".into(); + input.tools[0].diameter = 2.; + input.tools[0].shank_diameter = 2.; + input.tools[0].name = "2 mm center-cutting end mill".into(); + let mut pocket = input.operations[0].clone(); + pocket.name = "Concave pocket and islands".into(); + pocket.bottom = 4.; + pocket.stepdown = 0.5; + pocket.stepover = 0.75; + // Exercise winding normalization independently for outer and island loops. + boundary.outer.reverse(); + if let Some(first) = boundary.islands.first_mut() { + first.reverse(); + } + pocket.geometry = Strategy::Pocket { boundary: boundary.clone() }; + input.geometry.target = target(&boundary, if name == "multi-operation" { 5.5 } else { 6. }, name == "multi-operation"); + input.operations = vec![pocket.clone()]; + if name == "multi-operation" { + let mut face = super::fixture("face")?; + face.tools[0].id = 2; + face.tools[0].number = 2; + face.operations[0].id = 1; + face.operations[0].tool = 2; + face.operations[0].bottom = 5.5; + pocket.id = 2; + pocket.top = 5.5; + pocket.allowance = 0.2; + pocket.coolant = true; + let mut finish = pocket.clone(); + finish.id = 3; + finish.name = "Finish pocket".into(); + finish.allowance = 0.; + finish.climb = false; + let mut drill = super::fixture("drill")?; + drill.tools[0].id = 3; + drill.tools[0].number = 3; + drill.operations[0].id = 4; + drill.operations[0].tool = 3; + drill.operations[0].top = 4.; + drill.operations[0].bottom = 3.; + drill.operations[0].geometry = Strategy::Drill { holes: vec![[15., 6.]], peck: Some(0.3), full_diameter: false }; + input.tools.extend([face.tools.remove(0), drill.tools.remove(0)]); + input.operations = vec![face.operations.remove(0), pocket, finish, drill.operations.remove(0)]; + input.setup.frame.origin = Vec3::new(35., -20., 10.); + input.setup.work_offset = 55; + } + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn protected_target_volume_matches_the_original_recipe() { + // Polygonal R2 quarter arcs contain 32 equal angle segments; islands + // and the drill use 96. These areas come from triangle sector formulae. + let pocket_area = 176. + 256. * (std::f64::consts::PI / 64.).sin() - 96. * 0.8_f64.powi(2) * (std::f64::consts::TAU / 96.).sin(); + for name in ["concave-islands", "multi-operation"] { + let input = fixture(name).unwrap(); + let volume: f64 = + input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + let expected = if name == "concave-islands" { + 2400. - 2. * pocket_area + } else { + let hole_area = 12. * (std::f64::consts::TAU / 96.).sin(); + 2200. - 1.5 * pocket_area - hole_area * (1. - 1. / (3. * 59_f64.to_radians().tan())) + }; + assert!((volume - expected).abs() < 1e-8, "{name}: {volume} != {expected}"); + } + } + #[test] + fn extended_fixtures_certify_complete_protected_surfaces() { + for name in ["concave-islands", "multi-operation"] { + let input = fixture(name).unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!( + (report.status, report.finished_dimensions), + (verify::Status::Pass, verify::Status::Pass), + "{name} {} cells {} work {:?}", + report.cells.len(), + report.work, + report.findings.iter().take(4).collect::>() + ); + for (xy, expected) in super::super::probes(name, input.operations.len()).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE, "{name} {xy:?}"); + } + // Adaptive drill-tip cells must form a conforming stock surface, + // including where their refinement meets the pocket floor. + verify::stock_triangles(&report, input.setup.stock.min.z).unwrap(); + } + } + #[test] + fn intermediate_roughing_is_allowed_but_missing_finishing_is_not() { + let mut input = fixture("multi-operation").unwrap(); + input.operations.truncate(2); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let rough = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!( + (rough.status, rough.finished_dimensions), + (verify::Status::Pass, verify::Status::Pass), + "{:?}", + rough.findings.iter().take(5).collect::>() + ); + assert!((verify::probe_height(&input, &parsed, [6., 9.]).unwrap() - 4.2).abs() < 1e-8); + let mut finished = fixture("multi-operation").unwrap(); + finished.operations.truncate(3); + let missing = verify::simulate(&finished, &parsed, Default::default()).unwrap(); + assert_eq!(missing.finished_dimensions, verify::Status::Fail); + } + #[test] + fn removed_islands_wrong_tool_and_wrong_work_offset_are_rejected() { + let input = fixture("concave-islands").unwrap(); + let mut damaged = input.clone(); + if let Strategy::Pocket { boundary } = &mut damaged.operations[0].geometry { + boundary.islands.clear(); + } + let path = generate::generate(damaged).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let result = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(result.status, verify::Status::Fail); + assert_eq!(result.finished_dimensions, verify::Status::Fail); + let input = fixture("multi-operation").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let tools: Vec<_> = path.blocks.iter().filter_map(|b| if let Motion::Tool { number, .. } = b.motion { Some(number) } else { None }).collect(); + assert_eq!(tools, vec![2, 1, 1, 3]); + let code = nc::post(&path, &input.setup).unwrap(); + let wrong = nc::parse(&code.replace("T3 M6", "T1 M6"), &input.tools).unwrap(); + let report = verify::simulate(&input, &wrong, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + assert_eq!(report.finished_dimensions, verify::Status::Fail); + let wrong = nc::parse(&code.replace("G55", "G54"), &input.tools).unwrap(); + assert!(verify::simulate(&input, &wrong, Default::default()).is_err()); + } +} diff --git a/crates/cam/src/corpus/exterior.rs b/crates/cam/src/corpus/exterior.rs new file mode 100644 index 00000000..d3164f50 --- /dev/null +++ b/crates/cam/src/corpus/exterior.rs @@ -0,0 +1,84 @@ +//! Outside contour of a square boss, with allowance roughing then finishing. +use crate::*; + +pub(super) fn fixture() -> Result { + let mut input = super::fixture("contour")?; + input.setup.name = "External square boss".into(); + input.tools[0].diameter = 2.; + input.tools[0].shank_diameter = 2.; + input.tools[0].name = "2 mm center-cutting end mill".into(); + let square = vec![[5., 5.], [15., 5.], [15., 15.], [5., 15.]]; + let mut target = Vec::new(); + super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&square), 4.); + super::extended::flat(&mut target, &square, &[], 6.); + super::extended::wall(&mut target, &square, 6., 4.); + input.geometry.target = target; + let mut rough = input.operations[0].clone(); + rough.name = "External rough contour".into(); + rough.geometry = Strategy::Contour { boundary: Boundary { outer: square.into_iter().rev().collect(), islands: vec![] }, inside: false }; + rough.bottom = 4.; + rough.stepdown = 0.5; + rough.stepover = 1.; + rough.allowance = 0.2; + rough.lead_distance = 0.5; + rough.ramp_angle = Some(5_f64.to_radians()); + let mut finish = rough.clone(); + finish.id = 2; + finish.name = "External finish contour".into(); + finish.allowance = 0.; + finish.climb = false; + finish.coolant = true; + input.operations = vec![rough, finish]; + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn external_contour_finishes_walls_with_leads_and_multiple_depths() { + let input = fixture().unwrap(); + let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + assert!((volume - 1800.).abs() < 1e-9); + let path = generate::generate(input.clone()).unwrap(); + assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Arc { .. }))); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!( + (report.status, report.finished_dimensions), + (verify::Status::Pass, verify::Status::Pass), + "cells={} work={} blocks={} {:?}", + report.cells.len(), + report.work, + path.blocks.len(), + report.findings + ); + for (xy, expected) in super::super::probes("external-contour", 2).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE); + } + } + + #[test] + fn wrong_contour_side_gouges_and_omitted_finish_leaves_allowance() { + let input = fixture().unwrap(); + let mut wrong = input.clone(); + wrong.operations.truncate(1); + if let Strategy::Contour { inside, .. } = &mut wrong.operations[0].geometry { + *inside = true; + } + let path = generate::generate(wrong).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + let mut rough = input.clone(); + rough.operations.truncate(1); + let path = generate::generate(rough).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.finished_dimensions, verify::Status::Fail, "cells={} work={} {:?}", report.cells.len(), report.work, report.findings); + } +} diff --git a/crates/cam/src/corpus/through.rs b/crates/cam/src/corpus/through.rs new file mode 100644 index 00000000..caf88a2b --- /dev/null +++ b/crates/cam/src/corpus/through.rs @@ -0,0 +1,94 @@ +//! Two through holes in a plate with a raised clamp between their axes. +//! Geometry and analytic expectations are independent of toolpath generation. +use crate::*; +use solidcraft_geom::Vec3; + +pub(super) fn fixture() -> Result { + let mut input = super::fixture("drill")?; + input.setup.name = "Clamped through-hole plate".into(); + input.setup.fixtures = vec![BodyRef { body: "Center clamp".into(), occurrence: vec![] }]; + let mut simple = input.operations[0].clone(); + simple.name = "Simple through hole".into(); + simple.bottom = -0.25; + simple.geometry = Strategy::Drill { holes: vec![[6., 10.]], peck: None, full_diameter: true }; + let mut peck = simple.clone(); + peck.id = 2; + peck.name = "Peck through hole".into(); + peck.coolant = true; + peck.geometry = Strategy::Drill { holes: vec![[14., 10.]], peck: Some(2.), full_diameter: true }; + input.operations = vec![simple, peck]; + let holes: Vec> = [6., 14.] + .into_iter() + .map(|x| { + (0..96) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 96.; + [x + 0.5 * a.cos(), 10. + 0.5 * a.sin()] + }) + .collect() + }) + .collect(); + let mut target = Vec::new(); + super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], &holes, 6.); + for hole in &holes { + super::extended::wall(&mut target, hole, 6., 0.); + } + input.geometry.target = target; + // Closed, outward-oriented 2 x 2 x 2 mm clamp on the plate top. A + // clearance-height transfer passes over it; a transfer at Z7 cannot. + let p = [[9., 9., 6.], [11., 9., 6.], [11., 11., 6.], [9., 11., 6.], [9., 9., 8.], [11., 9., 8.], [11., 11., 8.], [9., 11., 8.]].map(Vec3::from); + input.geometry.fixtures = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]] + .into_iter() + .flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]]) + .collect(); + input.geometry.fixture_groups = vec![12]; + input.fingerprint.clear(); + input.fingerprint = crate::fingerprint(&input)?; + Ok(input) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn clamped_through_holes_preserve_surfaces_and_export_open_bores() { + let input = fixture().unwrap(); + // Each inscribed 96-gon has area 96/2 * r^2 * sin(2pi/96). + let expected = 2400. - 6. * 24. * (std::f64::consts::TAU / 96.).sin(); + let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum(); + assert!((volume - expected).abs() < 1e-8); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!((report.status, report.finished_dimensions), (verify::Status::Pass, verify::Status::Pass), "{:?}", report.findings); + for (xy, expected) in super::super::probes("clamped-through-holes", 2).unwrap() { + assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE); + } + let stock = verify::stock_stl(&report, 0.).unwrap(); + let mesh = compare::read_stl(stock.as_bytes()).unwrap(); + assert_eq!(compare::stock_mesh(&report, &mesh, 0., TOLERANCE).unwrap().status, verify::Status::Pass); + } + + #[test] + fn missing_breakthrough_and_low_clamp_transfer_do_not_pass() { + let input = fixture().unwrap(); + // Authored NC: the tip reaches the bottom but the full diameter does + // not. Both bores retain a cone of material and must fail dimensions. + let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z0 F100\nG0 Z15\nG0 X14\nG1 Z0 F100\nG0 Z15\nM5\nM30"; + let parsed = nc::parse(code, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.finished_dimensions, verify::Status::Fail); + // An otherwise clear rapid at Z7 crosses the raised clamp between + // axes. Require the actual source line, not merely a non-pass status. + let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nG0 Z7\nG0 X14\nG0 Z15\nM30"; + let parsed = nc::parse(code, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + assert!(report.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(5))); + let mut short = input; + short.tools[0].cutting_length = 6.; + assert!(generate::generate(short).is_err()); + } +} diff --git a/crates/cam/src/coverage.rs b/crates/cam/src/coverage.rs new file mode 100644 index 00000000..e6916758 --- /dev/null +++ b/crates/cam/src/coverage.rs @@ -0,0 +1,215 @@ +//! Coverage certificates use boundary incidence, never summed overlapping area. +use crate::{spatial::Index, verify::Cell, *}; +use solidcraft_geom::Vec3; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Coord(f64); +impl Eq for Coord {} +impl PartialOrd for Coord { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Coord { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) } + } +} +type Point = [Coord; 2]; +fn xy(p: Vec3) -> Point { + [Coord(p.x), Coord(p.y)] +} +fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 { + robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] }) +} +fn corners(min: [f64; 2], max: [f64; 2]) -> [[f64; 2]; 4] { + [min, [max[0], min[1]], max, [min[0], max[1]]] +} +fn predicate_coordinates(values: impl IntoIterator) -> Result<()> { + // Avoid underflow in adaptive predicate expansions. Such coordinates are + // far below the supported machining error budget; reject, never snap. + if values.into_iter().any(|v| v != 0. && v.abs() < 1e-100) { + return invalid("comparison coordinates are below the predicate numeric range"); + } + Ok(()) +} + +/// Reject overlaps and hostile intervals; certify exact rectangular tiling in +/// O(n log n). Missing strips of any width stay missing, with no area epsilon. +pub(crate) fn cells(cells: &[Cell], stock: Stock) -> Result { + stock.validate()?; + if cells.is_empty() || cells.len() > 1_000_000 { + return invalid("invalid comparison cell count"); + } + let mut events = Vec::with_capacity(cells.len() * 2); + for (id, c) in cells.iter().enumerate() { + for p in [Vec3::new(c.min[0], c.min[1], c.low), Vec3::new(c.max[0], c.max[1], c.high)] { + model::point(p)?; + } + predicate_coordinates(c.min.into_iter().chain(c.max))?; + if c.min[0] >= c.max[0] + || c.min[1] >= c.max[1] + || c.low > c.high + || c.low < stock.min.z + || c.high > stock.max.z + || c.min[0] < stock.min.x + || c.min[1] < stock.min.y + || c.max[0] > stock.max.x + || c.max[1] > stock.max.y + { + return invalid("comparison cell outside declared stock or invalid interval"); + } + events.push((Coord(c.min[0]), true, id)); + events.push((Coord(c.max[0]), false, id)); + } + events.sort_unstable(); + let mut active = BTreeMap::::new(); + let mut gaps = 1isize; + let mut previous = stock.min.x; + let mut complete = true; + for (at, start, id) in events { + if at.0 > previous && gaps != 0 { + complete = false; + } + previous = at.0; + let c = &cells[id]; + let a = Coord(c.min[1]); + let b = Coord(c.max[1]); + if start { + let left = active.range(..=a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end); + let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start); + if left > a || right < b { + return invalid("overlapping comparison cells"); + } + gaps += isize::from(left != a) + isize::from(b != right) - isize::from(left != right); + active.insert(a, b); + } else { + if active.remove(&a) != Some(b) { + return invalid("invalid comparison cell sweep"); + } + let left = active.range(..a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end); + let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start); + gaps += isize::from(left != right) - isize::from(left != a) - isize::from(b != right); + } + } + Ok(complete && previous == stock.max.x) +} + +pub(crate) struct MeshField<'a> { + triangles: &'a [[Vec3; 3]], + index: Index, + boundary: Vec<([f64; 2], [f64; 2])>, + boundary_index: Index, + stock: Stock, + guard: f64, +} +impl<'a> MeshField<'a> { + pub(crate) fn new(triangles: &'a [[Vec3; 3]], stock: Stock) -> Result { + stock.validate()?; + crate::stock_mesh::validate_triangles(triangles)?; + let mut edges = BTreeMap::<(Point, Point), i32>::new(); + let mut items = Vec::new(); + let extent = stock.min.len().max(stock.max.len()).max(1.); + for (id, t) in triangles.iter().enumerate() { + for p in t { + predicate_coordinates([p.x, p.y])?; + if p.x < stock.min.x || p.y < stock.min.y || p.z < stock.min.z || p.x > stock.max.x || p.y > stock.max.y || p.z > stock.max.z { + return invalid("reference stock mesh exceeds declared stock; check units and frame"); + } + } + let p = t.map(|v| [v.x, v.y]); + let orientation = orient(p[0], p[1], p[2]); + if orientation < 0. && t.iter().any(|p| p.z != stock.min.z) { + return invalid("reference stock is not a bottom-supported vertical height field"); + } + if orientation <= 0. { + continue; + } + let min = t.iter().copied().fold(t[0], Vec3::min); + let max = t.iter().copied().fold(t[0], Vec3::max); + items.push((id, [min.x, min.y], [max.x, max.y])); + for (a, b) in [(xy(t[0]), xy(t[1])), (xy(t[1]), xy(t[2])), (xy(t[2]), xy(t[0]))] { + let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) }; + *edges.entry(key).or_default() += sign; + } + } + let boundary: Vec<_> = edges.into_iter().filter(|(_, n)| *n != 0).map(|((a, b), _)| (a.map(|v| v.0), b.map(|v| v.0))).collect(); + let boundary_index = Index::new( + boundary.iter().enumerate().map(|(id, (a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(), + ); + Ok(Self { triangles, index: Index::new(items), boundary, boundary_index, stock, guard: 128. * f64::EPSILON * extent }) + } + /// Bounds the maximum upward surface everywhere in a rectangle, including + /// holes (height = stock bottom). Shared projected edges cancel exactly; + /// unmatched edges crossing the interior forbid a full-coverage certificate. + pub(crate) fn bounds(&self, min: [f64; 2], max: [f64; 2], work: &mut usize, budget: usize) -> Option<[f64; 2]> { + let rectangle = corners(min, max); + let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]; + let mut ids = Vec::new(); + self.boundary_index.query(min, max, &mut ids); + let mut crossed = false; + for id in ids { + *work += 1; + if *work > budget { + return None; + } + let (a, b) = self.boundary[id]; + if (0..2).any(|k| a[k].max(b[k]) <= min[k] || a[k].min(b[k]) >= max[k]) { + continue; + } + let signs = rectangle.map(|c| orient(a, b, c)); + if signs.iter().any(|v| *v < 0.) && signs.iter().any(|v| *v > 0.) { + crossed = true; + break; + } + } + let mut ids = Vec::new(); + self.index.query(min, max, &mut ids); + let mut low = f64::INFINITY; + let mut high = self.stock.min.z; + let mut inside = false; + for id in ids { + *work += 1; + if *work > budget { + return None; + } + let t = &self.triangles[id]; + let p = t.map(|p| [p.x, p.y]); + // These cells form a tiling whose closed columns are united later. + // A triangle touching only a cell edge belongs to the adjacent cell; + // including its height here creates false interior discontinuities. + // Positive-area overlap is decided with exact orientation predicates. + if (0..2).any(|k| p.iter().all(|v| v[k] <= min[k]) || p.iter().all(|v| v[k] >= max[k])) { + continue; + } + let edges = [(p[0], p[1]), (p[1], p[2]), (p[2], p[0])]; + if edges.iter().any(|(a, b)| rectangle.iter().all(|c| orient(*a, *b, *c) <= 0.)) { + continue; + } + inside |= edges.iter().all(|(a, b)| orient(*a, *b, center) >= 0.); + let mut lo = t.iter().map(|v| v.z).fold(f64::INFINITY, f64::min); + let mut hi = t.iter().map(|v| v.z).fold(f64::NEG_INFINITY, f64::max); + let n = (t[1] - t[0]).cross(t[2] - t[0]); + let normal_error = 4. * self.guard * (1. + (t[1] - t[0]).len() + (t[2] - t[0]).len()); + if n.z > normal_error { + let slope = [n.x / n.z, n.y / n.z]; + let slope_error = slope.map(|s| normal_error * (1. + s.abs()) / (n.z - normal_error)); + let error = self.guard * (1. + slope[0].abs() + slope[1].abs()) + + slope_error[0] * (t[0].x - min[0]).abs().max((t[0].x - max[0]).abs()) + + slope_error[1] * (t[0].y - min[1]).abs().max((t[0].y - max[1]).abs()); + let z = rectangle.map(|p| t[0].z + slope[0] * (t[0].x - p[0]) + slope[1] * (t[0].y - p[1])); + if error.is_finite() && z.iter().all(|v| v.is_finite()) { + lo = lo.max(z.iter().copied().fold(f64::INFINITY, f64::min) - error); + hi = hi.min(z.iter().copied().fold(f64::NEG_INFINITY, f64::max) + error); + } + } + low = low.min(lo); + high = high.max(hi); + } + if !inside || crossed { + low = self.stock.min.z; + } + Some([(low - self.guard).max(self.stock.min.z), (high + self.guard).min(self.stock.max.z)]) + } +} diff --git a/crates/cam/src/dimensions.rs b/crates/cam/src/dimensions.rs new file mode 100644 index 00000000..ea7b064c --- /dev/null +++ b/crates/cam/src/dimensions.rs @@ -0,0 +1,554 @@ +//! Independent analytic finished-height envelopes. No path-generation or offset +//! code is used here. Radial bands limit diameter error; depth is checked in Z. +use crate::{verify::Status, *}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Envelope { + lower_min: f64, + lower_max: f64, + upper_min: f64, + upper_max: f64, +} +impl Envelope { + fn range(lo: f64, hi: f64, tolerance: f64) -> Self { + Self { lower_min: lo - tolerance, lower_max: hi - tolerance, upper_min: lo + tolerance, upper_max: hi + tolerance } + } + fn alternatives(self, b: Self) -> Self { + Self { + lower_min: self.lower_min.min(b.lower_min), + lower_max: self.lower_max.max(b.lower_max), + upper_min: self.upper_min.min(b.upper_min), + upper_max: self.upper_max.max(b.upper_max), + } + } + fn cut(self, b: Self) -> Self { + Self { + lower_min: self.lower_min.min(b.lower_min), + lower_max: self.lower_max.min(b.lower_max), + upper_min: self.upper_min.min(b.upper_min), + upper_max: self.upper_max.min(b.upper_max), + } + } + pub(crate) fn classify(self, low: f64, high: f64) -> Status { + if high < self.lower_min || low > self.upper_max { + Status::Fail + } else if low >= self.lower_max && high <= self.upper_min { + Status::Pass + } else { + Status::Inconclusive + } + } + pub(crate) fn description(self) -> String { + format!( + "Finished-region height must lie within [{:.6}, {:.6}] mm; cell-wide certified range [{:.6}, {:.6}] mm", + self.lower_min, self.upper_max, self.lower_max, self.upper_min + ) + } +} + +enum Feature { + Plane(f64), + Hole { center: [f64; 2], radius: f64, tip: f64, slope: f64 }, + Mill { region: crate::region::Region, outside: bool, width: Option, allowance: f64, depth: f64 }, +} +pub(crate) struct Finished { + features: Vec, + bottom: f64, + top: f64, + tolerance: f64, + radial_tolerance: f64, + pub(crate) refinement: f64, +} +impl Finished { + pub(crate) fn new(input: &JobInput, tolerance: f64) -> Result> { + // Reserve radial error on both sides of a diameter, and convert XY + // interpolation error into a conservative cone-height error as well. + let has_walls = input.operations.iter().any(|o| !matches!(o.geometry, Strategy::Face)); + let profile_error = input.operations.iter().filter(|o| o.source.is_some()).map(|o| crate::profile_tolerance(o.tolerance)).fold(0.0, f64::max); + let radial_tolerance = tolerance / 2.0 - POST_ERROR - profile_error; + let slope = input + .operations + .iter() + .filter(|o| matches!(o.geometry, Strategy::Drill { .. })) + .filter_map(|o| input.tools.iter().find(|t| t.id == o.tool)) + .map(|t| 1.0 / (t.tip_angle / 2.0).tan()) + .fold(0.0, f64::max); + let tolerance = tolerance - POST_ERROR * (1.0 + slope); + if tolerance <= 0.0 || (has_walls && radial_tolerance <= 0.0) { + return Ok(None); + } + let mut features = Vec::new(); + for op in &input.operations { + match &op.geometry { + Strategy::Face => features.push(Feature::Plane(op.bottom + op.allowance)), + Strategy::Drill { holes, full_diameter, .. } => { + let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?; + let tip = op.bottom - if *full_diameter { tool.tip_length() } else { 0.0 }; + for center in holes { + if features.len() >= MAX_MOVES { + return invalid("dimensional feature budget exceeded"); + } + features.push(Feature::Hole { center: *center, radius: tool.diameter / 2.0, tip, slope: 1.0 / (tool.tip_angle / 2.0).tan() }); + } + } + Strategy::Contour { boundary, inside } => { + if !boundary.islands.is_empty() { + return invalid("select contour islands as separate operations"); + } + let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?; + features.push(Feature::Mill { + region: crate::region::Region::new(boundary)?, + outside: !inside, + width: Some(tool.diameter / 2.), + allowance: op.allowance, + depth: op.bottom + op.allowance, + }); + } + Strategy::Pocket { boundary } => features.push(Feature::Mill { + region: crate::region::Region::new(boundary)?, + outside: false, + width: None, + allowance: op.allowance, + depth: op.bottom + op.allowance, + }), + } + } + Ok(Some(Self { + features, + bottom: input.setup.stock.min.z, + top: input.setup.stock.max.z, + tolerance, + radial_tolerance, + refinement: if has_walls { VERIFY_ERROR.min(tolerance / 8.0).min(radial_tolerance / 4.0) } else { VERIFY_ERROR }, + })) + } + pub(crate) async fn envelope>( + &self, + region: ([f64; 2], f64), + work: &mut usize, + budget: usize, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result> { + let (p, half) = region; + let unchanged = Envelope::range(self.top, self.top, self.tolerance); + let mut combined = unchanged; + for (index, feature) in self.features.iter().enumerate() { + if index % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("dimensional verification cancelled"); + } + } + *work += 1; + if *work > budget { + return Ok(None); + } + let allowed = match feature { + Feature::Plane(z) => { + let z = z.clamp(self.bottom, self.top); + Envelope::range(z, z, self.tolerance) + } + Feature::Mill { region, outside, width, allowance, depth } => { + let reach = allowance + width.unwrap_or(0.) + self.radial_tolerance; + let Some(mut d) = region.range((p, half, reach), work, budget, cancelled, yield_control).await? else { + return Ok(None); + }; + if *outside { + d = [-d[1], -d[0]]; + } + let floor = Envelope::range(depth.clamp(self.bottom, self.top), depth.clamp(self.bottom, self.top), self.tolerance); + // A contour finishes the wall and adjacent floor out to one + // tool radius. Farther stock on the cutting side is optional + // roughing stock; it must never be cut below the stated floor. + let optional = Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..floor }; + let mut possible: Option = None; + let mut add = |v| possible = Some(possible.map_or(v, |old| old.alternatives(v))); + if d[0] <= allowance - self.radial_tolerance { + add(unchanged); + } + if d[1] >= allowance - self.radial_tolerance && d[0] <= allowance + self.radial_tolerance { + add(optional); + } + if d[1] >= allowance + self.radial_tolerance && width.is_none_or(|w| d[0] <= allowance + w) { + add(floor); + } + if width.is_some_and(|w| d[1] >= allowance + w) { + add(optional); + } + let Some(allowed) = possible else { + return Ok(None); + }; + allowed + } + Feature::Hole { center, radius, tip, slope } => { + let r = (p[0] - center[0]).hypot(p[1] - center[1]); + let lo = (r - half).max(0.0); + let hi = r + half; + let zlo = (tip + lo.min(*radius) * slope).clamp(self.bottom, self.top); + let zhi = (tip + hi.min(*radius) * slope).clamp(self.bottom, self.top); + let cut = Envelope::range(zlo, zhi, self.tolerance); + let mut possible: Option = None; + let mut add = |v| { + possible = Some(possible.map_or(v, |old| old.alternatives(v))); + }; + if lo <= radius - self.radial_tolerance { + add(cut); + } + if hi >= radius + self.radial_tolerance { + add(unchanged); + } + if hi >= radius - self.radial_tolerance && lo <= radius + self.radial_tolerance { + add(Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..cut }); + } + let Some(allowed) = possible else { + return Ok(None); + }; + allowed + } + }; + combined = combined.cut(allowed); + } + Ok(Some(combined)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn envelope(f: &Finished, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> Option { + use std::future::Future; + let mut ready = || std::future::ready(()); + let mut future = std::pin::pin!(f.envelope((p, half), work, budget, &|| false, &mut ready)); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(result) => result.unwrap(), + _ => None, + } + } + fn finished() -> Finished { + Finished::new(&corpus::fixture("drill").unwrap(), TOLERANCE).unwrap().unwrap() + } + #[test] + fn generated_contours_and_pockets_certify_selected_finished_regions() { + for name in ["contour", "pocket"] { + let input = corpus::fixture(name).unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap(); + let report = simulate(&input, &code); + assert_eq!( + report.status, + Status::Pass, + "{name} cells {} work {} {:?}", + report.cells.len(), + report.work, + report.findings.iter().take(4).collect::>() + ); + assert_eq!( + report.finished_dimensions, + Status::Pass, + "{name} cells {} work {} {:?}", + report.cells.len(), + report.work, + report.findings.iter().take(4).collect::>() + ); + } + } + #[test] + fn missed_milling_wrong_floor_and_inaccessible_corners_fail() { + for name in ["contour", "pocket"] { + let input = corpus::fixture(name).unwrap(); + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let wrong = simulate(&input, &code.replace("Z5.000000", "Z5.100000")); + assert_eq!(wrong.finished_dimensions, Status::Fail, "{name}"); + let missed = simulate(&input, "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n"); + assert_eq!(missed.finished_dimensions, Status::Fail, "{name}"); + } + let mut input = corpus::fixture("pocket").unwrap(); + input.operations[0].geometry = + Strategy::Pocket { boundary: Boundary { outer: vec![[2., 2.], [18., 2.], [18., 18.], [2., 18.]], islands: vec![] } }; + // A round cutter cannot finish sharp internal corners. Protected geometry + // is independently relaxed here to isolate the dimensional failure. + for t in &mut input.geometry.target { + for p in t { + p.z = 0.; + } + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail); + } + #[test] + fn allowance_winding_multiple_depths_and_external_contours() { + let mut input = corpus::fixture("contour").unwrap(); + input.operations[0].allowance = 0.2; + input.operations[0].stepdown = 0.3; + if let Strategy::Contour { boundary, .. } = &mut input.operations[0].geometry { + boundary.outer.reverse(); + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings); + if let Strategy::Contour { inside, .. } = &mut input.operations[0].geometry { + *inside = false; + } + // The separate protected-CAD tests exercise real walls; this case checks + // only the independent outside/inside selection and allowance contract. + for t in &mut input.geometry.target { + for p in t { + p.z = 0.; + } + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings); + } + #[test] + fn pocket_islands_are_protected_and_finished() { + let mut input = corpus::fixture("pocket").unwrap(); + if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry { + boundary.islands.push( + (0..48) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 48.; + [10. + a.cos(), 10. + a.sin()] + }) + .collect(), + ); + } + input.tools[0].diameter = 2.; + input.operations[0].stepover = 0.75; + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!( + r.finished_dimensions, + Status::Pass, + "cells {} work {} {:?}", + r.cells.len(), + r.work, + r.findings.iter().take(3).collect::>() + ); + let mut without = input.clone(); + if let Strategy::Pocket { boundary } = &mut without.operations[0].geometry { + boundary.islands.clear(); + } + let code = nc::post(&generate::generate(without.clone()).unwrap(), &without.setup).unwrap(); + assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail); + if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry { + boundary.islands = [8., 12.] + .into_iter() + .map(|x| { + (0..48) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 48.; + [x + 0.8 * a.cos(), 10. + 0.8 * a.sin()] + }) + .collect() + }) + .collect(); + } + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + let r = simulate(&input, &code); + assert_eq!( + r.finished_dimensions, + Status::Pass, + "cells {} work {} {:?}", + r.cells.len(), + r.work, + r.findings.iter().take(3).collect::>() + ); + } + #[test] + fn associated_profile_chord_error_has_a_separate_budget() { + let mut input = corpus::fixture("pocket").unwrap(); + input.operations[0].source = Some(ProfileRef { sketch: 1, curves: vec!["wall".into()], occurrence: vec![] }); + let f = Finished::new(&input, TOLERANCE).unwrap().unwrap(); + assert!((f.radial_tolerance - (TOLERANCE / 2. - POST_ERROR - crate::profile_tolerance(TOLERANCE))).abs() < 1e-12); + assert_eq!(crate::profile_tolerance(TOLERANCE), 0.0005); + // A looser operation can coexist with a tighter program tolerance. Use + // the actual sampling budget of each source, not the program minimum. + input.operations[0].tolerance = 0.1; + let f = Finished::new(&input, 0.005).unwrap().unwrap(); + assert!((f.radial_tolerance - 0.001).abs() < 1e-12); + let mut unused = input.tools[0].clone(); + unused.id = 2; + unused.number = 2; + unused.kind = ToolKind::TwistDrill; + unused.tip_angle = 0.11; + input.tools.push(unused); + assert!(Finished::new(&input, 0.005).unwrap().is_some()); + } + #[test] + fn wrong_milling_diameter_and_shifted_paths_are_detected() { + let mut input = corpus::fixture("contour").unwrap(); + let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap(); + for diameter in [5.8, 6.2] { + let mut other = input.tools[0].clone(); + other.id = 2; + other.number = 2; + other.diameter = diameter; + input.tools.truncate(1); + input.tools.push(other); + assert_eq!(simulate(&input, &code.replace("T1 M6", "T2 M6")).finished_dimensions, Status::Fail); + } + let mut shifted = nc::parse(&code, &input.tools).unwrap(); + for record in &mut shifted.records { + match &mut record.motion { + Motion::Rapid { to } | Motion::Linear { to, .. } => to.x += 0.03, + Motion::Arc { to, center, .. } => { + to.x += 0.03; + center.x += 0.03; + } + _ => {} + } + } + assert_eq!(verify::simulate(&input, &shifted, Default::default()).unwrap().finished_dimensions, Status::Fail); + } + #[test] + fn hand_calculated_cone_and_diameter_band() { + let f = finished(); + let mut work = 0; + assert_eq!(envelope(&f, [10., 10.], 0., &mut work, 100).unwrap().classify(5., 5.), Status::Pass); + let z = 5. + 0.25 / (59_f64.to_radians().tan()); + assert_eq!(envelope(&f, [10.25, 10.], 0., &mut work, 100).unwrap().classify(z, z), Status::Pass); + assert_eq!(envelope(&f, [10.49, 10.], 0., &mut work, 100).unwrap().classify(6., 6.), Status::Fail); + assert_eq!(envelope(&f, [10.51, 10.], 0., &mut work, 100).unwrap().classify(5.3, 5.3), Status::Fail); + assert_eq!(envelope(&f, [10.5, 10.], 0., &mut work, 100).unwrap().classify(5.31, 6.), Status::Pass); + } + #[test] + fn boundary_uncertainty_and_resource_limits_never_pass() { + let f = finished(); + let mut work = 0; + assert_eq!(envelope(&f, [10.5, 10.], 0.01, &mut work, 100).unwrap().classify(5.3, 6.), Status::Inconclusive); + assert!(envelope(&f, [10., 10.], 0., &mut work, 0).is_none()); + } + fn program(tool: u32, x: f64, depth: f64) -> String { + format!( + "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nT{tool} M6\nS6000 M3\nM9\nG0 X{x} Y10 Z15\nG1 Z6 F100\nG1 Z{depth} F100\nG0 Z15\nM5\nM9\nM30\n" + ) + } + fn simulate(input: &JobInput, code: &str) -> crate::verify::Report { + let parsed = nc::parse(code, &input.tools).unwrap(); + verify::simulate(input, &parsed, Default::default()).unwrap() + } + #[test] + fn blind_hole_certifies_and_bad_depth_position_diameter_fail() { + let mut input = corpus::fixture("drill").unwrap(); + let correct = simulate(&input, &program(1, 10., 5.)); + assert_eq!(correct.status, Status::Pass); + assert_eq!(correct.finished_dimensions, Status::Pass, "{:?}", correct.findings); + for (x, depth) in [(10., 5.1), (10., 4.9), (10.03, 5.)] { + assert_eq!(simulate(&input, &program(1, x, depth)).finished_dimensions, Status::Fail); + } + for diameter in [0.8, 1.2] { + let mut other = input.tools[0].clone(); + other.id = 2; + other.number = 2; + other.diameter = diameter; + other.shank_diameter = diameter; + input.tools.truncate(1); + input.tools.push(other); + assert_eq!(simulate(&input, &program(2, 10., 5.)).finished_dimensions, Status::Fail); + } + } + #[test] + fn absent_hole_is_not_hidden_by_a_coarse_stock_cell() { + let input = corpus::fixture("drill").unwrap(); + let code = "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n"; + let report = simulate(&input, code); + assert_eq!(report.finished_dimensions, Status::Fail); + assert!(report.findings.iter().any(|f| f.kind == "finished_region_error")); + } + #[test] + fn full_diameter_blind_and_through_depths_are_certified() { + for bottom in [4.5, 0.] { + let mut input = corpus::fixture("drill").unwrap(); + input.operations[0].bottom = bottom; + input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.]], peck: None, full_diameter: true }; + for tri in &mut input.geometry.target { + for p in tri { + p.z = 0.; + } + } + let depth = bottom - input.tools[0].tip_length(); + let report = simulate(&input, &program(1, 10., depth)); + assert_eq!(report.status, Status::Pass, "{:?}", report.findings); + assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings); + } + } + #[test] + fn declared_drill_allowance_is_rejected_instead_of_ignored() { + let mut input = corpus::fixture("drill").unwrap(); + input.operations[0].allowance = 0.1; + assert!(input.validate().is_err()); + } + #[test] + fn facing_and_multiple_holes_compose_without_restoring_stock() { + let mut input = corpus::fixture("drill").unwrap(); + for t in &mut input.geometry.target { + for p in t { + p.z = 0.0; + } + } + input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.], [12., 10.]], peck: Some(0.3), full_diameter: false }; + let mut face = corpus::fixture("face").unwrap(); + face.tools[0].id = 2; + face.tools[0].number = 2; + face.operations[0].id = 2; + face.operations[0].tool = 2; + face.operations[0].bottom = 5.8; + input.tools.push(face.tools[0].clone()); + input.operations.push(face.operations[0].clone()); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap(); + let report = simulate(&input, &code); + assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings); + } + #[test] + fn dimensional_work_yields_and_cancels() { + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + let mut f = finished(); + f.features = (0..5000).map(|_| Feature::Plane(5.)).collect(); + let cancel = std::cell::Cell::new(false); + let mut yield_once = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut work = 0; + let cancelled = || cancel.get(); + let mut future = std::pin::pin!(f.envelope(([10., 10.], 0.001), &mut work, 10000, &cancelled, &mut yield_once)); + let mut context = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut context).is_pending()); + cancel.set(true); + assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_)))); + } + #[test] + fn equivalent_inch_and_mm_holes_certify_the_same_dimensions() { + let input = corpus::fixture("drill").unwrap(); + let inch = format!( + "G20 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z{}\nT1 M6\nS6000 M3\nM9\nG0 X{} Y{} Z{}\nG1 Z{} F{}\nG1 Z{}\nG0 Z{}\nM5\nM9\nM30\n", + 15. / 25.4, + 10. / 25.4, + 10. / 25.4, + 15. / 25.4, + 6. / 25.4, + 100. / 25.4, + 5. / 25.4, + 15. / 25.4 + ); + let metric = simulate(&input, &program(1, 10., 5.)); + let imperial = simulate(&input, &inch); + assert_eq!(imperial.finished_dimensions, Status::Pass); + assert!((imperial.cutting_distance - metric.cutting_distance).abs() < 1e-10); + assert!((imperial.remaining_volume_high - metric.remaining_volume_high).abs() < 1e-8); + } +} diff --git a/crates/cam/src/generate.rs b/crates/cam/src/generate.rs new file mode 100644 index 00000000..13f5a976 --- /dev/null +++ b/crates/cam/src/generate.rs @@ -0,0 +1,680 @@ +//! Toolpath construction. No verifier or NC interpreter algorithms live here. +use crate::*; +use cavalier_contours::polyline::{PlineSource, PlineSourceMut, Polyline, seg_arc_radius_and_center, seg_length}; +use cavalier_contours::shape_algorithms::{Shape, ShapeOffsetOptions}; +use solidcraft_geom::Vec3; + +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll, Waker}, +}; + +/// A portable job advances a bounded emission/validation chunk or one bounded +/// library offset query per poll. No executor, OS thread or browser API is used. +pub struct Generator { + pending: Option> + Send>>>, + path: Option, + progress: Arc, + failed: Option, + cancelled: bool, +} +impl Generator { + pub fn new(input: JobInput) -> Result { + input.validate()?; + if input.operations.is_empty() || input.operations.len() > MAX_ITEMS { + return invalid("empty or excessive operation list"); + } + let progress = Arc::new(AtomicUsize::new(0)); + let pending = Box::pin(generate_incremental(input, progress.clone())); + Ok(Self { pending: Some(pending), path: None, progress, failed: None, cancelled: false }) + } + pub fn cancel(&mut self) { + self.cancelled = true; + self.pending = None; + self.path = None; + } + pub fn progress(&self) -> f64 { + self.progress.load(Ordering::Relaxed) as f64 / 1_000_000. + } + pub fn step(&mut self) -> Result { + if self.cancelled { + return invalid("generation cancelled"); + } + if let Some(error) = &self.failed { + return invalid(error.clone()); + } + if self.path.is_some() { + return Ok(true); + } + let future = self.pending.as_mut().ok_or_else(|| Error("generation has no pending work".into()))?; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| future.as_mut().poll(&mut Context::from_waker(Waker::noop())))); + match outcome { + Ok(Poll::Pending) => Ok(false), + Ok(Poll::Ready(Ok(path))) => { + self.pending = None; + self.path = Some(path); + self.progress.store(1_000_000, Ordering::Relaxed); + Ok(true) + } + result => { + let error = match result { + Ok(Poll::Ready(Err(e))) => e, + _ => Error("contour library failed; no path published".into()), + }; + self.pending = None; + self.failed = Some(error.0.clone()); + Err(error) + } + } + } + pub fn finish(self) -> Result { + if self.cancelled { + return invalid("generation cancelled"); + } + self.path.ok_or_else(|| Error(self.failed.unwrap_or_else(|| "generation incomplete".into()))) + } +} +#[derive(Default)] +struct YieldOnce(bool); +impl Future for YieldOnce { + type Output = (); + fn poll(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> { + if self.0 { + Poll::Ready(()) + } else { + self.0 = true; + Poll::Pending + } + } +} +#[derive(Clone)] +struct Progress { + value: Arc, + operation: usize, + count: usize, +} +impl Progress { + fn set(&self, fraction: f64) { + self.value.fetch_max(((self.operation as f64 + fraction.clamp(0., 1.)) / self.count as f64 * 1_000_000.) as usize, Ordering::Relaxed); + } +} +async fn generate_incremental(input: JobInput, value: Arc) -> Result { + let mut path = Toolpath { fingerprint: input.fingerprint.clone(), blocks: Vec::new() }; + for (index, o) in input.operations.iter().enumerate() { + YieldOnce::default().await; + let progress = Progress { value: value.clone(), operation: index, count: input.operations.len() }; + let blocks = operation(&input, o, progress.clone()).await?; + if path.blocks.len() + blocks.len() > MAX_MOVES { + return invalid("toolpath exceeds motion limit"); + } + path.blocks.extend(blocks); + progress.set(1.); + } + Ok(path) +} +pub fn generate(input: JobInput) -> Result { + let mut g = Generator::new(input)?; + while !g.step()? {} + g.finish() +} +fn tool(i: &JobInput, id: u64) -> Result<&Tool> { + i.tools.iter().find(|t| t.id == id).ok_or_else(|| Error("unknown tool".into())) +} + +struct Writer { + blocks: Vec, + op: u64, + pos: Vec3, + progress: Progress, +} +impl Writer { + async fn push(&mut self, motion: Motion) -> Result<()> { + if self.blocks.len() >= MAX_MOVES { + return invalid("operation exceeds motion limit"); + } + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &motion { + crate::model::point(*to)?; + self.pos = *to; + } + self.blocks.push(Block { operation: self.op, motion }); + if self.blocks.len().is_multiple_of(256) { + self.progress.set(0.3 + 0.65 * self.blocks.len() as f64 / (self.blocks.len() as f64 + 1024.)); + YieldOnce::default().await; + } + Ok(()) + } + async fn rapid(&mut self, p: Vec3) -> Result<()> { + self.push(Motion::Rapid { to: p }).await + } + async fn feed(&mut self, p: Vec3, f: f64) -> Result<()> { + self.push(Motion::Linear { to: p, feed: f }).await + } + async fn enter(&mut self, s: &Setup, p: Vec3, o: &Operation) -> Result<()> { + self.rapid(Vec3::new(self.pos.x, self.pos.y, s.clearance)).await?; + self.rapid(Vec3::new(p.x, p.y, s.clearance)).await?; + self.rapid(Vec3::new(p.x, p.y, s.retract)).await?; + self.feed(p, o.plunge_feed).await + } +} +async fn operation(input: &JobInput, o: &Operation, progress: Progress) -> Result> { + let s = &input.setup; + let t = tool(input, o.tool)?; + let mut w = Writer { blocks: Vec::new(), op: o.id, pos: Vec3::new(0.0, 0.0, s.clearance), progress: progress.clone() }; + w.push(Motion::Tool { tool: t.id, number: t.number }).await?; + w.push(Motion::Spindle { rpm: o.spindle }).await?; + w.push(Motion::Coolant { on: o.coolant }).await?; + let levels = depths(o.top, o.bottom + o.allowance, o.stepdown)?; + match &o.geometry { + Strategy::Face => { + let (a, b) = (s.stock.min, s.stock.max); + let radius = t.diameter / 2.0; + let n = ((b.y - a.y) / o.stepover).ceil() as usize; + if n > 10_000 { + return invalid("facing exceeds pass limit"); + } + for z in levels { + for j in 0..=n { + let y = a.y + (b.y - a.y) * j as f64 / n.max(1) as f64; + // M3/right-handed cutter: climb keeps uncut material on the + // right. Rows advance toward +Y, so climb travels toward -X. + let (x0, x1) = if o.climb { (b.x + radius, a.x - radius) } else { (a.x - radius, b.x + radius) }; + w.enter(s, Vec3::new(x0, y, z), o).await?; + w.feed(Vec3::new(x1, y, z), o.feed).await?; + } + } + } + Strategy::Contour { boundary, inside } => { + if !boundary.islands.is_empty() { + return invalid("contour islands: select each contour as its own operation"); + } + let shape = shape(boundary, progress.clone()).await?; + let d = (t.diameter / 2.0 + o.allowance) * if *inside { 1.0 } else { -1.0 }; + YieldOnce::default().await; + let loops = shape.offset(d); + if loops.ccw_plines.is_empty() { + return invalid("tool cannot fit contour"); + } + for z in levels { + for lp in &loops.ccw_plines { + // Offsets are CCW. Climb is CCW inside a wall and CW outside a boss. + trace(&mut w, s, o, lp, z, o.climb != *inside).await?; + } + } + } + Strategy::Pocket { boundary } => { + let original = shape(boundary, progress.clone()).await?; + let mut loops = Vec::new(); + let mut vertices = 0usize; + let mut distance = t.diameter / 2.0 + o.allowance; + for pass in 0..10_000 { + progress.set(0.1 + 0.15 * pass as f64 / (pass as f64 + 8.)); + YieldOnce::default().await; + // A 1e-6 mm clearance reserve keeps exact offset + // tangencies away from the shape stitcher's degenerate slices. + // It is 1/1000 of the post budget; independent verification still + // certifies the unmodified requested region and allowance. + let offset = original.offset(distance + 1e-6); + if offset.ccw_plines.is_empty() { + break; + } + for lp in offset.ccw_plines.iter().chain(&offset.cw_plines) { + vertices = vertices.checked_add(lp.vertex_count()).ok_or_else(|| Error("pocket vertex budget exhausted".into()))?; + if vertices > MAX_MOVES { + return invalid("pocket vertex budget exhausted"); + } + loops.push(lp.clone()); + } + if loops.len() > 10_000 { + return invalid("pocket exceeds pass limit"); + } + distance += o.stepover; + if pass == 9999 { + return invalid("pocket offset budget exhausted"); + } + } + if loops.is_empty() { + return invalid("tool cannot fit pocket"); + } + // Innermost passes first; independent clearance retracts avoid island crossings. + for z in levels { + for lp in loops.iter().rev() { + // CCW outer loops and CW islands already put protected + // material on the right; reverse both for conventional cuts. + trace(&mut w, s, o, lp, z, !o.climb).await?; + } + } + } + Strategy::Drill { holes, peck, full_diameter } => { + let bottom = o.bottom - if *full_diameter { t.tip_length() } else { 0.0 }; + let zs = depths(o.top, bottom, peck.unwrap_or(o.top - bottom))?; + for p in holes { + w.enter(s, Vec3::new(p[0], p[1], o.top), o).await?; + let mut previous = o.top; + for z in &zs { + if previous != o.top { + w.feed(Vec3::new(p[0], p[1], previous), o.plunge_feed).await?; + } + w.feed(Vec3::new(p[0], p[1], *z), o.plunge_feed).await?; + w.rapid(Vec3::new(p[0], p[1], s.retract)).await?; + previous = *z; + } + } + } + } + w.rapid(Vec3::new(w.pos.x, w.pos.y, s.clearance)).await?; + w.push(Motion::Coolant { on: false }).await?; + w.push(Motion::Spindle { rpm: 0.0 }).await?; + w.push(Motion::Stop).await?; + Ok(w.blocks) +} +fn depths(top: f64, bottom: f64, step: f64) -> Result> { + if bottom >= top { + return invalid("allowance leaves no cutting depth"); + } + let n = ((top - bottom) / step).ceil() as usize; + if n > 10_000 { + return invalid("depth pass limit"); + } + Ok((1..=n).map(|i| (top - step * i as f64).max(bottom)).collect()) +} +async fn trace(w: &mut Writer, s: &Setup, o: &Operation, lp: &Polyline, z: f64, reverse: bool) -> Result<()> { + let mut lp = lp.clone(); + if reverse { + lp.invert_direction_mut(); + } + let start = lp.iter_vertexes().next().ok_or_else(|| Error("empty offset".into()))?; + let entry_z = if o.ramp_angle.is_some() { o.top } else { z }; + let first = Vec3::new(start.x, start.y, entry_z); + let tangent = lp.iter_segments().next().map(|(a, b)| Vec3::new(b.x - a.x, b.y - a.y, 0.0)).and_then(|v| v.normalized()).unwrap_or(Vec3::X); + w.enter(s, first - tangent * o.lead_distance, o).await?; + if o.lead_distance > 0.0 { + w.feed(first, o.feed).await?; + } + if let Some(angle) = o.ramp_angle { + let perimeter = lp.iter_segments().map(|(a, b)| seg_length(a, b)).sum::(); + if perimeter <= 1e-8 { + return invalid("ramp contour has no length"); + } + let laps = ((o.top - z) / (perimeter * angle.tan())).ceil() as usize; + if laps > 1000 { + return invalid("entry ramp exceeds lap budget"); + } + let mut height = o.top; + for _ in 0..laps { + for (a, b) in lp.iter_segments() { + let length = seg_length(a, b); + let bottom = (height - length * angle.tan()).max(z); + if a.bulge.abs() < 1e-12 || length <= POST_ERROR / 4. { + w.feed(Vec3::new(b.x, b.y, bottom), o.plunge_feed).await?; + } else { + let (radius, c) = seg_arc_radius_and_center(a, b); + let sweep = 4.0 * a.bulge.atan(); + let start = (a.y - c.y).atan2(a.x - c.x); + let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6); + let n = (sweep.abs() / step).ceil() as usize; + if n > MAX_MOVES { + return invalid("ramp arc exceeds motion budget"); + } + for k in 1..=n { + let t = k as f64 / n as f64; + let theta = start + sweep * t; + w.feed(Vec3::new(c.x + radius * theta.cos(), c.y + radius * theta.sin(), height + (bottom - height) * t), o.plunge_feed) + .await?; + } + } + height = bottom; + } + } + } + for (a, b) in lp.iter_segments() { + let to = Vec3::new(b.x, b.y, z); + // Offset topology may contain a vanishing arc at a tangency. Preserve + // its short motion as a line: rounded coincident G2/G3 endpoints would + // otherwise command a full circle. Its entire arc length bounds error. + if a.bulge.abs() < 1e-12 || seg_length(a, b) <= POST_ERROR / 4. { + w.feed(to, o.feed).await?; + } else { + let (_, c) = seg_arc_radius_and_center(a, b); + w.push(Motion::Arc { to, center: Vec3::new(c.x, c.y, z), clockwise: a.bulge < 0.0, feed: o.feed }).await?; + } + } + if o.lead_distance > 0.0 { + w.feed(Vec3::new(start.x, start.y, z) - tangent * o.lead_distance, o.feed).await?; + } + Ok(()) +} +// Work in setup millimetres with predicate/stitch tolerances comfortably below +// the post budget. Upstream defaults can merge distinct small offset slices. +struct OffsetShape { + shape: Shape, + scale: f64, +} +struct OffsetLoops { + ccw_plines: Vec>, + cw_plines: Vec>, +} +impl OffsetShape { + fn offset(&self, distance: f64) -> OffsetLoops { + let scale = self.scale; + let options = ShapeOffsetOptions { pos_equal_eps: 1e-8 * scale, offset_dist_eps: 1e-7 * scale, slice_join_eps: 1e-8 * scale }; + let result = self.shape.parallel_offset(distance * scale, &options); + let restore = |p: cavalier_contours::shape_algorithms::IndexedPolyline| { + let mut polyline = p.polyline; + polyline.scale_mut(1. / scale); + polyline + }; + OffsetLoops { + ccw_plines: result.ccw_plines.into_iter().map(restore).collect(), + cw_plines: result.cw_plines.into_iter().map(restore).collect(), + } + } +} +async fn shape(b: &Boundary, progress: Progress) -> Result { + b.validate()?; + // Reject all crossings/touches, not just those the offset library happens to tolerate. + let rings: Vec<_> = std::iter::once(&b.outer).chain(&b.islands).collect(); + let mut edges = Vec::new(); + let mut extent = 1_f64; + let mut shortest = f64::MAX; + for (ring, points) in rings.iter().enumerate() { + for vertex in 0..points.len() { + if edges.len().is_multiple_of(256) { + YieldOnce::default().await; + } + let a = points[vertex]; + let b = points[(vertex + 1) % points.len()]; + let c = points[(vertex + 2) % points.len()]; + if distance(a, b) < 1e-7 { + return invalid("zero-length boundary edge"); + } + shortest = shortest.min(distance(a, b)); + // Adjacent edges share their endpoint, but must not double back. + if robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] }) == 0. + && (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0. + { + return invalid("backtracking boundary edge"); + } + extent = extent.max(a[0].abs()).max(a[1].abs()); + edges.push((ring, vertex, a, b)); + } + } + YieldOnce::default().await; + let index = crate::spatial::Index::new( + edges.iter().enumerate().map(|(id, (_, _, a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(), + ); + // Include every pair admitted by the existing near-touch predicate. The + // numerical reserve also covers rounding when expanding coordinate bounds. + let padding = 1e-8 + 128. * f64::EPSILON * extent; + let mut comparisons = 0usize; + let mut candidates = Vec::new(); + for (id, &(ring, vertex, a, b)) in edges.iter().enumerate() { + if id.is_multiple_of(128) { + progress.set(0.1 * id as f64 / edges.len() as f64); + YieldOnce::default().await; + } + candidates.clear(); + index.query([a[0].min(b[0]) - padding, a[1].min(b[1]) - padding], [a[0].max(b[0]) + padding, a[1].max(b[1]) + padding], &mut candidates); + for other in candidates.iter().copied().filter(|other| *other > id) { + let (other_ring, other_vertex, c, d) = edges[other]; + let count = rings[ring].len(); + if ring == other_ring && ((vertex + 1) % count == other_vertex || (other_vertex + 1) % count == vertex) { + continue; + } + comparisons += 1; + if comparisons.is_multiple_of(4096) { + YieldOnce::default().await; + } + if comparisons > 2_000_000 { + return invalid("boundary validation budget exceeded"); + } + if intersects(a, b, c, d) { + return invalid("boundary intersections or touching loops"); + } + } + } + for (i, hole) in b.islands.iter().enumerate() { + YieldOnce::default().await; + if !inside(hole[0], &b.outer) || b.islands.iter().enumerate().any(|(j, h)| i != j && inside(hole[0], h)) { + return invalid("islands must be disjoint and inside the outer boundary"); + } + } + // Cavalier also applies its position epsilon to line cross products. Small + // segments can otherwise be classified as parallel (including a 10k-sided + // circle). Normalize the shortest input edge to at least one library unit. + // Powers of two preserve finite input coordinates exactly; no vertices are + // removed. Scale distance tolerances too, retaining their physical meaning. + let scale = 2_f64.powi((-shortest.log2()).ceil().max(0.) as i32); + let mut plines = Vec::new(); + for (i, ring) in rings.iter().enumerate() { + YieldOnce::default().await; + let mut p = Polyline::new_closed(); + for xy in ring.iter() { + p.add(xy[0], xy[1], 0.0); + } + if p.area().abs() < 1e-8 { + return invalid("zero-area boundary"); + } + if (p.area() > 0.0) != (i == 0) { + p.invert_direction_mut(); + } + p.scale_mut(scale); + plines.push(p); + } + Ok(OffsetShape { shape: Shape::from_plines(plines), scale }) +} +fn distance(a: [f64; 2], b: [f64; 2]) -> f64 { + (a[0] - b[0]).hypot(a[1] - b[1]) +} +fn cross(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 { + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) +} +fn intersects(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool { + let eps = 1e-8; + let overlap = (0..2).all(|k| a[k].min(b[k]) <= c[k].max(d[k]) + eps && c[k].min(d[k]) <= a[k].max(b[k]) + eps); + overlap && cross(a, b, c) * cross(a, b, d) <= eps && cross(c, d, a) * cross(c, d, b) <= eps +} +fn inside(p: [f64; 2], ring: &[[f64; 2]]) -> bool { + let mut yes = false; + for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()) { + if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] { + yes = !yes; + } + } + yes +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod incremental_tests { + use super::*; + fn polar(count: usize, lobes: usize) -> Vec<[f64; 2]> { + (0..count) + .map(|i| { + let angle = i as f64 * std::f64::consts::TAU / count as f64; + let radius = if lobes == 0 { 8. } else { 8. - 0.6 * (1. + (lobes as f64 * angle).cos()) }; + [10. + radius * angle.cos(), 10. + radius * angle.sin()] + }) + .collect() + } + fn validate_shape(boundary: &Boundary) -> Result { + let progress = Progress { value: Arc::new(AtomicUsize::new(0)), operation: 0, count: 1 }; + let mut future = std::pin::pin!(shape(boundary, progress)); + for _ in 0..10_000 { + if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + return result; + } + } + invalid("test shape did not complete within checkpoint bound") + } + #[test] + fn dense_simple_boundaries_and_one_hundred_islands_fit_validation_budget() { + for lobes in [0, 7] { + let mut boundary = Boundary { outer: polar(10_000, lobes), islands: vec![] }; + for _ in 0..2 { + let validated = validate_shape(&boundary).unwrap(); + assert_eq!(validated.shape.ccw_plines.len(), 1); + assert!(validated.shape.cw_plines.is_empty()); + boundary.outer.reverse(); + } + let mut input = corpus::fixture("contour").unwrap(); + input.operations[0].geometry = Strategy::Contour { boundary, inside: true }; + let path = generate(input).unwrap(); + assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Linear { .. } | Motion::Arc { .. }))); + } + let islands = (0..100) + .map(|n| { + (0..99) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 99.; + [5. + (n % 10) as f64 * 10. + a.cos(), 5. + (n / 10) as f64 * 10. + a.sin()] + }) + .collect() + }) + .collect(); + let boundary = Boundary { outer: vec![[0., 0.], [100., 0.], [100., 100.], [0., 100.]], islands }; + let validated = validate_shape(&boundary).unwrap(); + assert_eq!((validated.shape.ccw_plines.len(), validated.shape.cw_plines.len()), (1, 100)); + let offset = validated.offset(1.); + assert_eq!((offset.ccw_plines.len(), offset.cw_plines.len()), (1, 100)); + } + #[test] + fn normalized_offsets_preserve_dense_polygon_distance_and_coordinates() { + // Independent regular-polygon construction: inward offset preserves the + // apothem minus the cutter radius. Check every resulting vertex, not area. + for center in [[10., 10.], [-10_000., 20_000.]] { + for factor in [1., 0.125] { + let points = polar(10_000, 0).into_iter().map(|p| [center[0] + factor * (p[0] - 10.), center[1] + factor * (p[1] - 10.)]).collect(); + let boundary = Boundary { outer: points, islands: vec![] }; + let offset = validate_shape(&boundary).unwrap().offset(3. * factor); + assert_eq!(offset.ccw_plines.len(), 1); + assert!(offset.cw_plines.is_empty()); + let expected = factor * (8. - 3. / (std::f64::consts::PI / 10_000.).cos()); + for p in offset.ccw_plines[0].iter_vertexes() { + assert!(((p.x - center[0]).hypot(p.y - center[1]) - expected).abs() < 1e-7); + } + } + } + // Concave case: sample output vertices against all ORIGINAL input + // segments using an independently written point/segment projection. + let boundary = Boundary { outer: polar(10_000, 7), islands: vec![] }; + let offset = validate_shape(&boundary).unwrap().offset(3.); + assert_eq!(offset.ccw_plines.len(), 1); + for p in offset.ccw_plines[0].iter_vertexes().step_by(64) { + let nearest = boundary + .outer + .iter() + .zip(boundary.outer.iter().cycle().skip(1)) + .map(|(a, b)| { + let dx = b[0] - a[0]; + let dy = b[1] - a[1]; + let t = (((p.x - a[0]) * dx + (p.y - a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.); + (p.x - a[0] - t * dx).hypot(p.y - a[1] - t * dy) + }) + .fold(f64::INFINITY, f64::min); + assert!((nearest - 3.).abs() < 1e-7, "offset distance {nearest}"); + } + } + #[test] + fn indexed_validation_rejects_crossings_touches_and_backtracking() { + let square = |min: [f64; 2], max: [f64; 2]| vec![min, [max[0], min[1]], max, [min[0], max[1]]]; + for boundary in [ + Boundary { outer: vec![[0., 0.], [3., 3.], [0., 3.], [3., 0.]], islands: vec![] }, + Boundary { outer: vec![[0., 0.], [3., 0.], [1., 0.], [3., 3.], [0., 3.]], islands: vec![] }, + Boundary { outer: vec![[0., 0.], [3., 0.], [3., 0.], [0., 3.]], islands: vec![] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([0., 1.], [2., 3.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([5e-9, 1.], [2., 3.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [4., 4.]), square([4., 2.], [6., 4.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [8., 8.]), square([3., 3.], [4., 4.])] }, + Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([12., 2.], [14., 4.])] }, + ] { + assert!(validate_shape(&boundary).is_err(), "{boundary:?}"); + } + // A non-adjacent touch hidden among thousands of otherwise valid edges. + let mut boundary = Boundary { outer: polar(10_000, 0), islands: vec![] }; + boundary.outer[5000] = boundary.outer[0]; + assert!(validate_shape(&boundary).is_err()); + } + #[test] + fn cancellation_during_dense_boundary_construction_and_queries_publishes_nothing() { + for checkpoint in [1, 20, 80, 120] { + let mut input = corpus::fixture("contour").unwrap(); + input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: polar(10_000, 7), islands: vec![] }, inside: true }; + let mut generator = Generator::new(input).unwrap(); + for _ in 0..checkpoint { + assert!(!generator.step().unwrap()); + } + generator.cancel(); + assert!(generator.step().is_err()); + assert!(generator.finish().is_err()); + } + } + #[test] + fn large_single_operations_yield_and_remain_deterministic() { + for name in ["face", "contour", "pocket", "drill"] { + let mut input = corpus::fixture(name).unwrap(); + input.operations[0].stepdown = 0.02; + if name == "drill" { + input.operations[0].geometry = Strategy::Drill { + holes: (0..300).map(|n| [1. + (n % 18) as f64, 1. + (n / 18) as f64]).collect(), + peck: Some(0.05), + full_diameter: false, + }; + } + let mut generator = Generator::new(input.clone()).unwrap(); + let mut polls = 0; + let mut previous = 0.; + let mut intermediate = false; + while !generator.step().unwrap() { + polls += 1; + let progress = generator.progress(); + assert!(progress >= previous && progress < 1.); + intermediate |= progress > 0.; + previous = progress; + assert!(polls < 100_000); + } + assert!(polls > 10 && intermediate, "{name}: {polls}"); + assert_eq!(generator.progress(), 1.); + let path = generator.finish().unwrap(); + assert_eq!(serde_json::to_value(path).unwrap(), serde_json::to_value(generate(input).unwrap()).unwrap()); + } + } + #[test] + fn cancellation_during_one_operation_discards_partial_output() { + let mut input = corpus::fixture("face").unwrap(); + input.operations[0].stepdown = 0.001; + let mut generator = Generator::new(input).unwrap(); + for _ in 0..4 { + assert!(!generator.step().unwrap()); + } + assert!(generator.progress() > 0. && generator.progress() < 1.); + generator.cancel(); + assert!(generator.step().is_err()); + assert!(generator.finish().is_err()); + } + #[test] + fn incomplete_and_failed_jobs_never_publish_partial_paths() { + let mut generator = Generator::new(corpus::fixture("face").unwrap()).unwrap(); + assert!(!generator.step().unwrap()); + assert!(generator.finish().is_err()); + let mut input = corpus::fixture("face").unwrap(); + let mut bad = input.operations[0].clone(); + bad.id = 2; + bad.allowance = 2.; + input.operations.push(bad); + let mut generator = Generator::new(input).unwrap(); + let failed = loop { + match generator.step() { + Ok(false) => {} + result => break result, + } + }; + assert!(failed.is_err()); + assert!(generator.step().is_err()); + assert!(generator.finish().is_err()); + } +} diff --git a/crates/cam/src/hashing.rs b/crates/cam/src/hashing.rs new file mode 100644 index 00000000..1a6f528d --- /dev/null +++ b/crates/cam/src/hashing.rs @@ -0,0 +1,150 @@ +//! Incremental encoding of the existing snapshot fingerprint, without a full JSON buffer. +use crate::{Error, JobInput, Result, VERSION, invalid}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +struct Encoder(Sha256); +impl Encoder { + fn bytes(&mut self, bytes: &[u8]) { + self.0.update(bytes); + } + fn value(&mut self, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec(value).map_err(|e| Error(e.to_string()))?; + self.bytes(&bytes); + Ok(()) + } + async fn array>( + &mut self, + values: &[T], + batch: usize, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result<()> { + self.bytes(b"["); + for (index, chunk) in values.chunks(batch).enumerate() { + checkpoint(cancelled, yield_control).await?; + if index != 0 { + self.bytes(b","); + } + // Serialize each bounded slice using Serde's own number/string rules. + // Only its enclosing brackets are omitted when joining array chunks. + let bytes = serde_json::to_vec(chunk).map_err(|e| Error(e.to_string()))?; + let contents = bytes.get(1..bytes.len().saturating_sub(1)).ok_or_else(|| Error("invalid fingerprint array encoding".into()))?; + self.bytes(contents); + } + self.bytes(b"]"); + Ok(()) + } +} +async fn checkpoint>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> { + if cancelled() { + return invalid("fingerprint cancelled"); + } + yield_control().await; + if cancelled() { invalid("fingerprint cancelled") } else { Ok(()) } +} + +/// Hash a validated immutable job using exactly the bytes of `fingerprint(input)`. +/// Yields before each operation/tool and every 256 triangles. Encoding one setup +/// or operation remains atomic; this is not a hard elapsed-time guarantee. +/// Cancellation returns no digest and never mutates the input. +pub async fn fingerprint_job_cooperative>( + input: &JobInput, + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + checkpoint(&cancelled, &mut yield_control).await?; + let mut encoder = Encoder(Sha256::new()); + encoder.bytes(b"["); + encoder.value(&VERSION)?; + // Field order deliberately matches the derived JobInput/Geometry encoders. + // Equality regressions below make schema additions or reorderings visible. + encoder.bytes(b",{\"setup\":"); + encoder.value(&input.setup)?; + encoder.bytes(b",\"tools\":"); + encoder.array(&input.tools, 1, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"operations\":"); + encoder.array(&input.operations, 1, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"geometry\":{\"target\":"); + encoder.array(&input.geometry.target, 256, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"fixtures\":"); + encoder.array(&input.geometry.fixtures, 256, &cancelled, &mut yield_control).await?; + encoder.bytes(b",\"fixture_groups\":"); + encoder.array(&input.geometry.fixture_groups, 256, &cancelled, &mut yield_control).await?; + encoder.bytes(b"},\"fingerprint\":"); + encoder.value(&input.fingerprint)?; + encoder.bytes(b"}]"); + checkpoint(&cancelled, &mut yield_control).await?; + Ok(format!("{:x}", encoder.0.finalize())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::{ + cell::Cell, + future::Future, + task::{Context, Poll, Waker}, + }; + + fn ready(future: impl Future) -> T { + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(value) => value, + Poll::Pending => panic!("test checkpoint unexpectedly suspended"), + } + } + #[test] + fn cooperative_encoding_preserves_existing_snapshot_fingerprints() { + for name in crate::corpus::CASES { + let mut input = crate::corpus::fixture(name).unwrap(); + input.setup.name = "Escaped \"setup\" \\ \nμ".into(); + input.fingerprint = "Existing digest remains part of the serialized input".into(); + let expected = crate::fingerprint(&input).unwrap(); + let count = Cell::new(0); + let actual = ready(fingerprint_job_cooperative( + &input, + || false, + || { + count.set(count.get() + 1); + std::future::ready(()) + }, + )) + .unwrap(); + assert_eq!(actual, expected, "{name}"); + assert!(count.get() >= 4); + } + } + #[test] + fn large_snapshot_hashing_yields_and_cancellation_never_returns_a_digest() { + let mut input = crate::corpus::fixture("face").unwrap(); + input.geometry.target.resize(200_000, input.geometry.target[0]); + let expected = crate::fingerprint(&input).unwrap(); + let count = Cell::new(0); + let actual = ready(fingerprint_job_cooperative( + &input, + || false, + || { + count.set(count.get() + 1); + std::future::ready(()) + }, + )) + .unwrap(); + assert_eq!(actual, expected); + assert!(count.get() > 780); + for stop in [0, 1, 400, count.get()] { + let checkpoints = Cell::new(0); + let result = ready(fingerprint_job_cooperative( + &input, + || checkpoints.get() >= stop, + || { + checkpoints.set(checkpoints.get() + 1); + std::future::ready(()) + }, + )); + assert!(result.is_err(), "cancel at checkpoint {stop}"); + } + assert_eq!(crate::fingerprint(&input).unwrap(), expected); + } +} diff --git a/crates/cam/src/lib.rs b/crates/cam/src/lib.rs new file mode 100644 index 00000000..d2052fdf --- /dev/null +++ b/crates/cam/src/lib.rs @@ -0,0 +1,51 @@ +//! Milling definitions, deterministic toolpaths and independent NC verification. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod collision; +pub mod compare; +pub mod corpus; +mod coverage; +mod dimensions; +mod hashing; +pub use hashing::fingerprint_job_cooperative; +pub mod generate; +mod model; +pub mod nc; +pub mod playback; +mod region; +mod spatial; +mod stock_mesh; +mod surface; +pub mod verify; +pub use model::*; + +#[derive(Debug, thiserror::Error)] +#[error("CAM: {0}")] +pub struct Error(pub String); +pub type Result = std::result::Result; +pub(crate) fn invalid(message: impl Into) -> Result { + Err(Error(message.into())) +} + +pub const VERSION: &str = "cam/18"; +pub const TOLERANCE: f64 = 0.01; +pub const POST_ERROR: f64 = 0.001; +pub const VERIFY_ERROR: f64 = 0.0025; +/// Maximum upward height change for closing tiny saddle contacts in stock exports. +/// Verification intervals are unchanged; exported meshes require independent comparison. +pub const STOCK_EXPORT_ERROR: f64 = POST_ERROR / 100.; +pub const MAX_MOVES: usize = 200_000; +pub const MAX_ITEMS: usize = 1000; + +/// Maximum chord error when resolving an associated CAD profile. The independent +/// dimensional envelope reserves this in addition to post/interpolation error. +pub fn profile_tolerance(tolerance: f64) -> f64 { + (tolerance / 16.).min(POST_ERROR / 2.) +} + +pub fn fingerprint(value: &T) -> Result { + use sha2::{Digest, Sha256}; + let data = serde_json::to_vec(&(VERSION, value)).map_err(|e| Error(e.to_string()))?; + Ok(format!("{:x}", Sha256::digest(data))) +} diff --git a/crates/cam/src/model.rs b/crates/cam/src/model.rs new file mode 100644 index 00000000..240aa3a7 --- /dev/null +++ b/crates/cam/src/model.rs @@ -0,0 +1,523 @@ +use crate::{MAX_ITEMS, Result, TOLERANCE, invalid}; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manufacturing { + #[serde(default)] + pub setups: Vec, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub operations: Vec, + #[serde(default)] + pub programs: Vec, +} +impl Manufacturing { + pub fn is_empty(&self) -> bool { + self.setups.is_empty() && self.tools.is_empty() && self.operations.is_empty() && self.programs.is_empty() + } + pub fn validate(&self) -> Result<()> { + for ids in [ + self.setups.iter().map(|x| x.id).collect::>(), + self.tools.iter().map(|x| x.id).collect(), + self.operations.iter().map(|x| x.id).collect(), + self.programs.iter().map(|x| x.id).collect(), + ] { + if ids.len() > MAX_ITEMS || ids.contains(&0) || ids.iter().collect::>().len() != ids.len() { + return invalid("too many items or duplicate/zero IDs"); + } + } + for s in &self.setups { + s.validate()?; + } + for t in &self.tools { + t.validate()?; + } + for o in &self.operations { + let s = self.setups.iter().find(|s| s.id == o.setup).ok_or_else(|| crate::Error("missing setup".into()))?; + let t = self.tools.iter().find(|t| t.id == o.tool).ok_or_else(|| crate::Error("missing tool".into()))?; + o.validate(s, t)?; + } + for p in &self.programs { + name(&p.name)?; + if p.operations.is_empty() + || p.operations.len() > MAX_ITEMS + || p.operations.iter().collect::>().len() != p.operations.len() + { + return invalid("program needs unique ordered operations"); + } + for id in &p.operations { + if !self.operations.iter().any(|o| o.id == *id && o.setup == p.setup) { + return invalid("program operation is missing or belongs to another setup"); + } + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BodyRef { + pub body: String, + #[serde(default)] + pub occurrence: Vec, +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Frame { + pub origin: Vec3, + pub x: Vec3, + pub y: Vec3, + pub z: Vec3, +} +impl Default for Frame { + fn default() -> Self { + Self { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y, z: Vec3::Z } + } +} +impl Frame { + pub fn validate(&self) -> Result<()> { + for v in [self.origin, self.x, self.y, self.z] { + point(v)?; + } + if (self.x.dot(self.x) - 1.0).abs() > 1e-8 + || (self.y.dot(self.y) - 1.0).abs() > 1e-8 + || (self.z.dot(self.z) - 1.0).abs() > 1e-8 + || self.x.dot(self.y).abs() > 1e-8 + || self.x.cross(self.y).dist(self.z) > 1e-8 + { + return invalid("setup frame must be right-handed and orthonormal"); + } + Ok(()) + } + pub fn local(&self, p: Vec3) -> Vec3 { + let p = p - self.origin; + Vec3::new(p.dot(self.x), p.dot(self.y), p.dot(self.z)) + } + pub fn world(&self, p: Vec3) -> Vec3 { + self.origin + self.x * p.x + self.y * p.y + self.z * p.z + } +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Stock { + pub min: Vec3, + pub max: Vec3, +} +impl Stock { + pub fn validate(&self) -> Result<()> { + point(self.min)?; + point(self.max)?; + if self.min.x >= self.max.x || self.min.y >= self.max.y || self.min.z >= self.max.z { + return invalid("stock bounds must have positive size"); + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Setup { + pub id: u64, + pub name: String, + pub stock: Stock, + #[serde(default)] + pub frame: Frame, + pub models: Vec, + #[serde(default)] + pub fixtures: Vec, + pub clearance: f64, + pub retract: f64, + #[serde(default = "work_offset")] + pub work_offset: u8, +} +fn work_offset() -> u8 { + 54 +} +impl Setup { + pub fn validate(&self) -> Result<()> { + name(&self.name)?; + self.stock.validate()?; + self.frame.validate()?; + if self.models.is_empty() || self.models.len() + self.fixtures.len() > MAX_ITEMS { + return invalid("setup needs model references (at most 1000)"); + } + for r in self.models.iter().chain(&self.fixtures) { + name(&r.body)?; + if r.occurrence.len() > 64 { + return invalid("occurrence path too deep"); + } + } + if !finite(self.clearance) + || !finite(self.retract) + || self.clearance < self.retract + || self.retract <= self.stock.max.z + || !(54..=59).contains(&self.work_offset) + { + return invalid("clearance >= retract > stock top; work offset must be G54–G59"); + } + Ok(()) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolKind { + FlatEndMill, + TwistDrill, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Tool { + pub id: u64, + pub name: String, + pub number: u32, + pub kind: ToolKind, + pub diameter: f64, + pub cutting_length: f64, + pub shank_diameter: f64, + pub overall_length: f64, + pub holder_diameter: f64, + pub holder_length: f64, + #[serde(default = "drill_angle")] + pub tip_angle: f64, +} +fn drill_angle() -> f64 { + 118.0_f64.to_radians() +} +impl Tool { + pub fn validate(&self) -> Result<()> { + name(&self.name)?; + if self.number == 0 + || self.number > 9999 + || [self.diameter, self.cutting_length, self.shank_diameter, self.overall_length, self.holder_diameter, self.holder_length] + .iter() + .any(|v| !positive(*v)) + || self.overall_length < self.cutting_length + || !self.tip_angle.is_finite() + || !(0.1..3.1).contains(&self.tip_angle) + { + return invalid("invalid tool dimensions, number or drill angle (radians)"); + } + Ok(()) + } + pub fn tip_length(&self) -> f64 { + if self.kind == ToolKind::TwistDrill { self.diameter * 0.5 / (self.tip_angle * 0.5).tan() } else { 0.0 } + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Boundary { + pub outer: Vec<[f64; 2]>, + #[serde(default)] + pub islands: Vec>, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileRef { + pub sketch: u64, + pub curves: Vec, + #[serde(default)] + pub occurrence: Vec, +} +/// Document-local identities only. The engine resolves these into setup XY centers. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HoleSource { + Face { target: BodyRef, name: String }, + Profile { profile: ProfileRef }, +} +impl HoleSource { + pub fn validate(&self) -> Result<()> { + match self { + Self::Face { target, name: face } => { + name(&target.body)?; + name(face)?; + if target.occurrence.len() > 64 { + return invalid("hole occurrence path too deep"); + } + } + Self::Profile { profile } => { + if profile.sketch == 0 || profile.curves.is_empty() || profile.curves.len() > 10_000 || profile.occurrence.len() > 64 { + return invalid("invalid circular profile reference"); + } + for curve in &profile.curves { + name(curve)?; + } + } + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "strategy", rename_all = "snake_case", deny_unknown_fields)] +pub enum Strategy { + Face, + Contour { + boundary: Boundary, + inside: bool, + }, + Pocket { + boundary: Boundary, + }, + Drill { + holes: Vec<[f64; 2]>, + #[serde(default)] + peck: Option, + #[serde(default)] + full_diameter: bool, + }, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Operation { + pub id: u64, + pub name: String, + pub setup: u64, + pub tool: u64, + pub geometry: Strategy, + #[serde(default)] + pub source: Option, + /// When nonempty, replaces every preview XY center during snapshot preparation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hole_sources: Vec, + pub top: f64, + pub bottom: f64, + pub stepdown: f64, + pub stepover: f64, + pub feed: f64, + pub plunge_feed: f64, + pub spindle: f64, + #[serde(default)] + pub allowance: f64, + #[serde(default = "yes")] + pub climb: bool, + #[serde(default = "tolerance")] + pub tolerance: f64, + #[serde(default)] + pub coolant: bool, + /// Optional entry ramp angle in radians; None uses a center-cutting plunge. + #[serde(default)] + pub ramp_angle: Option, + #[serde(default)] + pub lead_distance: f64, +} +fn yes() -> bool { + true +} +fn tolerance() -> f64 { + TOLERANCE +} +impl Operation { + pub fn validate(&self, s: &Setup, t: &Tool) -> Result<()> { + name(&self.name)?; + if !finite(self.lead_distance) + || self.lead_distance < 0.0 + || self.ramp_angle.is_some_and(|a| !a.is_finite() || !(0.001..=0.174533).contains(&a)) + { + return invalid("lead must be nonnegative; ramp angle must be 0.06–10 degrees"); + } + if (self.ramp_angle.is_some() || self.lead_distance > 0.0) && matches!(self.geometry, Strategy::Face | Strategy::Drill { .. }) { + return invalid("lead/ramp entry is supported for contour and pocket operations"); + } + if let Some(source) = &self.source { + if source.sketch == 0 + || source.curves.is_empty() + || source.curves.len() > 10_000 + || source.occurrence.len() > 64 + || !matches!(self.geometry, Strategy::Contour { .. } | Strategy::Pocket { .. }) + { + return invalid("profile association requires a bounded contour/pocket reference"); + } + for c in &source.curves { + name(c)?; + } + } + if !self.hole_sources.is_empty() { + let Strategy::Drill { holes, .. } = &self.geometry else { + return invalid("hole references require drilling"); + }; + if self.hole_sources.len() > MAX_ITEMS || self.hole_sources.len() != holes.len() { + return invalid("each linked hole needs exactly one source reference"); + } + for (index, source) in self.hole_sources.iter().enumerate() { + source.validate()?; + if self.hole_sources.iter().take(index).any(|old| old == source) { + return invalid("duplicate hole reference"); + } + } + } + if [self.stepdown, self.stepover, self.feed, self.plunge_feed, self.spindle, self.tolerance].iter().any(|v| !positive(*v)) + || !finite(self.top) + || !finite(self.bottom) + || !finite(self.allowance) + || self.allowance < 0.0 + || self.bottom >= self.top + || self.top > s.stock.max.z + || self.top < s.stock.min.z + || self.stepover > t.diameter + || self.top - self.bottom + t.tip_length() > t.cutting_length + || (self.top - self.bottom) / self.stepdown > 10_000.0 + || self.tolerance < 1e-5 + || self.tolerance > 1.0 + { + return invalid("invalid heights, feeds, step sizes, tolerance or cutting reach"); + } + match &self.geometry { + Strategy::Drill { holes, peck, .. } => { + if t.kind != ToolKind::TwistDrill + || self.allowance != 0.0 + || holes.is_empty() + || holes.len() > MAX_ITEMS + || peck.is_some_and(|v| !positive(v) || (self.top - self.bottom) / v > 10_000.0) + { + return invalid( + "drilling needs a drill, bounded holes, positive peck and zero stock allowance; set the required depth explicitly", + ); + } + for p in holes { + point(Vec3::new(p[0], p[1], 0.0))?; + } + } + Strategy::Contour { boundary, .. } | Strategy::Pocket { boundary } => { + if t.kind != ToolKind::FlatEndMill { + return invalid("milling needs a flat end mill"); + } + boundary.validate()?; + } + Strategy::Face => { + if t.kind != ToolKind::FlatEndMill { + return invalid("facing needs a flat end mill"); + } + } + } + Ok(()) + } +} +impl Boundary { + pub fn validate(&self) -> Result<()> { + if self.islands.len() > 100 { + return invalid("too many islands"); + } + let mut total = 0; + for ring in std::iter::once(&self.outer).chain(&self.islands) { + total += ring.len(); + if ring.len() < 3 || total > 10_000 { + return invalid("boundary needs 3–10000 vertices"); + } + for p in ring { + point(Vec3::new(p[0], p[1], 0.0))?; + } + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Program { + pub id: u64, + pub name: String, + pub setup: u64, + pub operations: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Geometry { + pub target: Vec<[Vec3; 3]>, + pub fixtures: Vec<[Vec3; 3]>, + /// Triangle counts for consecutive fixture bodies. Shells of one body share + /// a winding sum so enclosed cavities remain empty. Legacy snapshots contain + /// one triangle soup; an empty list treats that soup as one body. + #[serde(default)] + pub fixture_groups: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct JobInput { + pub setup: Setup, + pub tools: Vec, + pub operations: Vec, + pub geometry: Geometry, + pub fingerprint: String, +} +impl JobInput { + pub fn validate(&self) -> Result<()> { + let manufacturing = Manufacturing { + setups: vec![self.setup.clone()], + tools: self.tools.clone(), + operations: self.operations.clone(), + programs: vec![Program { + id: 1, + name: "Snapshot".into(), + setup: self.setup.id, + operations: self.operations.iter().map(|o| o.id).collect(), + }], + }; + manufacturing.validate()?; + if self.tools.iter().map(|t| t.number).collect::>().len() != self.tools.len() { + return invalid("ambiguous tool numbers"); + } + if self.geometry.target.is_empty() || self.geometry.target.len() + self.geometry.fixtures.len() > 200_000 { + return invalid("empty or excessive verification geometry"); + } + if !self.geometry.fixture_groups.is_empty() + && (self.geometry.fixture_groups.len() > MAX_ITEMS + || self.geometry.fixture_groups.contains(&0) + || self.geometry.fixture_groups.iter().try_fold(0usize, |sum, n| sum.checked_add(*n)) != Some(self.geometry.fixtures.len())) + { + return invalid("invalid fixture body triangle groups"); + } + for triangle in self.geometry.target.iter().chain(&self.geometry.fixtures) { + for p in triangle { + point(*p)?; + } + } + for p in self.geometry.target.iter().flatten() { + let a = self.setup.stock.min; + let b = self.setup.stock.max; + if p.x < a.x - TOLERANCE + || p.y < a.y - TOLERANCE + || p.z < a.z - TOLERANCE + || p.x > b.x + TOLERANCE + || p.y > b.y + TOLERANCE + || p.z > b.z + TOLERANCE + { + return invalid("rectangular stock must enclose selected model geometry"); + } + } + Ok(()) + } +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Motion { + Rapid { to: Vec3 }, + Linear { to: Vec3, feed: f64 }, + Arc { to: Vec3, center: Vec3, clockwise: bool, feed: f64 }, + Tool { tool: u64, number: u32 }, + Spindle { rpm: f64 }, + Coolant { on: bool }, + Stop, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Block { + pub operation: u64, + pub motion: Motion, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Toolpath { + pub fingerprint: String, + pub blocks: Vec, +} +pub(crate) fn finite(v: f64) -> bool { + v.is_finite() && v.abs() <= 1e6 +} +pub(crate) fn positive(v: f64) -> bool { + finite(v) && v > 0.0 +} +pub(crate) fn point(v: Vec3) -> Result<()> { + if [v.x, v.y, v.z].into_iter().all(finite) { Ok(()) } else { invalid("non-finite or excessive coordinate") } +} +fn name(s: &str) -> Result<()> { + if s.trim().is_empty() || s.len() > 256 || s.chars().any(char::is_control) { invalid("invalid name") } else { Ok(()) } +} diff --git a/crates/cam/src/nc.rs b/crates/cam/src/nc.rs new file mode 100644 index 00000000..0b58a0ea --- /dev/null +++ b/crates/cam/src/nc.rs @@ -0,0 +1,712 @@ +//! Strict reference-dialect post and independent modal NC reader. +//! The reader has no dependency on the toolpath generator. +use crate::*; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; +use std::{ + future::Future, + task::{Context, Poll, Waker}, +}; + +pub const DIALECT: &str = "solidcraft-reference-3axis/2; not a qualified machine post"; +// A maximum-size path also emits the three explicit startup events below. +const MAX_RECORDS: usize = MAX_MOVES + 3; +fn complete(future: impl Future>) -> Result { + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(result) => result, + Poll::Pending => invalid("synchronous NC checkpoint unexpectedly suspended"), + } +} +async fn checkpoint>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> { + if cancelled() { + return invalid("NC processing cancelled"); + } + yield_control().await; + if cancelled() { invalid("NC processing cancelled") } else { Ok(()) } +} +fn startup(setup: &Setup) -> [Motion; 3] { + [Motion::Spindle { rpm: 0.0 }, Motion::Coolant { on: false }, Motion::Rapid { to: Vec3::new(0., 0., setup.clearance) }] +} +pub fn post(path: &Toolpath, setup: &Setup) -> Result { + complete(post_cooperative(path, setup, || false, || std::future::ready(()))) +} +/// Preserve post bytes while yielding every 256 blocks. No partial output on cancellation. +pub async fn post_cooperative>( + path: &Toolpath, + setup: &Setup, + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + checkpoint(&cancelled, &mut yield_control).await?; + if path.blocks.is_empty() || path.blocks.len() > MAX_MOVES { + return invalid("cannot post an empty or excessive path"); + } + let mut out = format!("({DIALECT})\n(Fingerprint {})\nG21 G90 G17 G91.1 G94 G{} G40 G49 G80\nM5\nM9\n", path.fingerprint, setup.work_offset); + let mut current = Vec3::new(0.0, 0.0, setup.clearance); + out.push_str(&format!("G0 X0.000000 Y0.000000 Z{:.6}\n", setup.clearance)); + for (index, block) in path.blocks.iter().enumerate() { + if index.is_multiple_of(256) { + checkpoint(&cancelled, &mut yield_control).await?; + } + out.push_str(&format!("(Operation {})\n", block.operation)); + let line = match &block.motion { + Motion::Rapid { to } => format!("G0 X{:.6} Y{:.6} Z{:.6}", to.x, to.y, to.z), + Motion::Linear { to, feed } => format!("G1 X{:.6} Y{:.6} Z{:.6} F{:.6}", to.x, to.y, to.z, feed), + Motion::Arc { to, center, clockwise, feed } => format!( + "G{} X{:.6} Y{:.6} Z{:.6} I{:.6} J{:.6} F{:.6}", + if *clockwise { 2 } else { 3 }, + to.x, + to.y, + to.z, + center.x - current.x, + center.y - current.y, + feed + ), + Motion::Tool { number, .. } => format!("T{number} M6"), + Motion::Spindle { rpm } => { + if *rpm == 0.0 { + "M5".into() + } else { + format!("S{rpm:.6} M3") + } + } + Motion::Coolant { on } => { + if *on { + "M8".into() + } else { + "M9".into() + } + } + Motion::Stop => "M0".into(), + }; + out.push_str(&line); + out.push('\n'); + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &block.motion { + current = *to; + } + } + out.push_str("M30\n"); + checkpoint(&cancelled, &mut yield_control).await?; + Ok(out) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Record { + pub line: usize, + pub motion: Motion, +} +/// Provenance established only after the independent reader matches a posted block. +/// `block` is one-based and also the completed-block count used by playback. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: usize, + pub block: usize, + pub operation: u64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Parsed { + pub records: Vec, + pub work_offset: u8, + // Never accept provenance from comments or deserialized external programs. + #[serde(skip)] + sources: Vec, +} +impl Parsed { + pub fn source_for_line(&self, line: usize) -> Option { + self.sources.binary_search_by_key(&line, |s| s.line).ok().and_then(|i| self.sources.get(i).copied()) + } + /// First interpreted record on a physical NC line, using a zero-based index. + pub fn record_for_line(&self, line: usize) -> Option { + let i = self.records.partition_point(|r| r.line < line); + self.records.get(i).filter(|r| r.line == line).map(|_| i) + } +} + +/// Strictly consumes every word. Units and distance mode must be declared before motion. +pub fn parse(source: &str, tools: &[Tool]) -> Result { + complete(parse_cooperative(source, tools, || false, || std::future::ready(()))) +} +/// Independent modal reader with cancellation and a checkpoint every 256 physical lines. +pub async fn parse_cooperative>( + source: &str, + tools: &[Tool], + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + checkpoint(&cancelled, &mut yield_control).await?; + if source.len() > 32 * 1024 * 1024 { + return invalid("NC file too large"); + } + let mut out = Vec::new(); + let mut units = None; + let mut absolute = None; + let mut plane = false; + let mut per_minute = false; + let mut offset = None; + let mut pos = Vec3::ZERO; + let mut have_pos = false; + let mut mode = None; + let mut feed = 0.0; + let mut rpm = 0.0; + let mut spindle_on = false; + let mut coolant_on = false; + let mut selected = None; + let mut ended = false; + for (index, line) in source.lines().enumerate() { + if index.is_multiple_of(256) { + checkpoint(&cancelled, &mut yield_control).await?; + } + let line_number = index + 1; + let fail = |s: &str| Error(format!("NC line {line_number}: {s}")); + let mut words = words(line).map_err(|e| fail(&e.0))?; + // Modal changes apply to the whole block, independent of word order. + words.sort_by_key(|(letter, _)| if *letter == 'G' { 0 } else { 1 }); + if words.is_empty() { + continue; + } + if ended { + return Err(fail("content after program end")); + } + let mut coordinates = [None; 3]; + let mut ij = [None; 2]; + let mut m = None; + let mut groups = std::collections::BTreeSet::new(); + let mut seen = std::collections::BTreeSet::new(); + for (letter, value) in words { + if letter != 'G' && !seen.insert(letter) { + return Err(fail("duplicate address")); + } + match letter { + 'N' => { + if value.fract() != 0.0 || value < 0.0 { + return Err(fail("invalid line number")); + } + } + 'G' => { + let group = match value { + 0.0..=3.0 if value.fract() == 0.0 => { + mode = Some(value as u8); + 0 + } + 17.0 => { + plane = true; + 1 + } + 20.0 => { + units = Some(25.4); + 2 + } + 21.0 => { + units = Some(1.0); + 2 + } + 90.0 => { + absolute = Some(true); + 3 + } + 91.0 => { + absolute = Some(false); + 3 + } + 94.0 => { + per_minute = true; + 4 + } + 54.0..=59.0 if value.fract() == 0.0 => { + if offset.is_some_and(|o| o != value as u8) { + return Err(fail("multiple work offsets unsupported")); + } + offset = Some(value as u8); + 5 + } + 40.0 => 6, + 49.0 => 7, + 80.0 => { + mode = None; + 0 + } + 91.1 => 9, + _ => return Err(fail("unsupported G code; no commands are silently discarded")), + }; + if !groups.insert(group) { + return Err(fail("conflicting modal codes")); + } + } + 'M' => { + if value.fract() != 0.0 { + return Err(fail("invalid M code")); + } + m = Some(value as i32); + } + 'X' => coordinates[0] = Some(value), + 'Y' => coordinates[1] = Some(value), + 'Z' => coordinates[2] = Some(value), + 'I' => ij[0] = Some(value), + 'J' => ij[1] = Some(value), + 'F' => { + let scale = units.ok_or_else(|| fail("declare units before feed"))?; + feed = value * scale; + if !crate::model::positive(feed) { + return Err(fail("feed must be positive")); + } + } + 'S' => { + if value < 0.0 { + return Err(fail("negative spindle speed")); + } + rpm = value; + } + 'T' => { + if value.fract() != 0.0 || value < 1.0 || value > 9999.0 { + return Err(fail("invalid tool number")); + } + selected = Some(value as u32); + } + _ => return Err(fail("unsupported address")), + } + } + let moving = coordinates.iter().any(Option::is_some) || ij.iter().any(Option::is_some); + if moving && m.is_some() { + return Err(fail("combined motion/M blocks unsupported by reference dialect")); + } + // Speed changes precede motion and coolant/stop commands in a block. + // Other spindle-changing M codes below already emit its final state. + if seen.contains(&'S') && spindle_on && !matches!(m, Some(3 | 5 | 6 | 2 | 30)) { + out.push(Record { line: line_number, motion: Motion::Spindle { rpm } }); + } + if moving { + let scale = units.ok_or_else(|| fail("missing units"))?; + let abs = absolute.ok_or_else(|| fail("missing distance mode"))?; + if !plane || !per_minute || offset.is_none() { + return Err(fail("declare G17, G94 and work offset before motion")); + } + if !have_pos && (!abs || coordinates.iter().any(Option::is_none)) { + return Err(fail("first position requires absolute XYZ")); + } + let base = [pos.x, pos.y, pos.z]; + let mut xyz = base; + for k in 0..3 { + if let Some(v) = coordinates[k] { + xyz[k] = v * scale + if abs { 0.0 } else { base[k] }; + } + } + let to = Vec3::new(xyz[0], xyz[1], xyz[2]); + crate::model::point(to)?; + let motion = match mode.ok_or_else(|| fail("missing motion mode"))? { + 0 => { + if ij.iter().any(Option::is_some) { + return Err(fail("arc center on rapid")); + } + Motion::Rapid { to } + } + 1 => { + if ij.iter().any(Option::is_some) || feed <= 0.0 { + return Err(fail("invalid linear feed/center")); + } + Motion::Linear { to, feed } + } + arc => { + if !have_pos || feed <= 0.0 || ij.iter().all(Option::is_none) || (to.z - pos.z).abs() > 1e-8 { + return Err(fail("only planar XY I/J arcs with positive feed are supported")); + } + let center = Vec3::new(pos.x + ij[0].unwrap_or(0.0) * scale, pos.y + ij[1].unwrap_or(0.0) * scale, pos.z); + let r = pos.dist(center); + if r <= 1e-8 || (to.dist(center) - r).abs() > POST_ERROR { + return Err(fail("inconsistent arc radius")); + } + Motion::Arc { to, center, clockwise: arc == 2, feed } + } + }; + out.push(Record { line: line_number, motion }); + pos = to; + have_pos = true; + } + if let Some(m) = m { + let motion = match m { + 0 => Some(Motion::Stop), + 3 => { + if rpm <= 0.0 { + return Err(fail("spindle start requires positive speed")); + } + spindle_on = true; + Some(Motion::Spindle { rpm }) + } + 5 => { + spindle_on = false; + Some(Motion::Spindle { rpm: 0.0 }) + } + 6 => { + let n = selected.ok_or_else(|| fail("tool change needs T"))?; + let matches: Vec<_> = tools.iter().filter(|t| t.number == n).collect(); + if matches.len() != 1 { + return Err(fail("unknown or ambiguous tool number")); + } + // NIST RS274/NGC §3.6.3: M6 stops the spindle without + // restarting it; programmed speed and coolant are retained. + if spindle_on { + out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } }); + } + spindle_on = false; + Some(Motion::Tool { tool: matches[0].id, number: n }) + } + 8 => { + coolant_on = true; + Some(Motion::Coolant { on: true }) + } + 9 => { + coolant_on = false; + Some(Motion::Coolant { on: false }) + } + 2 | 30 => { + // Program end stops spindle and coolant in the interpreted state. + if spindle_on { + out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } }); + } + if coolant_on { + out.push(Record { line: line_number, motion: Motion::Coolant { on: false } }); + } + ended = true; + None + } + _ => return Err(fail("unsupported M code")), + }; + if let Some(motion) = motion { + out.push(Record { line: line_number, motion }); + } + } + if out.len() > MAX_RECORDS { + return Err(fail("NC motion limit")); + } + } + if !ended || !have_pos { + return invalid("NC program requires motion and an explicit program end"); + } + checkpoint(&cancelled, &mut yield_control).await?; + Ok(Parsed { records: out, work_offset: offset.ok_or_else(|| Error("missing work offset".into()))?, sources: Vec::new() }) +} +fn words(line: &str) -> Result> { + if line.len() > 4096 { + return invalid("NC line too long"); + } + let mut clean = String::new(); + let mut comment = false; + for c in line.chars() { + match c { + '(' if !comment => comment = true, + ')' if comment => comment = false, + '(' | ')' => return invalid("malformed comment"), + ';' if !comment => break, + _ if !comment => clean.push(c), + _ => {} + } + } + if comment { + return invalid("unterminated comment"); + } + let clean = clean.trim(); + if clean == "%" { + return Ok(Vec::new()); + } + let chars: Vec<_> = clean.chars().collect(); + let mut i = 0; + let mut result = Vec::new(); + while i < chars.len() { + if chars[i].is_whitespace() { + i += 1; + continue; + } + let letter = chars[i].to_ascii_uppercase(); + if !letter.is_ascii_uppercase() { + return invalid("expected address letter"); + } + i += 1; + while chars.get(i).is_some_and(|c| c.is_whitespace()) { + i += 1; + } + let start = i; + while chars.get(i).is_some_and(|c| c.is_ascii_digit() || matches!(c, '.' | '+' | '-')) { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + let v = s.parse::().map_err(|_| Error("invalid numeric word".into()))?; + if !crate::model::finite(v) { + return invalid("non-finite/excessive word"); + } + result.push((letter, v)); + } + Ok(result) +} + +pub fn round_trip(path: &Toolpath, setup: &Setup, tools: &[Tool]) -> Result { + complete(round_trip_cooperative(path, setup, tools, || false, || std::future::ready(()))) +} +/// Post, independently interpret and compare in bounded batches, retaining trusted line mappings. +pub async fn round_trip_cooperative>( + path: &Toolpath, + setup: &Setup, + tools: &[Tool], + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + let source = post_cooperative(path, setup, &cancelled, &mut yield_control).await?; + let mut parsed = parse_cooperative(&source, tools, &cancelled, &mut yield_control).await?; + let startup = startup(setup); + if parsed.work_offset != setup.work_offset || parsed.records.len() != path.blocks.len() + startup.len() { + return invalid("post changed block count or work offset"); + } + if !startup.iter().zip(&parsed.records).all(|(a, b)| equivalent(a, &b.motion)) { + return invalid("post changed startup state"); + } + let mut original_position = Vec3::new(0., 0., setup.clearance); + let mut posted_position = original_position; + if let Some(Record { motion: Motion::Rapid { to }, .. }) = parsed.records.get(startup.len() - 1) { + posted_position = *to; + } + for (i, (a, b)) in path.blocks.iter().zip(parsed.records.iter().skip(startup.len())).enumerate() { + if i.is_multiple_of(256) { + checkpoint(&cancelled, &mut yield_control).await?; + } + if !equivalent(&a.motion, &b.motion) { + return invalid(format!("post mismatch at NC line {}", b.line)); + } + if let (Motion::Arc { to, center, clockwise, .. }, Motion::Arc { to: posted_to, center: posted_center, .. }) = (&a.motion, &b.motion) { + let before = sweep(original_position, *to, *center, *clockwise); + let after = sweep(posted_position, *posted_to, *posted_center, *clockwise); + if (before - after).abs() * original_position.dist(*center) > POST_ERROR { + return invalid(format!("post changed arc sweep at NC line {}", b.line)); + } + } + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = a.motion { + original_position = to; + } + if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = b.motion { + posted_position = to; + } + parsed.sources.push(SourceLocation { line: b.line, block: i + 1, operation: a.operation }); + } + checkpoint(&cancelled, &mut yield_control).await?; + Ok(parsed) +} +fn sweep(from: Vec3, to: Vec3, center: Vec3, clockwise: bool) -> f64 { + if from.x == to.x && from.y == to.y { + return if clockwise { -std::f64::consts::TAU } else { std::f64::consts::TAU }; + } + let a = (from.y - center.y).atan2(from.x - center.x); + let b = (to.y - center.y).atan2(to.x - center.x); + if clockwise { -(a - b).rem_euclid(std::f64::consts::TAU) } else { (b - a).rem_euclid(std::f64::consts::TAU) } +} +fn equivalent(a: &Motion, b: &Motion) -> bool { + match (a, b) { + (Motion::Rapid { to: a }, Motion::Rapid { to: b }) => a.dist(*b) <= POST_ERROR, + (Motion::Linear { to: a, feed: f }, Motion::Linear { to: b, feed: g }) => a.dist(*b) <= POST_ERROR && (f - g).abs() <= 1e-6, + (Motion::Arc { to: a, center: c, clockwise: d, feed: f }, Motion::Arc { to: b, center: e, clockwise: h, feed: g }) => { + a.dist(*b) <= POST_ERROR && c.dist(*e) <= POST_ERROR && d == h && (f - g).abs() <= 1e-6 + } + (Motion::Spindle { rpm: a }, Motion::Spindle { rpm: b }) => (a - b).abs() <= 1e-6, + _ => a == b, + } +} + +#[derive(Clone, Debug)] +pub struct Segment { + pub from: Vec3, + pub to: Vec3, + pub rapid: bool, + pub feed: f64, + pub tool: u64, + pub line: usize, +} +/// Expand interpreted circular motion with a bounded chord error, independent of posting. +pub fn segments(parsed: &Parsed, setup: &Setup) -> Result> { + let mut out = Vec::new(); + let mut pos = None; + let mut tool = 0; + let mut spindle = 0.0; + for r in &parsed.records { + match &r.motion { + Motion::Tool { tool: id, .. } => { + if pos.is_none_or(|p: Vec3| p.z < setup.clearance - POST_ERROR) { + return invalid(format!("tool change below clearance at line {}", r.line)); + } + if spindle != 0.0 { + return invalid("tool change with spindle running"); + } + tool = *id; + } + Motion::Spindle { rpm } => spindle = *rpm, + Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } => { + let rapid = matches!(r.motion, Motion::Rapid { .. }); + let feed = match r.motion { + Motion::Linear { feed, .. } | Motion::Arc { feed, .. } => feed, + _ => 0.0, + }; + if !rapid && (tool == 0 || spindle <= 0.0) { + return invalid(format!("cutting without tool/spindle at line {}", r.line)); + } + if let Some(from) = pos { + if tool == 0 { + return invalid(format!("motion before selecting a known tool at line {}", r.line)); + } + if let Motion::Arc { center, clockwise, .. } = r.motion { + let a: f64 = (from.y - center.y).atan2(from.x - center.x); + let sweep = sweep(from, *to, center, clockwise); + let radius = from.dist(center); + let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6); + let count = (sweep.abs() / step).ceil() as usize; + if count > MAX_MOVES || out.len() + count > MAX_MOVES { + return invalid("arc subdivision limit"); + } + let mut prev = from; + for k in 1..=count { + let angle = a + sweep * k as f64 / count as f64; + let p = if k == count { *to } else { Vec3::new(center.x + radius * angle.cos(), center.y + radius * angle.sin(), to.z) }; + out.push(Segment { from: prev, to: p, rapid, feed, tool, line: r.line }); + prev = p; + } + } else { + out.push(Segment { from, to: *to, rapid, feed, tool, line: r.line }); + } + } else if !rapid || to.z < setup.clearance - POST_ERROR { + return invalid("initial position must be at clearance"); + } + pos = Some(*to); + } + _ => {} + } + if out.len() > MAX_MOVES { + return invalid("simulation segment limit"); + } + } + Ok(out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod provenance_tests { + use super::*; + #[test] + fn tiny_arcs_cannot_round_into_unintended_full_circles() { + let input = corpus::fixture("face").unwrap(); + for clockwise in [false, true] { + let to = Vec3::new(1., if clockwise { -1e-8 } else { 1e-8 }, 15.); + let path = Toolpath { + fingerprint: input.fingerprint.clone(), + blocks: vec![ + Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } }, + Block { operation: 1, motion: Motion::Spindle { rpm: 1000. } }, + Block { operation: 1, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } }, + Block { operation: 1, motion: Motion::Arc { to, center: Vec3::new(0., 0., 15.), clockwise, feed: 100. } }, + Block { operation: 1, motion: Motion::Spindle { rpm: 0. } }, + ], + }; + assert!(round_trip(&path, &input.setup, &input.tools).unwrap_err().0.contains("arc sweep")); + } + let raw = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X1 Y0.0000000000001 I-1 J0 F100\nM5\nM30"; + let parsed = parse(raw, &input.tools).unwrap(); + let distance: f64 = segments(&parsed, &input.setup).unwrap().iter().map(|s| s.from.dist(s.to)).sum(); + assert!(distance < 1e-10, "a noncoincident tiny arc is not a full circle: {distance}"); + } + #[test] + fn diagnostic_sources_follow_checked_records_and_ignore_untrusted_annotations() { + let input = corpus::fixture("face").unwrap(); + let path = Toolpath { + fingerprint: input.fingerprint.clone(), + blocks: vec![ + Block { operation: 10, motion: Motion::Tool { tool: 1, number: 1 } }, + Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } }, + Block { operation: 10, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } }, + Block { + operation: 20, + motion: Motion::Arc { to: Vec3::new(1., 0., 15.), center: Vec3::new(0., 0., 15.), clockwise: true, feed: 100. }, + }, + Block { operation: 20, motion: Motion::Spindle { rpm: 0. } }, + Block { operation: 20, motion: Motion::Stop }, + ], + }; + let parsed = round_trip(&path, &input.setup, &input.tools).unwrap(); + let prefix = startup(&input.setup).len(); + for (i, record) in parsed.records.iter().skip(prefix).enumerate() { + assert_eq!( + parsed.source_for_line(record.line), + Some(SourceLocation { line: record.line, block: i + 1, operation: path.blocks[i].operation }) + ); + assert_eq!(parsed.record_for_line(record.line), Some(i + prefix)); + } + for record in parsed.records.iter().take(prefix) { + assert!(parsed.source_for_line(record.line).is_none()); + } + assert!(parsed.source_for_line(7).is_none()); // comment + let arc = &parsed.records[prefix + 3]; + let segments = segments(&parsed, &input.setup).unwrap(); + assert!(segments.iter().filter(|s| s.line == arc.line).count() > 10); + assert!(segments.iter().filter(|s| s.line == arc.line).all(|s| parsed.source_for_line(s.line).unwrap().block == 4)); + let raw = parse(&post(&path, &input.setup).unwrap().replace("Operation 20", "Operation 999"), &input.tools).unwrap(); + assert!(raw.source_for_line(arc.line).is_none()); + let mut serialized = serde_json::to_value(&parsed).unwrap(); + serialized["sources"] = serde_json::json!([{"line":arc.line,"block":4,"operation":999}]); + let decoded: Parsed = serde_json::from_value(serialized).unwrap(); + assert!(decoded.source_for_line(arc.line).is_none()); + } + #[test] + fn diagnostic_fault_findings_retain_checked_block_but_raw_nc_has_only_record() { + let input = corpus::fixture("face").unwrap(); + let mut path = generate::generate(input.clone()).unwrap(); + for block in &mut path.blocks { + if let Motion::Linear { to, .. } = &mut block.motion + && to.z == 5. + { + to.z = 4.; + } + } + let parsed = round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, verify::Status::Fail); + let finding = report.findings.iter().find(|f| f.kind == "gouge").unwrap(); + assert_eq!(finding.severity, verify::FindingSeverity::Error); + assert_eq!(report.findings[0].severity, verify::FindingSeverity::Error); + let source = finding.source.unwrap(); + assert_eq!(Some(source.line), finding.line); + assert_eq!(source.operation, path.blocks[source.block - 1].operation); + assert_eq!(finding.record, Some(source.block + startup(&input.setup).len())); + let raw = parse(&post(&path, &input.setup).unwrap(), &input.tools).unwrap(); + let raw_report = verify::simulate(&input, &raw, Default::default()).unwrap(); + assert_eq!(raw_report.status, report.status); + assert_eq!(raw_report.finished_dimensions, report.finished_dimensions); + assert!(raw_report.findings.iter().filter(|f| f.line.is_some()).all(|f| f.source.is_none() && f.record.is_some())); + let legacy: verify::Finding = + serde_json::from_value(serde_json::json!({"kind":"legacy","line":4,"at":[0,0,0],"detail":"old report"})).unwrap(); + assert!(legacy.record.is_none() && legacy.source.is_none()); + } + #[test] + fn diagnostic_retention_keeps_errors_and_budget_reasons_bounded() { + let input = corpus::fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = round_trip(&path, &input.setup, &input.tools).unwrap(); + let mut report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + report.findings.clear(); + let finding = |line, severity, kind: &str| verify::Finding { + line, + severity, + kind: kind.into(), + record: None, + source: None, + at: Vec3::ZERO, + detail: "witness".into(), + }; + for line in 1..=250 { + report.finding(finding(Some(line), verify::FindingSeverity::Unresolved, "clearance_unresolved")); + } + assert_eq!(report.findings.len(), 200); + report.finding(finding(None, verify::FindingSeverity::Unresolved, "budget_exhausted")); + report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge")); + for _ in 0..250 { + report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge")); + } + assert_eq!(report.findings.len(), 200); + assert_eq!(report.findings.iter().filter(|f| f.kind == "gouge").count(), 1); + assert!(report.findings.iter().any(|f| f.kind == "budget_exhausted")); + // Diagnostic storage is not an input to acceptance decisions. + assert_eq!(report.status, verify::Status::Pass); + } +} diff --git a/crates/cam/src/playback.rs b/crates/cam/src/playback.rs new file mode 100644 index 00000000..e30dea41 --- /dev/null +++ b/crates/cam/src/playback.rs @@ -0,0 +1,73 @@ +//! Machine state at a typed toolpath boundary, shared by inspection and playback. +use crate::*; +use solidcraft_geom::Vec3; +#[derive(Clone, Debug, serde::Serialize)] +pub struct State { + pub completed: usize, + pub position: Vec3, + pub operation: Option, + pub tool: Option, + pub spindle: f64, + pub coolant: bool, + pub feed: Option, +} +pub fn at(path: &Toolpath, setup: &Setup, completed: usize) -> Result { + if completed > path.blocks.len() { + return invalid("playback motion exceeds toolpath length"); + } + let mut state = + State { completed, position: Vec3::new(0., 0., setup.clearance), operation: None, tool: None, spindle: 0., coolant: false, feed: None }; + for block in path.blocks.iter().take(completed) { + state.operation = Some(block.operation); + match block.motion { + Motion::Rapid { to } => state.position = to, + Motion::Linear { to, feed } | Motion::Arc { to, feed, .. } => { + state.position = to; + state.feed = Some(feed); + } + Motion::Tool { tool, .. } => state.tool = Some(tool), + Motion::Spindle { rpm } => state.spindle = rpm, + Motion::Coolant { on } => state.coolant = on, + Motion::Stop => { + state.spindle = 0.; + state.coolant = false; + } + } + } + Ok(state) +} +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + #[test] + fn initial_pose_tool_changes_and_shutdown() { + let input = corpus::fixture("face").unwrap(); + let path = Toolpath { + fingerprint: "test".into(), + blocks: vec![ + Block { operation: 10, motion: Motion::Tool { tool: 3, number: 7 } }, + Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } }, + Block { operation: 10, motion: Motion::Coolant { on: true } }, + Block { operation: 10, motion: Motion::Linear { to: Vec3::new(2., 3., 4.), feed: 100. } }, + Block { operation: 11, motion: Motion::Tool { tool: 4, number: 8 } }, + Block { operation: 11, motion: Motion::Stop }, + ], + }; + let start = at(&path, &input.setup, 0).unwrap(); + assert_eq!(start.position, Vec3::new(0., 0., 15.)); + assert!(start.tool.is_none()); + let cut = at(&path, &input.setup, 4).unwrap(); + assert_eq!(cut.position, Vec3::new(2., 3., 4.)); + assert_eq!(cut.tool, Some(3)); + assert_eq!(cut.spindle, 5000.); + assert!(cut.coolant); + assert_eq!(cut.feed, Some(100.)); + let end = at(&path, &input.setup, 6).unwrap(); + assert_eq!(end.tool, Some(4)); + assert_eq!(end.operation, Some(11)); + assert_eq!(end.spindle, 0.); + assert!(!end.coolant); + assert!(at(&path, &input.setup, 7).is_err()); + } +} diff --git a/crates/cam/src/region.rs b/crates/cam/src/region.rs new file mode 100644 index 00000000..f6089dae --- /dev/null +++ b/crates/cam/src/region.rs @@ -0,0 +1,233 @@ +//! Independent planar-region predicates for finished dimensions. No offsets or +//! toolpath-generation routines are used. Winding ignores input ring direction. +use crate::{spatial::Index, *}; + +fn orient(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> f64 { + robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: p[0], y: p[1] }) +} +fn contains(ring: &[[f64; 2]], p: [f64; 2]) -> bool { + ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).fold(false, |inside, (a, b)| inside ^ crossing(*a, *b, p)) +} +fn crossing(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> bool { + (a[1] > p[1]) != (b[1] > p[1]) && if b[1] > a[1] { orient(a, b, p) > 0. } else { orient(a, b, p) < 0. } +} +struct Edge { + a: [f64; 2], + b: [f64; 2], + ring: usize, + vertex: usize, + count: usize, +} +pub(crate) struct Region { + edges: Vec, + index: Index, + max_x: f64, + guard: f64, +} +impl Region { + pub(crate) fn new(boundary: &Boundary) -> Result { + boundary.validate()?; + let rings: Vec<_> = std::iter::once(&boundary.outer).chain(&boundary.islands).collect(); + let mut edges = Vec::new(); + let mut extent = 1_f64; + let mut max_x = f64::NEG_INFINITY; + for (r, ring) in rings.iter().enumerate() { + let mut area = 0.; + let origin = ring[0]; + for (vertex, (&a, &b)) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).enumerate() { + if a.into_iter().any(|v| v != 0. && v.abs() < 1e-100) || (a[0] - b[0]).hypot(a[1] - b[1]) < 1e-7 { + return invalid("unresolvable dimensional boundary edge"); + } + extent = extent.max(a[0].abs()).max(a[1].abs()); + max_x = max_x.max(a[0]); + area += (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0]); + edges.push(Edge { a, b, ring: r, vertex, count: ring.len() }); + } + if area.abs() < 1e-8 { + return invalid("zero-area dimensional boundary"); + } + } + let index = Index::new( + edges + .iter() + .enumerate() + .map(|(id, e)| (id, [e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])])) + .collect(), + ); + let mut work = 0; + for (id, e) in edges.iter().enumerate() { + let mut candidates = Vec::new(); + index.query([e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])], &mut candidates); + for other in candidates.into_iter().filter(|other| *other > id) { + work += 1; + if work > 2_000_000 { + return invalid("dimensional boundary validation budget exceeded"); + } + let f = &edges[other]; + if e.ring == f.ring && ((e.vertex + 1) % e.count == f.vertex || (f.vertex + 1) % e.count == e.vertex) { + let (a, b, c) = if e.b == f.a { (e.a, e.b, f.b) } else { (f.a, f.b, e.b) }; + if orient(a, b, c) == 0. && (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0. { + return invalid("backtracking dimensional boundary"); + } + continue; + } + if (0..2).any(|k| e.a[k].max(e.b[k]) < f.a[k].min(f.b[k]) || f.a[k].max(f.b[k]) < e.a[k].min(e.b[k])) { + continue; + } + let opposite = |x: f64, y: f64| x == 0. || y == 0. || x.is_sign_positive() != y.is_sign_positive(); + if opposite(orient(e.a, e.b, f.a), orient(e.a, e.b, f.b)) && opposite(orient(f.a, f.b, e.a), orient(f.a, f.b, e.b)) { + return invalid("intersecting or touching dimensional boundaries"); + } + } + } + for (id, island) in boundary.islands.iter().enumerate() { + if !contains(&boundary.outer, island[0]) || boundary.islands.iter().enumerate().any(|(j, r)| id != j && contains(r, island[0])) { + return invalid("dimensional islands must be disjoint and inside the outer boundary"); + } + } + Ok(Self { edges, index, max_x, guard: 128. * f64::EPSILON * extent }) + } + /// Signed distance is positive in the material-removal region. Distance to + /// its boundary is 1-Lipschitz, so center distance +/- cell radius bounds the + /// whole cell. A capped nearest-edge query returns an infinite far endpoint. + pub(crate) async fn range>( + &self, + query: ([f64; 2], f64, f64), + work: &mut usize, + budget: usize, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result> { + let (p, half, reach) = query; + let guard = self.guard.max(128. * f64::EPSILON * p[0].abs().max(p[1].abs())); + let mut candidates = Vec::new(); + self.index.query(p, [self.max_x.max(p[0]), p[1]], &mut candidates); + let mut inside = false; + for (n, id) in candidates.into_iter().enumerate() { + if n % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("dimensional verification cancelled"); + } + } + *work += 1; + if *work > budget { + return Ok(None); + } + let e = &self.edges[id]; + inside ^= crossing(e.a, e.b, p); + } + let radius = reach + half + guard; + let mut candidates = Vec::new(); + self.index.query(p.map(|v| v - radius), p.map(|v| v + radius), &mut candidates); + let mut distance = radius; + for (n, id) in candidates.into_iter().enumerate() { + if n % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("dimensional verification cancelled"); + } + } + *work += 1; + if *work > budget { + return Ok(None); + } + let e = &self.edges[id]; + let dx = e.b[0] - e.a[0]; + let dy = e.b[1] - e.a[1]; + let t = (((p[0] - e.a[0]) * dx + (p[1] - e.a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.); + distance = distance.min((p[0] - e.a[0] - t * dx).hypot(p[1] - e.a[1] - t * dy)); + } + let lo = (distance - half - guard).max(0.); + let hi = if distance == radius { f64::INFINITY } else { distance + half + guard }; + if distance <= half + guard { + Ok(Some([-hi, hi])) + } else if inside { + Ok(Some([lo, hi])) + } else { + Ok(Some([-hi, -lo])) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + fn square(a: f64, b: f64) -> Vec<[f64; 2]> { + vec![[a, a], [b, a], [b, b], [a, b]] + } + fn range(r: &Region, p: [f64; 2], half: f64, budget: usize) -> Option<[f64; 2]> { + let mut ready = || std::future::ready(()); + let mut work = 0; + let mut f = std::pin::pin!(r.range((p, half, 10.), &mut work, budget, &|| false, &mut ready)); + match f.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(result) => result.unwrap(), + _ => None, + } + } + #[test] + fn signed_distances_cover_concavity_islands_and_winding() { + let mut boundary = Boundary { outer: square(0., 10.), islands: vec![square(4., 6.)] }; + for _ in 0..2 { + let r = Region::new(&boundary).unwrap(); + for (p, expected) in [([3., 5.], 1.), ([5., 5.], -1.), ([-1., 5.], -1.)] { + let [a, b] = range(&r, p, 0.1, 1000).unwrap(); + assert!((a - (expected - 0.1)).abs() < 1e-10 && (b - (expected + 0.1)).abs() < 1e-10, "{a} {b}"); + } + boundary.outer.reverse(); + boundary.islands[0].reverse(); + } + let r = Region::new(&Boundary { outer: vec![[0., 0.], [4., 0.], [4., 1.], [1., 1.], [1., 4.], [0., 4.]], islands: vec![] }).unwrap(); + assert!((range(&r, [2., 2.], 0., 1000).unwrap()[0] + 1.).abs() < 1e-10); + assert!((range(&r, [0.5, 3.], 0., 1000).unwrap()[0] - 0.5).abs() < 1e-10); + assert!(range(&r, [0.5, 3.], 0., 0).is_none()); + } + #[test] + fn invalid_regions_are_rejected_without_generator_validation() { + for b in [ + Boundary { outer: vec![[0., 0.], [2., 2.], [0., 2.], [2., 0.]], islands: vec![] }, + Boundary { outer: square(0., 10.), islands: vec![square(0., 2.)] }, + Boundary { outer: square(0., 10.), islands: vec![square(2., 8.), square(3., 4.)] }, + Boundary { outer: vec![[0., 0.], [2., 0.], [1., 0.], [2., 2.], [0., 2.]], islands: vec![] }, + ] { + assert!(Region::new(&b).is_err()); + } + } + #[test] + fn large_boundary_queries_yield_and_cancel() { + let b = Boundary { + outer: (0..10_000) + .map(|i| { + let a = (i as f64 + 0.1) * std::f64::consts::TAU / 10_000.; + [a.cos() * 10., a.sin() * 10.] + }) + .collect(), + islands: vec![], + }; + let r = Region::new(&b).unwrap(); + let mut work = 0; + let cancel = std::cell::Cell::new(false); + let cancelled = || cancel.get(); + let mut yielding = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut f = std::pin::pin!(r.range(([0., 0.], 20., 0.), &mut work, 100_000, &cancelled, &mut yielding)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(f.as_mut().poll(&mut cx).is_pending()); + cancel.set(true); + assert!(matches!(f.as_mut().poll(&mut cx), Poll::Ready(Err(_)))); + } +} diff --git a/crates/cam/src/spatial.rs b/crates/cam/src/spatial.rs new file mode 100644 index 00000000..3af7c4b7 --- /dev/null +++ b/crates/cam/src/spatial.rs @@ -0,0 +1,65 @@ +// Independent median-split XY index: artifact comparison never calls CAM offsets. +pub(crate) struct Index { + min: [f64; 2], + max: [f64; 2], + items: Vec<(usize, [f64; 2], [f64; 2])>, + children: Vec, +} +impl Index { + pub(crate) fn new(mut items: Vec<(usize, [f64; 2], [f64; 2])>) -> Self { + let mut min = [f64::INFINITY; 2]; + let mut max = [f64::NEG_INFINITY; 2]; + for (_, a, b) in &items { + for k in 0..2 { + min[k] = min[k].min(a[k]); + max[k] = max[k].max(b[k]); + } + } + if items.len() <= 8 { + return Self { min, max, items, children: vec![] }; + } + let axis = usize::from(max[1] - min[1] > max[0] - min[0]); + items.sort_unstable_by(|a, b| (a.1[axis] + a.2[axis]).total_cmp(&(b.1[axis] + b.2[axis]))); + let rest = items.split_off(items.len() / 2); + Self { min, max, items: vec![], children: vec![Self::new(items), Self::new(rest)] } + } + pub(crate) fn query(&self, min: [f64; 2], max: [f64; 2], out: &mut Vec) { + if (0..2).any(|k| self.min[k] > max[k] || self.max[k] < min[k]) { + return; + } + out.extend(self.items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| id)); + for child in &self.children { + child.query(min, max, out); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn indexed_candidates_include_every_brute_force_overlap() { + let items: Vec<_> = (0..1024) + .map(|i| { + let x = (i % 32) as f64 - 16.; + let y = (i / 32) as f64 - 16.; + (i, [x, y], [x + 0.5, y + 0.75]) + }) + .collect(); + let index = Index::new(items.clone()); + for (min, max) in [([-0.5, -2.], [3.5, 4.]), ([-16., -16.], [-16., -16.]), ([16., 16.], [17., 17.]), ([-4.75, -7.5], [-4.5, -7.25])] { + let mut actual = Vec::new(); + index.query(min, max, &mut actual); + for (id, a, b) in &items { + if (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k]) { + assert!(actual.contains(id)); + } + } + let expected: Vec<_> = items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| *id).collect(); + actual.sort_unstable(); + assert_eq!(actual, expected); + let unique: std::collections::BTreeSet<_> = actual.iter().collect(); + assert_eq!(unique.len(), actual.len()); + } + } +} diff --git a/crates/cam/src/stock_mesh.rs b/crates/cam/src/stock_mesh.rs new file mode 100644 index 00000000..ce5e6bc1 --- /dev/null +++ b/crates/cam/src/stock_mesh.rs @@ -0,0 +1,728 @@ +//! Conservative boundary extraction from non-overlapping rectangular height cells. +//! Shared planar edges and vertical steps are split before triangulation. No +//! XY snapping, interpolation or generation algorithm is involved. Tiny adaptive +//! saddle contacts may be filled upward within a declared export error budget. +use crate::{ + Error, Result, invalid, + verify::{Cell, Report}, +}; +use solidcraft_geom::Vec3; +use std::{ + collections::{BTreeMap, BTreeSet}, + future::Future, +}; + +/// Count elementary topology work, including sorting and manifold validation. +/// A yield callback which is immediately ready retains the synchronous API. +struct Work { + cancelled: C, + yield_control: Y, + remaining: usize, +} +impl bool, Y: FnMut() -> F, F: Future> Work { + async fn tick(&mut self) -> Result<()> { + if self.remaining == 0 { + if (self.cancelled)() { + return invalid("stock extraction cancelled"); + } + (self.yield_control)().await; + if (self.cancelled)() { + return invalid("stock extraction cancelled"); + } + self.remaining = 256; + } + self.remaining -= 1; + Ok(()) + } + // Sort small runs, then merge with checkpoints. A single standard-library + // sort of up to two million cell events otherwise monopolizes a browser poll. + async fn sort(&mut self, values: &mut Vec, compare: impl Fn(&T, &T) -> std::cmp::Ordering) -> Result<()> { + for run in values.chunks_mut(256) { + if run.len() == 256 { + self.remaining = 0; + } + self.tick().await?; + run.sort_unstable_by(&compare); + } + if values.len() <= 256 { + return Ok(()); + } + let mut scratch = Vec::with_capacity(values.len()); + let mut width = 256; + while width < values.len() { + for begin in (0..values.len()).step_by(width * 2) { + let middle = (begin + width).min(values.len()); + let end = (middle + width).min(values.len()); + let (mut a, mut b) = (begin, middle); + while a < middle || b < end { + self.tick().await?; + let take_a = b == end || (a < middle && compare(&values[a], &values[b]).is_le()); + let index = if take_a { + let i = a; + a += 1; + i + } else { + let i = b; + b += 1; + i + }; + scratch.push(values[index].clone()); + } + } + std::mem::swap(values, &mut scratch); + scratch.clear(); + width *= 2; + } + Ok(()) + } +} +fn ready(future: impl Future>) -> Result { + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(result) => result, + std::task::Poll::Pending => invalid("synchronous stock extraction unexpectedly yielded"), + } +} + +const MAX_CELLS: usize = 60_000; +const MAX_TRIANGLES: usize = 500_000; +// One percent of the post budget, reserved only for conservative saddle closure. +// This is an artifact approximation; it never changes the verification report. +const MAX_SADDLE_FILL: f64 = crate::STOCK_EXPORT_ERROR; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Coord(f64); +impl Eq for Coord {} +impl PartialOrd for Coord { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Coord { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) } + } +} +type Point = [Coord; 3]; +type XY = [Coord; 2]; +fn point(p: Vec3) -> Point { + [Coord(p.x), Coord(p.y), Coord(p.z)] +} + +#[derive(Clone, Copy)] +struct Event { + at: Coord, + cell: usize, + side: usize, + start: bool, +} +struct Span { + axis: usize, + fixed: f64, + a: f64, + b: f64, + cells: [Option; 2], +} +impl Span { + fn xy(&self, t: f64) -> XY { + if self.axis == 0 { [Coord(self.fixed), Coord(t)] } else { [Coord(t), Coord(self.fixed)] } + } + fn vertex(&self, t: f64, z: f64) -> Vec3 { + let xy = self.xy(t); + Vec3::new(xy[0].0, xy[1].0, z) + } +} + +async fn cells bool, Y: FnMut() -> F, F: Future>(report: &Report, bottom: f64, work: &mut Work) -> Result> { + work.tick().await?; + if !bottom.is_finite() || report.cells.is_empty() || report.cells.len() > 1_000_000 { + return invalid("invalid stock export bounds"); + } + if !report.stock_complete || report.findings.iter().any(|f| f.kind == "budget_exhausted") { + return invalid("stock verification is incomplete; cannot export remaining stock"); + } + for c in &report.cells { + work.tick().await?; + for p in [Vec3::new(c.min[0], c.min[1], bottom), Vec3::new(c.max[0], c.max[1], c.high)] { + crate::model::point(p)?; + } + if c.min[0] >= c.max[0] || c.min[1] >= c.max[1] || !c.low.is_finite() || c.low > c.high || c.high < bottom { + return invalid("invalid stock cell"); + } + } + // Reject overlap in O(n log n). End events precede starts at the same X. + let mut events = Vec::with_capacity(report.cells.len() * 2); + for (i, c) in report.cells.iter().enumerate() { + work.tick().await?; + events.push((Coord(c.min[0]), true, i)); + events.push((Coord(c.max[0]), false, i)); + } + work.sort(&mut events, Ord::cmp).await?; + let mut active = BTreeMap::::new(); + for (_, start, i) in events { + work.tick().await?; + let c = &report.cells[i]; + let y = Coord(c.min[1]); + if start { + if active.range(..=y).next_back().is_some_and(|(_, end)| *end > c.min[1]) + || active.range(y..).next().is_some_and(|(start, _)| start.0 < c.max[1]) + { + return invalid("overlapping stock cells"); + } + active.insert(y, c.max[1]); + } else { + active.remove(&y); + } + } + // Exact coplanar rectangle merging reduces output without changing height. + let mut cells = Vec::new(); + for c in &report.cells { + work.tick().await?; + if c.high > bottom { + cells.push(c.clone()); + } + } + for _ in 0..24 { + let before = cells.len(); + for axis in 0..2 { + let other = 1 - axis; + work.sort(&mut cells, |a, b| { + a.min[other] + .total_cmp(&b.min[other]) + .then(a.max[other].total_cmp(&b.max[other])) + .then(a.high.total_cmp(&b.high)) + .then(a.min[axis].total_cmp(&b.min[axis])) + }) + .await?; + let mut merged: Vec = Vec::new(); + for c in cells { + work.tick().await?; + if let Some(last) = merged.last_mut() + && last.min[other] == c.min[other] + && last.max[other] == c.max[other] + && last.high == c.high + && last.max[axis] == c.min[axis] + { + last.max[axis] = c.max[axis]; + } else { + merged.push(c); + } + } + cells = merged; + } + if cells.len() == before { + break; + } + } + if cells.len() > MAX_CELLS { + return invalid(format!("stock mesh has {} merged cells, exceeding 60000; use validation JSON for this resolution", cells.len())); + } + Ok(cells) +} + +async fn spans bool, Y: FnMut() -> F, F: Future>(cells: &[Cell], work: &mut Work) -> Result> { + let mut lines: BTreeMap<(usize, Coord), Vec> = BTreeMap::new(); + for (i, c) in cells.iter().enumerate() { + work.tick().await?; + for axis in 0..2 { + for (fixed, side) in [(c.min[axis], 1), (c.max[axis], 0)] { + let events = lines.entry((axis, Coord(fixed))).or_default(); + events.push(Event { at: Coord(c.min[1 - axis]), cell: i, side, start: true }); + events.push(Event { at: Coord(c.max[1 - axis]), cell: i, side, start: false }); + } + } + } + let mut spans = Vec::new(); + for ((axis, fixed), mut events) in lines { + work.sort(&mut events, |a, b| (a.at, a.start).cmp(&(b.at, b.start))).await?; + let mut active = [None; 2]; + let mut previous: Option = None; + for event in events { + work.tick().await?; + if let Some(a) = previous + && a < event.at + && active != [None; 2] + { + spans.push(Span { axis, fixed: fixed.0, a: a.0, b: event.at.0, cells: active }); + } + previous = Some(event.at); + if event.start { + if active[event.side].is_some() { + return invalid("overlapping stock boundaries"); + } + active[event.side] = Some(event.cell); + } else { + active[event.side] = None; + } + } + } + Ok(spans) +} + +async fn close_tiny_saddles bool, Y: FnMut() -> F, F: Future>( + cells: &mut [Cell], + spans: &[Span], + bottom: f64, + work: &mut Work, +) -> Result<()> { + // NW, NE, SE, SW occupancy at every boundary endpoint, including T-junctions. + let mut vertices = BTreeMap::; 4]>::new(); + for s in spans { + for (side, id) in s.cells.iter().enumerate() { + work.tick().await?; + let Some(id) = id else { continue }; + let (a, b) = match (s.axis, side) { + (0, 0) => (0, 3), + (0, 1) => (1, 2), + (1, 0) => (2, 3), + _ => (1, 0), + }; + vertices.entry(s.xy(s.a)).or_insert([None; 4])[a] = Some(*id); + vertices.entry(s.xy(s.b)).or_insert([None; 4])[b] = Some(*id); + } + } + let mut ceilings = Vec::with_capacity(cells.len()); + for c in cells.iter() { + work.tick().await?; + ceilings.push(c.high + MAX_SADDLE_FILL); + } + // Monotone bounded closure. Stop rather than silently exceeding either the + // error or work bound; the final manifold validator rejects unresolved cases. + for _ in 0..8 { + let mut changed = false; + for ids in vertices.values() { + work.tick().await?; + let heights = ids.map(|id| id.map_or(bottom, |i| cells[i].high)); + for (upper, lower) in [([0, 2], [1, 3]), ([1, 3], [0, 2])] { + let threshold = heights[upper[0]].min(heights[upper[1]]); + let pick = if heights[lower[0]] >= heights[lower[1]] { lower[0] } else { lower[1] }; + if threshold > heights[pick] + && let Some(id) = ids[pick] + && threshold <= ceilings[id] + { + cells[id].high = threshold; + changed = true; + } + } + } + if !changed { + break; + } + } + for _ in vertices { + work.tick().await?; + } + Ok(()) +} + +pub(crate) fn validate_triangles(triangles: &[[Vec3; 3]]) -> Result<()> { + ready(validate(triangles, &mut Work { cancelled: || false, yield_control: || std::future::ready(()), remaining: 0 })) +} +async fn validate bool, Y: FnMut() -> F, F: Future>(triangles: &[[Vec3; 3]], work: &mut Work) -> Result<()> { + if triangles.len() > MAX_TRIANGLES { + return invalid("stock triangle budget exceeded"); + } + for t in triangles { + work.tick().await?; + for p in t { + crate::model::point(*p)?; + } + if (t[1] - t[0]).cross(t[2] - t[0]).len2() == 0. { + return invalid("degenerate stock triangle"); + } + } + + // Edge incidence plus connected vertex links reject cracks, T-junctions, + // touching components and non-manifold saddle edges explicitly. + let mut edges = BTreeMap::<(Point, Point), (usize, i32)>::new(); + let mut links = BTreeMap::>::new(); + for t in triangles { + work.tick().await?; + let p = t.map(point); + for (a, b, c) in [(p[0], p[1], p[2]), (p[1], p[2], p[0]), (p[2], p[0], p[1])] { + let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) }; + let edge = edges.entry(key).or_default(); + edge.0 += 1; + edge.1 += sign; + if edge.0 > 2 { + return invalid(format!("stock boundary is non-manifold at {key:?}; use validation JSON")); + } + links.entry(a).or_default().push((b, c)); + } + } + for (_, edge) in edges { + work.tick().await?; + if edge != (2, 0) { + return invalid("stock boundary has an unstitched or misoriented edge"); + } + } + // Consume the maps incrementally too: dropping a large tree all at once + // can be as visible as constructing it on the browser's event loop. + for (_, link) in links { + let mut next = BTreeMap::new(); + for (a, b) in &link { + work.tick().await?; + if next.insert(*a, *b).is_some() { + return invalid("non-manifold stock vertex"); + } + } + let Some((&start, _)) = next.first_key_value() else { + continue; + }; + let mut at = start; + for _ in 0..link.len() { + work.tick().await?; + let Some(b) = next.remove(&at) else { + return invalid("disconnected stock vertex surface; use validation JSON"); + }; + at = b; + } + if at != start || !next.is_empty() { + return invalid("open stock vertex surface"); + } + } + Ok(()) +} + +struct Mesh { + triangles: Vec<[Vec3; 3]>, +} +impl Mesh { + fn triangle(&mut self, a: Vec3, b: Vec3, c: Vec3) -> Result<()> { + if self.triangles.len() >= MAX_TRIANGLES { + return invalid("stock mesh exceeds 500000 triangles"); + } + if (b - a).cross(c - a).len2() == 0.0 { + return invalid("degenerate stock boundary triangle"); + } + self.triangles.push([a, b, c]); + Ok(()) + } + async fn convex bool, Y: FnMut() -> F, F: Future>( + &mut self, + boundary: &[Vec3], + reverse: bool, + work: &mut Work, + ) -> Result<()> { + // All faces are rectangles with collinear boundary subdivisions. Clip + // the smallest nonzero corner first, preserving every boundary vertex. + // An ordered corner set bounds this to O(n log n), including long edges. + let n = boundary.len(); + if !(3..=MAX_TRIANGLES).contains(&n) { + return invalid("invalid stock polygon size"); + } + let mut prev: Vec<_> = (0..n).map(|i| (i + n - 1) % n).collect(); + let mut next: Vec<_> = (0..n).map(|i| (i + 1) % n).collect(); + let area = |a: usize, b: usize, c: usize| Coord((boundary[b] - boundary[a]).cross(boundary[c] - boundary[b]).len2()); + let mut corners = BTreeSet::new(); + for i in 0..n { + work.tick().await?; + let a = area(prev[i], i, next[i]); + if a.0 > 0.0 { + corners.insert((a, i)); + } + } + for _ in 0..n - 2 { + work.tick().await?; + let (_, i) = corners.pop_first().ok_or_else(|| Error("degenerate stock polygon".into()))?; + let a = prev[i]; + let b = next[i]; + if reverse { + self.triangle(boundary[a], boundary[b], boundary[i])?; + } else { + self.triangle(boundary[a], boundary[i], boundary[b])?; + } + corners.remove(&(area(prev[a], a, next[a]), a)); + corners.remove(&(area(prev[b], b, next[b]), b)); + next[a] = b; + prev[b] = a; + for k in [a, b] { + let v = area(prev[k], k, next[k]); + if v.0 > 0.0 { + corners.insert((v, k)); + } + } + } + Ok(()) + } +} + +async fn boundary bool, Y: FnMut() -> F, F: Future>(report: &Report, bottom: f64, work: &mut Work) -> Result { + let mut cells = cells(report, bottom, work).await?; + let spans = spans(&cells, work).await?; + close_tiny_saddles(&mut cells, &spans, bottom, work).await?; + let mut levels = BTreeMap::>::new(); + for s in &spans { + work.tick().await?; + for xy in [s.xy(s.a), s.xy(s.b)] { + let z = levels.entry(xy).or_default(); + z.insert(Coord(bottom)); + for id in s.cells.into_iter().flatten() { + z.insert(Coord(cells[id].high)); + } + } + } + let mut mesh = Mesh { triangles: Vec::new() }; + let mut tops: Vec> = vec![Vec::new(); cells.len()]; + for s in &spans { + work.tick().await?; + for (side, id) in s.cells.iter().enumerate() { + let Some(id) = id else { + continue; + }; + let c = &cells[*id]; + let edge = match (s.axis, side) { + (1, 1) => 0, + (0, 0) => 1, + (1, 0) => 2, + _ => 3, + }; + let a = if edge < 2 { s.a } else { s.b }; + tops[*id].push((edge, Coord(if edge < 2 { a } else { -a }), s.vertex(a, c.high))); + } + let heights = s.cells.map(|id| id.map_or(bottom, |id| cells[id].high)); + if heights[0] == heights[1] { + continue; + } + let lo = heights[0].min(heights[1]); + let hi = heights[0].max(heights[1]); + let mut polygon = vec![s.vertex(s.a, lo), s.vertex(s.b, lo)]; + let right = levels.get(&s.xy(s.b)).ok_or_else(|| Error("missing stock edge levels".into()))?; + for z in right.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))) { + work.tick().await?; + polygon.push(s.vertex(s.b, z.0)); + } + polygon.push(s.vertex(s.b, hi)); + polygon.push(s.vertex(s.a, hi)); + let left = levels.get(&s.xy(s.a)).ok_or_else(|| Error("missing stock edge levels".into()))?; + for z in left.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))).rev() { + work.tick().await?; + polygon.push(s.vertex(s.a, z.0)); + } + mesh.convex(&polygon, (s.axis == 0) != (heights[0] > heights[1]), work).await?; + } + for mut top in tops { + work.sort(&mut top, |a, b| (a.0, a.1).cmp(&(b.0, b.1))).await?; + let mut polygon: Vec<_> = top.into_iter().map(|p| p.2).collect(); + mesh.convex(&polygon, false, work).await?; + for p in &mut polygon { + p.z = bottom; + } + mesh.convex(&polygon, true, work).await?; + } + for _ in levels { + work.tick().await?; + } + validate(&mesh.triangles, work).await?; + Ok(mesh) +} + +pub(crate) fn triangles(report: &Report, bottom: f64) -> Result> { + ready(triangles_cooperative(report, bottom, || false, || std::future::ready(()))) +} +pub(crate) async fn triangles_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result> { + let mut work = Work { cancelled, yield_control, remaining: 0 }; + Ok(boundary(report, bottom, &mut work).await?.triangles) +} + +pub(crate) fn stl(report: &Report, bottom: f64) -> Result { + ready(stl_cooperative(report, bottom, || false, || std::future::ready(()))) +} +pub(crate) async fn stl_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result { + let mut work = Work { cancelled, yield_control, remaining: 0 }; + let mesh = boundary(report, bottom, &mut work).await?.triangles; + let mut out = String::from("solid cam_remaining_stock\n"); + for triangle in mesh { + work.tick().await?; + out.push_str("facet normal 0 0 0\nouter loop\n"); + for p in triangle { + out.push_str(&format!("vertex {} {} {}\n", p.x, p.y, p.z)); + } + out.push_str("endloop\nendfacet\n"); + if out.len() > 128 * 1024 * 1024 { + return invalid("stock mesh exceeds 128 MiB"); + } + } + out.push_str("endsolid cam_remaining_stock\n"); + work.remaining = 0; + work.tick().await?; + Ok(out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::verify::Status; + fn cell(min: [f64; 2], max: [f64; 2], high: f64) -> Cell { + Cell { min, max, low: high, high, target_low: high, target_high: high } + } + fn report(cells: Vec) -> Report { + Report { + version: crate::VERSION.into(), + fingerprint: "analytic".into(), + status: Status::Pass, + finished_dimensions: Status::Pass, + post_version: "analytic".into(), + tolerance: 0.01, + uncertainty: 0.0025, + maximum_height_interval: 0.0, + stock_complete: true, + stock: None, + cells, + findings: vec![], + cutting_distance: 0.0, + rapid_distance: 0.0, + cutting_minutes: 0.0, + remaining_volume_low: 0.0, + remaining_volume_high: 0.0, + work: 0, + } + } + fn check(cells: Vec, volume: f64, area: f64) -> Vec<[Vec3; 3]> { + let text = stl(&report(cells), 0.0).unwrap(); + let mesh = crate::compare::read_stl(text.as_bytes()).unwrap(); + let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum(); + let a: f64 = mesh.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).len() / 2.0).sum(); + assert!((v - volume).abs() < 1e-9, "volume {v} expected {volume}"); + assert!((a - area).abs() < 1e-9, "area {a} expected {area}"); + mesh + } + #[test] + fn box_and_step_have_only_exterior_faces() { + check(vec![cell([0., 0.], [1., 1.], 1.)], 1., 6.); + let mesh = check(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 0.], [2., 1.], 2.)], 3., 14.); + for t in mesh { + if t.iter().all(|p| p.x == 1.) { + assert!(t.iter().all(|p| p.z >= 1.)); + } + } + } + #[test] + fn adaptive_t_junctions_and_three_height_vertices_are_stitched() { + check(vec![cell([0., 0.], [1., 2.], 1.), cell([1., 0.], [2., 1.], 2.), cell([1., 1.], [2., 2.], 3.)], 7., 26.); + } + #[test] + fn hole_and_disconnected_stock_are_watertight() { + let mut cells = Vec::new(); + for x in 0..3 { + for y in 0..3 { + cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], if x == 1 && y == 1 { 0. } else { 1. })); + } + } + check(cells, 8., 32.); + check(vec![cell([0., 0.], [1., 1.], 1.), cell([2., 0.], [3., 1.], 1.)], 2., 12.); + } + #[test] + fn nonmanifold_touching_stock_is_explicitly_rejected() { + assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 1.], [2., 2.], 1.)]), 0.).is_err()); + assert!( + stl( + &report(vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 1.), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)]), + 0. + ) + .is_err() + ); + } + #[test] + fn tiny_adaptive_saddle_closure_is_conservative_and_bounded() { + let cells = + vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 2. - 4e-6), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)]; + let mut original = report(cells); + original.stock = Some(crate::Stock { min: Vec3::new(0., 0., 0.), max: Vec3::new(2., 2., 2.) }); + let mesh = triangles(&original, 0.).unwrap(); + let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.).sum(); + assert!((volume - 7.).abs() < 1e-12); + assert_eq!(original.cells[1].high, 2. - 4e-6); + assert_eq!(crate::compare::stock_mesh(&original, &mesh, 0., crate::TOLERANCE).unwrap().status, Status::Pass); + let mut too_large = original; + too_large.cells[1].high = 2. - 2. * MAX_SADDLE_FILL; + assert!(triangles(&too_large, 0.).is_err()); + } + #[test] + fn hostile_and_overlapping_cells_are_rejected() { + for bad in [cell([0., 0.], [0., 1.], 1.), cell([0., 0.], [1., 1.], f64::NAN), cell([0., 0.], [1., 1.], -1.)] { + assert!(stl(&report(vec![bad]), 0.).is_err()); + } + assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([1., 1.], [3., 3.], 2.)]), 0.).is_err()); + assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([0., 0.], [2., 2.], 1.)]), 0.).is_err()); + } + #[test] + fn zero_stock_and_translated_base_are_preserved() { + assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 0.)]), 0.).unwrap().lines().count() == 2); + let mesh = triangles(&report(vec![cell([-3., 2.], [-2., 3.], 7.)]), 5.).unwrap(); + let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum(); + assert!((v - 2.).abs() < 1e-9); + } + #[test] + fn incomplete_verification_cannot_export_partial_stock() { + let input = crate::corpus::fixture("face").unwrap(); + let path = crate::generate::generate(input.clone()).unwrap(); + let nc = crate::nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + for limits in [crate::verify::Limits { cells: 1, ..Default::default() }, crate::verify::Limits { work: 1, ..Default::default() }] { + let report = crate::verify::simulate(&input, &nc, limits).unwrap(); + assert!(!report.stock_complete); + assert!(stl(&report, 0.).is_err()); + } + } + #[test] + fn cooperative_mesh_matches_sync_and_cancels_during_topology_work() { + use std::task::{Context, Poll, Waker}; + let report = report( + (0..32) + .rev() + .flat_map(|x| (0..32).rev().map(move |y| cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64))) + .collect(), + ); + let expected = triangles(&report, 0.).unwrap(); + let yield_once = || { + let mut first = true; + std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) }) + }; + let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || false, yield_once)); + let mut cx = Context::from_waker(Waker::noop()); + let mut polls = 0; + loop { + polls += 1; + if let Poll::Ready(result) = future.as_mut().poll(&mut cx) { + assert_eq!(result.unwrap(), expected); + break; + } + assert!(polls < 100_000); + } + assert!(polls > 100); + // Cancel in cell sorting, boundary work and late manifold validation. + // Each suspended future must fail on its next poll without a partial mesh. + for cancel_after in [1, polls / 2, polls - 1] { + let cancelled = std::cell::Cell::new(false); + let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || cancelled.get(), yield_once)); + for _ in 0..cancel_after { + assert!(future.as_mut().poll(&mut cx).is_pending()); + } + cancelled.set(true); + let result = future.as_mut().poll(&mut cx); + assert!(matches!(result, Poll::Ready(Err(e)) if e.0.contains("cancelled"))); + } + } + #[test] + fn stepped_grid_preserves_volume_and_surface_area() { + let n = 16; + let mut cells = Vec::new(); + for x in 0..n { + for y in 0..n { + cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64)); + } + } + check(cells, (n * n * n) as f64, (6 * n * n + 2 * n * (n - 1)) as f64); + } +} diff --git a/crates/cam/src/surface.rs b/crates/cam/src/surface.rs new file mode 100644 index 00000000..eb4c3b9c --- /dev/null +++ b/crates/cam/src/surface.rs @@ -0,0 +1,308 @@ +//! Local no-gouge certificates for the vertical height-field target solid. +//! +//! To bound distance to the solid's complement, choose an XY point q with a +//! conservative upper target height H(q). Every point p in a candidate removed +//! column has an exterior point (q, max(p.z,H(q))) at distance no more than +//! hypot(max_xy_distance(p,q), max(H(q)-column_low,0)). This covers whole cells, +//! not sampled surface points, and remains valid for overlapping target bodies. +use crate::{ + spatial::Index, + verify::{Limits, Status}, + *, +}; +use solidcraft_geom::Vec3; + +pub(crate) struct Surface<'a> { + triangles: &'a [[Vec3; 3]], + index: Index, + bottom: f64, + guard: f64, +} +pub(crate) struct Region { + pub center: [f64; 2], + pub half: f64, + pub low: f64, + pub high: f64, + pub target_high: f64, + pub tolerance: f64, +} +impl<'a> Surface<'a> { + pub(crate) fn new(triangles: &'a [[Vec3; 3]], bottom: f64) -> Result { + let extent = triangles.iter().flatten().map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs())).fold(1., f64::max); + let guard = 128. * f64::EPSILON * extent; + let items: Vec<_> = triangles + .iter() + .enumerate() + .filter(|(_, t)| (t[1] - t[0]).cross(t[2] - t[0]).z > 0.) + .map(|(id, t)| { + let min = t.iter().copied().fold(t[0], Vec3::min); + let max = t.iter().copied().fold(t[0], Vec3::max); + (id, [min.x - guard, min.y - guard], [max.x + guard, max.y + guard]) + }) + .collect(); + if items.is_empty() { + return invalid("protected target has no upward height-field surface"); + } + Ok(Self { triangles, index: Index::new(items), bottom, guard }) + } + pub(crate) fn bounds(&self, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) { + let mut ids = Vec::new(); + self.index.query([p[0] - half, p[1] - half], [p[0] + half, p[1] + half], &mut ids); + let local: Vec<_> = ids.into_iter().map(|i| self.triangles[i]).collect(); + crate::verify::target_height(p, half, &local, self.bottom, work, budget) + } + async fn height>( + &self, + p: [f64; 2], + work: &mut usize, + limits: Limits, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result> { + let mut ids = Vec::new(); + self.index.query([p[0] - self.guard, p[1] - self.guard], [p[0] + self.guard, p[1] + self.guard], &mut ids); + let mut height = self.bottom; + for (count, id) in ids.into_iter().enumerate() { + if count % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("surface verification cancelled"); + } + } + *work += 1; + if *work > limits.work { + return Ok(None); + } + let t = &self.triangles[id]; + if let Some((_, upper)) = height_interval(t, p, self.guard) { + height = height.max(upper); + } + } + Ok(Some(height + self.guard)) + } + pub(crate) async fn protect>( + &self, + r: Region, + work: &mut usize, + limits: Limits, + cancelled: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result { + let tolerance = r.tolerance - POST_ERROR - VERIFY_ERROR - self.guard; + if tolerance > 0. && r.target_high + self.guard - r.low <= tolerance { + return Ok(Status::Pass); + } + if r.half < tolerance { + // The search locates a certificate. Failure to find one never proves + // a gouge and never becomes a clearance pass. + let reach = tolerance - r.half; + for scale in [0., 0.5, 0.95] { + for direction in 0..8 { + if scale == 0. && direction != 0 { + continue; + } + let angle = direction as f64 * std::f64::consts::FRAC_PI_4; + let offset = scale * reach; + let q = [r.center[0] + offset * angle.cos(), r.center[1] + offset * angle.sin()]; + let Some(height) = self.height(q, work, limits, cancelled, yield_control).await? else { + return Ok(Status::Inconclusive); + }; + // Include coordinate rounding in the actual computed offset. + let lateral = r.half + (q[0] - r.center[0]).hypot(q[1] - r.center[1]); + if lateral.hypot((height - r.low).max(0.)) + self.guard <= tolerance { + return Ok(Status::Pass); + } + } + } + } + // A positive failure certificate: a ball larger than the tolerance is + // contained in one target triangle's downward extrusion, while its center + // is definitely removed. No coverage sums or assumed mesh watertightness. + let radius = r.tolerance + POST_ERROR + VERIFY_ERROR + self.guard; + let z = (r.high + self.guard).max(self.bottom + 2. * radius); + if z - radius > self.bottom { + let mut ids = Vec::new(); + self.index.query(r.center, r.center, &mut ids); + for (count, id) in ids.into_iter().enumerate() { + if count % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("surface verification cancelled"); + } + } + *work += 1; + if *work > limits.work { + return Ok(Status::Inconclusive); + } + let t = &self.triangles[id]; + let covered = [[-radius, -radius], [radius, -radius], [radius, radius], [-radius, radius]] + .iter() + .all(|d| height_interval(t, [r.center[0] + d[0], r.center[1] + d[1]], self.guard).is_some_and(|(lower, _)| lower > z + radius)); + if covered { + return Ok(Status::Fail); + } + } + } + Ok(Status::Inconclusive) + } +} + +// Upper is conservative even on a projected triangle edge. Lower is -infinity +// near edges or degenerate projections so it cannot certify an interior ball. +fn height_interval(t: &[Vec3; 3], p: [f64; 2], guard: f64) -> Option<(f64, f64)> { + let n = (t[1] - t[0]).cross(t[2] - t[0]); + if n.z <= 0. { + return None; + } + let mut near = false; + for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { + let edge = (b.x - a.x) * (p[1] - a.y) - (b.y - a.y) * (p[0] - a.x); + let error = guard * (1. + (b.x - a.x).abs() + (b.y - a.y).abs()); + if edge < -error { + return None; + } + near |= edge <= error; + } + let hi = t.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max); + if near || n.z <= guard * guard { + return Some((f64::NEG_INFINITY, hi + guard)); + } + let z = t[0].z + (n.x * (t[0].x - p[0]) + n.y * (t[0].y - p[1])) / n.z; + let error = guard * (1. + (n.x / n.z).abs() + (n.y / n.z).abs()); + if !z.is_finite() || !error.is_finite() { + return Some((f64::NEG_INFINITY, hi + guard)); + } + Some((z - error, (z + error).min(hi + guard))) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn plate(lo: f64, hi: f64, z: f64) -> Vec<[Vec3; 3]> { + let a = Vec3::new(lo, lo, z); + let b = Vec3::new(hi, lo, z); + let c = Vec3::new(hi, hi, z); + let d = Vec3::new(lo, hi, z); + vec![[a, b, c], [a, c, d]] + } + fn protect(s: &Surface, r: Region, budget: usize) -> Status { + use std::future::Future; + let mut ready = || std::future::ready(()); + let mut work = 0; + let mut future = std::pin::pin!(s.protect(r, &mut work, Limits { work: budget, ..Default::default() }, &|| false, &mut ready)); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(r) => r.unwrap(), + _ => Status::Inconclusive, + } + } + fn region(x: f64, half: f64, low: f64, high: f64) -> Region { + Region { center: [x, 1.], half, low, high, target_high: 5., tolerance: 0.01 } + } + #[test] + fn vertical_boundary_is_certified_but_interior_gouge_fails() { + let tris = plate(0., 2., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1000), Status::Pass); + assert_eq!(protect(&s, region(0.1, 0.001, 4.9, 4.9), 1000), Status::Fail); + assert_eq!(protect(&s, region(0.001, 0.1, 1., 5.), 1000), Status::Inconclusive); + } + #[test] + fn overlapping_top_faces_do_not_create_false_exterior_points() { + let mut tris = plate(0., 2., 5.); + tris.extend(plate(-1., 3., 6.)); + let s = Surface::new(&tris, 0.).unwrap(); + let mut r = region(0.001, 0.001, 4.9, 4.9); + r.target_high = 6.; + assert_eq!(protect(&s, r, 1000), Status::Fail); + } + #[test] + fn budget_exhaustion_is_inconclusive() { + let tris = plate(0., 2., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1), Status::Inconclusive); + } + #[test] + fn missing_upward_faces_are_rejected() { + assert!(Surface::new(&[[Vec3::ZERO, Vec3::X, Vec3::X]], 0.).is_err()); + } + #[test] + fn shifted_coordinates_preserve_height_bounds() { + let tris = plate(999999., 1000001., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + let mut work = 0; + let (lo, hi, uncertain) = s.bounds([1000000., 1000000.], 0.001, &mut work, 1000); + assert!(!uncertain); + assert_eq!(lo, 5.); + assert_eq!(hi, 5.); + } + fn drilled_plate() -> JobInput { + corpus::fixture("drill").unwrap() + } + + #[test] + fn protected_drilled_plate_certifies_with_its_wall_and_cone() { + let input = drilled_plate(); + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, Status::Pass, "{:?}", report.findings); + assert_eq!(report.finished_dimensions, Status::Pass); + } + #[test] + fn wrong_hole_against_protected_cad_surface_is_a_gouge() { + let input = drilled_plate(); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap().replace("X10.000000", "X10.100000"); + let parsed = nc::parse(&code, &input.tools).unwrap(); + let report = verify::simulate(&input, &parsed, Default::default()).unwrap(); + assert_eq!(report.status, Status::Fail, "{:?}", report.findings); + assert!(report.findings.iter().any(|f| f.kind == "gouge")); + } + #[test] + fn through_gouges_and_sloping_surfaces_use_spatial_distance() { + let mut tris = plate(0., 2., 5.); + let s = Surface::new(&tris, 0.).unwrap(); + assert_eq!(protect(&s, region(0.1, 0.001, 0., 0.), 1000), Status::Fail); + for t in &mut tris { + for p in t { + p.z = 5. + 10. * p.x; + } + } + let s = Surface::new(&tris, 0.).unwrap(); + let mut r = region(0.1, 0.001, 5.97, 5.97); + r.target_high = 6.02; + assert_eq!(protect(&s, r, 1000), Status::Pass); + let mut r = region(0.1, 0.001, 5.8, 5.8); + r.target_high = 6.02; + assert_eq!(protect(&s, r, 1000), Status::Fail); + } + #[test] + fn surface_queries_yield_and_cancel() { + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + let tris: Vec<_> = (0..5000).flat_map(|_| plate(0., 2., 5.)).collect(); + let s = Surface::new(&tris, 0.).unwrap(); + let mut work = 0; + let cancel = std::cell::Cell::new(false); + let cancelled = || cancel.get(); + let mut yielding = || { + let mut first = true; + std::future::poll_fn(move |_| { + if first { + first = false; + Poll::Pending + } else { + Poll::Ready(()) + } + }) + }; + let mut future = std::pin::pin!(s.protect(region(0.1, 0.001, 4.9, 4.9), &mut work, Default::default(), &cancelled, &mut yielding)); + let mut context = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut context).is_pending()); + cancel.set(true); + assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_)))); + } +} diff --git a/crates/cam/src/verify.rs b/crates/cam/src/verify.rs new file mode 100644 index 00000000..782b55df --- /dev/null +++ b/crates/cam/src/verify.rs @@ -0,0 +1,644 @@ +//! Independent bounded stock verifier. Adaptive cells carry height intervals; +//! unresolved intervals and exhausted work budgets never become a pass. +use crate::{ + nc::{Parsed, Segment}, + *, +}; +use serde::{Deserialize, Serialize}; +use solidcraft_geom::Vec3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Pass, + Fail, + Inconclusive, +} +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + Information, + #[default] + Unresolved, + Error, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Finding { + #[serde(default)] + pub severity: FindingSeverity, + pub kind: String, + pub line: Option, + /// One-based interpreted NC record, not a tessellated arc segment. + #[serde(default)] + pub record: Option, + /// Present only for an independently checked SolidCraft post round trip. + #[serde(default)] + pub source: Option, + pub at: Vec3, + pub detail: String, +} +impl Finding { + fn priority(&self) -> (FindingSeverity, bool) { + (self.severity, self.line.is_none()) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Cell { + pub min: [f64; 2], + pub max: [f64; 2], + pub low: f64, + pub high: f64, + pub target_low: f64, + pub target_high: f64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Report { + pub version: String, + pub fingerprint: String, + pub status: Status, + /// Separate from path clearance. Unsupported finished surfaces stay inconclusive. + pub finished_dimensions: Status, + pub post_version: String, + pub tolerance: f64, + /// Requested spatial refinement budget, not a claim about height uncertainty. + pub uncertainty: f64, + pub maximum_height_interval: f64, + /// False when resource limits prevented evaluating all stock cells/motions. + #[serde(default)] + pub stock_complete: bool, + /// Comparison domain. Legacy reports without this cannot certify coverage. + #[serde(default)] + pub stock: Option, + pub cells: Vec, + pub findings: Vec, + pub cutting_distance: f64, + pub rapid_distance: f64, + pub cutting_minutes: f64, + pub remaining_volume_low: f64, + pub remaining_volume_high: f64, + pub work: usize, +} +impl Report { + /// Bounded representative witnesses. Repeated cells on one NC line must not + /// crowd out a definite failure found later in the verification traversal. + pub(crate) fn finding(&mut self, finding: Finding) { + if self.findings.iter().any(|old| old.kind == finding.kind && old.line == finding.line && old.severity == finding.severity) { + return; + } + if self.findings.len() < 200 { + self.findings.push(finding); + } else if let Some((index, old)) = self.findings.iter().enumerate().min_by_key(|(_, old)| old.priority()) + && old.priority() < finding.priority() + && let Some(slot) = self.findings.get_mut(index) + { + *slot = finding; + } + } +} +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub cells: usize, + pub work: usize, + pub uncertainty: f64, +} +impl Default for Limits { + fn default() -> Self { + Self { cells: 250_000, work: 30_000_000, uncertainty: VERIFY_ERROR } + } +} + +pub fn simulate(input: &JobInput, nc: &Parsed, limits: Limits) -> Result { + simulate_cancellable(input, nc, limits, || false) +} +pub fn simulate_cancellable(input: &JobInput, nc: &Parsed, limits: Limits, cancelled: impl Fn() -> bool) -> Result { + use std::future::Future; + let mut future = std::pin::pin!(simulate_cooperative(input, nc, limits, cancelled, || std::future::ready(()))); + match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(result) => result, + std::task::Poll::Pending => invalid("synchronous verifier unexpectedly yielded"), + } +} +/// Portable host-driven verification. A host supplies a yield future to bound a poll's work. +/// This contains no executor, OS thread, browser or generator dependency. +pub async fn simulate_cooperative>( + input: &JobInput, + nc: &Parsed, + limits: Limits, + cancelled: impl Fn() -> bool, + mut yield_control: impl FnMut() -> F, +) -> Result { + input.validate()?; + if limits.cells == 0 + || limits.cells > 1_000_000 + || limits.work == 0 + || limits.work > 100_000_000 + || !limits.uncertainty.is_finite() + || limits.uncertainty <= 0.0 + || limits.uncertainty > VERIFY_ERROR + { + return invalid("invalid verifier limits"); + } + if nc.work_offset != input.setup.work_offset { + return invalid("NC/setup work-offset mismatch"); + } + if input.geometry.target.is_empty() { + return invalid("no target geometry to verify"); + } + if input.geometry.target.len() + input.geometry.fixtures.len() > 200_000 { + return invalid("verification mesh limit"); + } + for t in input.geometry.target.iter().chain(&input.geometry.fixtures) { + for p in t { + crate::model::point(*p)?; + } + } + let segments = nc::segments(nc, &input.setup)?; + let tol = input.operations.iter().map(|o| o.tolerance).fold(TOLERANCE, f64::min); + let stock = input.setup.stock; + // Only motions whose cutter or non-cutting envelopes can reach stock need + // cell queries. Keep original sequence IDs for machine-state/withdrawal checks. + let motion_index = crate::spatial::Index::new( + segments + .iter() + .enumerate() + .filter_map(|(id, s)| { + let tool = input.tools.iter().find(|t| t.id == s.tool)?; + let z = s.from.z.min(s.to.z); + let mut radius = if z <= stock.max.z + POST_ERROR { tool.diameter / 2. + POST_ERROR } else { 0. }; + for (r, lift) in [(tool.shank_diameter / 2., tool.cutting_length), (tool.holder_diameter / 2., tool.overall_length)] { + if z + lift <= stock.max.z + POST_ERROR { + radius = radius.max(r + POST_ERROR); + } + } + if radius == 0. { + return None; + } + Some(( + id, + [s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius], + [s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius], + )) + }) + .collect(), + ); + let surface = crate::surface::Surface::new(&input.geometry.target, stock.min.z)?; + let finished = crate::dimensions::Finished::new(input, tol)?; + let dimension_resolution = finished.as_ref().map_or(limits.uncertainty, |f| limits.uncertainty.min(f.refinement)); + let mut r = Report { + version: VERSION.into(), + fingerprint: input.fingerprint.clone(), + status: Status::Pass, + finished_dimensions: if finished.is_some() { Status::Pass } else { Status::Inconclusive }, + post_version: "reference-three-axis/1".into(), + tolerance: tol, + uncertainty: limits.uncertainty, + maximum_height_interval: 0.0, + stock_complete: true, + stock: Some(stock), + cells: Vec::new(), + findings: Vec::new(), + cutting_distance: 0.0, + rapid_distance: 0.0, + cutting_minutes: 0.0, + remaining_volume_low: 0.0, + remaining_volume_high: 0.0, + work: 0, + }; + if finished.is_none() { + r.finding(Finding { + record: None, + severity: FindingSeverity::Unresolved, + source: None, + kind: "finished_region_unsupported".into(), + line: None, + at: stock.min, + detail: "Finished-region certification needs a tolerance larger than the post/interpolation error budget".into(), + }); + } + for s in &segments { + let d = s.from.dist(s.to); + if s.rapid { + r.rapid_distance += d; + } else { + r.cutting_distance += d; + r.cutting_minutes += d / s.feed; + } + } + crate::collision::fixtures(input, &segments, limits, &cancelled, &mut yield_control, &mut r).await?; + let mut pending = vec![([stock.min.x, stock.min.y], [stock.max.x, stock.max.y], 0u8)]; + let mut next_yield = 0; + while let Some((min, max, depth)) = pending.pop() { + if r.work >= next_yield { + next_yield = r.work.saturating_add(4096); + yield_control().await; + } + if cancelled() { + return invalid("simulation cancelled"); + } + if r.work > limits.work || r.cells.len() + pending.len() >= limits.cells { + r.stock_complete = false; + r.status = if r.status == Status::Fail { Status::Fail } else { Status::Inconclusive }; + r.finding(Finding { + record: None, + severity: FindingSeverity::Unresolved, + source: None, + kind: "budget_exhausted".into(), + line: None, + at: stock.min, + detail: "Adaptive verification incomplete; increase resource budget or simplify the job".into(), + }); + break; + } + let center = [(min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0]; + let half = ((max[0] - min[0]).hypot(max[1] - min[1])) / 2.0; + let mut low = stock.max.z; + let mut high = stock.max.z; + let mut collision = None; + let mut removal_line = None; + let mut candidates = Vec::new(); + motion_index.query(min, max, &mut candidates); + candidates.sort_unstable(); + for (query_index, segment_index) in candidates.into_iter().enumerate() { + let seg = &segments[segment_index]; + r.work += 1; + if query_index % 4096 == 4095 { + yield_control().await; + if cancelled() { + return invalid("simulation cancelled"); + } + } + if r.work > limits.work { + break; + } + let Some(tool) = input.tools.iter().find(|t| t.id == seg.tool) else { + continue; + }; + let radius = tool.diameter / 2.0; + let (distance, zlo, zhi) = sweep(center, half, seg, radius); + // A vertical drill has exact radial extrema on a rectangular cell. + // Circumscribed disks are unnecessarily loose here and can create + // artificial saddles where coarse and fine cone cells meet. + let (near, far) = if tool.kind == ToolKind::TwistDrill && seg.from.x == seg.to.x && seg.from.y == seg.to.y { + radial_bounds(min, max, [seg.from.x, seg.from.y]) + } else if seg.from.z == seg.to.z || (seg.from.x == seg.to.x && seg.from.y == seg.to.y) { + lateral_bounds(min, max, seg) + } else { + // Sloping motion still uses a common covering disk: its upper + // height bound must cover the entire cell at the same pose. + ((distance - half).max(0.0), distance + half) + }; + let cone_low = if tool.kind == ToolKind::TwistDrill { near / (tool.tip_angle / 2.0).tan() } else { 0.0 }; + let cone_high = if tool.kind == ToolKind::TwistDrill { far / (tool.tip_angle / 2.0).tan() } else { 0.0 }; + let overlaps = near <= radius + POST_ERROR; + // A vertical withdrawal starts at the endpoint of the preceding feed + // with the same tool; this exact geometric fact needs no raster estimate. + let withdrawal = segment_index.checked_sub(1).and_then(|i| segments.get(i)).is_some_and(|previous| { + !previous.rapid + && previous.tool == seg.tool + && previous.to == seg.from + && seg.from.x == seg.to.x + && seg.from.y == seg.to.y + && seg.to.z >= seg.from.z + }); + if seg.rapid && overlaps && !withdrawal { + if zhi + cone_high < low - tol && far <= radius { + collision = Some(("rapid_through_stock", seg.line)); + } else if zlo + cone_low < high - tol { + collision = Some(("rapid_clearance_unresolved", seg.line)); + } + } else if !seg.rapid && overlaps { + low = low.min(zlo + cone_low).max(stock.min.z); + if far <= radius { + let next = high.min(zhi + cone_high).max(stock.min.z); + if next < high { + removal_line = Some(seg.line); + } + high = next; + } + } + // Shank and holder sweeps against remaining stock, separate from cutter removal. + for (rad, lift, label) in [ + (tool.shank_diameter / 2.0, tool.cutting_length, "shank_collision"), + (tool.holder_diameter / 2.0, tool.overall_length, "holder_collision"), + ] { + let (d, z, _) = sweep(center, half, seg, rad); + if d + half < rad && z + lift < low - tol { + collision = Some((label, seg.line)); + } else if d < rad + half && z + lift < high - tol { + collision = Some(("holder_clearance_unresolved", seg.line)); + } + } + } + let (target_low, target_high, _) = surface.bounds(center, (max[0] - min[0]).max(max[1] - min[1]) / 2.0, &mut r.work, limits.work); + let protection = surface + .protect( + crate::surface::Region { center, half, low, high, target_high, tolerance: tol }, + &mut r.work, + limits, + &cancelled, + &mut yield_control, + ) + .await?; + let rest = low - target_high > tol; + let expected = + if let Some(f) = &finished { f.envelope((center, half), &mut r.work, limits.work, &cancelled, &mut yield_control).await? } else { None }; + let dimension = expected.map(|e| e.classify(low, high)); + // Refine both the machined result and the requested finished features: + // an omitted hole must not hide inside one large untouched stock cell. + let unresolved = ((high - low > tol / 2.0 || protection == Status::Inconclusive || collision.is_some()) && half > limits.uncertainty) + || (dimension == Some(Status::Inconclusive) && half > dimension_resolution); + if unresolved && depth < 24 && r.work <= limits.work { + pending.extend([ + (min, center, depth + 1), + ([center[0], min[1]], [max[0], center[1]], depth + 1), + ([min[0], center[1]], [center[0], max[1]], depth + 1), + (center, max, depth + 1), + ]); + continue; + } + let at = Vec3::new(center[0], center[1], low); + if let Some(dimension) = dimension { + if dimension == Status::Fail { + r.finished_dimensions = Status::Fail; + r.finding(Finding { + record: None, + severity: FindingSeverity::Error, + source: None, + kind: "finished_region_error".into(), + line: removal_line, + at, + detail: expected.map_or_else(String::new, |e| e.description()), + }); + } else if dimension == Status::Inconclusive && r.finished_dimensions == Status::Pass { + r.finished_dimensions = Status::Inconclusive; + } + } + if let Some((kind, line)) = collision { + if !kind.ends_with("unresolved") { + r.status = Status::Fail; + } else if r.status == Status::Pass { + r.status = Status::Inconclusive; + } + r.finding(Finding { + record: None, + severity: if kind.ends_with("unresolved") { FindingSeverity::Unresolved } else { FindingSeverity::Error }, + source: None, + kind: kind.into(), + line: Some(line), + at, + detail: "Swept cutter or non-cutting tool envelope intersects remaining stock".into(), + }); + } + if protection == Status::Fail { + r.status = Status::Fail; + r.finding(Finding { + record: None, + severity: FindingSeverity::Error, + source: None, + kind: "gouge".into(), + line: removal_line, + at, + detail: format!("A definitely removed point lies more than {:.6} mm inside the protected height-field solid", tol), + }); + } + // Rest is reported separately: intermediate roughing is not required to finish a part. + if rest && !r.findings.iter().any(|f| f.kind == "remaining_material") { + r.finding(Finding { + record: None, + severity: FindingSeverity::Information, + source: None, + kind: "remaining_material".into(), + line: None, + at, + detail: format!("At least {:.6} mm above target; may be intentional outside selected finished regions", low - target_high), + }); + } + if protection == Status::Inconclusive { + if r.status == Status::Pass { + r.status = Status::Inconclusive; + } + r.finding(Finding { + record: None, + severity: FindingSeverity::Unresolved, + source: None, + kind: "protected_surface_unresolved".into(), + line: removal_line, + at, + detail: "Local surface-distance bounds cannot yet certify the protected CAD boundary at this resolution or work budget".into(), + }); + } + let area = (max[0] - min[0]) * (max[1] - min[1]); + r.remaining_volume_low += area * (low - stock.min.z); + r.remaining_volume_high += area * (high - stock.min.z); + r.maximum_height_interval = r.maximum_height_interval.max(high - low); + r.cells.push(Cell { min, max, low, high, target_low, target_high }); + } + if r.work > limits.work { + r.stock_complete = false; + if r.status == Status::Pass { + r.status = Status::Inconclusive; + } + } + if (!r.stock_complete || r.status != Status::Pass) && r.finished_dimensions == Status::Pass { + r.finished_dimensions = Status::Inconclusive; + } + r.findings.sort_by_key(|finding| std::cmp::Reverse(finding.priority())); + for finding in &mut r.findings { + if let Some(line) = finding.line { + finding.record = nc.record_for_line(line).map(|i| i + 1); + finding.source = nc.source_for_line(line); + } + } + Ok(r) +} + +/// Distance to XY sweep and conservative height range over the part of a segment +/// intersecting a disk expanded by the cell radius. +fn sweep(p: [f64; 2], half: f64, s: &Segment, radius: f64) -> (f64, f64, f64) { + let dx = s.to.x - s.from.x; + let dy = s.to.y - s.from.y; + let l2 = dx * dx + dy * dy; + let t = if l2 > 1e-20 { ((p[0] - s.from.x) * dx + (p[1] - s.from.y) * dy) / l2 } else { 0.0 }; + let c = t.clamp(0.0, 1.0); + let d = (p[0] - s.from.x - c * dx).hypot(p[1] - s.from.y - c * dy); + let minimum = |r: f64| { + if l2 <= 1e-20 { + return s.from.z.min(s.to.z); + } + let perpendicular = ((p[0] - s.from.x).powi(2) + (p[1] - s.from.y).powi(2) - t * t * l2).max(0.0); + let dt = ((r * r - perpendicular).max(0.0) / l2).sqrt(); + let z0 = s.from.z + (s.to.z - s.from.z) * (t - dt).clamp(0.0, 1.0); + let z1 = s.from.z + (s.to.z - s.from.z) * (t + dt).clamp(0.0, 1.0); + z0.min(z1) + }; + (d, minimum(radius + half), minimum((radius - half).max(0.0))) +} +fn radial_bounds(min: [f64; 2], max: [f64; 2], center: [f64; 2]) -> (f64, f64) { + let axis = |i: usize| { + let near = (min[i] - center[i]).max(center[i] - max[i]).max(0.0); + let far = (min[i] - center[i]).abs().max((max[i] - center[i]).abs()); + (near, far) + }; + let (x, y) = (axis(0), axis(1)); + (x.0.hypot(y.0), x.1.hypot(y.1)) +} + +/// Conservative rectangle-to-segment distance bounds. The segment AABB gives +/// an additional lower bound, exact for axis-aligned sweeps. Distance to a +/// convex segment is convex, so its maximum over a rectangle is attained at a +/// corner. Circumscribed disks alone invent overlap along long straight walls +/// and force unnecessary refinement of already resolved stock. +fn lateral_bounds(min: [f64; 2], max: [f64; 2], s: &Segment) -> (f64, f64) { + let a = [s.from.x, s.from.y]; + let b = [s.to.x, s.to.y]; + let delta = [b[0] - a[0], b[1] - a[1]]; + let length2 = delta[0] * delta[0] + delta[1] * delta[1]; + let gap = |k: usize| (min[k] - a[k].max(b[k])).max(a[k].min(b[k]) - max[k]).max(0.); + let distance = |p: [f64; 2]| { + let t = if length2 > 0. { (((p[0] - a[0]) * delta[0] + (p[1] - a[1]) * delta[1]) / length2).clamp(0., 1.) } else { 0. }; + (p[0] - a[0] - t * delta[0]).hypot(p[1] - a[1] - t * delta[1]) + }; + let mut far = 0_f64; + for p in [min, [min[0], max[1]], max, [max[0], min[1]]] { + far = far.max(distance(p)); + } + let center_distance = distance([(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]); + let half = (max[0] - min[0]).hypot(max[1] - min[1]) / 2.; + let extent = min.into_iter().chain(max).chain(a).chain(b).map(f64::abs).fold(1_f64, f64::max); + let guard = 128. * f64::EPSILON * extent; + (((center_distance - half).max(gap(0).hypot(gap(1))) - guard).max(0.), far + guard) +} + +pub(crate) fn target_height(p: [f64; 2], half: f64, tris: &[[Vec3; 3]], bottom: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) { + let mut lo = f64::INFINITY; + let mut hi = bottom; + let mut area = 0.0; + for tri in tris { + *work += 1; + if *work > budget { + return (bottom, hi, true); + } + let [a, b, c] = *tri; + // Positive-Z faces form the top envelope of supported height-field solids. + if (b - a).cross(c - a).z <= 0.0 { + continue; + } + let mut poly = tri.to_vec(); + for (axis, bound, greater) in [(0, p[0] - half, true), (0, p[0] + half, false), (1, p[1] - half, true), (1, p[1] + half, false)] { + let old = std::mem::take(&mut poly); + if old.is_empty() { + break; + } + let value = |v: Vec3| if axis == 0 { v.x } else { v.y }; + let inside = |v: Vec3| if greater { value(v) >= bound } else { value(v) <= bound }; + for (a, b) in old.iter().zip(old.iter().cycle().skip(1)).take(old.len()) { + if inside(*a) { + poly.push(*a); + } + if inside(*a) != inside(*b) { + let t = (bound - value(*a)) / (value(*b) - value(*a)); + poly.push(*a + (*b - *a) * t); + } + } + } + if poly.len() < 3 { + continue; + } + let origin = poly[0]; + let covered = + poly.iter().zip(poly.iter().cycle().skip(1)).take(poly.len()).map(|(a, b)| (*a - origin).cross(*b - origin).z).sum::().abs() / 2.0; + if covered <= 0.0 { + continue; + } + area += covered; + for v in poly { + lo = lo.min(v.z); + hi = hi.max(v.z); + } + } + if area == 0.0 { + return (bottom, bottom, false); + } + let complete = area >= 4.0 * half * half * (1.0 - 1e-8); + (if complete { lo } else { bottom }, hi, !complete) +} +/// Export a conforming conservative stock boundary. Tiny saddle closure may add +/// at most `STOCK_EXPORT_ERROR` to a cell height; it never removes stock. +pub fn stock_stl(report: &Report, bottom: f64) -> Result { + crate::stock_mesh::stl(report, bottom) +} + +/// Incremental extraction and ASCII serialization. Cancellation returns no partial artifact. +pub async fn stock_stl_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result { + crate::stock_mesh::stl_cooperative(report, bottom, cancelled, yield_control).await +} + +/// The same conforming conservative boundary as `stock_stl`, without serialization. +/// Coordinates are in the setup frame. An empty mesh represents fully removed stock. +pub fn stock_triangles(report: &Report, bottom: f64) -> Result> { + crate::stock_mesh::triangles(report, bottom) +} + +/// Cancellation-aware boundary extraction, yielding during sorting, stitching, +/// triangulation and manifold checks. No partial mesh is returned. +pub async fn stock_triangles_cooperative>( + report: &Report, + bottom: f64, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result> { + crate::stock_mesh::triangles_cooperative(report, bottom, cancelled, yield_control).await +} + +/// Independently evaluate a point on the upper stock surface. Used for analytic +/// acceptance probes, never as a substitute for whole-region verification. +pub fn probe_height(input: &JobInput, nc: &Parsed, p: [f64; 2]) -> Result { + let mut z = input.setup.stock.max.z; + for s in nc::segments(nc, &input.setup)? { + if s.rapid { + continue; + } + let t = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| Error("unknown probe tool".into()))?; + let (d, low, _) = sweep(p, 0.0, &s, t.diameter / 2.0); + if d <= t.diameter / 2.0 { + let point = if t.kind == ToolKind::TwistDrill { d / (t.tip_angle / 2.0).tan() } else { 0.0 }; + z = z.min(low + point); + } + } + Ok(z.max(input.setup.stock.min.z)) +} + +#[cfg(test)] +mod radial_tests { + use super::*; + #[test] + fn rectangle_radial_extrema_include_corners_and_interior_axis() { + assert_eq!(super::radial_bounds([3., 4.], [6., 8.], [0., 0.]), (5., 10.)); + assert_eq!(super::radial_bounds([-3., -4.], [3., 4.], [0., 0.]), (0., 5.)); + assert_eq!(super::radial_bounds([7., -2.], [10., 6.], [10., 2.]), (0., 5.)); + } + + #[test] + fn rectangle_sweep_bounds_cover_axis_diagonal_endcaps_and_translations() { + for offset in [0., 1_000_000.] { + let point = |x, y| Vec3::new(x + offset, y + offset, 0.); + for (a, b, min, max, expected) in [ + ([0., 0.], [10., 0.], [2., 3.], [4., 5.], (3., 5.)), + ([0., 0.], [0., 10.], [3., 2.], [5., 4.], (3., 5.)), + ([0., 0.], [0., 0.], [3., 4.], [6., 8.], (5., 10.)), + ([0., 0.], [1., 0.], [4., 4.], [7., 8.], (5., 10.)), + ([0., 0.], [10., 10.], [4., 4.], [6., 6.], (0., 2_f64.sqrt())), + ] { + let s = Segment { from: point(a[0], a[1]), to: point(b[0], b[1]), rapid: false, feed: 100., tool: 1, line: 1 }; + let (low, high) = lateral_bounds(min.map(|x| x + offset), max.map(|x| x + offset), &s); + assert!(low <= expected.0 && high >= expected.1, "{low} {high} {expected:?}"); + assert!(expected.0 - low < 1e-6 && high - expected.1 < 1e-6); + } + } + } +} diff --git a/crates/cam/tests/machine_state.rs b/crates/cam/tests/machine_state.rs new file mode 100644 index 00000000..a2f8e5bf --- /dev/null +++ b/crates/cam/tests/machine_state.rs @@ -0,0 +1,69 @@ +//! Hand-authored modal programs exercise NIST machine state independently of generation. +use solidcraft_cam::{Motion, corpus, generate, nc}; + +const HEADER: &str = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000 M3\nM8\n"; + +#[test] +fn tool_change_stops_spindle_preserves_coolant_and_requires_restart() { + let mut input = corpus::fixture("face").unwrap(); + let mut second = input.tools[0].clone(); + second.id = 2; + second.number = 2; + input.tools.push(second); + for number in [1, 2] { + let source = format!("{HEADER}T{number} M6\nS7000\nG1 X1 F100\nM30"); + let parsed = nc::parse(&source, &input.tools).unwrap(); + let change: Vec<_> = parsed.records.iter().filter(|r| r.line == 6).map(|r| &r.motion).collect(); + assert_eq!(change, [&Motion::Spindle { rpm: 0. }, &Motion::Tool { tool: number as u64, number }]); + assert!(parsed.records.iter().all(|r| r.line != 7), "S alone cannot restart after M6"); + assert_eq!(parsed.records.last().unwrap().motion, Motion::Coolant { on: false }, "M6 must preserve coolant until M30"); + assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 8")); + + let restarted = source.replace("S7000\n", "M3\n"); + let parsed = nc::parse(&restarted, &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 7).unwrap().motion, Motion::Spindle { rpm: 6000. }); + let segments = nc::segments(&parsed, &input.setup).unwrap(); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].tool, number as u64); + assert_eq!(segments[0].to.x, 1.); + } +} + +#[test] +fn same_block_speed_and_coolant_commands_keep_both_effects() { + let input = corpus::fixture("face").unwrap(); + for coolant in ["M8", "M9", "M0"] { + let source = format!("{HEADER}S0 {coolant}\nG1 X1 F100\nM30"); + let parsed = nc::parse(&source, &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. }); + assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 7")); + let source = source.replace("S0", "S12000"); + let parsed = nc::parse(&source, &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 12000. }); + assert_eq!(nc::segments(&parsed, &input.setup).unwrap().len(), 1); + } + let parsed = nc::parse(&format!("{HEADER}S0 G1 X1 F100\nM30"), &input.tools).unwrap(); + assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. }); + assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 6")); +} + +#[test] +fn reference_post_initializes_state_before_pose_and_keeps_block_provenance() { + let input = corpus::fixture("face").unwrap(); + let path = generate::generate(input.clone()).unwrap(); + let code = nc::post(&path, &input.setup).unwrap(); + let lines: Vec<_> = code.lines().filter(|l| !l.starts_with('(')).collect(); + assert_eq!(lines[0], "G21 G90 G17 G91.1 G94 G54 G40 G49 G80"); + assert_eq!(&lines[1..3], ["M5", "M9"]); + assert!(lines[3].starts_with("G0 X0.000000 Y0.000000 Z15.000000")); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + assert_eq!(parsed.records.len(), path.blocks.len() + 3); + for record in &parsed.records[..3] { + assert!(parsed.source_for_line(record.line).is_none()); + } + for (i, record) in parsed.records[3..].iter().enumerate() { + let source = parsed.source_for_line(record.line).unwrap(); + assert_eq!(source.block, i + 1); + assert_eq!(source.operation, path.blocks[i].operation); + } +} diff --git a/crates/cam/tests/milling_direction.rs b/crates/cam/tests/milling_direction.rs new file mode 100644 index 00000000..ab180410 --- /dev/null +++ b/crates/cam/tests/milling_direction.rs @@ -0,0 +1,112 @@ +//! Independent direction checks for a right-handed cutter with M3 spindle rotation. +//! A climb cut keeps the material being finished to the right of tool travel. +use solidcraft_cam::{Boundary, JobInput, Strategy, corpus, generate, nc}; + +fn segments(input: &JobInput) -> Vec { + let path = generate::generate(input.clone()).unwrap(); + let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + nc::segments(&parsed, &input.setup).unwrap() +} + +fn square(lo: f64, hi: f64, reversed: bool) -> Vec<[f64; 2]> { + let mut ring = vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]]; + if reversed { + ring.reverse(); + } + ring +} + +// Collect continuous constant-depth cuts from the independently interpreted NC. +// Signed polygon area checks orientation without calling the offset adapter. +fn loops(input: &JobInput) -> Vec> { + let mut result = Vec::new(); + let mut current = Vec::new(); + for s in segments(input) { + if !s.rapid && (s.from.z - 5.).abs() < 1e-8 && (s.to.z - 5.).abs() < 1e-8 { + current.push(s); + } else if !current.is_empty() { + result.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + result.push(current); + } + result +} + +fn ccw(path: &[nc::Segment]) -> bool { + let origin = path[0].from; + assert!((path.last().unwrap().to - origin).len() < 1e-5, "cut must close"); + let area = path + .iter() + .map(|s| { + let a = s.from - origin; + let b = s.to - origin; + a.x * b.y - b.x * a.y + }) + .sum::(); + assert!(area.abs() > 1.); + area > 0. +} + +#[test] +fn facing_direction_matches_uncut_side_at_every_depth() { + for climb in [false, true] { + let mut input = corpus::fixture("face").unwrap(); + input.operations[0].climb = climb; + input.operations[0].stepdown = 0.5; + let cuts: Vec<_> = segments(&input).into_iter().filter(|s| !s.rapid && (s.from.z - s.to.z).abs() < 1e-8 && s.to.z < 6.).collect(); + assert_eq!(cuts.len(), 22); + for depth in cuts.as_chunks::<11>().0 { + assert!(depth.windows(2).all(|pair| pair[1].from.y > pair[0].from.y)); + // Advancing rows toward +Y leaves uncut material north of the tool. + // Right of a -X move is +Y; right of a +X move is -Y. + assert!(depth.iter().all(|s| (s.to.x < s.from.x) == climb)); + } + } +} + +#[test] +fn contour_direction_matches_inside_and_outside_material() { + for reversed in [false, true] { + for inside in [false, true] { + for climb in [false, true] { + let mut input = corpus::fixture("face").unwrap(); + input.tools[0].diameter = 2.; + input.operations[0].climb = climb; + input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: square(4., 16., reversed), islands: vec![] }, inside }; + let paths = loops(&input); + assert_eq!(paths.len(), 1); + // Pocket wall: material outside => CCW climb. Boss: inside => CW climb. + assert_eq!(ccw(&paths[0]), inside == climb, "inside={inside}, climb={climb}, reversed={reversed}"); + } + } + } +} + +#[test] +fn pocket_direction_preserves_opposite_outer_and_island_winding() { + for reversed in [false, true] { + for climb in [false, true] { + let mut input = corpus::fixture("face").unwrap(); + input.tools[0].diameter = 2.; + input.operations[0].climb = climb; + input.operations[0].geometry = + Strategy::Pocket { boundary: Boundary { outer: square(0., 20., reversed), islands: vec![square(4., 16., reversed)] } }; + let paths = loops(&input); + assert_eq!(paths.len(), 2); + let mut outer = 0; + let mut island = 0; + for path in &paths { + let is_outer = path.iter().any(|s| s.from.x < 2.); + if is_outer { + outer += 1; + } else { + island += 1; + } + assert_eq!(ccw(path), is_outer == climb, "outer={is_outer}, climb={climb}, reversed={reversed}"); + } + assert_eq!((outer, island), (1, 1)); + } + } +} diff --git a/crates/cam/tests/nc_incremental.rs b/crates/cam/tests/nc_incremental.rs new file mode 100644 index 00000000..cfff1c05 --- /dev/null +++ b/crates/cam/tests/nc_incremental.rs @@ -0,0 +1,116 @@ +//! Original programs exercise cooperative NC processing and its exact boundary limits. +use solidcraft_cam::{self as cam, Block, Motion, Toolpath, nc}; +use solidcraft_geom::Vec3; +use std::{ + cell::Cell, + future::Future, + task::{Context, Poll, Waker}, +}; + +fn yield_once() -> impl Future { + let mut first = true; + std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) }) +} +fn drive(future: impl Future>) -> (cam::Result, usize) { + let mut future = std::pin::pin!(future); + let mut polls = 0; + loop { + polls += 1; + assert!(polls < 20_000, "unbounded checkpoint count"); + if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + return (result, polls); + } + } +} +fn path(count: usize) -> Toolpath { + let mut blocks = vec![ + Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } }, + Block { operation: 1, motion: Motion::Spindle { rpm: 6000. } }, + Block { operation: 1, motion: Motion::Coolant { on: true } }, + ]; + for i in 0..count - 6 { + blocks.push(Block { + operation: if i < count / 2 { 1 } else { 2 }, + motion: Motion::Linear { to: Vec3::new(if i % 2 == 0 { 1. } else { 19. }, 10., 5.), feed: 100. }, + }); + } + blocks.extend([ + Block { operation: 2, motion: Motion::Coolant { on: false } }, + Block { operation: 2, motion: Motion::Spindle { rpm: 0. } }, + Block { operation: 2, motion: Motion::Stop }, + ]); + Toolpath { fingerprint: "original-independent-test".into(), blocks } +} +#[test] +fn maximum_path_includes_startup_records_and_excess_is_rejected() { + let input = cam::corpus::fixture("face").unwrap(); + let mut path = path(cam::MAX_MOVES); + let (parsed, polls) = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once)); + let parsed = parsed.unwrap(); + assert_eq!(parsed.records.len(), cam::MAX_MOVES + 3); + assert!(polls > 3000); + let last = parsed.records.last().unwrap(); + let source = parsed.source_for_line(last.line).unwrap(); + assert_eq!((source.block, source.operation), (cam::MAX_MOVES, 2)); + path.blocks.push(path.blocks.last().unwrap().clone()); + assert!(nc::post(&path, &input.setup).is_err()); + // External NC records remain bounded independently of the posting limit. + let mut external = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\n".to_owned(); + external.push_str(&"G0 Z15\n".repeat(cam::MAX_MOVES + 3)); + external.push_str("M30\n"); + assert!(nc::parse(&external, &input.tools).unwrap_err().to_string().contains("NC motion limit")); +} +#[test] +fn synchronous_and_cooperative_processing_keep_bytes_state_and_provenance() { + let input = cam::corpus::fixture("face").unwrap(); + let path = path(1024); + let source = nc::post(&path, &input.setup).unwrap(); + assert!(source.starts_with("(solidcraft-reference-3axis/2; not a qualified machine post)\n(Fingerprint original-independent-test)\nG21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\nG0 X0.000000 Y0.000000 Z15.000000\n(Operation 1)\nT1 M6\n")); + assert_eq!(drive(nc::post_cooperative(&path, &input.setup, || false, yield_once)).0.unwrap(), source); + let synchronous = nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let cooperative = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once)).0.unwrap(); + assert_eq!(serde_json::to_value(&synchronous).unwrap(), serde_json::to_value(&cooperative).unwrap()); + for record in &synchronous.records { + assert_eq!(synchronous.source_for_line(record.line), cooperative.source_for_line(record.line)); + } + let broken = source.replace("(Operation 2)", "G123"); + let ordinary = nc::parse(&broken, &input.tools).unwrap_err(); + let incremental = drive(nc::parse_cooperative(&broken, &input.tools, || false, yield_once)).0.unwrap_err(); + assert_eq!(ordinary.to_string(), incremental.to_string()); + assert!(incremental.to_string().contains("unsupported G code")); +} +#[test] +fn cancellation_during_each_stage_returns_no_partial_program_or_line_mapping() { + let input = cam::corpus::fixture("face").unwrap(); + let path = path(1024); + let count = Cell::new(0); + let full = drive(nc::round_trip_cooperative( + &path, + &input.setup, + &input.tools, + || false, + || { + count.set(count.get() + 1); + yield_once() + }, + )) + .0 + .unwrap(); + assert_eq!(full.records.len(), 1027); + // Every checkpoint, including post, reader and correspondence stages. + for stop in 0..=count.get() { + let at = Cell::new(0); + let result = drive(nc::round_trip_cooperative( + &path, + &input.setup, + &input.tools, + || at.get() >= stop, + || { + at.set(at.get() + 1); + yield_once() + }, + )) + .0; + assert!(result.unwrap_err().to_string().contains("cancelled"), "checkpoint {stop}"); + } +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 4fac21b0..b44e7aae 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +solidcraft-cam = { workspace = true } solidcraft-geom = { workspace = true } solidcraft-sketch = { workspace = true } solidcraft-kernel = { workspace = true } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 00523f8e..a0b0d246 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2225,12 +2225,21 @@ pub struct DesignLink { pub feature: u64, } +fn read_manufacturing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + let manufacturing = ::deserialize(d)?; + manufacturing.validate().map_err(serde::de::Error::custom)?; + Ok(manufacturing) +} + /// The document. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { /// Named, renderer-independent setups. Device/job state is never saved here. #[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")] pub render: std::sync::Arc, + /// Manufacturing definitions; generated toolpaths remain session caches. + #[serde(default, deserialize_with = "read_manufacturing", skip_serializing_if = "solidcraft_cam::Manufacturing::is_empty")] + pub manufacturing: solidcraft_cam::Manufacturing, #[serde(default = "default_format")] pub format: String, /// The changes of `format` this design has while they are pending (no release has fixed @@ -2453,6 +2462,7 @@ impl Document { pub fn new(name: &str) -> Self { Document { render: Default::default(), + manufacturing: Default::default(), format: default_format(), format_pending: crate::format::pending_names(), name: name.into(), diff --git a/crates/doc/src/format/pending/manufacturing.rs b/crates/doc/src/format/pending/manufacturing.rs new file mode 100644 index 00000000..573e515c --- /dev/null +++ b/crates/doc/src/format/pending/manufacturing.rs @@ -0,0 +1,4 @@ +//! Persistent milling setups, stock, fixtures, local tools, ordered operations, NC programs +//! and durable profile/hole references. Older designs get empty manufacturing; explicit hole +//! coordinates without references stay explicit. Generated paths and validation are not saved. +//! Earlier released readers refuse the new format instead of discarding manufacturing. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 89d54bfd..380d7cb7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +solidcraft-cam = { workspace = true } solidcraft-geom = { workspace = true } solidcraft-sketch = { workspace = true } solidcraft-kernel = { workspace = true } diff --git a/crates/engine/src/cmd/cam.rs b/crates/engine/src/cmd/cam.rs new file mode 100644 index 00000000..ef9344d9 --- /dev/null +++ b/crates/engine/src/cmd/cam.rs @@ -0,0 +1,603 @@ +//! Manufacturing commands shared by UI, CLI and MCP. +use super::CommandSpec; +use crate::{EngineError, Result, Session, cam, manufacturing as jobs}; +use serde_json::{Value, json}; +use std::sync::atomic::Ordering; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("cam.holes.from_selection", "Use Selected Holes", from_holes) + .noundo() + .params("setup: ID, selections: [{kind:face,body,index,occurrence?:[IDs]}|{kind:profile,sketch,index,occurrence?:[IDs]}]; returns ordered holes, diameters and durable sources; face body is a world label unless occurrence is explicit"), + CommandSpec::new("cam.boundary.from_profile", "Use Sketch Profile", from_profile) + .noundo() + .params("setup: ID, sketch: feature ID, profile: index, occurrence?: [IDs]; returns boundary and durable source reference"), + CommandSpec::new("cam.setup.put", "Milling Setup", setup) + .at("MILLING", "SETUP") + .icon("box") + .params("setup: complete Setup object (create or replace by ID)"), + CommandSpec::new("cam.tool.put", "Tool Library", tool).at("MILLING", "SETUP").icon("hole").params("tool: complete Tool object"), + CommandSpec::new("cam.operation.put", "Milling Operation", operation) + .at("MILLING", "TOOLPATHS") + .icon("cam_path") + .params("operation: complete Operation object; strategy face, contour, pocket or drill"), + CommandSpec::new("cam.program.put", "NC Program", program) + .at("MILLING", "SETUP") + .icon("save") + .params("program: {id,name,setup,operations:[ordered IDs]}"), + CommandSpec::new("cam.operation.order", "Reorder Operations", order).params("program: ID, operations: permutation of existing operation IDs"), + CommandSpec::new("cam.delete", "Delete Manufacturing Item", delete) + .params("kind: setup|tool|operation|program, id; referenced items cannot be deleted"), + CommandSpec::new("cam.inspect", "Inspect Manufacturing", inspect) + .noundo() + .params("job?: ID; include_path?: bool; include_stock?: bool (world-space display mesh); motion?: completed block count; finding?: zero-based verification finding index"), + CommandSpec::new("cam.generate", "Generate Toolpaths", generate) + .at("MILLING", "TOOLPATHS") + .icon("cam_generate") + .noundo() + .params("program: ID, background?: bool (default false); returns job ID"), + CommandSpec::new("cam.job.status", "CAM Job Status", status).noundo().params("job: ID"), + CommandSpec::new("cam.job.cancel", "Cancel CAM Job", cancel).noundo().params("job: ID"), + CommandSpec::new("cam.simulate", "Simulate Toolpaths", simulate) + .at("MILLING", "VERIFY") + .icon("cam_verify") + .noundo() + .params("job: ID, background?: bool"), + CommandSpec::new("cam.post", "Post Reference G-code", post) + .at("MILLING", "EXPORT") + .icon("save") + .noundo() + .params("job: ID, path?: string, background?: bool; requires current passing verification; background output is published by cam.export.result"), + CommandSpec::new("cam.export", "Export CAM Artifact", export) + .noundo() + .params("job: ID, format: toolpath|setup_sheet|stock|validation, path?: string, background?: bool; background output is published by cam.export.result"), + CommandSpec::new("cam.export.result", "Collect CAM Export", export_result).noundo().params("job: ID; consumes completed output after checking freshness; writes the requested path or returns content"), +]; +fn from_holes(s: &mut Session, p: &Value) -> Result { + let setup = id(p, "setup")?; + let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?; + let (sources, selected) = jobs::holes::select(s, setup, decode(p, "selections")?)?; + Ok( + json!({"sources":sources,"holes":selected.iter().map(|h|h.center).collect::>(),"diameters":selected.iter().map(|h|h.diameter).collect::>()}), + ) +} +fn from_profile(s: &mut Session, p: &Value) -> Result { + let setup = id(p, "setup")?; + let sketch = id(p, "sketch")?; + let profile = id(p, "profile")? as usize; + let state = s.model.state(); + let profile = state + .sketches + .iter() + .find(|x| x.feature == sketch) + .and_then(|s| s.profiles.get(profile)) + .ok_or_else(|| EngineError::Other("selected profile not found".into()))?; + let source = cam::ProfileRef { + sketch, + curves: profile.curve_ids(), + occurrence: p.get("occurrence").map(|_| decode(p, "occurrence")).transpose()?.unwrap_or_default(), + }; + let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?; + let boundary = jobs::profile_boundary(s, setup, &source, cam::TOLERANCE)?; + Ok(json!({"boundary":boundary,"source":source})) +} +fn decode(p: &Value, key: &str) -> Result { + serde_json::from_value(p.get(key).cloned().ok_or_else(|| EngineError::Other(format!("missing {key}")))?) + .map_err(|e| EngineError::Other(e.to_string())) +} +fn id(p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("{key} must be an ID"))) +} +macro_rules! put {($fn:ident,$key:literal,$ty:ty,$field:ident)=>{fn $fn(s:&mut Session,p:&Value)->Result{ + let item:$ty=decode(p,$key)?;let id=item.id;let mut m=s.doc.manufacturing.clone(); + if let Some(old)=m.$field.iter_mut().find(|x|x.id==id){*old=item;}else{m.$field.push(item);} + m.validate().map_err(jobs::error)?;s.doc_mut().manufacturing=m;Ok(json!({"id":id})) +}};} +put!(setup, "setup", cam::Setup, setups); +put!(tool, "tool", cam::Tool, tools); +put!(operation, "operation", cam::Operation, operations); +put!(program, "program", cam::Program, programs); +fn order(s: &mut Session, p: &Value) -> Result { + let program = id(p, "program")?; + let ids: Vec = decode(p, "operations")?; + let mut m = s.doc.manufacturing.clone(); + let target = m.programs.iter_mut().find(|x| x.id == program).ok_or_else(|| EngineError::Other("program not found".into()))?; + let mut a = target.operations.clone(); + let mut b = ids.clone(); + a.sort(); + b.sort(); + if a != b { + return Err(EngineError::Other("order must be a permutation of the program operations".into())); + } + target.operations = ids; + m.validate().map_err(jobs::error)?; + s.doc_mut().manufacturing = m; + Ok(json!({"program":program})) +} +fn delete(s: &mut Session, p: &Value) -> Result { + let id = id(p, "id")?; + let mut m = s.doc.manufacturing.clone(); + let removed = match p.get("kind").and_then(Value::as_str) { + Some("setup") => { + let n = m.setups.len(); + m.setups.retain(|x| x.id != id); + n != m.setups.len() + } + Some("tool") => { + let n = m.tools.len(); + m.tools.retain(|x| x.id != id); + n != m.tools.len() + } + Some("operation") => { + let n = m.operations.len(); + m.operations.retain(|x| x.id != id); + n != m.operations.len() + } + Some("program") => { + let n = m.programs.len(); + m.programs.retain(|x| x.id != id); + n != m.programs.len() + } + _ => return Err(EngineError::Other("unknown CAM item kind".into())), + }; + if !removed { + return Err(EngineError::Other("CAM item not found".into())); + } + m.validate().map_err(jobs::error)?; + s.doc_mut().manufacturing = m; + Ok(json!({"deleted":id})) +} +fn inspect(s: &mut Session, p: &Value) -> Result { + if p.get("job").is_none() { + if p.get("finding").is_some() + || p.get("motion").is_some() + || ["include_path", "include_stock"].iter().any(|key| p.get(key).and_then(Value::as_bool) == Some(true)) + { + return Err(EngineError::Other("job is required for result inspection".into())); + } + return Ok(json!({"manufacturing":s.doc.manufacturing,"jobs":s.cam_jobs.jobs.keys().collect::>()})); + } + let job = jobs::job(s, id(p, "job")?)?; + let mut result = status(s, p)?; + match job.state()? { + jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) if p.get("include_path").and_then(Value::as_bool) == Some(true) => { + result["toolpath"] = json!(path); + } + _ => {} + } + if let Some(motion) = p.get("motion") { + jobs::current(s, &job)?; + let motion = motion + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| EngineError::Other("motion must be a nonnegative block count".into()))?; + let path = match job.state()? { + jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) => path, + _ => return Err(EngineError::Other("generate before inspecting playback".into())), + }; + let state = cam::playback::at(&path, &job.input()?.setup, motion).map_err(jobs::error)?; + result["playback"] = json!(state); + let operation = job.input()?.operations.iter().find(|o| Some(o.id) == state.operation); + result["source"] = json!(operation.and_then(|o| o.source.as_ref())); + result["hole_sources"] = json!(operation.map(|o| &o.hole_sources)); + } + if let Some(index) = p.get("finding") { + jobs::current(s, &job)?; + let index = + index.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| EngineError::Other("finding must be a nonnegative index".into()))?; + let jobs::JobState::Verified(path, report) = job.state()? else { + return Err(EngineError::Other("simulate before inspecting findings".into())); + }; + let finding = report.findings.get(index).ok_or_else(|| EngineError::Other("finding index out of range".into()))?; + let input = job.input()?; + let operation = finding.source.and_then(|source| input.operations.iter().find(|o| o.id == source.operation)); + let code = cam::nc::post(&path, &input.setup).map_err(jobs::error)?; + let line = finding.line.and_then(|line| line.checked_sub(1)).and_then(|index| code.lines().nth(index)); + result["diagnostic"] = json!({"index":index,"finding":finding,"world":input.setup.frame.world(finding.at),"operation":operation, + "source":operation.and_then(|o|o.source.as_ref()),"hole_sources":operation.map(|o|&o.hole_sources),"gcode_line":line, + "playback":finding.source.map(|source|cam::playback::at(&path,&input.setup,source.block)).transpose().map_err(jobs::error)?}); + } + if p.get("include_stock").and_then(Value::as_bool) == Some(true) { + jobs::current(s, &job)?; + result["stock_mesh"] = serde_json::to_value(job.stock_mesh()?.as_deref()).map_err(|e| EngineError::Other(e.to_string()))?; + } + Ok(result) +} +fn generate(s: &mut Session, p: &Value) -> Result { + let id = jobs::start(s, id(p, "program")?, p.get("background").and_then(Value::as_bool).unwrap_or(false))?; + status(s, &json!({"job":id})) +} +fn status(s: &mut Session, p: &Value) -> Result { + let id = id(p, "job")?; + let job = jobs::job(s, id)?; + #[cfg(target_arch = "wasm32")] + jobs::advance(&job); + let stale = jobs::current(s, &job).is_err(); + let mut v = json!({"job":id,"stale":stale,"progress":job.progress.load(Ordering::Relaxed),"fingerprint":job.input().ok().map(|input| &input.fingerprint)}); + match job.state()? { + jobs::JobState::Verifying(_) => v["state"] = json!("simulating"), + jobs::JobState::Preparing => v["state"] = json!("preparing"), + jobs::JobState::Running => v["state"] = json!("running"), + jobs::JobState::Cancelled => v["state"] = json!("cancelled"), + jobs::JobState::Failed(e) => { + v["state"] = json!("failed"); + v["error"] = json!(e); + } + jobs::JobState::Generated(path) => { + v["state"] = json!("generated"); + v["blocks"] = json!(path.blocks.len()); + } + jobs::JobState::Verified(path, r) => { + v["state"] = json!("verified"); + v["blocks"] = json!(path.blocks.len()); + v["stock_preview"] = match job.stock_mesh() { + Ok(Some(mesh)) => json!({"available":true,"triangles":mesh.triangles.len()}), + Ok(None) => json!({"available":false}), + Err(e) => json!({"available":false,"error":e.to_string()}), + }; + v["verification"] = json!({"status":r.status,"finished_dimensions":r.finished_dimensions,"stock_complete":r.stock_complete, + "findings":r.findings,"cells":r.cells.len(),"work":r.work}); + } + } + v["export"] = jobs::exports::status(&job)?; + Ok(v) +} +fn cancel(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + job.cancelled.store(true, Ordering::Relaxed); + job.set(jobs::JobState::Cancelled); + status(s, p) +} +fn simulate(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + if jobs::exports::busy(&job)? { + return Err(EngineError::Other("finish or cancel the current export first".into())); + } + let path = match job.state()? { + jobs::JobState::Generated(p) | jobs::JobState::Verified(p, _) => p, + _ => return Err(EngineError::Other("generate a path first; another job may still be running".into())), + }; + job.set(jobs::JobState::Verifying(path)); + jobs::dispatch(job, p.get("background").and_then(Value::as_bool).unwrap_or(false), true)?; + status(s, p) +} +fn output(p: &Value, content: String) -> Result { + if let Some(path) = p.get("path").and_then(Value::as_str) { + crate::io::write_atomic(std::path::Path::new(path), content.as_bytes(), false).map_err(|e| EngineError::Other(e.to_string()))?; + Ok(json!({"path":path,"bytes":content.len()})) + } else { + Ok(json!({"content":content})) + } +} +fn post(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + let jobs::JobState::Verified(path, report) = job.state()? else { + return Err(EngineError::Other("simulate before posting".into())); + }; + if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete { + return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into())); + } + if p.get("background").and_then(Value::as_bool).unwrap_or(false) { + jobs::exports::start(job, "gcode".into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?; + return status(s, p); + } + cam::nc::round_trip(&path, &job.input()?.setup, &job.input()?.tools).map_err(jobs::error)?; + output(p, cam::nc::post(&path, &job.input()?.setup).map_err(jobs::error)?) +} +fn export(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + let (path, report) = match job.state()? { + jobs::JobState::Generated(p) => (p, None), + jobs::JobState::Verified(p, r) => (p, Some(r)), + _ => return Err(EngineError::Other("no generated result".into())), + }; + let format = p.get("format").and_then(Value::as_str).unwrap_or("validation"); + if p.get("background").and_then(Value::as_bool).unwrap_or(false) { + jobs::exports::start(job, format.into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?; + return status(s, p); + } + output(p, crate::io::cam::artifact(job.input()?, &path, report.as_deref(), format)?) +} + +fn export_result(s: &mut Session, p: &Value) -> Result { + let job = jobs::job(s, id(p, "job")?)?; + jobs::current(s, &job)?; + let (content, destination) = jobs::exports::take(&job)?; + output(&destination.map_or_else(|| json!({}), |path| json!({"path":path})), content) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + fn wait_export(s: &mut Session, job: u64) -> Value { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let status = s.execute("cam.job.status", &json!({"job":job})).unwrap(); + if status["export"]["state"] != "running" { + return status; + } + assert!(std::time::Instant::now() < deadline, "export worker timeout"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + } + #[test] + fn background_exports_only_publish_complete_current_results_once() { + let mut s = configured(); + let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap(); + s.execute("cam.simulate", &json!({"job":job})).unwrap(); + for format in ["toolpath", "validation", "setup_sheet", "stock", "gcode"] { + let cmd = if format == "gcode" { "cam.post" } else { "cam.export" }; + let expected = s.execute(cmd, &json!({"job":job,"format":format})).unwrap()["content"].as_str().unwrap().to_owned(); + s.execute(cmd, &json!({"job":job,"format":format,"background":true})).unwrap(); + assert!(s.execute(cmd, &json!({"job":job,"format":format,"background":true})).is_err()); + let status = wait_export(&mut s, job); + assert_eq!(status["export"]["state"], "ready", "{status}"); + let actual = s.execute("cam.export.result", &json!({"job":job})).unwrap()["content"].as_str().unwrap().to_owned(); + match format { + "toolpath" | "validation" => { + assert_eq!(serde_json::from_str::(&actual).unwrap(), serde_json::from_str::(&expected).unwrap()) + } + "setup_sheet" => assert!(actual.contains("

Operation settings and selected geometry

")), + _ => assert_eq!(actual, expected), + } + assert!(s.execute("cam.export.result", &json!({"job":job})).is_err()); + } + let path = std::env::temp_dir().join(format!("solidcraft-cam-export-{}.nc", std::process::id())); + s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap(); + assert_eq!(wait_export(&mut s, job)["export"]["state"], "ready"); + assert!(!path.exists()); + s.execute("cam.export.result", &json!({"job":job})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("G21")); + std::fs::remove_file(&path).unwrap(); + s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap(); + wait_export(&mut s, job); + let mut tool = s.doc.manufacturing.tools[0].clone(); + tool.diameter += 0.1; + s.execute("cam.tool.put", &json!({"tool":tool})).unwrap(); + assert!(s.execute("cam.export.result", &json!({"job":job})).unwrap_err().to_string().contains("stale")); + assert!(!path.exists()); + } + #[test] + fn cancelling_a_background_export_cannot_publish_or_restart_it() { + let mut s = configured(); + let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap(); + s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).unwrap(); + s.execute("cam.job.cancel", &json!({"job":job})).unwrap(); + assert_eq!(wait_export(&mut s, job)["export"]["state"], "cancelled"); + assert!(s.execute("cam.export.result", &json!({"job":job})).is_err()); + assert!(s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).is_err()); + } + fn configured() -> Session { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":20,"width":20,"height":4})).unwrap(); + let mut i = cam::corpus::fixture("face").unwrap(); + i.setup.models[0].body = s.model.state().bodies[0].name.clone(); + let bounds = s.model.state().bodies[0].mesh().bounds(); + i.setup.stock.min = bounds.min; + i.setup.stock.max = bounds.max + crate::geom::Vec3::Z; + i.operations[0].top = bounds.max.z + 1.0; + i.operations[0].bottom = bounds.max.z; + s.execute("cam.setup.put", &json!({"setup":i.setup})).unwrap(); + s.execute("cam.tool.put", &json!({"tool":i.tools[0]})).unwrap(); + s.execute("cam.operation.put", &json!({"operation":i.operations[0]})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Plate","setup":1,"operations":[1]}})).unwrap(); + s + } + #[test] + fn save_undo_and_stale_generation() { + let mut s = configured(); + let saved = crate::io::write_design(&s.doc); + assert_eq!(crate::io::read_design(&saved).unwrap().manufacturing, s.doc.manufacturing); + let r = s.execute("cam.generate", &json!({"program":1})).unwrap(); + assert_eq!(r["state"], "generated"); + let id = r["job"].clone(); + s.execute("cam.operation.order", &json!({"program":1,"operations":[1]})).unwrap(); + s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap(); + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], false); + } + #[test] + fn operation_order_survives_reopen_undo_redo_and_controls_generated_sequence() { + let mut s = configured(); + let mut second = s.doc.manufacturing.operations[0].clone(); + second.id = 2; + second.name = "Second facing pass".into(); + second.feed += 25.; + s.execute("cam.operation.put", &json!({"operation":second})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Ordered milling","setup":1,"operations":[1,2]}})).unwrap(); + let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone(); + for invalid in [json!([1, 1]), json!([2]), json!([1, 3])] { + let before = s.doc.clone(); + assert!(s.execute("cam.operation.order", &json!({"program":1,"operations":invalid})).is_err()); + assert_eq!(s.doc, before); + } + s.execute("cam.operation.order", &json!({"program":1,"operations":[2,1]})).unwrap(); + assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.manufacturing.programs[0].operations, vec![1, 2]); + assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], false); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc.manufacturing.programs[0].operations, vec![2, 1]); + assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true); + let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap(); + let mut reopened = Session::new(doc); + let id = reopened.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap(); + let state = jobs::job(&reopened, id).unwrap().state().unwrap(); + let path = match state { + jobs::JobState::Generated(path) => Some(path), + _ => None, + } + .unwrap(); + let mut sequence = path.blocks.iter().map(|b| b.operation).collect::>(); + sequence.dedup(); + assert_eq!(sequence, vec![2, 1]); + assert_eq!(reopened.doc.manufacturing.programs[0].operations, vec![2, 1]); + } + + #[test] + fn referenced_tool_delete_rolls_back() { + let mut s = configured(); + let d = s.doc.clone(); + assert!(s.execute("cam.delete", &json!({"kind":"tool","id":1})).is_err()); + assert_eq!(s.doc, d); + } + #[test] + fn missing_body_is_not_guessed() { + let mut s = configured(); + s.doc_mut().manufacturing.setups[0].models[0].body = "missing".into(); + assert!(s.execute("cam.generate", &json!({"program":1})).is_err()); + } + #[test] + fn generate_simulate_post_and_reopen() { + let mut s = configured(); + let r = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = r["job"].clone(); + let v = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(v["verification"]["status"], "pass", "{v}"); + let posted = s.execute("cam.post", &json!({"job":id})).unwrap(); + assert!(posted["content"].as_str().unwrap().contains("G21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\n")); + for format in ["toolpath", "setup_sheet", "validation", "stock"] { + assert!(!s.execute("cam.export", &json!({"job":id,"format":format})).unwrap()["content"].as_str().unwrap().is_empty()); + } + let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap(); + let mut reopened = Session::new(doc); + assert!(reopened.execute("cam.post", &json!({"job":id})).is_err()); + assert_eq!(reopened.execute("cam.generate", &json!({"program":1})).unwrap()["state"], "generated"); + s.execute("cam.job.cancel", &json!({"job":id})).unwrap(); + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + } + #[test] + fn legacy_document_has_no_manufacturing() { + let mut value: Value = serde_json::from_slice(&crate::io::write_design(&solidcraft_doc::Document::default())).unwrap(); + value["format"] = json!("solidcraft/11"); + value.as_object_mut().unwrap().remove("manufacturing"); + let migrated = crate::io::read_design(value.to_string().as_bytes()).unwrap(); + assert!(migrated.manufacturing.is_empty()); + } + #[test] + fn inconclusive_dimensions_cannot_be_posted() { + let mut s = configured(); + let r = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = r["job"].as_u64().unwrap(); + s.execute("cam.simulate", &json!({"job":id})).unwrap(); + let job = jobs::job(&s, id).unwrap(); + if let jobs::JobState::Verified(path, report) = job.state().unwrap() { + let mut report = (*report).clone(); + report.finished_dimensions = cam::verify::Status::Inconclusive; + job.set(jobs::JobState::Verified(path, std::sync::Arc::new(report))); + } + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + } + #[test] + fn drilled_cad_body_generates_verifies_posts_and_reopens() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap(); + s.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118})).unwrap(); + let mut input = cam::corpus::fixture("drill").unwrap(); + input.setup.models[0].body = s.model.state().bodies[0].name.clone(); + input.operations[0].geometry = cam::Strategy::Drill { holes: vec![[10., 10.]], peck: Some(0.3), full_diameter: true }; + s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap(); + let body = s.model.state().bodies[0].clone(); + let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap(); + let selected = + s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face}]})).unwrap(); + input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap(); + assert!((selected["diameters"][0].as_f64().unwrap() - 1.).abs() < 1e-8); + let center: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap(); + assert!((center[0][0] - 10.).abs() < 1e-8 && (center[0][1] - 10.).abs() < 1e-8); + s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap(); + s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Drilled plate","setup":1,"operations":[1]}})).unwrap(); + let generated = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = generated["job"].clone(); + let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(verified["verification"]["status"], "pass", "{verified}"); + assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{verified}"); + assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30")); + let saved = crate::io::write_design(&s.doc); + let mut reopened = Session::new(crate::io::read_design(&saved).unwrap()); + let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap(); + let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap(); + assert_eq!(verified["verification"]["finished_dimensions"], "pass"); + } + #[test] + fn milled_cad_pocket_and_contour_generate_verify_post_and_reopen() { + let mut model = Session::default(); + model.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap(); + model.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":16,"depth":1,"type":"simple"})).unwrap(); + model.execute("sketch.create", &json!({"plane":"XY","name":"Pocket boundary"})).unwrap(); + model.execute("sketch.circle.center", &json!({"center":[10,10],"radius":8,"id":"wall"})).unwrap(); + model.execute("sketch.finish", &json!({})).unwrap(); + let sketch = model.model.state().sketches.last().unwrap().feature; + for name in ["contour", "pocket"] { + let mut s = Session::new((*model.doc).clone()); + let mut input = cam::corpus::fixture(name).unwrap(); + input.setup.models[0].body = s.model.state().bodies[0].name.clone(); + input.operations[0].ramp_angle = Some(5_f64.to_radians()); + s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap(); + let selected = s.execute("cam.boundary.from_profile", &json!({"setup":1,"sketch":sketch,"profile":0})).unwrap(); + input.operations[0].source = Some(serde_json::from_value(selected["source"].clone()).unwrap()); + match &mut input.operations[0].geometry { + cam::Strategy::Contour { boundary, .. } | cam::Strategy::Pocket { boundary } => { + *boundary = serde_json::from_value(selected["boundary"].clone()).unwrap() + } + _ => {} + } + s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap(); + s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap(); + s.execute("cam.program.put", &json!({"program":{"id":1,"name":name,"setup":1,"operations":[1]}})).unwrap(); + let generated = s.execute("cam.generate", &json!({"program":1})).unwrap(); + let id = generated["job"].clone(); + let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(verified["verification"]["status"], "pass", "{name}: {verified}"); + assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}"); + assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30")); + let mut reopened = Session::new(crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap()); + let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap(); + let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap(); + assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}"); + } + } + #[test] + fn diagnostic_inspection_identifies_posted_operation_and_rejects_stale_results() { + let mut s = configured(); + assert!(s.execute("cam.inspect", &json!({"finding":0})).is_err()); + let origin = crate::geom::Vec3::new(100., 200., 0.); + let mut setup = s.doc.manufacturing.setups[0].clone(); + setup.frame.origin = origin; + setup.stock.min -= origin; + setup.stock.max -= origin; + s.execute("cam.setup.put", &json!({"setup":setup})).unwrap(); + let mut operation = s.doc.manufacturing.operations[0].clone(); + operation.bottom = 3.; + s.execute("cam.operation.put", &json!({"operation":operation})).unwrap(); + let id = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone(); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":0})).is_err()); + let report = s.execute("cam.simulate", &json!({"job":id})).unwrap(); + assert_eq!(report["verification"]["status"], "fail"); + let index = report["verification"]["findings"].as_array().unwrap().iter().position(|f| f["source"].is_object()).unwrap(); + let inspected = s.execute("cam.inspect", &json!({"job":id,"finding":index})).unwrap(); + let diagnostic = &inspected["diagnostic"]; + assert_eq!(diagnostic["operation"]["id"], 1); + assert_eq!(diagnostic["playback"]["completed"], diagnostic["finding"]["source"]["block"]); + assert!(diagnostic["gcode_line"].as_str().unwrap().starts_with("G1")); + let at: crate::geom::Vec3 = serde_json::from_value(diagnostic["finding"]["at"].clone()).unwrap(); + assert_eq!(diagnostic["world"], json!(at + origin)); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":-1})).is_err()); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":999999})).is_err()); + assert!(s.execute("cam.post", &json!({"job":id})).is_err()); + let exported = s.execute("cam.export", &json!({"job":id,"format":"validation"})).unwrap(); + assert!(exported["content"].as_str().unwrap().contains("source")); + s.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap(); + assert!(s.execute("cam.inspect", &json!({"job":id,"finding":index})).is_err()); + } +} + +#[cfg(test)] +#[path = "cam_holes_tests.rs"] +mod holes_tests; diff --git a/crates/engine/src/cmd/cam_holes_tests.rs b/crates/engine/src/cmd/cam_holes_tests.rs new file mode 100644 index 00000000..f301cd65 --- /dev/null +++ b/crates/engine/src/cmd/cam_holes_tests.rs @@ -0,0 +1,201 @@ +#![allow(clippy::unwrap_used)] +use super::*; +use crate::geom::Vec3; +fn run(s: &mut Session, cmd: &str, value: Value) -> Value { + s.execute(cmd, &value).unwrap() +} +fn linked_profiles() -> (Session, u64) { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6})); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.circle.center", json!({"center":[4,5],"radius":0.5,"id":"a"})); + run(&mut s, "sketch.circle.center", json!({"center":[12,15],"radius":0.5,"id":"b"})); + run(&mut s, "sketch.finish", json!({})); + let state = s.model.state(); + let sketch = state.sketches.last().unwrap(); + let id = sketch.feature; + let a = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"a".to_string())).unwrap(); + let b = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"b".to_string())).unwrap(); + let mut input = cam::corpus::fixture("drill").unwrap(); + input.setup.models[0].body = state.bodies[0].name.clone(); + input.setup.frame.origin = Vec3::new(100., -50., 0.); + input.setup.stock.min -= input.setup.frame.origin; + input.setup.stock.max -= input.setup.frame.origin; + run(&mut s, "cam.setup.put", json!({"setup":input.setup})); + let selected = run( + &mut s, + "cam.holes.from_selection", + json!({"setup":1,"selections":[ + {"kind":"profile","sketch":id,"index":b},{"kind":"profile","sketch":id,"index":a} + ]}), + ); + let holes: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap(); + assert_eq!(holes, vec![[-88., 65.], [-96., 55.]]); + input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap(); + input.operations[0].geometry = cam::Strategy::Drill { holes, peck: Some(0.3), full_diameter: false }; + run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]})); + run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]})); + run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Selected holes","setup":1,"operations":[1]}})); + (s, id) +} +fn centers(s: &mut Session) -> (u64, Vec<[f64; 2]>) { + let generated = run(s, "cam.generate", json!({"program":1})); + assert_eq!(generated["state"], "generated", "{generated}"); + let id = generated["job"].as_u64().unwrap(); + let job = jobs::job(s, id).unwrap(); + let op = &job.input().unwrap().operations[0]; + let holes = match &op.geometry { + cam::Strategy::Drill { holes, .. } => holes.clone(), + _ => vec![], + }; + (id, holes) +} +#[test] +fn hole_selection_follows_profile_edits_frames_undo_and_reopen() { + let (mut s, sketch) = linked_profiles(); + let (old, before) = centers(&mut s); + let saved = crate::io::write_design(&s.doc); + let mut reopened = Session::new(crate::io::read_design(&saved).unwrap()); + assert_eq!(centers(&mut reopened).1, before); + run(&mut s, "sketch.edit", json!({"sketch":sketch})); + run(&mut s, "sketch.move", json!({"entities":["a"],"translate":[2,3]})); + assert_eq!(run(&mut s, "cam.job.status", json!({"job":old}))["stale"], true); + assert!(s.execute("cam.post", &json!({"job":old})).is_err()); + assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(centers(&mut s).1, before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]); + // Unassociated drilling preserves manually authored coordinates on reopen. + let mut legacy: Value = serde_json::from_slice(&crate::io::write_design(&s.doc)).unwrap(); + legacy["manufacturing"]["operations"][0].as_object_mut().unwrap().remove("hole_sources"); + let doc = crate::io::read_design(legacy.to_string().as_bytes()).unwrap(); + assert!(doc.manufacturing.operations[0].hole_sources.is_empty()); + assert_eq!(doc.manufacturing.operations[0].geometry, s.doc.manufacturing.operations[0].geometry); +} +#[test] +fn hole_selection_rejects_missing_duplicate_tilted_and_wrong_size_sources() { + let (mut s, sketch) = linked_profiles(); + let pick = json!({"kind":"profile","sketch":sketch,"index":0}); + for picks in [json!([]), json!([pick.clone(), pick.clone()]), json!([{"kind":"profile","sketch":sketch,"index":999}])] { + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":picks})).is_err()); + } + let excessive = vec![pick.clone(); cam::MAX_ITEMS + 1]; + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":excessive})).is_err()); + let mut invalid = s.doc.manufacturing.operations[0].clone(); + invalid.hole_sources.pop(); + assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err()); + let mut invalid = s.doc.manufacturing.operations[0].clone(); + invalid.hole_sources[1] = invalid.hole_sources[0].clone(); + assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err()); + let mut setup = s.doc.manufacturing.setups[0].clone(); + setup.frame = cam::Frame { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z, z: -Vec3::Y }; + run(&mut s, "cam.setup.put", json!({"setup":setup})); + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[pick]})).is_err()); + run(&mut s, "edit.undo", json!({})); + let mut tool = s.doc.manufacturing.tools[0].clone(); + tool.diameter = 0.8; + run(&mut s, "cam.tool.put", json!({"tool":tool})); + let failed = run(&mut s, "cam.generate", json!({"program":1})); + assert_eq!(failed["state"], "failed"); + assert!(failed["error"].as_str().unwrap().contains("diameter")); + assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err()); + run(&mut s, "edit.undo", json!({})); + let mut op = s.doc.manufacturing.operations[0].clone(); + if let cam::HoleSource::Profile { profile } = &mut op.hole_sources[0] { + profile.curves = vec!["missing".into()]; + } + run(&mut s, "cam.operation.put", json!({"operation":op})); + assert!(s.execute("cam.generate", &json!({"program":1})).unwrap_err().to_string().contains("reselect")); +} + +#[test] +fn hole_selection_keeps_exact_occurrence_and_rejects_lost_faces() { + let mut s = Session::default(); + let component = run(&mut s, "component.create", json!({"name":"Drilled part"})); + run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6})); + run(&mut s, "solid.hole", json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118})); + run(&mut s, "component.activate", json!({"component":0})); + let second = run(&mut s, "occurrence.copy", json!({"component":component["component"],"translate":[30,0,0]}))["occurrence"].as_u64().unwrap(); + let body = s.model.state().bodies[0].clone(); + let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap(); + let mut input = cam::corpus::fixture("drill").unwrap(); + input.setup.models = vec![cam::BodyRef { body: body.name.clone(), occurrence: vec![second] }]; + input.setup.frame.origin = Vec3::new(20., 0., 0.); + input.setup.stock.min.x += 10.; + input.setup.stock.max.x += 20.; + run(&mut s, "cam.setup.put", json!({"setup":input.setup})); + let selected = run( + &mut s, + "cam.holes.from_selection", + json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face,"occurrence":[second]}]}), + ); + // Viewport labels resolve through the same setup occurrence as explicit command picks. + let state = s.model.state(); + let placed = s.placed_document(); + let label = placed.world_body_name(&state, &body.name, placed.model_body_component(&body), &[second]); + let from_viewport = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":[{"kind":"face","body":label,"index":face}]})); + assert_eq!(from_viewport, selected); + assert!(crate::kernel::hole_axis(&body.body, face, f64::NAN).is_err()); + assert!(crate::kernel::hole_axis(&body.body, usize::MAX, 0.0005).is_err()); + let plane = (0..body.body.face_count()) + .find(|i| matches!(crate::kernel::face_surface(&body.body, *i), Some(crate::kernel::FaceSurface::Plane { .. }))) + .unwrap(); + assert!( + s.execute( + "cam.holes.from_selection", + &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":plane,"occurrence":[second]}]}) + ) + .is_err() + ); + input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap(); + run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]})); + run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]})); + run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Placed holes","setup":1,"operations":[1]}})); + let (job, before) = centers(&mut s); + assert!((before[0][0] - 20.).abs() < 1e-8 && (before[0][1] - 10.).abs() < 1e-8); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false})); + assert_eq!(run(&mut s, "cam.job.status", json!({"job":job}))["stale"], true); + let moved = centers(&mut s).1; + assert!((moved[0][0] - 25.).abs() < 1e-8); + let mut op = s.doc.manufacturing.operations[0].clone(); + if let cam::HoleSource::Face { name, .. } = &mut op.hole_sources[0] { + *name = "missing:face".into(); + } + run(&mut s, "cam.operation.put", json!({"operation":op})); + let failed = run(&mut s, "cam.generate", json!({"program":1})); + assert_eq!(failed["state"], "failed"); + assert!(failed["error"].as_str().unwrap().contains("reselect")); + assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err()); +} + +#[test] +fn hole_selection_combines_seam_faces_but_rejects_exterior_cylinders() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let body = s.model.state().bodies[0].clone(); + let mut setup = cam::corpus::fixture("drill").unwrap().setup; + setup.models[0].body = body.name.clone(); + run(&mut s, "cam.setup.put", json!({"setup":setup})); + let mut picks = vec![]; + for index in 0..body.body.face_count() { + if let Ok((origin, _, _)) = crate::kernel::hole_axis(&body.body, index, 0.0005) { + if (origin.x - 68.).abs() < 1e-8 { + picks.push(json!({"kind":"face","body":body.name,"index":index})); + } + } else if matches!(crate::kernel::face_surface(&body.body, index), Some(crate::kernel::FaceSurface::Cylinder { .. })) { + assert!( + s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":index}]})).is_err() + ); + } + } + assert!(picks.len() >= 2, "the sample must exercise multiple seam faces on one drilled wall"); + let mut duplicate = picks.clone(); + duplicate.push(picks[1].clone()); + assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":duplicate})).is_err()); + let result = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":picks})); + let centers: Vec<[f64; 2]> = serde_json::from_value(result["holes"].clone()).unwrap(); + assert_eq!(centers.len(), 1); + assert!((centers[0][0] - 68.).abs() < 1e-8 && (centers[0][1] - 25.).abs() < 1e-8); + assert_eq!(result["sources"].as_array().unwrap().len(), 1); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7380d9d..f21270fa 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ macro_rules! planned { } mod browser; +mod cam; mod clipboard; mod compare; mod component; @@ -252,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(component_link::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); v.extend(motion::COMMANDS.iter()); + v.extend(cam::COMMANDS.iter()); v.extend(config::COMMANDS.iter()); v.extend(parts::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6b60a557..19700f0c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,8 @@ pub mod crash; pub mod frames; pub mod fuzz; pub mod licences; +pub mod manufacturing; +pub use solidcraft_cam as cam; pub mod occurrences; pub mod params; pub mod product_scene; @@ -252,6 +254,8 @@ pub struct Session { /// Host-owned viewport metadata, never saved or included in history. render_viewport: Option>, render_jobs: render_jobs::Jobs, + /// Derived, immutable CAM jobs; not persisted or copied into undo snapshots. + pub cam_jobs: manufacturing::Jobs, /// The visibility the design file holds (what `is_dirty` compares with). saved_visibility: Visibility, /// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of @@ -377,6 +381,7 @@ impl Session { camera: None, render_viewport: None, render_jobs: Default::default(), + cam_jobs: Default::default(), #[cfg(test)] evaluations: 0, crash_journal: false, @@ -844,6 +849,7 @@ impl Session { camera: self.camera, render_viewport: self.render_viewport.clone(), render_jobs: self.render_jobs.scratch(), + cam_jobs: Default::default(), saved_visibility: self.saved_visibility.clone(), #[cfg(test)] evaluations: 0, diff --git a/crates/engine/src/manufacturing.rs b/crates/engine/src/manufacturing.rs new file mode 100644 index 00000000..65c367aa --- /dev/null +++ b/crates/engine/src/manufacturing.rs @@ -0,0 +1,461 @@ +//! Immutable CAM jobs, independent of the desktop UI and document undo state. +use crate::{EngineError, Result, Session, cam}; +pub mod exports; +pub(crate) mod holes; +mod snapshot; +use std::collections::BTreeMap; +use std::sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +#[derive(Default)] +pub struct Jobs { + pub jobs: BTreeMap>, + pub next: u64, +} +pub struct Job { + pub source_doc: Arc, + pub source_moves: BTreeMap, + input: Arc>, + snapshot: Mutex>, + stock: Arc, String>>>>, + pub(crate) export: Arc>, + pub cancelled: Arc, + #[cfg(target_arch = "wasm32")] + pending: Mutex>>>>>, + pub progress: Arc, + pub result: Arc>, +} +#[derive(Clone)] +pub enum JobState { + Preparing, + Running, + Generated(Arc), + Verifying(Arc), + Verified(Arc, Arc), + Failed(String), + Cancelled, +} +impl Job { + /// Display geometry is a derived cache. It never substitutes for a verification report. + pub fn stock_mesh(&self) -> Result>> { + if !matches!(self.state()?, JobState::Verified(..)) { + return Ok(None); + } + match self.stock.lock().map_err(|_| EngineError::Other("CAM stock display unavailable".into()))?.as_ref() { + Some(Ok(mesh)) => Ok(Some(mesh.clone())), + Some(Err(e)) => Err(EngineError::Other(e.clone())), + None => Ok(None), + } + } + pub fn input(&self) -> Result<&cam::JobInput> { + self.input.get().ok_or_else(|| EngineError::Other("CAM input is still being prepared".into())) + } + pub fn state(&self) -> Result { + self.result.lock().map(|s| s.clone()).map_err(|_| EngineError::Other("CAM worker state unavailable".into())) + } + pub fn set(&self, state: JobState) { + if let Ok(mut s) = self.result.lock() { + *s = if self.cancelled.load(Ordering::Relaxed) { JobState::Cancelled } else { state }; + } + } +} +pub fn error(e: cam::Error) -> EngineError { + EngineError::Other(e.to_string()) +} +pub fn input(s: &Session, program: u64) -> Result { + snapshot::ready(snapshot::Snapshot::capture(s, program)?.build(&|| false, &|_| {}, &mut || std::future::ready(()))) +} +pub fn profile_boundary(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result { + snapshot::ready(snapshot::Profile::capture(s, setup, source, tolerance)?.sample(&|| false, &mut || std::future::ready(()))) +} +pub fn start(s: &mut Session, program: u64, background: bool) -> Result { + if s.cam_jobs.jobs.len() >= 16 { + let removable = s.cam_jobs.jobs.iter().find_map(|(id, j)| match j.state() { + Ok(JobState::Preparing | JobState::Running | JobState::Verifying(_)) => None, + Ok(JobState::Generated(_) | JobState::Verified(..)) if exports::busy(j).unwrap_or(true) => None, + _ => Some(*id), + }); + if let Some(id) = removable { + s.cam_jobs.jobs.remove(&id); + } else { + return Err(EngineError::Other("too many running CAM jobs".into())); + } + } + let snapshot = snapshot::Snapshot::capture(s, program)?; + let job = Arc::new(Job { + source_doc: s.doc.clone(), + source_moves: s.pending_moves.clone(), + input: Arc::new(OnceLock::new()), + snapshot: Mutex::new(Some(snapshot)), + stock: Arc::new(Mutex::new(None)), + export: Arc::new(Mutex::new(exports::State::Idle)), + cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(target_arch = "wasm32")] + pending: Mutex::new(None), + progress: Arc::new(AtomicUsize::new(0)), + result: Arc::new(Mutex::new(JobState::Preparing)), + }); + s.cam_jobs.next = s.cam_jobs.next.checked_add(1).ok_or_else(|| EngineError::Other("CAM job ID exhausted".into()))?; + let id = s.cam_jobs.next; + s.cam_jobs.jobs.insert(id, job.clone()); + dispatch(job, background, false)?; + Ok(id) +} +pub fn dispatch(job: Arc, background: bool, verify: bool) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + if background { + let failed = job.clone(); + if let Err(e) = std::thread::Builder::new().name("cam".into()).spawn(move || run(job, verify)) { + failed.set(JobState::Failed(e.to_string())); + return Err(EngineError::Other(e.to_string())); + } + return Ok(()); + } + #[cfg(target_arch = "wasm32")] + if background { + let future = task(&job, verify)?; + *job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future)); + return Ok(()); + } + run(job, verify); + Ok(()) +} +// Capture owned task components instead of Job itself: a browser's pending +// future must not keep its owning Job alive through an Arc cycle. +fn task(job: &Job, verify: bool) -> Result> + use<>> { + let input = job.input.clone(); + let cancelled = job.cancelled.clone(); + let progress = job.progress.clone(); + let state = job.result.clone(); + let stock = job.stock.clone(); + let path = if verify { + match job.state()? { + JobState::Verifying(path) => Some(path), + _ => return Err(EngineError::Other("generate a path before simulation".into())), + } + } else { + None + }; + let snapshot = if verify { None } else { job.snapshot.lock().map_err(|_| EngineError::Other("CAM snapshot unavailable".into()))?.take() }; + Ok(async move { + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + if let Some(path) = path { + let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?; + let parsed = cam::nc::round_trip_cooperative(&path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(error)?; + let report = + cam::verify::simulate_cooperative(input, &parsed, Default::default(), || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(error)?; + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + let display = stock_display(&report, input.setup.frame, input.setup.stock.min.z, &cancelled).await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + if let Ok(mut cache) = stock.lock() { + *cache = Some(display.map(Arc::new).map_err(|e| e.to_string())); + } + Ok(JobState::Verified(path, Arc::new(report))) + } else { + let snapshot = snapshot.ok_or_else(|| EngineError::Other("CAM snapshot already consumed".into()))?; + let prepared = snapshot + .build( + &|| cancelled.load(Ordering::Relaxed), + &|n| { + progress.store(n, Ordering::Relaxed); + }, + &mut YieldOnce::default, + ) + .await?; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + input.set(prepared).map_err(|_| EngineError::Other("CAM snapshot already prepared".into()))?; + if let Ok(mut state) = state.lock() { + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + *state = JobState::Running; + } + let mut generator = + cam::generate::Generator::new(input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?.clone()) + .map_err(error)?; + loop { + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + let done = generator.step().map_err(error)?; + progress.store(20 + (generator.progress() * 80.) as usize, Ordering::Relaxed); + if done { + break; + } + } + Ok(JobState::Generated(Arc::new(generator.finish().map_err(error)?))) + } + }) +} +async fn stock_display(report: &cam::verify::Report, frame: cam::Frame, bottom: f64, cancelled: &AtomicBool) -> Result { + let triangles = + cam::verify::stock_triangles_cooperative(report, bottom, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error)?; + let mut mesh = crate::geom::Mesh::default(); + for (i, triangle) in triangles.into_iter().enumerate() { + if i.is_multiple_of(256) { + YieldOnce::default().await; + if cancelled.load(Ordering::Relaxed) { + return Err(EngineError::Other("CAM stock display cancelled".into())); + } + } + let triangle = triangle.map(|p| frame.world(p)); + let normal = (triangle[1] - triangle[0]) + .cross(triangle[2] - triangle[0]) + .normalized() + .ok_or_else(|| EngineError::Other("invalid stock display triangle".into()))?; + let base = mesh.positions.len() as u32; + mesh.positions.extend(triangle); + mesh.normals.extend([normal; 3]); + mesh.triangles.push([base, base + 1, base + 2]); + } + Ok(mesh) +} +fn run(job: Arc, verify: bool) { + match task(&job, verify) { + Ok(future) => run_future(job, future, true), + Err(e) => job.set(JobState::Failed(e.to_string())), + } +} +fn run_future(job: Arc, future: impl std::future::Future>, publish_state: bool) { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result { + let mut future = std::pin::pin!(future); + loop { + if job.cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + if let std::task::Poll::Ready(result) = + std::future::Future::poll(future.as_mut(), &mut std::task::Context::from_waker(std::task::Waker::noop())) + { + return result; + } + } + })); + let state = match outcome { + Ok(Ok(state)) => state, + Ok(Err(e)) => JobState::Failed(e.to_string()), + Err(_) => JobState::Failed("CAM worker failed; no result published".into()), + }; + if publish_state { + job.set(state); + } else if let JobState::Failed(error) = state + && let Ok(mut export) = job.export.lock() + { + *export = exports::State::Failed(error); + } +} +pub fn job(s: &Session, id: u64) -> Result> { + s.cam_jobs.jobs.get(&id).cloned().ok_or_else(|| EngineError::Other("CAM job missing; regenerate after reopening".into())) +} +pub fn current(s: &Session, job: &Job) -> Result<()> { + if (!Arc::ptr_eq(&s.doc, &job.source_doc) && *s.doc != *job.source_doc) || s.pending_moves != job.source_moves { + Err(EngineError::Other("CAM result is stale; regenerate after editing the design, tools or setup".into())) + } else { + Ok(()) + } +} + +/// Resume one browser job checkpoint. Returns true while more work is pending. +/// The host controls its frame budget; status queries can still advance one step. +#[cfg(target_arch = "wasm32")] +pub fn advance(job: &Job) -> bool { + let Ok(mut pending) = job.pending.lock() else { return false }; + if job.cancelled.load(Ordering::Relaxed) { + *pending = None; + job.set(JobState::Cancelled); + return false; + } + let Some(future) = pending.as_mut() else { return false }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) + })); + let state = match result { + Ok(std::task::Poll::Pending) => return true, + Ok(std::task::Poll::Ready(Ok(state))) => state, + Ok(std::task::Poll::Ready(Err(error))) => JobState::Failed(error.to_string()), + Err(_) => JobState::Failed("CAM worker failed; no result published".into()), + }; + *pending = None; + job.progress.store(100, Ordering::Relaxed); + job.set(state); + false +} +#[derive(Default)] +struct YieldOnce(bool); +impl std::future::Future for YieldOnce { + type Output = (); + fn poll(mut self: std::pin::Pin<&mut Self>, _: &mut std::task::Context<'_>) -> std::task::Poll<()> { + if self.0 { + std::task::Poll::Ready(()) + } else { + self.0 = true; + std::task::Poll::Pending + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use serde_json::json; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + fn captured() -> (Session, Arc) { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":20,"width":20,"height":5})).unwrap(); + let mut input = cam::corpus::fixture("face").unwrap(); + input.setup.models[0].body = s.model.state().bodies[0].name.clone(); + let m = &mut s.doc_mut().manufacturing; + m.setups.push(input.setup); + m.tools = input.tools; + m.operations = input.operations; + m.programs.push(cam::Program { id: 1, name: "Test".into(), setup: 1, operations: vec![1] }); + let job = Arc::new(Job { + source_doc: s.doc.clone(), + source_moves: s.pending_moves.clone(), + input: Arc::new(OnceLock::new()), + snapshot: Mutex::new(Some(snapshot::Snapshot::capture(&s, 1).unwrap())), + stock: Arc::new(Mutex::new(None)), + export: Arc::new(Mutex::new(exports::State::Idle)), + cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(target_arch = "wasm32")] + pending: Mutex::new(None), + progress: Arc::new(AtomicUsize::new(0)), + result: Arc::new(Mutex::new(JobState::Preparing)), + }); + (s, job) + } + #[test] + fn completed_export_worker_cannot_restore_state_after_result_collection() { + let (_, job) = captured(); + let ready = Arc::new(std::sync::Barrier::new(2)); + let collected = Arc::new(std::sync::Barrier::new(2)); + let worker_job = job.clone(); + let worker_ready = ready.clone(); + let worker_collected = collected.clone(); + let worker = std::thread::spawn(move || { + let result = worker_job.export.clone(); + run_future( + worker_job, + async move { + *result.lock().unwrap() = exports::State::Ready { content: "complete".into(), destination: None }; + worker_ready.wait(); + worker_collected.wait(); + Ok(JobState::Preparing) + }, + false, + ); + }); + ready.wait(); + assert_eq!(exports::take(&job).unwrap().0, "complete"); + job.set(JobState::Running); + collected.wait(); + worker.join().unwrap(); + assert!(matches!(job.state().unwrap(), JobState::Running)); + } + + #[test] + fn snapshot_is_immutable_and_preparation_is_deferred() { + let (mut s, job) = captured(); + let expected = input(&s, 1).unwrap(); + let mut future = std::pin::pin!(task(&job, false).unwrap()); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + assert!(job.input().is_err()); + s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap(); + s.doc_mut().manufacturing.operations[0].feed = 900.; + let mut polls = 0; + let mut previous = 0; + let state = loop { + match future.as_mut().poll(&mut cx) { + Poll::Pending => { + polls += 1; + let progress = job.progress.load(Ordering::Relaxed); + assert!(progress >= previous); + previous = progress; + } + Poll::Ready(result) => break result.unwrap(), + } + }; + assert!(polls > 2); + assert!(matches!(state, JobState::Generated(_))); + assert_eq!(serde_json::to_value(job.input().unwrap()).unwrap(), serde_json::to_value(&expected).unwrap()); + assert!(current(&s, &job).is_err()); + } + #[test] + fn preparation_cancellation_never_publishes_or_revives_job() { + let (_, job) = captured(); + let mut future = std::pin::pin!(task(&job, false).unwrap()); + let mut cx = Context::from_waker(Waker::noop()); + for _ in 0..2 { + assert!(future.as_mut().poll(&mut cx).is_pending()); + } + job.cancelled.store(true, Ordering::Relaxed); + job.set(JobState::Cancelled); + let result = loop { + if let Poll::Ready(result) = future.as_mut().poll(&mut cx) { + break result; + } + }; + assert!(result.is_err() || matches!(result, Ok(JobState::Cancelled))); + assert!(job.input().is_err()); + job.set(JobState::Running); + assert!(matches!(job.state().unwrap(), JobState::Cancelled)); + } + #[test] + fn native_worker_prepares_generates_and_releases_its_snapshot() { + let (_, job) = captured(); + let worker = job.clone(); + std::thread::spawn(move || run(worker, false)).join().unwrap(); + assert!(matches!(job.state().unwrap(), JobState::Generated(_))); + assert!(job.input().is_ok()); + assert!(job.snapshot.lock().unwrap().is_none()); + assert_eq!(job.progress.load(Ordering::Relaxed), 100); + } + + #[test] + fn cam_snapshot_uses_fixed_tolerance_for_large_curved_geometry() { + let mut session = Session::default(); + session.execute("solid.cylinder", &json!({"radius":5000,"height":6})).unwrap(); + let mut recipe = cam::corpus::fixture("face").unwrap(); + recipe.setup.models[0].body = session.model.state().bodies[0].name.clone(); + recipe.setup.stock.min = crate::geom::Vec3::new(-5000., -5000., 0.); + recipe.setup.stock.max = crate::geom::Vec3::new(5000., 5000., 7.); + let manufacturing = &mut session.doc_mut().manufacturing; + manufacturing.setups.push(recipe.setup); + manufacturing.tools = recipe.tools; + manufacturing.operations = recipe.operations; + manufacturing.programs.push(cam::Program { id: 1, name: "Large cylinder".into(), setup: 1, operations: vec![1] }); + let prepared = input(&session, 1).unwrap(); + let mut checked = 0; + for triangle in &prepared.geometry.target { + if triangle.iter().any(|p| p.z < 1.) && triangle.iter().any(|p| p.z > 5.) { + for i in 0..3 { + let midpoint = (triangle[i] + triangle[(i + 1) % 3]) * 0.5; + assert!((5000. - midpoint.x.hypot(midpoint.y)).abs() <= cam::VERIFY_ERROR / 2.); + checked += 1; + } + } + } + assert!(checked > 1000); + } +} diff --git a/crates/engine/src/manufacturing/exports.rs b/crates/engine/src/manufacturing/exports.rs new file mode 100644 index 00000000..b1c69052 --- /dev/null +++ b/crates/engine/src/manufacturing/exports.rs @@ -0,0 +1,119 @@ +//! Artifact jobs retain immutable source data; only a fresh result command may publish output. +use super::*; + +#[derive(Default)] +pub(crate) enum State { + #[default] + Idle, + Running, + Ready { + content: String, + destination: Option, + }, + Failed(String), +} +pub fn status(job: &Job) -> Result { + if job.cancelled.load(Ordering::Relaxed) { + return Ok(serde_json::json!({"state":"cancelled"})); + } + let state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + Ok(match &*state { + State::Idle => serde_json::json!({"state":"idle"}), + State::Running => serde_json::json!({"state":"running"}), + State::Ready { content, .. } => serde_json::json!({"state":"ready","bytes":content.len()}), + State::Failed(error) => serde_json::json!({"state":"failed","error":error}), + }) +} +pub fn busy(job: &Job) -> Result { + Ok(matches!(*job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?, State::Running | State::Ready { .. })) +} +pub fn take(job: &Job) -> Result<(String, Option)> { + if job.cancelled.load(Ordering::Relaxed) { + return Err(EngineError::Other("CAM export cancelled".into())); + } + let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + if !matches!(*state, State::Ready { .. }) { + return Err(EngineError::Other("CAM export is not ready".into())); + } + match std::mem::take(&mut *state) { + State::Ready { content, destination } => Ok((content, destination)), + _ => Err(EngineError::Other("CAM export is not ready".into())), + } +} +pub fn start(job: Arc, format: String, destination: Option) -> Result<()> { + if !matches!(format.as_str(), "gcode" | "toolpath" | "validation" | "stock" | "setup_sheet") { + return Err(EngineError::Other("unknown CAM artifact format".into())); + } + let original = job.state()?; + if !matches!(original, JobState::Generated(_) | JobState::Verified(..)) { + return Err(EngineError::Other("no generated result".into())); + } + { + let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + if matches!(*state, State::Running | State::Ready { .. }) { + return Err(EngineError::Other("finish or cancel the current export first".into())); + } + *state = State::Running; + } + #[cfg(not(target_arch = "wasm32"))] + { + let failed = job.clone(); + if let Err(e) = std::thread::Builder::new().name("cam-export".into()).spawn(move || { + let future = task(&job, original, format, destination); + // A ready result may already have been collected and another job started. + // Completing an export must never restore its old generation/verification state. + run_future(job, future, false); + }) { + if let Ok(mut state) = failed.export.lock() { + *state = State::Failed(e.to_string()); + } + return Err(EngineError::Other(e.to_string())); + } + } + #[cfg(target_arch = "wasm32")] + { + let future = task(&job, original, format, destination); + *job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future)); + } + Ok(()) +} +fn task(job: &Job, original: JobState, format: String, destination: Option) -> impl std::future::Future> + use<> { + let input = job.input.clone(); + let cancelled = job.cancelled.clone(); + let result = job.export.clone(); + async move { + YieldOnce::default().await; + let produce = async { + let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?; + let (path, report) = match &original { + JobState::Generated(path) => (path, None), + JobState::Verified(path, report) => (path, Some(report.as_ref())), + _ => return Err(EngineError::Other("no generated result".into())), + }; + if format == "gcode" { + let report = report.ok_or_else(|| EngineError::Other("simulate before posting".into()))?; + if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete { + return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into())); + } + cam::nc::round_trip_cooperative(path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(error)?; + cam::nc::post_cooperative(path, &input.setup, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error) + } else { + crate::io::cam::artifact_cooperative(input, path, report, &format, || cancelled.load(Ordering::Relaxed), YieldOnce::default) + .await + .map_err(|e| EngineError::Other(e.to_string())) + } + } + .await; + if cancelled.load(Ordering::Relaxed) { + return Ok(JobState::Cancelled); + } + let mut state = result.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?; + *state = match produce { + Ok(content) => State::Ready { content, destination }, + Err(error) => State::Failed(error.to_string()), + }; + Ok(original) + } +} diff --git a/crates/engine/src/manufacturing/holes.rs b/crates/engine/src/manufacturing/holes.rs new file mode 100644 index 00000000..b1e24caf --- /dev/null +++ b/crates/engine/src/manufacturing/holes.rs @@ -0,0 +1,179 @@ +//! Explicit hole picks and immutable resolution of their persistent identities. +use super::snapshot::{PlacedBody, Profile}; +use crate::{ + EngineError, Result, Session, cam, + geom::{Seg2, Vec3}, +}; +use serde::{Deserialize, Serialize}; +use solidcraft_doc::apply_point; + +fn bad(message: &str) -> EngineError { + EngineError::Other(message.into()) +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum Pick { + Face { + body: String, + index: usize, + #[serde(default)] + occurrence: Option>, + }, + Profile { + sketch: u64, + index: usize, + #[serde(default)] + occurrence: Vec, + }, +} +#[derive(Serialize)] +pub(crate) struct Selected { + pub center: [f64; 2], + pub diameter: f64, +} +pub(super) enum Hole { + Face { body: Box, name: String }, + Profile(Profile), +} +impl Hole { + pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::HoleSource, tolerance: f64) -> Result { + source.validate().map_err(super::error)?; + match source { + cam::HoleSource::Face { target, name } => { + if !setup.models.contains(target) { + return Err(bad("select a hole on one of this setup's model occurrences")); + } + Ok(Self::Face { body: Box::new(PlacedBody::capture(s, target)?), name: name.clone() }) + } + cam::HoleSource::Profile { profile } => Ok(Self::Profile(Profile::capture(s, setup, profile, tolerance)?)), + } + } + pub(super) fn resolve(self, frame: cam::Frame, tolerance: f64) -> Result { + let (origin, axis, diameter) = match self { + Self::Face { body, name } => { + let names = solidcraft_doc::naming::face_names(&body.body); + let mut indices = names.iter().enumerate().filter_map(|(i, n)| (*n == name).then_some(i)); + let index = indices.next().ok_or_else(|| bad("CAM hole face missing; reselect it"))?; + if indices.next().is_some() { + return Err(bad("CAM hole face is ambiguous; reselect it")); + } + let (origin, axis, radius) = crate::kernel::hole_axis(&body.body.body, index, cam::profile_tolerance(tolerance))?; + let point = |p| frame.local(apply_point(&body.placement, p)); + let center = point(origin); + let x = axis.any_perp(); + let y = axis.cross(x); + let dx = point(origin + x) - center; + let dy = point(origin + y) - center; + if (dx.len() - dy.len()).abs() > 1e-9 || dx.dot(dy).abs() > 1e-9 { + return Err(bad("non-circular placed hole; select a rigid occurrence")); + } + (center, point(origin + axis) - center, 2. * radius * dx.len()) + } + Self::Profile(profile) => { + if !profile.region.holes.is_empty() { + return Err(bad("select one circular profile per drill center")); + } + let Some(Seg2::Arc { center, radius, .. }) = profile.region.outer.segs.first() else { + return Err(bad("drilling profile must be circular")); + }; + let (center, radius) = (*center, *radius); + let mut sweep = 0.; + let segments = &profile.region.outer.segs; + for (a, next) in segments.iter().zip(segments.iter().cycle().skip(1)).take(segments.len()) { + let Seg2::Arc { center: c, radius: r, sweep: angle, .. } = a else { + return Err(bad("drilling profile must be circular")); + }; + if c.dist(center) > 1e-9 || (r - radius).abs() > 1e-9 || a.end().dist(next.start()) > 1e-8 { + return Err(bad("drilling profile must be a closed circle")); + } + sweep += angle; + } + if !sweep.is_finite() || (sweep.abs() - std::f64::consts::TAU).abs() > 1e-8 || radius <= 0. { + return Err(bad("drilling profile must be one complete circle")); + } + let (x, y) = (profile.plane.x, profile.plane.y); + if (x.len() - y.len()).abs() > 1e-9 || x.dot(y).abs() > 1e-9 { + return Err(bad("non-circular placed profile; select a rigid occurrence")); + } + (profile.plane.to_world(center), x.cross(y), 2. * radius * x.len()) + } + }; + let axis = axis.normalized().ok_or_else(|| bad("invalid hole axis"))?; + if !origin.is_finite() || !axis.is_finite() || !diameter.is_finite() || diameter <= 0. || axis.cross(Vec3::Z).len() > 1e-9 { + return Err(bad("drilling needs a finite circular hole parallel to setup Z")); + } + Ok(Selected { center: [origin.x, origin.y], diameter }) + } +} + +pub(crate) fn select(s: &Session, setup: &cam::Setup, picks: Vec) -> Result<(Vec, Vec)> { + if picks.is_empty() || picks.len() > cam::MAX_ITEMS { + return Err(bad("select between one and 1000 holes")); + } + let state = s.model.state(); + let doc = s.placed_document(); + let mut sources = Vec::new(); + let mut seen = Vec::new(); + let mut selected = Vec::new(); + for pick in picks { + let source = match pick { + Pick::Face { body, index, occurrence } => { + let target = if let Some(occurrence) = occurrence { + cam::BodyRef { body, occurrence } + } else { + if setup.models.len().saturating_mul(doc.occurrences.len() + state.bodies.len() + 64) > 2_000_000 { + return Err(bad("hole selection work limit; provide a definition body and occurrence path")); + } + // Only these explicit model placements may be selected. Do not expand + // an entire shared assembly merely to interpret a viewport label. + let mut matching = setup.models.iter().filter(|target| { + state.body(&target.body).is_some_and(|model| { + doc.world_body_name(&state, &model.name, doc.model_body_component(model), &target.occurrence) == body + }) + }); + let target = matching.next().ok_or_else(|| bad("selected hole body is not in this setup; reselect its model occurrence"))?; + if matching.next().is_some() { + return Err(bad("selected hole body ambiguous; provide its occurrence path")); + } + target.clone() + }; + let placed = PlacedBody::capture(s, &target)?; + let name = solidcraft_doc::naming::face_names(&placed.body).get(index).cloned().ok_or_else(|| bad("selected hole face missing"))?; + cam::HoleSource::Face { target, name } + } + Pick::Profile { sketch, index, occurrence } => { + let profile = state + .sketches + .iter() + .find(|s| s.feature == sketch) + .and_then(|s| s.profiles.get(index)) + .ok_or_else(|| bad("selected circular profile missing"))?; + cam::HoleSource::Profile { profile: cam::ProfileRef { sketch, curves: profile.curve_ids(), occurrence } } + } + }; + if seen.contains(&source) { + return Err(bad("duplicate hole selection")); + } + seen.push(source.clone()); + let hole = Hole::capture(s, setup, &source, cam::TOLERANCE)?.resolve(setup.frame, cam::TOLERANCE)?; + if let Some((old, prior)) = sources + .iter() + .zip(&selected) + .find(|(_, p): &(_, &Selected)| (p.center[0] - hole.center[0]).hypot(p.center[1] - hole.center[1]) < cam::TOLERANCE) + { + // A viewport pick expands a cylinder across its topological seam. Its + // faces still describe one hole on one explicitly placed body. + if matches!((old, &source), (cam::HoleSource::Face { target: a, .. }, cam::HoleSource::Face { target: b, .. }) if a == b) + && (prior.center[0] - hole.center[0]).hypot(prior.center[1] - hole.center[1]) < 1e-8 + && (prior.diameter - hole.diameter).abs() < 1e-8 + { + continue; + } + return Err(bad("selected hole axes coincide; remove duplicate selections")); + } + sources.push(source); + selected.push(hole); + } + Ok((sources, selected)) +} diff --git a/crates/engine/src/manufacturing/snapshot.rs b/crates/engine/src/manufacturing/snapshot.rs new file mode 100644 index 00000000..0218d856 --- /dev/null +++ b/crates/engine/src/manufacturing/snapshot.rs @@ -0,0 +1,422 @@ +//! Cheap immutable capture on the session thread; curve sampling, tessellation, +//! transforms and hashing happen during preparation on the job's worker. +use crate::{ + EngineError, Result, Session, cam, + geom::{Plane, Region2, Seg2, Vec2, Vec3}, +}; +use solidcraft_doc::{Mat, apply_point}; + +pub(super) struct Snapshot { + setup: cam::Setup, + tools: Vec, + operations: Vec, + profiles: Vec<(usize, Profile)>, + holes: Vec<(usize, Vec)>, + models: Vec, + fixtures: Vec, +} +pub(super) struct PlacedBody { + pub(super) body: solidcraft_doc::ModelBody, + pub(super) placement: Mat, +} +pub(super) struct Profile { + pub(super) region: Region2, + pub(super) plane: Plane, + tolerance: f64, +} +fn error(message: &str) -> EngineError { + EngineError::Other(message.into()) +} +fn cancelled(check: &impl Fn() -> bool) -> Result<()> { + if check() { Err(error("CAM preparation cancelled")) } else { Ok(()) } +} + +impl Snapshot { + pub(super) fn capture(s: &Session, program: u64) -> Result { + s.doc.manufacturing.validate().map_err(super::error)?; + let m = &s.doc.manufacturing; + let p = m.programs.iter().find(|p| p.id == program).ok_or_else(|| error("CAM program not found"))?; + let setup = m.setups.iter().find(|x| x.id == p.setup).cloned().ok_or_else(|| error("CAM setup missing"))?; + let operations = p + .operations + .iter() + .map(|id| m.operations.iter().find(|o| o.id == *id).cloned().ok_or_else(|| error("CAM operation missing"))) + .collect::>>()?; + let mut profiles = Vec::new(); + for (i, o) in operations.iter().enumerate() { + if let Some(source) = &o.source { + profiles.push((i, Profile::capture(s, &setup, source, o.tolerance)?)); + } + } + let mut holes = Vec::new(); + for (index, operation) in operations.iter().enumerate() { + if !operation.hole_sources.is_empty() { + holes.push(( + index, + operation + .hole_sources + .iter() + .map(|source| super::holes::Hole::capture(s, &setup, source, operation.tolerance)) + .collect::>()?, + )); + } + } + let tools = m.tools.iter().filter(|t| operations.iter().any(|o| o.tool == t.id)).cloned().collect::>(); + if tools.iter().map(|t| t.number).collect::>().len() != tools.len() { + return Err(error("CAM tools must have unique tool numbers within a program")); + } + let models = setup.models.iter().map(|r| PlacedBody::capture(s, r)).collect::>()?; + let fixtures = setup.fixtures.iter().map(|r| PlacedBody::capture(s, r)).collect::>()?; + Ok(Self { setup, tools, operations, profiles, holes, models, fixtures }) + } + pub(super) async fn build>( + mut self, + check: &impl Fn() -> bool, + progress: &impl Fn(usize), + yield_control: &mut impl FnMut() -> F, + ) -> Result { + let total = + self.profiles.len() + self.holes.iter().map(|(_, holes)| holes.len()).sum::() + self.models.len() + self.fixtures.len() + 1; + let mut done = 0; + for (index, p) in self.profiles { + cancelled(check)?; + yield_control().await; + let boundary = p.sample(check, yield_control).await?; + let op = self.operations.get_mut(index).ok_or_else(|| error("CAM operation missing from snapshot"))?; + match &mut op.geometry { + cam::Strategy::Contour { boundary: b, .. } | cam::Strategy::Pocket { boundary: b } => *b = boundary, + _ => return Err(error("profile bound to unsupported operation")), + } + done += 1; + progress(done * 20 / total); + } + for (index, holes) in self.holes { + let operation = self.operations.get_mut(index).ok_or_else(|| error("CAM hole operation missing"))?; + let tool = self.tools.iter().find(|t| t.id == operation.tool).ok_or_else(|| error("CAM drill missing"))?; + let mut centers = Vec::new(); + for hole in holes { + cancelled(check)?; + yield_control().await; + let selected = hole.resolve(self.setup.frame, operation.tolerance)?; + if (selected.diameter - tool.diameter).abs() > operation.tolerance { + return Err(error("selected hole diameter differs from the drill; choose a matching tool or unlink the hole")); + } + if centers.iter().any(|p: &[f64; 2]| (p[0] - selected.center[0]).hypot(p[1] - selected.center[1]) <= operation.tolerance) { + return Err(error("selected hole axes coincide; remove duplicate selections")); + } + centers.push(selected.center); + done += 1; + progress(done * 20 / total); + } + let cam::Strategy::Drill { holes, .. } = &mut operation.geometry else { + return Err(error("hole references require drilling")); + }; + *holes = centers; + } + let mut target = Vec::new(); + let mut fixtures = Vec::new(); + let mut fixture_groups = Vec::new(); + for body in self.models { + body.append(self.setup.frame, &mut target, check, yield_control).await?; + done += 1; + progress(done * 20 / total); + } + for body in self.fixtures { + let before = fixtures.len(); + body.append(self.setup.frame, &mut fixtures, check, yield_control).await?; + fixture_groups.push(fixtures.len() - before); + done += 1; + progress(done * 20 / total); + } + if target.len() + fixtures.len() > 200_000 { + return Err(error("CAM mesh exceeds verification budget")); + } + cancelled(check)?; + yield_control().await; + let mut input = cam::JobInput { + setup: self.setup, + tools: self.tools, + operations: self.operations, + geometry: cam::Geometry { target, fixtures, fixture_groups }, + fingerprint: String::new(), + }; + input.validate().map_err(super::error)?; + input.fingerprint = cam::fingerprint_job_cooperative(&input, check, yield_control).await.map_err(super::error)?; + cancelled(check)?; + progress(20); + Ok(input) + } +} +impl PlacedBody { + pub(super) fn capture(s: &Session, r: &cam::BodyRef) -> Result { + let state = s.model.state(); + let candidates: Vec<_> = state.bodies.iter().filter(|b| b.name == r.body).collect(); + if candidates.len() != 1 { + return Err(error(&format!("CAM body `{}` missing or ambiguous; reselect it", r.body))); + } + let b = candidates[0]; + let component = s.doc.model_body_component(b); + let placement = if r.occurrence.is_empty() { + if component != 0 { + return Err(error("CAM component body requires an occurrence path")); + } + solidcraft_doc::IDENTITY + } else { + let p = s.occurrence_placement(&r.occurrence)?; + if p.component != component { + return Err(error("CAM body no longer belongs to selected occurrence")); + } + p.world + }; + let placement = s.doc.model_body_offset(b).map(|offset| solidcraft_doc::mat_mul(&placement, offset)).unwrap_or(placement); + Ok(Self { body: b.clone(), placement }) + } + async fn append>( + self, + frame: cam::Frame, + out: &mut Vec<[Vec3; 3]>, + check: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result<()> { + cancelled(check)?; + yield_control().await; + cancelled(check)?; + let mesh = self.body.body.tessellate_for_verification(cam::VERIFY_ERROR / 2., 200_000usize.saturating_sub(out.len()))?; + if out.len() + mesh.triangles.len() > 200_000 { + return Err(error("CAM mesh exceeds verification budget")); + } + for (i, indices) in mesh.triangles.iter().enumerate() { + if i.is_multiple_of(256) { + cancelled(check)?; + yield_control().await; + } + let triangle = mesh.tri(indices).ok_or_else(|| error("CAM mesh contains invalid indices"))?; + out.push(triangle.map(|v| frame.local(apply_point(&self.placement, v)))); + } + Ok(()) + } +} +impl Profile { + pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result { + let state = s.model.state(); + let sketch = state.sketches.iter().find(|x| x.feature == source.sketch).ok_or_else(|| error("CAM sketch missing; reselect the profile"))?; + let mut wanted = source.curves.clone(); + wanted.sort(); + let candidates: Vec<_> = sketch + .profiles + .iter() + .filter(|p| { + let mut ids = p.curve_ids(); + ids.sort(); + ids == wanted + }) + .collect(); + if candidates.len() != 1 { + return Err(error("CAM profile missing or ambiguous; reselect it")); + } + let component = s.doc.feature(source.sketch).map(|f| f.component).ok_or_else(|| error("CAM sketch feature missing"))?; + let transform = if source.occurrence.is_empty() { + if component != 0 { + return Err(error("CAM sketch needs an explicit occurrence path")); + } + solidcraft_doc::IDENTITY + } else { + let p = s.occurrence_placement(&source.occurrence)?; + if p.component != component { + return Err(error("CAM sketch occurrence mismatch")); + } + p.world + }; + let point = |p| setup.frame.local(apply_point(&transform, p)); + let origin = point(sketch.plane.origin); + let plane = + Plane { origin, x: point(sketch.plane.origin + sketch.plane.x) - origin, y: point(sketch.plane.origin + sketch.plane.y) - origin }; + Ok(Self { region: candidates[0].region.clone(), plane, tolerance: cam::profile_tolerance(tolerance) }) + } + fn segments(&self, s: &Seg2) -> Result { + let v = |p: Vec2| self.plane.x * p.x + self.plane.y * p.y; + let m = match s { + Seg2::Line { .. } => 0., + Seg2::Arc { radius, sweep, .. } => radius.abs() * sweep.powi(2) * (self.plane.x.len2() + self.plane.y.len2()).sqrt(), + Seg2::Cubic { p0, p1, p2, p3 } => 6. * v(*p0 - *p1 * 2. + *p2).len().max(v(*p1 - *p2 * 2. + *p3).len()), + Seg2::Bezier { n, p } => { + let points = p.get(..=*n as usize).filter(|p| p.len() >= 2).ok_or_else(|| error("invalid CAM Bezier degree"))?; + *n as f64 * (*n as f64 - 1.) * points.windows(3).map(|p| v(p[0] - p[1] * 2. + p[2]).len()).fold(0., f64::max) + } + Seg2::Conic { a, apex, b, w } => { + if !w.is_finite() || *w <= -1. { + return Err(error("singular CAM conic profile")); + } + let u = v((*apex - *a) * *w); + let b = v(*b - *a); + let d = 1_f64.min((1. + w) / 2.); + let n = u.len().max(b.len()); + let n1 = 2. * u.len().max((b - u).len()); + let n2 = 2. * (b - u * 2.).len(); + let d1 = 2. * (w - 1.).abs(); + let d2 = 4. * (w - 1.).abs(); + n2 / d + 2. * n1 * d1 / d.powi(2) + n * d2 / d.powi(2) + 2. * n * d1.powi(2) / d.powi(3) + } + }; + // ||curve''||/(8 n²) bounds the chord error on every parameter interval. + // The reserve covers evaluation/placement rounding, rather than silently + // clamping the segment count or increasing the requested tolerance. + let size = |p: Vec2| self.plane.x.len() * p.x.abs() + self.plane.y.len() * p.y.abs(); + let magnitude = match s { + Seg2::Line { a, b } => size(*a) + size(*b), + Seg2::Arc { center, radius, start, sweep } => { + size(*center) + radius.abs() * (1. + start.abs() + sweep.abs()) * (self.plane.x.len() + self.plane.y.len()) + } + Seg2::Cubic { p0, p1, p2, p3 } => size(*p0) + size(*p1) + size(*p2) + size(*p3), + Seg2::Bezier { n, p } => p.get(..=*n as usize).ok_or_else(|| error("invalid CAM Bezier degree"))?.iter().map(|p| size(*p)).sum(), + Seg2::Conic { a, apex, b, w } => (size(*a) + w.abs() * size(*apex) + size(*b)) / 1_f64.min((1. + w) / 2.), + }; + let guard = 256. * f64::EPSILON * (1. + self.plane.origin.len() + magnitude + m.abs()); + let budget = self.tolerance * 15. / 16. - guard; + if !m.is_finite() || !budget.is_finite() || budget <= 0. { + return Err(error("CAM profile precision cannot meet the requested tolerance")); + } + let count = ((m * (1. + 256. * f64::EPSILON) / (8. * budget)).sqrt().ceil()).max(1.); + if !count.is_finite() || count > 10_000. { + return Err(error("CAM profile exceeds 10000 vertices at the required tolerance")); + } + Ok(count as usize) + } + pub(super) async fn sample>( + &self, + check: &impl Fn() -> bool, + yield_control: &mut impl FnMut() -> F, + ) -> Result { + if !self.plane.origin.is_finite() || !self.plane.x.is_finite() || !self.plane.y.is_finite() || self.plane.normal().len() < 1e-12 { + return Err(error("degenerate CAM profile placement")); + } + if self.plane.x.z.abs() > 1e-10 * self.plane.x.len() || self.plane.y.z.abs() > 1e-10 * self.plane.y.len() { + return Err(error("CAM profile must be perpendicular to setup Z")); + } + let mut rings = Vec::new(); + let mut total = 0; + for ring in std::iter::once(&self.region.outer).chain(&self.region.holes) { + let mut points = Vec::new(); + for (a, b) in ring.segs.iter().zip(ring.segs.iter().cycle().skip(1)).take(ring.segs.len()) { + let gap = self.plane.dir_to_world(a.end() - b.start()).len(); + if !gap.is_finite() || gap > self.tolerance / 16. { + return Err(error("disconnected CAM profile segments")); + } + } + for curve in &ring.segs { + cancelled(check)?; + yield_control().await; + let count = self.segments(curve)?; + total += count; + if total > 10_000 { + return Err(error("CAM profile exceeds 10000 vertices at the required tolerance")); + } + for i in 0..count { + if i.is_multiple_of(256) { + cancelled(check)?; + yield_control().await; + } + let p = self.plane.to_world(curve.point_at(i as f64 / count as f64)); + if !p.is_finite() { + return Err(error("non-finite CAM profile point")); + } + points.push([p.x, p.y]); + } + } + if points.first() == points.last() { + points.pop(); + } + rings.push(points); + } + let mut rings = rings.into_iter(); + let boundary = cam::Boundary { outer: rings.next().ok_or_else(|| error("empty CAM profile"))?, islands: rings.collect() }; + boundary.validate().map_err(super::error)?; + Ok(boundary) + } +} + +pub(super) fn ready(future: impl std::future::Future>) -> Result { + use std::task::{Context, Poll, Waker}; + let mut future = std::pin::pin!(future); + match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(result) => result, + Poll::Pending => Err(error("synchronous CAM preparation unexpectedly yielded")), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::geom::Loop2; + fn profile() -> Profile { + Profile { region: Region2 { outer: Loop2::circle(Vec2::ZERO, 8.), holes: vec![] }, plane: Plane::XY, tolerance: cam::profile_tolerance(0.01) } + } + #[test] + fn bounded_sampler_meets_transformed_curve_chord_budget() { + let mut p = profile(); + p.plane.x = Vec3::new(3., 1., 0.); + p.plane.y = Vec3::new(-1., 0.5, 0.); + p.plane.origin = Vec3::new(100., -120., 2.); + let a = Vec2::new(2., -1.); + let b = Vec2::new(-2., 3.); + let c = Vec2::new(4., 8.); + let mut curves = vec![ + Seg2::Line { a, b }, + Seg2::Arc { center: a, radius: 8., start: 0.2, sweep: -5. }, + Seg2::Cubic { p0: a, p1: c, p2: b, p3: Vec2::ZERO }, + Seg2::Bezier { n: 7, p: [a, b, c, a, b, c, a, b, c, a] }, + Seg2::Bezier { n: 9, p: [a, b, c, a, b, c, a, b, c, a] }, + ]; + for w in [-0.5, 0., 0.7, 1., 5.] { + curves.push(Seg2::Conic { a, apex: c, b, w }); + } + for curve in curves { + let n = p.segments(&curve).unwrap(); + for i in 0..n { + let a = p.plane.to_world(curve.point_at(i as f64 / n as f64)); + let b = p.plane.to_world(curve.point_at((i + 1) as f64 / n as f64)); + for k in 1..8 { + let fraction = k as f64 / 8.; + let exact = p.plane.to_world(curve.point_at((i as f64 + fraction) / n as f64)); + assert!(exact.dist(a + (b - a) * fraction) <= p.tolerance, "{curve:?}"); + } + } + } + } + #[test] + fn precision_and_vertex_limits_fail_without_relaxing_tolerance() { + let mut p = profile(); + let circle = Seg2::Arc { center: Vec2::ZERO, radius: 1e6, start: 0., sweep: std::f64::consts::TAU }; + assert!(p.segments(&circle).is_err()); + p.plane.origin = Vec3::new(1e15, 0., 0.); + assert!(p.segments(&Seg2::Line { a: Vec2::ZERO, b: Vec2::X }).is_err()); + let p = profile(); + assert!(p.segments(&Seg2::Bezier { n: 255, p: Default::default() }).is_err()); + assert!(p.segments(&Seg2::Conic { a: Vec2::ZERO, apex: Vec2::X, b: Vec2::Y, w: -1. }).is_err()); + assert!(p.segments(&Seg2::Line { a: Vec2::new(f64::NAN, 0.), b: Vec2::ZERO }).is_err()); + } + #[test] + fn profile_sampling_yields_and_honours_cancellation_and_planarity() { + use std::{ + future::Future, + sync::atomic::{AtomicBool, Ordering}, + task::{Context, Poll, Waker}, + }; + let p = profile(); + let cancel = AtomicBool::new(false); + let check = || cancel.load(Ordering::Relaxed); + let mut yields = super::super::YieldOnce::default; + let mut future = std::pin::pin!(p.sample(&check, &mut yields)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(future.as_mut().poll(&mut cx).is_pending()); + cancel.store(true, Ordering::Relaxed); + assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_)))); + let mut p = profile(); + p.plane = Plane::XZ; + assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err()); + p.plane = Plane::XY; + p.region.outer.segs.pop(); + assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err()); + } +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 7afd3eeb..b6dd52fc 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +solidcraft-cam = { workspace = true } solidcraft-geom = { workspace = true } solidcraft-kernel = { workspace = true } solidcraft-doc = { workspace = true } diff --git a/crates/io/src/cam.rs b/crates/io/src/cam.rs new file mode 100644 index 00000000..4f42cf87 --- /dev/null +++ b/crates/io/src/cam.rs @@ -0,0 +1,62 @@ +//! CAM artifact serialization. The engine owns freshness and posting gates. +mod incremental; +pub use incremental::artifact_cooperative; + +#[cfg(test)] +use crate::IoError; +use crate::Result; +use solidcraft_cam::{JobInput, Toolpath, verify::Report}; +pub fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") +} +/// Synchronous compatibility wrapper; interactive clients use the cooperative entry point. +pub fn artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result { + let mut future = std::pin::pin!(artifact_cooperative(input, path, report, format, || false, || std::future::ready(()))); + loop { + if let std::task::Poll::Ready(result) = + std::future::Future::poll(future.as_mut(), &mut std::task::Context::from_waker(std::task::Waker::noop())) + { + return result; + } + } +} + +// Original serializer retained only as an independent schema/escaping oracle in tests. +#[cfg(test)] +fn reference_artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result { + let json = |v: serde_json::Value| serde_json::to_string_pretty(&v).map_err(|e| IoError::Invalid(e.to_string())); + match format { + "toolpath" => json(serde_json::json!({"version":solidcraft_cam::VERSION,"input":input,"toolpath":path})), + "validation" => json( + serde_json::to_value(report.ok_or_else(|| IoError::Invalid("simulate first".into()))?).map_err(|e| IoError::Invalid(e.to_string()))?, + ), + "stock" => solidcraft_cam::verify::stock_stl(report.ok_or_else(|| IoError::Invalid("simulate first".into()))?, input.setup.stock.min.z) + .map_err(|e| IoError::Invalid(e.to_string())), + "setup_sheet" => { + let mut html = format!( + "{}

{}

Reference three-axis milling — not a qualified machine post.

Fingerprint: {}

Setup

{}

Operations

", + escape(&input.setup.name), + escape(&input.setup.name), + escape(&path.fingerprint), + escape(&serde_json::to_string_pretty(&input.setup).map_err(|e| IoError::Invalid(e.to_string()))?) + ); + for o in &input.operations { + html.push_str(&format!( + "", + escape(&o.name), + o.tool, + o.feed, + o.spindle, + o.bottom + )); + } + html.push_str("
NameToolFeed mm/minSpindle rpmBottom mm
{}{}{}{}{}

Tools

");
+            html.push_str(&escape(&serde_json::to_string_pretty(&input.tools).map_err(|e| IoError::Invalid(e.to_string()))?));
+            html.push_str("

Operation settings and selected geometry

");
+            html.push_str(&escape(&serde_json::to_string_pretty(&input.operations).map_err(|e| IoError::Invalid(e.to_string()))?));
+            html.push_str("
"); + Ok(html) + } + _ => Err(IoError::Invalid("unknown CAM artifact format".into())), + } +} diff --git a/crates/io/src/cam/incremental.rs b/crates/io/src/cam/incremental.rs new file mode 100644 index 00000000..7401e8ce --- /dev/null +++ b/crates/io/src/cam/incremental.rs @@ -0,0 +1,276 @@ +//! Bounded artifact serialization. Large arrays never become an intermediate JSON Value. +use super::{JobInput, Report, Toolpath, escape}; +use crate::{IoError, Result}; +use serde::Serialize; +use std::future::Future; + +const MAX_BYTES: usize = 128 * 1024 * 1024; +struct Writer { + out: String, + cancelled: C, + yield_control: Y, +} +impl bool, Y: FnMut() -> F, F: Future> Writer { + async fn checkpoint(&mut self) -> Result<()> { + if (self.cancelled)() { + return Err(IoError::Invalid("CAM export cancelled".into())); + } + if self.out.len() > MAX_BYTES { + return Err(IoError::Invalid("CAM artifact exceeds 128 MiB".into())); + } + (self.yield_control)().await; + if (self.cancelled)() { + return Err(IoError::Invalid("CAM export cancelled".into())); + } + Ok(()) + } + fn value(&mut self, v: &impl Serialize) -> Result<()> { + self.out.push_str(&serde_json::to_string(v).map_err(|e| IoError::Invalid(e.to_string()))?); + Ok(()) + } + async fn array(&mut self, values: &[T], batch: usize) -> Result<()> { + self.out.push('['); + for (i, value) in values.iter().enumerate() { + if i.is_multiple_of(batch) { + self.checkpoint().await?; + } + if i != 0 { + self.out.push(','); + } + self.value(value)?; + } + self.out.push(']'); + Ok(()) + } + async fn html_array(&mut self, values: &[T]) -> Result<()> { + self.out.push('['); + for (i, value) in values.iter().enumerate() { + self.checkpoint().await?; + if i != 0 { + self.out.push(','); + } + self.out.push('\n'); + self.out.push_str(&escape(&serde_json::to_string_pretty(value).map_err(|e| IoError::Invalid(e.to_string()))?)); + } + self.out.push_str("\n]"); + Ok(()) + } +} + +/// Build a complete artifact, yielding between bounded array batches and observing cancellation. +/// JSON field values match `artifact`; whitespace/object order may differ. No partial output escapes. +pub async fn artifact_cooperative>( + input: &JobInput, + path: &Toolpath, + report: Option<&Report>, + format: &str, + cancelled: impl Fn() -> bool, + yield_control: impl FnMut() -> F, +) -> Result { + if format == "stock" { + return solidcraft_cam::verify::stock_stl_cooperative( + report.ok_or_else(|| IoError::Invalid("simulate first".into()))?, + input.setup.stock.min.z, + cancelled, + yield_control, + ) + .await + .map_err(|e| IoError::Invalid(e.to_string())); + } + let mut w = Writer { out: String::new(), cancelled, yield_control }; + w.checkpoint().await?; + match format { + "toolpath" => { + w.out.push_str("{\"version\":"); + w.value(&solidcraft_cam::VERSION)?; + w.out.push_str(",\"input\":{\"setup\":"); + w.value(&input.setup)?; + w.out.push_str(",\"tools\":"); + w.array(&input.tools, 1).await?; + w.out.push_str(",\"operations\":"); + w.array(&input.operations, 1).await?; + w.out.push_str(",\"fingerprint\":"); + w.value(&input.fingerprint)?; + w.out.push_str(",\"geometry\":{\"target\":"); + w.array(&input.geometry.target, 256).await?; + w.out.push_str(",\"fixtures\":"); + w.array(&input.geometry.fixtures, 256).await?; + w.out.push_str(",\"fixture_groups\":"); + w.array(&input.geometry.fixture_groups, 256).await?; + w.out.push_str("}},\"toolpath\":{\"fingerprint\":"); + w.value(&path.fingerprint)?; + w.out.push_str(",\"blocks\":"); + w.array(&path.blocks, 256).await?; + w.out.push_str("}}"); + } + "validation" => { + let r = report.ok_or_else(|| IoError::Invalid("simulate first".into()))?; + // This small metadata object excludes the potentially large cell array. + let metadata = serde_json::json!({ + "version":r.version,"fingerprint":r.fingerprint,"status":r.status, + "finished_dimensions":r.finished_dimensions,"post_version":r.post_version, + "tolerance":r.tolerance,"uncertainty":r.uncertainty,"maximum_height_interval":r.maximum_height_interval, + "stock_complete":r.stock_complete,"stock":r.stock,"findings":r.findings, + "cutting_distance":r.cutting_distance,"rapid_distance":r.rapid_distance,"cutting_minutes":r.cutting_minutes, + "remaining_volume_low":r.remaining_volume_low,"remaining_volume_high":r.remaining_volume_high,"work":r.work, + }); + w.value(&metadata)?; + w.out.pop(); + w.out.push_str(",\"cells\":"); + w.array(&r.cells, 256).await?; + w.out.push('}'); + } + "setup_sheet" => { + w.out = format!( + "{}

{}

Reference three-axis milling — not a qualified machine post.

Fingerprint: {}

Setup

{}

Operations

", + escape(&input.setup.name), + escape(&input.setup.name), + escape(&path.fingerprint), + escape(&serde_json::to_string_pretty(&input.setup).map_err(|e| IoError::Invalid(e.to_string()))?), + ); + for o in &input.operations { + w.checkpoint().await?; + w.out.push_str(&format!( + "", + escape(&o.name), + o.tool, + o.feed, + o.spindle, + o.bottom + )); + } + w.out.push_str("
NameToolFeed mm/minSpindle rpmBottom mm
{}{}{}{}{}

Tools

");
+            w.html_array(&input.tools).await?;
+            w.out.push_str("

Operation settings and selected geometry

");
+            w.html_array(&input.operations).await?;
+            w.out.push_str("
"); + } + _ => return Err(IoError::Invalid("unknown CAM artifact format".into())), + } + w.checkpoint().await?; + Ok(w.out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use std::cell::Cell; + fn ready(future: impl Future) -> T { + let mut future = std::pin::pin!(future); + loop { + if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + return value; + } + } + } + fn fixture() -> (JobInput, Toolpath, Report) { + let input = solidcraft_cam::corpus::fixture("face").unwrap(); + let path = solidcraft_cam::generate::generate(input.clone()).unwrap(); + let parsed = solidcraft_cam::nc::round_trip(&path, &input.setup, &input.tools).unwrap(); + let report = solidcraft_cam::verify::simulate(&input, &parsed, Default::default()).unwrap(); + (input, path, report) + } + fn pre_values(html: &str) -> Vec { + html.split("
")
+            .skip(1)
+            .map(|part| {
+                let text = part
+                    .split("
") + .next() + .unwrap() + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&"); + serde_json::from_str(&text).unwrap() + }) + .collect() + } + #[test] + fn cooperative_artifacts_preserve_every_field_geometry_and_html_escaping() { + let (mut input, mut path, mut report) = fixture(); + input.operations[0].name = "ø