diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d1f990b..8b523826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,8 +119,6 @@ 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) @@ -212,7 +210,6 @@ 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) @@ -251,14 +248,6 @@ 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 663095e2..6221864b 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: 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-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-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 30f3b62d..684cb308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,15 +197,6 @@ 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" @@ -761,18 +752,6 @@ 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" @@ -821,7 +800,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" dependencies = [ - "approx 0.4.0", + "approx", "num-traits", "serde", ] @@ -1187,12 +1166,6 @@ 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" @@ -1354,15 +1327,6 @@ 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" @@ -1726,28 +1690,6 @@ 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" @@ -1925,15 +1867,6 @@ 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" @@ -1954,16 +1887,6 @@ 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" @@ -2184,8 +2107,6 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -2653,17 +2574,6 @@ 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" @@ -3116,34 +3026,6 @@ 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" @@ -3676,17 +3558,6 @@ 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" @@ -3814,15 +3685,6 @@ 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" @@ -4053,19 +3915,6 @@ 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" @@ -4229,20 +4078,6 @@ 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" @@ -4271,7 +4106,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "solidcraft-cam", "solidcraft-derive", "solidcraft-geom", "solidcraft-kernel", @@ -4288,7 +4122,6 @@ dependencies = [ "log", "serde", "serde_json", - "solidcraft-cam", "solidcraft-doc", "solidcraft-geom", "solidcraft-io", @@ -4336,7 +4169,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "solidcraft-cam", "solidcraft-doc", "solidcraft-geom", "solidcraft-kernel", @@ -4461,15 +4293,6 @@ 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" @@ -5125,7 +4948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", - "downcast-rs 1.2.1", + "downcast-rs", "rustix 1.1.5", "scoped-tls", "smallvec", @@ -5487,16 +5310,6 @@ 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" @@ -5942,7 +5755,6 @@ version = "0.14.0" dependencies = [ "serde_json", "sha2 0.10.9", - "solidcraft-cam", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fa7dda4c..080e0184 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ 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 11aba9cb..67e1ba71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,21 +39,7 @@ 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, 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. +PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Milestones diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 4af4b117..2f342a8c 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -142,19 +142,6 @@ 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 fad39f95..b2238047 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -305,22 +305,6 @@ "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", @@ -1129,7 +1113,6 @@ "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", @@ -1142,7 +1125,6 @@ "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", @@ -1177,12 +1159,10 @@ "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 f51ed2e5..9086717a 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -305,22 +305,6 @@ "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": "キャンバスを削除", @@ -1129,7 +1113,6 @@ "ui.heading_design": "デザイン", "ui.heading_draft": "勾配", "ui.heading_edit": "編集", - "ui.heading_export": "出力", "ui.heading_extrude": "押し出し", "ui.heading_fillet": "フィレット", "ui.heading_finish_sketch": "スケッチを終了", @@ -1142,7 +1125,6 @@ "ui.heading_make": "製造", "ui.heading_measure": "計測", "ui.heading_mesh": "メッシュ", - "ui.heading_milling": "切削", "ui.heading_mirror": "ミラー", "ui.heading_modify": "修正", "ui.heading_move": "移動", @@ -1177,12 +1159,10 @@ "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 885028db..e9acd94e 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -305,22 +305,6 @@ "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": "删除画布", @@ -1129,7 +1113,6 @@ "ui.heading_design": "设计", "ui.heading_draft": "拔模", "ui.heading_edit": "编辑", - "ui.heading_export": "导出", "ui.heading_extrude": "拉伸", "ui.heading_fillet": "圆角", "ui.heading_finish_sketch": "完成草图", @@ -1142,7 +1125,6 @@ "ui.heading_make": "制作", "ui.heading_measure": "测量", "ui.heading_mesh": "网格", - "ui.heading_milling": "铣削", "ui.heading_mirror": "镜像", "ui.heading_modify": "修改", "ui.heading_move": "移动", @@ -1177,12 +1159,10 @@ "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 5c697cbe..2236e5ff 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -46,11 +46,6 @@ 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), @@ -146,11 +141,6 @@ 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); @@ -219,12 +209,6 @@ 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 } @@ -241,12 +225,6 @@ 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 deleted file mode 100644 index cf056af5..00000000 --- a/crates/cam/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[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 deleted file mode 100644 index fbcb9460..00000000 --- a/crates/cam/src/collision.rs +++ /dev/null @@ -1,483 +0,0 @@ -//! 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 deleted file mode 100644 index 978a9397..00000000 --- a/crates/cam/src/compare.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! 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 deleted file mode 100644 index bee8007d..00000000 --- a/crates/cam/src/corpus.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! 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 deleted file mode 100644 index 7f0f1b52..00000000 --- a/crates/cam/src/corpus/channel.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! 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 deleted file mode 100644 index 40854062..00000000 --- a/crates/cam/src/corpus/extended.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! 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 deleted file mode 100644 index d3164f50..00000000 --- a/crates/cam/src/corpus/exterior.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! 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 deleted file mode 100644 index caf88a2b..00000000 --- a/crates/cam/src/corpus/through.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! 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 deleted file mode 100644 index e6916758..00000000 --- a/crates/cam/src/coverage.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! 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 deleted file mode 100644 index ea7b064c..00000000 --- a/crates/cam/src/dimensions.rs +++ /dev/null @@ -1,554 +0,0 @@ -//! 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 deleted file mode 100644 index 13f5a976..00000000 --- a/crates/cam/src/generate.rs +++ /dev/null @@ -1,680 +0,0 @@ -//! 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 deleted file mode 100644 index 1a6f528d..00000000 --- a/crates/cam/src/hashing.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! 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 deleted file mode 100644 index d2052fdf..00000000 --- a/crates/cam/src/lib.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! 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 deleted file mode 100644 index 240aa3a7..00000000 --- a/crates/cam/src/model.rs +++ /dev/null @@ -1,523 +0,0 @@ -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 deleted file mode 100644 index 0b58a0ea..00000000 --- a/crates/cam/src/nc.rs +++ /dev/null @@ -1,712 +0,0 @@ -//! 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 deleted file mode 100644 index e30dea41..00000000 --- a/crates/cam/src/playback.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! 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 deleted file mode 100644 index f6089dae..00000000 --- a/crates/cam/src/region.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! 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 deleted file mode 100644 index 3af7c4b7..00000000 --- a/crates/cam/src/spatial.rs +++ /dev/null @@ -1,65 +0,0 @@ -// 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 deleted file mode 100644 index ce5e6bc1..00000000 --- a/crates/cam/src/stock_mesh.rs +++ /dev/null @@ -1,728 +0,0 @@ -//! 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 deleted file mode 100644 index eb4c3b9c..00000000 --- a/crates/cam/src/surface.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! 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 deleted file mode 100644 index 782b55df..00000000 --- a/crates/cam/src/verify.rs +++ /dev/null @@ -1,644 +0,0 @@ -//! 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 deleted file mode 100644 index a2f8e5bf..00000000 --- a/crates/cam/tests/machine_state.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! 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 deleted file mode 100644 index ab180410..00000000 --- a/crates/cam/tests/milling_direction.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! 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 deleted file mode 100644 index cfff1c05..00000000 --- a/crates/cam/tests/nc_incremental.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! 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 b44e7aae..4fac21b0 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -9,7 +9,6 @@ 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 a0b0d246..00523f8e 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2225,21 +2225,12 @@ 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 @@ -2462,7 +2453,6 @@ 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 deleted file mode 100644 index 573e515c..00000000 --- a/crates/doc/src/format/pending/manufacturing.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! 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 380d7cb7..89d54bfd 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,7 +9,6 @@ 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 deleted file mode 100644 index ef9344d9..00000000 --- a/crates/engine/src/cmd/cam.rs +++ /dev/null @@ -1,603 +0,0 @@ -//! 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 deleted file mode 100644 index f301cd65..00000000 --- a/crates/engine/src/cmd/cam_holes_tests.rs +++ /dev/null @@ -1,201 +0,0 @@ -#![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 f21270fa..e7380d9d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,7 +21,6 @@ macro_rules! planned { } mod browser; -mod cam; mod clipboard; mod compare; mod component; @@ -253,7 +252,6 @@ 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 19700f0c..6b60a557 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,8 +14,6 @@ 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; @@ -254,8 +252,6 @@ 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 @@ -381,7 +377,6 @@ impl Session { camera: None, render_viewport: None, render_jobs: Default::default(), - cam_jobs: Default::default(), #[cfg(test)] evaluations: 0, crash_journal: false, @@ -849,7 +844,6 @@ 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 deleted file mode 100644 index 65c367aa..00000000 --- a/crates/engine/src/manufacturing.rs +++ /dev/null @@ -1,461 +0,0 @@ -//! 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 deleted file mode 100644 index b1c69052..00000000 --- a/crates/engine/src/manufacturing/exports.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! 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 deleted file mode 100644 index b1e24caf..00000000 --- a/crates/engine/src/manufacturing/holes.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! 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 deleted file mode 100644 index 0218d856..00000000 --- a/crates/engine/src/manufacturing/snapshot.rs +++ /dev/null @@ -1,422 +0,0 @@ -//! 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 b6dd52fc..7afd3eeb 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -9,7 +9,6 @@ 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 deleted file mode 100644 index 4f42cf87..00000000 --- a/crates/io/src/cam.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! 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 deleted file mode 100644 index 7401e8ce..00000000 --- a/crates/io/src/cam/incremental.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! 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 = "ø