Add three-axis milling with independent CAM and Fusion validation (#1682)

Thanks @CypherNaught-0x.
This commit is contained in:
CypherNaugh_0x
2026-10-16 14:26:28 +02:00
committed by GitHub
parent dd5278a402
commit 737c9358d0
85 changed files with 14487 additions and 43 deletions
+11
View File
@@ -119,6 +119,8 @@ jobs:
run: cargo xtask assets
- name: Licences
run: cargo xtask licences --check
- name: Analytic CAM acceptance
run: cargo xtask cam-validate
- name: Dependency layers
run: cargo xtask layers
- name: Web app offline use and updates (headless Chrome)
@@ -210,6 +212,7 @@ jobs:
# would skip for want of an adapter fails instead.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
CAM_UI_EVIDENCE: ${{ github.workspace }}/target/cam-ui-evidence
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install a software Vulkan driver (lavapipe)
@@ -248,6 +251,14 @@ jobs:
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter \
-E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))"
- name: Upload original CAM review images
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: cam-ui-evidence-${{ matrix.shard }}
path: target/cam-ui-evidence
if-no-files-found: ignore
retention-days: 14
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+1 -1
View File
@@ -176,7 +176,7 @@ copied into SolidCraft.
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: fixed-tolerance CAM verification path and conservative boundary index; bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
Generated
+190 -2
View File
@@ -197,6 +197,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
@@ -752,6 +761,18 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cavalier_contours"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85e06038a969c5e4d93565f12b044814d3b1a48d50c4ef868d588e79956fea51"
dependencies = [
"ahash",
"num-traits",
"smallvec",
"static_aabb2d_index",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -800,7 +821,7 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317"
dependencies = [
"approx",
"approx 0.4.0",
"num-traits",
"serde",
]
@@ -1166,6 +1187,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "downcast-rs"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1327,6 +1354,15 @@ dependencies = [
"winreg",
]
[[package]]
name = "ena"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
dependencies = [
"log",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -1690,6 +1726,28 @@ dependencies = [
"xml-rs",
]
[[package]]
name = "glam"
version = "0.33.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37bfe73dc8ec21f54d181e5a60554327ec3f1c600c455a75ccbfc65d2106a6c4"
dependencies = [
"approx 0.5.1",
"libm",
]
[[package]]
name = "glamx"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d74079318e3a077bd3d15faa90fde24fa37bb9f157f92f853e6019a15fd413"
dependencies = [
"approx 0.5.1",
"glam",
"num-traits",
"simba",
]
[[package]]
name = "glifo"
version = "0.2.0"
@@ -1867,6 +1925,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1887,6 +1954,16 @@ dependencies = [
"foldhash",
]
[[package]]
name = "heapless"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2107,6 +2184,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@@ -2574,6 +2653,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "num-integer"
version = "0.1.47"
@@ -3026,6 +3116,34 @@ dependencies = [
"windows-link",
]
[[package]]
name = "parry3d-f64"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb9e04261f1783d8f66f36767b2b73132a63c9b30b12a5ed47e1faa240f7591"
dependencies = [
"approx 0.5.1",
"arrayvec",
"bitflags 2.13.2",
"downcast-rs 2.0.2",
"either",
"ena",
"foldhash",
"glamx",
"hashbrown 0.17.1",
"indexmap",
"log",
"num-derive",
"num-traits",
"ordered-float 5.5.0",
"rstar",
"simba",
"slab",
"smallvec",
"spade",
"thiserror 2.0.21",
]
[[package]]
name = "peniko"
version = "0.6.1"
@@ -3558,6 +3676,17 @@ dependencies = [
"memchr",
]
[[package]]
name = "rstar"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe"
dependencies = [
"heapless",
"num-traits",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3685,6 +3814,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "safe_arch"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f"
dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3915,6 +4053,19 @@ dependencies = [
"libc",
]
[[package]]
name = "simba"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7"
dependencies = [
"approx 0.5.1",
"libm",
"num-complex",
"num-traits",
"wide",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -4078,6 +4229,20 @@ dependencies = [
"winit",
]
[[package]]
name = "solidcraft-cam"
version = "0.14.0"
dependencies = [
"cavalier_contours",
"parry3d-f64",
"robust",
"serde",
"serde_json",
"sha2 0.10.9",
"solidcraft-geom",
"thiserror 2.0.21",
]
[[package]]
name = "solidcraft-cli"
version = "0.14.0"
@@ -4106,6 +4271,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"solidcraft-cam",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4122,6 +4288,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"solidcraft-cam",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
@@ -4169,6 +4336,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"solidcraft-cam",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4293,6 +4461,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_aabb2d_index"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a753368e4dd146a347d0ea692f0f8a37df18df7eee5659d36dd75eb287c6454"
dependencies = [
"num-traits",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -4948,7 +5125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"downcast-rs 1.2.1",
"rustix 1.1.5",
"scoped-tls",
"smallvec",
@@ -5310,6 +5487,16 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wide"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -5755,6 +5942,7 @@ version = "0.14.0"
dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcraft-cam",
]
[[package]]
+1
View File
@@ -14,6 +14,7 @@ repository = "https://github.com/bherbruck/solidcraft"
[workspace.dependencies]
# internal
solidcraft-cam = { path = "crates/cam" }
solidcraft-geom = { path = "crates/geom" }
solidcraft-sketch = { path = "crates/sketch" }
solidcraft-kernel = { path = "crates/kernel" }
+15 -1
View File
@@ -39,7 +39,21 @@ In scope, in priority order:
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
PCB / Package, Render, Animation, Simulation, Drawings.
## Milling
The first Manufacture workflow implements rectangular stock, setups and fixtures,
flat mills and drills, facing, contouring, pockets with islands and simple/peck drilling.
It includes generation, playback, conservative stock/collision verification and five
exports through shared commands. The reference post is not qualified for a physical
controller. See [implementation and validation](docs/cam.md),
[acceptance evidence](docs/cam-acceptance.md) and [dependency assessment](docs/cam-crates.md).
[Adaptive clearing and 3D roughing/finishing](docs/cam-next-milestone.md) are the next milestone. They require their
own analytic fixtures, independent engagement/surface checks and complete local Fusion
comparisons at the existing dimensional budgets before parity is declared. Indexed and
multi-axis machining follow later; turning, additive and inspection remain excluded.
## Milestones
+13
View File
@@ -142,6 +142,19 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
let a = pen.accent;
match name {
"cam_path" => {
pen.closed(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], ink);
pen.line(&[(5.0, 7.0), (18.0, 7.0), (18.0, 11.0), (6.0, 11.0), (6.0, 15.0), (18.0, 15.0)], a);
}
"cam_generate" => {
pen.line(&[(3.0, 5.0), (10.0, 5.0), (10.0, 11.0), (3.0, 11.0), (3.0, 18.0), (10.0, 18.0)], ink);
pen.poly(&[(14.0, 5.0), (22.0, 12.0), (14.0, 19.0)], a, a);
}
"cam_verify" => {
pen.iso_box(2.0, 9.0, 9.0, 6.0, 7.0);
pen.line(&[(12.0, 16.0), (15.0, 19.0), (22.0, 9.0)], a);
}
"sketch" => {
pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink);
pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a);
+20
View File
@@ -305,6 +305,22 @@
"cmd.browser_rename_group": "Rename Group",
"cmd.browser_ungroup": "Ungroup",
"cmd.browser_visibility": "Show/Hide",
"cmd.cam_boundary_from_profile": "Use Sketch Profile",
"cmd.cam_delete": "Delete Manufacturing Item",
"cmd.cam_export": "Export CAM Artifact",
"cmd.cam_export_result": "Collect CAM Export",
"cmd.cam_generate": "Generate Toolpaths",
"cmd.cam_holes_from_selection": "Use Selected Holes",
"cmd.cam_inspect": "Inspect Manufacturing",
"cmd.cam_job_cancel": "Cancel CAM Job",
"cmd.cam_job_status": "CAM Job Status",
"cmd.cam_operation_order": "Reorder Operations",
"cmd.cam_operation_put": "Milling Operation",
"cmd.cam_post": "Post Reference G-code",
"cmd.cam_program_put": "NC Program",
"cmd.cam_setup_put": "Milling Setup",
"cmd.cam_simulate": "Simulate Toolpaths",
"cmd.cam_tool_put": "Tool Library",
"cmd.canvas_calibrate": "Calibrate Canvas",
"cmd.canvas_decal": "Decal",
"cmd.canvas_delete": "Delete Canvas",
@@ -1113,6 +1129,7 @@
"ui.heading_design": "DESIGN",
"ui.heading_draft": "DRAFT",
"ui.heading_edit": "EDIT",
"ui.heading_export": "EXPORT",
"ui.heading_extrude": "EXTRUDE",
"ui.heading_fillet": "FILLET",
"ui.heading_finish_sketch": "FINISH SKETCH",
@@ -1125,6 +1142,7 @@
"ui.heading_make": "MAKE",
"ui.heading_measure": "MEASURE",
"ui.heading_mesh": "MESH",
"ui.heading_milling": "MILLING",
"ui.heading_mirror": "MIRROR",
"ui.heading_modify": "MODIFY",
"ui.heading_move": "MOVE",
@@ -1159,10 +1177,12 @@
"ui.heading_surface": "SURFACE",
"ui.heading_sweep": "SWEEP",
"ui.heading_thread": "THREAD",
"ui.heading_toolpaths": "TOOLPATHS",
"ui.heading_top": "TOP",
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "UTILITIES",
"ui.heading_utility": "UTILITY",
"ui.heading_verify": "VERIFY",
"ui.help": "Help",
"ui.hide": "Hide",
"ui.hideAxes": "Hide Axes",
+20
View File
@@ -305,6 +305,22 @@
"cmd.browser_rename_group": "グループの名前を変更",
"cmd.browser_ungroup": "グループ解除",
"cmd.browser_visibility": "表示/非表示",
"cmd.cam_boundary_from_profile": "スケッチプロファイルを使用",
"cmd.cam_delete": "加工項目を削除",
"cmd.cam_export": "CAM 成果物をエクスポート",
"cmd.cam_export_result": "CAM 出力を取得",
"cmd.cam_generate": "工具経路を生成",
"cmd.cam_holes_from_selection": "選択した穴を使用",
"cmd.cam_inspect": "加工を確認",
"cmd.cam_job_cancel": "CAM ジョブをキャンセル",
"cmd.cam_job_status": "CAM ジョブの状態",
"cmd.cam_operation_order": "加工順序を変更",
"cmd.cam_operation_put": "切削加工",
"cmd.cam_post": "参照 G コードを出力",
"cmd.cam_program_put": "NC プログラム",
"cmd.cam_setup_put": "切削セットアップ",
"cmd.cam_simulate": "工具経路をシミュレーション",
"cmd.cam_tool_put": "工具ライブラリ",
"cmd.canvas_calibrate": "キャンバスの尺度を設定",
"cmd.canvas_decal": "デカール",
"cmd.canvas_delete": "キャンバスを削除",
@@ -1113,6 +1129,7 @@
"ui.heading_design": "デザイン",
"ui.heading_draft": "勾配",
"ui.heading_edit": "編集",
"ui.heading_export": "出力",
"ui.heading_extrude": "押し出し",
"ui.heading_fillet": "フィレット",
"ui.heading_finish_sketch": "スケッチを終了",
@@ -1125,6 +1142,7 @@
"ui.heading_make": "製造",
"ui.heading_measure": "計測",
"ui.heading_mesh": "メッシュ",
"ui.heading_milling": "切削",
"ui.heading_mirror": "ミラー",
"ui.heading_modify": "修正",
"ui.heading_move": "移動",
@@ -1159,10 +1177,12 @@
"ui.heading_surface": "サーフェス",
"ui.heading_sweep": "スイープ",
"ui.heading_thread": "ねじ",
"ui.heading_toolpaths": "工具経路",
"ui.heading_top": "上面",
"ui.heading_torus": "トーラス",
"ui.heading_utilities": "ユーティリティ",
"ui.heading_utility": "ユーティリティ",
"ui.heading_verify": "検証",
"ui.help": "ヘルプ",
"ui.hide": "非表示",
"ui.hideAxes": "軸を非表示",
+20
View File
@@ -305,6 +305,22 @@
"cmd.browser_rename_group": "重命名组",
"cmd.browser_ungroup": "取消编组",
"cmd.browser_visibility": "显示/隐藏",
"cmd.cam_boundary_from_profile": "使用草图轮廓",
"cmd.cam_delete": "删除制造项目",
"cmd.cam_export": "导出 CAM 工件",
"cmd.cam_export_result": "获取 CAM 导出",
"cmd.cam_generate": "生成刀具路径",
"cmd.cam_holes_from_selection": "使用选定孔",
"cmd.cam_inspect": "检查制造",
"cmd.cam_job_cancel": "取消 CAM 作业",
"cmd.cam_job_status": "CAM 作业状态",
"cmd.cam_operation_order": "重新排序操作",
"cmd.cam_operation_put": "铣削操作",
"cmd.cam_post": "输出参考 G 代码",
"cmd.cam_program_put": "NC 程序",
"cmd.cam_setup_put": "铣削设置",
"cmd.cam_simulate": "模拟刀具路径",
"cmd.cam_tool_put": "刀具库",
"cmd.canvas_calibrate": "校准画布",
"cmd.canvas_decal": "贴花",
"cmd.canvas_delete": "删除画布",
@@ -1113,6 +1129,7 @@
"ui.heading_design": "设计",
"ui.heading_draft": "拔模",
"ui.heading_edit": "编辑",
"ui.heading_export": "导出",
"ui.heading_extrude": "拉伸",
"ui.heading_fillet": "圆角",
"ui.heading_finish_sketch": "完成草图",
@@ -1125,6 +1142,7 @@
"ui.heading_make": "制作",
"ui.heading_measure": "测量",
"ui.heading_mesh": "网格",
"ui.heading_milling": "铣削",
"ui.heading_mirror": "镜像",
"ui.heading_modify": "修改",
"ui.heading_move": "移动",
@@ -1159,10 +1177,12 @@
"ui.heading_surface": "曲面",
"ui.heading_sweep": "扫掠",
"ui.heading_thread": "螺纹",
"ui.heading_toolpaths": "刀具路径",
"ui.heading_top": "上视",
"ui.heading_torus": "圆环体",
"ui.heading_utilities": "工具",
"ui.heading_utility": "实用工具",
"ui.heading_verify": "验证",
"ui.help": "帮助",
"ui.hide": "隐藏",
"ui.hideAxes": "隐藏轴",
+22
View File
@@ -46,6 +46,11 @@ impl Tokens {
preview_add: Color32::from_rgb(112, 160, 226),
preview_sketch: Color32::from_rgb(32, 190, 96),
preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110),
cam_stock: Color32::from_rgb(166, 127, 65),
cam_fixture: Color32::from_rgb(150, 90, 180),
cam_cut: Color32::from_rgb(20, 110, 160),
cam_rapid: Color32::from_rgb(185, 115, 25),
cam_tool: Color32::from_rgb(40, 150, 70),
cube_face: Color32::from_rgb(236, 239, 244),
cube_border: Color32::from_rgb(85, 94, 110),
cube_text: Color32::from_rgb(38, 42, 50),
@@ -141,6 +146,11 @@ impl Tokens {
t.hover = Color32::from_rgb(54, 61, 74);
t.hover_edge_halo = Color32::from_rgb(203, 203, 203);
t.origin_point = Color32::from_rgb(214, 215, 218);
t.cam_stock = Color32::from_rgb(190, 154, 92);
t.cam_fixture = Color32::from_rgb(177, 119, 209);
t.cam_cut = Color32::from_rgb(50, 180, 240);
t.cam_rapid = Color32::from_rgb(230, 170, 65);
t.cam_tool = Color32::from_rgb(135, 220, 145);
t.viewport_top = Color32::from_rgb(46, 52, 64);
t.viewport_bottom = Color32::from_rgb(46, 52, 64);
t.grid = Color32::from_rgb(59, 65, 76);
@@ -209,6 +219,12 @@ impl Tokens {
t.bend_down = Color32::from_rgb(200, 120, 0);
t.sketch_accent = Color32::from_rgb(60, 150, 105);
}
// Use the measured five-colour semantic set for stock, fixtures and motions.
t.cam_stock = t.sketch_fixed;
t.cam_fixture = t.sketch_projected;
t.cam_cut = t.sketch;
t.cam_rapid = t.sketch_lost;
t.cam_tool = t.sketch_locked;
t
}
@@ -225,6 +241,12 @@ impl Tokens {
t.sketch_locked = Color32::from_rgb(20, 165, 70);
t.box_crossing = Color32::from_rgb(200, 60, 120);
}
// Use the measured five-colour semantic set for stock, fixtures and motions.
t.cam_stock = t.sketch_fixed;
t.cam_fixture = t.sketch_projected;
t.cam_cut = t.sketch;
t.cam_rapid = t.sketch_lost;
t.cam_tool = t.sketch_locked;
t
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solidcraft-cam"
description = "Portable milling toolpaths and independent NC verification"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solidcraft-geom.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
sha2.workspace = true
cavalier_contours = "=0.9.0"
parry3d-f64 = { version = "=0.31.1", default-features = false, features = ["required-features", "std", "alloc", "enhanced-determinism"] }
robust = "=1.2.0"
+483
View File
@@ -0,0 +1,483 @@
//! Independent fixture collision adapter. No toolpath generation code is used.
//! Surface casts and solid containment are both needed: a surface-only query can
//! miss a tool that starts wholly inside a closed fixture.
use crate::{
JobInput, POST_ERROR, Result, Tool, ToolKind, invalid,
nc::Segment,
verify::{Finding, Limits, Report, Status},
};
use parry3d_f64::{
math::{Pose, Vector},
query::{ShapeCastOptions, ShapeCastStatus, cast_shapes, distance},
shape::{Cone, Cylinder, Shape, Triangle},
};
use solidcraft_geom::Vec3;
use std::collections::BTreeMap;
#[derive(Clone, Copy)]
struct Bounds {
min: Vec3,
max: Vec3,
}
impl Bounds {
fn point(p: Vec3) -> Self {
Self { min: p, max: p }
}
fn include(&mut self, p: Vec3) {
self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z));
self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z));
}
fn overlaps(self, b: Self) -> bool {
self.min.x <= b.max.x
&& self.max.x >= b.min.x
&& self.min.y <= b.max.y
&& self.max.y >= b.min.y
&& self.min.z <= b.max.z
&& self.max.z >= b.min.z
}
}
struct Component {
body: usize,
triangles: Vec<usize>,
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<Part> {
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<F: std::future::Future<Output = ()>>(
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<usize> = 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<usize, Component> = 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<usize, f64> = 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<usize>, 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());
}
}
+489
View File
@@ -0,0 +1,489 @@
//! Conservative artifact comparison, separate from toolpath generation.
use crate::spatial::Index;
use crate::{
verify::{Report, Status},
*,
};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
#[derive(Debug, Serialize, Deserialize)]
pub struct Comparison {
pub status: Status,
pub maximum_possible_error: f64,
pub maximum_definite_error: f64,
pub checked_regions: usize,
pub detail: String,
}
fn gaps(a: [f64; 2], b: [f64; 2]) -> (f64, f64) {
((a[0] - b[1]).max(b[0] - a[1]).max(0.0), (a[0] - b[1]).abs().max((a[1] - b[0]).abs()))
}
fn result(detail: &str) -> Comparison {
Comparison { status: Status::Pass, maximum_possible_error: 0., maximum_definite_error: 0., checked_regions: 0, detail: detail.into() }
}
fn inconclusive(detail: &str) -> Comparison {
Comparison { status: Status::Inconclusive, ..result(detail) }
}
fn tolerance(value: f64) -> Result<()> {
if !value.is_finite() || value <= 0. || value > 1e6 {
return invalid("comparison tolerance must be positive and bounded");
}
Ok(())
}
/// Compare both remaining material and removed space, in both directions.
/// Each passing region is a whole-column distance certificate, not a sample.
pub fn stock_reports(a: &Report, b: &Report, tol: f64) -> Result<Comparison> {
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<Comparison> {
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<Comparison> {
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<Comparison> {
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<Vec<[Vec3; 3]>> {
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::<Vec<_>>()).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::<f64>().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::<f64>().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<verify::Cell>) -> 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());
}
}
+388
View File
@@ -0,0 +1,388 @@
//! Original analytic fixtures; never generated by a proprietary CAD application.
use crate::*;
use solidcraft_geom::Vec3;
mod channel;
mod extended;
mod exterior;
mod through;
pub const CASES: &[&str] =
&["face", "contour", "pocket", "drill", "concave-islands", "multi-operation", "clamped-through-holes", "external-contour", "narrow-channel"];
/// Independent point expectations, indexed by the completed operation count.
pub fn probes(name: &str, completed: usize) -> Result<Vec<([f64; 2], f64)>> {
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<JobInput> {
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<nc::Segment>| s.iter().map(|x| x.from.dist(x.to)).sum::<f64>();
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);
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Original narrow-channel recipes with independently triangulated protected stock.
use crate::*;
fn capsule() -> Boundary {
let mut outer = Vec::new();
for (x, start) in [(5., 90_f64), (15., 270_f64)] {
for i in 0..=64 {
let a = (start + 180. * i as f64 / 64.).to_radians();
outer.push([x + 1.1 * a.cos(), 10. + 1.1 * a.sin()]);
}
}
Boundary { outer, islands: vec![] }
}
fn with_boundary(boundary: Boundary) -> Result<JobInput> {
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<JobInput> {
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()));
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Original dimensioned recipes. Analytic surfaces are triangulated independently
//! of the CAM offset/generation algorithms; no proprietary artifacts are used.
use crate::*;
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2, Vec3};
fn circle(center: [f64; 2], radius: f64) -> Vec<[f64; 2]> {
(0..96)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 96.;
[center[0] + radius * a.cos(), center[1] + radius * a.sin()]
})
.collect()
}
// An L with R2 convex corners and an R2 reentrant corner. The sampled polygon
// itself is the recipe, so there is no unstated curve approximation in its input.
fn boundary() -> Boundary {
let mut outer = Vec::new();
for (center, start, sweep) in
[([4., 4.], 180., 90.), ([16., 4.], 270., 90.), ([16., 8.], 0., 90.), ([12., 12.], 270., -90.), ([8., 16.], 0., 90.), ([4., 16.], 90., 90.)]
{
for i in 0..=32 {
let a = (start + sweep * i as f64 / 32.) * std::f64::consts::PI / 180.;
outer.push([center[0] + 2. * a.cos(), center[1] + 2. * a.sin()]);
}
}
Boundary { outer, islands: vec![circle([6., 6.], 0.8), circle([6., 13.], 0.8)] }
}
fn ring(points: &[[f64; 2]]) -> Loop2 {
Loop2 {
segs: points
.iter()
.zip(points.iter().cycle().skip(1))
.take(points.len())
.map(|(a, b)| Seg2::Line { a: Vec2::new(a[0], a[1]), b: Vec2::new(b[0], b[1]) })
.collect(),
}
}
pub(super) fn flat(out: &mut Vec<[Vec3; 3]>, outer: &[[f64; 2]], holes: &[Vec<[f64; 2]>], z: f64) {
let region = Region2 { outer: ring(outer), holes: holes.iter().map(|h| ring(h)).collect() };
for t in region.triangulate(1e-6) {
if (t[1] - t[0]).cross(t[2] - t[0]).abs() > 1e-14 {
out.push(t.map(|p| Vec3::new(p.x, p.y, z)));
}
}
}
pub(super) fn wall(out: &mut Vec<[Vec3; 3]>, points: &[[f64; 2]], top: f64, bottom: f64) {
for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
let (a, b) = (Vec3::new(a[0], a[1], top), Vec3::new(b[0], b[1], top));
let (c, d) = (Vec3::new(a.x, a.y, bottom), Vec3::new(b.x, b.y, bottom));
out.extend([[a, b, d], [a, d, c]]);
}
}
fn target(boundary: &Boundary, top: f64, drilled: bool) -> Vec<[Vec3; 3]> {
let mut out = Vec::new();
flat(&mut out, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), top);
let mut holes = boundary.islands.clone();
let drill = circle([15., 6.], 0.5);
if drilled {
holes.push(drill.clone());
}
flat(&mut out, &boundary.outer, &holes, 4.);
wall(&mut out, &boundary.outer, top, 4.);
for island in &boundary.islands {
flat(&mut out, island, &[], top);
wall(&mut out, island, top, 4.);
}
if drilled {
let shoulder = 3. + 0.5 / 59_f64.to_radians().tan();
wall(&mut out, &drill, 4., shoulder);
for (a, b) in drill.iter().zip(drill.iter().cycle().skip(1)).take(drill.len()) {
out.push([Vec3::new(15., 6., 3.), Vec3::new(a[0], a[1], shoulder), Vec3::new(b[0], b[1], shoulder)]);
}
}
out
}
pub(super) fn fixture(name: &str) -> Result<JobInput> {
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::<Vec<_>>()
);
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::<Vec<_>>()
);
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());
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Outside contour of a square boss, with allowance roughing then finishing.
use crate::*;
pub(super) fn fixture() -> Result<JobInput> {
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);
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Two through holes in a plate with a raised clamp between their axes.
//! Geometry and analytic expectations are independent of toolpath generation.
use crate::*;
use solidcraft_geom::Vec3;
pub(super) fn fixture() -> Result<JobInput> {
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<Vec<[f64; 2]>> = [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());
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Coverage certificates use boundary incidence, never summed overlapping area.
use crate::{spatial::Index, verify::Cell, *};
use solidcraft_geom::Vec3;
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq)]
struct Coord(f64);
impl Eq for Coord {}
impl PartialOrd for Coord {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
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<Item = f64>) -> 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<bool> {
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::<Coord, Coord>::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<Self> {
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)])
}
}
+554
View File
@@ -0,0 +1,554 @@
//! Independent analytic finished-height envelopes. No path-generation or offset
//! code is used here. Radial bands limit diameter error; depth is checked in Z.
use crate::{verify::Status, *};
#[derive(Clone, Copy, Debug)]
pub(crate) struct Envelope {
lower_min: f64,
lower_max: f64,
upper_min: f64,
upper_max: f64,
}
impl Envelope {
fn range(lo: f64, hi: f64, tolerance: f64) -> Self {
Self { lower_min: lo - tolerance, lower_max: hi - tolerance, upper_min: lo + tolerance, upper_max: hi + tolerance }
}
fn alternatives(self, b: Self) -> Self {
Self {
lower_min: self.lower_min.min(b.lower_min),
lower_max: self.lower_max.max(b.lower_max),
upper_min: self.upper_min.min(b.upper_min),
upper_max: self.upper_max.max(b.upper_max),
}
}
fn cut(self, b: Self) -> Self {
Self {
lower_min: self.lower_min.min(b.lower_min),
lower_max: self.lower_max.min(b.lower_max),
upper_min: self.upper_min.min(b.upper_min),
upper_max: self.upper_max.min(b.upper_max),
}
}
pub(crate) fn classify(self, low: f64, high: f64) -> Status {
if high < self.lower_min || low > self.upper_max {
Status::Fail
} else if low >= self.lower_max && high <= self.upper_min {
Status::Pass
} else {
Status::Inconclusive
}
}
pub(crate) fn description(self) -> String {
format!(
"Finished-region height must lie within [{:.6}, {:.6}] mm; cell-wide certified range [{:.6}, {:.6}] mm",
self.lower_min, self.upper_max, self.lower_max, self.upper_min
)
}
}
enum Feature {
Plane(f64),
Hole { center: [f64; 2], radius: f64, tip: f64, slope: f64 },
Mill { region: crate::region::Region, outside: bool, width: Option<f64>, allowance: f64, depth: f64 },
}
pub(crate) struct Finished {
features: Vec<Feature>,
bottom: f64,
top: f64,
tolerance: f64,
radial_tolerance: f64,
pub(crate) refinement: f64,
}
impl Finished {
pub(crate) fn new(input: &JobInput, tolerance: f64) -> Result<Option<Self>> {
// 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<F: std::future::Future<Output = ()>>(
&self,
region: ([f64; 2], f64),
work: &mut usize,
budget: usize,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Option<Envelope>> {
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<Envelope> = 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<Envelope> = 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<Envelope> {
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::<Vec<_>>()
);
assert_eq!(
report.finished_dimensions,
Status::Pass,
"{name} cells {} work {} {:?}",
report.cells.len(),
report.work,
report.findings.iter().take(4).collect::<Vec<_>>()
);
}
}
#[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::<Vec<_>>()
);
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::<Vec<_>>()
);
}
#[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);
}
}
+680
View File
@@ -0,0 +1,680 @@
//! Toolpath construction. No verifier or NC interpreter algorithms live here.
use crate::*;
use cavalier_contours::polyline::{PlineSource, PlineSourceMut, Polyline, seg_arc_radius_and_center, seg_length};
use cavalier_contours::shape_algorithms::{Shape, ShapeOffsetOptions};
use solidcraft_geom::Vec3;
use std::{
future::Future,
pin::Pin,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
task::{Context, Poll, Waker},
};
/// A portable job advances a bounded emission/validation chunk or one bounded
/// library offset query per poll. No executor, OS thread or browser API is used.
pub struct Generator {
pending: Option<Pin<Box<dyn Future<Output = Result<Toolpath>> + Send>>>,
path: Option<Toolpath>,
progress: Arc<AtomicUsize>,
failed: Option<String>,
cancelled: bool,
}
impl Generator {
pub fn new(input: JobInput) -> Result<Self> {
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<bool> {
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<Toolpath> {
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<AtomicUsize>,
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<AtomicUsize>) -> Result<Toolpath> {
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<Toolpath> {
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<Block>,
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<Vec<Block>> {
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<Vec<f64>> {
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<f64>, 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::<f64>();
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<f64>,
scale: f64,
}
struct OffsetLoops {
ccw_plines: Vec<Polyline<f64>>,
cw_plines: Vec<Polyline<f64>>,
}
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<f64>| {
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<OffsetShape> {
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<OffsetShape> {
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());
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Incremental encoding of the existing snapshot fingerprint, without a full JSON buffer.
use crate::{Error, JobInput, Result, VERSION, invalid};
use serde::Serialize;
use sha2::{Digest, Sha256};
struct Encoder(Sha256);
impl Encoder {
fn bytes(&mut self, bytes: &[u8]) {
self.0.update(bytes);
}
fn value(&mut self, value: &impl Serialize) -> Result<()> {
let bytes = serde_json::to_vec(value).map_err(|e| Error(e.to_string()))?;
self.bytes(&bytes);
Ok(())
}
async fn array<T: Serialize, F: std::future::Future<Output = ()>>(
&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<F: std::future::Future<Output = ()>>(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<F: std::future::Future<Output = ()>>(
input: &JobInput,
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<String> {
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<T>(future: impl Future<Output = T>) -> 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);
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Milling definitions, deterministic toolpaths and independent NC verification.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod collision;
pub mod compare;
pub mod corpus;
mod coverage;
mod dimensions;
mod hashing;
pub use hashing::fingerprint_job_cooperative;
pub mod generate;
mod model;
pub mod nc;
pub mod playback;
mod region;
mod spatial;
mod stock_mesh;
mod surface;
pub mod verify;
pub use model::*;
#[derive(Debug, thiserror::Error)]
#[error("CAM: {0}")]
pub struct Error(pub String);
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn invalid<T>(message: impl Into<String>) -> Result<T> {
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<T: serde::Serialize>(value: &T) -> Result<String> {
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)))
}
+523
View File
@@ -0,0 +1,523 @@
use crate::{MAX_ITEMS, Result, TOLERANCE, invalid};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manufacturing {
#[serde(default)]
pub setups: Vec<Setup>,
#[serde(default)]
pub tools: Vec<Tool>,
#[serde(default)]
pub operations: Vec<Operation>,
#[serde(default)]
pub programs: Vec<Program>,
}
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::<Vec<_>>(),
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::<std::collections::BTreeSet<_>>().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::<std::collections::BTreeSet<_>>().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<u64>,
}
#[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<BodyRef>,
#[serde(default)]
pub fixtures: Vec<BodyRef>,
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<Vec<[f64; 2]>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileRef {
pub sketch: u64,
pub curves: Vec<String>,
#[serde(default)]
pub occurrence: Vec<u64>,
}
/// 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<f64>,
#[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<ProfileRef>,
/// When nonempty, replaces every preview XY center during snapshot preparation.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hole_sources: Vec<HoleSource>,
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<f64>,
#[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<u64>,
}
#[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<usize>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JobInput {
pub setup: Setup,
pub tools: Vec<Tool>,
pub operations: Vec<Operation>,
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::<std::collections::BTreeSet<_>>().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<Block>,
}
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(()) }
}
+712
View File
@@ -0,0 +1,712 @@
//! Strict reference-dialect post and independent modal NC reader.
//! The reader has no dependency on the toolpath generator.
use crate::*;
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use std::{
future::Future,
task::{Context, Poll, Waker},
};
pub const DIALECT: &str = "solidcraft-reference-3axis/2; not a qualified machine post";
// A maximum-size path also emits the three explicit startup events below.
const MAX_RECORDS: usize = MAX_MOVES + 3;
fn complete<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
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<F: Future<Output = ()>>(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<String> {
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<F: Future<Output = ()>>(
path: &Toolpath,
setup: &Setup,
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<String> {
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<Record>,
pub work_offset: u8,
// Never accept provenance from comments or deserialized external programs.
#[serde(skip)]
sources: Vec<SourceLocation>,
}
impl Parsed {
pub fn source_for_line(&self, line: usize) -> Option<SourceLocation> {
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<usize> {
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<Parsed> {
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<F: Future<Output = ()>>(
source: &str,
tools: &[Tool],
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<Parsed> {
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<Vec<(char, f64)>> {
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::<f64>().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<Parsed> {
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<F: Future<Output = ()>>(
path: &Toolpath,
setup: &Setup,
tools: &[Tool],
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<Parsed> {
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<Vec<Segment>> {
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);
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Machine state at a typed toolpath boundary, shared by inspection and playback.
use crate::*;
use solidcraft_geom::Vec3;
#[derive(Clone, Debug, serde::Serialize)]
pub struct State {
pub completed: usize,
pub position: Vec3,
pub operation: Option<u64>,
pub tool: Option<u64>,
pub spindle: f64,
pub coolant: bool,
pub feed: Option<f64>,
}
pub fn at(path: &Toolpath, setup: &Setup, completed: usize) -> Result<State> {
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());
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Independent planar-region predicates for finished dimensions. No offsets or
//! toolpath-generation routines are used. Winding ignores input ring direction.
use crate::{spatial::Index, *};
fn orient(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> f64 {
robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: p[0], y: p[1] })
}
fn contains(ring: &[[f64; 2]], p: [f64; 2]) -> bool {
ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).fold(false, |inside, (a, b)| inside ^ crossing(*a, *b, p))
}
fn crossing(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> bool {
(a[1] > p[1]) != (b[1] > p[1]) && if b[1] > a[1] { orient(a, b, p) > 0. } else { orient(a, b, p) < 0. }
}
struct Edge {
a: [f64; 2],
b: [f64; 2],
ring: usize,
vertex: usize,
count: usize,
}
pub(crate) struct Region {
edges: Vec<Edge>,
index: Index,
max_x: f64,
guard: f64,
}
impl Region {
pub(crate) fn new(boundary: &Boundary) -> Result<Self> {
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<F: std::future::Future<Output = ()>>(
&self,
query: ([f64; 2], f64, f64),
work: &mut usize,
budget: usize,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Option<[f64; 2]>> {
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(_))));
}
}
+65
View File
@@ -0,0 +1,65 @@
// Independent median-split XY index: artifact comparison never calls CAM offsets.
pub(crate) struct Index {
min: [f64; 2],
max: [f64; 2],
items: Vec<(usize, [f64; 2], [f64; 2])>,
children: Vec<Index>,
}
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<usize>) {
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());
}
}
}
+728
View File
@@ -0,0 +1,728 @@
//! Conservative boundary extraction from non-overlapping rectangular height cells.
//! Shared planar edges and vertical steps are split before triangulation. No
//! XY snapping, interpolation or generation algorithm is involved. Tiny adaptive
//! saddle contacts may be filled upward within a declared export error budget.
use crate::{
Error, Result, invalid,
verify::{Cell, Report},
};
use solidcraft_geom::Vec3;
use std::{
collections::{BTreeMap, BTreeSet},
future::Future,
};
/// Count elementary topology work, including sorting and manifold validation.
/// A yield callback which is immediately ready retains the synchronous API.
struct Work<C, Y> {
cancelled: C,
yield_control: Y,
remaining: usize,
}
impl<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>> Work<C, Y> {
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<T: Clone>(&mut self, values: &mut Vec<T>, 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<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
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<std::cmp::Ordering> {
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<usize>; 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<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(report: &Report, bottom: f64, work: &mut Work<C, Y>) -> Result<Vec<Cell>> {
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::<Coord, f64>::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<Cell> = 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<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(cells: &[Cell], work: &mut Work<C, Y>) -> Result<Vec<Span>> {
let mut lines: BTreeMap<(usize, Coord), Vec<Event>> = 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<Coord> = 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<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(
cells: &mut [Cell],
spans: &[Span],
bottom: f64,
work: &mut Work<C, Y>,
) -> Result<()> {
// NW, NE, SE, SW occupancy at every boundary endpoint, including T-junctions.
let mut vertices = BTreeMap::<XY, [Option<usize>; 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<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(triangles: &[[Vec3; 3]], work: &mut Work<C, Y>) -> 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::<Point, Vec<(Point, Point)>>::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<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(
&mut self,
boundary: &[Vec3],
reverse: bool,
work: &mut Work<C, Y>,
) -> 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<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(report: &Report, bottom: f64, work: &mut Work<C, Y>) -> Result<Mesh> {
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::<XY, BTreeSet<Coord>>::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<(usize, Coord, Vec3)>> = 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<Vec<[Vec3; 3]>> {
ready(triangles_cooperative(report, bottom, || false, || std::future::ready(())))
}
pub(crate) async fn triangles_cooperative<F: Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<Vec<[Vec3; 3]>> {
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<String> {
ready(stl_cooperative(report, bottom, || false, || std::future::ready(())))
}
pub(crate) async fn stl_cooperative<F: Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<String> {
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<Cell>) -> 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<Cell>, 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);
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Local no-gouge certificates for the vertical height-field target solid.
//!
//! To bound distance to the solid's complement, choose an XY point q with a
//! conservative upper target height H(q). Every point p in a candidate removed
//! column has an exterior point (q, max(p.z,H(q))) at distance no more than
//! hypot(max_xy_distance(p,q), max(H(q)-column_low,0)). This covers whole cells,
//! not sampled surface points, and remains valid for overlapping target bodies.
use crate::{
spatial::Index,
verify::{Limits, Status},
*,
};
use solidcraft_geom::Vec3;
pub(crate) struct Surface<'a> {
triangles: &'a [[Vec3; 3]],
index: Index,
bottom: f64,
guard: f64,
}
pub(crate) struct Region {
pub center: [f64; 2],
pub half: f64,
pub low: f64,
pub high: f64,
pub target_high: f64,
pub tolerance: f64,
}
impl<'a> Surface<'a> {
pub(crate) fn new(triangles: &'a [[Vec3; 3]], bottom: f64) -> Result<Self> {
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<F: std::future::Future<Output = ()>>(
&self,
p: [f64; 2],
work: &mut usize,
limits: Limits,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Option<f64>> {
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<F: std::future::Future<Output = ()>>(
&self,
r: Region,
work: &mut usize,
limits: Limits,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Status> {
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(_))));
}
}
+644
View File
@@ -0,0 +1,644 @@
//! Independent bounded stock verifier. Adaptive cells carry height intervals;
//! unresolved intervals and exhausted work budgets never become a pass.
use crate::{
nc::{Parsed, Segment},
*,
};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Pass,
Fail,
Inconclusive,
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Information,
#[default]
Unresolved,
Error,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Finding {
#[serde(default)]
pub severity: FindingSeverity,
pub kind: String,
pub line: Option<usize>,
/// One-based interpreted NC record, not a tessellated arc segment.
#[serde(default)]
pub record: Option<usize>,
/// Present only for an independently checked SolidCraft post round trip.
#[serde(default)]
pub source: Option<nc::SourceLocation>,
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<Stock>,
pub cells: Vec<Cell>,
pub findings: Vec<Finding>,
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<Report> {
simulate_cancellable(input, nc, limits, || false)
}
pub fn simulate_cancellable(input: &JobInput, nc: &Parsed, limits: Limits, cancelled: impl Fn() -> bool) -> Result<Report> {
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<F: std::future::Future<Output = ()>>(
input: &JobInput,
nc: &Parsed,
limits: Limits,
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<Report> {
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::<f64>().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<String> {
crate::stock_mesh::stl(report, bottom)
}
/// Incremental extraction and ASCII serialization. Cancellation returns no partial artifact.
pub async fn stock_stl_cooperative<F: std::future::Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<String> {
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<Vec<[Vec3; 3]>> {
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<F: std::future::Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<Vec<[Vec3; 3]>> {
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<f64> {
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);
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Hand-authored modal programs exercise NIST machine state independently of generation.
use solidcraft_cam::{Motion, corpus, generate, nc};
const HEADER: &str = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000 M3\nM8\n";
#[test]
fn tool_change_stops_spindle_preserves_coolant_and_requires_restart() {
let mut input = corpus::fixture("face").unwrap();
let mut second = input.tools[0].clone();
second.id = 2;
second.number = 2;
input.tools.push(second);
for number in [1, 2] {
let source = format!("{HEADER}T{number} M6\nS7000\nG1 X1 F100\nM30");
let parsed = nc::parse(&source, &input.tools).unwrap();
let change: Vec<_> = parsed.records.iter().filter(|r| r.line == 6).map(|r| &r.motion).collect();
assert_eq!(change, [&Motion::Spindle { rpm: 0. }, &Motion::Tool { tool: number as u64, number }]);
assert!(parsed.records.iter().all(|r| r.line != 7), "S alone cannot restart after M6");
assert_eq!(parsed.records.last().unwrap().motion, Motion::Coolant { on: false }, "M6 must preserve coolant until M30");
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 8"));
let restarted = source.replace("S7000\n", "M3\n");
let parsed = nc::parse(&restarted, &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 7).unwrap().motion, Motion::Spindle { rpm: 6000. });
let segments = nc::segments(&parsed, &input.setup).unwrap();
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].tool, number as u64);
assert_eq!(segments[0].to.x, 1.);
}
}
#[test]
fn same_block_speed_and_coolant_commands_keep_both_effects() {
let input = corpus::fixture("face").unwrap();
for coolant in ["M8", "M9", "M0"] {
let source = format!("{HEADER}S0 {coolant}\nG1 X1 F100\nM30");
let parsed = nc::parse(&source, &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 7"));
let source = source.replace("S0", "S12000");
let parsed = nc::parse(&source, &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 12000. });
assert_eq!(nc::segments(&parsed, &input.setup).unwrap().len(), 1);
}
let parsed = nc::parse(&format!("{HEADER}S0 G1 X1 F100\nM30"), &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 6"));
}
#[test]
fn reference_post_initializes_state_before_pose_and_keeps_block_provenance() {
let input = corpus::fixture("face").unwrap();
let path = generate::generate(input.clone()).unwrap();
let code = nc::post(&path, &input.setup).unwrap();
let lines: Vec<_> = code.lines().filter(|l| !l.starts_with('(')).collect();
assert_eq!(lines[0], "G21 G90 G17 G91.1 G94 G54 G40 G49 G80");
assert_eq!(&lines[1..3], ["M5", "M9"]);
assert!(lines[3].starts_with("G0 X0.000000 Y0.000000 Z15.000000"));
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
assert_eq!(parsed.records.len(), path.blocks.len() + 3);
for record in &parsed.records[..3] {
assert!(parsed.source_for_line(record.line).is_none());
}
for (i, record) in parsed.records[3..].iter().enumerate() {
let source = parsed.source_for_line(record.line).unwrap();
assert_eq!(source.block, i + 1);
assert_eq!(source.operation, path.blocks[i].operation);
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Independent direction checks for a right-handed cutter with M3 spindle rotation.
//! A climb cut keeps the material being finished to the right of tool travel.
use solidcraft_cam::{Boundary, JobInput, Strategy, corpus, generate, nc};
fn segments(input: &JobInput) -> Vec<nc::Segment> {
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<Vec<nc::Segment>> {
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::<f64>();
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));
}
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Original programs exercise cooperative NC processing and its exact boundary limits.
use solidcraft_cam::{self as cam, Block, Motion, Toolpath, nc};
use solidcraft_geom::Vec3;
use std::{
cell::Cell,
future::Future,
task::{Context, Poll, Waker},
};
fn yield_once() -> impl Future<Output = ()> {
let mut first = true;
std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) })
}
fn drive<T>(future: impl Future<Output = cam::Result<T>>) -> (cam::Result<T>, 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}");
}
}
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
solidcraft-cam = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+10
View File
@@ -2225,12 +2225,21 @@ pub struct DesignLink {
pub feature: u64,
}
fn read_manufacturing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<solidcraft_cam::Manufacturing, D::Error> {
let manufacturing = <solidcraft_cam::Manufacturing as Deserialize>::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<crate::render_settings::RenderSettings>,
/// Manufacturing definitions; generated toolpaths remain session caches.
#[serde(default, deserialize_with = "read_manufacturing", skip_serializing_if = "solidcraft_cam::Manufacturing::is_empty")]
pub manufacturing: solidcraft_cam::Manufacturing,
#[serde(default = "default_format")]
pub format: String,
/// The changes of `format` this design has while they are pending (no release has fixed
@@ -2453,6 +2462,7 @@ impl Document {
pub fn new(name: &str) -> Self {
Document {
render: Default::default(),
manufacturing: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
name: name.into(),
@@ -0,0 +1,4 @@
//! Persistent milling setups, stock, fixtures, local tools, ordered operations, NC programs
//! and durable profile/hole references. Older designs get empty manufacturing; explicit hole
//! coordinates without references stay explicit. Generated paths and validation are not saved.
//! Earlier released readers refuse the new format instead of discarding manufacturing.
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
solidcraft-cam = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+603
View File
@@ -0,0 +1,603 @@
//! Manufacturing commands shared by UI, CLI and MCP.
use super::CommandSpec;
use crate::{EngineError, Result, Session, cam, manufacturing as jobs};
use serde_json::{Value, json};
use std::sync::atomic::Ordering;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("cam.holes.from_selection", "Use Selected Holes", from_holes)
.noundo()
.params("setup: ID, selections: [{kind:face,body,index,occurrence?:[IDs]}|{kind:profile,sketch,index,occurrence?:[IDs]}]; returns ordered holes, diameters and durable sources; face body is a world label unless occurrence is explicit"),
CommandSpec::new("cam.boundary.from_profile", "Use Sketch Profile", from_profile)
.noundo()
.params("setup: ID, sketch: feature ID, profile: index, occurrence?: [IDs]; returns boundary and durable source reference"),
CommandSpec::new("cam.setup.put", "Milling Setup", setup)
.at("MILLING", "SETUP")
.icon("box")
.params("setup: complete Setup object (create or replace by ID)"),
CommandSpec::new("cam.tool.put", "Tool Library", tool).at("MILLING", "SETUP").icon("hole").params("tool: complete Tool object"),
CommandSpec::new("cam.operation.put", "Milling Operation", operation)
.at("MILLING", "TOOLPATHS")
.icon("cam_path")
.params("operation: complete Operation object; strategy face, contour, pocket or drill"),
CommandSpec::new("cam.program.put", "NC Program", program)
.at("MILLING", "SETUP")
.icon("save")
.params("program: {id,name,setup,operations:[ordered IDs]}"),
CommandSpec::new("cam.operation.order", "Reorder Operations", order).params("program: ID, operations: permutation of existing operation IDs"),
CommandSpec::new("cam.delete", "Delete Manufacturing Item", delete)
.params("kind: setup|tool|operation|program, id; referenced items cannot be deleted"),
CommandSpec::new("cam.inspect", "Inspect Manufacturing", inspect)
.noundo()
.params("job?: ID; include_path?: bool; include_stock?: bool (world-space display mesh); motion?: completed block count; finding?: zero-based verification finding index"),
CommandSpec::new("cam.generate", "Generate Toolpaths", generate)
.at("MILLING", "TOOLPATHS")
.icon("cam_generate")
.noundo()
.params("program: ID, background?: bool (default false); returns job ID"),
CommandSpec::new("cam.job.status", "CAM Job Status", status).noundo().params("job: ID"),
CommandSpec::new("cam.job.cancel", "Cancel CAM Job", cancel).noundo().params("job: ID"),
CommandSpec::new("cam.simulate", "Simulate Toolpaths", simulate)
.at("MILLING", "VERIFY")
.icon("cam_verify")
.noundo()
.params("job: ID, background?: bool"),
CommandSpec::new("cam.post", "Post Reference G-code", post)
.at("MILLING", "EXPORT")
.icon("save")
.noundo()
.params("job: ID, path?: string, background?: bool; requires current passing verification; background output is published by cam.export.result"),
CommandSpec::new("cam.export", "Export CAM Artifact", export)
.noundo()
.params("job: ID, format: toolpath|setup_sheet|stock|validation, path?: string, background?: bool; background output is published by cam.export.result"),
CommandSpec::new("cam.export.result", "Collect CAM Export", export_result).noundo().params("job: ID; consumes completed output after checking freshness; writes the requested path or returns content"),
];
fn from_holes(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>(),"diameters":selected.iter().map(|h|h.diameter).collect::<Vec<_>>()}),
)
}
fn from_profile(s: &mut Session, p: &Value) -> Result<Value> {
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<T: serde::de::DeserializeOwned>(p: &Value, key: &str) -> Result<T> {
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<u64> {
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<Value>{
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<Value> {
let program = id(p, "program")?;
let ids: Vec<u64> = 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<Value> {
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<Value> {
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::<Vec<_>>()}));
}
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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::<Value>(&actual).unwrap(), serde_json::from_str::<Value>(&expected).unwrap())
}
"setup_sheet" => assert!(actual.contains("<h2>Operation settings and selected geometry</h2>")),
_ => 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::<Vec<_>>();
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;
+201
View File
@@ -0,0 +1,201 @@
#![allow(clippy::unwrap_used)]
use super::*;
use crate::geom::Vec3;
fn run(s: &mut Session, cmd: &str, value: Value) -> Value {
s.execute(cmd, &value).unwrap()
}
fn linked_profiles() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[4,5],"radius":0.5,"id":"a"}));
run(&mut s, "sketch.circle.center", json!({"center":[12,15],"radius":0.5,"id":"b"}));
run(&mut s, "sketch.finish", json!({}));
let state = s.model.state();
let sketch = state.sketches.last().unwrap();
let id = sketch.feature;
let a = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"a".to_string())).unwrap();
let b = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"b".to_string())).unwrap();
let mut input = cam::corpus::fixture("drill").unwrap();
input.setup.models[0].body = state.bodies[0].name.clone();
input.setup.frame.origin = Vec3::new(100., -50., 0.);
input.setup.stock.min -= input.setup.frame.origin;
input.setup.stock.max -= input.setup.frame.origin;
run(&mut s, "cam.setup.put", json!({"setup":input.setup}));
let selected = run(
&mut s,
"cam.holes.from_selection",
json!({"setup":1,"selections":[
{"kind":"profile","sketch":id,"index":b},{"kind":"profile","sketch":id,"index":a}
]}),
);
let holes: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap();
assert_eq!(holes, vec![[-88., 65.], [-96., 55.]]);
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
input.operations[0].geometry = cam::Strategy::Drill { holes, peck: Some(0.3), full_diameter: false };
run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]}));
run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]}));
run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Selected holes","setup":1,"operations":[1]}}));
(s, id)
}
fn centers(s: &mut Session) -> (u64, Vec<[f64; 2]>) {
let generated = run(s, "cam.generate", json!({"program":1}));
assert_eq!(generated["state"], "generated", "{generated}");
let id = generated["job"].as_u64().unwrap();
let job = jobs::job(s, id).unwrap();
let op = &job.input().unwrap().operations[0];
let holes = match &op.geometry {
cam::Strategy::Drill { holes, .. } => holes.clone(),
_ => vec![],
};
(id, holes)
}
#[test]
fn hole_selection_follows_profile_edits_frames_undo_and_reopen() {
let (mut s, sketch) = linked_profiles();
let (old, before) = centers(&mut s);
let saved = crate::io::write_design(&s.doc);
let mut reopened = Session::new(crate::io::read_design(&saved).unwrap());
assert_eq!(centers(&mut reopened).1, before);
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
run(&mut s, "sketch.move", json!({"entities":["a"],"translate":[2,3]}));
assert_eq!(run(&mut s, "cam.job.status", json!({"job":old}))["stale"], true);
assert!(s.execute("cam.post", &json!({"job":old})).is_err());
assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(centers(&mut s).1, before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]);
// Unassociated drilling preserves manually authored coordinates on reopen.
let mut legacy: Value = serde_json::from_slice(&crate::io::write_design(&s.doc)).unwrap();
legacy["manufacturing"]["operations"][0].as_object_mut().unwrap().remove("hole_sources");
let doc = crate::io::read_design(legacy.to_string().as_bytes()).unwrap();
assert!(doc.manufacturing.operations[0].hole_sources.is_empty());
assert_eq!(doc.manufacturing.operations[0].geometry, s.doc.manufacturing.operations[0].geometry);
}
#[test]
fn hole_selection_rejects_missing_duplicate_tilted_and_wrong_size_sources() {
let (mut s, sketch) = linked_profiles();
let pick = json!({"kind":"profile","sketch":sketch,"index":0});
for picks in [json!([]), json!([pick.clone(), pick.clone()]), json!([{"kind":"profile","sketch":sketch,"index":999}])] {
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":picks})).is_err());
}
let excessive = vec![pick.clone(); cam::MAX_ITEMS + 1];
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":excessive})).is_err());
let mut invalid = s.doc.manufacturing.operations[0].clone();
invalid.hole_sources.pop();
assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err());
let mut invalid = s.doc.manufacturing.operations[0].clone();
invalid.hole_sources[1] = invalid.hole_sources[0].clone();
assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err());
let mut setup = s.doc.manufacturing.setups[0].clone();
setup.frame = cam::Frame { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z, z: -Vec3::Y };
run(&mut s, "cam.setup.put", json!({"setup":setup}));
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[pick]})).is_err());
run(&mut s, "edit.undo", json!({}));
let mut tool = s.doc.manufacturing.tools[0].clone();
tool.diameter = 0.8;
run(&mut s, "cam.tool.put", json!({"tool":tool}));
let failed = run(&mut s, "cam.generate", json!({"program":1}));
assert_eq!(failed["state"], "failed");
assert!(failed["error"].as_str().unwrap().contains("diameter"));
assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err());
run(&mut s, "edit.undo", json!({}));
let mut op = s.doc.manufacturing.operations[0].clone();
if let cam::HoleSource::Profile { profile } = &mut op.hole_sources[0] {
profile.curves = vec!["missing".into()];
}
run(&mut s, "cam.operation.put", json!({"operation":op}));
assert!(s.execute("cam.generate", &json!({"program":1})).unwrap_err().to_string().contains("reselect"));
}
#[test]
fn hole_selection_keeps_exact_occurrence_and_rejects_lost_faces() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Drilled part"}));
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6}));
run(&mut s, "solid.hole", json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118}));
run(&mut s, "component.activate", json!({"component":0}));
let second = run(&mut s, "occurrence.copy", json!({"component":component["component"],"translate":[30,0,0]}))["occurrence"].as_u64().unwrap();
let body = s.model.state().bodies[0].clone();
let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap();
let mut input = cam::corpus::fixture("drill").unwrap();
input.setup.models = vec![cam::BodyRef { body: body.name.clone(), occurrence: vec![second] }];
input.setup.frame.origin = Vec3::new(20., 0., 0.);
input.setup.stock.min.x += 10.;
input.setup.stock.max.x += 20.;
run(&mut s, "cam.setup.put", json!({"setup":input.setup}));
let selected = run(
&mut s,
"cam.holes.from_selection",
json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face,"occurrence":[second]}]}),
);
// Viewport labels resolve through the same setup occurrence as explicit command picks.
let state = s.model.state();
let placed = s.placed_document();
let label = placed.world_body_name(&state, &body.name, placed.model_body_component(&body), &[second]);
let from_viewport = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":[{"kind":"face","body":label,"index":face}]}));
assert_eq!(from_viewport, selected);
assert!(crate::kernel::hole_axis(&body.body, face, f64::NAN).is_err());
assert!(crate::kernel::hole_axis(&body.body, usize::MAX, 0.0005).is_err());
let plane = (0..body.body.face_count())
.find(|i| matches!(crate::kernel::face_surface(&body.body, *i), Some(crate::kernel::FaceSurface::Plane { .. })))
.unwrap();
assert!(
s.execute(
"cam.holes.from_selection",
&json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":plane,"occurrence":[second]}]})
)
.is_err()
);
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]}));
run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]}));
run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Placed holes","setup":1,"operations":[1]}}));
let (job, before) = centers(&mut s);
assert!((before[0][0] - 20.).abs() < 1e-8 && (before[0][1] - 10.).abs() < 1e-8);
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
assert_eq!(run(&mut s, "cam.job.status", json!({"job":job}))["stale"], true);
let moved = centers(&mut s).1;
assert!((moved[0][0] - 25.).abs() < 1e-8);
let mut op = s.doc.manufacturing.operations[0].clone();
if let cam::HoleSource::Face { name, .. } = &mut op.hole_sources[0] {
*name = "missing:face".into();
}
run(&mut s, "cam.operation.put", json!({"operation":op}));
let failed = run(&mut s, "cam.generate", json!({"program":1}));
assert_eq!(failed["state"], "failed");
assert!(failed["error"].as_str().unwrap().contains("reselect"));
assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err());
}
#[test]
fn hole_selection_combines_seam_faces_but_rejects_exterior_cylinders() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let body = s.model.state().bodies[0].clone();
let mut setup = cam::corpus::fixture("drill").unwrap().setup;
setup.models[0].body = body.name.clone();
run(&mut s, "cam.setup.put", json!({"setup":setup}));
let mut picks = vec![];
for index in 0..body.body.face_count() {
if let Ok((origin, _, _)) = crate::kernel::hole_axis(&body.body, index, 0.0005) {
if (origin.x - 68.).abs() < 1e-8 {
picks.push(json!({"kind":"face","body":body.name,"index":index}));
}
} else if matches!(crate::kernel::face_surface(&body.body, index), Some(crate::kernel::FaceSurface::Cylinder { .. })) {
assert!(
s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":index}]})).is_err()
);
}
}
assert!(picks.len() >= 2, "the sample must exercise multiple seam faces on one drilled wall");
let mut duplicate = picks.clone();
duplicate.push(picks[1].clone());
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":duplicate})).is_err());
let result = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":picks}));
let centers: Vec<[f64; 2]> = serde_json::from_value(result["holes"].clone()).unwrap();
assert_eq!(centers.len(), 1);
assert!((centers[0][0] - 68.).abs() < 1e-8 && (centers[0][1] - 25.).abs() < 1e-8);
assert_eq!(result["sources"].as_array().unwrap().len(), 1);
}
+2
View File
@@ -21,6 +21,7 @@ macro_rules! planned {
}
mod browser;
mod cam;
mod clipboard;
mod compare;
mod component;
@@ -252,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(cam::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
+6
View File
@@ -14,6 +14,8 @@ pub mod crash;
pub mod frames;
pub mod fuzz;
pub mod licences;
pub mod manufacturing;
pub use solidcraft_cam as cam;
pub mod occurrences;
pub mod params;
pub mod product_scene;
@@ -252,6 +254,8 @@ pub struct Session {
/// Host-owned viewport metadata, never saved or included in history.
render_viewport: Option<Arc<render::ExecutorDescriptor>>,
render_jobs: render_jobs::Jobs,
/// Derived, immutable CAM jobs; not persisted or copied into undo snapshots.
pub cam_jobs: manufacturing::Jobs,
/// The visibility the design file holds (what `is_dirty` compares with).
saved_visibility: Visibility,
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
@@ -377,6 +381,7 @@ impl Session {
camera: None,
render_viewport: None,
render_jobs: Default::default(),
cam_jobs: Default::default(),
#[cfg(test)]
evaluations: 0,
crash_journal: false,
@@ -844,6 +849,7 @@ impl Session {
camera: self.camera,
render_viewport: self.render_viewport.clone(),
render_jobs: self.render_jobs.scratch(),
cam_jobs: Default::default(),
saved_visibility: self.saved_visibility.clone(),
#[cfg(test)]
evaluations: 0,
+461
View File
@@ -0,0 +1,461 @@
//! Immutable CAM jobs, independent of the desktop UI and document undo state.
use crate::{EngineError, Result, Session, cam};
pub mod exports;
pub(crate) mod holes;
mod snapshot;
use std::collections::BTreeMap;
use std::sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
#[derive(Default)]
pub struct Jobs {
pub jobs: BTreeMap<u64, Arc<Job>>,
pub next: u64,
}
pub struct Job {
pub source_doc: Arc<solidcraft_doc::Document>,
pub source_moves: BTreeMap<u64, solidcraft_doc::Mat>,
input: Arc<OnceLock<cam::JobInput>>,
snapshot: Mutex<Option<snapshot::Snapshot>>,
stock: Arc<Mutex<Option<std::result::Result<Arc<crate::geom::Mesh>, String>>>>,
pub(crate) export: Arc<Mutex<exports::State>>,
pub cancelled: Arc<AtomicBool>,
#[cfg(target_arch = "wasm32")]
pending: Mutex<Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<JobState>>>>>>,
pub progress: Arc<AtomicUsize>,
pub result: Arc<Mutex<JobState>>,
}
#[derive(Clone)]
pub enum JobState {
Preparing,
Running,
Generated(Arc<cam::Toolpath>),
Verifying(Arc<cam::Toolpath>),
Verified(Arc<cam::Toolpath>, Arc<cam::verify::Report>),
Failed(String),
Cancelled,
}
impl Job {
/// Display geometry is a derived cache. It never substitutes for a verification report.
pub fn stock_mesh(&self) -> Result<Option<Arc<crate::geom::Mesh>>> {
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<JobState> {
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<cam::JobInput> {
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<cam::Boundary> {
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<u64> {
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<Job>, 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<impl std::future::Future<Output = Result<JobState>> + 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<crate::geom::Mesh> {
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<Job>, 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<Job>, future: impl std::future::Future<Output = Result<JobState>>, publish_state: bool) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<JobState> {
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<Arc<Job>> {
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<Job>) {
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);
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Artifact jobs retain immutable source data; only a fresh result command may publish output.
use super::*;
#[derive(Default)]
pub(crate) enum State {
#[default]
Idle,
Running,
Ready {
content: String,
destination: Option<String>,
},
Failed(String),
}
pub fn status(job: &Job) -> Result<serde_json::Value> {
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<bool> {
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<String>)> {
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<Job>, format: String, destination: Option<String>) -> 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<String>) -> impl std::future::Future<Output = Result<JobState>> + 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)
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Explicit hole picks and immutable resolution of their persistent identities.
use super::snapshot::{PlacedBody, Profile};
use crate::{
EngineError, Result, Session, cam,
geom::{Seg2, Vec3},
};
use serde::{Deserialize, Serialize};
use solidcraft_doc::apply_point;
fn bad(message: &str) -> EngineError {
EngineError::Other(message.into())
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum Pick {
Face {
body: String,
index: usize,
#[serde(default)]
occurrence: Option<Vec<u64>>,
},
Profile {
sketch: u64,
index: usize,
#[serde(default)]
occurrence: Vec<u64>,
},
}
#[derive(Serialize)]
pub(crate) struct Selected {
pub center: [f64; 2],
pub diameter: f64,
}
pub(super) enum Hole {
Face { body: Box<PlacedBody>, name: String },
Profile(Profile),
}
impl Hole {
pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::HoleSource, tolerance: f64) -> Result<Self> {
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<Selected> {
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<Pick>) -> Result<(Vec<cam::HoleSource>, Vec<Selected>)> {
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))
}
+422
View File
@@ -0,0 +1,422 @@
//! Cheap immutable capture on the session thread; curve sampling, tessellation,
//! transforms and hashing happen during preparation on the job's worker.
use crate::{
EngineError, Result, Session, cam,
geom::{Plane, Region2, Seg2, Vec2, Vec3},
};
use solidcraft_doc::{Mat, apply_point};
pub(super) struct Snapshot {
setup: cam::Setup,
tools: Vec<cam::Tool>,
operations: Vec<cam::Operation>,
profiles: Vec<(usize, Profile)>,
holes: Vec<(usize, Vec<super::holes::Hole>)>,
models: Vec<PlacedBody>,
fixtures: Vec<PlacedBody>,
}
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<Self> {
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::<Result<Vec<_>>>()?;
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::<Result<_>>()?,
));
}
}
let tools = m.tools.iter().filter(|t| operations.iter().any(|o| o.tool == t.id)).cloned().collect::<Vec<_>>();
if tools.iter().map(|t| t.number).collect::<std::collections::BTreeSet<_>>().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::<Result<_>>()?;
let fixtures = setup.fixtures.iter().map(|r| PlacedBody::capture(s, r)).collect::<Result<_>>()?;
Ok(Self { setup, tools, operations, profiles, holes, models, fixtures })
}
pub(super) async fn build<F: std::future::Future<Output = ()>>(
mut self,
check: &impl Fn() -> bool,
progress: &impl Fn(usize),
yield_control: &mut impl FnMut() -> F,
) -> Result<cam::JobInput> {
let total =
self.profiles.len() + self.holes.iter().map(|(_, holes)| holes.len()).sum::<usize>() + 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<Self> {
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<F: std::future::Future<Output = ()>>(
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<Self> {
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<usize> {
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<F: std::future::Future<Output = ()>>(
&self,
check: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<cam::Boundary> {
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<T>(future: impl std::future::Future<Output = Result<T>>) -> Result<T> {
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());
}
}
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
solidcraft-cam = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-kernel = { workspace = true }
solidcraft-doc = { workspace = true }
+62
View File
@@ -0,0 +1,62 @@
//! CAM artifact serialization. The engine owns freshness and posting gates.
mod incremental;
pub use incremental::artifact_cooperative;
#[cfg(test)]
use crate::IoError;
use crate::Result;
use solidcraft_cam::{JobInput, Toolpath, verify::Report};
pub fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
/// Synchronous compatibility wrapper; interactive clients use the cooperative entry point.
pub fn artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result<String> {
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<String> {
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!(
"<!doctype html><meta charset=utf-8><title>{}</title><style>body{{font:16px system-ui;max-width:1000px;margin:40px auto}}table{{border-collapse:collapse;width:100%}}td,th{{padding:8px;border:1px solid #ccc;text-align:left}}pre{{white-space:pre-wrap}}</style><h1>{}</h1><p>Reference three-axis milling — not a qualified machine post.</p><p>Fingerprint: {}</p><h2>Setup</h2><pre>{}</pre><h2>Operations</h2><table><tr><th>Name</th><th>Tool</th><th>Feed mm/min</th><th>Spindle rpm</th><th>Bottom mm</th></tr>",
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!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
escape(&o.name),
o.tool,
o.feed,
o.spindle,
o.bottom
));
}
html.push_str("</table><h2>Tools</h2><pre>");
html.push_str(&escape(&serde_json::to_string_pretty(&input.tools).map_err(|e| IoError::Invalid(e.to_string()))?));
html.push_str("</pre><h2>Operation settings and selected geometry</h2><pre>");
html.push_str(&escape(&serde_json::to_string_pretty(&input.operations).map_err(|e| IoError::Invalid(e.to_string()))?));
html.push_str("</pre>");
Ok(html)
}
_ => Err(IoError::Invalid("unknown CAM artifact format".into())),
}
}
+276
View File
@@ -0,0 +1,276 @@
//! Bounded artifact serialization. Large arrays never become an intermediate JSON Value.
use super::{JobInput, Report, Toolpath, escape};
use crate::{IoError, Result};
use serde::Serialize;
use std::future::Future;
const MAX_BYTES: usize = 128 * 1024 * 1024;
struct Writer<C, Y> {
out: String,
cancelled: C,
yield_control: Y,
}
impl<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>> Writer<C, Y> {
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<T: Serialize>(&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<T: Serialize>(&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<F: Future<Output = ()>>(
input: &JobInput,
path: &Toolpath,
report: Option<&Report>,
format: &str,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<String> {
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!(
"<!doctype html><meta charset=utf-8><title>{}</title><style>body{{font:16px system-ui;max-width:1000px;margin:40px auto}}table{{border-collapse:collapse;width:100%}}td,th{{padding:8px;border:1px solid #ccc;text-align:left}}pre{{white-space:pre-wrap}}</style><h1>{}</h1><p>Reference three-axis milling — not a qualified machine post.</p><p>Fingerprint: {}</p><h2>Setup</h2><pre>{}</pre><h2>Operations</h2><table><tr><th>Name</th><th>Tool</th><th>Feed mm/min</th><th>Spindle rpm</th><th>Bottom mm</th></tr>",
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!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
escape(&o.name),
o.tool,
o.feed,
o.spindle,
o.bottom
));
}
w.out.push_str("</table><h2>Tools</h2><pre>");
w.html_array(&input.tools).await?;
w.out.push_str("</pre><h2>Operation settings and selected geometry</h2><pre>");
w.html_array(&input.operations).await?;
w.out.push_str("</pre>");
}
_ => 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<T>(future: impl Future<Output = T>) -> 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<serde_json::Value> {
html.split("<pre>")
.skip(1)
.map(|part| {
let text = part
.split("</pre>")
.next()
.unwrap()
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&");
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 = "ø <script>&\"'".into();
input.setup.name = "stock </pre> &".into();
path.blocks.extend(std::iter::repeat_n(path.blocks[0].clone(), 1025));
input.geometry.fixture_groups = vec![input.geometry.target.len()];
input.geometry.fixtures = input.geometry.target.clone();
report.findings.push(solidcraft_cam::verify::Finding {
severity: solidcraft_cam::verify::FindingSeverity::Information,
kind: "original".into(),
line: Some(42),
record: Some(17),
source: None,
at: solidcraft_geom::Vec3::new(1., 2., 3.),
detail: "example".into(),
});
for format in ["toolpath", "validation", "setup_sheet", "stock"] {
let expected = super::super::reference_artifact(&input, &path, Some(&report), format).unwrap();
let checkpoints = Cell::new(0);
let actual = ready(artifact_cooperative(
&input,
&path,
Some(&report),
format,
|| false,
|| {
checkpoints.set(checkpoints.get() + 1);
std::future::ready(())
},
))
.unwrap();
assert!(checkpoints.get() >= 2);
match format {
"stock" => assert_eq!(actual, expected),
"setup_sheet" => {
assert_eq!(pre_values(&actual), pre_values(&expected));
assert!(!actual.contains("<script>"));
assert!(actual.contains("&lt;script&gt;"));
}
_ => assert_eq!(
serde_json::from_str::<serde_json::Value>(&actual).unwrap(),
serde_json::from_str::<serde_json::Value>(&expected).unwrap()
),
}
}
}
#[test]
fn cancellation_at_each_checkpoint_never_returns_a_partial_artifact() {
let (input, path, report) = fixture();
for format in ["toolpath", "validation", "setup_sheet", "stock"] {
let ticks = Cell::new(0);
ready(artifact_cooperative(
&input,
&path,
Some(&report),
format,
|| false,
|| {
ticks.set(ticks.get() + 1);
std::future::ready(())
},
))
.unwrap();
for stop in 0..=ticks.get() {
let current = Cell::new(0);
let result = ready(artifact_cooperative(
&input,
&path,
Some(&report),
format,
|| current.get() >= stop,
|| {
current.set(current.get() + 1);
std::future::ready(())
},
));
assert!(result.unwrap_err().to_string().contains("cancelled"), "{format} checkpoint{stop}");
}
}
for format in ["validation", "stock"] {
assert!(ready(artifact_cooperative(&input, &path, None, format, || false, || std::future::ready(()))).is_err());
}
assert!(ready(artifact_cooperative(&input, &path, Some(&report), "unknown", || false, || std::future::ready(()))).is_err());
}
}
+1
View File
@@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod cam;
pub mod catalog;
mod design;
mod design_metadata;
+6
View File
@@ -1612,3 +1612,9 @@ mod display_mesh_tests;
#[cfg(test)]
#[path = "regularization_protection_tests.rs"]
mod regularization_protection_tests;
#[path = "body_verification.rs"]
mod verification;
#[cfg(test)]
#[path = "body_verification_tests.rs"]
mod verification_tests;
+58
View File
@@ -0,0 +1,58 @@
//! Manufacturing mesh path: no display LOD, tolerance floor or missing-face fallback.
use super::*;
impl Body {
/// Mesh at a fixed physical chord tolerance. Refinement/output exhaustion is an error.
pub fn tessellate_for_verification(&self, tol: f64, triangle_limit: usize) -> Result<Mesh> {
if !tol.is_finite() || tol <= 0. || triangle_limit == 0 || triangle_limit > 2_000_000 {
return Err(KernelError::Invalid("verification tessellation needs positive finite tolerance and 1–2000000 triangles".into()));
}
if let Some(mesh) = &self.mesh {
if mesh.triangles.len() > triangle_limit {
return Err(KernelError::Failed("verification tessellation exceeds triangle budget".into()));
}
return Ok(mesh.to_mesh());
}
guard("verification tessellation", || {
let mut out = Mesh::default();
let mut face_index = 0_u32;
for shell in self.solid.boundaries() {
let mesh = truck_meshalgo::tessellation::robust_triangulation_checked(shell, tol)
.ok_or_else(|| KernelError::Failed("verification tessellation cannot meet tolerance within refinement budget".into()))?;
if mesh.face_iter().count() != shell.face_iter().count() {
return Err(KernelError::Failed("verification tessellation has missing faces".into()));
}
for face in mesh.face_iter() {
let pm =
face.surface().ok_or_else(|| KernelError::Failed(format!("verification tessellation could not mesh face {face_index}")))?;
let count = pm.faces().triangle_iter().count();
if count == 0 || count > triangle_limit.saturating_sub(out.triangles.len()) {
return Err(KernelError::Failed("verification tessellation has empty face or exceeds triangle budget".into()));
}
let flip = !face.orientation();
for tri in pm.faces().triangle_iter() {
let mut indices = [0; 3];
for (slot, vertex) in indices.iter_mut().zip(tri) {
let p = pm.positions().get(vertex.pos).ok_or_else(|| KernelError::Failed("invalid verification vertex".into()))?;
let p = Vec3::new(p.x, p.y, p.z);
if ![p.x, p.y, p.z].into_iter().all(f64::is_finite) {
return Err(KernelError::Failed("non-finite verification vertex".into()));
}
*slot = u32::try_from(out.positions.len()).map_err(|_| KernelError::Failed("verification vertex limit".into()))?;
out.positions.push(p);
let n = vertex.nor.and_then(|i| pm.normals().get(i)).map(|n| Vec3::new(n.x, n.y, n.z)).unwrap_or(Vec3::Z);
out.normals.push(if flip { -n } else { n });
}
out.triangles.push(if flip { [indices[0], indices[2], indices[1]] } else { indices });
out.tri_face.push(face_index);
}
face_index += 1;
}
}
if face_index == 0 {
return Err(KernelError::Failed("verification tessellation has no faces".into()));
}
Ok(out)
})
}
}
@@ -0,0 +1,94 @@
//! Independent analytic checks for the fixed-tolerance verification mesh.
#![allow(clippy::unwrap_used)]
use super::*;
// Run the original candidate-index differential tests in ordinary workspace CI;
// the vendored crate's standalone test targets are intentionally disabled.
#[path = "../../../vendor/truck-meshalgo/src/tessellation/boundary_index.rs"]
mod boundary_index;
fn radial_error(mesh: &Mesh, radius: f64, center: Vec3) -> f64 {
let mut error = 0_f64;
for indices in &mesh.triangles {
let points = mesh.tri(indices).unwrap();
// The cylinder side spans both cap heights. Check every side edge,
// including triangle diagonals, against the analytic radial distance.
if points.iter().any(|p| p.z < center.z + 1.) && points.iter().any(|p| p.z > center.z + 5.) {
for i in 0..3 {
let midpoint = (points[i] + points[(i + 1) % 3]) * 0.5 - center;
error = error.max((radius - midpoint.x.hypot(midpoint.y)).abs());
}
}
}
error
}
#[test]
fn verification_tessellation_keeps_physical_tolerance_for_large_curved_bodies() {
let tolerance = 0.00125;
let radius = 5000.;
let center = Vec3::new(20_000., -10_000., 3.);
let body = crate::cylinder(center, Vec3::Z, radius, 6.).unwrap();
let display = body.tessellate(tolerance).unwrap();
assert!(radial_error(&display, radius, center) > tolerance * 4.);
let mesh = body.tessellate_for_verification(tolerance, 200_000).unwrap();
assert!(mesh.triangles.len() > display.triangles.len());
let volume = mesh.measure().volume;
assert!(volume >= std::f64::consts::PI * (radius - tolerance).powi(2) * 6.);
assert!(volume <= std::f64::consts::PI * (radius + tolerance).powi(2) * 6.);
assert!(radial_error(&mesh, radius, center) <= tolerance, "{}", radial_error(&mesh, radius, center));
assert_eq!(mesh.tri_face.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), body.face_count());
}
#[test]
fn verification_tessellation_rejects_bad_settings_and_excessive_output() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 6.)).unwrap();
for tolerance in [0., -1., f64::NAN, f64::INFINITY] {
assert!(body.tessellate_for_verification(tolerance, 100).is_err());
}
for limit in [0, 2_000_001] {
assert!(body.tessellate_for_verification(0.00125, limit).is_err());
}
assert!(body.tessellate_for_verification(0.00125, 11).unwrap_err().to_string().contains("triangle budget"));
let mesh = body.tessellate_for_verification(0.00125, 12).unwrap();
assert_eq!(mesh.triangles.len(), 12);
let imported = crate::mesh_body(&mesh.positions, &mesh.triangles).unwrap();
assert!(imported.tessellate_for_verification(0.00125, 11).is_err());
assert_eq!(imported.tessellate_for_verification(0.00125, 12).unwrap().triangles.len(), 12);
}
#[test]
fn verification_tessellation_does_not_accept_refinement_budget_fallback() {
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 5000., 6.).unwrap();
let error = body.tessellate_for_verification(1e-10, 200_000).unwrap_err();
assert!(error.to_string().contains("refinement budget"), "{error}");
// Curved in both parameters: the surface grid exhausts its budget before
// any circular boundary needs the edge's 20,000-point limit.
let sphere = crate::sphere(Vec3::ZERO, 10.).unwrap();
let error = sphere.tessellate_for_verification(1e-5, 200_000).unwrap_err();
assert!(error.to_string().contains("refinement budget"), "{error}");
}
#[test]
fn verification_tessellation_preserves_concave_material_and_multiple_holes() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let polygon = |points: &[(f64, f64)]| Loop2::polygon(&points.iter().map(|&(x, y)| Vec2::new(x, y)).collect::<Vec<_>>());
let region = Region2 {
outer: polygon(&[(0., 0.), (20., 0.), (20., 10.), (10., 10.), (10., 20.), (0., 20.)]),
holes: vec![polygon(&[(2., 2.), (2., 4.), (4., 4.), (4., 2.)]), polygon(&[(12., 2.), (12., 4.), (14., 4.), (14., 2.)])],
};
let bodies = crate::extrude(&Plane::XY, &[region], 0., 6.).unwrap();
assert_eq!(bodies.len(), 1);
let mesh = bodies.first().unwrap().tessellate_for_verification(0.00125, 200_000).unwrap();
assert!((mesh.measure().volume - 1752.).abs() < 1e-6);
for (point, material) in [
(Vec3::new(1., 1., 3.), true),
(Vec3::new(19., 9., 3.), true),
(Vec3::new(9., 19., 3.), true),
(Vec3::new(3., 3., 3.), false),
(Vec3::new(13., 3., 3.), false),
(Vec3::new(15., 15., 3.), false),
] {
assert_eq!(mesh.contains(point), material, "{point:?}");
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ pub use step_in::{
};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use surface_extend::{ExtendLimit, ExtendMode, ExtendOptions, extend_surface};
pub use surface_kind::{EdgeCircle, FaceSurface, edge_circle, face_surface};
pub use surface_kind::{EdgeCircle, FaceSurface, edge_circle, face_surface, hole_axis};
pub use surface_offset::offset_surface;
pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface};
pub use surface_untrim::{UntrimHoles, untrim};
+15
View File
@@ -165,6 +165,21 @@ impl FaceSurface {
}
}
/// An inward cylindrical face's axis and radius. The caller still verifies the
/// machined result against the complete target, including trimmed face bounds.
pub fn hole_axis(b: &Body, face: usize, tolerance: f64) -> crate::Result<(Vec3, Vec3, f64)> {
if !tolerance.is_finite() || tolerance <= 0. {
return Err(crate::KernelError::Invalid("invalid hole tolerance".into()));
}
crate::guard("hole face axis", || {
let f = b.solid.face_iter().nth(face).ok_or_else(|| crate::KernelError::Invalid("hole face no longer exists".into()))?;
match surf_of(f, tolerance) {
Some(Surf::Cylinder { o, a, r, convex: false }) => Ok((o, a, r)),
_ => Err(crate::KernelError::Invalid("select an inward cylindrical hole face".into())),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
+14 -1
View File
@@ -253,6 +253,17 @@ fn groups(t: &Tokens) -> Vec<Group> {
let ic = &t.icons;
let a = &t.analysis;
let mut out = vec![
group(
"manufacturing",
&[
("cam_stock", t.cam_stock),
("cam_fixture", t.cam_fixture),
("cam_cut", t.cam_cut),
("cam_rapid", t.cam_rapid),
("cam_tool", t.cam_tool),
],
&[("viewport_top", t.viewport_top), ("viewport_bottom", t.viewport_bottom)],
),
group(
"sketch lines",
&[
@@ -611,7 +622,9 @@ fn changed_fields(a: &Tokens, b: &Tokens) -> BTreeSet<String> {
#[test]
fn each_mode_changes_only_its_table() {
let set = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<BTreeSet<_>>();
let set = |s: &[&str]| {
s.iter().copied().chain(["cam_stock", "cam_fixture", "cam_cut", "cam_rapid", "cam_tool"]).map(str::to_string).collect::<BTreeSet<_>>()
};
let red_green_both = [
"triad",
"gizmo",
+22 -7
View File
@@ -342,13 +342,23 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::Workspace => {
let rendering = crate::render_workspace::active(app);
let mut v = vec![
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering || app.ui.tab == "MILLING" {
""
} else {
"current"
}),
act("ui.workspace.render", "Render", "camera")
.with(json!({"workspace":"render"}))
.key(if rendering { "current" } else { "" })
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
];
for w in ["Animation", "Simulation", "Manufacture", "Drawing"] {
v.push(
act("ui.workspace.manufacture", "Manufacture", "box")
.with(json!({"workspace":"manufacture"}))
.key(if app.ui.tab == "MILLING" { "current" } else { "" })
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
);
for w in ["Animation", "Simulation", "Drawing"] {
v.push(act("ui.workspace", w, "").key("coming later").on(false));
}
v
@@ -1042,11 +1052,16 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.toolbarCustomize" | "ui.shortcutPin" => {
crate::toolbar_custom::run(app, &item.id, &p)
}
"ui.workspace" | "ui.workspace.design" | "ui.workspace.render" => {
if let Some(workspace) = p.get("workspace").and_then(Value::as_str)
&& let Err(e) = crate::render_workspace::enter(app, workspace == "render")
{
app.set_status(e, true);
"ui.workspace" | "ui.workspace.design" | "ui.workspace.render" | "ui.workspace.manufacture" => {
if let Some(workspace) = p.get("workspace").and_then(Value::as_str) {
if let Err(e) = crate::render_workspace::enter(app, workspace == "render") {
app.set_status(e, true);
} else {
crate::manufacturing::cancel_editor(app);
if workspace != "render" {
app.ui.tab = if workspace == "manufacture" { "MILLING" } else { "SOLID" }.into();
}
}
}
}
"ui.findJoint" => {
+26 -5
View File
@@ -176,14 +176,17 @@ pub fn request_scope(app: &SolveApp, params: &Value) -> Result<Option<vfs::PathS
/// mode, the section the viewport shows: an open Section Analysis dialog's preview, a sketch's
/// Slice), with the grid and the origin's axes as `guides` says.
pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides) -> solidcraft_engine::render::Scene {
solidcraft_engine::view::scene_with_palette_and_guides(
let mut scene = solidcraft_engine::view::scene_with_palette_and_guides(
&app.session,
&app.cam,
crate::viewport::section_plane(app),
solidcraft_engine::view::world_curve_parts,
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
guides,
)
);
crate::manufacturing::view::cpu(app, &mut scene);
crate::manufacturing::view::cpu_overlays(app, &mut scene);
scene
}
/// The grid and the origin's axes as the window shows them (the Grid and Origin switches).
@@ -224,14 +227,20 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
match method {
"ui.workspace" => {
if let Some(workspace) = s("workspace") {
if !matches!(workspace, "design" | "render") {
return err("workspace must be design or render");
if !matches!(workspace, "design" | "render" | "manufacture") {
return err("workspace must be design, render or manufacture");
}
if let Err(e) = crate::render_workspace::enter(app, workspace == "render") {
return err(e);
}
crate::manufacturing::cancel_editor(app);
if workspace != "render" {
app.ui.tab = if workspace == "manufacture" { "MILLING" } else { "SOLID" }.into();
}
}
ok(json!({"workspace":if crate::render_workspace::active(app) {"render"} else {"design"}}))
ok(
json!({"workspace":if crate::render_workspace::active(app) {"render"} else if app.ui.tab == "MILLING" {"manufacture"} else {"design"}}),
)
}
"engine.execute" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
@@ -267,6 +276,18 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
"camera": app.cam,
"tool": app.tool.as_ref().map(|t| t.cmd),
"manufacturing": {
"program": app.manufacturing.program,
"job": app.manufacturing.job,
"completed_motions": app.manufacturing.playback,
"finding": app.manufacturing.finding,
"playing": app.manufacturing.playing,
"workpiece": format!("{:?}", app.manufacturing.view.workpiece),
"fixtures": app.manufacturing.view.fixtures,
"paths": app.manufacturing.view.paths,
"stock_boundary": app.manufacturing.view.boundary,
"events_per_second": app.manufacturing.view.speed,
},
"dialog": app.dialog.as_ref().map(|d| format!("{d:?}")),
"status": app.status.as_ref().map(|(text, _, error)| json!({"text": text, "error": error})),
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
+1
View File
@@ -3130,6 +3130,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
/// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back.
pub fn cancel(app: &mut SolveApp) {
crate::manufacturing::cancel_editor(app);
app.applying = None;
if let Some(mut d) = app.dialog.take() {
crate::dialogs_motion::closed(app, &mut d, false);
+3
View File
@@ -92,6 +92,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
// Whatever was going on in the design leaves with it.
crate::sketch_text_move::cancel_drag(app);
crate::dialogs::cancel(app);
app.manufacturing = Default::default();
crate::dim_view::cancel();
app.tool = None;
app.params = None;
@@ -166,6 +167,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
if active_is_blank(app) {
let rev = app.session.revision;
crate::dialogs::cancel(app);
app.manufacturing = Default::default();
app.tool = None;
app.menu.props = None;
crate::sketch_attachment_ui::clear(app);
@@ -251,6 +253,7 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
crate::sketch_text_move::cancel_drag(app);
let rev = app.session.revision;
crate::dialogs::cancel(app);
app.manufacturing = Default::default();
app.tool = None;
app.session.replace_design(Session::default());
app.session.revision = rev + 1;
+108
View File
@@ -0,0 +1,108 @@
//! Native file actions must share the toolbar flow while JSON calls remain dialog-free.
#![allow(clippy::unwrap_used)]
use crate::scenario::Harness;
use serde_json::json;
use std::{cell::Cell, rc::Rc};
fn manufacturing(h: &mut Harness) {
let mut input = solidcraft_engine::cam::corpus::fixture("face").unwrap();
h.app.run("solid.box", json!({"length":20,"width":20,"height":5})).unwrap();
input.setup.models[0].body = h.app.session.model.state().bodies[0].name.clone();
h.app.run("cam.setup.put", json!({"setup":input.setup})).unwrap();
h.app.run("cam.tool.put", json!({"tool":input.tools[0]})).unwrap();
h.app.run("cam.operation.put", json!({"operation":input.operations[0]})).unwrap();
h.app.run("cam.program.put", json!({"program":{"id":1,"name":"Facing","setup":1,"operations":[1]}})).unwrap();
}
#[test]
fn interactive_save_and_save_as_preserve_cam_definitions_and_cancel_cleanly() {
let mut h = Harness::new();
manufacturing(&mut h);
let before = serde_json::to_value(&h.app.session.doc.manufacturing).unwrap();
let first = h.dir.join("first.solidcraft");
let second = h.dir.join("second.solidcraft");
let picked = first.clone();
h.app.services.pick_save = Some(Box::new(move |_, _| {
let path = picked.to_string_lossy().into_owned();
Box::pin(async move { Some(path) })
}));
// Programmatic commands must never invoke the picker.
assert!(h.app.run("file.save", json!({})).is_err());
assert!(!first.exists());
h.app.start("file.save");
h.frames(2);
assert!(first.exists());
assert!(!h.app.session.is_dirty());
h.app.run("solid.box", json!({"length":1,"width":1,"height":1})).unwrap();
h.app.services.pick_save = Some(Box::new(|_, _| Box::pin(async { None })));
h.app.start("file.save_as");
h.frames(2);
assert!(h.app.session.is_dirty());
assert_eq!(h.app.session.path.as_deref(), first.to_str());
let picked = second.clone();
h.app.services.pick_save = Some(Box::new(move |_, _| {
let path = picked.to_string_lossy().into_owned();
Box::pin(async move { Some(path) })
}));
h.app.start("file.save_as");
h.frames(2);
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&second).unwrap()).unwrap();
assert_eq!(serde_json::to_value(&reopened.manufacturing).unwrap(), before);
assert_eq!(reopened.features.len(), 2);
assert!(!h.app.session.is_dirty());
}
#[test]
fn quick_save_uses_key_event_modifiers_after_same_frame_release() {
for modifiers in [egui::Modifiers { command: true, mac_cmd: true, ..Default::default() }, egui::Modifiers::CTRL] {
let mut h = Harness::new();
h.app.home.open = false;
let calls = Rc::new(Cell::new(0));
let count = calls.clone();
h.app.services.pick_save = Some(Box::new(move |_, _| {
count.set(count.get() + 1);
Box::pin(async { None })
}));
let modifiers = egui::Modifiers { command: true, ..modifiers };
let raw = egui::RawInput {
focused: true,
events: vec![
egui::Event::ModifiersChanged(modifiers),
egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::ModifiersChanged(egui::Modifiers::default()),
],
..Default::default()
};
let _ = h.ctx.run_ui(raw, |ui| crate::toolbar::shortcuts(&mut h.app, ui.ctx()));
assert_eq!(calls.get(), 1, "the Save picker must open, rather than plain-S command search");
}
}
#[test]
fn interactive_open_uses_background_loader_and_keeps_current_design_on_cancel() {
let mut h = Harness::new();
manufacturing(&mut h);
let path = h.dir.join("cam.solidcraft");
h.app.run("file.save", json!({"path":path})).unwrap();
let before = serde_json::to_value(&h.app.session.doc.manufacturing).unwrap();
h.app.services.pick_open = Some(Box::new(|| Box::pin(async { None })));
h.app.start("doc.open");
h.frames(2);
assert_eq!(serde_json::to_value(&h.app.session.doc.manufacturing).unwrap(), before);
crate::documents::new_design(&mut h.app);
let picked = path.clone();
h.app.services.pick_open = Some(Box::new(move || {
let path = picked.to_string_lossy().into_owned();
Box::pin(async move { Some(path) })
}));
h.app.start("doc.open");
h.frames(2);
let started = std::time::Instant::now();
while h.app.file_picker_busy() || h.app.file_load.busy() {
h.frames(1);
assert!(started.elapsed().as_secs() < 30);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(serde_json::to_value(&h.app.session.doc.manufacturing).unwrap(), before);
assert_eq!(h.app.session.path.as_deref(), path.to_str());
}
+37 -1
View File
@@ -65,6 +65,7 @@ pub mod install;
pub mod issue_report;
pub mod joint_drag;
pub mod keymap;
pub mod manufacturing;
mod material_card_review;
#[cfg(test)]
mod measurement_ui_tests;
@@ -107,6 +108,8 @@ pub(crate) mod display_lod;
#[cfg(test)]
mod file_load_ui_tests;
#[cfg(test)]
mod file_ui_tests;
#[cfg(test)]
mod fillet_spline_handle_tests;
pub(crate) mod navigation;
pub mod palette;
@@ -271,6 +274,7 @@ pub struct Services {
}
pub struct SolveApp {
pub manufacturing: manufacturing::State,
pub session: Session,
pub ui: UiState,
pub cam: Camera,
@@ -420,6 +424,7 @@ impl SolveApp {
file_picker: None,
viewport: viewport::ViewportState::default(),
render_workspace,
manufacturing: Default::default(),
tool: None,
dialog: None,
params: None,
@@ -709,6 +714,31 @@ impl SolveApp {
self.set_status(format!("{label}: {why}"), true);
return;
}
match id {
"file.save" => {
self.home.open = false;
return toolbar::save(self);
}
"file.save_as" => {
self.home.open = false;
return toolbar::save_as(self);
}
"doc.open" => {
self.pick_open(|app, path| {
if let Some(path) = path {
app.open_path_async(&path);
}
});
return;
}
_ => {}
}
manufacturing::cancel_editor(self);
if id.starts_with("cam.") {
self.home.open = false;
manufacturing::start(self, id);
return;
}
if id == "material.properties.validate" {
self.material_card_review = Some(material_card_review::ReviewWindow::default());
self.home.open = false;
@@ -1221,6 +1251,9 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
manufacturing::update_exports(self, ctx);
#[cfg(target_arch = "wasm32")]
manufacturing::advance(self, ctx);
file_picker::tick(self, ctx);
render_workspace::sync_backend(self);
render_workspace::tick(self, ctx);
@@ -1358,7 +1391,9 @@ impl SolveApp {
timeline::timeline(self, ui);
}
palette::palette_bar(self, ui);
if self.ui.show_browser {
if self.ui.tab == "MILLING" {
manufacturing::panel(self, ui);
} else if self.ui.show_browser {
if left_timeline {
browser::browser_below_timeline(self, ui);
} else {
@@ -1380,6 +1415,7 @@ impl SolveApp {
}
});
dialogs::show(self, ui.ctx());
manufacturing::editor(self, ui.ctx());
issue_report::CadInput::block(self, ui, report_input);
sketch_palette::show(self, ui.ctx());
self.resolve_fit(ui.ctx());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
//! Clock-independent cooperative scheduling; the web host supplies its deadline.
/// Poll a round-robin sequence until all jobs settle or the host budget expires.
/// `poll` returns true for pending work. The count cap also bounds a stalled clock.
pub(super) fn tick(cursor: &mut usize, count: usize, mut poll: impl FnMut(usize) -> bool, mut has_time: impl FnMut() -> bool) -> bool {
if count == 0 {
*cursor = 0;
return false;
}
let mut idle = 0;
for _ in 0..65_536 {
if !has_time() {
return true;
}
let index = *cursor % count;
*cursor = (index + 1) % count;
if poll(index) {
idle = 0;
} else {
idle += 1;
if idle == count {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_slices_do_not_starve_jobs_and_settled_jobs_stop_repainting() {
let mut cursor = 0;
let mut remaining = [7_usize, 1, 5];
let mut order = Vec::new();
for _ in 0..30 {
let mut budget = 1;
let pending = tick(
&mut cursor,
remaining.len(),
|i| {
order.push(i);
remaining[i] = remaining[i].saturating_sub(1usize);
remaining[i] > 0
},
|| {
let more = budget > 0;
budget -= usize::from(more);
more
},
);
assert!(pending);
}
assert!(order.as_chunks::<3>().0.iter().all(|chunk| *chunk == [0, 1, 2]));
assert_eq!(remaining, [0; 3]);
assert!(!tick(&mut cursor, 3, |_| false, || true));
// Removing completed jobs must tolerate a cursor past the new length.
cursor = 12;
assert!(!tick(
&mut cursor,
1,
|i| {
assert_eq!(i, 0);
false
},
|| true
));
assert!(!tick(&mut cursor, 0, |_| true, || true));
}
#[test]
fn clock_stall_and_empty_budget_are_bounded() {
let mut cursor = 0;
let mut polls = 0;
assert!(tick(
&mut cursor,
2,
|_| {
polls += 1;
true
},
|| false
));
assert_eq!(polls, 0);
assert!(tick(
&mut cursor,
2,
|_| {
polls += 1;
true
},
|| true
));
assert_eq!(polls, 65_536);
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Manufacture display choices. Geometry comes from immutable engine jobs;
//! this module changes neither the model nor the verification result.
use super::*;
use crate::theme::Tokens;
use solidcraft_engine::{
geom::{Aabb3, Mesh},
render::{Rgb, Scene, SceneMesh},
};
use std::{
collections::BTreeSet,
hash::{Hash, Hasher},
sync::Arc,
};
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)]
pub enum Workpiece {
#[default]
Model,
InitialStock,
RemainingStock,
}
pub struct View {
pub workpiece: Workpiece,
pub fixtures: bool,
pub paths: bool,
pub boundary: bool,
pub speed: f64,
pub loaded_job: Option<u64>,
pub fraction: f64,
}
impl Default for View {
fn default() -> Self {
Self { workpiece: Workpiece::Model, fixtures: true, paths: true, boundary: true, speed: 60., loaded_job: None, fraction: 0. }
}
}
pub fn setup(app: &SolveApp) -> Option<&cam::Setup> {
if let Some(Editor::Setup(setup)) = &app.manufacturing.editor {
return Some(setup);
}
let m = &app.session.doc.manufacturing;
let program = m.programs.iter().find(|p| p.id == app.manufacturing.program).or_else(|| m.programs.first());
program.and_then(|p| m.setups.iter().find(|s| s.id == p.setup)).or_else(|| m.setups.first())
}
pub fn corners(stock: cam::Stock) -> [Vec3; 8] {
let a = stock.min;
let b = stock.max;
[
Vec3::new(a.x, a.y, a.z),
Vec3::new(b.x, a.y, a.z),
Vec3::new(b.x, b.y, a.z),
Vec3::new(a.x, b.y, a.z),
Vec3::new(a.x, a.y, b.z),
Vec3::new(b.x, a.y, b.z),
Vec3::new(b.x, b.y, b.z),
Vec3::new(a.x, b.y, b.z),
]
}
pub fn bounds(app: &SolveApp) -> Aabb3 {
let mut bounds = Aabb3::EMPTY;
if app.ui.tab != "MILLING" {
return bounds;
}
if let Some(setup) = setup(app).filter(|s| s.validate().is_ok()) {
for p in corners(setup.stock) {
bounds.add(setup.frame.world(p));
}
for p in corners(cam::Stock { min: setup.stock.min, max: Vec3::new(setup.stock.max.x, setup.stock.max.y, setup.clearance) }) {
bounds.add(setup.frame.world(p));
}
}
bounds
}
pub fn fresh_job(app: &SolveApp) -> Option<Arc<solidcraft_engine::manufacturing::Job>> {
let job = solidcraft_engine::manufacturing::job(&app.session, app.manufacturing.job?).ok()?;
solidcraft_engine::manufacturing::current(&app.session, &job).ok()?;
Some(job)
}
pub fn stock(app: &SolveApp) -> Option<Arc<Mesh>> {
if app.ui.tab != "MILLING" || matches!(app.manufacturing.editor, Some(Editor::Setup(_))) {
return None;
}
match app.manufacturing.view.workpiece {
Workpiece::Model => None,
Workpiece::RemainingStock => fresh_job(app)?.stock_mesh().ok().flatten(),
Workpiece::InitialStock => {
let setup = setup(app).filter(|s| s.validate().is_ok())?;
let points = corners(setup.stock).map(|p| setup.frame.world(p));
let mut mesh = Mesh::default();
for face in [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]] {
for indices in [[face[0], face[1], face[2]], [face[0], face[2], face[3]]] {
let t = indices.map(|i| points[i]);
let n = (t[1] - t[0]).cross(t[2] - t[0]).normalized().unwrap_or(Vec3::Z);
let base = mesh.positions.len() as u32;
mesh.positions.extend(t);
mesh.normals.extend([n; 3]);
mesh.triangles.push([base, base + 1, base + 2]);
}
}
Some(Arc::new(mesh))
}
}
}
fn names(app: &SolveApp, refs: &[cam::BodyRef]) -> BTreeSet<String> {
refs.iter()
.filter_map(|r| {
if r.occurrence.is_empty() {
Some(r.body.clone())
} else {
app.session.occurrence_placement(&r.occurrence).ok()?.bodies.into_iter().find(|b| b.source == r.body).map(|b| b.world)
}
})
.collect()
}
pub struct Bodies {
models: BTreeSet<String>,
fixtures: BTreeSet<String>,
active: bool,
show_model: bool,
show_fixtures: bool,
}
impl Bodies {
pub fn new(app: &SolveApp) -> Self {
let setup = setup(app).filter(|_| app.ui.tab == "MILLING");
Self {
models: setup.map(|s| names(app, &s.models)).unwrap_or_default(),
fixtures: setup.map(|s| names(app, &s.fixtures)).unwrap_or_default(),
active: setup.is_some() && !matches!(app.manufacturing.editor, Some(Editor::Setup(_))),
show_model: stock(app).is_none(),
show_fixtures: app.manufacturing.view.fixtures,
}
}
pub fn visible(&self, name: &str) -> bool {
!self.active || (self.fixtures.contains(name) && self.show_fixtures) || (self.models.contains(name) && self.show_model)
}
pub fn color(&self, name: &str) -> Option<Color32> {
(self.active && self.fixtures.contains(name)).then_some(Tokens::get().cam_fixture)
}
}
pub fn key(app: &SolveApp, h: &mut impl Hasher) {
let active = app.ui.tab == "MILLING";
active.hash(h);
if !active {
return;
}
matches!(app.manufacturing.editor, Some(Editor::Setup(_))).hash(h);
if let Some(Editor::Setup(setup)) = &app.manufacturing.editor {
for r in &setup.fixtures {
r.body.hash(h);
r.occurrence.hash(h);
}
}
app.manufacturing.program.hash(h);
app.manufacturing.job.hash(h);
app.manufacturing.view.workpiece.hash(h);
app.manufacturing.view.fixtures.hash(h);
fresh_job(app).and_then(|j| j.stock_mesh().ok().flatten()).map(|m| Arc::as_ptr(&m) as usize).hash(h);
}
pub fn gpu(app: &SolveApp, scene: &mut crate::gpu::GpuScene) {
let Some(mesh) = stock(app) else { return };
let color = Tokens::get().cam_stock.to_array();
for indices in &mesh.triangles {
for i in indices {
if let (Some(p), Some(n)) = (mesh.positions.get(*i as usize), mesh.normals.get(*i as usize)) {
scene.tri(p.to_f32(), n.to_f32(), color);
}
}
}
}
pub fn cpu(app: &SolveApp, scene: &mut Scene) {
if app.ui.tab != "MILLING" {
return;
}
// Replace the model mesh list with the selected machining bodies, preserving
// occurrence meshes and applying the same visibility/color choices as GPU rendering.
let bodies = Bodies::new(app);
let world = app.session.world_state();
scene.meshes.clear();
for body in &world.bodies {
if !app.session.body_visible(&body.name) || !bodies.visible(&body.name) {
continue;
}
let c = bodies.color(&body.name).unwrap_or(Tokens::get().body);
scene.meshes.push(SceneMesh { mesh: body.mesh(), color: Rgb(c.r(), c.g(), c.b()), face_colors: vec![] });
}
// CAD edge lines would reveal hidden target geometry inside the stock preview.
// CAM outlines and paths are drawn by the shared overlay afterwards.
scene.lines.clear();
if let Some(mesh) = stock(app) {
let c = Tokens::get().cam_stock;
scene.meshes.push(SceneMesh { mesh, color: Rgb(c.r(), c.g(), c.b()), face_colors: vec![] });
}
scene.radius = scene.radius.max(bounds(app).diagonal());
}
/// Complete motion blocks. Zero is the initial pose, never a sentinel for all.
pub fn advance(state: &mut State, count: usize, dt: f64) {
if state.view.loaded_job != state.job {
state.view.loaded_job = state.job;
state.playback = count;
state.playing = false;
state.view.fraction = 0.;
}
state.playback = state.playback.min(count);
if !state.playing {
return;
}
state.view.fraction += dt.clamp(0., 0.1) * state.view.speed.clamp(1., 1000.);
let steps = state.view.fraction.floor() as usize;
state.view.fraction -= steps as f64;
state.playback = state.playback.saturating_add(steps).min(count);
if state.playback == count {
state.playing = false;
state.view.fraction = 0.;
}
}
/// Draw the same motion prefix for native overlays and CPU snapshots.
pub fn segments(path: &cam::Toolpath, setup: &cam::Setup, completed: usize, mut line: impl FnMut(Vec3, Vec3, bool)) {
let mut pos = Vec3::new(0., 0., setup.clearance);
for block in path.blocks.iter().take(completed) {
match block.motion {
cam::Motion::Rapid { to } | cam::Motion::Linear { to, .. } => {
line(setup.frame.world(pos), setup.frame.world(to), matches!(block.motion, cam::Motion::Rapid { .. }));
pos = to;
}
cam::Motion::Arc { to, center, clockwise, .. } => {
let start = (pos.y - center.y).atan2(pos.x - center.x);
let end = (to.y - center.y).atan2(to.x - center.x);
let delta = (end - start).rem_euclid(std::f64::consts::TAU);
let sweep = if clockwise {
if delta < 1e-12 { -std::f64::consts::TAU } else { delta - std::f64::consts::TAU }
} else if delta < 1e-12 {
std::f64::consts::TAU
} else {
delta
};
let radius = (pos.x - center.x).hypot(pos.y - center.y);
let angle = (2. * (1. - 0.02 / radius).clamp(-1., 1.).acos()).max(0.001);
let n = (sweep.abs() / angle).ceil().clamp(1., 512.) as usize;
let mut previous = pos;
for i in 1..=n {
let theta = start + sweep * i as f64 / n as f64;
let p = if i == n { to } else { Vec3::new(center.x + radius * theta.cos(), center.y + radius * theta.sin(), to.z) };
line(setup.frame.world(previous), setup.frame.world(p), false);
previous = p;
}
pos = to;
}
_ => {}
}
}
}
pub fn severity_color(severity: cam::verify::FindingSeverity) -> Color32 {
match severity {
cam::verify::FindingSeverity::Error => Tokens::get().error,
cam::verify::FindingSeverity::Unresolved => Tokens::get().warning,
cam::verify::FindingSeverity::Information => Tokens::get().cam_stock,
}
}
pub fn finding_marker(app: &SolveApp) -> Option<(Vec3, Color32)> {
let index = app.manufacturing.finding?;
let job = fresh_job(app)?;
let JobState::Verified(_, report) = job.state().ok()? else { return None };
let finding = report.findings.get(index)?;
Some((job.input().ok()?.setup.frame.world(finding.at), severity_color(finding.severity)))
}
pub fn cpu_overlays(app: &SolveApp, scene: &mut Scene) {
if app.ui.tab != "MILLING" {
return;
}
let tokens = Tokens::get();
let mut line = |a: Vec3, b: Vec3, c: Color32| {
scene.lines.push(solidcraft_engine::render::SceneLine { points: vec![a, b], color: Rgb(c.r(), c.g(), c.b()), width: 1.2, on_top: true })
};
if app.manufacturing.view.boundary
&& let Some(setup) = setup(app)
{
let points = corners(setup.stock).map(|p| setup.frame.world(p));
for (a, b) in [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)] {
line(points[a], points[b], tokens.cam_stock);
}
}
if let Some((point, color)) = finding_marker(app) {
let size = setup(app).map_or(1., |s| (s.stock.max - s.stock.min).len() / 100.);
for delta in [Vec3::X * size, Vec3::Y * size, Vec3::Z * size] {
line(point - delta, point + delta, color);
}
}
if !app.manufacturing.view.paths {
return;
}
let Some(job) = fresh_job(app) else { return };
let Ok(input) = job.input() else { return };
let path = match job.state() {
Ok(JobState::Generated(p) | JobState::Verified(p, _)) => p,
_ => return,
};
segments(&path, &input.setup, app.manufacturing.playback, |a, b, rapid| line(a, b, if rapid { tokens.cam_rapid } else { tokens.cam_cut }));
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn playback_zero_is_initial_and_rate_is_independent_of_frames() {
let mut s = State { job: Some(1), ..Default::default() };
advance(&mut s, 100, 0.);
assert_eq!(s.playback, 100);
s.playback = 0;
advance(&mut s, 100, 0.1);
assert_eq!(s.playback, 0);
for fps in [10, 30, 60] {
s.playback = 0;
s.playing = true;
s.view.fraction = 0.;
for _ in 0..fps {
advance(&mut s, 100, 1. / fps as f64);
}
assert_eq!(s.playback, 60);
}
s.job = Some(2);
advance(&mut s, 10, 0.1);
assert_eq!(s.playback, 10);
assert!(!s.playing);
}
#[test]
fn full_circle_playback_preserves_direction_and_empty_prefix() {
let input = cam::corpus::fixture("face").unwrap();
for clockwise in [true, false] {
let path = cam::Toolpath {
fingerprint: "test".into(),
blocks: vec![
cam::Block { operation: 1, motion: cam::Motion::Rapid { to: Vec3::new(1., 0., 0.) } },
cam::Block { operation: 1, motion: cam::Motion::Arc { to: Vec3::new(1., 0., 0.), center: Vec3::ZERO, clockwise, feed: 100. } },
],
};
let mut lines = Vec::new();
segments(&path, &input.setup, 0, |a, b, _| lines.push((a, b)));
assert!(lines.is_empty());
segments(&path, &input.setup, 2, |a, b, rapid| {
if !rapid {
lines.push((a, b))
}
});
assert!(lines.len() > 8);
assert_eq!(lines.last().unwrap().1, Vec3::new(1., 0., 0.));
assert_eq!(lines[0].1.y < 0., clockwise);
let length: f64 = lines.iter().map(|(a, b)| a.dist(*b)).sum();
assert!((length - std::f64::consts::TAU).abs() < 0.1);
}
}
#[test]
fn stock_display_uses_setup_frame_and_respects_fixture_visibility() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":20,"width":20,"height":5})).unwrap();
h.app.run("solid.box", json!({"length":3,"width":3,"height":3})).unwrap();
let names: Vec<_> = h.app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect();
let mut input = cam::corpus::fixture("face").unwrap();
input.setup.models[0].body = names[0].clone();
input.setup.fixtures = vec![cam::BodyRef { body: names[1].clone(), occurrence: vec![] }];
input.setup.frame = cam::Frame { origin: Vec3::new(40., 30., 2.), x: Vec3::Y, y: -Vec3::X, z: Vec3::Z };
h.app.run("cam.setup.put", json!({"setup":input.setup})).unwrap();
h.app.ui.tab = "MILLING".into();
h.app.manufacturing.view.workpiece = Workpiece::InitialStock;
let mesh = stock(&h.app).unwrap();
let b = mesh.bounds();
assert_eq!(b.min, Vec3::new(20., 30., 2.));
assert_eq!(b.max, Vec3::new(40., 50., 8.));
assert!((mesh.measure().volume - 2400.).abs() < 1e-8);
let bodies = Bodies::new(&h.app);
assert!(!bodies.visible(&names[0]));
assert!(bodies.visible(&names[1]));
assert_eq!(bodies.color(&names[1]), Some(Tokens::get().cam_fixture));
h.app.manufacturing.view.fixtures = false;
assert!(!Bodies::new(&h.app).visible(&names[1]));
let mut scene = solidcraft_engine::view::scene(&h.app.session, &h.app.cam);
cpu(&h.app, &mut scene);
assert_eq!(scene.meshes.len(), 1);
assert_eq!(scene.meshes[0].mesh.bounds().min, b.min);
h.app.manufacturing.editor = Some(Editor::Setup(input.setup));
assert!(Bodies::new(&h.app).visible(&names[1]));
assert!(stock(&h.app).is_none());
}
}
+6
View File
@@ -207,6 +207,12 @@ pub struct Tokens {
pub preview_sketch: Color32,
/// Removed material in a feature preview (translucent).
pub preview_cut: Color32,
// Manufacturing display.
pub cam_stock: Color32,
pub cam_fixture: Color32,
pub cam_cut: Color32,
pub cam_rapid: Color32,
pub cam_tool: Color32,
/// Navigation cube faces, outline and labels.
pub cube_face: Color32,
pub cube_border: Color32,
+19 -8
View File
@@ -51,6 +51,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
return p;
}
match tab {
"MILLING" => &["SETUP", "TOOLPATHS", "VERIFY", "EXPORT"],
"SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
"SKETCH" => &["CREATE", "MODIFY", "PATTERN", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
"SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
@@ -545,7 +546,7 @@ pub fn save(app: &mut SolveApp) {
}
}
fn save_as(app: &mut SolveApp) {
pub(crate) fn save_as(app: &mut SolveApp) {
save_as_with_extension(app, solidcraft_engine::io::DESIGN_EXT);
}
@@ -601,7 +602,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
app.tr_label(if crate::render_workspace::active(app) { "RENDER" } else { crate::toolbar_custom::WORKSPACE }),
app.tr_label(if app.ui.tab == "MILLING" {
"Manufacture"
} else if crate::render_workspace::active(app) {
"RENDER"
} else {
crate::toolbar_custom::WORKSPACE
}),
FontId::proportional(12.0),
t.text,
);
@@ -617,7 +624,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
if sketching {
tabs.push("SKETCH");
}
if crate::render_workspace::active(app) {
if app.ui.tab == "MILLING" {
tabs.push("MILLING");
} else if crate::render_workspace::active(app) {
tabs.push("RENDER");
} else {
tabs.extend(TABS.iter());
@@ -1292,10 +1301,12 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
if ctx.egui_wants_keyboard_input() || crate::dim_view::editing() || crate::dialogs_save_mesh::popup_open(ctx) {
return;
}
let (keys, mods) = ctx.input(|i| {
let keys: Vec<egui::Key> =
i.events.iter().filter_map(|e| if let egui::Event::Key { key, pressed: true, .. } = e { Some(*key) } else { None }).collect();
(keys, i.modifiers)
// A modifier released later in this frame must not turn Cmd+S into plain S.
let keys: Vec<_> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
.collect()
});
// Home hides the design: only New, Open and Preferences work there.
let shown = design_shown(app);
@@ -1304,7 +1315,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
} else {
crate::edit_clipboard::flush(ctx);
}
for k in keys {
for (k, mods) in keys {
use egui::Key;
if shown && crate::navigation::view_key(app, ctx, k, mods) {
continue;
+45 -14
View File
@@ -280,6 +280,7 @@ pub fn view_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 {
fn scene_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
app.session.revision.hash(&mut h);
crate::manufacturing::view::key(app, &mut h);
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
app.ui.show_grid.hash(&mut h);
app.ui.visual_style.hash(&mut h);
@@ -617,6 +618,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
let s = &app.session;
// Bodies where their occurrences place them.
let st = s.world_state();
let cam_bodies = crate::manufacturing::view::Bodies::new(app);
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
let (shaded, edges) = match app.ui.visual_style {
1 => (true, false),
@@ -633,16 +635,17 @@ fn build_scene(app: &SolveApp) -> GpuScene {
let mut run: Vec<BodyDraw> = Vec::new();
let mut run_key = std::collections::hash_map::DefaultHasher::new();
for b in &st.bodies {
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) || !cam_bodies.visible(&b.name) {
continue;
}
let faded = body_faded(app, b, &active_bodies);
let base = body_color(&s.doc, b).unwrap_or(tk.body);
let cam_color = cam_bodies.color(&b.name);
let base = cam_color.or_else(|| body_color(&s.doc, b)).unwrap_or(tk.body);
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
// Appearances: the body's opacity, and faces with looks of their own.
let opacity = s.doc.model_body_opacity(b);
let face_looks: std::collections::HashMap<usize, solidcraft_engine::doc::appearance::Look> =
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
if shaded && cam_color.is_none() { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999);
let planned = drawn_plan(app, b, opaque);
let on_gpu = planned.is_some();
@@ -673,13 +676,14 @@ fn build_scene(app: &SolveApp) -> GpuScene {
if let Ok(mut b) = app.viewport.blocks.lock() {
*b = made;
}
crate::manufacturing::view::gpu(app, &mut sc);
if app.ui.ground_shadow && app.ui.visual_style != 2 && app.cam.pitch > 0.0 {
ground_shadow(&mut sc, app, &st);
}
// Section: the outline of each body where the plane cuts it.
if let Some((o, n)) = section_plane(app) {
let col = c4(tk.text.gamma_multiply(0.9));
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) {
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && cam_bodies.visible(&b.name)) {
for (a, c) in solidcraft_engine::view::section_segments(&b.mesh(), o, n) {
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
}
@@ -787,8 +791,12 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &std::sync::Arc<solidcra
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
let mut b = solidcraft_engine::geom::Aabb3::EMPTY;
let boxes = body_bounds(app, st);
for (x, (_, bb)) in st.bodies.iter().zip(boxes.iter()) {
if bb.is_empty() || !app.session.body_visible(&x.name) {
let bodies = crate::manufacturing::view::Bodies::new(app);
let stock = crate::manufacturing::view::stock(app);
let visible =
st.bodies.iter().zip(boxes.iter()).filter(|(x, _)| app.session.body_visible(&x.name) && bodies.visible(&x.name)).map(|(_, (_, bb))| *bb);
for bb in visible.chain(stock.iter().map(|mesh| mesh.bounds())) {
if bb.is_empty() {
continue;
}
for i in 0..8 {
@@ -1010,7 +1018,9 @@ type BodyBounds = (std::sync::Arc<solidcraft_engine::doc::ModelState>, std::sync
/// The model's extent: bodies (from the cached boxes) and sketches.
pub fn model_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 {
let st = app.session.world_state();
let mut b = body_bounds(app, &st).iter().fold(solidcraft_engine::geom::Aabb3::EMPTY, |a, (_, x)| a.union(x));
let bodies = crate::manufacturing::view::Bodies::new(app);
let mut b =
body_bounds(app, &st).iter().filter(|(name, _)| bodies.visible(name)).fold(crate::manufacturing::view::bounds(app), |a, (_, x)| a.union(x));
for ss in &st.sketches {
if let Some((lo, hi)) = ss.sketch.bounds() {
for p in [lo, hi] {
@@ -1083,6 +1093,7 @@ pub(crate) fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit
v.to_bits().hash(&mut h);
}
app.session.revision.hash(&mut h);
crate::manufacturing::view::key(app, &mut h);
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
app.session.active_sketch.hash(&mut h);
crate::sketch3d::drawing(app).hash(&mut h);
@@ -1179,6 +1190,7 @@ impl<T: PartialOrd + Copy> Near<T> {
}
fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec<Hit> {
let cam_bodies = crate::manufacturing::view::Bodies::new(app);
let s = &app.session;
let st = s.world_state();
let bounds = body_bounds(app, &st);
@@ -1248,7 +1260,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
let mut bestf: Option<(f64, Hit)> = None;
let mut faces = Near::new(all);
for b in &st.bodies {
if !app.session.body_visible(&b.name) {
if !app.session.body_visible(&b.name) || !cam_bodies.visible(&b.name) {
continue;
}
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped.
@@ -1280,7 +1292,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
let mut bestv = Near::new(all);
let mut beste = Near::new(all);
for b in &st.bodies {
if !app.session.body_visible(&b.name) {
if !app.session.body_visible(&b.name) || !cam_bodies.visible(&b.name) {
continue;
}
// Only bodies whose outline on screen is near the cursor can have an edge under it.
@@ -1812,6 +1824,7 @@ fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32) {
fn highlight_key(app: &SolveApp) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
app.session.revision.hash(&mut h);
crate::manufacturing::view::key(app, &mut h);
serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h);
format!("{:?}", app.viewport.hover).hash(&mut h);
app.viewport.hover_feature.hash(&mut h);
@@ -1837,6 +1850,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
/// The highlight scene: origin widget, construction planes, selection and hover.
fn build_highlight(app: &SolveApp) -> GpuScene {
let cam_bodies = crate::manufacturing::view::Bodies::new(app);
let t = Tokens::get();
let mut sc = GpuScene::default();
let st = app.session.world_state();
@@ -1847,12 +1861,23 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let mut sel = Vec::new();
for item in app.highlighted() {
for piece in displayed_selection(app, &item) {
let body = match &piece {
Sel::Body { name } => Some(name),
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body),
_ => None,
};
if body.is_some_and(|name| !cam_bodies.visible(name)) {
continue;
}
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(piece);
}
}
}
let hover = app.viewport.hover.as_ref();
let hover = app.viewport.hover.as_ref().filter(|h| match h {
Hit::Face { body, .. } | Hit::Edge { body, .. } | Hit::Vertex { body, .. } => cam_bodies.visible(body),
_ => true,
});
let size = origin_size(app.cam.half_height());
// Origin planes and construction planes.
let plane_state = |name: &str| {
@@ -1980,7 +2005,8 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
// Timeline modifiers light only their resulting faces; browser rows light whole bodies.
let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default();
hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() }));
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name)) {
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name) && cam_bodies.visible(&b.name))
{
let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
let faces: Vec<_> = if whole {
(0..b.body.face_count()).collect()
@@ -2788,6 +2814,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
if app.applying.is_some() {
return;
}
let bodies = crate::manufacturing::view::Bodies::new(app);
let st = app.session.world_state();
let mut out: Vec<Sel> = Vec::new();
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };
@@ -2851,7 +2878,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
} else {
let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES);
for b in &st.bodies {
if !app.session.body_visible(&b.name) {
if !app.session.body_visible(&b.name) || !bodies.visible(&b.name) {
continue;
}
let m = b.mesh();
@@ -2938,6 +2965,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
/// Screen-space dots: the origin point, and hovered or selected body vertices.
fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
let bodies = crate::manufacturing::view::Bodies::new(app);
if app.origin_visible()
&& !app.session.visibility.hidden_origin.iter().any(|h| h == "O")
&& let Some(o) = proj.to_screen(Vec3::ZERO)
@@ -2945,13 +2973,15 @@ fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110)));
}
for x in app.highlighted() {
if let Sel::Vertex { point, .. } = x
if let Sel::Vertex { point, body, .. } = x
&& bodies.visible(&body)
&& let Some(p) = proj.to_screen(point)
{
painter.circle(p, 4.0, t.sel_vertex, Stroke::new(1.5, t.sel_edge));
}
}
if let Some(Hit::Vertex { point, .. }) = &app.viewport.hover
if let Some(Hit::Vertex { point, body, .. }) = &app.viewport.hover
&& bodies.visible(body)
&& let Some(p) = proj.to_screen(*point)
{
painter.circle(p, 4.5, t.hover_edge_halo, Stroke::new(1.5, t.hover_edge));
@@ -2986,6 +3016,7 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
/// Sketch points and the status of the active sketch (constraint glyphs: `sketch_tools`).
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
crate::manufacturing::overlay(app, painter, proj);
let t = Tokens::get();
let st = app.session.world_state();
for item in &app.session.selection {
+99
View File
@@ -0,0 +1,99 @@
//! Original native CAD cases driven through the shared UI command and widget paths.
//! Headless frames exercise layout/input; GPU snapshots exercise the stock display.
use serde_json::json;
use solidcraft_engine::cam;
use solidcraft_ui_egui::scenario::Harness;
#[test]
fn four_strategies_generate_simulate_post_and_display_through_ui() {
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, camera, width, height| {
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, camera, width, height)?;
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
}));
for name in ["face", "contour", "pocket", "drill"] {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1500,"height":1200}));
h.app.run("solid.box", json!({"length":20,"width":20,"height":if name=="face" {5} else {6}})).unwrap();
if name != "face" {
h.app.run("solid.hole",json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":if name=="drill" {1} else {16},"depth":1,"type":if name=="drill" {"drilled"} else {"simple"},"tip_angle":118})).unwrap();
}
let mut input = cam::corpus::fixture(name).unwrap();
input.setup.models[0].body = h.app.session.model.state().bodies[0].name.clone();
h.app.run("cam.setup.put", json!({"setup":input.setup})).unwrap();
if name == "contour" || name == "pocket" {
h.app.run("sketch.create", json!({"plane":"XY","name":"Pocket boundary"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[10,10],"radius":8,"id":"wall"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let sketch = h.app.session.model.state().sketches.last().unwrap().feature;
let b = h.app.run("cam.boundary.from_profile", json!({"setup":1,"sketch":sketch,"profile":0})).unwrap();
input.operations[0].source = Some(serde_json::from_value(b["source"].clone()).unwrap());
match &mut input.operations[0].geometry {
cam::Strategy::Contour { boundary, .. } | cam::Strategy::Pocket { boundary } => {
*boundary = serde_json::from_value(b["boundary"].clone()).unwrap()
}
_ => {}
}
input.operations[0].ramp_angle = Some(5_f64.to_radians());
}
if name == "drill" {
input.operations[0].geometry = cam::Strategy::Drill { holes: vec![[10., 10.]], peck: Some(0.3), full_diameter: true };
}
h.app.run("cam.tool.put", json!({"tool":input.tools[0]})).unwrap();
h.app.run("cam.operation.put", json!({"operation":input.operations[0]})).unwrap();
h.app.run("cam.program.put", json!({"program":{"id":1,"name":name,"setup":1,"operations":[1]}})).unwrap();
h.call("ui.set", json!({"tab":"MILLING","showGrid":false,"showOrigin":false,"showSketches":false}));
if name == "drill" {
let body = h.app.session.model.state().bodies[0].clone();
let face = (0..body.body.face_count()).find(|i| solidcraft_engine::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap();
h.app.session.selection =
vec![solidcraft_engine::Sel::Face { body: body.name, index: face, point: solidcraft_engine::geom::Vec3::new(10.5, 10., 5.5) }];
h.frames(3);
h.step(&json!({"widget":"Analytic plate"})).unwrap();
h.step(&json!({"widget":"drill"})).unwrap();
h.frames(20);
h.step(&json!({"widget":"Use selected holes"})).unwrap();
h.step(&json!({"widget":"Save"})).unwrap();
assert_eq!(h.app.session.doc.manufacturing.operations[0].hole_sources.len(), 1);
}
h.step(&json!({"start":"cam.generate"})).unwrap();
let id = h.app.manufacturing.job.unwrap();
wait(&mut h, id, "generated");
h.step(&json!({"start":"cam.simulate"})).unwrap();
let report = wait(&mut h, id, "verified");
assert_eq!(report["verification"]["status"], "pass", "{name}: {report}");
assert_eq!(report["verification"]["finished_dimensions"], "pass", "{name}: {report}");
h.app.run("cam.post", json!({"job":id,"path":h.dir.join(format!("{name}.nc"))})).unwrap();
h.step(&json!({"widget":"Target model"})).unwrap();
h.step(&json!({"widget":"Remaining stock"})).unwrap();
h.step(&json!({"widget":"To start"})).unwrap();
h.call("ui.view", json!({"view":"fit"}));
let out = h.call("ui.render", json!({"path":h.dir.join(format!("{name}.png")),"width":1000,"height":750}));
assert_eq!(out["ok"], true, "{out}");
let state = h.call("ui.inspect", json!({}));
assert_eq!(state["result"]["manufacturing"]["completed_motions"], 0);
assert_eq!(state["result"]["manufacturing"]["workpiece"], "RemainingStock", "{name}: {state}");
assert!(std::fs::read(h.dir.join(format!("{name}.png"))).unwrap().starts_with(b"\x89PNG"));
// Optional public-safe review evidence from these original fixtures only.
if let Some(directory) = std::env::var_os("CAM_UI_EVIDENCE") {
let directory = std::path::PathBuf::from(directory);
std::fs::create_dir_all(&directory).unwrap();
std::fs::copy(h.dir.join(format!("{name}.png")), directory.join(format!("{name}.png"))).unwrap();
}
}
}
fn wait(h: &mut Harness, id: u64, want: &str) -> serde_json::Value {
let start = std::time::Instant::now();
loop {
h.frames(1);
let state = h.app.run("cam.job.status", json!({"job":id})).unwrap();
let s = state["state"].as_str().unwrap();
if s == want {
// The worker may finish between the last frame and the status read.
// Lay out the completed-job controls before resolving widget coordinates.
h.frames(3);
return state;
}
assert!(s != "failed" && s != "cancelled", "{state}");
assert!(start.elapsed().as_secs() < 180, "{state}");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
+102
View File
@@ -0,0 +1,102 @@
# First milling milestone acceptance
Audit date: 2026-10-16. Scope: rectangular stock, fixed-axis three-axis facing,
2D contouring, pockets with islands, and simple/peck drilling. The planned workflow
is implemented and the initial four-strategy Fusion comparison gate passes.
This is machining-result equivalence for the recorded reference corpus, not a
claim about all Fusion Manufacturing features or physical controller qualification.
## Requirement-to-evidence audit
Paths below identify committed implementation and independently authored tests.
Fusion captures and their derived reports remain ignored local evidence.
| Plan requirement | Implementation and acceptance evidence |
| --- | --- |
| Pure-Rust L1 CAM core, document/engine/IO separation | `crates/cam/Cargo.toml`, `crates/doc/src/document.rs`, `crates/engine/src/manufacturing/`, `crates/io/src/cam/`; workspace layer and Wasm gates enforce dependencies. |
| Persistent setups, stock, fixtures, tools, ordered operations and programs | `cam/model.rs` validates definitions, rigid frames, IDs and program membership. The pending manufacturing format extension preserves explicit drilling coordinates; older documents receive empty manufacturing. Engine `save_undo_and_stale_generation`, `legacy_document_has_no_manufacturing`, and reopen tests assert actual values. |
| Flat mills/drills with cutting, shank and holder geometry; explicit machining settings | `cam/model.rs`, `generate.rs`, `collision.rs`; cone-depth cases distinguish tip and full-diameter depth. No inferred feeds or spindle speeds. |
| CAD bodies, occurrences, planar boundaries and selected holes | Engine snapshot and profile resolution plus `cmd/cam_holes_tests.rs` cover exact occurrence frames, edited profiles, lost/ambiguous references, wrong drill diameters and rejected tilted selections. Native recovery includes lost-profile reselection. |
| Shared commands, editing and operation ordering | `engine/cmd/cam.rs` registers editing, generation, status/cancel, inspection, simulation and export commands used by UI/CLI/MCP. `operation_order_survives_reopen_undo_redo_and_controls_generated_sequence` checks two operations, invalid permutations, undo/redo, persistence and actual block order. |
| Derived caches and stale/failed posting refusal | Fingerprints include geometry, definitions and generator version. Save/reopen rejects old job IDs; edits invalidate jobs; undo can restore freshness. Tests reject stale, cancelled, failed and inconclusive results, including completed-but-uncollected exports. |
| Immutable background generation, progress and cancellation | `engine/manufacturing.rs` tests deferred immutable preparation, worker snapshot release, preparation cancellation and no revival. Browser futures have bounded checkpoints; native preparation and exports run on workers. Native simulation cancellation is exercised. |
| Facing, contour offsets, pockets/islands, drilling | `cam/generate.rs` and original `corpus.rs` fixtures plus `corpus/{extended,through,exterior,channel}.rs`: nine recipes, fourteen operation stages. All four strategies also have complete local Fusion packages. |
| Direction, depths, allowance, entry, leads, ramps, clearances and deterministic order | Independent posted-NC direction tests in `cam/tests/milling_direction.rs`; multi-stage rough/finish recipes, both contour sides, shifted origins, tool changes, through holes and pecks. Inaccessible necks, oversized tools and protected-geometry lead gouges fail. |
| Bounded Cavalier adapter; degeneracy and curve accuracy | Exact `cavalier_contours 0.9.0` pin with finite/size checks, crossing/touch/backtracking rejection, winding/island cases, dense convex/concave and translated/scaled regressions. CAD curve sampling and fixed-tolerance meshing reject exhausted accuracy budgets. See `cam-crates.md`. |
| Typed motions/events and provenance | `cam/model.rs`, NC post/reader and engine inspection retain operation/block/record links. `diagnostic_inspection_identifies_posted_operation_and_rejects_stale_results` checks actual NC line, operation and transformed world point. |
| Manufacture workspace, tree, dialogs, stock/fixtures, paths and playback | `ui-egui/src/manufacturing.rs` and `ui-egui/tests/cam_workflow.rs`, plus four original native strategy walkthroughs. Playback shows motion and machine state; initial/final stock is displayed. Recovery captures cover reselection, fixture issue location/correction, cancellation and stale edits. |
| Adaptive stock simulation; cutter/shank/holder/fixture and rapid checks | `cam/verify.rs`, `collision.rs`, `dimensions.rs`, `surface.rs` use independent NC and conservative bounds. Tests cover thin swept obstacles, stationary overlap, enclosed fixtures/cavities, insufficient cutting length, holder/shank interference and rapid-through-stock. Unknown topology or exhausted budgets remain inconclusive. |
| Explicit reference post; arcs and no implicit controller behavior | `cam/nc.rs` initializes units, absolute coordinates, plane, arc-center mode, feed mode, work offset, compensation/cycle cancellation and spindle/coolant state. Drilling is expanded and compensation geometric. `machine_state.rs` checks modal/tool/spindle/coolant semantics independently. Unsupported words fail. |
| Five exports with provenance and no stale publication | IO emits NC, toolpath JSON, setup HTML, stock STL and validation JSON. Serializer tests check all JSON fields, HTML escaping and exact NC/STL bytes. Background tests assert no file before collection, single collection, cancellation and stale rejection. Native all-format export and actual system-dialog posting pass. |
| Reproducible Fusion packages and clean-room storage | `xtask/src/cam/reference.rs` requires capture identity/version, capabilities, post identity/hash/properties, source geometry, reconciled inputs, NC, setup values/HTML, stock, simulation findings and hashes. All four required local packages are complete. No Fusion-produced files are committed. |
| Input equality before comparison | Oracle requires matching stock, frames, tools, operations and budgets, recomputes fingerprints and checks artifact hashes. Mismatches invalidate the case. |
| Independent G-code semantics and post round trip | Strict parser consumes every word; hand-authored modal programs, equivalent inch/mm positions, opposite arc sweeps, unknown words and stopped-spindle cuts test semantics. Round trip compares pre-post IR and interpreted NC within 0.001 mm, including state transitions and startup records. |
| Machined result and local surface distances | Both programs use the same independent verifier. `compare.rs` compares SolidCraft NC/Fusion NC, SolidCraft NC/Fusion STL, and Fusion NC/Fusion STL directly. Protected geometry and allowed rough stock are checked per stage; finished regions require dimensional pass. Small local gouges cannot be hidden by aggregate volume. |
| Verifier qualification and deliberate fault injection | Hand-calculated face volume and drill-cone heights; independent analytic probes, missing cuts/holes, shifted holes, wrong tools/depths, unsafe rapids and malformed meshes. Coverage gaps and unresolved intervals cannot pass. Stock exports are reimported and compared to conservative intervals. |
| Setup values, order and metrics | Oracle checks setup/tool/operation values and order, records cutting/rapid distance, retracts, feed-time estimates and volume intervals. HTML includes overlays and line-linked failures; JSON retains findings and provenance. |
| Ordinary CI and local oracle gates | `cargo xtask cam-validate` runs nine recipes/fourteen stages in CI. `cargo xtask cam-oracle --dir …` requires all four strategies and fails missing/incomplete packages. Full CI includes formatting, clippy, tests, assets, layers and portable-crate Wasm checks. |
## Upstream integration
The historical acceptance below was recorded before rebasing onto upstream.
The current integration includes `650ba567` (0.14.0 and subsequent merges) and adopts SolidCraft naming, pending document migrations,
copy-on-write sessions, asynchronous file dialogs and the current viewport pipeline.
Fresh analytic acceptance passes all nine recipes/fourteen stages. Input reconciliation
and the independent oracle were rerun against the same immutable Fusion captures:
all four packages pass. The rebased native facing workflow passes generation,
simulation, posting, save/reopen and regeneration. Its actual Manufacture window
was inspected, including blue cutting paths, amber clearances and Pass/Pass status.
First Save changes the design name and conservatively invalidates the prior cache;
regeneration restores freshness. On the intermediate `9d82b944` base, formatting,
Clippy, assets, layers and the 12-crate Wasm check passed. The full native run found
UI integration regressions; their fixes are included, but local reruns were stopped
after disk exhaustion and severe host overload. Final validation on `650ba567` is
pending in upstream PR #1682. Its initial locked-build failure was corrected for
the 0.14.0 release, and profile-sampling tests now cover upstream's degree-9 Beziers.
Historical timings and captures alone do not qualify these integration changes.
## Recorded local acceptance
The four complete facing/contour/pocket/drill packages pass input reconciliation,
both programs' clearance and finished-dimension checks, and all three stock
comparisons per case. Acceptance remains **0.01 mm**, with **0.001 mm** post error
and **0.0025 mm** verifier uncertainty budgets. The latest report's stock hashes
match the current files. Contour/pocket/drill were re-exported at maximum Fusion
Stock Accuracy after playback to completion; the user confirmed no collisions.
Fusion's unexposed numeric resolution remains `not_reported`, not a fabricated value.
Local reproducibility locations (deliberately untracked):
- `plan/fusion/cam/capture-recovered/comparison/report.{json,html}`: four cases,
generator `cam/18`, comparator `cam-oracle/2`, post `solidcraft-reference-3axis/2`.
- `target/cam-native-acceptance/`: native four-strategy create/generate/simulate/
post/reopen/edit/regenerate workflows.
- `target/cam-native-recovery/`: reference, collision, cancellation and stale-edit
recovery captures. Preparation cancellation is an engine test, not a manual UI claim.
- `target/cam-native-export/artifact-manifest.json`: all five background exports.
- `target/cam-native-audit/save-acceptance.json`: actual native Save dialog created
a 146,348-byte NC file identical to the registered-command export. Job status
remained fresh, verified and Pass/Pass with export idle after UI collection.
Post-save screenshot capture was unavailable; this does not claim a fresh screenshot.
- `target/cam-web-probe/`: original release-Wasm timing and cancellation probes.
## Bounded scope and computational limits
Browser execution is cooperative, with finite input, work, motion, mesh and output
caps. The four-ms scheduler target is checked between polls, not a hard deadline.
Measured opaque steps on the validation machine remain about 21.2 ms for a dense
offset, 90.8 ms for a radius-5000 mm body tessellation, and 8 ms for a long single
reference encoding. Those are explicit latency limits of this implementation;
they do not weaken physical tolerances or permit partial results. Incremental
large-artifact probes measured batches at most 4.3 ms. Timings are machine-specific.
Verification is for vertical height-field machining. It refines uncertainty and
fails closed when limits prevent a conclusion. Initial pose is a declared tool-tip
position at clearance; travel from an unknown physical machine pose is outside
the reference dialect. Final stock display is not animated stock removal at each
playback frame. Finishing uses explicit zero-allowance operations after roughing.
These limits satisfy the bounded first workflow; they are not general Fusion or
machine certification. Adaptive clearing, 3D roughing/finishing, indexed/multi-axis
machining, turning, additive, inspection and manufacturing extensions remain later
or excluded scope. New supported geometry and post dialects need their own fixtures.
+113
View File
@@ -0,0 +1,113 @@
# CAM crate assessment
Research date: 2026-10-15. This assessment supplements [the CAM implementation status](cam.md).
Versions below are the releases inspected, not a promise that every upstream `latest` page is
current. The initial assessment changed no dependencies. Subsequent implementation pinned Parry
0.31.1 for collision queries and robust 1.2.0 for independent projected-coverage predicates.
Other candidates remain proposals;
see the current quality-gate results for target compilation evidence.
## Recommended order
| Candidate | Inspected version / license | Proposed use and decision |
| --- | --- | --- |
| [parry3d-f64](https://docs.rs/parry3d-f64/0.31.1/parry3d_f64/query/index.html) | 0.31.1; [Apache-2.0](https://github.com/dimforge/parry) | Implemented behind a private adapter: distance and swept collision queries refine fixture, holder and shank checks. Conservative fallback remains. |
| [proptest](https://docs.rs/proptest/1.12.0/proptest/) | 1.12.0; [MIT OR Apache-2.0](https://github.com/proptest-rs/proptest) | Add as a development dependency for generated cases and shrinking failures to small reproducible examples. |
| [stl_io](https://docs.rs/stl_io/0.11.0/stl_io/) | 0.11.0; [MIT](https://github.com/hmeyer/stl_io) | Evaluate for bounded ASCII/binary import and binary stock export. This simplifies file handling; our independent conforming stock boundary extractor supplies the geometry. |
| [robust](https://docs.rs/robust/1.2.0/robust/) | 1.2.0; [MIT OR Apache-2.0](https://github.com/georust/robust) | Implemented as a pinned direct dependency for independent stock-mesh coverage and sidedness checks. Exact predicate signs prevent near-collinear coverage errors; guarded plane intervals still account for dimensional uncertainty. |
| [static_aabb2d_index](https://docs.rs/crate/static_aabb2d_index/2.1.0) | 2.1.0; MIT OR Apache-2.0 | Already present through Cavalier. Benchmark against the verifier's current XY index before replacing it. Keep unsafe optimizations disabled. |
| [gcode](https://docs.rs/gcode/0.7.0/gcode/) | 0.7.0; [MIT OR Apache-2.0](https://docs.rs/crate/gcode/0.7.0) | Useful as a second parser in differential tests. It handles syntax and source spans, not modal machine semantics. Keep SolidCraft's independent interpreter and rejection of unsupported behavior. |
Keep [cavalier_contours 0.9.0](https://docs.rs/crate/cavalier_contours/0.9.0), already pinned in
`solidcraft-cam`. Its [Rust core](https://github.com/jbuckmccready/cavalier_contours) supports
line/arc offsets and regions with islands under MIT OR Apache-2.0. It also documents limitations:
rounded offset joins, arc segments of at most a half-circle, and nonintersecting closed loops for
multi-loop regions. Our bounded adapter and invalid-region tests remain necessary. The adapter now uses explicit
1e-8 mm position/join and 1e-7 mm offset-distance tolerances, with a round-island regression
that exposed a slice-merging failure under upstream defaults.
The six-case corpus also exposed degenerate tangencies in multi-loop offsets. Pocket offsets
reserve an additional 0.000001 mm of clearance, within the existing post error budget, while
the independent verifier checks the unchanged requested geometry and allowance. Debug builds
exercise the concave/two-island recipes as part of `cam-validate`. This is a bounded numerical
mitigation, not a general proof of the upstream stitcher: the
[0.9.0 release notes](https://github.com/jbuckmccready/cavalier_contours/releases)
say the shape offset algorithm has not received the polyline stitching update, and
[issue 66](https://github.com/jbuckmccready/cavalier_contours/issues/66) records threshold/tangency
limitations. Keep the pin, native error guard and impossible-region checks; do not adopt a new
geometry dependency or disable debug assertions to hide these failures.
A 2026-10-16 original 10,000-vertex circle also exposed a short-segment cross-product
threshold failure. The adapter now normalizes coordinates by an exact power of two and
scales physical distance tolerances with them, then restores output to millimetres. It
retains every input vertex. Native debug regressions cover dense convex/concave offsets,
shifted/scaled coordinates and independent distance expectations. The release-Wasm dense
concave probe still measured a 21.2 ms atomic offset batch, an explicit scheduling limit
recorded in the [acceptance audit](cam-acceptance.md).
## Portability and integration
- **Parry:** use double precision and a private adapter from SolidCraft geometry types. The
inspected API uses its own math types; avoid exposing these in persisted models. Start with
sequential queries and no optional Rayon feature. Native and `wasm32-unknown-unknown` compilation,
dependency/license review, and bounded execution measurements are adoption gates. The upstream
[manifest](https://github.com/dimforge/parry/blob/master/crates/parry3d-f64/Cargo.toml) exposes
optional parallelism and enhanced determinism; exact release features must be pinned and tested.
- **Proptest:** keep it out of runtime dependencies. Native CI can run the broad randomized suite.
Browser tests need a separate feature configuration: its [defaults](https://docs.rs/crate/proptest/1.12.0/features)
include process-based fork/timeout functionality. Save seeds and minimized regressions.
- **STL:** use memory readers/writers in the portable layers. Enforce our byte/triangle caps and
finite-value checks around the library. Measure float serialization error against the artifact
tolerance budget. A portable I/O abstraction alone is not evidence of a passing Wasm build.
- **Existing geometry crates:** `robust 1.2.0`, `spade 2.15.1`, and `static_aabb2d_index 2.1.0` are
already in this worktree's lockfile and were included in the previous workspace Wasm gate.
Any new direct adapter still needs its own tests.
- **G-code:** the crate explicitly targets no-std/WebAssembly. Treat any parser diagnostic as a
rejection in validation; error recovery must never silently remove machine behavior.
## Other candidates
[spade 2.15.1](https://docs.rs/spade/2.15.1/spade/) provides constrained 2D triangulation and
already exists transitively. The 2026-10-16 meshing profile justified using its `bulk_load_cdt`
API for empty boundary triangulations, replacing expensive sequential point location. The vendor
manifest now requires at least the inspected 2.15.1 release. No constraints are passed during
bulk loading; rounded positions (including duplicates and signed zero) map back to vertex
handles before the existing constraint insertion loop. Nonempty or failed loads retain the
sequential path. The installed release's API documentation and implementation were inspected;
workspace native/Wasm gates and analytic kernel tests exercise the adapter. Combined with an
original conservative boundary candidate index, the original large-cylinder browser measurement
fell from about 1087 ms to 91 ms without changing its measured chord error or triangle count.
This is a concrete reuse of an existing dependency, not a new mesh Boolean or solid-boundary
library. It does not supply stock side-wall stitching; the remaining atomic browser latency
is recorded in the acceptance audit.
[rstar](https://docs.rs/crate/rstar/0.13.0) is an alternative spatial index. Defer an additional
index dependency until profiling shows a need beyond the existing XY index or Parry's structures.
[csgrs](https://github.com/timschmidt/csgrs) is MIT licensed and worth a separate mesh-Boolean
experiment, but is not a recommended dependency for this milestone yet. The inspected
[published 0.20.1 documentation](https://docs.rs/crate/csgrs/0.20.1) describes BSP operations,
while the current repository describes a substantially different Hypermesh/Hypercurve stack and
0.23.0 API. Resolve the exact published release and audit its dependency graph before evaluating
it. Neither upstream feature claims nor a successful Boolean operation establish our CAM error
budget or performance at hundreds of thousands of cells.
## Concrete adoption experiments
1. **Collision refinement:** cast the cutter, shank and holder against fixture triangles. Cover
a clear near miss, grazing contact, an initial overlap, a thin obstacle crossed between
endpoints, shifted setup frames, and degenerate triangles. Linear shape casts fit fixed-axis
motions; arc subdivision needs conservative inflation for chord error. Unsupported shape
pairs and numerical failures remain inconclusive. Preserve operation, motion and NC-line IDs.
2. **Generated tests:** exercise winding reversal, translation, inch/mm equivalence, post
round trips, cancellation boundaries, non-finite values, malformed NC, and budget exhaustion.
Cross-check indexed candidate sets against brute force on small fixtures.
3. **Stock artifacts:** round-trip ASCII and binary STL against analytic solids, reject malformed
and oversized files, and compare sizes. Independently implement or qualify conforming surface
extraction; verify edge incidence, orientation, volume and local surface error. A valid STL
container is insufficient evidence of a watertight stock mesh.
4. **Interpreter independence:** compare parser tokens/spans on the supported dialect, then use
hand-calculated machine-state expectations and deliberate faults. Never reuse toolpath
generation algorithms as the semantic oracle.
These crates reduce implementation effort. Broader dimensional and uncertainty coverage,
and complete Fusion reference captures still require project-specific work.
+92
View File
@@ -0,0 +1,92 @@
# Adaptive clearing and 3D milling
Status: planned implementation following the first milling PR. The strategies and
ball-end tooling below are not implemented or qualified yet.
## Scope and delivery order
Retain vertical, fixed-axis three-axis machining, rectangular stock, rigid setups,
explicit feeds/speeds and fail-closed posting. Indexed/multi-axis work and physical
controller qualification remain outside scope.
1. **Tool and verifier foundation.** Add ball-end mills with explicit cutting length,
shank and holder dimensions, a pending document migration, command/UI editing and
tool-tip semantics. Qualify swept spherical cutting envelopes independently on
stationary, horizontal and sloping motions, including holder/shank clearance.
2. **Adaptive clearing.** Introduce explicit optimal load, maximum stepdown, entry
and linking settings. Maintain material state during planning; bound actual radial
engagement through corners and narrow passages. Generate smooth clearing loops
with proved entry/link clearances. Ordinary offset pocketing is not adaptive clearing.
3. **3D roughing.** Slice the protected target at bounded Z levels, include radial
and axial stock allowances, and support rest roughing from a fingerprinted preceding
stock state. Reject unsupported undercuts, uncertain topology and inaccessible
regions. Removed material must never be inferred solely from a preceding path's name.
4. **3D finishing.** Deliver parallel finishing for shallow surfaces and constant-Z
finishing for steep surfaces, with ball tools, cutter-contact offsets, bounded
chord error and explicit cusp/stepover settings. Scallop finishing follows only
after its surface-spacing and connector bounds are independently qualified.
5. **Integrated acceptance.** Exercise editing, generation/cancellation, simulation,
diagnosis, exports, save/reopen and stale-result refusal through commands and the
visible Manufacture workspace. Keep portable jobs incremental and bounded.
## Independent validation
Keep the generator, NC interpreter and stock verifier separate. Extend typed motions
and provenance only where required; preserve the reference post's initialized machine
state and reject unsupported controller behavior. Inspect every emitted NC block.
Original fixtures cover a sloped plane, convex dome, concave bowl, saddle, stepped
boss, steep wall, islands connected by a narrow neck, interrupted cuts, rest stock,
shifted origins and tool changes. Each fixture declares protected surfaces, allowed
rough stock, finished regions, accessibility and independent dimensional expectations.
Hand-calculated sphere-plane contact and cusp-height cases qualify the verifier first.
Fault injections include the wrong ball radius, omitted roughing stock, excess
engagement, reversed arcs, connecting gouges, rapid-through-stock and holder contact.
Acceptance retains 0.01 mm dimensional tolerance, 0.001 mm post/interpolation budget
and 0.0025 mm verifier uncertainty. Local surface distances and hole/wall dimensions
accompany volume differences. Refine uncertain regions; exhausting resources yields
inconclusive and blocks posting. For roughing, verify protected geometry and allowed
stock per operation instead of requiring identical intermediate material removal.
Engagement checks report their definition, sampling/bound uncertainty and the NC line.
## Fusion comparison packages
Use the same original recipes in local Fusion and SolidCraft. Reconcile units, tools,
stock, frames, allowances, entry/links and operation settings before comparison.
Record version, available capabilities, post identity/hash/properties and timestamps.
Capture NC Program output, setup HTML and final simulation stock after every operation;
use Save Stock when automated export is unavailable. Preserve the actual Accuracy
setting and report an unexposed numeric resolution as not reported.
Both posted programs pass through the independent interpreter and verifier. Compare
each against intended geometry and Fusion stock, with line-linked diagnostics, path
overlays, surface differences, volume, distance, retract count and time estimates.
Missing artifacts or unavailable licensed strategies cannot count as passing parity.
Require complete passing packages for each delivered strategy before declaring parity.
All Fusion-produced artifacts and derived comparisons stay in ignored local storage;
commit only original recipes, harness code and independently authored expectations.
## Dependency decisions
Keep the existing pinned Cavalier 0.9.0 adapter for planar offsets/islands and Parry
0.31.1 for bounded collision/distance queries. Both need geometry-specific guards;
neither provides an adaptive machining algorithm or an independent stock proof.
Cavalier documents rounded joins and arc-size constraints; retain current degeneracy
and scale tests. Parry's double-precision mesh and point-query APIs are candidates for
cutter-contact acceleration; benchmark against a simple original implementation first.
No additional runtime dependency is justified yet. Reuse the existing robust predicates,
Spade and spatial index; consider property testing as a development-only dependency.
Primary references inspected on 2026-10-16:
- [Autodesk adaptive roughing overview](https://help.autodesk.com/cloudhelp/ENU/Fusion-CAM/files/3D-ADAPTIVE-READ.htm)
- [Autodesk adaptive parameters](https://help.autodesk.com/cloudhelp/ENU/Fusion-CAM/files/GUID09E44604-DAD8-47D6-ADC6-C100869DE724.htm)
- [Autodesk parallel finishing](https://help.autodesk.com/cloudhelp/ENU/Fusion-CAM/files/3D-PARALLEL-STEPS.htm)
- [Cavalier 0.9.0](https://docs.rs/crate/cavalier_contours/0.9.0)
- [Parry 0.31.1 triangle mesh](https://docs.rs/parry3d-f64/0.31.1/parry3d_f64/shape/struct.TriMesh.html)
- [Parry 0.31.1 point queries](https://docs.rs/parry3d-f64/0.31.1/parry3d_f64/query/point/trait.PointQuery.html)
Each implementation arc runs focused analytic/fault tests, then repository quality
and Wasm gates. The oracle command must require the strategies actually claimed by
that milestone, without relaxing the first milestone's completeness requirements.
+598
View File
@@ -0,0 +1,598 @@
# Three-axis milling and independent validation
The planned first milling workflow is implemented and its four-strategy Fusion comparison
gate passes. See the [requirement-to-evidence audit](cam-acceptance.md) for scope, tests and
recorded limitations. This is not parity with all Fusion CAM features. No output is qualified
for a physical machine controller.
## Architecture and commands
`solidcraft-cam` is pure Rust at L1 and depends on geometry. The document persists manufacturing
through the pending `manufacturing` format extension. Older designs receive an empty
manufacturing section; drilling coordinates remain explicit without inferred associations. Generated paths and
verification results are session caches and are deliberately absent from saved designs.
The engine resolves bodies, explicit occurrence paths and durable sketch-profile curve identities
into immutable snapshots in setup coordinates. Missing or ambiguous references require reselection.
Any document or pending occurrence-placement edit makes the cached job stale; undo can restore it.
The Manufacture workspace, CLI and MCP share these registered commands:
| Command | Input / behavior |
|---|---|
| `cam.setup.put` | `{setup: Setup}`: rectangular stock, right-handed rigid frame, model/fixture references, clearance, retract, G54–G59 |
| `cam.tool.put` | `{tool: Tool}`: document-local center-cutting flat mill or twist drill, cutting/shank/holder dimensions |
| `cam.holes.from_selection` | `{setup,selections}`: ordered cylindrical-face/circular-profile picks; returns centers, diameters and persistent sources |
| `cam.boundary.from_profile` | `{setup, sketch, profile, occurrence?}`: returns sampled boundary and durable source reference |
| `cam.operation.put` | `{operation: Operation}`: face, contour, pocket or drill; explicit feed, plunge, spindle and depth settings |
| `cam.program.put` | `{program: {id,name,setup,operations}}`: ordered operations, one setup/work offset |
| `cam.operation.order` | `{program, operations}`: permutation of existing operations |
| `cam.delete` | `{kind,id}`: refuses removal while referenced |
| `cam.generate` | `{program, background?}`: immutable job; returns job ID/status |
| `cam.job.status`, `cam.job.cancel` | `{job}` |
| `cam.inspect` | `{job?, include_path?, include_stock?, motion?, finding?}`: world-space stock display mesh and machine state after the requested number of motion blocks |
| `cam.simulate` | `{job, background?}`: independent post round trip and stock/clearance checks |
| `cam.post` | `{job,path?,background?}`: requires fresh generated paths and passing verification |
| `cam.export` | `{job,format,path?,background?}`: `toolpath`, `setup_sheet`, `stock`, `validation` |
| `cam.export.result` | `{job}`: consumes completed background output after checking freshness; writes its requested path or returns content |
UI export and posting use background jobs: a native worker or the browser's cooperative scheduler
builds the complete artifact from the immutable snapshot. `cam.job.status` includes an `export`
state (`idle`, `running`, `ready`, `failed`, `cancelled`). `cam.export.result` rechecks document and
occurrence freshness before consuming output and atomically writing the requested destination.
No file is written before this collection command. Cancellation returns no partial artifact;
`cam.job.cancel` cancels the job, including its export. One pending/ready export per job is allowed.
The UI collects completion through that same command and displays export progress and errors.
Programmatic calls default to the existing synchronous behavior. JSON values and stock geometry
are preserved; incremental JSON can use different whitespace and object-key order. Artifacts are
bounded to 128 MiB; exhausted output budgets fail explicitly.
All dimensions are mm; speeds are mm/min and rpm; angles in the command model are radians.
The UI displays tool and ramp angles in degrees. IDs must be nonzero and unique per item kind.
See the serializable types in `crates/cam/src/model.rs` and original fixtures in
`crates/cam/src/corpus.rs` for complete payloads. Without a path, exports return `content`.
Facing cuts stock with raster passes. Contouring uses geometric inside/outside cutter-radius
offsets. Pocketing uses successive offsets, handles islands, and retracts between loops.
Drilling expands every peck into ordinary motions and distinguishes tip depth from full-diameter
depth. Contours/pockets can use tangential leads and feed-controlled ramps. Allowance means stock
to leave; use a separate zero-allowance operation for finishing. Flat mills are explicitly assumed
to be center cutting. Entry/lead collisions are checked during simulation, before posting.
The reference spindle rotates clockwise (M3) with right-handed cutters. Climb milling keeps
the finished material to the right of travel: counterclockwise inside walls, clockwise around
external bosses and pocket islands. Facing advances rows in +Y and cuts toward -X for climb;
conventional reverses each cutting direction. Full-width entry can engage both sides of the
cutter. Independent tests interpret posted NC and check travel direction and signed loop area
for both settings and both input windings. The convention follows the
[Haas mill programming workbook](https://www.haascnc.com/content/dam/haascnc/videos/bonus-content/ep81-essential-9-lines/programming-workbooks/mill---programming-workbook.pdf),
printed pages 51 and 61. Generator version `cam/14` invalidates paths made with the earlier
reversed direction settings; regenerate and simulate before posting.
Offsets use exactly `cavalier_contours 0.9.0` (MIT OR Apache-2.0) behind bounded input checks.
Crossing/touching loops, nested islands, oversized tools, non-finite values and exceeded budgets
are errors. The offset adapter uses 1e-8 mm position/join and 1e-7 mm distance tolerances;
these avoid merging small distinct slices in round-island pockets with upstream defaults.
Boundary validation queries an edge spatial index instead of charging every possible edge pair
against its work budget. Crossings, near touches and adjacent backtracking still fail; candidate
work remains capped, with cancellation checkpoints during construction, queries and containment.
The offset adapter scales coordinates by a power of two so the shortest edge is at least one
library unit, and scales distance tolerances by the same factor. This avoids the library treating
short nonparallel segments as parallel through its cross-product epsilon. It removes no vertices
and restores output to millimetres; the original input remains the independent verifier's target.
Dense convex/concave, reversed-winding, translated/scaled and 100-island regressions cover the
adapter. Output retains operation IDs, machine events and linear/planar-arc motions.
## Reference NC dialect
The post emits tool-tip coordinates, three linear axes, absolute mm positioning, XY arcs with
incremental I/J centers, units-per-minute feed and geometrically applied cutter compensation.
Post version `solidcraft-reference-3axis/2` explicitly initializes
G21/G90/G17/G91.1/G94/G54–59/G40/G49/G80 and stops the spindle and coolant with M5/M9
before the declared initial pose. It emits tool/spindle/coolant events,
and expands drilling. Startup is a declared position at clearance; physical travel to that
initial pose is outside this reference dialect's contract. There is no machine kinematics,
G28/G53 homing, length offset activation, canned cycle or controller-specific behavior.
The separate NC reader consumes every word and rejects unsupported commands. It supports G20
and G91 for comparison, but requires complete absolute XYZ for the initial pose. Modal groups,
units, feed, tool changes, spindle state, planar arc radii and program termination are checked.
M6 stops the spindle, preserves coolant and the programmed speed, and requires a subsequent
M3 before cutting. Speed words take effect before motion and alongside coolant/stop commands.
Program end includes spindle/coolant shutdown in the interpreted event stream. Hand-authored
modal programs check these transitions and reject cutting with a stopped spindle. The post
round trip checks its three startup events separately, without assigning them an operation;
subsequent motions are compared against the pre-post typed IR with a 0.001 mm error budget.
Semantics reference: [NIST RS274/NGC](https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=823374).
The reader is a deliberately strict subset, not a general RS274 interpreter.
## Verification and present limits
The independent verifier uses adaptive XY cells with conservative lower/upper stock heights,
flat-mill or drill-cone removal, local target-surface checks, and swept rapid/shank/holder bounds.
Fixture checks use pinned `parry3d-f64 0.31.1` (Apache-2.0): separate cutter, drill cone, shank and
holder shapes are swept against fixture triangles. Initial-distance and closed-volume winding
checks catch stationary contact and tools already inside fixtures. Body groups preserve enclosed
cavities and prevent opposite winding in separate fixtures from cancelling. Open/nonmanifold
meshes, clearance within the interpolation/refinement margin, and failed numerical queries remain
**inconclusive**. It does not call offset or toolpath-generation algorithms and observes cancellation
during topology preparation and collision queries. The current algorithm fingerprint is `cam/18`;
the geometry and tessellation changes invalidate older generated/verified caches.
No target fitting, translation, unit guessing or discarded unknown NC commands is allowed.
Default dimensional tolerance is 0.01 mm; interpolation/post error is 0.001 mm; requested spatial
refinement is 0.0025 mm. Reports distinguish requested refinement from the actual maximum stock
height interval. Limits are 250,000 cells and 30 million work units by default. Exhaustion or an
unresolved collision blocks posting. Remaining-stock volume is an interval, not an exact number.
Stock STL contains a conforming conservative exterior of the upper height envelope. Shared edges and
vertical steps are split, internal walls are removed, and every edge and vertex link is checked
for a closed, consistently oriented manifold. Coplanar cells merge exactly without resampling.
Vertical drill cones use exact rectangular-cell radial extrema. Adaptive cells can still create
tiny saddle contacts: export may fill these upward by at most 0.00001 mm per cell, with no XY
snapping or removed material. This declared artifact budget does not alter simulation intervals;
analytic artifact checks independently compare the resulting STL against those intervals.
Exports are bounded to 60,000 merged cells, 500,000 triangles and 128 MiB. Larger or unresolved
non-manifold contacts are rejected explicitly; validation JSON retains the cell intervals. `stock_complete` is
false after verification budget exhaustion, which blocks export of a misleading partial stock.
A watertight upper-bound mesh does not establish finished-part dimensional accuracy.
Stock comparisons require declared stock bounds and exact, non-overlapping cell coverage. Older
reports without bounds remain inconclusive. Imported reference meshes must be closed, consistently
oriented manifolds within those bounds, with downward faces only on the stock bottom. Unsupported
undercuts, reversed faces, degenerate triangles and out-of-frame vertices are rejected. Projection
coverage uses boundary incidence and pinned `robust 1.2.0` orientation predicates, not sums of
triangle areas. Duplicate or overlapping faces cannot counterfeit coverage. Extremely small nonzero
XY coordinates below 1e-100 are rejected to avoid predicate underflow; coordinates are never snapped.
The comparator independently bounds reference heights from triangles, reusing only the validated
spatial partition before further adaptive subdivision. It certifies whole-column distances for
both remaining material and removed space, in both directions, including the stock exterior.
This resolves narrow uncertainty bands at vertical walls without concealing an omitted deep hole.
Failures require a definitely present material/void point farther than tolerance from all possible
corresponding columns; inconclusive intervals are not failures. Distance maxima describe examined
regions; a certified failure may stop at its first witness. Cell/work exhaustion remains inconclusive.
Tests include independent analytic round holes, alternate facet layouts, generated drilling-stock
round trips, shifted walls, missing holes, incomplete coverage and malformed reference meshes.
The current passing verification status certifies the implemented no-gouge/clearance checks.
The separate `finished_dimensions` status certifies facing planes, analytic drill cylinders/cones,
pocket floors and islands, and the selected side of contour walls. The milling verifier uses
independent robust winding and point-to-segment distances, with whole-cell signed-distance bounds;
it never calls the generator's offset functions. A contour must finish its selected wall and floor
within one tool radius of the allowance boundary. Farther stock on the cutting side may remain as
roughing stock; it cannot be cut below the specified floor. A pocket must clear its full selected
interior to the allowance floor while preserving its islands and outer wall.
The radial tolerance band reserves half the dimensional tolerance for paired-wall/diameter error,
then subtracts the post/interpolation budget. Associated CAD profiles also reserve their chord-error
budget, at most 0.0005 mm (and at most 1/16 of operation tolerance), during engine sampling and
independent certification. Requested features drive adaptive refinement even if the NC omits them.
Multiple operations compose by taking the minimum permitted remaining-height envelopes.
Drilling requires zero stock allowance: use explicit depth, since drill paths do not apply milling
allowance. Sharp inaccessible internal corners, omitted islands, wrong tools/depths and shifted
paths fail dimensional validation and block posting. Exhaustion and unresolved bounds stay
inconclusive. Posting requires both clearance and finished dimensions to pass and complete stock.
Motion queries use an independent XY index and retain original sequence IDs for withdrawals and
machine-state checks. Exact item bounds exclude irrelevant candidates; shank/holder radii remain
included whenever those components can reach stock. Region predicates have bounded work and
cooperative cancellation. The analytic contour/pocket recipes now use an original 128-sided round
boundary and matching protected walls/floors; they no longer rely on a plane-only square smoke test.
Constant-height and vertical mill sweeps use rectangular-cell lateral bounds: the segment's
bounding box tightens minimum distance, and convexity bounds maximum distance at the four corners.
This avoids artificial overlap from circumscribed disks along straight walls. Sloping moves retain
the common-disk height bound; neither tolerances nor resource limits are relaxed.
Protected height-field boundaries use local spatial certificates. A conservative upper target
height at a neighboring XY point bounds distance to the outside of the protected solid for an
entire potentially removed cell column. A gouge is reported only with a definitely removed point
inside a protected volume by more than tolerance. Clearance reserves 0.001 mm post/interpolation
error plus the 0.0025 mm verifier budget; CAD tessellation uses half that verifier budget as a fixed physical tolerance. It bypasses the
display mesher's size-dependent tolerance floor and planar display reprojection. Exhausted edge
or surface refinement, non-finite evaluations, missing faces and excessive triangle output fail
preparation instead of accepting a coarser or partial target. The original large-cylinder
regression (radius 5000 mm) previously had 0.00954 mm radial chord error for a 0.00125 mm
request; the verification path reduces it below 0.00125 mm, including through engine snapshot
preparation. These checks expose known mesher fallbacks; they are not an analytic approximation
proof for arbitrary oscillatory surfaces.
Queries use an independent XY index, observe work limits and cancellation, and leave unsuccessful
certificates explicitly inconclusive with a `protected_surface_unresolved` finding.
The original drilling fixture now contains the plate top, hole wall and cone, replacing its former
plane-only protected target. Tests cover boundary clearance, shifted-hole and through gouges,
sloping surfaces, overlapping projected targets and large-coordinate precision. A native CAD
drilled body also passes create/generate/simulate/post/save/reopen through registered commands.
This does not establish qualification of arbitrary non-height-field solids.
Native CAD pocket and contour workflows also pass through durable sketch-profile references,
ramp entry, generation, simulation, posting and save/reopen. All four analytic strategies now
require passing finished-dimension checks; independent point probes supplement those certificates.
The Fusion oracle requires the same dimensional checks for both programs. Current acceptance
evidence and supported limits are:
- Nine analytic recipes / fourteen operation stages cover concave regions, two islands, a narrow
channel, allowances, multiple depths/tools, shifted origins and blind/through drilling. Deliberate
faulty programs check missing cuts, protected material, unsafe leads and inaccessible channels.
- Fixture regressions cover thin swept obstacles, initial overlap, enclosed volumes and cavities,
holder/shank interference, malformed/open meshes and cancellation. Supported stock/model geometry
remains vertical height-field scope. Fixture triangles must form closed consistently wound bodies
for volume-containment certification; surface contact can still be detected on an open mesh.
- Background jobs yield during preparation, boundary validation, between offset queries and every
256 emitted motions, as well as during verification, NC round trips, fingerprint encoding and
conforming stock extraction. The browser shares a four-ms scheduling target across jobs in
round-robin order; status queries advance one checkpoint. This target is checked between polls,
so it is not a hard deadline. Measured dense offset, large-body tessellation and long-reference
encoding remain atomic for about 21.2, 90.8 and 8 ms respectively on the documented browser.
The previously synchronous export path is now incremental: original large export probes measure
batches at most4.3ms, with no batch above16ms; direct synchronous command calls remain atomic.
The measured geometry calls above remain explicit scheduling limits, not a hard deadline.
Native background jobs perform profile sampling, tessellation, transforms and hashing on the
worker from an immutable snapshot. Synchronous command calls remain available. Status distinguishes
`preparing` from `running`; the fingerprint is null until preparation completes. Cancellation
cannot publish a partial path or revive a cancelled job.
- Associated CAD curves use second-derivative chord-error bounds after placement, including a
numerical reserve and a shared 10,000-vertex boundary limit. Requests exceeding precision or
vertex budgets fail explicitly rather than silently loosening the operation tolerance.
- The four initial local Fusion reference cases pass. Native desktop walkthroughs cover each
strategy through generation, simulation, posting through the system save dialog, save/reopen/
edit/regenerate, stock display and playback. Eight additional recovery captures cover lost-profile
reselection, line-linked fixture collisions and correction, simulation cancellation and stale
feed edits followed by regeneration. Preparation cancellation is covered by an engine regression;
it was not manually clicked during that walkthrough.
- The [completed acceptance audit](cam-acceptance.md) maps the original requirements to
implementation and concrete assertions, including multi-operation ordering and native saving.
The first milling milestone is complete within these declared limits. The passing reference
corpus is evidence for its recorded fixtures, not qualification of arbitrary parts or physical
controllers.
## Reproducible comparisons
Run `cargo xtask cam-validate`. Original analytic recipes generate input snapshots, canonical paths,
NC, stock, setup sheets, validation JSON and an HTML report under ignored `target/cam-validation/`.
Nine recipes cover the four initial strategies, a rounded concave pocket with two islands,
a four-operation facing → roughing → finishing → peck-drilling program with multiple tools and
a shifted G55 setup, two clamped through holes, an external square-boss contour and a narrow capsule channel. The harness
verifies prefixes of the actual complete program after every operation, including roughing
allowance, coolant transitions and conventional finishing. All fourteen
stages require protected-surface, dimensional, point-probe and independent exported-STL checks.
The new target surfaces have independently calculated volume expectations; deleting islands,
omitting finishing, choosing the wrong tool or changing the work offset must fail. Reports link
each stage's NC diagnostics, setup sheet and stock, and include tool order, retract count, cutting
and rapid distance, feed-time estimate and a remaining-volume interval. Analytic checks
run in ordinary CI and `cargo xtask ci`. Core tests include modal state, inch/mm equivalence, wrong
arc direction, deliberate gouges/unsafe rapids, shank interference, fixture overlap, geometry errors,
islands, ramps and cancellation. Engine tests cover save/reopen, undo and stale-result rejection.
Post validation also compares arc sweeps: a tiny arc rounding to coincident endpoints must not
become a full circle. Generated arcs shorter than 0.00025 mm become lines, bounded by their total
arc length. Shared reference-mesh edges do not cause false interior height discontinuities;
positive-area overlaps retain both heights even for extremely thin regions.
The through-hole recipe uses both simple and peck drilling with full-diameter depth below the
stock bottom, a closed raised clamp between the axes, and an independently calculated perforated
plate target. Tests reject missing breakthrough, insufficient cutting length and a rapid crossing
the clamp at Z7, identifying its NC line. The external-contour recipe uses a reversed square
boundary, four depth passes, tangential leads, ramp entry, 0.2 mm roughing allowance and a separate
conventional finish. Wrong-side cutting and omitted finishing cannot pass. The final external
contour uses 243,154 cells in the current native run, below the unchanged 250,000-cell default;
larger jobs can still return inconclusive when their declared budgets are exhausted.
The narrow-channel recipe has a 2.2 mm width, 10 mm between semicircle centers and a 1 mm
cut depth in a 20 × 20 × 6 mm plate. Its R1.1 ends use 64 original polygon segments per
semicircle; a 2 mm cutter must finish the floor and both walls while preserving the surrounding
plate. An independent polygon-area formula checks target volume, and point probes check both
ends, the center and protected stock. Negative regressions reject a 2.4 mm cutter, detect uncut
material in a 1.6 mm neck between accessible R2 lobes, and reject a 5 mm lead that gouges the
original target. The lead failure retains its operation/motion/NC-line attribution. These cases
exercise the unchanged dimensional, clearance and posting gates; they are original analytic
evidence and do not count as additional Fusion reference captures.
Run `cargo xtask cam-oracle --dir plan/fusion/cam/oracle` for local Fusion packages. The harness
requires separate `face`, `contour`, `pocket`, `drill` directories. Missing evidence is a failure
to establish parity, never a skipped pass. Each directory requires:
- `manifest.json`: nonempty `fusion_version`, `captured_at`, `input_fingerprint`,
`post_identity`, SHA-256 `post_sha256`, and `capture_complete: true` only after actual capture.
`capabilities` records a nonempty `license` string and an array of available `strategies`.
`source_geometry` names the captured source file inside the package (for example `source.step`).
`artifact_sha256` maps every required artifact below, including source geometry and both input
snapshots, to its SHA-256 hash. Hashes bind the captured set and reject stale replacements;
they do not establish that the input transcription or original capture was correct.
- `post.cps`, `post-properties.json`, `input.json`, independently captured `reference-input.json`.
Input snapshots must match exactly, including frames, geometry, tools, allowance and settings.
- `reference.nc`, Fusion `setup-sheet.html`, `setup-sheet-values.json` with `setup`, `tools`,
`operations` values transcribed from the captured sheet/API, and `simulation.json` as below.
- `stock.stl` in setup millimetres, captured after the operation. Each first fixture has one
operation; multi-operation coverage must capture and validate every intermediate state.
`simulation.json` must record `status` (`pass`, `fail`, or `inconclusive`), `complete: true`,
`at_program_end: true`, a captured `findings` array, `completed_operations` in canonical input
ID order, and `stock_after_operation` equal to the final operation ID. Its `accuracy` object
records the actual UI `setting` and positive `resolution_mm` when available. If Fusion does
not report a numeric resolution, use `resolution_mm: null` and `resolution_status: "not_reported"`;
do not infer a resolution from the slider or confuse it with the independent verifier budget.
Missing accuracy/end-position evidence is incomplete. Captured failure or uncertainty prevents
a pass even when the independent checks pass. `post-properties.json` must contain valid JSON
(an object or array), including an empty object when the post has no properties.
Each finding has nonempty `detail` and `severity` (`error`, `unresolved`, or `information`).
An error or unresolved finding prevents a pass even if the captured summary says `pass`;
unknown severities are rejected. Add operation/motion/line locations when the reference exposes them.
The harness verifies provenance/input fingerprints and the actual post hash, interprets both
programs, simulates them through the same independent verifier, compares local spatial stock
bounds and the exported reference STL, checks setup values and reports distance/time/volume
deltas. Spatial indexing bounds local comparisons. HTML path overlays link to numbered NC listings and diagnostics. Stock heatmaps show sampled
remaining material relative to protected geometry; they are diagnostic views, not acceptance tests. Intermediate roughing must be judged against protected geometry and allowed stock;
identical roughing paths, retract counts or execution times are not acceptance criteria.
All three stock pairs must pass directly: SolidCraft NC versus reference NC, SolidCraft NC
versus reference STL, and reference NC versus its own exported STL. Tolerance comparisons
are not transitive: two results can each lie within 0.01 mm of a third while differing by
0.012 mm from each other. An original analytic regression exercises that failure. Reports
retain definite failures even alongside inconclusive checks and include `cam-oracle/2`
comparison provenance, capture settings, artifact hashes, generator/post identities and error
budgets. The imported NC report identifies the captured post, rather than attributing it to
SolidCraft's post. Synthetic unit-test packages are original expectations and never count
as Fusion parity evidence.
Create geometry from the original analytic recipe in each application. Record Fusion version,
license/capabilities, all resolved settings and capture timestamps. Use its current
[NC Program posting API](https://help.autodesk.com/cloudhelp/ENU/Fusion-360-API/files/PostToolpaths_Sample_Sample.htm)
and [Save Stock](https://www.autodesk.com/support/technical/article/caas/sfdcarticles/sfdcarticles/How-to-save-the-stock-as-an-STL-from-simulation-in-Autodesk-HSM-and-Fusion-360.html)
where no supported automated stock export exists. Do not fabricate missing artifacts or mark
incomplete captures complete. Additional fixtures must cover concavity, multiple islands,
reversed winding, narrow channels, round profiles, offsets, through holes and multiple tools.
All Fusion-produced files and derived reports belong in ignored `plan/` or outside the repository.
The oracle refuses an unignored in-repository destination. Commit only original recipes, harness
code and independently authored expectations. No vendor post source, templates or assets are
part of SolidCraft. Local references for facing, contouring, pocketing and drilling now pass
input reconciliation, setup values, both programs' clearance and dimensional checks, and all
three NC/stock comparisons (4/4). Together with the [acceptance audit](cam-acceptance.md),
this closes the planned first milling milestone. The contour, pocket and drill exports use the maximum
Stock Accuracy setting after replay to completion; the original coarser exports and their
failed/inconclusive comparisons remain in ignored local storage. Numeric Fusion resolution is
recorded as not reported, without substituting the independent verifier's resolution. Acceptance
remains 0.01 mm with the original post and verifier error budgets.
## Manufacture display and playback
The workspace shows the selected program's model, initial stock or simulated final stock, with
separate fixture coloring and visibility controls. Stock uses the setup coordinate frame; hidden
model and fixture bodies cannot be selected in the viewport. Editing a setup reveals the bodies
for reference selection. Missing or stale stock falls back to the model with an explicit message.
The remaining-stock display is the final conservative upper surface, not stock removal animated
at each playback step. Its mesh is cached by the simulation job and shares the bounded, conforming
boundary extraction used by STL export. Display extraction errors do not replace simulation status.
Playback advances typed motion/event blocks at an adjustable rate, independent of frame rate.
Zero means the initial clearance pose. The panel shows operation, tool, spindle and tool-tip
coordinates; paths distinguish rapid and cutting motion, including full-circle arcs. `cam.inspect`
exposes the same machine state, and `ui.inspect.manufacturing` exposes the current display choices.
Stock extraction runs on the native worker and cooperatively on WebAssembly. Cell sorting,
boundary stitching, triangulation, manifold validation and large topology-map cleanup yield and
check cancellation. Cancellation returns no partial mesh; synchronous STL export uses the same
implementation. Native UI tests cover stock-frame
placement, visibility, stale results and playback boundaries. The four-strategy headless UI
walkthrough exercises background generation/simulation, posting, stock selection and playback zero,
with CPU-rendered stock views. A separate native desktop-window walkthrough exercises facing,
internal contouring, pocketing and peck drilling on original CAD plates. Contour and pocket
operations retain associated circular sketch boundaries and ramp entry; drilling retains a
cylindrical face selected in the viewport. Closing and reopening the saved designs clears derived
jobs while retaining those references. Editing coolant in the operation dialog and regenerating
passes clearance and finished-dimension checks for each case. Native-posted contour, pocket and
drill files match their command exports byte for byte. Playback, final-stock display and stale-post
rejection were also checked. Local artifacts and screenshots are in ignored
`target/cam-native-acceptance/`; this is SolidCraft evidence, not a Fusion comparison.
A second disposable native-window walkthrough checks recovery. A missing profile produces a
reselection error; selecting the original profile and using the operation editor's **Use selected
sketch profile** and **Save** restores generation and passing simulation. A clamp intersecting
the pocket produces a cutter/fixture error attributed to motion 7 and NC line 20; **Locate issue**
seeks that motion. Moving the same associated clamp clear restores passing clearance and dimensions.
Cancelling active simulation through **Cancel job** leaves the job cancelled and blocks posting,
including after later jobs finish. Editing feed in the operation dialog marks the result stale;
posting rejects it, and regeneration/simulation passes. Geometry, deliberately broken references,
profile selection and fixture correction were arranged through registered commands; editor actions,
generation, simulation, cancellation and issue location used native UI input. Evidence is local in
`target/cam-native-recovery/`. Preparation cancellation is covered by engine regressions, not this
native walkthrough.
Interactive Save, Save As and Open use the same picker/background-loading flows as the toolbar;
JSON commands remain dialog-free. Keyboard shortcuts use the modifier state on each key event,
so a quick Command/Ctrl+S still saves if the modifier is released in the same frame. Regression
tests cover these flows, cancelled pickers and persistence of manufacturing definitions.
Verification findings include a one-based interpreted NC `record` when a physical line is
known. SolidCraft's independent post round trip also attaches `source` with physical line,
operation ID and one-based IR block (the completed-block count used by playback). Attribution
is added after verification and never influences clearance or dimensional decisions. Raw NC
comments and deserialized data cannot supply this trusted mapping; imported programs retain
line/record attribution without a guessed SolidCraft operation. Arc subdivisions share their
original source block. Program-wide and missing-cut findings may have no responsible line.
`cam.inspect {job, finding: index}` uses a zero-based finding index and returns the reported
world-space point, operation definition, associated CAD source, exact NC line and playback state
where mapped. It rejects stale jobs and invalid indices; failed verification still blocks posting.
The Manufacture panel's **Locate issue** action uses that command to center the reported witness
and seek the completed source block. The marker remains visible when paths are hidden, can be
cleared, and disappears when inputs change. The marked witness is not a claim of the exact first
collision contact, and the stock view remains the final conservative stock surface. Validation
JSON and HTML NC diagnostics retain the operation/block/line attribution.
Findings have explicit information, unresolved or error severity. The bounded list keeps one
representative witness per kind/NC line/severity, prioritizes definite errors when full, and
shows errors first. This list is diagnostic sampling, not an exhaustive inventory of affected
points; verification status and stock intervals are computed independently of finding retention.
## Browser responsiveness measurements
A local release-mode WebAssembly probe on Chrome 155 (macOS, 2026-10-15) measured the original
four analytic fixtures, using the actual generator, verifier and stock extractor. Before cooperative
stock extraction, its longest single call was 433.1 ms for contour, 275.8 ms for pocket and
383.1 ms for drill. After the change, the longest stock-extraction poll across those fixtures was
2.5 ms. Triangle counts and passing clearance/dimension results were unchanged.
A second probe reused the UI scheduler with a four-ms target and one batch per animation frame.
The largest observed batch was 4.9 ms; no batch exceeded 16 ms. These are local observations,
not performance guarantees or complete browser acceptance: the probe excludes CAD snapshot
preparation, the rendered application, export serialization, concurrent jobs and hostile-size
inputs. Later primitive/export probes below cover representative accepted-size inputs; their
remaining atomic latencies are explicit limits in the acceptance audit. Unit
regressions verify exact synchronous/cooperative mesh agreement, early/mid/late cancellation,
round-robin fairness, completed-job termination and a poll cap if the host clock stalls.
The clamped through-hole and external-contour recipes also pass the same local release Wasm
probe. Their largest observed scheduler batches were 5.2 ms and 7.4 ms, with no batch over 16 ms;
stock extraction peaked at 4.0 ms and 4.1 ms. The external contour produced 243,154 cells and
111,102 triangles. These measurements have the same limited scope described above.
## Associated drilling selections
In a drilling operation, select inward cylindrical faces or circular sketch profiles in drilling
order, then choose **Use selected holes**. The engine command accepts `selections` containing
`{kind:"face",body,index,occurrence?}` or `{kind:"profile",sketch,index,occurrence?}`. A face pick
without `occurrence` uses the displayed world body name; an explicit occurrence path uses the
body definition name. Profile picks use the sketch feature ID and evaluated profile index.
The returned `sources` belong in `Operation.hole_sources`, with the returned `holes` as the
preview coordinates in `geometry.holes`. The command also reports selected diameters.
Face sources store a body/occurrence identity and persistent face name. Profile sources store
sketch curve identities. Immutable job preparation resolves these again in setup coordinates,
including uncaptured occurrence moves. Stored preview centers are never used as a fallback.
Matching cylindrical seam faces on the same body occurrence become one hole, retaining the first
pick’s source. Duplicate identical references and conflicting coincident axes are rejected.
Missing, split-to-new-name or ambiguous faces/profiles require reselection. Noncircular profiles,
external cylindrical faces and axes not parallel to setup Z are rejected. A linked hole's diameter
must match the drill within
the operation tolerance; choose a matching tool or explicitly unlink before pilot drilling.
Linked coordinates are read-only in the operation dialog. **Unlink holes** retains the displayed
coordinates as explicit input. Feeds, spindle, top/bottom heights, peck depth and tip/full-diameter
depth remain user-controlled. The complete operation settings, resolved centers and source
identities are included in setup sheets and canonical toolpath JSON; inspection exposes the
operation's hole sources. Selection establishes an axis only: independent machining and clearance
verification still decides whether the program can be posted.
### Snapshot hashing responsiveness
A local Chrome 155 release-Wasm stress probe found synchronous snapshot hashing calls of
54.6 ms for 32 operations with 10,000 boundary vertices each, 172.6 ms for 1,000 operations
with 1,000 vertices each, and 92.2 ms for 200,000 target triangles. Background snapshot
preparation now hashes incrementally, yielding before each operation/tool and every 256
triangles. It preserves the exact existing serialized fingerprint, including field order and
numeric/string encoding; no algorithm-version or cache-key change is needed. Cancellation
checks surround every yield and cannot return a partial digest. Regression tests compare the
old and incremental encoders across the original corpus and a maximum-size triangle snapshot.
On the same local probe, maximum scheduler batches for those three large cases were 5.1,
4.2 and 4.2 ms respectively; none exceeded 16 ms. These are measured observations, not hard
deadlines.
One setup or operation is still encoded atomically. This hashing probe alone does not measure
large reference lists, CAD tessellation, a single offset query or direct synchronous exports.
The subsequent primitive/export probes cover those cases and record remaining atomic limits.
These measurements do not establish whole-application latency guarantees.
### NC round-trip responsiveness and limits
Background simulation now yields during reference posting, independent modal interpretation
and IR correspondence checking. Posting and correspondence yield every 256 blocks; the reader
yields every 256 physical lines, including comment/blank lines. Cancellation is checked before
and after each yield and returns no partial G-code, parsed program or trusted line mapping.
The synchronous APIs use the same processing code with immediately ready checkpoints.
A local Chrome 155 release-Wasm probe measured a 228.9 ms synchronous round trip for 199,990
blocks. The cooperative path's largest scheduler batch was 4.2 ms. A 200,000-block path also
passes, with 200,003 interpreted records including the three post startup events; its largest
batch was 4.1 ms. Neither case had a batch over 16 ms. The former reader incorrectly counted
startup against the generated-path limit and rejected the maximum-size path. Both posted blocks
and external interpreted records now have explicit, independently checked bounds.
Regression tests exercise the exact path limit and excess input, synchronous/cooperative bytes
and machine-state equality, operation/block/line provenance, unchanged unknown-command errors,
and cancellation at every checkpoint across all three stages. Measurements exclude direct
synchronous export commands, CAD preparation, offset queries and the rendered application;
these observations do not establish a universal latency guarantee.
### Dense boundary validation and offsetting
The original 10,000-vertex seven-lobe boundary exceeded the all-pairs validation budget before
reaching offset generation. Indexed candidate checks now admit that simple boundary without
relaxing intersection tests or the candidate-work limit. A separate dense-circle regression
exposed a library assertion on short segments; power-of-two coordinate normalization resolves
the reproduced case without simplifying geometry. Independent regular-polygon distance checks
cover every output vertex at two scales and shifted coordinates; concave checks sample output
vertices against the original input segments. These checks are not whole-stock certification.
A local Chrome 155 release-Wasm generation probe on 2026-10-16 produced 6,674 blocks from the
10,000-vertex seven-lobe input. Its longest scheduler batch was 21.2 ms (one batch above 16 ms),
compared with 6.4 ms for the 1,400-vertex case. Validation is cooperative, but the library offset
query remains atomic. This records an unresolved responsiveness limit; it is not a guarantee of
frame-rate responsiveness. The probe excludes CAD preparation and stock verification.
A 2026-10-16 primitive probe measures the remaining atomic work separately from machining
acceptance. A 100-island pocket with 9,904 vertices emits 80,307 blocks with a maximum 9.7 ms
scheduler batch. Hashing 1,000 setup references or 10,000 profile curve IDs (256-byte names and
64-element occurrence paths) takes batches up to 3.2 ms and 8.0 ms respectively. These original
synthetic probes test computation; they do not supply matching intended geometry for dimensional
acceptance. Fixed-tolerance CAD cylinder tessellation takes about 12 ms at radius 10 mm,
102 ms at radius 500 mm and 1,087 ms at radius 5000 mm, producing 3,560, 8,188 and 28,382
triangles. The largest case now respects the requested 0.00125 mm chord tolerance instead of
silently coarsening it. Native preparation runs on its worker, but a Wasm shell tessellation is
still atomic and the measured large-body stall remains a browser responsiveness gap. Local
before/after evidence is in `target/cam-web-probe/primitive-baseline.json` and
`primitive-fixed.json`; output budgets and tolerances were not relaxed.
The follow-up mesher optimization uses Spade's bulk point insertion and an original conservative
line/AABB index for trim-boundary candidates. It retains every boundary segment and the existing
winding predicate; uncertain arithmetic visits candidates rather than pruning them. Rounded
positions are mapped back to vertex handles so duplicate points do not change constraint indices.
Differential tests compare indexed and complete winding scans across concavity, holes, reversed
winding, translated/scaled coordinates, boundary points and nearly parallel rays. Kernel checks
independently assert concave two-hole prism volume/containment and large-cylinder chord/volume
bounds. The algorithm fingerprint advances to `cam/18` because tessellation can change triangle
ordering. A local instrumented prototype reduced the radius-5000 case from about 1089.8 ms to
96.5 ms with the same 28,382 triangles and 0.00100715 mm maximum measured radial chord error.
Its segment visits fell from 352,784,748 to 1,462,907. This optimization does not make shell
meshing incremental; its remaining atomic latency is recorded below. Incremental exports are
covered by the subsequent acceptance checks.
The uninstrumented production release-Wasm follow-up measured 10.3 / 27.2 / 90.8 ms for radii
10 / 500 / 5000 mm, with the same triangle counts and radial errors as the fixed-tolerance
baseline. The largest case remains an approximately 91 ms atomic step on this machine; no
hard scheduling deadline or full browser responsiveness claim follows from this measurement.
Evidence: `target/cam-web-probe/tessellation-production-index.json`.
## Incremental export acceptance
A quiet Chrome155 release-Wasm probe with an original 200,000-block path measured synchronous
toolpath JSON up to296.7ms and posting/roundtrip342.5–360.8ms. An original tiled height field
with40,000cells exported34,051,171bytes of STL in540.5ms; validation JSON took35.5ms. These are
serialization/resource measurements, not claims that a synthetic report passed machining checks.
The incremental path avoids a full intermediate JSON Value for large arrays, yields between
motion/cell/triangle batches and each operation/tool, and reuses cooperative stock extraction
through ASCII serialization. Focused tests compare all JSON fields, HTML values/escaping and
STL/NC bytes, inject cancellation at every serialization checkpoint, and check stale/cancelled
background results cannot write output. The UI regression now exercises background posting and collection through an injected save-picker
service. A disposable native command walkthrough additionally exports all five formats, including
a33.9MB stock mesh, and verifies no destination file exists before explicit result collection.
A fresh system save-dialog attempt was not completed: native accessibility timed out and window
capture reported no presented frame. That manual check is not counted as a new passing walkthrough.
The incremental follow-up measured at most4.3ms per scheduler batch for the same original export
cases (no batch above16ms). The40,000-cell STL is still34,051,171bytes, and the200,000-block NC
program is still11,999,980bytes. Toolpath JSON shrinks from41,002,015 to15,201,072bytes through
whitespace removal; semantic equivalence is asserted against the original serializer. Evidence:
`target/cam-web-probe/exports-baseline.json` and `exports-incremental.json`. These timings exclude
file-system publication and are machine-specific; command freshness checks still govern publication.
The final native save retry used a regular disposable app bundle. The actual system Save dialog
created `posted.nc` (146,348 bytes), identical to the registered-command NC export. Job status
confirmed a fresh verified job, passing clearance/dimensions and idle export after UI collection.
Post-save screenshot capture remained unavailable; the result is established by the output file
and job state. Evidence: `target/cam-native-audit/save-acceptance.json`.
+1 -1
View File
@@ -160,7 +160,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
| `ui.resize` | `width`, `height` | |
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
`ui.workspace {workspace?: "design" | "render"}` reads or changes the active workspace.
`ui.workspace {workspace?: "design" | "render" | "manufacture"}` reads or changes the active workspace.
Entering Render requires finishing the current modelling operation; returning restores
the Design camera. See [the integrated Render workspace](render-workspace.md).
+6
View File
@@ -76,6 +76,12 @@ Changes:
## truck-meshalgo 0.4.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck)
CAM uses a separate `tessellation/verification.rs` path adapted from the original
fixed-tolerance triangulator. It propagates failed projection/refinement explicitly,
retains a physical tolerance independent of display LOD, and uses an original bounded
boundary candidate index plus Spade bulk loading. Display meshing remains unchanged.
The adapter is exposed only through the guarded kernel verification-mesh API.
Changes:
- `tessellation/mod.rs`, `triangulation.rs`: a separate provisional `preview_face`
path accepts at most 4,096 already sampled boundary points and a fixed grid of
+1 -1
View File
@@ -59,7 +59,7 @@ version = "0.13.0"
version = "2.0.0"
[dependencies.spade]
version = "2.12.1"
version = "2.15.1"
optional = true
[dependencies.truck-base]
+206
View File
@@ -0,0 +1,206 @@
//! SolidCraft: conservative line candidates for the existing trimming winding predicate.
//! This index never classifies a point, changes a segment or chooses a ray direction.
use std::ops::Range;
type Point = [f64; 2];
type Segment = (Point, Point);
#[derive(Clone, Debug)]
struct Node {
low: Point,
high: Point,
range: Range<usize>,
children: Option<(usize, usize)>,
}
#[derive(Clone, Debug, Default)]
pub(super) struct BoundaryIndex {
segments: Vec<Segment>,
nodes: Vec<Node>,
root: Option<usize>,
}
impl BoundaryIndex {
pub(super) fn new(segments: impl IntoIterator<Item = Segment>) -> Self {
let mut index = Self { segments: segments.into_iter().collect(), ..Self::default() };
// Non-finite or extreme domains retain the full scan. The magnitude cap
// also keeps midpoint sorting and the conservative rounding reserve finite.
if index.segments.iter().all(|(a, b)| a.iter().chain(b).all(|v| v.is_finite() && v.abs() <= 1e100)) {
index.root = index.build(0..index.segments.len());
}
index
}
fn build(&mut self, range: Range<usize>) -> Option<usize> {
let segments = self.segments.get_mut(range.clone())?;
if segments.is_empty() {
return None;
}
let mut low = [f64::INFINITY; 2];
let mut high = [f64::NEG_INFINITY; 2];
for (a, b) in segments.iter() {
for axis in 0..2 {
low[axis] = low[axis].min(a[axis]).min(b[axis]);
high[axis] = high[axis].max(a[axis]).max(b[axis]);
}
}
let middle = range.start + segments.len() / 2;
if segments.len() > 8 {
let axis = usize::from(high[1] - low[1] > high[0] - low[0]);
let position = segments.len() / 2;
segments.select_nth_unstable_by(position, |(a, b), (c, d)| (a[axis] + b[axis]).total_cmp(&(c[axis] + d[axis])));
}
let id = self.nodes.len();
self.nodes.push(Node { low, high, range: range.clone(), children: None });
if range.len() > 8 {
let left = self.build(range.start..middle)?;
let right = self.build(middle..range.end)?;
self.nodes.get_mut(id)?.children = Some((left, right));
}
Some(id)
}
/// Visit a conservative superset of segments crossing the infinite query
/// line. Keeping the backwards half avoids changing near-origin/parallel
/// behavior of the caller's floating-point winding predicate. False stops.
pub(super) fn visit(&self, point: Point, ray: Point, mut visitor: impl FnMut(Point, Point) -> bool) -> bool {
match self.root {
Some(root) => self.visit_node(root, point, ray, &mut visitor),
None => self.segments.iter().all(|(a, b)| visitor(*a, *b)),
}
}
fn visit_node(&self, id: usize, point: Point, ray: Point, visitor: &mut impl FnMut(Point, Point) -> bool) -> bool {
let Some(node) = self.nodes.get(id) else {
return false;
};
let side = |p: Point| ray[0] * (p[1] - point[1]) - ray[1] * (p[0] - point[0]);
let values = [side(node.low), side(node.high), side([node.low[0], node.high[1]]), side([node.high[0], node.low[1]])];
let scale = 1. + point[0].abs() + point[1].abs() + node.low[0].abs() + node.low[1].abs() + node.high[0].abs() + node.high[1].abs();
let guard = 64. * f64::EPSILON * scale * (1. + ray[0].abs() + ray[1].abs());
// The same subtraction/multiplication order as the original predicate
// bounds every segment endpoint inside the box. Non-finite arithmetic
// cannot prune a node; the old predicate remains the authority.
if guard.is_finite() && values.iter().all(|v| v.is_finite()) && (values.iter().all(|v| *v > guard) || values.iter().all(|v| *v < -guard)) {
return true;
}
match node.children {
Some((left, right)) => self.visit_node(left, point, ray, visitor) && self.visit_node(right, point, ray, visitor),
None => self.segments.get(node.range.clone()).is_some_and(|segments| segments.iter().all(|(a, b)| visitor(*a, *b))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn loop_segments(points: &[Point]) -> Vec<Segment> {
points.iter().copied().zip(points.iter().copied().cycle().skip(1)).take(points.len()).collect()
}
fn winding(segments: impl IntoIterator<Item = Segment>, point: Point, ray: Point) -> Option<i32> {
segments.into_iter().try_fold(0, |counter, (a, b)| winding_edge(counter, a, b, point, ray))
}
fn winding_edge(counter: i32, a: Point, b: Point, c: Point, r: Point) -> Option<i32> {
let a = [a[0] - c[0], a[1] - c[1]];
let b = [b[0] - c[0], b[1] - c[1]];
let s0 = r[0] * a[1] - r[1] * a[0];
let s1 = r[0] * b[1] - r[1] * b[0];
let s2 = a[0] * b[1] - a[1] * b[0];
let x = s2 / (s1 - s0);
if x.abs() < 1e-6 && s0 * s1 < 0. {
None
} else if x > 0. && s0 <= 0. && s1 > 0. {
Some(counter + 1)
} else if x > 0. && s0 >= 0. && s1 < 0. {
Some(counter - 1)
} else {
Some(counter)
}
}
#[test]
fn indexed_trimming_matches_full_scan_for_concavity_holes_and_boundary_points() {
for (center, scale) in [([0., 0.], 1.), ([1e8, -1e8], 1000.), ([0.001, -0.002], 1e-6)] {
let outer: Vec<_> = (0..1001)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 1001.;
let r = 8. - (7. * a).cos();
[center[0] + scale * r * a.cos(), center[1] + scale * r * a.sin()]
})
.collect();
let hole: Vec<_> = (0..64)
.rev()
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 64.;
[center[0] + scale * a.cos(), center[1] + scale * a.sin()]
})
.collect();
let mut segments = loop_segments(&outer);
segments.extend(loop_segments(&hole));
for reverse in [false, true] {
if reverse {
for (a, b) in &mut segments {
std::mem::swap(a, b);
}
}
let index = BoundaryIndex::new(segments.clone());
let mut points = outer.iter().step_by(11).copied().collect::<Vec<_>>();
points.extend(segments.iter().step_by(13).map(|(a, b)| [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5]));
points
.extend((0..400).map(|i| [center[0] + scale * (i % 20) as f64 - scale * 10., center[1] + scale * (i / 20) as f64 - scale * 10.]));
for (i, point) in points.into_iter().enumerate() {
for ray in [[1., 0.], [1., 1e-15], [0., 1.], [(i as f64).cos(), (i as f64).sin()]] {
let expected = winding(segments.iter().copied(), point, ray);
let mut actual = Some(0);
index.visit(point, ray, |a, b| {
actual = actual.and_then(|counter| winding_edge(counter, a, b, point, ray));
actual.is_some()
});
assert_eq!(actual, expected, "point={point:?} ray={ray:?}");
}
}
}
}
}
#[test]
fn index_prunes_dense_boundaries_and_preserves_nonfinite_fallback_and_early_stop() {
let points: Vec<_> = (0..10_000)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 10_000.;
[a.cos(), a.sin()]
})
.collect();
let index = BoundaryIndex::new(loop_segments(&points));
let mut count = 0;
assert!(index.visit([0., 0.], [0.4, 0.8], |_, _| {
count += 1;
true
}));
assert!(count > 0 && count < 200, "visited {count} of 10000 segments");
count = 0;
assert!(!index.visit([0., 0.], [0.4, 0.8], |_, _| {
count += 1;
false
}));
assert_eq!(count, 1);
count = 0;
index.visit([f64::NAN, 0.], [1., 0.], |_, _| {
count += 1;
true
});
assert_eq!(count, 10_000);
for bad in [f64::NAN, f64::INFINITY, 1e200] {
let segments = [([bad, 0.], [1., 0.]), ([0., 1.], [0., 2.])];
let index = BoundaryIndex::new(segments);
count = 0;
index.visit([0., 0.], [1., 0.], |_, _| {
count += 1;
true
});
assert_eq!(count, 2);
}
assert!(BoundaryIndex::default().visit([0., 0.], [1., 0.], |_, _| false));
}
}
+18
View File
@@ -512,3 +512,21 @@ mod triangulation;
#[cfg(feature = "test-hooks")]
#[doc(hidden)]
pub use triangulation::include_test_hooks;
mod boundary_index;
#[allow(dead_code)]
mod verification;
/// Fixed physical chord tolerance; refinement exhaustion and non-finite samples return None.
pub fn robust_triangulation_checked<C: PolylineableCurve, S: RobustMeshableSurface>(
shell: &Shell<Point3, C, S>, tol: f64,
) -> Option<Shell<Point3, PolylineCurve, Option<PolygonMesh>>> {
use std::sync::atomic::{AtomicBool, Ordering};
if !tol.is_finite() || tol <= 0.0 { return None; }
let unresolved = AtomicBool::new(false);
#[cfg(not(target_arch = "wasm32"))]
let result = verification::shell_tessellation(shell, tol, verification::by_search_nearest_parameter, Some(&unresolved));
#[cfg(target_arch = "wasm32")]
let result = verification::shell_tessellation_single_thread(shell, tol, verification::by_search_nearest_parameter, Some(&unresolved));
(!unresolved.load(Ordering::Relaxed)).then_some(result)
}
+876
View File
@@ -0,0 +1,876 @@
//! Fixed-tolerance CAM triangulation, isolated from display-only fallback policies.
#![allow(clippy::many_single_char_names)]
use super::*;
use crate::filters::NormalFilters;
use crate::Point2;
use array_macro::array;
use itertools::Itertools;
use rustc_hash::FxHashMap as HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
// SolidCraft: optional per-call failure flag; ordinary display meshing retains its fallback.
fn mark_unresolved(flag: Option<&AtomicBool>) {
if let Some(flag) = flag { flag.store(true, Ordering::Relaxed); }
}
fn unresolved(flag: Option<&AtomicBool>) -> bool {
flag.is_some_and(|flag| flag.load(Ordering::Relaxed))
}
#[cfg(not(target_arch = "wasm32"))]
use rayon::prelude::*;
type SPoint2 = spade::Point2<f64>;
type Cdt = ConstrainedDelaunayTriangulation<SPoint2>;
type MeshedShell = Shell<Point3, PolylineCurve, Option<PolygonMesh>>;
type MeshedCShell = CompressedShell<Point3, PolylineCurve, Option<PolygonMesh>>;
pub(super) trait SP<S>:
Fn(&S, Point3, Option<(f64, f64)>) -> Option<(f64, f64)> + Parallelizable {
}
impl<S, F> SP<S> for F where F: Fn(&S, Point3, Option<(f64, f64)>) -> Option<(f64, f64)> + Parallelizable {}
/// SolidCraft: a parameter inside the surface's bounded, non-periodic ranges.
fn in_domain<S: ParametricSurface>(surface: &S, (u, v): (f64, f64)) -> bool {
let ok = |x: f64, range: Option<(f64, f64)>, period: Option<f64>| {
x.is_finite()
&& match (range, period) {
(_, Some(_)) | (None, None) => true,
(Some((a, b)), None) => {
let m = (b - a).abs() * 1e-6 + 1e-9;
x >= a.min(b) - m && x <= a.max(b) + m
}
}
};
let (ur, vr) = surface.try_range_tuple();
ok(u, ur, surface.u_period()) && ok(v, vr, surface.v_period())
}
/// SolidCraft: the parameter clamped into the surface's bounded, non-periodic ranges.
fn clamped<S: ParametricSurface>(surface: &S, (u, v): (f64, f64)) -> (f64, f64) {
let c = |x: f64, range: Option<(f64, f64)>, period: Option<f64>| match (range, period) {
(Some((a, b)), None) => x.clamp(a.min(b), a.max(b)),
_ => x,
};
let (ur, vr) = surface.try_range_tuple();
(c(u, ur, surface.u_period()), c(v, vr, surface.v_period()))
}
/// SolidCraft: a nearest-parameter search can run off a B-spline's domain and extrapolate to
/// thousands of parameter units (meshing that grid never finishes). Outside the nominal domain
/// keep the result only if it is really closer to the point than the clamped one (planes report
/// a nominal unit range but extend for ever).
fn sane<S: RobustMeshableSurface>(surface: &S, point: Point3, uv: (f64, f64)) -> (f64, f64) {
if in_domain(surface, uv) {
return uv;
}
let c = clamped(surface, uv);
let d = |(u, v): (f64, f64)| surface.subs(u, v).distance2(point);
if d(uv) <= d(c) { uv } else { c }
}
pub(super) fn by_search_parameter<S>(
surface: &S,
point: Point3,
hint: Option<(f64, f64)>,
) -> Option<(f64, f64)>
where
S: MeshableSurface,
{
surface
.search_parameter(point, hint, 100)
.or_else(|| surface.search_parameter(point, None, 100))
}
pub(super) fn by_search_nearest_parameter<S>(
surface: &S,
point: Point3,
hint: Option<(f64, f64)>,
) -> Option<(f64, f64)>
where
S: RobustMeshableSurface,
{
surface
.search_parameter(point, hint, 100)
.or_else(|| surface.search_parameter(point, None, 100))
.or_else(|| surface.search_nearest_parameter(point, hint, 100).map(|uv| sane(surface, point, uv)))
.or_else(|| surface.search_nearest_parameter(point, None, 100).map(|uv| sane(surface, point, uv)))
}
/// Tessellates faces
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn shell_tessellation<'a, C, S>(
shell: &Shell<Point3, C, S>,
tol: f64,
sp: impl SP<S>,
unresolved_tolerance: Option<&AtomicBool>,
) -> MeshedShell
where
C: PolylineableCurve + 'a,
S: PreMeshableSurface + 'a,
{
let cap = face_cap(shell.len());
let vmap: HashMap<_, _> = shell
.vertex_par_iter()
.map(|v| (v.id(), v.mapped(Point3::clone)))
.collect();
let eset: HashMap<_, _> = shell.edge_par_iter().map(move |e| (e.id(), e)).collect();
let edge_map: HashMap<_, _> = eset
.into_par_iter()
.map(move |(id, edge)| {
let v0 = vmap.get(&edge.absolute_front().id()).unwrap();
let v1 = vmap.get(&edge.absolute_back().id()).unwrap();
let curve = edge.curve();
let poly = bounded_polyline(&curve, curve.range_tuple(), tol, unresolved_tolerance);
(id, Edge::new_unchecked(v0, v1, poly))
})
.collect();
let create_edge = |edge: &Edge<Point3, C>| -> Edge<_, _> {
let new_edge = edge_map.get(&edge.id()).unwrap();
match edge.orientation() {
true => new_edge.clone(),
false => new_edge.inverse(),
}
};
let create_boundary =
|wire: &Wire<Point3, C>| -> Wire<_, _> { wire.edge_iter().map(create_edge).collect() };
let create_face = move |face: &Face<Point3, C, S>| -> Face<_, _, _> {
let wires: Vec<_> = face
.absolute_boundaries()
.iter()
.map(create_boundary)
.collect();
shell_create_polygon(&face.surface(), wires, face.orientation(), tol, &sp, cap, unresolved_tolerance)
};
shell.face_par_iter().map(create_face).collect()
}
/// Tessellates faces
#[cfg(any(target_arch = "wasm32", test))]
pub(super) fn shell_tessellation_single_thread<'a, C, S>(
shell: &'a Shell<Point3, C, S>,
tol: f64,
sp: impl SP<S>,
unresolved_tolerance: Option<&AtomicBool>,
) -> MeshedShell
where
C: PolylineableCurve + 'a,
S: PreMeshableSurface + 'a,
{
use truck_base::entry_map::FxEntryMap as EntryMap;
use truck_topology::Vertex as TVertex;
let cap = face_cap(shell.len());
let mut vmap = EntryMap::new(
move |v: &TVertex<Point3>| v.id(),
move |v| v.mapped(Point3::clone),
);
let mut edge_map = EntryMap::new(
move |edge: &'a Edge<Point3, C>| edge.id(),
move |edge| {
let vf = edge.absolute_front();
let v0 = vmap.entry_or_insert(vf).clone();
let vb = edge.absolute_back();
let v1 = vmap.entry_or_insert(vb).clone();
let curve = edge.curve();
let poly = bounded_polyline(&curve, curve.range_tuple(), tol, unresolved_tolerance);
Edge::new_unchecked(&v0, &v1, poly)
},
);
let mut create_edge = move |edge: &'a Edge<Point3, C>| -> Edge<_, _> {
let new_edge = edge_map.entry_or_insert(edge);
match edge.orientation() {
true => new_edge.clone(),
false => new_edge.inverse(),
}
};
let mut create_boundary = move |wire: &'a Wire<Point3, C>| -> Wire<_, _> {
wire.edge_iter().map(&mut create_edge).collect()
};
let create_face = move |face: &'a Face<Point3, C, S>| -> Face<_, _, _> {
let wires: Vec<_> = face
.absolute_boundaries()
.iter()
.map(&mut create_boundary)
.collect();
shell_create_polygon(&face.surface(), wires, face.orientation(), tol, &sp, cap, unresolved_tolerance)
};
shell.face_iter().map(create_face).collect()
}
/// Tessellates faces
pub(super) fn cshell_tessellation<'a, C, S>(
shell: &CompressedShell<Point3, C, S>,
tol: f64,
sp: impl SP<S>,
) -> MeshedCShell
where
C: PolylineableCurve + 'a,
S: PreMeshableSurface + 'a,
{
let vertices = shell.vertices.clone();
let cap = face_cap(shell.faces.len());
let tessellate_edge = |edge: &CompressedEdge<C>| {
let curve = &edge.curve;
CompressedEdge {
vertices: edge.vertices,
curve: bounded_polyline(curve, curve.range_tuple(), tol, None),
}
};
#[cfg(not(target_arch = "wasm32"))]
let edges: Vec<_> = shell.edges.par_iter().map(tessellate_edge).collect();
#[cfg(target_arch = "wasm32")]
let edges: Vec<_> = shell.edges.iter().map(tessellate_edge).collect();
let tessellate_face = |face: &CompressedFace<S>| {
let boundaries = face.boundaries.clone();
let surface = &face.surface;
let create_edge = |edge_idx: &CompressedEdgeIndex| match edge_idx.orientation {
true => Some(edges.get(edge_idx.index)?.curve.clone()),
false => Some(edges.get(edge_idx.index)?.curve.inverse()),
};
let create_boundary = |wire: &Vec<CompressedEdgeIndex>| {
let wire_iter = wire.iter().filter_map(create_edge);
PolyBoundaryPiece::try_new(surface, wire_iter, &sp)
};
let preboundary: Option<Vec<_>> = boundaries.iter().map(create_boundary).collect();
let polygon: Option<PolygonMesh> = (|| {
let boundary = PolyBoundary::new(preboundary?, &surface, tol);
Some(trimming_tessellation(&surface, &boundary, tol, cap, None))
})();
CompressedFace {
boundaries,
orientation: face.orientation,
surface: polygon,
}
};
#[cfg(not(target_arch = "wasm32"))]
let faces = shell.faces.par_iter().map(tessellate_face).collect();
#[cfg(target_arch = "wasm32")]
let faces = shell.faces.iter().map(tessellate_face).collect();
MeshedCShell {
vertices,
edges,
faces,
}
}
fn shell_create_polygon<S: PreMeshableSurface>(
surface: &S,
wires: Vec<Wire<Point3, PolylineCurve>>,
orientation: bool,
tol: f64,
sp: impl SP<S>,
cap: usize,
unresolved_tolerance: Option<&AtomicBool>,
) -> Face<Point3, PolylineCurve, Option<PolygonMesh>> {
let preboundary = wires
.iter()
.map(|wire: &Wire<_, _>| {
let wire_iter = wire.iter().map(Edge::oriented_curve);
PolyBoundaryPiece::try_new(surface, wire_iter, &sp)
})
.collect::<Option<Vec<_>>>();
let polygon: Option<PolygonMesh> = (|| {
if unresolved(unresolved_tolerance) { return None; }
let boundary = PolyBoundary::new(preboundary?, &surface, tol);
Some(trimming_tessellation(surface, &boundary, tol, cap, unresolved_tolerance))
})();
// SolidCraft: faces with seam edges (a loop running along an edge both ways, as STEP
// writes cylinders) are valid here; `debug_new` asserts simple loops in debug builds.
let mut new_face = Face::new_unchecked(wires, polygon);
if !orientation {
new_face.invert();
}
new_face
}
#[derive(Debug, Default, Clone)]
struct PolyBoundaryPiece(Vec<Point2>);
impl PolyBoundaryPiece {
fn try_new<S: PreMeshableSurface>(
surface: &S,
wire: impl Iterator<Item = PolylineCurve>,
sp: impl SP<S>,
) -> Option<Self> {
let (up, vp) = (surface.u_period(), surface.v_period());
let (urange, vrange) = surface.try_range_tuple();
let mut bdry3d: Vec<Point3> = wire
.flat_map(|poly_edge| {
let n = poly_edge.len() - 1;
poly_edge.into_iter().take(n)
})
.collect();
bdry3d.push(bdry3d[0]);
let mut previous = None;
let mut vec = bdry3d
.into_iter()
.flat_map(|pt| {
let (mut u, mut v) = match sp(surface, pt, previous) {
Some(hint) => hint,
None => return vec![None],
};
if let (Some(up), Some((u0, _))) = (up, previous) {
u = get_mindiff(u, u0, up);
}
if let (Some(vp), Some((_, v0))) = (vp, previous) {
v = get_mindiff(v, v0, vp);
}
let res = (|| {
if let Some((u0, v0)) = previous {
if !u0.near(&u) && surface.uder(u0, v0).so_small() {
return vec![Some(Point2::new(u, v0)), Some(Point2::new(u, v))];
} else if !v0.near(&v) && surface.vder(u0, v0).so_small() {
return vec![Some(Point2::new(u0, v)), Some(Point2::new(u, v))];
}
}
vec![Some(Point2::new(u, v))]
})();
previous = Some((u, v));
res
})
.collect::<Option<Vec<Point2>>>()?;
let grav = vec.iter().fold(Point2::origin(), |g, p| g + p.to_vec()) / vec.len() as f64;
if let (Some(up), Some((u0, _))) = (up, urange) {
let quot = f64::floor((grav.x - u0) / up);
vec.iter_mut().for_each(|p| p.x -= quot * up);
}
if let (Some(vp), Some((v0, _))) = (vp, vrange) {
let quot = f64::floor((grav.y - v0) / vp);
vec.iter_mut().for_each(|p| p.y -= quot * vp);
}
let last = *vec.last().unwrap();
if !vec[0].near(&last) {
let Point2 { x: u0, y: v0 } = last;
if surface.uder(u0, v0).so_small() || surface.vder(u0, v0).so_small() {
vec.push(vec[0]);
}
}
Some(Self(vec))
}
}
fn abs_diff(previous: f64) -> impl Fn(&f64, &f64) -> std::cmp::Ordering {
let f = move |x: &f64| f64::abs(x - previous);
move |x: &f64, y: &f64| f(x).partial_cmp(&f(y)).unwrap()
}
fn get_mindiff(u: f64, u0: f64, up: f64) -> f64 {
let closure = |i| u + i as f64 * up;
(-2..=2).map(closure).min_by(abs_diff(u0)).unwrap()
}
#[derive(Debug, Default, Clone)]
struct PolyBoundary(Vec<Vec<Point2>>, boundary_index::BoundaryIndex);
fn normalize_range(curve: &mut Vec<Point2>, compidx: usize, (u0, u1): (f64, f64)) {
let p = curve[0];
let q = curve[curve.len() - 1];
let tmp = f64::min(p[compidx], q[compidx]) + TOLERANCE;
let del = f64::floor((tmp - u0) / (u1 - u0)) * (u1 - u0);
curve.iter_mut().for_each(|p| p[compidx] -= del);
let Some(i) = curve
.iter()
.position(|p| (curve[0][compidx] - u1) * (p[compidx] - u1) < 0.0)
else {
return;
};
let mut curve1 = curve.split_off(i + 1);
curve1.pop();
curve1.insert(0, curve[i]);
match curve[0][compidx] < curve[curve.len() - 1][compidx] {
true => curve1.iter_mut(),
false => curve.iter_mut(),
}
.for_each(|p| p[compidx] -= u1 - u0);
curve1.append(curve);
*curve = curve1;
}
fn loop_orientation(curve: &[Point2]) -> bool {
curve
.iter()
.circular_tuple_windows()
.fold(0.0, |sum, (p, q)| sum + (q.x + p.x) * (q.y - p.y))
> 0.0
}
impl PolyBoundary {
fn from_loops(loops: Vec<Vec<Point2>>) -> Self {
let index = boundary_index::BoundaryIndex::new(
loops.iter().flat_map(|ring| ring.iter().circular_tuple_windows())
.map(|(a, b)| ([a.x, a.y], [b.x, b.y])),
);
Self(loops, index)
}
fn new(pieces: Vec<PolyBoundaryPiece>, surface: &impl PreMeshableSurface, tol: f64) -> Self {
let (mut closed, mut open) = (Vec::new(), Vec::new());
pieces.into_iter().for_each(|PolyBoundaryPiece(mut vec)| {
match vec[0].distance(vec[vec.len() - 1]) < 1.0e-3 {
true => {
vec.pop();
closed.push(vec)
}
false => open.push(vec),
}
});
fn connect_edges(vecs: impl IntoIterator<Item = Vec<Point2>>) -> Vec<Point2> {
let closure = |vec: Vec<Point2>| {
let len = vec.len();
vec.into_iter().take(len - 1)
};
vecs.into_iter().flat_map(closure).collect()
}
match open.len() {
1 => {
let mut curve = open.pop().unwrap();
let p = curve[0];
let q = curve[curve.len() - 1];
if let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() {
if p.x < q.x - TOLERANCE {
normalize_range(&mut curve, 0, (u0, u1));
let p = curve[0];
let q = curve[curve.len() - 1];
let x = Point2::new(u0, v1);
let y = Point2::new(u1, v1);
let vec0 = polyline_on_surface(surface, q, y, tol);
let vec1 = polyline_on_surface(surface, y, x, tol);
let vec2 = polyline_on_surface(surface, x, p, tol);
closed.push(connect_edges([vec0, vec1, vec2, curve]));
} else if q.x < p.x - TOLERANCE {
normalize_range(&mut curve, 0, (u0, u1));
let p = curve[0];
let q = curve[curve.len() - 1];
let x = Point2::new(u1, v0);
let y = Point2::new(u0, v0);
let vec0 = polyline_on_surface(surface, q, y, tol);
let vec1 = polyline_on_surface(surface, y, x, tol);
let vec2 = polyline_on_surface(surface, x, p, tol);
closed.push(connect_edges([vec0, vec1, vec2, curve]));
} else if p.y < q.y - TOLERANCE {
normalize_range(&mut curve, 1, (v0, v1));
let p = curve[0];
let q = curve[curve.len() - 1];
let x = Point2::new(u0, v0);
let y = Point2::new(u0, v1);
let vec0 = polyline_on_surface(surface, q, y, tol);
let vec1 = polyline_on_surface(surface, y, x, tol);
let vec2 = polyline_on_surface(surface, x, p, tol);
closed.push(connect_edges([vec0, vec1, vec2, curve]));
} else if q.y < p.y - TOLERANCE {
normalize_range(&mut curve, 1, (v0, v1));
let p = curve[0];
let q = curve[curve.len() - 1];
let x = Point2::new(u1, v1);
let y = Point2::new(u1, v0);
let vec0 = polyline_on_surface(surface, q, y, tol);
let vec1 = polyline_on_surface(surface, y, x, tol);
let vec2 = polyline_on_surface(surface, x, p, tol);
closed.push(connect_edges([vec0, vec1, vec2, curve]));
}
}
}
2 => {
let mut curve1 = open.pop().unwrap();
let mut curve0 = open.pop().unwrap();
fn end_pts<T: Copy>(vec: &[T]) -> (T, T) { (vec[0], vec[vec.len() - 1]) }
let ((p0, p1), (q0, q1)) = (end_pts(&curve0), end_pts(&curve1));
if !p0.x.near(&p1.x) && !q0.x.near(&q1.x) {
if let (Some(urange), _) = surface.try_range_tuple() {
normalize_range(&mut curve0, 0, urange);
normalize_range(&mut curve1, 0, urange);
}
} else if !p0.y.near(&p1.y) && !q0.y.near(&q1.y) {
if let (_, Some(vrange)) = surface.try_range_tuple() {
normalize_range(&mut curve0, 1, vrange);
normalize_range(&mut curve1, 1, vrange);
}
}
let ((p0, p1), (q0, q1)) = (end_pts(&curve0), end_pts(&curve1));
let vec0 = polyline_on_surface(surface, p1, q0, tol);
let vec1 = polyline_on_surface(surface, q1, p0, tol);
closed.push(connect_edges([curve0, vec0, curve1, vec1]));
}
_ => {}
}
if !closed.iter().any(|curve| loop_orientation(curve)) {
if let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() {
let p = [
Point2::new(u0, v0),
Point2::new(u1, v0),
Point2::new(u1, v1),
Point2::new(u0, v1),
];
let vec0 = polyline_on_surface(surface, p[0], p[1], tol);
let vec1 = polyline_on_surface(surface, p[1], p[2], tol);
let vec2 = polyline_on_surface(surface, p[2], p[3], tol);
let vec3 = polyline_on_surface(surface, p[3], p[0], tol);
closed.push(connect_edges([vec0, vec1, vec2, vec3]));
}
}
Self::from_loops(closed)
}
/// whether `c` is included in the domain with boundary = `self`.
fn include(&self, c: Point2) -> bool {
let t = 2.0 * std::f64::consts::PI * HashGen::hash1(c);
let r = Vector2::new(f64::cos(t), f64::sin(t));
let mut counter = 0_i32;
let complete = self.1.visit([c.x, c.y], [r.x, r.y], |p0, p1| {
let a = Point2::new(p0[0], p0[1]) - c;
let b = Point2::new(p1[0], p1[1]) - c;
let s0 = r.x * a.y - r.y * a.x;
let s1 = r.x * b.y - r.y * b.x;
let s2 = a.x * b.y - a.y * b.x;
let x = s2 / (s1 - s0);
if x.so_small() && s0 * s1 < 0.0 { return false; }
if x > 0.0 && s0 <= 0.0 && s1 > 0.0 { counter += 1; }
else if x > 0.0 && s0 >= 0.0 && s1 < 0.0 { counter -= 1; }
true
});
complete && counter > 0
}
/// Inserts points and adds constraint into triangulation.
fn insert_to(&self, triangulation: &mut Cdt) {
let points: Vec<_> = self.0.iter().flatten().map(|p| SPoint2::new(spade_round(p.x), spade_round(p.y))).collect();
let key = |p: SPoint2| (if p.x == 0. { 0 } else { p.x.to_bits() }, if p.y == 0. { 0 } else { p.y.to_bits() });
// SolidCraft: bulk insertion avoids quadratic location walks on ordered boundaries.
// Keep the original constraint loop and map duplicate/rounded points by position.
// No constraints are passed to bulk loading, so edge conflicts cannot panic.
let bulk = if triangulation.num_vertices() == 0 { Cdt::bulk_load_cdt(points.clone(), Vec::new()).ok() } else { None };
let poly2tri: Vec<_> = if let Some(bulk) = bulk {
*triangulation = bulk;
let handles: HashMap<_, _> = triangulation.vertices().map(|v| (key(v.position()), v.fix())).collect();
points.iter().map(|p| handles.get(&key(*p)).copied()).collect()
} else {
points.into_iter().map(|p| triangulation.insert(p).ok()).collect()
};
let mut prev: Option<usize> = None;
let mut counter = 0;
self.0
.iter()
.map(Vec::len)
.flat_map(|len| {
let range = counter..counter + len;
counter += len;
range.circular_tuple_windows()
})
.for_each(|(i, j)| {
let Some(vj) = poly2tri[j] else { return };
if let Some(p) = prev {
let Some(v) = poly2tri[p] else { return };
if triangulation.can_add_constraint(v, vj) {
triangulation.add_constraint(v, vj);
prev = None;
}
} else {
let Some(vi) = poly2tri[i] else { return };
if triangulation.can_add_constraint(vi, vj) {
triangulation.add_constraint(vi, vj);
} else {
prev = Some(i);
}
}
});
}
}
fn spade_round(x: f64) -> f64 {
match f64::abs(x) < MIN_ALLOWED_VALUE {
true => 0.0,
false => x,
}
}
/// Tessellates one surface trimmed by polyline.
fn trimming_tessellation<S>(surface: &S, polyboundary: &PolyBoundary, tol: f64, cap: usize, unresolved_tolerance: Option<&AtomicBool>) -> PolygonMesh
where S: PreMeshableSurface {
let mut triangulation = Cdt::new();
polyboundary.insert_to(&mut triangulation);
insert_surface(&mut triangulation, surface, polyboundary, tol, cap, unresolved_tolerance);
if unresolved(unresolved_tolerance) { return PolygonMesh::default(); }
let mut mesh = triangulation_into_polymesh(
triangulation.vertices(),
triangulation.inner_faces(),
surface,
polyboundary,
);
mesh.make_face_compatible_to_normal();
mesh
}
/// Inserts parameter divisions into triangulation.
fn insert_surface(
triangulation: &mut Cdt,
surface: impl PreMeshableSurface,
polyline: &PolyBoundary,
tol: f64,
cap: usize,
unresolved_tolerance: Option<&AtomicBool>,
) {
let bdb: BoundingBox<Point2> = polyline.0.iter().flatten().collect();
let range = ((bdb.min()[0], bdb.max()[0]), (bdb.min()[1], bdb.max()[1]));
if !(range.0 .0.is_finite() && range.0 .1.is_finite() && range.1 .0.is_finite() && range.1 .1.is_finite()) {
mark_unresolved(unresolved_tolerance);
return;
}
let (udiv, vdiv) = bounded_surface_division(&surface, range, tol, cap, unresolved_tolerance);
let (udiv, vdiv) = balanced_grid(&surface, udiv, vdiv, cap, unresolved_tolerance);
if unresolved(unresolved_tolerance) { return; }
let insert_res: Vec<Vec<Option<_>>> = udiv
.into_iter()
.map(|u| {
vdiv.iter()
.map(|v| match polyline.include(Point2::new(u, *v)) {
true => triangulation.insert(SPoint2::new(u, *v)).ok(),
false => None,
})
.collect()
})
.collect();
insert_res.windows(2).for_each(|vec| {
vec[0].windows(2).zip(&vec[1]).for_each(|(a, z)| {
if let Some(x) = a[0] {
if let Some(y) = a[1] {
if triangulation.can_add_constraint(x, y) {
triangulation.add_constraint(x, y);
}
}
if let Some(z) = z {
if triangulation.can_add_constraint(x, *z) {
triangulation.add_constraint(x, *z);
}
}
}
});
let idx = vec[0].len() - 1;
if let (Some(x), Some(y)) = (vec[0][idx], vec[1][idx]) {
if triangulation.can_add_constraint(x, y) {
triangulation.add_constraint(x, y);
}
}
});
}
/// SolidCraft: most grid points a face gets (keeps hostile or huge faces bounded).
pub(super) const MAX_GRID: usize = 150_000;
/// SolidCraft: grid points shared by all faces of one shell; many faces get fewer each.
pub(super) const SHELL_GRID_BUDGET: usize = 10_000_000;
/// SolidCraft: most points on one edge's polyline.
const MAX_EDGE_POINTS: usize = 20_000;
/// SolidCraft: the grid budget of each face of a shell with `faces` faces.
pub(super) fn face_cap(faces: usize) -> usize {
(SHELL_GRID_BUDGET / faces.max(1)).clamp(2_000, MAX_GRID)
}
/// SolidCraft: truck's curve division, bounded: at most `MAX_EDGE_POINTS` points and a
/// bisection depth of 24; a midpoint that does not evaluate (NaN) counts as flat (truck's
/// version recursed on it 100 levels deep, which never finishes).
fn bounded_polyline<C: PolylineableCurve>(curve: &C, range: (f64, f64), tol: f64, unresolved_tolerance: Option<&AtomicBool>) -> PolylineCurve {
let mut out = vec![curve.subs(range.0)];
let mut stack: Vec<(f64, f64, usize)> = vec![(range.0, range.1, 0)];
while let Some((a, b, depth)) = stack.pop() {
let (pa, pb) = (curve.subs(a), curve.subs(b));
let t = a + (b - a) * 0.4729;
let chord = pa + (pb - pa) * 0.4729;
let d2 = curve.subs(t).distance2(chord);
let tm = 0.5 * (a + b);
let dm = curve.subs(tm).distance2(pa.midpoint(pb));
if !d2.is_finite() || !dm.is_finite() { mark_unresolved(unresolved_tolerance); }
let flat = !(d2 >= tol * tol || dm >= tol * tol);
if flat || depth >= 24 || out.len() + stack.len() >= MAX_EDGE_POINTS {
if !flat { mark_unresolved(unresolved_tolerance); }
out.push(pb);
} else {
// Right half after the left one (stack order).
stack.push((tm, b, depth + 1));
stack.push((a, tm, depth + 1));
}
}
PolylineCurve(out)
}
/// SolidCraft: truck's surface division, bounded: stops refining when the grid would exceed
/// `cap` points or after 24 rounds; cells that do not evaluate (NaN) are not refined.
fn bounded_surface_division<S: PreMeshableSurface>(surface: &S, (ur, vr): ((f64, f64), (f64, f64)), tol: f64, cap: usize, unresolved_tolerance: Option<&AtomicBool>) -> (Vec<f64>, Vec<f64>) {
let (mut udiv, mut vdiv) = (vec![ur.0, ur.1], vec![vr.0, vr.1]);
for iteration in 0..24 {
let mut fu = vec![false; udiv.len() - 1];
let mut fv = vec![false; vdiv.len() - 1];
for (i, u) in udiv.windows(2).enumerate() {
for (j, v) in vdiv.windows(2).enumerate() {
if fu[i] && fv[j] {
continue;
}
let (p, q) = (0.4729, 0.5271);
let (u0, v0) = (u[0] * (1.0 - p) + u[1] * p, v[0] * (1.0 - q) + v[1] * q);
let p0 = surface.subs(u0, v0);
let pt00 = surface.subs(u[0], v[0]).to_vec();
let pt01 = surface.subs(u[0], v[1]).to_vec();
let pt10 = surface.subs(u[1], v[0]).to_vec();
let pt11 = surface.subs(u[1], v[1]).to_vec();
let pt = Point3::from_vec(pt00 * (1.0 - p) * (1.0 - q) + pt01 * (1.0 - p) * q + pt10 * p * (1.0 - q) + pt11 * p * q);
let deviation = p0.distance2(pt);
if !deviation.is_finite() { mark_unresolved(unresolved_tolerance); }
let far = deviation > tol * tol;
// Which direction bends: refine only that one (a cylinder needs no division
// along its rulings).
let alu = surface.subs(u[0], v0).to_vec() * (1.0 - p) + surface.subs(u[1], v0).to_vec() * p;
let alv = surface.subs(u0, v[0]).to_vec() * (1.0 - q) + surface.subs(u0, v[1]).to_vec() * q;
let du = p0.distance2(Point3::from_vec(alu));
let dv = p0.distance2(Point3::from_vec(alv));
if !du.is_finite() || !dv.is_finite() { mark_unresolved(unresolved_tolerance); }
let bend_u = du > tol * tol;
let bend_v = dv > tol * tol;
if bend_u || bend_v {
fu[i] |= bend_u;
fv[j] |= bend_v;
} else if far {
fu[i] = true;
fv[j] = true;
}
}
}
let nu = udiv.len() + fu.iter().filter(|x| **x).count();
let nv = vdiv.len() + fv.iter().filter(|x| **x).count();
if nu == udiv.len() && nv == vdiv.len() {
break;
}
if nu.saturating_mul(nv) > cap || (iteration == 23 && unresolved_tolerance.is_some()) {
mark_unresolved(unresolved_tolerance);
break;
}
let refine = |d: &[f64], f: &[bool]| {
let mut n = vec![d[0]];
for (w, b) in d.windows(2).zip(f) {
if *b {
n.push(0.5 * (w[0] + w[1]));
}
n.push(w[1]);
}
n
};
udiv = refine(&udiv, &fu);
vdiv = refine(&vdiv, &fv);
}
(udiv, vdiv)
}
/// SolidCraft: refine the parameter grid so its cells are not much longer in one direction than
/// the other. A ruled surface (cylinder, cone) needs no division along its rulings, and the
/// triangles that then join a trimming loop to the far ends of the grid cut through the
/// surface (a cylinder with a hole lost volume this way).
fn balanced_grid(surface: &impl PreMeshableSurface, udiv: Vec<f64>, vdiv: Vec<f64>, cap: usize, unresolved_tolerance: Option<&AtomicBool>) -> (Vec<f64>, Vec<f64>) {
let mid = |d: &[f64]| if d.is_empty() { 0.0 } else { d[d.len() / 2] };
let (um, vm) = (mid(&udiv), mid(&vdiv));
let step = |d: &[f64], along_u: bool| -> Vec<f64> {
d.windows(2)
.map(|w| {
let (a, b) = (w[0], w[1]);
if along_u { surface.subs(a, vm).distance(surface.subs(b, vm)) } else { surface.subs(um, a).distance(surface.subs(um, b)) }
})
.collect()
};
let (lu, lv) = (step(&udiv, true), step(&vdiv, false));
let typical = |l: &[f64]| {
let mut v: Vec<f64> = l.iter().copied().filter(|x| x.is_finite() && *x > 0.0).collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if v.is_empty() { None } else { Some(v[v.len() / 2]) }
};
let refine = |d: &[f64], l: &[f64], target: Option<f64>| -> Vec<f64> {
let Some(t) = target.filter(|t| *t > 0.0) else { return d.to_vec() };
// At most 24 cells along a ruling: enough for holes and trims to get short triangles.
let total: f64 = l.iter().filter(|x| x.is_finite()).sum();
let t = t.max(total / 24.0 / 4.0);
let mut out = Vec::with_capacity(d.len());
for (i, w) in d.windows(2).enumerate() {
let n = l.get(i).map(|x| (x / (4.0 * t)).ceil()).filter(|n| n.is_finite()).unwrap_or(1.0).clamp(1.0, 64.0) as usize;
for k in 0..n {
out.push(w[0] + (w[1] - w[0]) * k as f64 / n as f64);
}
}
if let Some(last) = d.last() {
out.push(*last);
}
out
};
// Only a direction left undivided (a ruling) needs it; curved directions already follow
// the tolerance.
let u2 = if udiv.len() <= 3 { refine(&udiv, &lu, typical(&lv)) } else { udiv.clone() };
let v2 = if vdiv.len() <= 3 { refine(&vdiv, &lv, typical(&lu)) } else { vdiv.clone() };
let (u2, v2) = if u2.len().saturating_mul(v2.len()) > cap { (udiv, vdiv) } else { (u2, v2) };
// Never more than MAX_GRID points: thin out evenly (the mesh gets coarser, not endless).
let total = u2.len().saturating_mul(v2.len());
if total <= cap {
return (u2, v2);
}
mark_unresolved(unresolved_tolerance);
let f = (total as f64 / cap.max(4) as f64).sqrt();
let thin = |d: Vec<f64>| -> Vec<f64> {
let n = ((d.len() as f64 / f).ceil() as usize).max(2);
if d.len() <= n {
return d;
}
(0..n).map(|i| d[i * (d.len() - 1) / (n - 1)]).collect()
};
(thin(u2), thin(v2))
}
/// Converts triangulation into `PolygonMesh`.
fn triangulation_into_polymesh<'a>(
vertices: VertexIterator<'a, SPoint2, (), CdtEdge<()>, ()>,
triangles: InnerFaceIterator<'a, SPoint2, (), CdtEdge<()>, ()>,
surface: &impl ParametricSurface3D,
polyline: &PolyBoundary,
) -> PolygonMesh {
let mut positions = Vec::<Point3>::new();
let mut uv_coords = Vec::<Vector2>::new();
let mut normals = Vec::<Vector3>::new();
let vmap: HashMap<_, _> = vertices
.enumerate()
.map(|(i, v)| {
let p = *v.as_ref();
let uv = Vector2::new(p.x, p.y);
positions.push(surface.subs(uv[0], uv[1]));
uv_coords.push(uv);
normals.push(surface.normal(uv[0], uv[1]));
(v.fix(), i)
})
.collect();
let tri_faces: Vec<[StandardVertex; 3]> = triangles
.map(|tri| tri.vertices())
.filter(|tri| {
fn sp2cg(p: SPoint2) -> Point2 { Point2::new(p.x, p.y) }
let tri = array![i => sp2cg(*tri[i].as_ref()); 3];
let (a, b) = (tri[1] - tri[0], tri[2] - tri[0]);
let c = tri[0] + (a + b) / 3.0;
let area = a.x * b.y - a.y * b.x;
polyline.include(c) && !area.so_small2()
})
.map(|tri| {
let idcs = array![i => vmap[&tri[i].fix()]; 3];
array![i => [idcs[i], idcs[i], idcs[i]].into(); 3]
})
.collect();
PolygonMesh::debug_new(
StandardAttributes {
positions,
uv_coords,
normals,
},
Faces::from_tri_and_quad_faces(tri_faces, Vec::new()),
)
}
fn polyline_on_surface(
surface: impl PreMeshableSurface,
p: Point2,
q: Point2,
tol: f64,
) -> Vec<Point2> {
use truck_geometry::prelude::*;
let line = Line(p, q);
let pcurve = PCurve::new(line, surface);
let (vec, _) = pcurve.parameter_division(pcurve.range_tuple(), tol);
vec.into_iter().map(|t| line.subs(t)).collect()
}
+1
View File
@@ -6,5 +6,6 @@ license.workspace = true
publish = false
[dependencies]
solidcraft-cam = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
+352
View File
@@ -0,0 +1,352 @@
//! CAM analytic validation and local-only Fusion oracle reports.
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use solidcraft_cam::{self as cam, JobInput, nc, verify};
use std::path::{Path, PathBuf};
mod reference;
#[cfg(test)]
mod tests;
const COMPARISON_VERSION: &str = "cam-oracle/2";
fn write(path: &Path, content: impl AsRef<[u8]>) -> Result<(), String> {
std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
}
fn json_file(path: &Path) -> Result<Value, String> {
let bytes = read(path)?;
serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))
}
fn read(path: &Path) -> Result<Vec<u8>, String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.len() > 128 * 1024 * 1024 {
return Err("oracle artifact exceeds 128 MiB".into());
}
std::fs::read(path).map_err(|e| e.to_string())
}
fn pretty(value: &Value) -> Result<String, String> {
serde_json::to_string_pretty(value).map_err(|e| e.to_string())
}
fn directory(path: &Path) -> Result<(), String> {
std::fs::create_dir_all(path).map_err(|e| e.to_string())
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
pub fn validate(root: &Path) -> Result<(), String> {
let out = root.join("target/cam-validation");
directory(&out)?;
let mut cases = Vec::new();
for &name in cam::corpus::CASES {
let result = (|| -> Result<Value, String> {
let input = cam::corpus::fixture(name).map_err(|e| e.to_string())?;
let path = cam::generate::generate(input.clone()).map_err(|e| e.to_string())?;
let mut stages = Vec::new();
for completed in 1..=input.operations.len() {
let operation = input.operations.get(completed - 1).ok_or("missing operation")?;
let end = path.blocks.iter().rposition(|b| b.operation == operation.id).ok_or("missing operation motions")? + 1;
// Validate prefixes of the actual complete program, not independently
// generated replacements. Final protected geometry remains intact.
let mut stage_input = input.clone();
stage_input.operations.truncate(completed);
stage_input.fingerprint.clear();
stage_input.fingerprint = cam::fingerprint(&stage_input).map_err(|e| e.to_string())?;
let stage_path = cam::Toolpath { fingerprint: stage_input.fingerprint.clone(), blocks: path.blocks[..end].to_vec() };
let mut dir = out.join(name);
if completed != input.operations.len() {
dir = dir.join(format!("after-{completed}"));
}
let mut stage = validate_stage(&dir, name, completed, &stage_input, &stage_path)?;
stage["operation"] = json!(operation.id);
stage["artifacts"] = json!(if completed == input.operations.len() { ".".to_string() } else { format!("after-{completed}") });
stages.push(stage);
}
let mut summary = stages.last().cloned().ok_or("empty fixture")?;
summary["case"] = json!(name);
summary["pass"] = json!(stages.iter().all(|s| s["pass"] == true));
summary["stages"] = json!(stages);
Ok(summary)
})();
cases.push(result.unwrap_or_else(|e| json!({"case":name,"pass":false,"error":e})));
}
report(&out, &cases, "Analytic CAM validation")?;
println!("CAM validation: {}/{} passed → {}", cases.iter().filter(|v| v["pass"] == true).count(), cases.len(), out.display());
if cases.iter().all(|v| v["pass"] == true) { Ok(()) } else { Err("CAM analytic validation failed; see report.json".into()) }
}
fn validate_stage(dir: &Path, name: &str, completed: usize, input: &JobInput, path: &cam::Toolpath) -> Result<Value, String> {
let parsed = nc::round_trip(path, &input.setup, &input.tools).map_err(|e| e.to_string())?;
let report = verify::simulate(input, &parsed, Default::default()).map_err(|e| e.to_string())?;
let mut probes = Vec::new();
for (xy, expected) in cam::corpus::probes(name, completed).map_err(|e| e.to_string())? {
let actual = verify::probe_height(input, &parsed, xy).map_err(|e| e.to_string())?;
probes.push(json!({"xy":xy,"expected":expected,"actual":actual,"pass":(actual-expected).abs()<=cam::TOLERANCE}));
}
directory(dir)?;
write(&dir.join("input.json"), pretty(&json!(input))?)?;
write(&dir.join("toolpath.json"), pretty(&json!(path))?)?;
let code = nc::post(path, &input.setup).map_err(|e| e.to_string())?;
write(&dir.join("program.nc"), &code)?;
write(&dir.join("solve.html"), program_html(&code, &report))?;
write(&dir.join("stock-difference.svg"), stock_overlay(input, &report))?;
write(&dir.join("validation.json"), pretty(&json!(report))?)?;
let stock = verify::stock_stl(&report, input.setup.stock.min.z).map_err(|e| e.to_string())?;
write(&dir.join("stock.stl"), &stock)?;
let mesh = cam::compare::read_stl(stock.as_bytes()).map_err(|e| e.to_string())?;
let exported = cam::compare::stock_mesh(&report, &mesh, input.setup.stock.min.z, cam::TOLERANCE).map_err(|e| e.to_string())?;
let mut export_values = json!(exported);
export_values["maximum_added_height_budget"] = json!(cam::STOCK_EXPORT_ERROR);
write(&dir.join("stock-export-validation.json"), pretty(&export_values)?)?;
write(&dir.join("paths.svg"), overlay(input, &parsed, None)?)?;
let values = json!({"setup":input.setup,"tools":input.tools,"operations":input.operations});
write(&dir.join("setup-sheet-values.json"), pretty(&values)?)?;
write(
&dir.join("setup-sheet.html"),
format!(
"<!doctype html><meta charset=utf-8><title>Analytic CAM setup</title><h1>Analytic CAM setup</h1><p>Lengths: mm; feeds: mm/min; angles: radians. Generator: {}. Input: {}</p><pre>{}</pre>",
cam::VERSION,
esc(&input.fingerprint),
esc(&pretty(&values)?)
),
)?;
let pass = report.status == verify::Status::Pass
&& report.finished_dimensions == verify::Status::Pass
&& report.stock_complete
&& exported.status == verify::Status::Pass
&& probes.iter().all(|p| p["pass"] == true);
Ok(json!({"pass":pass,"status":report.status,"finished_dimensions":report.finished_dimensions,"probes":probes,
"stock_export":export_values,"cells":report.cells.len(),"work":report.work,"metrics":metrics(input,&parsed,&report)?,
"findings":report.findings.iter().take(5).collect::<Vec<_>>()}))
}
fn metrics(input: &JobInput, parsed: &nc::Parsed, report: &verify::Report) -> Result<Value, String> {
let segments = nc::segments(parsed, &input.setup).map_err(|e| e.to_string())?;
let tools: Vec<_> =
parsed.records.iter().filter_map(|r| if let cam::Motion::Tool { number, .. } = r.motion { Some(number) } else { None }).collect();
let retracts = segments.iter().filter(|s| s.rapid && s.to.z > s.from.z + 1e-9).count();
Ok(json!({"tool_order":tools,"retract_count":retracts,"cutting_distance":report.cutting_distance,
"rapid_distance":report.rapid_distance,"cutting_minutes":report.cutting_minutes,
"remaining_volume":[report.remaining_volume_low,report.remaining_volume_high]}))
}
pub fn oracle(root: &Path, args: &[&str]) -> Result<(), String> {
let dir = args.windows(2).find(|w| w[0] == "--dir").map(|w| PathBuf::from(w[1])).ok_or("cam-oracle requires --dir LOCAL_ORACLE_DIRECTORY")?;
let canonical = std::fs::canonicalize(&dir).map_err(|e| e.to_string())?;
if canonical.starts_with(root) {
let ignored = std::process::Command::new("git")
.current_dir(root)
.args(["check-ignore", "--quiet", "--"])
.arg(&canonical)
.status()
.map_err(|e| e.to_string())?;
if !ignored.success() {
return Err("Fusion storage inside this repository must be git-ignored (use plan/fusion/cam)".into());
}
}
let out = dir.join("comparison");
directory(&out)?;
let mut cases = Vec::new();
for name in ["face", "contour", "pocket", "drill"] {
let case = dir.join(name);
let value = compare_case(&case, &out.join(name)).unwrap_or_else(|e| json!({"pass":false,"status":"incomplete","error":e}));
let mut value = value;
value["case"] = json!(name);
cases.push(value);
}
report(&out, &cases, "Local Fusion CAM comparison")?;
let _ = root;
println!("Fusion CAM oracle: {}/{} passed → {}", cases.iter().filter(|v| v["pass"] == true).count(), cases.len(), out.display());
if cases.iter().all(|v| v["pass"] == true) {
Ok(())
} else {
Err("Fusion parity unverified: missing, failed or inconclusive reference cases".into())
}
}
fn compare_case(dir: &Path, out: &Path) -> Result<Value, String> {
let manifest = json_file(&dir.join("manifest.json"))?;
for field in ["fusion_version", "post_sha256", "captured_at", "input_fingerprint"] {
if manifest[field].as_str().is_none_or(str::is_empty) {
return Err(format!("missing reference provenance: {field}"));
}
}
let digest = format!("{:x}", Sha256::digest(read(&dir.join("post.cps"))?));
if manifest["post_sha256"] != digest {
return Err("reference post hash mismatch".into());
}
if manifest["capture_complete"] != true {
return Err("reference capture is incomplete".into());
}
let input_value = json_file(&dir.join("input.json"))?;
let reference_value = json_file(&dir.join("reference-input.json"))?;
if input_value != reference_value {
return Err("SolidCraft and reference inputs differ; comparison invalid".into());
}
let input: JobInput = serde_json::from_value(input_value).map_err(|e| e.to_string())?;
if manifest["input_fingerprint"] != input.fingerprint {
return Err("manifest input fingerprint mismatch".into());
}
let mut unhashed = input.clone();
unhashed.fingerprint.clear();
if cam::fingerprint(&unhashed).map_err(|e| e.to_string())? != input.fingerprint {
return Err("input fingerprint does not describe supplied geometry and settings".into());
}
let capture = reference::validate(dir, &manifest, &input)?;
let reference = String::from_utf8(read(&dir.join("reference.nc"))?).map_err(|e| e.to_string())?;
let reference_code = reference;
let reference = nc::parse(&reference_code, &input.tools).map_err(|e| e.to_string())?;
let path = cam::generate::generate(input.clone()).map_err(|e| e.to_string())?;
let own = nc::round_trip(&path, &input.setup, &input.tools).map_err(|e| e.to_string())?;
let ours = verify::simulate(&input, &own, Default::default()).map_err(|e| e.to_string())?;
let mut theirs = verify::simulate(&input, &reference, Default::default()).map_err(|e| e.to_string())?;
// The verifier interprets the reference dialect, but did not create this post.
// Attribute the imported program to its captured post rather than our own.
theirs.post_version = format!("{}; sha256={digest}", manifest["post_identity"].as_str().unwrap_or_default());
directory(out)?;
write(&out.join("solve.html"), program_html(&nc::post(&path, &input.setup).map_err(|e| e.to_string())?, &ours))?;
write(&out.join("fusion.html"), program_html(&reference_code, &theirs))?;
write(&out.join("stock-difference.svg"), stock_overlay(&input, &ours))?;
write(&out.join("solve-validation.json"), pretty(&json!(ours))?)?;
write(&out.join("fusion-nc-validation.json"), pretty(&json!(theirs))?)?;
write(&out.join("paths.svg"), overlay(&input, &own, Some(&reference))?)?;
let mesh = cam::compare::read_stl(&read(&dir.join("stock.stl"))?).map_err(|e| e.to_string())?;
let stock = cam::compare::stock_reports(&ours, &theirs, cam::TOLERANCE).map_err(|e| e.to_string())?;
let mesh_check = cam::compare::stock_mesh(&ours, &mesh, input.setup.stock.min.z, cam::TOLERANCE).map_err(|e| e.to_string())?;
let reference_mesh_check = cam::compare::stock_mesh(&theirs, &mesh, input.setup.stock.min.z, cam::TOLERANCE).map_err(|e| e.to_string())?;
let metadata = json_file(&dir.join("setup-sheet-values.json"))?;
let setup_values_match =
metadata["setup"] == json!(input.setup) && metadata["tools"] == json!(input.tools) && metadata["operations"] == json!(input.operations);
let status = combined_status([
ours.status,
theirs.status,
ours.finished_dimensions,
theirs.finished_dimensions,
stock.status,
mesh_check.status,
reference_mesh_check.status,
capture.simulation_status,
if setup_values_match { verify::Status::Pass } else { verify::Status::Fail },
]);
let pass = status == verify::Status::Pass;
let solve_metrics = metrics(&input, &own, &ours)?;
let reference_metrics = metrics(&input, &reference, &theirs)?;
Ok(json!({"pass":pass,"status":status,"comparison_version":COMPARISON_VERSION,"provenance":capture.provenance,
"solve":ours.status,"reference":theirs.status,"finished_dimensions":[ours.finished_dimensions,theirs.finished_dimensions],
"stock":stock,"reference_stl":mesh_check,"reference_nc_vs_reference_stl":reference_mesh_check,
"setup_values_match":setup_values_match,"distance_delta":ours.cutting_distance-theirs.cutting_distance,
"minutes_delta":ours.cutting_minutes-theirs.cutting_minutes,
"volume_intervals":[[ours.remaining_volume_low,ours.remaining_volume_high],[theirs.remaining_volume_low,theirs.remaining_volume_high]],
"metrics":{"solve":solve_metrics,"reference":reference_metrics}}))
}
fn combined_status(statuses: impl IntoIterator<Item = verify::Status>) -> verify::Status {
statuses.into_iter().fold(verify::Status::Pass, |result, next| match (result, next) {
(verify::Status::Fail, _) | (_, verify::Status::Fail) => verify::Status::Fail,
(verify::Status::Inconclusive, _) | (_, verify::Status::Inconclusive) => verify::Status::Inconclusive,
_ => verify::Status::Pass,
})
}
fn overlay(input: &JobInput, a: &nc::Parsed, b: Option<&nc::Parsed>) -> Result<String, String> {
let min = input.setup.stock.min;
let max = input.setup.stock.max;
let w = (max.x - min.x).max(1.0);
let h = (max.y - min.y).max(1.0);
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{} {} {} {}\"><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#101820\"/>",
min.x - 5.0,
min.y - 5.0,
w + 10.0,
h + 10.0,
min.x - 5.0,
min.y - 5.0,
w + 10.0,
h + 10.0
);
for (program, color, name) in std::iter::once((a, "#42c4ff", "solve")).chain(b.map(|b| (b, "#ffb347", "fusion"))) {
for s in nc::segments(program, &input.setup).map_err(|e| e.to_string())? {
svg.push_str(&format!(
"<a href=\"{name}.html#L{line}\"><path d=\"M {} {} L {} {}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"{}\"><title>NC line {}</title></path></a>",
s.from.x,
s.from.y,
s.to.x,
s.to.y,
color,
w / 400.0,
if s.rapid { 0.25 } else { 0.8 },
s.line,
line = s.line
));
}
}
svg.push_str("</svg>");
Ok(svg)
}
fn report(out: &Path, cases: &[Value], title: &str) -> Result<(), String> {
write(&out.join("report.json"), pretty(&json!({"version":cam::VERSION,"comparison_version":COMPARISON_VERSION,"cases":cases}))?)?;
let mut html = format!(
"<!doctype html><meta charset=utf-8><title>{title}</title><style>body{{font:16px system-ui;margin:40px;max-width:1100px}}pre{{white-space:pre-wrap;background:#eee;padding:16px}}img{{width:480px}}a{{color:#145aa8}}</style><h1>{title}</h1><p>Blue: SolidCraft; orange: Fusion. Path and time differences are diagnostic, not equivalence gates.</p>"
);
for c in cases {
let name = c["case"].as_str().unwrap_or("unknown");
html.push_str(&format!(
"<h2>{}</h2><pre>{}</pre><a href=\"{}/paths.svg\">Toolpath overlay</a><br><img src=\"{}/paths.svg\" alt=\"Toolpath overlay\">",
esc(name),
esc(&pretty(c)?),
esc(name),
esc(name)
));
html.push_str(&format!("<p><a href=\"{name}/solve.html\">SolidCraft line-linked diagnostics</a></p><img src=\"{name}/stock-difference.svg\" alt=\"Remaining material relative to protected geometry\">"));
if let Some(stages) = c["stages"].as_array() {
html.push_str("<h3>After each operation</h3><ul>");
for stage in stages {
let directory = esc(stage["artifacts"].as_str().unwrap_or("."));
html.push_str(&format!("<li>Operation {}: {} — <a href=\"{name}/{directory}/solve.html\">NC and diagnostics</a>, <a href=\"{name}/{directory}/setup-sheet.html\">setup sheet</a>, <a href=\"{name}/{directory}/stock.stl\">stock STL</a>, <a href=\"{name}/{directory}/stock-export-validation.json\">stock export comparison</a></li>",stage["operation"],if stage["pass"] == true { "pass" } else { "failed or inconclusive" }));
}
html.push_str("</ul>");
}
}
write(&out.join("report.html"), html)
}
fn program_html(code: &str, validation: &verify::Report) -> String {
let mut html = String::from(
"<!doctype html><meta charset=utf-8><title>NC diagnostics</title><style>body{font:16px system-ui;margin:30px}pre{line-height:1.6}pre a{display:block;color:inherit;text-decoration:none}a:target{background:#ffc}code{white-space:pre}</style><h1>NC diagnostics</h1><ul>",
);
for f in &validation.findings {
let location = f.source.map(|source| format!("Operation {}, motion {} · ", source.operation, source.block)).unwrap_or_default();
let label = esc(&format!("{location}{}: {}", f.kind, f.detail));
if let Some(line) = f.line {
html.push_str(&format!("<li><a href=\"#L{line}\">Line {line}: {label}</a></li>"));
} else {
html.push_str(&format!("<li>{label}</li>"));
}
}
html.push_str("</ul><pre>");
for (i, line) in code.lines().enumerate() {
let n = i + 1;
html.push_str(&format!("<a id=\"L{n}\" href=\"#L{n}\">{n:6} <code>{}</code></a>", esc(line)));
}
html.push_str("</pre>");
html
}
fn stock_overlay(input: &JobInput, validation: &verify::Report) -> String {
let min = input.setup.stock.min;
let max = input.setup.stock.max;
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{} {} {} {}\"><title>Sampled stock height above protected geometry; diagnostic only. Orange: remaining stock; red: definite gouge; blue: within tolerance.</title>",
min.x,
min.y,
max.x - min.x,
max.y - min.y
);
let step = validation.cells.len().div_ceil(20_000).max(1);
for c in validation.cells.iter().step_by(step) {
let excess = c.low - c.target_high;
let gouge = c.target_low - c.high;
let color = if gouge > validation.tolerance {
"#e33"
} else if excess > validation.tolerance {
"#fa3"
} else {
"#29a6d2"
};
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{color}\"><title>Height [{:.6}, {:.6}] mm; excess at least {:.6} mm</title></rect>",c.min[0],c.min[1],c.max[0]-c.min[0],c.max[1]-c.min[1],c.low,c.high,excess));
}
svg.push_str("</svg>");
svg
}
+104
View File
@@ -0,0 +1,104 @@
//! Completeness and provenance of local reference captures; no vendor API dependency.
use super::*;
#[derive(Debug)]
pub(super) struct Capture {
pub simulation_status: verify::Status,
pub provenance: Value,
}
pub(super) fn validate(dir: &Path, manifest: &Value, input: &JobInput) -> Result<Capture, String> {
for field in ["post_identity", "source_geometry"] {
if manifest[field].as_str().is_none_or(|s| s.trim().is_empty()) {
return Err(format!("missing reference provenance: {field}"));
}
}
if manifest["capabilities"]["license"].as_str().is_none_or(|s| s.trim().is_empty())
|| manifest["capabilities"]["strategies"]
.as_array()
.is_none_or(|v| v.is_empty() || v.iter().any(|s| s.as_str().is_none_or(|s| s.trim().is_empty())))
{
return Err("capture must record Fusion license/capabilities".into());
}
let source = manifest["source_geometry"].as_str().ok_or("missing source geometry")?;
if Path::new(source).components().count() != 1 || !matches!(Path::new(source).components().next(), Some(std::path::Component::Normal(_))) {
return Err("source geometry must name a file in the reference package".into());
}
let extension = Path::new(source).extension().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
if !matches!(extension.as_str(), "step" | "stp" | "stl" | "f3d" | "iges" | "igs" | "obj") {
return Err("source geometry must be a captured CAD or mesh artifact".into());
}
let artifacts = manifest["artifact_sha256"].as_object().ok_or("missing captured artifact hashes")?;
for name in [
source,
"input.json",
"reference-input.json",
"reference.nc",
"setup-sheet.html",
"setup-sheet-values.json",
"stock.stl",
"simulation.json",
"post-properties.json",
] {
let bytes = read(&dir.join(name))?;
if bytes.is_empty() {
return Err(format!("empty artifact {name}"));
}
let actual = format!("{:x}", Sha256::digest(bytes));
if artifacts.get(name).and_then(Value::as_str) != Some(actual.as_str()) {
return Err(format!("missing or mismatched captured artifact hash: {name}"));
}
}
let properties = json_file(&dir.join("post-properties.json"))?;
if !properties.is_object() && !properties.is_array() {
return Err("post properties must be a captured JSON object or array".into());
}
let simulation = json_file(&dir.join("simulation.json"))?;
if simulation["complete"] != true || simulation["at_program_end"] != true {
return Err("reference simulation/end-position capture is incomplete".into());
}
let findings = simulation["findings"].as_array().ok_or("reference simulation findings were not captured")?;
let order: Vec<_> = input.operations.iter().map(|op| op.id).collect();
if simulation["completed_operations"] != json!(order) {
return Err("reference simulation operation order or completeness differs".into());
}
if simulation["stock_after_operation"] != json!(order.last()) {
return Err("reference stock is not captured after the final operation".into());
}
if simulation["accuracy"]["setting"].as_str().is_none_or(|s| s.trim().is_empty()) {
return Err("reference simulation accuracy setting was not captured".into());
}
let resolution = &simulation["accuracy"]["resolution_mm"];
if !resolution.as_f64().is_some_and(|v| v.is_finite() && v > 0.)
&& !(resolution.is_null() && simulation["accuracy"]["resolution_status"] == "not_reported")
{
return Err("reference simulation resolution must be positive or explicitly not_reported".into());
}
let mut simulation_status =
serde_json::from_value(simulation["status"].clone()).map_err(|e| format!("invalid reference simulation status: {e}"))?;
for finding in findings {
if finding["detail"].as_str().is_none_or(|s| s.trim().is_empty()) {
return Err("reference simulation finding lacks detail".into());
}
let status = match finding["severity"].as_str() {
Some("error") => verify::Status::Fail,
Some("unresolved") => verify::Status::Inconclusive,
Some("information") => verify::Status::Pass,
_ => return Err("reference simulation finding has unknown severity".into()),
};
simulation_status = combined_status([simulation_status, status]);
}
Ok(Capture {
simulation_status,
provenance: json!({
"fusion_version":manifest["fusion_version"], "captured_at":manifest["captured_at"],
"input_fingerprint":input.fingerprint, "generator_version":cam::VERSION,
"solve_post":nc::DIALECT, "reference_post":manifest["post_identity"],
"reference_post_sha256":manifest["post_sha256"], "post_properties":properties,
"capabilities":manifest["capabilities"], "source_geometry":source,
"artifact_sha256":artifacts, "reference_simulation":simulation,
"verifier_uncertainty_mm":cam::VERIFY_ERROR, "post_error_budget_mm":cam::POST_ERROR,
"comparison_tolerance_mm":cam::TOLERANCE,
}),
})
}
+173
View File
@@ -0,0 +1,173 @@
//! Original analytic capture packages. These are not Fusion observations or parity evidence.
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct Package(PathBuf);
impl Drop for Package {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn box_stl(top: f64) -> String {
let points = [[0., 0., 0.], [20., 0., 0.], [20., 20., 0.], [0., 20., 0.], [0., 0., top], [20., 0., top], [20., 20., top], [0., 20., top]];
let mut text = String::from("solid original_analytic_box\n");
for face in [[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]] {
text.push_str("facet normal 0 0 0\nouter loop\n");
for index in face {
let [x, y, z] = points[index];
text.push_str(&format!("vertex {x} {y} {z}\n"));
}
text.push_str("endloop\nendfacet\n");
}
text.push_str("endsolid original_analytic_box\n");
text
}
fn package(nc_height: f64, stock_height: f64) -> (Package, JobInput, Value) {
static NEXT: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!("solidcraft-original-cam-oracle-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&path).unwrap();
let package = Package(path);
let input = cam::corpus::fixture("face").unwrap();
let toolpath = cam::generate::generate(input.clone()).unwrap();
let nc = nc::post(&toolpath, &input.setup).unwrap().replace("Z5.000000", &format!("Z{nc_height:.6}"));
write(&package.0.join("reference.nc"), nc).unwrap();
write(&package.0.join("input.json"), pretty(&json!(input)).unwrap()).unwrap();
write(&package.0.join("reference-input.json"), pretty(&json!(input)).unwrap()).unwrap();
write(&package.0.join("post.cps"), "original analytic test post marker; not executable vendor code").unwrap();
write(&package.0.join("post-properties.json"), "{}").unwrap();
write(&package.0.join("source.stl"), box_stl(5.)).unwrap();
write(&package.0.join("stock.stl"), box_stl(stock_height)).unwrap();
write(&package.0.join("setup-sheet.html"), "<!doctype html><title>Original analytic test setup</title>").unwrap();
write(
&package.0.join("setup-sheet-values.json"),
pretty(&json!({"setup":input.setup,"tools":input.tools,"operations":input.operations})).unwrap(),
)
.unwrap();
write(
&package.0.join("simulation.json"),
pretty(&json!({
"status":"pass", "complete":true, "at_program_end":true, "findings":[],
"completed_operations":[1], "stock_after_operation":1,
"accuracy":{"setting":"exact analytic box", "resolution_mm":null,"resolution_status":"not_reported"},
}))
.unwrap(),
)
.unwrap();
let mut manifest = json!({
"fusion_version":"original analytic test; not a Fusion capture", "captured_at":"2026-10-16T00:00:00Z",
"input_fingerprint":input.fingerprint, "post_identity":"independently authored test post",
"post_sha256":format!("{:x}",Sha256::digest(read(&package.0.join("post.cps")).unwrap())),
"capture_complete":true, "capabilities":{"license":"analytic test", "strategies":["face"]},
"source_geometry":"source.stl", "artifact_sha256":{},
});
for name in [
"source.stl",
"input.json",
"reference-input.json",
"reference.nc",
"setup-sheet.html",
"setup-sheet-values.json",
"stock.stl",
"simulation.json",
"post-properties.json",
] {
hash(&package, &mut manifest, name);
}
write(&package.0.join("manifest.json"), pretty(&manifest).unwrap()).unwrap();
(package, input, manifest)
}
fn hash(package: &Package, manifest: &mut Value, name: &str) {
manifest["artifact_sha256"][name] = json!(format!("{:x}", Sha256::digest(read(&package.0.join(name)).unwrap())));
}
#[test]
fn oracle_requires_direct_reference_nc_to_exported_stock_agreement() {
let (package, _, _) = package(5.006, 4.994);
let result = compare_case(&package.0, &package.0.join("comparison")).unwrap();
// Both used to pass through a common midpoint at Z5, although the two
// reference results differ by 0.012 mm, beyond the 0.01 mm budget.
assert_eq!(result["stock"]["status"], "pass", "{result}");
assert_eq!(result["reference_stl"]["status"], "pass", "{result}");
assert_eq!(result["finished_dimensions"], json!(["pass", "pass"]), "{result}");
assert_eq!(result["reference_nc_vs_reference_stl"]["status"], "fail", "{result}");
assert_eq!(result["status"], "fail");
assert_eq!(result["pass"], false);
}
#[test]
fn oracle_complete_analytic_capture_passes_and_retains_post_provenance() {
let (package, _, manifest) = package(5., 5.);
let out = package.0.join("comparison");
let result = compare_case(&package.0, &out).unwrap();
assert_eq!(result["pass"], true, "{result}");
assert_eq!(result["reference_nc_vs_reference_stl"]["status"], "pass");
assert_eq!(result["comparison_version"], COMPARISON_VERSION);
assert_eq!(result["provenance"]["reference_post_sha256"], manifest["post_sha256"]);
assert_eq!(result["provenance"]["reference_simulation"]["accuracy"]["resolution_status"], "not_reported");
let validation = json_file(&out.join("fusion-nc-validation.json")).unwrap();
assert!(validation["post_version"].as_str().unwrap().starts_with("independently authored test post; sha256="));
}
#[test]
fn oracle_rejects_incomplete_stale_and_unlocated_capture_evidence() {
let (package, input, manifest) = package(5., 5.);
for field in ["post_identity", "source_geometry", "capabilities", "artifact_sha256"] {
let mut bad = manifest.clone();
bad.as_object_mut().unwrap().remove(field);
assert!(reference::validate(&package.0, &bad, &input).is_err(), "{field}");
}
let mut bad = manifest.clone();
bad["source_geometry"] = json!("../source.stl");
assert!(reference::validate(&package.0, &bad, &input).unwrap_err().contains("file in the reference package"));
bad["source_geometry"] = json!("reference.nc");
assert!(reference::validate(&package.0, &bad, &input).unwrap_err().contains("CAD or mesh"));
let original = json_file(&package.0.join("simulation.json")).unwrap();
for (field, value) in [
("complete", json!(false)),
("at_program_end", json!(false)),
("completed_operations", json!([])),
("stock_after_operation", json!(2)),
("accuracy", json!({})),
("accuracy", json!({"setting":"captured", "resolution_mm":0})),
("status", json!("unknown")),
("findings", json!(null)),
("findings", json!([{"severity":"unknown","detail":"test"}])),
] {
let mut simulation = original.clone();
simulation[field] = value;
write(&package.0.join("simulation.json"), pretty(&simulation).unwrap()).unwrap();
let mut updated = manifest.clone();
hash(&package, &mut updated, "simulation.json");
assert!(reference::validate(&package.0, &updated, &input).is_err(), "{field}");
}
write(&package.0.join("simulation.json"), pretty(&original).unwrap()).unwrap();
write(&package.0.join("reference.nc"), "G21 G0 X99").unwrap();
assert!(reference::validate(&package.0, &manifest, &input).unwrap_err().contains("artifact hash: reference.nc"));
}
#[test]
fn oracle_definite_failures_survive_inconclusive_checks() {
use verify::Status::{Fail, Inconclusive, Pass};
assert_eq!(combined_status([Pass, Inconclusive, Fail]), Fail);
assert_eq!(combined_status([Fail, Inconclusive, Pass]), Fail);
assert_eq!(combined_status([Pass, Inconclusive]), Inconclusive);
let (package, _, mut manifest) = package(5., 5.);
let mut simulation = json_file(&package.0.join("simulation.json")).unwrap();
simulation["status"] = json!("fail");
write(&package.0.join("simulation.json"), pretty(&simulation).unwrap()).unwrap();
hash(&package, &mut manifest, "simulation.json");
write(&package.0.join("manifest.json"), pretty(&manifest).unwrap()).unwrap();
let result = compare_case(&package.0, &package.0.join("comparison")).unwrap();
assert_eq!(result["status"], "fail");
assert_eq!(result["pass"], false);
for (severity, expected) in [("error", "fail"), ("unresolved", "inconclusive")] {
simulation["status"] = json!("pass");
simulation["findings"] = json!([{"severity":severity,"detail":"Original injected simulation finding"}]);
write(&package.0.join("simulation.json"), pretty(&simulation).unwrap()).unwrap();
hash(&package, &mut manifest, "simulation.json");
write(&package.0.join("manifest.json"), pretty(&manifest).unwrap()).unwrap();
let result = compare_case(&package.0, &package.0.join("comparison")).unwrap();
assert_eq!(result["status"], expected);
assert_eq!(result["pass"], false);
}
}
+1
View File
@@ -39,6 +39,7 @@ pub const TABLE: &[(&str, Class)] = &[
// the derive macros (proc-macro, no workspace dependencies)
("derive", Class::Standalone),
("sketch", Class::Layer(1)),
("cam", Class::Layer(1)),
("kernel", Class::Layer(1)),
("render", Class::Layer(1)),
("doc", Class::Layer(2)),
+7
View File
@@ -6,6 +6,7 @@
mod app_icons;
mod assets;
mod book;
mod cam;
mod features;
mod i18n;
mod layers;
@@ -61,6 +62,9 @@ commands:
web-offline-test [--site DIR]
check in headless Chrome that the web app works offline and updates to a new
build (a stand-in app, or the web build in DIR such as dist/web)
cam-validate validate analytic machining fixtures and write JSON/HTML reports
cam-oracle --dir DIR
compare ignored local Fusion reference packages (missing evidence fails)
ci fmt --check, assets, licences --check, lists, layers, clippy -D warnings, then the tests
with the wasm check alongside, then web-offline-test when Chrome is installed
(stops at the first failing step)
@@ -70,6 +74,8 @@ fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) {
Some("cam-validate") => cam::validate(&root()),
Some("cam-oracle") => cam::oracle(&root(), &rest),
Some("layers") => cmd_layers(),
Some("assets") => assets::run(&root()),
Some("app-icons") => app_icons::run(&root()),
@@ -371,6 +377,7 @@ fn cmd_ci() -> Result<(), String> {
let (wasm, n) = wasm_check()?;
test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm), test::doc_tests(&[], &[])])?;
web_offline_test::run(&root(), None, false)?;
cam::validate(&root())?;
eprintln!("ci: all gates passed");
Ok(())
}