mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Add three-axis milling with independent CAM and Fusion validation (#1682)
Thanks @CypherNaught-0x.
This commit is contained in:
@@ -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
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "軸を非表示",
|
||||
|
||||
@@ -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": "隐藏轴",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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(()) }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! CAM artifact serialization. The engine owns freshness and posting gates.
|
||||
mod incremental;
|
||||
pub use incremental::artifact_cooperative;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::IoError;
|
||||
use crate::Result;
|
||||
use solidcraft_cam::{JobInput, Toolpath, verify::Report};
|
||||
pub fn escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
||||
}
|
||||
/// Synchronous compatibility wrapper; interactive clients use the cooperative entry point.
|
||||
pub fn artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result<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())),
|
||||
}
|
||||
}
|
||||
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&");
|
||||
serde_json::from_str(&text).unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn cooperative_artifacts_preserve_every_field_geometry_and_html_escaping() {
|
||||
let (mut input, mut path, mut report) = fixture();
|
||||
input.operations[0].name = "ø <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("<script>"));
|
||||
}
|
||||
_ => 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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:?}")),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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).
|
||||
|
||||
|
||||
Vendored
+6
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -6,5 +6,6 @@ license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
solidcraft-cam = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user