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

Thanks @CypherNaught-0x.
This commit is contained in:
CypherNaugh_0x authored and GitHub committed 2026-10-16 08:26:28 -04:00
1 parent dd5278a402
commit 737c9358d0
85 files changed
+14487 -43

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solidcraft-cam"
description = "Portable milling toolpaths and independent NC verification"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solidcraft-geom.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
sha2.workspace = true
cavalier_contours = "=0.9.0"
parry3d-f64 = { version = "=0.31.1", default-features = false, features = ["required-features", "std", "alloc", "enhanced-determinism"] }
robust = "=1.2.0"
+483
View File
@@ -0,0 +1,483 @@
//! Independent fixture collision adapter. No toolpath generation code is used.
//! Surface casts and solid containment are both needed: a surface-only query can
//! miss a tool that starts wholly inside a closed fixture.
use crate::{
JobInput, POST_ERROR, Result, Tool, ToolKind, invalid,
nc::Segment,
verify::{Finding, Limits, Report, Status},
};
use parry3d_f64::{
math::{Pose, Vector},
query::{ShapeCastOptions, ShapeCastStatus, cast_shapes, distance},
shape::{Cone, Cylinder, Shape, Triangle},
};
use solidcraft_geom::Vec3;
use std::collections::BTreeMap;
#[derive(Clone, Copy)]
struct Bounds {
min: Vec3,
max: Vec3,
}
impl Bounds {
fn point(p: Vec3) -> Self {
Self { min: p, max: p }
}
fn include(&mut self, p: Vec3) {
self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z));
self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z));
}
fn overlaps(self, b: Self) -> bool {
self.min.x <= b.max.x
&& self.max.x >= b.min.x
&& self.min.y <= b.max.y
&& self.max.y >= b.min.y
&& self.min.z <= b.max.z
&& self.max.z >= b.min.z
}
}
struct Component {
body: usize,
triangles: Vec<usize>,
bounds: Bounds,
closed: bool,
}
struct Edge {
first: usize,
forward: bool,
count: usize,
consistent: bool,
}
type Vertex = [u64; 3];
fn vertex(p: Vec3) -> Vertex {
[p.x, p.y, p.z].map(|v| if v == 0.0 { 0 } else { v.to_bits() })
}
fn root(parents: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = parents.get(i) {
if p == i {
break;
}
let next = parents.get(p).copied().unwrap_or(p);
if let Some(slot) = parents.get_mut(i) {
*slot = next;
}
i = next;
}
i
}
/// Parry's primitives have a Y axis. This orthogonal mapping puts SolidCraft's
/// downward drill tip on +Y without introducing a rounded rotation transform.
fn vector(p: Vec3) -> Vector {
Vector::new(p.x, -p.z, p.y)
}
struct Part {
radius: f64,
low: f64,
high: f64,
cone: bool,
label: &'static str,
}
fn parts(t: &Tool) -> Vec<Part> {
let tip = t.tip_length();
let mut out = Vec::with_capacity(4);
if t.kind == ToolKind::TwistDrill {
out.push(Part { radius: t.diameter / 2.0, low: 0.0, high: tip, cone: true, label: "cutter" });
}
if t.cutting_length > tip {
out.push(Part { radius: t.diameter / 2.0, low: tip, high: t.cutting_length, cone: false, label: "cutter" });
}
if t.overall_length > t.cutting_length {
out.push(Part { radius: t.shank_diameter / 2.0, low: t.cutting_length, high: t.overall_length, cone: false, label: "shank" });
}
out.push(Part { radius: t.holder_diameter / 2.0, low: t.overall_length, high: t.overall_length + t.holder_length, cone: false, label: "holder" });
out
}
impl Part {
fn center(&self, p: Vec3) -> Vec3 {
p + Vec3::new(0.0, 0.0, (self.low + self.high) / 2.0)
}
fn bounds(&self, s: &Segment, margin: f64) -> Bounds {
let radius = self.radius + margin;
Bounds {
min: Vec3::new(s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius, s.from.z.min(s.to.z) + self.low - margin),
max: Vec3::new(s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius, s.from.z.max(s.to.z) + self.high + margin),
}
}
fn cast(&self, s: &Segment, tri: &[Vec3; 3], margin: f64) -> Status {
let center = vector(self.center(s.from));
let position = Pose::translation(center.x, center.y, center.z);
let triangle = Triangle::new(vector(tri[0]), vector(tri[1]), vector(tri[2]));
let cylinder = Cylinder::new((self.high - self.low) / 2.0, self.radius);
let cone = Cone::new((self.high - self.low) / 2.0, self.radius);
let shape: &dyn Shape = if self.cone { &cone } else { &cylinder };
let query = |distance| {
cast_shapes(
&position,
vector(s.to - s.from),
shape,
&Pose::IDENTITY,
Vector::ZERO,
&triangle,
ShapeCastOptions {
max_time_of_impact: 1.0,
target_distance: distance,
stop_at_penetration: true,
compute_impact_geometry_on_penetration: true,
},
)
};
// Third-party numerical errors or panics never certify clearance.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// A zero-velocity shape cast may report no impact even for initial
// penetration. Check the initial pose independently before sweeping.
let initial = match distance(&position, shape, &Pose::IDENTITY, &triangle).map(|d| d.distance) {
Ok(d) if d.is_finite() && d <= 1e-10 => return Status::Fail,
Ok(d) if d.is_finite() && d > margin => Status::Pass,
_ => Status::Inconclusive,
};
match query(margin) {
Ok(None) => initial,
Ok(Some(_)) => match query(0.0) {
Ok(Some(hit))
if matches!(hit.status, ShapeCastStatus::Converged | ShapeCastStatus::PenetratingOrWithinTargetDist)
&& hit.time_of_impact.is_finite()
&& (0.0..=1.0).contains(&hit.time_of_impact) =>
{
Status::Fail
}
_ => Status::Inconclusive,
},
Err(_) => Status::Inconclusive,
}
}))
.unwrap_or(Status::Inconclusive)
}
}
fn finding(report: &mut Report, status: Status, s: &Segment, part: &Part, index: usize, detail: &str) {
if status == Status::Fail || report.status == Status::Pass {
report.status = status;
}
report.finding(Finding {
severity: if status == Status::Fail { crate::verify::FindingSeverity::Error } else { crate::verify::FindingSeverity::Unresolved },
record: None,
source: None,
kind: if status == Status::Fail { format!("fixture_{}_collision", part.label) } else { "fixture_clearance_unresolved".into() },
line: Some(s.line),
at: s.from,
detail: format!("{} of tool {} at motion {}: {detail}", part.label, s.tool, index + 1),
});
}
pub(crate) async fn fixtures<F: std::future::Future<Output = ()>>(
input: &JobInput,
segments: &[Segment],
limits: Limits,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
report: &mut Report,
) -> Result<()> {
let tris = &input.geometry.fixtures;
if tris.is_empty() {
return Ok(());
}
let mut parents: Vec<_> = (0..tris.len()).collect();
let mut edges: BTreeMap<(usize, [Vertex; 2]), Edge> = BTreeMap::new();
let bodies: Vec<usize> = if input.geometry.fixture_groups.is_empty() {
vec![0; tris.len()]
} else {
input.geometry.fixture_groups.iter().enumerate().flat_map(|(body, &count)| std::iter::repeat_n(body, count)).collect()
};
let mut degenerate = Vec::new();
for (i, tri) in tris.iter().enumerate() {
if i.is_multiple_of(1024) {
yield_control().await;
if cancelled() {
return invalid("simulation cancelled");
}
}
report.work += 3;
if report.work > limits.work {
return Ok(());
}
if (tri[1] - tri[0]).cross(tri[2] - tri[0]).len() < 1e-12 {
degenerate.push(i);
}
for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
let a = vertex(a);
let b = vertex(b);
let forward = a < b;
let key = (bodies.get(i).copied().unwrap_or(0), if forward { [a, b] } else { [b, a] });
if let Some(e) = edges.get_mut(&key) {
let a = root(&mut parents, e.first);
let b = root(&mut parents, i);
if let Some(slot) = parents.get_mut(b) {
*slot = a;
}
e.count += 1;
e.consistent &= e.forward != forward;
} else {
edges.insert(key, Edge { first: i, forward, count: 1, consistent: true });
}
}
}
let mut components: BTreeMap<usize, Component> = BTreeMap::new();
for (i, tri) in tris.iter().enumerate() {
let key = root(&mut parents, i);
let c = components.entry(key).or_insert_with(|| Component {
body: bodies.get(i).copied().unwrap_or(0),
triangles: Vec::new(),
bounds: Bounds::point(tri[0]),
closed: true,
});
c.triangles.push(i);
for p in tri {
c.bounds.include(*p);
}
if i.is_multiple_of(1024) {
yield_control().await;
if cancelled() {
return invalid("simulation cancelled");
}
}
}
for (index, e) in edges.values().enumerate() {
if index.is_multiple_of(1024) {
yield_control().await;
if cancelled() {
return invalid("simulation cancelled");
}
}
if (e.count != 2 || !e.consistent)
&& let Some(c) = components.get_mut(&root(&mut parents, e.first))
{
c.closed = false;
}
}
for i in degenerate {
if let Some(c) = components.get_mut(&root(&mut parents, i)) {
c.closed = false;
}
}
let mut body_closed = BTreeMap::new();
for c in components.values() {
let closed = body_closed.entry(c.body).or_insert(true);
*closed &= c.closed;
}
let margin = POST_ERROR + limits.uncertainty;
for (index, s) in segments.iter().enumerate() {
yield_control().await;
if cancelled() {
return invalid("simulation cancelled");
}
let tool = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| crate::Error("unknown simulation tool".into()))?;
for part in parts(tool) {
let swept = part.bounds(s, margin);
let mut windings: BTreeMap<usize, f64> = BTreeMap::new();
for c in components.values() {
report.work += 1;
if report.work.is_multiple_of(1024) {
yield_control().await;
if cancelled() {
return invalid("simulation cancelled");
}
}
if report.work > limits.work {
return Ok(());
}
if !swept.overlaps(c.bounds) {
continue;
}
let center = part.center(s.from);
let containment = Bounds::point(center).overlaps(c.bounds);
let mut solid_angle = 0.0;
let mut status = if c.closed { Status::Pass } else { Status::Inconclusive };
for &i in &c.triangles {
report.work += 1;
if report.work.is_multiple_of(1024) {
yield_control().await;
if cancelled() {
return invalid("simulation cancelled");
}
}
if report.work > limits.work {
return Ok(());
}
let Some(tri) = tris.get(i) else {
return invalid("invalid fixture triangle index");
};
let mut bounds = Bounds::point(tri[0]);
bounds.include(tri[1]);
bounds.include(tri[2]);
if swept.overlaps(bounds) {
let cast = part.cast(s, tri, margin);
if cast == Status::Fail {
status = Status::Fail;
break;
}
if cast == Status::Inconclusive {
status = Status::Inconclusive;
}
}
if c.closed && containment {
let [a, b, d] = tri.map(|v| v - center);
let denominator = a.len() * b.len() * d.len() + a.dot(b) * d.len() + b.dot(d) * a.len() + d.dot(a) * b.len();
solid_angle += 2.0 * a.dot(b.cross(d)).atan2(denominator);
}
}
if status != Status::Fail && c.closed && containment {
*windings.entry(c.body).or_default() += solid_angle / (4.0 * std::f64::consts::PI);
}
if status != Status::Pass {
finding(
report,
status,
s,
&part,
index,
if status == Status::Fail {
"swept shape contacts the fixture surface"
} else {
"clearance is within the error margin, the query did not converge, or fixture topology is open/degenerate"
},
);
}
}
for (body, winding) in &windings {
let status = if !body_closed.get(body).copied().unwrap_or(false) {
Status::Inconclusive
} else if winding.is_finite() && winding.abs() > 1.0 - 1e-6 {
Status::Fail
} else if !winding.is_finite() || winding.abs() > 1e-6 {
Status::Inconclusive
} else {
Status::Pass
};
if status != Status::Pass {
finding(report, status, s, &part, index, "initial tool volume is inside a fixture or its solid winding is unresolved");
}
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::{corpus, nc, verify};
fn box_mesh(min: Vec3, max: Vec3) -> Vec<[Vec3; 3]> {
let p = [
min,
Vec3::new(max.x, min.y, min.z),
Vec3::new(max.x, max.y, min.z),
Vec3::new(min.x, max.y, min.z),
Vec3::new(min.x, min.y, max.z),
Vec3::new(max.x, min.y, max.z),
max,
Vec3::new(min.x, max.y, max.z),
];
[[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]
.into_iter()
.flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]])
.collect()
}
fn run(mesh: Vec<[Vec3; 3]>, motion: &str) -> Report {
run_groups(mesh, vec![], motion)
}
fn run_groups(mesh: Vec<[Vec3; 3]>, groups: Vec<usize>, motion: &str) -> Report {
let mut input = corpus::fixture("face").unwrap();
input.geometry.fixtures = mesh;
input.geometry.fixture_groups = groups;
let src = format!("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\n{motion}\nM30");
let parsed = nc::parse(&src, &input.tools).unwrap();
verify::simulate(&input, &parsed, Limits::default()).unwrap()
}
#[test]
fn near_fixture_is_clear_below_wide_holder() {
// Old whole-tool AABB overlaps this fixture. Cutter/shank radius is 3;
// the 10-radius holder starts at Z65 and cannot touch this low block.
let r = run(box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0)), "G0 X1");
assert_eq!(r.status, Status::Pass, "{:?}", r.findings);
}
#[test]
fn thin_fixture_between_endpoints_is_a_collision() {
let r = run(box_mesh(Vec3::new(5.0, -1.0, 16.0), Vec3::new(5.01, 1.0, 17.0)), "G0 X10");
assert_eq!(r.status, Status::Fail);
assert!(r.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(4)));
}
#[test]
fn holder_and_shank_collisions_are_distinguished() {
for (z, kind) in [(40.0, "shank"), (70.0, "holder")] {
let r = run(box_mesh(Vec3::new(5.0, -1.0, z), Vec3::new(5.1, 1.0, z + 1.0)), "G0 X10");
assert_eq!(r.status, Status::Fail);
assert!(r.findings.iter().any(|f| f.kind == format!("fixture_{kind}_collision")));
}
}
#[test]
fn contained_tool_is_not_missed_and_winding_is_irrelevant() {
let cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
for reverse in [false, true] {
let mesh = cube.iter().map(|t| if reverse { [t[2], t[1], t[0]] } else { *t }).collect();
let r = run(mesh, "G0 X1");
assert_eq!(r.status, Status::Fail, "{:?}", r.findings);
}
}
#[test]
fn overlapping_components_with_opposite_winding_do_not_cancel() {
let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
a.extend(box_mesh(Vec3::new(-21.0, -21.0, 9.0), Vec3::new(21.0, 21.0, 101.0)).into_iter().map(|t| [t[2], t[1], t[0]]));
assert_eq!(run_groups(a, vec![12, 12], "G0 X1").status, Status::Fail);
}
#[test]
fn cavity_in_one_fixture_body_remains_empty() {
let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
a.extend(box_mesh(Vec3::new(-15.0, -15.0, 11.0), Vec3::new(15.0, 15.0, 99.0)).into_iter().map(|t| [t[2], t[1], t[0]]));
assert_eq!(run_groups(a, vec![24], "G0 X1").status, Status::Pass);
}
#[test]
fn malformed_fixture_group_counts_are_rejected() {
let mut input = corpus::fixture("face").unwrap();
input.geometry.fixture_groups = vec![usize::MAX, 1];
assert!(input.validate().is_err());
input.geometry.fixture_groups = vec![0];
assert!(input.validate().is_err());
}
#[test]
fn open_containing_shell_is_inconclusive() {
let mut cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
cube.pop();
assert_eq!(run(cube, "G0 X1").status, Status::Inconclusive);
}
#[test]
fn cylinder_corners_and_margin_are_resolved() {
let part = Part { radius: 1.0, low: 0.0, high: 2.0, cone: false, label: "cutter" };
let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: true, feed: 0.0, tool: 1, line: 4 };
let triangle = |x, y| [Vec3::new(x, y, 0.5), Vec3::new(x + 0.05, y, 1.5), Vec3::new(x, y + 0.05, 1.5)];
assert_eq!(part.cast(&s, &triangle(0.8, 0.8), 0.0035), Status::Pass);
assert_eq!(part.cast(&s, &triangle(0.5, 0.5), 0.0035), Status::Fail);
assert_ne!(part.cast(&s, &triangle(1.001, 0.0), 0.0035), Status::Pass);
}
#[test]
fn drill_point_shape_has_clearance_outside_its_cone() {
let part = Part { radius: 1.0, low: 0.0, high: 1.0, cone: true, label: "cutter" };
let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: false, feed: 100.0, tool: 1, line: 4 };
let clear = [Vec3::new(0.8, 0.0, 0.1), Vec3::new(0.9, 0.0, 0.1), Vec3::new(0.8, 0.1, 0.2)];
assert_eq!(part.cast(&s, &clear, 0.0035), Status::Pass);
let contact = clear.map(|p| p + Vec3::new(0.0, 0.0, 0.8));
assert_eq!(part.cast(&s, &contact, 0.0035), Status::Fail);
}
#[test]
fn fixture_budget_and_cancellation_never_pass() {
let mut input = corpus::fixture("face").unwrap();
input.geometry.fixtures = box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0));
let parsed = nc::parse("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X1\nM30", &input.tools).unwrap();
let r = verify::simulate(&input, &parsed, Limits { work: 1, ..Limits::default() }).unwrap();
assert_eq!(r.status, Status::Inconclusive);
assert!(verify::simulate_cancellable(&input, &parsed, Limits::default(), || true).is_err());
}
}
+489
View File
@@ -0,0 +1,489 @@
//! Conservative artifact comparison, separate from toolpath generation.
use crate::spatial::Index;
use crate::{
verify::{Report, Status},
*,
};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
#[derive(Debug, Serialize, Deserialize)]
pub struct Comparison {
pub status: Status,
pub maximum_possible_error: f64,
pub maximum_definite_error: f64,
pub checked_regions: usize,
pub detail: String,
}
fn gaps(a: [f64; 2], b: [f64; 2]) -> (f64, f64) {
((a[0] - b[1]).max(b[0] - a[1]).max(0.0), (a[0] - b[1]).abs().max((a[1] - b[0]).abs()))
}
fn result(detail: &str) -> Comparison {
Comparison { status: Status::Pass, maximum_possible_error: 0., maximum_definite_error: 0., checked_regions: 0, detail: detail.into() }
}
fn inconclusive(detail: &str) -> Comparison {
Comparison { status: Status::Inconclusive, ..result(detail) }
}
fn tolerance(value: f64) -> Result<()> {
if !value.is_finite() || value <= 0. || value > 1e6 {
return invalid("comparison tolerance must be positive and bounded");
}
Ok(())
}
/// Compare both remaining material and removed space, in both directions.
/// Each passing region is a whole-column distance certificate, not a sample.
pub fn stock_reports(a: &Report, b: &Report, tol: f64) -> Result<Comparison> {
stock_reports_budget(a, b, tol, &mut 0, 30_000_000)
}
fn stock_reports_budget(a: &Report, b: &Report, tol: f64, work: &mut usize, budget: usize) -> Result<Comparison> {
tolerance(tol)?;
let (Some(stock), Some(other)) = (a.stock, b.stock) else {
return Ok(inconclusive("missing declared stock bounds; regenerate legacy reports"));
};
if stock != other {
return invalid("stock bounds differ; verify input units and coordinate frames");
}
let complete_a = crate::coverage::cells(&a.cells, stock)?;
let complete_b = crate::coverage::cells(&b.cells, stock)?;
if !complete_a || !complete_b || !a.stock_complete || !b.stock_complete || a.status != Status::Pass || b.status != Status::Pass {
return Ok(inconclusive("stock verification or exact cell coverage is incomplete"));
}
let guard = 128. * f64::EPSILON * stock.min.len().max(stock.max.len()).max(1.);
let mut r = result("Bidirectional whole-column distances for remaining material and removed space in declared setup coordinates");
for (a, b) in [(a, b), (b, a)] {
let index = Index::new(b.cells.iter().enumerate().map(|(id, c)| (id, c.min, c.max)).collect());
for x in &a.cells {
let mut ids = Vec::new();
index.query(x.min, x.max, &mut ids);
for id in ids {
*work += 1;
if *work > budget {
r.status = Status::Inconclusive;
r.detail = "stock comparison work budget exhausted".into();
return Ok(r);
}
let y = &b.cells[id];
let min = [x.min[0].max(y.min[0]), x.min[1].max(y.min[1])];
let max = [x.max[0].min(y.max[0]), x.max[1].min(y.max[1])];
if (0..2).any(|k| min[k] >= max[k]) {
continue;
}
r.checked_regions += 1;
let mut possible = gaps([x.low, x.high], [y.low, y.high]).1 + guard;
if possible > tol {
let mut candidates = Vec::new();
index.query(min.map(|v| v - tol - guard), max.map(|v| v + tol + guard), &mut candidates);
let mut material = (x.high - y.low).max(0.);
// Outside the declared XY stock domain is guaranteed void.
let exterior = (max[0] - stock.min.x).min(stock.max.x - min[0]).min(max[1] - stock.min.y).min(stock.max.y - min[1]);
let mut removed = (y.high - x.low).max(0.).min(exterior);
for id in candidates {
*work += 1;
if *work > budget {
r.status = Status::Inconclusive;
r.detail = "stock distance certificate budget exhausted".into();
return Ok(r);
}
let c = &b.cells[id];
// The farthest rectangle corner bounds the distance of
// every XY point to this guaranteed material/void prism.
let xy = [0, 1].map(|k| (c.min[k] - min[k]).max(max[k] - c.max[k]).max(0.));
let lateral = xy[0].hypot(xy[1]);
material = material.min(lateral.hypot((x.high - c.low).max(0.)));
removed = removed.min(lateral.hypot((c.high - x.low).max(0.)));
}
possible = material.max(removed) + guard;
if possible > tol {
let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
// A positive failure requires a definitely present
// material/void point farther than tolerance from ALL
// possibly corresponding prisms, not just a Z gap.
let radius = 2. * tol + guard;
let mut candidates = Vec::new();
index.query(center.map(|v| v - radius), center.map(|v| v + radius), &mut candidates);
let mut material_distance = radius;
let mut removed_distance = radius
.min(center[0] - stock.min.x)
.min(stock.max.x - center[0])
.min(center[1] - stock.min.y)
.min(stock.max.y - center[1]);
for id in candidates {
*work += 1;
if *work > budget {
r.status = Status::Inconclusive;
r.detail = "stock failure certificate budget exhausted".into();
return Ok(r);
}
let c = &b.cells[id];
let dx = (c.min[0] - center[0]).max(center[0] - c.max[0]).max(0.);
let dy = (c.min[1] - center[1]).max(center[1] - c.max[1]).max(0.);
material_distance = material_distance.min(dx.hypot(dy).hypot((x.low - c.high).max(0.)));
removed_distance = removed_distance.min(dx.hypot(dy).hypot((c.low - x.high).max(0.)));
}
let definite = (material_distance.max(removed_distance) - guard).max(0.);
r.maximum_definite_error = r.maximum_definite_error.max(definite);
if definite > tol {
r.status = Status::Fail;
r.detail =
format!("Certified local stock difference near X{} Y{}; lower distance bound {} mm", center[0], center[1], definite);
r.maximum_possible_error = r.maximum_possible_error.max(possible);
return Ok(r);
}
r.status = Status::Inconclusive;
}
}
r.maximum_possible_error = r.maximum_possible_error.max(possible);
}
}
}
Ok(r)
}
pub fn stock_mesh(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64) -> Result<Comparison> {
stock_mesh_budget(report, mesh, bottom, tol, 250_000, 30_000_000)
}
fn stock_mesh_budget(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64, cell_budget: usize, budget: usize) -> Result<Comparison> {
tolerance(tol)?;
if mesh.is_empty() || mesh.len() > 500_000 || !bottom.is_finite() {
return invalid("reference stock mesh empty or too large, or invalid bottom");
}
let Some(stock) = report.stock else {
return Ok(inconclusive("missing declared stock bounds; regenerate legacy report"));
};
if bottom != stock.min.z {
return invalid("reference stock bottom differs from declared stock");
}
let field = crate::coverage::MeshField::new(mesh, stock)?;
let mut cells = Vec::new();
if !crate::coverage::cells(&report.cells, stock)? || !report.stock_complete || report.status != Status::Pass {
return Ok(inconclusive("stock verification or exact cell coverage is incomplete"));
}
// Reuse only the spatial partition, never the report's heights as mesh
// evidence. Every reference interval is calculated from its own triangles.
let mut pending: Vec<_> = report.cells.iter().map(|c| (c.min, c.max, [c.low, c.high])).collect();
let mut work = 0;
while let Some((min, max, source)) = pending.pop() {
if cells.len() + pending.len() >= cell_budget {
return Ok(inconclusive("reference stock refinement cell budget exhausted"));
}
let Some([low, high]) = field.bounds(min, max, &mut work, budget) else {
return Ok(inconclusive("reference stock refinement work budget exhausted"));
};
let size = (max[0] - min[0]).hypot(max[1] - min[1]);
if gaps(source, [low, high]).1 > tol && high - low > VERIFY_ERROR.min(tol / 4.) && size > VERIFY_ERROR.min(tol / 4.) {
let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
if (0..2).any(|k| center[k] <= min[k] || center[k] >= max[k]) {
return Ok(inconclusive("reference refinement reached coordinate precision limit"));
}
for (a, b) in [(min, center), ([center[0], min[1]], [max[0], center[1]]), (center, max), ([min[0], center[1]], [center[0], max[1]])] {
pending.push((a, b, source));
}
} else {
cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high });
}
}
let reference = Report { cells, status: Status::Pass, stock_complete: true, ..report.clone() };
stock_reports_budget(report, &reference, tol, &mut work, budget)
}
/// Strict STL decoding: malformed triangles are rejected, never silently dropped.
pub fn read_stl(bytes: &[u8]) -> Result<Vec<[Vec3; 3]>> {
if bytes.len() > 128 * 1024 * 1024 {
return invalid("STL exceeds 128 MiB");
}
let mut triangles = Vec::new();
let count = bytes.get(80..84).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes).map(|n| n as usize);
if let Some(count) = count.filter(|n| 84usize.checked_add(n.saturating_mul(50)) == Some(bytes.len())) {
if count > 500_000 {
return invalid("STL triangle limit");
}
for chunk in bytes.get(84..).unwrap_or_default().as_chunks::<50>().0 {
let mut pts = [Vec3::ZERO; 3];
for (k, p) in pts.iter_mut().enumerate() {
let mut xyz = [0.0; 3];
for (j, v) in xyz.iter_mut().enumerate() {
let start = 12 + k * 12 + j * 4;
let b =
chunk.get(start..start + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).ok_or_else(|| Error("truncated STL vertex".into()))?;
*v = f32::from_le_bytes(b) as f64;
}
*p = Vec3::new(xyz[0], xyz[1], xyz[2]);
crate::model::point(*p)?;
}
triangles.push(pts);
}
} else {
let text = std::str::from_utf8(bytes).map_err(|_| Error("invalid STL encoding/length".into()))?;
let mut lines = text.lines().map(|line| line.split_whitespace().collect::<Vec<_>>()).filter(|line| !line.is_empty());
if lines.next().and_then(|line| line.first().copied()) != Some("solid") {
return invalid("invalid ASCII STL header");
}
loop {
let header = lines.next().ok_or_else(|| Error("missing STL terminator".into()))?;
if header.first() == Some(&"endsolid") {
if lines.next().is_some() {
return invalid("data after STL terminator");
}
break;
}
if header.len() != 5 || header[..2] != ["facet", "normal"] || header[2..].iter().any(|v| !v.parse::<f64>().is_ok_and(f64::is_finite)) {
return invalid("invalid STL facet header");
}
if lines.next().as_deref() != Some(&["outer", "loop"]) {
return invalid("missing STL outer loop");
}
let mut points = [Vec3::ZERO; 3];
for point in &mut points {
let words = lines.next().ok_or_else(|| Error("incomplete STL triangle".into()))?;
if words.len() != 4 || words[0] != "vertex" {
return invalid("invalid STL vertex");
}
let mut xyz = [0.0; 3];
for (i, word) in words[1..].iter().enumerate() {
xyz[i] = word.parse::<f64>().map_err(|_| Error("invalid STL number".into()))?;
}
*point = Vec3::new(xyz[0], xyz[1], xyz[2]);
crate::model::point(*point)?;
}
if lines.next().as_deref() != Some(&["endloop"]) || lines.next().as_deref() != Some(&["endfacet"]) {
return invalid("incomplete STL facet");
}
triangles.push(points);
if triangles.len() > 500_000 {
return invalid("STL triangle limit");
}
}
}
if triangles.is_empty() {
return invalid("empty STL");
}
Ok(triangles)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn cell(x0: f64, x1: f64, low: f64, high: f64) -> verify::Cell {
verify::Cell { min: [x0, 0.], max: [x1, 2.], low, high, target_low: low, target_high: high }
}
fn report(cells: Vec<verify::Cell>) -> Report {
Report {
version: VERSION.into(),
fingerprint: "analytic".into(),
status: Status::Pass,
finished_dimensions: Status::Pass,
post_version: "analytic".into(),
tolerance: TOLERANCE,
uncertainty: VERIFY_ERROR,
maximum_height_interval: 0.,
stock_complete: true,
stock: Some(Stock { min: Vec3::ZERO, max: Vec3::new(2., 2., 3.) }),
cells,
findings: vec![],
cutting_distance: 0.,
rapid_distance: 0.,
cutting_minutes: 0.,
remaining_volume_low: 0.,
remaining_volume_high: 0.,
work: 0,
}
}
fn mesh(report: &Report) -> Vec<[Vec3; 3]> {
read_stl(verify::stock_stl(report, 0.).unwrap().as_bytes()).unwrap()
}
#[test]
fn duplicate_open_and_misoriented_reference_triangles_cannot_pass() {
let r = report(vec![cell(0., 2., 1., 1.)]);
let tri = [Vec3::new(0., 0., 1.), Vec3::new(2., 0., 1.), Vec3::new(2., 2., 1.)];
assert!(stock_mesh(&r, &[tri, tri], 0., TOLERANCE).is_err());
let mut m = mesh(&r);
m.pop();
assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err());
let mut m = mesh(&r);
for t in &mut m {
t.swap(0, 1);
}
assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err());
}
#[test]
fn cell_coverage_has_no_area_epsilon_and_rejects_duplicates() {
let full = report(vec![cell(0., 2., 1., 1.)]);
let duplicate = report(vec![cell(0., 1., 1., 1.), cell(0., 1., 1., 1.)]);
assert!(stock_reports(&full, &duplicate, TOLERANCE).is_err());
let gap = report(vec![cell(0., 1., 1., 1.), cell(1. + 1e-10, 2., 1., 1.)]);
assert_eq!(stock_reports(&full, &gap, TOLERANCE).unwrap().status, Status::Inconclusive);
let mut legacy = full.clone();
legacy.stock = None;
assert_eq!(stock_reports(&full, &legacy, TOLERANCE).unwrap().status, Status::Inconclusive);
let mut shifted = full.clone();
shifted.stock.as_mut().unwrap().min.x = -1.;
assert!(stock_reports(&full, &shifted, TOLERANCE).is_err());
let mut bad = full.clone();
bad.cells[0].high = f64::NAN;
assert!(stock_reports(&full, &bad, TOLERANCE).is_err());
let mut bad = full.clone();
bad.cells[0].min[0] = 1e-200;
assert!(stock_reports(&full, &bad, TOLERANCE).is_err());
}
#[test]
fn whole_column_certificates_resolve_wall_uncertainty_and_detect_wall_shift() {
let exact = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]);
let band = report(vec![cell(0., 0.999, 1., 1.), cell(0.999, 1.001, 1., 2.), cell(1.001, 2., 2., 2.)]);
let r = stock_reports(&exact, &band, TOLERANCE).unwrap();
assert_eq!(r.status, Status::Pass, "{r:?}");
assert!(r.maximum_possible_error < 0.003);
let shifted = report(vec![cell(0., 1.1, 1., 1.), cell(1.1, 2., 2., 2.)]);
assert_eq!(stock_reports(&exact, &shifted, TOLERANCE).unwrap().status, Status::Fail);
let wide = report(vec![cell(0., 2., 1., 2.)]);
assert_eq!(stock_reports(&wide, &wide, TOLERANCE).unwrap().status, Status::Inconclusive);
assert_eq!(stock_reports_budget(&exact, &exact, TOLERANCE, &mut 0, 1).unwrap().status, Status::Inconclusive);
}
#[test]
fn removed_space_detects_a_narrow_deep_missing_hole() {
let full = report(vec![cell(0., 2., 2., 2.)]);
// A solid-only Hausdorff test could hide this narrow cut because all
// missing material is within tolerance of a neighboring solid column.
let hole = report(vec![cell(0., 0.999, 2., 2.), cell(0.999, 1.001, 0., 0.), cell(1.001, 2., 2., 2.)]);
assert_eq!(stock_reports(&full, &hole, TOLERANCE).unwrap().status, Status::Fail);
let rim = report(vec![cell(0., 0.001, 0., 0.), cell(0.001, 2., 2., 2.)]);
assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Pass);
let rim = report(vec![cell(0., 0.1, 0., 0.), cell(0.1, 2., 2., 2.)]);
assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Fail);
}
#[test]
fn closed_stock_mesh_compares_at_vertical_walls_and_holes() {
let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]);
let m = mesh(&step);
let comparison = stock_mesh(&step, &m, 0., TOLERANCE).unwrap();
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
let hole = report(vec![cell(0., 0.9, 2., 2.), cell(0.9, 1.1, 0., 0.), cell(1.1, 2., 2., 2.)]);
let comparison = stock_mesh(&hole, &mesh(&hole), 0., TOLERANCE).unwrap();
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 1, 100).unwrap().status, Status::Inconclusive);
}
#[test]
fn shared_mesh_edges_do_not_refine_flat_cells_or_hide_thin_overlaps() {
let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]);
let m = mesh(&step);
assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 3, 100_000).unwrap().status, Status::Pass);
let field = crate::coverage::MeshField::new(&m, step.stock.unwrap()).unwrap();
let flat = field.bounds([0., 0.], [1., 2.], &mut 0, 1000).unwrap();
assert!((flat[0] - 1.).abs() < 1e-10 && (flat[1] - 1.).abs() < 1e-10);
// Positive-area overlap has no area epsilon: even this tiny strip must
// retain both heights, rather than treating it as a shared edge.
let sliver = field.bounds([0., 0.], [1. + 1e-10, 2.], &mut 0, 1000).unwrap();
assert!(sliver[0] <= 1. && sliver[1] >= 2.);
}
#[test]
fn overlapping_closed_components_cannot_counterfeit_full_coverage() {
let expected = report(vec![cell(0., 2., 1., 1.)]);
let mut m = mesh(&report(vec![cell(0., 1., 1., 1.)]));
m.extend(mesh(&report(vec![cell(0.1, 1.1, 1., 1.)])));
let comparison = stock_mesh(&expected, &m, 0., TOLERANCE).unwrap();
assert_eq!(comparison.status, Status::Fail, "{comparison:?}");
let mut extra = mesh(&expected);
for t in &mut extra {
for p in t {
p.x += 1.;
}
}
assert!(stock_mesh(&expected, &extra, 0., TOLERANCE).is_err());
}
#[test]
fn generated_drill_stock_round_trip_is_spatially_certified() {
let input = corpus::fixture("drill").unwrap();
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let r = verify::simulate(&input, &parsed, Default::default()).unwrap();
let m = mesh(&r);
let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap();
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
}
#[test]
fn analytic_round_hole_matches_independently_faceted_closed_stock() {
// Original analytic solid: 2x2 plate, top Z2, radius .25 bore, a
// cone from tip Z1 to Z1.5, and a vertical wall to the plate top.
// Intervals below use only radial extrema; no toolpath or STL exporter.
let height = |d: f64| if d < 0.25 { 1. + 2. * d } else { 2. };
let mut cells = Vec::new();
let mut pending = vec![([0., 0.], [2., 2.])];
while let Some((min, max)) = pending.pop() {
let near: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).max(1. - max[k]).max(0.));
let far: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).abs().max((max[k] - 1.).abs()));
let low = height(near[0].hypot(near[1]));
let high = height(far[0].hypot(far[1]));
if high - low > 0.002 && (max[0] - min[0]).hypot(max[1] - min[1]) > VERIFY_ERROR {
let c = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
pending.extend([(min, c), ([c[0], min[1]], [max[0], c[1]]), (c, max), ([min[0], c[1]], [c[0], max[1]])]);
} else {
cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high });
}
}
let r = report(cells);
let mut m = Vec::new();
let ring = |i: usize| {
let a = (i % 64) as f64 * std::f64::consts::TAU / 64.;
let (y, x) = a.sin_cos();
let outer =
if x.abs() >= y.abs() { Vec3::new(1. + x.signum(), 1. + y / x.abs(), 2.) } else { Vec3::new(1. + x / y.abs(), 1. + y.signum(), 2.) };
[Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 2.), Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 1.5), outer, Vec3::new(outer.x, outer.y, 0.)]
};
for i in 0..64 {
let [a, lo, outer, base] = ring(i);
let [b, lo_b, outer_b, base_b] = ring(i + 1);
m.extend([
[a, outer, outer_b],
[a, outer_b, b],
[a, b, lo_b],
[a, lo_b, lo],
[Vec3::new(1., 1., 1.), lo, lo_b],
[outer, base, base_b],
[outer, base_b, outer_b],
[Vec3::new(1., 1., 0.), base_b, base],
]);
}
let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap();
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
// Different facet decomposition must preserve the result.
let split: Vec<_> = m
.iter()
.flat_map(|t| {
if (t[1] - t[0]).cross(t[2] - t[0]).z == 0. {
return vec![*t];
}
let c = (t[0] + t[1] + t[2]) / 3.;
vec![[t[0], t[1], c], [t[1], t[2], c], [t[2], t[0], c]]
})
.collect();
assert_eq!(stock_mesh(&r, &split, 0., TOLERANCE).unwrap().status, Status::Pass);
let missed = report(vec![cell(0., 2., 2., 2.)]);
assert_eq!(stock_mesh(&missed, &m, 0., TOLERANCE).unwrap().status, Status::Fail);
}
#[test]
fn invalid_stl_is_not_partially_accepted() {
assert!(read_stl(b"solid x\nvertex NaN 0 0\nvertex 1 0 0\nvertex 0 1 0\nendsolid").is_err());
assert!(read_stl(&[0; 84]).is_err());
}
#[test]
fn interval_difference_catches_local_gouge() {
assert!((gaps([4.0, 4.001], [5.0, 5.001]).0 - 0.999).abs() < 1e-10);
assert!(gaps([4.99, 5.01], [5.0, 5.0]).1 > 0.009);
}
#[test]
fn incomplete_coverage_is_symmetric_and_stl_is_bounded() {
let input = corpus::fixture("face").unwrap();
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let full = verify::simulate(&input, &parsed, Default::default()).unwrap();
let mut partial = full.clone();
partial.cells.truncate(1);
assert_eq!(stock_reports(&partial, &full, TOLERANCE).unwrap().status, Status::Inconclusive);
assert_eq!(stock_reports(&full, &partial, TOLERANCE).unwrap().status, Status::Inconclusive);
let stl = verify::stock_stl(&full, 0.0).unwrap();
let mesh = read_stl(stl.as_bytes()).unwrap();
assert_eq!(mesh.len(), 12); // exact coplanar merge, conforming boundary
let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum();
assert!((volume - 2000.0).abs() < 1e-9);
assert!(read_stl(stl.replace("endfacet", "unknown").as_bytes()).is_err());
}
}
+388
View File
@@ -0,0 +1,388 @@
//! Original analytic fixtures; never generated by a proprietary CAD application.
use crate::*;
use solidcraft_geom::Vec3;
mod channel;
mod extended;
mod exterior;
mod through;
pub const CASES: &[&str] =
&["face", "contour", "pocket", "drill", "concave-islands", "multi-operation", "clamped-through-holes", "external-contour", "narrow-channel"];
/// Independent point expectations, indexed by the completed operation count.
pub fn probes(name: &str, completed: usize) -> Result<Vec<([f64; 2], f64)>> {
match name {
"face" | "pocket" | "drill" => Ok(vec![([10., 10.], 5.)]),
"contour" => Ok(vec![([10., 10.], 6.)]),
"narrow-channel" if completed == 1 => Ok(vec![([10., 10.], 5.), ([4., 10.], 5.), ([16., 10.], 5.), ([10., 11.2], 6.), ([3.8, 10.], 6.)]),
"concave-islands" => Ok(vec![([6., 6.], 6.), ([6., 13.], 6.), ([14., 14.], 6.), ([15., 6.], 4.), ([6., 9.], 4.)]),
"clamped-through-holes" if (1..=2).contains(&completed) => {
Ok(vec![([6., 10.], 0.), ([14., 10.], if completed == 2 { 0. } else { 6. }), ([10., 10.], 6.), ([6.6, 10.], 6.)])
}
"external-contour" if (1..=2).contains(&completed) => {
Ok(vec![([10., 10.], 6.), ([5.1, 10.], 6.), ([4.5, 10.], if completed == 1 { 4.2 } else { 4. }), ([1., 1.], 6.)])
}
"multi-operation" if (1..=4).contains(&completed) => {
let floor = [5.5, 4.2, 4., 4.].get(completed - 1).copied().ok_or_else(|| Error("unknown stage".into()))?;
Ok(vec![([1., 1.], 5.5), ([6., 6.], 5.5), ([14., 14.], 5.5), ([6., 9.], floor), ([15., 6.], if completed == 4 { 3. } else { floor })])
}
_ => invalid("unknown analytic stage"),
}
}
pub fn fixture(strategy: &str) -> Result<JobInput> {
if strategy == "narrow-channel" {
return channel::fixture();
}
if strategy == "external-contour" {
return exterior::fixture();
}
if strategy == "clamped-through-holes" {
return through::fixture();
}
if matches!(strategy, "concave-islands" | "multi-operation") {
return extended::fixture(strategy);
}
let tool = Tool {
id: 1,
name: "6 mm center-cutting end mill".into(),
number: 1,
kind: ToolKind::FlatEndMill,
diameter: 6.0,
cutting_length: 20.0,
shank_diameter: 6.0,
overall_length: 50.0,
holder_diameter: 20.0,
holder_length: 30.0,
tip_angle: 118_f64.to_radians(),
};
let setup = Setup {
id: 1,
name: "Analytic plate".into(),
stock: Stock { min: Vec3::ZERO, max: Vec3::new(20.0, 20.0, 6.0) },
frame: Frame::default(),
models: vec![BodyRef { body: "Plate".into(), occurrence: vec![] }],
fixtures: vec![],
clearance: 15.0,
retract: 10.0,
work_offset: 54,
};
let boundary = Boundary {
outer: (0..128)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 128.;
[10. + 8. * a.cos(), 10. + 8. * a.sin()]
})
.collect(),
islands: vec![],
};
let geometry = match strategy {
"face" => Strategy::Face,
"contour" => Strategy::Contour { boundary, inside: true },
"pocket" => Strategy::Pocket { boundary },
"drill" => Strategy::Drill { holes: vec![[10.0, 10.0]], peck: Some(1.0), full_diameter: false },
_ => return invalid("unknown analytic fixture"),
};
let mut tool = tool;
if strategy == "drill" {
tool.kind = ToolKind::TwistDrill;
tool.diameter = 1.0;
tool.shank_diameter = 1.0;
tool.name = "1 mm twist drill".into();
}
let operation = Operation {
id: 1,
name: strategy.into(),
setup: 1,
tool: 1,
geometry,
source: None,
hole_sources: vec![],
top: 6.0,
bottom: 5.0,
stepdown: 1.0,
stepover: if strategy == "drill" { 0.5 } else { 2.0 },
feed: 300.0,
plunge_feed: 100.0,
spindle: 6000.0,
allowance: 0.0,
climb: true,
tolerance: TOLERANCE,
coolant: false,
ramp_angle: None,
lead_distance: 0.0,
};
let a = Vec3::new(0.0, 0.0, 5.0);
let b = Vec3::new(20.0, 0.0, 5.0);
let c = Vec3::new(20.0, 20.0, 5.0);
let d = Vec3::new(0.0, 20.0, 5.0);
let mut input = JobInput {
setup,
tools: vec![tool],
operations: vec![operation],
geometry: Geometry { target: vec![[a, b, c], [a, c, d]], fixtures: vec![], fixture_groups: vec![] },
fingerprint: String::new(),
};
if strategy == "drill" {
input.geometry.target = drilled_plate_target();
}
if matches!(strategy, "contour" | "pocket") {
input.geometry.target = round_pocket_target();
}
input.fingerprint = crate::fingerprint(&input)?;
Ok(input)
}
// Original circular pocket with the same faceted boundary as its input recipe.
// Chord error at radius 8 with 128 segments is 0.002410 mm; the recipe itself is
// this polygon, not an unrecorded exact circle.
fn round_pocket_target() -> Vec<[Vec3; 3]> {
let mut triangles = Vec::new();
let ring = |i: usize| {
let a = (i % 128) as f64 * std::f64::consts::TAU / 128.;
let (y, x) = a.sin_cos();
let outer = if x.abs() >= y.abs() {
[10. + 10. * x.signum(), 10. + 10. * y / x.abs(), 6.]
} else {
[10. + 10. * x / y.abs(), 10. + 10. * y.signum(), 6.]
};
([10. + 8. * x, 10. + 8. * y, 6.], outer)
};
for i in 0..128 {
let (a, c) = ring(i);
let (b, d) = ring(i + 1);
triangles.extend([[a.into(), c.into(), d.into()], [a.into(), d.into(), b.into()]]);
let e = [a[0], a[1], 5.];
let f = [b[0], b[1], 5.];
triangles.extend([[[10., 10., 5.].into(), e.into(), f.into()], [a.into(), b.into(), f.into()], [a.into(), f.into(), e.into()]]);
}
triangles
}
// Original faceted plate with a blind drilled hole, vertical wall and cone.
fn drilled_plate_target() -> Vec<[Vec3; 3]> {
let mut triangles = Vec::new();
let n = 48;
let slope = 1.0 / (59_f64.to_radians().tan());
for k in 0..n {
let point = |index: usize, r: f64, z: f64| {
let a = index as f64 * std::f64::consts::TAU / n as f64;
[10. + r * a.cos(), 10. + r * a.sin(), z]
};
let outer = |index: usize| {
let a = index as f64 * std::f64::consts::TAU / n as f64;
point(index, 10. / a.cos().abs().max(a.sin().abs()), 6.)
};
let a = point(k, 0.5, 6.);
let b = point(k + 1, 0.5, 6.);
let c = outer(k);
let d = outer(k + 1);
triangles.push([a.into(), c.into(), d.into()]);
triangles.push([a.into(), d.into(), b.into()]);
let e = point(k, 0.5, 5. + 0.5 * slope);
let f = point(k + 1, 0.5, 5. + 0.5 * slope);
triangles.push([[10., 10., 5.].into(), e.into(), f.into()]);
triangles.push([a.into(), b.into(), f.into()]);
triangles.push([a.into(), f.into(), e.into()]);
}
triangles
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn all_strategies_round_trip() {
for name in ["face", "contour", "pocket", "drill"] {
let input = fixture(name).unwrap();
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
assert!(!nc::segments(&parsed, &input.setup).unwrap().is_empty());
}
}
#[test]
fn missing_modes_and_unknown_words_fail() {
let input = fixture("face").unwrap();
for code in [
"G0 X0 Y0 Z10\nM30",
"G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG43 H1\nM30",
"G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG0 XNaN\nM30",
"G21 G90 G91\nM30",
] {
assert!(nc::parse(code, &input.tools).is_err(), "{code}");
}
}
#[test]
fn cancelled_job_cannot_publish() {
let mut g = generate::Generator::new(fixture("face").unwrap()).unwrap();
g.cancel();
assert!(g.step().is_err());
assert!(g.finish().is_err());
}
#[test]
fn malformed_geometry_rejected() {
let mut i = fixture("pocket").unwrap();
if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry {
boundary.outer = vec![[0.0, 0.0], [10.0, 10.0], [0.0, 10.0], [10.0, 0.0]];
}
assert!(generate::generate(i).is_err());
}
#[test]
fn zero_budget_cannot_pass() {
let i = fixture("face").unwrap();
let p = generate::generate(i.clone()).unwrap();
let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap();
let r = verify::simulate(&i, &nc, verify::Limits { work: 1, ..Default::default() }).unwrap();
assert_ne!(r.status, verify::Status::Pass);
}
#[test]
fn equivalent_units() {
let i = fixture("face").unwrap();
let a = nc::parse("G21 G90 G17 G94 G54\nG0 X25.4 Y0 Z25.4\nM30", &i.tools).unwrap();
let b = nc::parse("G20 G90 G17 G94 G54\nG0 X1 Y0 Z1\nM30", &i.tools).unwrap();
assert_eq!(a.records[0].motion, b.records[0].motion);
}
#[test]
fn direction_changes_arc_sweep() {
let i = fixture("face").unwrap();
let src = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X0 Y1 I-1 J0 F100\nM5\nM30";
let a = nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).unwrap();
let b = nc::segments(&nc::parse(&src.replace("G3 ", "G2 "), &i.tools).unwrap(), &i.setup).unwrap();
let len = |s: Vec<nc::Segment>| s.iter().map(|x| x.from.dist(x.to)).sum::<f64>();
assert!((len(b) / len(a) - 3.0).abs() < 0.01);
}
#[test]
fn speed_selection_does_not_start_a_stopped_spindle() {
let i = fixture("face").unwrap();
let src = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000\nG1 X2 F100\nM30";
assert!(nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).is_err());
let running = src.replace("S6000", "S6000 M3");
let parsed = nc::parse(&running, &i.tools).unwrap();
assert_eq!(parsed.records.last().unwrap().motion, Motion::Spindle { rpm: 0.0 });
}
#[test]
fn unsafe_rapid_and_wide_shank_do_not_pass() {
let mut i = fixture("face").unwrap();
let rapid = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X10 Y10 Z2\nM30";
let p = nc::parse(rapid, &i.tools).unwrap();
assert_ne!(
verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap().status,
verify::Status::Pass
);
i.tools[0].diameter = 1.0;
i.tools[0].cutting_length = 1.0;
i.operations[0].stepover = 0.5;
let shank = "G21 G90 G17 G94 G54\nG0 X-1 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z3 F100\nM5\nM30";
let p = nc::parse(shank, &i.tools).unwrap();
let r = verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap();
assert_ne!(r.status, verify::Status::Pass);
assert!(r.findings.iter().any(|f| f.kind.contains("shank") || f.kind.contains("holder") || f.kind == "budget_exhausted"));
}
#[test]
fn gouge_and_fixture_overlap_are_blocked() {
let mut i = fixture("face").unwrap();
let path = generate::generate(i.clone()).unwrap();
let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z4.000000");
let p = nc::parse(&code, &i.tools).unwrap();
let r = verify::simulate(&i, &p, Default::default()).unwrap();
assert_eq!(r.status, verify::Status::Fail);
i.geometry.fixtures.push([Vec3::new(9.0, 9.0, 6.0), Vec3::new(11.0, 9.0, 6.0), Vec3::new(10.0, 11.0, 10.0)]);
let p = nc::round_trip(&path, &i.setup, &i.tools).unwrap();
let r = verify::simulate(&i, &p, Default::default()).unwrap();
assert_ne!(r.status, verify::Status::Pass);
}
#[test]
fn simulation_observes_cancellation() {
let i = fixture("face").unwrap();
let p = generate::generate(i.clone()).unwrap();
let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap();
assert!(verify::simulate_cancellable(&i, &nc, Default::default(), || true).is_err());
}
#[test]
fn islands_winding_ramps_and_tool_fit() {
let mut i = fixture("pocket").unwrap();
if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry {
boundary.outer.reverse();
boundary.islands.push(vec![[9.0, 9.0], [11.0, 9.0], [11.0, 11.0], [9.0, 11.0]]);
}
i.tools[0].diameter = 2.0;
i.operations[0].stepover = 0.5;
i.operations[0].ramp_angle = Some(5_f64.to_radians());
let path = generate::generate(i.clone()).unwrap();
nc::round_trip(&path, &i.setup, &i.tools).unwrap();
i.tools[0].diameter = 30.0;
assert!(generate::generate(i).is_err());
}
#[test]
fn hand_calculated_stock_and_drill_cone() {
let i = fixture("face").unwrap();
let path = generate::generate(i.clone()).unwrap();
let nc = nc::round_trip(&path, &i.setup, &i.tools).unwrap();
let report = verify::simulate(&i, &nc, Default::default()).unwrap();
assert_eq!(report.status, verify::Status::Pass);
assert!((report.remaining_volume_low - 2000.0).abs() < 1e-6);
assert!((report.remaining_volume_high - 2000.0).abs() < 1e-6);
let i = fixture("drill").unwrap();
// Authored NC, independent of the generator. A 118-degree cone rises
// r / tan(59 degrees) from its tip at every radial position r.
let source = "G21 G90 G17 G94 G54\nG0 X10 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z5 F100\nG0 Z15\nM5\nM30";
let nc = nc::parse(source, &i.tools).unwrap();
let actual = verify::probe_height(&i, &nc, [10.25, 10.0]).unwrap();
assert!((actual - (5.0 + 0.25 / 59_f64.to_radians().tan())).abs() < 1e-9);
assert_eq!(verify::probe_height(&i, &nc, [11.0, 10.0]).unwrap(), 6.0);
}
#[test]
fn climb_pocket_follows_counterclockwise_outer_wall() {
let i = fixture("pocket").unwrap();
let path = generate::generate(i).unwrap();
let points: Vec<_> = path
.blocks
.iter()
.filter_map(|b| match b.motion {
Motion::Linear { to, feed: 300.0 } => Some(to),
_ => None,
})
.collect();
// With M3, the pocket wall lies to the right of a counterclockwise cut.
let last = &points[points.len() - 128..];
let area: f64 = last.iter().zip(last.iter().cycle().skip(1)).take(128).map(|(a, b)| a.x * b.y - a.y * b.x).sum();
assert!(area > 0.0);
}
#[test]
fn cooperative_verification_yields_and_cancels() {
use std::future::Future;
use std::task::{Context, Poll, Waker};
let input = fixture("face").unwrap();
let path = generate::generate(input.clone()).unwrap();
let nc = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let cancel = std::cell::Cell::new(false);
let yield_once = || {
let mut first = true;
std::future::poll_fn(move |_| {
if first {
first = false;
Poll::Pending
} else {
Poll::Ready(())
}
})
};
let mut future = std::pin::pin!(verify::simulate_cooperative(&input, &nc, Default::default(), || cancel.get(), yield_once));
let mut cx = Context::from_waker(Waker::noop());
assert!(future.as_mut().poll(&mut cx).is_pending());
cancel.set(true);
assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_))));
}
#[test]
fn excess_facing_stock_is_a_dimensional_failure() {
let i = fixture("face").unwrap();
let path = generate::generate(i.clone()).unwrap();
let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z5.100000");
let nc = nc::parse(&code, &i.tools).unwrap();
let report = verify::simulate(&i, &nc, Default::default()).unwrap();
assert_eq!(report.status, verify::Status::Pass); // no gouge or collision
assert_eq!(report.finished_dimensions, verify::Status::Fail);
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Original narrow-channel recipes with independently triangulated protected stock.
use crate::*;
fn capsule() -> Boundary {
let mut outer = Vec::new();
for (x, start) in [(5., 90_f64), (15., 270_f64)] {
for i in 0..=64 {
let a = (start + 180. * i as f64 / 64.).to_radians();
outer.push([x + 1.1 * a.cos(), 10. + 1.1 * a.sin()]);
}
}
Boundary { outer, islands: vec![] }
}
fn with_boundary(boundary: Boundary) -> Result<JobInput> {
let mut input = super::fixture("pocket")?;
input.setup.name = "2.2 mm capsule channel".into();
input.tools[0].name = "2 mm center-cutting end mill".into();
input.tools[0].diameter = 2.;
input.tools[0].shank_diameter = 2.;
input.operations[0].name = "Finish narrow channel".into();
input.operations[0].stepover = 0.75;
input.operations[0].geometry = Strategy::Pocket { boundary: boundary.clone() };
input.geometry.target.clear();
super::extended::flat(&mut input.geometry.target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), 6.);
super::extended::flat(&mut input.geometry.target, &boundary.outer, &[], 5.);
super::extended::wall(&mut input.geometry.target, &boundary.outer, 6., 5.);
input.fingerprint.clear();
input.fingerprint = crate::fingerprint(&input)?;
Ok(input)
}
pub(super) fn fixture() -> Result<JobInput> {
with_boundary(capsule())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::verify::Status;
fn program(input: &JobInput) -> nc::Parsed {
let path = generate::generate(input.clone()).unwrap();
nc::round_trip(&path, &input.setup, &input.tools).unwrap()
}
#[test]
fn narrow_channel_finishes_protected_walls_and_floor() {
let input = fixture().unwrap();
// A rectangle plus two polygonal semicircles, each with 64 segments.
let area = 22. + 64. * 1.1_f64.powi(2) * (std::f64::consts::PI / 64.).sin();
let volume: f64 =
input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum();
assert!((volume - (2400. - area)).abs() < 1e-8);
let parsed = program(&input);
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!((report.status, report.finished_dimensions), (Status::Pass, Status::Pass), "{:?}", report.findings);
for (xy, expected) in super::super::probes("narrow-channel", 1).unwrap() {
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE);
}
}
#[test]
fn oversized_tool_and_inaccessible_neck_cannot_succeed() {
let mut input = fixture().unwrap();
input.tools[0].diameter = 2.4;
input.tools[0].shank_diameter = 2.4;
assert!(generate::generate(input).unwrap_err().0.contains("tool cannot fit pocket"));
// R2 lobes linked by a 1.6 mm wide, >6 mm long neck. A 2 mm tool
// can enter both lobes but cannot reach the center of the neck.
let angle = 0.4_f64.asin();
let mut outer = Vec::new();
for (x, start) in [(5., angle), (15., std::f64::consts::PI + angle)] {
for i in 0..=128 {
let a = start + (std::f64::consts::TAU - 2. * angle) * i as f64 / 128.;
outer.push([x + 2. * a.cos(), 10. + 2. * a.sin()]);
}
}
let input = with_boundary(Boundary { outer, islands: vec![] }).unwrap();
let parsed = program(&input);
assert_eq!(verify::probe_height(&input, &parsed, [10., 10.]).unwrap(), 6.);
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.finished_dimensions, Status::Fail, "{:?}", report.findings);
assert!(report.findings.iter().any(|f| f.kind == "finished_region_error"));
}
#[test]
fn unsafe_lead_is_rejected_against_original_protected_geometry() {
let mut input = fixture().unwrap();
input.operations[0].lead_distance = 5.;
input.fingerprint.clear();
input.fingerprint = crate::fingerprint(&input).unwrap();
let parsed = program(&input);
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.status, Status::Fail, "{:?}", report.findings);
assert!(report.findings.iter().any(|f| f.severity == verify::FindingSeverity::Error && f.source.is_some()));
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Original dimensioned recipes. Analytic surfaces are triangulated independently
//! of the CAM offset/generation algorithms; no proprietary artifacts are used.
use crate::*;
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2, Vec3};
fn circle(center: [f64; 2], radius: f64) -> Vec<[f64; 2]> {
(0..96)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 96.;
[center[0] + radius * a.cos(), center[1] + radius * a.sin()]
})
.collect()
}
// An L with R2 convex corners and an R2 reentrant corner. The sampled polygon
// itself is the recipe, so there is no unstated curve approximation in its input.
fn boundary() -> Boundary {
let mut outer = Vec::new();
for (center, start, sweep) in
[([4., 4.], 180., 90.), ([16., 4.], 270., 90.), ([16., 8.], 0., 90.), ([12., 12.], 270., -90.), ([8., 16.], 0., 90.), ([4., 16.], 90., 90.)]
{
for i in 0..=32 {
let a = (start + sweep * i as f64 / 32.) * std::f64::consts::PI / 180.;
outer.push([center[0] + 2. * a.cos(), center[1] + 2. * a.sin()]);
}
}
Boundary { outer, islands: vec![circle([6., 6.], 0.8), circle([6., 13.], 0.8)] }
}
fn ring(points: &[[f64; 2]]) -> Loop2 {
Loop2 {
segs: points
.iter()
.zip(points.iter().cycle().skip(1))
.take(points.len())
.map(|(a, b)| Seg2::Line { a: Vec2::new(a[0], a[1]), b: Vec2::new(b[0], b[1]) })
.collect(),
}
}
pub(super) fn flat(out: &mut Vec<[Vec3; 3]>, outer: &[[f64; 2]], holes: &[Vec<[f64; 2]>], z: f64) {
let region = Region2 { outer: ring(outer), holes: holes.iter().map(|h| ring(h)).collect() };
for t in region.triangulate(1e-6) {
if (t[1] - t[0]).cross(t[2] - t[0]).abs() > 1e-14 {
out.push(t.map(|p| Vec3::new(p.x, p.y, z)));
}
}
}
pub(super) fn wall(out: &mut Vec<[Vec3; 3]>, points: &[[f64; 2]], top: f64, bottom: f64) {
for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
let (a, b) = (Vec3::new(a[0], a[1], top), Vec3::new(b[0], b[1], top));
let (c, d) = (Vec3::new(a.x, a.y, bottom), Vec3::new(b.x, b.y, bottom));
out.extend([[a, b, d], [a, d, c]]);
}
}
fn target(boundary: &Boundary, top: f64, drilled: bool) -> Vec<[Vec3; 3]> {
let mut out = Vec::new();
flat(&mut out, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), top);
let mut holes = boundary.islands.clone();
let drill = circle([15., 6.], 0.5);
if drilled {
holes.push(drill.clone());
}
flat(&mut out, &boundary.outer, &holes, 4.);
wall(&mut out, &boundary.outer, top, 4.);
for island in &boundary.islands {
flat(&mut out, island, &[], top);
wall(&mut out, island, top, 4.);
}
if drilled {
let shoulder = 3. + 0.5 / 59_f64.to_radians().tan();
wall(&mut out, &drill, 4., shoulder);
for (a, b) in drill.iter().zip(drill.iter().cycle().skip(1)).take(drill.len()) {
out.push([Vec3::new(15., 6., 3.), Vec3::new(a[0], a[1], shoulder), Vec3::new(b[0], b[1], shoulder)]);
}
}
out
}
pub(super) fn fixture(name: &str) -> Result<JobInput> {
let mut input = super::fixture("pocket")?;
let mut boundary = boundary();
input.setup.name = "Rounded L with two islands".into();
input.tools[0].diameter = 2.;
input.tools[0].shank_diameter = 2.;
input.tools[0].name = "2 mm center-cutting end mill".into();
let mut pocket = input.operations[0].clone();
pocket.name = "Concave pocket and islands".into();
pocket.bottom = 4.;
pocket.stepdown = 0.5;
pocket.stepover = 0.75;
// Exercise winding normalization independently for outer and island loops.
boundary.outer.reverse();
if let Some(first) = boundary.islands.first_mut() {
first.reverse();
}
pocket.geometry = Strategy::Pocket { boundary: boundary.clone() };
input.geometry.target = target(&boundary, if name == "multi-operation" { 5.5 } else { 6. }, name == "multi-operation");
input.operations = vec![pocket.clone()];
if name == "multi-operation" {
let mut face = super::fixture("face")?;
face.tools[0].id = 2;
face.tools[0].number = 2;
face.operations[0].id = 1;
face.operations[0].tool = 2;
face.operations[0].bottom = 5.5;
pocket.id = 2;
pocket.top = 5.5;
pocket.allowance = 0.2;
pocket.coolant = true;
let mut finish = pocket.clone();
finish.id = 3;
finish.name = "Finish pocket".into();
finish.allowance = 0.;
finish.climb = false;
let mut drill = super::fixture("drill")?;
drill.tools[0].id = 3;
drill.tools[0].number = 3;
drill.operations[0].id = 4;
drill.operations[0].tool = 3;
drill.operations[0].top = 4.;
drill.operations[0].bottom = 3.;
drill.operations[0].geometry = Strategy::Drill { holes: vec![[15., 6.]], peck: Some(0.3), full_diameter: false };
input.tools.extend([face.tools.remove(0), drill.tools.remove(0)]);
input.operations = vec![face.operations.remove(0), pocket, finish, drill.operations.remove(0)];
input.setup.frame.origin = Vec3::new(35., -20., 10.);
input.setup.work_offset = 55;
}
input.fingerprint.clear();
input.fingerprint = crate::fingerprint(&input)?;
Ok(input)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn protected_target_volume_matches_the_original_recipe() {
// Polygonal R2 quarter arcs contain 32 equal angle segments; islands
// and the drill use 96. These areas come from triangle sector formulae.
let pocket_area = 176. + 256. * (std::f64::consts::PI / 64.).sin() - 96. * 0.8_f64.powi(2) * (std::f64::consts::TAU / 96.).sin();
for name in ["concave-islands", "multi-operation"] {
let input = fixture(name).unwrap();
let volume: f64 =
input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum();
let expected = if name == "concave-islands" {
2400. - 2. * pocket_area
} else {
let hole_area = 12. * (std::f64::consts::TAU / 96.).sin();
2200. - 1.5 * pocket_area - hole_area * (1. - 1. / (3. * 59_f64.to_radians().tan()))
};
assert!((volume - expected).abs() < 1e-8, "{name}: {volume} != {expected}");
}
}
#[test]
fn extended_fixtures_certify_complete_protected_surfaces() {
for name in ["concave-islands", "multi-operation"] {
let input = fixture(name).unwrap();
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(
(report.status, report.finished_dimensions),
(verify::Status::Pass, verify::Status::Pass),
"{name} {} cells {} work {:?}",
report.cells.len(),
report.work,
report.findings.iter().take(4).collect::<Vec<_>>()
);
for (xy, expected) in super::super::probes(name, input.operations.len()).unwrap() {
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE, "{name} {xy:?}");
}
// Adaptive drill-tip cells must form a conforming stock surface,
// including where their refinement meets the pocket floor.
verify::stock_triangles(&report, input.setup.stock.min.z).unwrap();
}
}
#[test]
fn intermediate_roughing_is_allowed_but_missing_finishing_is_not() {
let mut input = fixture("multi-operation").unwrap();
input.operations.truncate(2);
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let rough = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(
(rough.status, rough.finished_dimensions),
(verify::Status::Pass, verify::Status::Pass),
"{:?}",
rough.findings.iter().take(5).collect::<Vec<_>>()
);
assert!((verify::probe_height(&input, &parsed, [6., 9.]).unwrap() - 4.2).abs() < 1e-8);
let mut finished = fixture("multi-operation").unwrap();
finished.operations.truncate(3);
let missing = verify::simulate(&finished, &parsed, Default::default()).unwrap();
assert_eq!(missing.finished_dimensions, verify::Status::Fail);
}
#[test]
fn removed_islands_wrong_tool_and_wrong_work_offset_are_rejected() {
let input = fixture("concave-islands").unwrap();
let mut damaged = input.clone();
if let Strategy::Pocket { boundary } = &mut damaged.operations[0].geometry {
boundary.islands.clear();
}
let path = generate::generate(damaged).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let result = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(result.status, verify::Status::Fail);
assert_eq!(result.finished_dimensions, verify::Status::Fail);
let input = fixture("multi-operation").unwrap();
let path = generate::generate(input.clone()).unwrap();
let tools: Vec<_> = path.blocks.iter().filter_map(|b| if let Motion::Tool { number, .. } = b.motion { Some(number) } else { None }).collect();
assert_eq!(tools, vec![2, 1, 1, 3]);
let code = nc::post(&path, &input.setup).unwrap();
let wrong = nc::parse(&code.replace("T3 M6", "T1 M6"), &input.tools).unwrap();
let report = verify::simulate(&input, &wrong, Default::default()).unwrap();
assert_eq!(report.status, verify::Status::Fail);
assert_eq!(report.finished_dimensions, verify::Status::Fail);
let wrong = nc::parse(&code.replace("G55", "G54"), &input.tools).unwrap();
assert!(verify::simulate(&input, &wrong, Default::default()).is_err());
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Outside contour of a square boss, with allowance roughing then finishing.
use crate::*;
pub(super) fn fixture() -> Result<JobInput> {
let mut input = super::fixture("contour")?;
input.setup.name = "External square boss".into();
input.tools[0].diameter = 2.;
input.tools[0].shank_diameter = 2.;
input.tools[0].name = "2 mm center-cutting end mill".into();
let square = vec![[5., 5.], [15., 5.], [15., 15.], [5., 15.]];
let mut target = Vec::new();
super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&square), 4.);
super::extended::flat(&mut target, &square, &[], 6.);
super::extended::wall(&mut target, &square, 6., 4.);
input.geometry.target = target;
let mut rough = input.operations[0].clone();
rough.name = "External rough contour".into();
rough.geometry = Strategy::Contour { boundary: Boundary { outer: square.into_iter().rev().collect(), islands: vec![] }, inside: false };
rough.bottom = 4.;
rough.stepdown = 0.5;
rough.stepover = 1.;
rough.allowance = 0.2;
rough.lead_distance = 0.5;
rough.ramp_angle = Some(5_f64.to_radians());
let mut finish = rough.clone();
finish.id = 2;
finish.name = "External finish contour".into();
finish.allowance = 0.;
finish.climb = false;
finish.coolant = true;
input.operations = vec![rough, finish];
input.fingerprint.clear();
input.fingerprint = crate::fingerprint(&input)?;
Ok(input)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn external_contour_finishes_walls_with_leads_and_multiple_depths() {
let input = fixture().unwrap();
let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum();
assert!((volume - 1800.).abs() < 1e-9);
let path = generate::generate(input.clone()).unwrap();
assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Arc { .. })));
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(
(report.status, report.finished_dimensions),
(verify::Status::Pass, verify::Status::Pass),
"cells={} work={} blocks={} {:?}",
report.cells.len(),
report.work,
path.blocks.len(),
report.findings
);
for (xy, expected) in super::super::probes("external-contour", 2).unwrap() {
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE);
}
}
#[test]
fn wrong_contour_side_gouges_and_omitted_finish_leaves_allowance() {
let input = fixture().unwrap();
let mut wrong = input.clone();
wrong.operations.truncate(1);
if let Strategy::Contour { inside, .. } = &mut wrong.operations[0].geometry {
*inside = true;
}
let path = generate::generate(wrong).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.status, verify::Status::Fail);
let mut rough = input.clone();
rough.operations.truncate(1);
let path = generate::generate(rough).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.finished_dimensions, verify::Status::Fail, "cells={} work={} {:?}", report.cells.len(), report.work, report.findings);
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Two through holes in a plate with a raised clamp between their axes.
//! Geometry and analytic expectations are independent of toolpath generation.
use crate::*;
use solidcraft_geom::Vec3;
pub(super) fn fixture() -> Result<JobInput> {
let mut input = super::fixture("drill")?;
input.setup.name = "Clamped through-hole plate".into();
input.setup.fixtures = vec![BodyRef { body: "Center clamp".into(), occurrence: vec![] }];
let mut simple = input.operations[0].clone();
simple.name = "Simple through hole".into();
simple.bottom = -0.25;
simple.geometry = Strategy::Drill { holes: vec![[6., 10.]], peck: None, full_diameter: true };
let mut peck = simple.clone();
peck.id = 2;
peck.name = "Peck through hole".into();
peck.coolant = true;
peck.geometry = Strategy::Drill { holes: vec![[14., 10.]], peck: Some(2.), full_diameter: true };
input.operations = vec![simple, peck];
let holes: Vec<Vec<[f64; 2]>> = [6., 14.]
.into_iter()
.map(|x| {
(0..96)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 96.;
[x + 0.5 * a.cos(), 10. + 0.5 * a.sin()]
})
.collect()
})
.collect();
let mut target = Vec::new();
super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], &holes, 6.);
for hole in &holes {
super::extended::wall(&mut target, hole, 6., 0.);
}
input.geometry.target = target;
// Closed, outward-oriented 2 x 2 x 2 mm clamp on the plate top. A
// clearance-height transfer passes over it; a transfer at Z7 cannot.
let p = [[9., 9., 6.], [11., 9., 6.], [11., 11., 6.], [9., 11., 6.], [9., 9., 8.], [11., 9., 8.], [11., 11., 8.], [9., 11., 8.]].map(Vec3::from);
input.geometry.fixtures = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]
.into_iter()
.flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]])
.collect();
input.geometry.fixture_groups = vec![12];
input.fingerprint.clear();
input.fingerprint = crate::fingerprint(&input)?;
Ok(input)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn clamped_through_holes_preserve_surfaces_and_export_open_bores() {
let input = fixture().unwrap();
// Each inscribed 96-gon has area 96/2 * r^2 * sin(2pi/96).
let expected = 2400. - 6. * 24. * (std::f64::consts::TAU / 96.).sin();
let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum();
assert!((volume - expected).abs() < 1e-8);
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!((report.status, report.finished_dimensions), (verify::Status::Pass, verify::Status::Pass), "{:?}", report.findings);
for (xy, expected) in super::super::probes("clamped-through-holes", 2).unwrap() {
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE);
}
let stock = verify::stock_stl(&report, 0.).unwrap();
let mesh = compare::read_stl(stock.as_bytes()).unwrap();
assert_eq!(compare::stock_mesh(&report, &mesh, 0., TOLERANCE).unwrap().status, verify::Status::Pass);
}
#[test]
fn missing_breakthrough_and_low_clamp_transfer_do_not_pass() {
let input = fixture().unwrap();
// Authored NC: the tip reaches the bottom but the full diameter does
// not. Both bores retain a cone of material and must fail dimensions.
let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z0 F100\nG0 Z15\nG0 X14\nG1 Z0 F100\nG0 Z15\nM5\nM30";
let parsed = nc::parse(code, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.finished_dimensions, verify::Status::Fail);
// An otherwise clear rapid at Z7 crosses the raised clamp between
// axes. Require the actual source line, not merely a non-pass status.
let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nG0 Z7\nG0 X14\nG0 Z15\nM30";
let parsed = nc::parse(code, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.status, verify::Status::Fail);
assert!(report.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(5)));
let mut short = input;
short.tools[0].cutting_length = 6.;
assert!(generate::generate(short).is_err());
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Coverage certificates use boundary incidence, never summed overlapping area.
use crate::{spatial::Index, verify::Cell, *};
use solidcraft_geom::Vec3;
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq)]
struct Coord(f64);
impl Eq for Coord {}
impl PartialOrd for Coord {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Coord {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) }
}
}
type Point = [Coord; 2];
fn xy(p: Vec3) -> Point {
[Coord(p.x), Coord(p.y)]
}
fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] })
}
fn corners(min: [f64; 2], max: [f64; 2]) -> [[f64; 2]; 4] {
[min, [max[0], min[1]], max, [min[0], max[1]]]
}
fn predicate_coordinates(values: impl IntoIterator<Item = f64>) -> Result<()> {
// Avoid underflow in adaptive predicate expansions. Such coordinates are
// far below the supported machining error budget; reject, never snap.
if values.into_iter().any(|v| v != 0. && v.abs() < 1e-100) {
return invalid("comparison coordinates are below the predicate numeric range");
}
Ok(())
}
/// Reject overlaps and hostile intervals; certify exact rectangular tiling in
/// O(n log n). Missing strips of any width stay missing, with no area epsilon.
pub(crate) fn cells(cells: &[Cell], stock: Stock) -> Result<bool> {
stock.validate()?;
if cells.is_empty() || cells.len() > 1_000_000 {
return invalid("invalid comparison cell count");
}
let mut events = Vec::with_capacity(cells.len() * 2);
for (id, c) in cells.iter().enumerate() {
for p in [Vec3::new(c.min[0], c.min[1], c.low), Vec3::new(c.max[0], c.max[1], c.high)] {
model::point(p)?;
}
predicate_coordinates(c.min.into_iter().chain(c.max))?;
if c.min[0] >= c.max[0]
|| c.min[1] >= c.max[1]
|| c.low > c.high
|| c.low < stock.min.z
|| c.high > stock.max.z
|| c.min[0] < stock.min.x
|| c.min[1] < stock.min.y
|| c.max[0] > stock.max.x
|| c.max[1] > stock.max.y
{
return invalid("comparison cell outside declared stock or invalid interval");
}
events.push((Coord(c.min[0]), true, id));
events.push((Coord(c.max[0]), false, id));
}
events.sort_unstable();
let mut active = BTreeMap::<Coord, Coord>::new();
let mut gaps = 1isize;
let mut previous = stock.min.x;
let mut complete = true;
for (at, start, id) in events {
if at.0 > previous && gaps != 0 {
complete = false;
}
previous = at.0;
let c = &cells[id];
let a = Coord(c.min[1]);
let b = Coord(c.max[1]);
if start {
let left = active.range(..=a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end);
let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start);
if left > a || right < b {
return invalid("overlapping comparison cells");
}
gaps += isize::from(left != a) + isize::from(b != right) - isize::from(left != right);
active.insert(a, b);
} else {
if active.remove(&a) != Some(b) {
return invalid("invalid comparison cell sweep");
}
let left = active.range(..a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end);
let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start);
gaps += isize::from(left != right) - isize::from(left != a) - isize::from(b != right);
}
}
Ok(complete && previous == stock.max.x)
}
pub(crate) struct MeshField<'a> {
triangles: &'a [[Vec3; 3]],
index: Index,
boundary: Vec<([f64; 2], [f64; 2])>,
boundary_index: Index,
stock: Stock,
guard: f64,
}
impl<'a> MeshField<'a> {
pub(crate) fn new(triangles: &'a [[Vec3; 3]], stock: Stock) -> Result<Self> {
stock.validate()?;
crate::stock_mesh::validate_triangles(triangles)?;
let mut edges = BTreeMap::<(Point, Point), i32>::new();
let mut items = Vec::new();
let extent = stock.min.len().max(stock.max.len()).max(1.);
for (id, t) in triangles.iter().enumerate() {
for p in t {
predicate_coordinates([p.x, p.y])?;
if p.x < stock.min.x || p.y < stock.min.y || p.z < stock.min.z || p.x > stock.max.x || p.y > stock.max.y || p.z > stock.max.z {
return invalid("reference stock mesh exceeds declared stock; check units and frame");
}
}
let p = t.map(|v| [v.x, v.y]);
let orientation = orient(p[0], p[1], p[2]);
if orientation < 0. && t.iter().any(|p| p.z != stock.min.z) {
return invalid("reference stock is not a bottom-supported vertical height field");
}
if orientation <= 0. {
continue;
}
let min = t.iter().copied().fold(t[0], Vec3::min);
let max = t.iter().copied().fold(t[0], Vec3::max);
items.push((id, [min.x, min.y], [max.x, max.y]));
for (a, b) in [(xy(t[0]), xy(t[1])), (xy(t[1]), xy(t[2])), (xy(t[2]), xy(t[0]))] {
let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) };
*edges.entry(key).or_default() += sign;
}
}
let boundary: Vec<_> = edges.into_iter().filter(|(_, n)| *n != 0).map(|((a, b), _)| (a.map(|v| v.0), b.map(|v| v.0))).collect();
let boundary_index = Index::new(
boundary.iter().enumerate().map(|(id, (a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(),
);
Ok(Self { triangles, index: Index::new(items), boundary, boundary_index, stock, guard: 128. * f64::EPSILON * extent })
}
/// Bounds the maximum upward surface everywhere in a rectangle, including
/// holes (height = stock bottom). Shared projected edges cancel exactly;
/// unmatched edges crossing the interior forbid a full-coverage certificate.
pub(crate) fn bounds(&self, min: [f64; 2], max: [f64; 2], work: &mut usize, budget: usize) -> Option<[f64; 2]> {
let rectangle = corners(min, max);
let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
let mut ids = Vec::new();
self.boundary_index.query(min, max, &mut ids);
let mut crossed = false;
for id in ids {
*work += 1;
if *work > budget {
return None;
}
let (a, b) = self.boundary[id];
if (0..2).any(|k| a[k].max(b[k]) <= min[k] || a[k].min(b[k]) >= max[k]) {
continue;
}
let signs = rectangle.map(|c| orient(a, b, c));
if signs.iter().any(|v| *v < 0.) && signs.iter().any(|v| *v > 0.) {
crossed = true;
break;
}
}
let mut ids = Vec::new();
self.index.query(min, max, &mut ids);
let mut low = f64::INFINITY;
let mut high = self.stock.min.z;
let mut inside = false;
for id in ids {
*work += 1;
if *work > budget {
return None;
}
let t = &self.triangles[id];
let p = t.map(|p| [p.x, p.y]);
// These cells form a tiling whose closed columns are united later.
// A triangle touching only a cell edge belongs to the adjacent cell;
// including its height here creates false interior discontinuities.
// Positive-area overlap is decided with exact orientation predicates.
if (0..2).any(|k| p.iter().all(|v| v[k] <= min[k]) || p.iter().all(|v| v[k] >= max[k])) {
continue;
}
let edges = [(p[0], p[1]), (p[1], p[2]), (p[2], p[0])];
if edges.iter().any(|(a, b)| rectangle.iter().all(|c| orient(*a, *b, *c) <= 0.)) {
continue;
}
inside |= edges.iter().all(|(a, b)| orient(*a, *b, center) >= 0.);
let mut lo = t.iter().map(|v| v.z).fold(f64::INFINITY, f64::min);
let mut hi = t.iter().map(|v| v.z).fold(f64::NEG_INFINITY, f64::max);
let n = (t[1] - t[0]).cross(t[2] - t[0]);
let normal_error = 4. * self.guard * (1. + (t[1] - t[0]).len() + (t[2] - t[0]).len());
if n.z > normal_error {
let slope = [n.x / n.z, n.y / n.z];
let slope_error = slope.map(|s| normal_error * (1. + s.abs()) / (n.z - normal_error));
let error = self.guard * (1. + slope[0].abs() + slope[1].abs())
+ slope_error[0] * (t[0].x - min[0]).abs().max((t[0].x - max[0]).abs())
+ slope_error[1] * (t[0].y - min[1]).abs().max((t[0].y - max[1]).abs());
let z = rectangle.map(|p| t[0].z + slope[0] * (t[0].x - p[0]) + slope[1] * (t[0].y - p[1]));
if error.is_finite() && z.iter().all(|v| v.is_finite()) {
lo = lo.max(z.iter().copied().fold(f64::INFINITY, f64::min) - error);
hi = hi.min(z.iter().copied().fold(f64::NEG_INFINITY, f64::max) + error);
}
}
low = low.min(lo);
high = high.max(hi);
}
if !inside || crossed {
low = self.stock.min.z;
}
Some([(low - self.guard).max(self.stock.min.z), (high + self.guard).min(self.stock.max.z)])
}
}
+554
View File
@@ -0,0 +1,554 @@
//! Independent analytic finished-height envelopes. No path-generation or offset
//! code is used here. Radial bands limit diameter error; depth is checked in Z.
use crate::{verify::Status, *};
#[derive(Clone, Copy, Debug)]
pub(crate) struct Envelope {
lower_min: f64,
lower_max: f64,
upper_min: f64,
upper_max: f64,
}
impl Envelope {
fn range(lo: f64, hi: f64, tolerance: f64) -> Self {
Self { lower_min: lo - tolerance, lower_max: hi - tolerance, upper_min: lo + tolerance, upper_max: hi + tolerance }
}
fn alternatives(self, b: Self) -> Self {
Self {
lower_min: self.lower_min.min(b.lower_min),
lower_max: self.lower_max.max(b.lower_max),
upper_min: self.upper_min.min(b.upper_min),
upper_max: self.upper_max.max(b.upper_max),
}
}
fn cut(self, b: Self) -> Self {
Self {
lower_min: self.lower_min.min(b.lower_min),
lower_max: self.lower_max.min(b.lower_max),
upper_min: self.upper_min.min(b.upper_min),
upper_max: self.upper_max.min(b.upper_max),
}
}
pub(crate) fn classify(self, low: f64, high: f64) -> Status {
if high < self.lower_min || low > self.upper_max {
Status::Fail
} else if low >= self.lower_max && high <= self.upper_min {
Status::Pass
} else {
Status::Inconclusive
}
}
pub(crate) fn description(self) -> String {
format!(
"Finished-region height must lie within [{:.6}, {:.6}] mm; cell-wide certified range [{:.6}, {:.6}] mm",
self.lower_min, self.upper_max, self.lower_max, self.upper_min
)
}
}
enum Feature {
Plane(f64),
Hole { center: [f64; 2], radius: f64, tip: f64, slope: f64 },
Mill { region: crate::region::Region, outside: bool, width: Option<f64>, allowance: f64, depth: f64 },
}
pub(crate) struct Finished {
features: Vec<Feature>,
bottom: f64,
top: f64,
tolerance: f64,
radial_tolerance: f64,
pub(crate) refinement: f64,
}
impl Finished {
pub(crate) fn new(input: &JobInput, tolerance: f64) -> Result<Option<Self>> {
// Reserve radial error on both sides of a diameter, and convert XY
// interpolation error into a conservative cone-height error as well.
let has_walls = input.operations.iter().any(|o| !matches!(o.geometry, Strategy::Face));
let profile_error = input.operations.iter().filter(|o| o.source.is_some()).map(|o| crate::profile_tolerance(o.tolerance)).fold(0.0, f64::max);
let radial_tolerance = tolerance / 2.0 - POST_ERROR - profile_error;
let slope = input
.operations
.iter()
.filter(|o| matches!(o.geometry, Strategy::Drill { .. }))
.filter_map(|o| input.tools.iter().find(|t| t.id == o.tool))
.map(|t| 1.0 / (t.tip_angle / 2.0).tan())
.fold(0.0, f64::max);
let tolerance = tolerance - POST_ERROR * (1.0 + slope);
if tolerance <= 0.0 || (has_walls && radial_tolerance <= 0.0) {
return Ok(None);
}
let mut features = Vec::new();
for op in &input.operations {
match &op.geometry {
Strategy::Face => features.push(Feature::Plane(op.bottom + op.allowance)),
Strategy::Drill { holes, full_diameter, .. } => {
let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?;
let tip = op.bottom - if *full_diameter { tool.tip_length() } else { 0.0 };
for center in holes {
if features.len() >= MAX_MOVES {
return invalid("dimensional feature budget exceeded");
}
features.push(Feature::Hole { center: *center, radius: tool.diameter / 2.0, tip, slope: 1.0 / (tool.tip_angle / 2.0).tan() });
}
}
Strategy::Contour { boundary, inside } => {
if !boundary.islands.is_empty() {
return invalid("select contour islands as separate operations");
}
let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?;
features.push(Feature::Mill {
region: crate::region::Region::new(boundary)?,
outside: !inside,
width: Some(tool.diameter / 2.),
allowance: op.allowance,
depth: op.bottom + op.allowance,
});
}
Strategy::Pocket { boundary } => features.push(Feature::Mill {
region: crate::region::Region::new(boundary)?,
outside: false,
width: None,
allowance: op.allowance,
depth: op.bottom + op.allowance,
}),
}
}
Ok(Some(Self {
features,
bottom: input.setup.stock.min.z,
top: input.setup.stock.max.z,
tolerance,
radial_tolerance,
refinement: if has_walls { VERIFY_ERROR.min(tolerance / 8.0).min(radial_tolerance / 4.0) } else { VERIFY_ERROR },
}))
}
pub(crate) async fn envelope<F: std::future::Future<Output = ()>>(
&self,
region: ([f64; 2], f64),
work: &mut usize,
budget: usize,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Option<Envelope>> {
let (p, half) = region;
let unchanged = Envelope::range(self.top, self.top, self.tolerance);
let mut combined = unchanged;
for (index, feature) in self.features.iter().enumerate() {
if index % 4096 == 4095 {
yield_control().await;
if cancelled() {
return invalid("dimensional verification cancelled");
}
}
*work += 1;
if *work > budget {
return Ok(None);
}
let allowed = match feature {
Feature::Plane(z) => {
let z = z.clamp(self.bottom, self.top);
Envelope::range(z, z, self.tolerance)
}
Feature::Mill { region, outside, width, allowance, depth } => {
let reach = allowance + width.unwrap_or(0.) + self.radial_tolerance;
let Some(mut d) = region.range((p, half, reach), work, budget, cancelled, yield_control).await? else {
return Ok(None);
};
if *outside {
d = [-d[1], -d[0]];
}
let floor = Envelope::range(depth.clamp(self.bottom, self.top), depth.clamp(self.bottom, self.top), self.tolerance);
// A contour finishes the wall and adjacent floor out to one
// tool radius. Farther stock on the cutting side is optional
// roughing stock; it must never be cut below the stated floor.
let optional = Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..floor };
let mut possible: Option<Envelope> = None;
let mut add = |v| possible = Some(possible.map_or(v, |old| old.alternatives(v)));
if d[0] <= allowance - self.radial_tolerance {
add(unchanged);
}
if d[1] >= allowance - self.radial_tolerance && d[0] <= allowance + self.radial_tolerance {
add(optional);
}
if d[1] >= allowance + self.radial_tolerance && width.is_none_or(|w| d[0] <= allowance + w) {
add(floor);
}
if width.is_some_and(|w| d[1] >= allowance + w) {
add(optional);
}
let Some(allowed) = possible else {
return Ok(None);
};
allowed
}
Feature::Hole { center, radius, tip, slope } => {
let r = (p[0] - center[0]).hypot(p[1] - center[1]);
let lo = (r - half).max(0.0);
let hi = r + half;
let zlo = (tip + lo.min(*radius) * slope).clamp(self.bottom, self.top);
let zhi = (tip + hi.min(*radius) * slope).clamp(self.bottom, self.top);
let cut = Envelope::range(zlo, zhi, self.tolerance);
let mut possible: Option<Envelope> = None;
let mut add = |v| {
possible = Some(possible.map_or(v, |old| old.alternatives(v)));
};
if lo <= radius - self.radial_tolerance {
add(cut);
}
if hi >= radius + self.radial_tolerance {
add(unchanged);
}
if hi >= radius - self.radial_tolerance && lo <= radius + self.radial_tolerance {
add(Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..cut });
}
let Some(allowed) = possible else {
return Ok(None);
};
allowed
}
};
combined = combined.cut(allowed);
}
Ok(Some(combined))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn envelope(f: &Finished, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> Option<Envelope> {
use std::future::Future;
let mut ready = || std::future::ready(());
let mut future = std::pin::pin!(f.envelope((p, half), work, budget, &|| false, &mut ready));
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
std::task::Poll::Ready(result) => result.unwrap(),
_ => None,
}
}
fn finished() -> Finished {
Finished::new(&corpus::fixture("drill").unwrap(), TOLERANCE).unwrap().unwrap()
}
#[test]
fn generated_contours_and_pockets_certify_selected_finished_regions() {
for name in ["contour", "pocket"] {
let input = corpus::fixture(name).unwrap();
let path = generate::generate(input.clone()).unwrap();
let code = nc::post(&path, &input.setup).unwrap();
let report = simulate(&input, &code);
assert_eq!(
report.status,
Status::Pass,
"{name} cells {} work {} {:?}",
report.cells.len(),
report.work,
report.findings.iter().take(4).collect::<Vec<_>>()
);
assert_eq!(
report.finished_dimensions,
Status::Pass,
"{name} cells {} work {} {:?}",
report.cells.len(),
report.work,
report.findings.iter().take(4).collect::<Vec<_>>()
);
}
}
#[test]
fn missed_milling_wrong_floor_and_inaccessible_corners_fail() {
for name in ["contour", "pocket"] {
let input = corpus::fixture(name).unwrap();
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
let wrong = simulate(&input, &code.replace("Z5.000000", "Z5.100000"));
assert_eq!(wrong.finished_dimensions, Status::Fail, "{name}");
let missed = simulate(&input, "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n");
assert_eq!(missed.finished_dimensions, Status::Fail, "{name}");
}
let mut input = corpus::fixture("pocket").unwrap();
input.operations[0].geometry =
Strategy::Pocket { boundary: Boundary { outer: vec![[2., 2.], [18., 2.], [18., 18.], [2., 18.]], islands: vec![] } };
// A round cutter cannot finish sharp internal corners. Protected geometry
// is independently relaxed here to isolate the dimensional failure.
for t in &mut input.geometry.target {
for p in t {
p.z = 0.;
}
}
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail);
}
#[test]
fn allowance_winding_multiple_depths_and_external_contours() {
let mut input = corpus::fixture("contour").unwrap();
input.operations[0].allowance = 0.2;
input.operations[0].stepdown = 0.3;
if let Strategy::Contour { boundary, .. } = &mut input.operations[0].geometry {
boundary.outer.reverse();
}
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
let r = simulate(&input, &code);
assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings);
if let Strategy::Contour { inside, .. } = &mut input.operations[0].geometry {
*inside = false;
}
// The separate protected-CAD tests exercise real walls; this case checks
// only the independent outside/inside selection and allowance contract.
for t in &mut input.geometry.target {
for p in t {
p.z = 0.;
}
}
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
let r = simulate(&input, &code);
assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings);
}
#[test]
fn pocket_islands_are_protected_and_finished() {
let mut input = corpus::fixture("pocket").unwrap();
if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry {
boundary.islands.push(
(0..48)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 48.;
[10. + a.cos(), 10. + a.sin()]
})
.collect(),
);
}
input.tools[0].diameter = 2.;
input.operations[0].stepover = 0.75;
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
let r = simulate(&input, &code);
assert_eq!(
r.finished_dimensions,
Status::Pass,
"cells {} work {} {:?}",
r.cells.len(),
r.work,
r.findings.iter().take(3).collect::<Vec<_>>()
);
let mut without = input.clone();
if let Strategy::Pocket { boundary } = &mut without.operations[0].geometry {
boundary.islands.clear();
}
let code = nc::post(&generate::generate(without.clone()).unwrap(), &without.setup).unwrap();
assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail);
if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry {
boundary.islands = [8., 12.]
.into_iter()
.map(|x| {
(0..48)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 48.;
[x + 0.8 * a.cos(), 10. + 0.8 * a.sin()]
})
.collect()
})
.collect();
}
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
let r = simulate(&input, &code);
assert_eq!(
r.finished_dimensions,
Status::Pass,
"cells {} work {} {:?}",
r.cells.len(),
r.work,
r.findings.iter().take(3).collect::<Vec<_>>()
);
}
#[test]
fn associated_profile_chord_error_has_a_separate_budget() {
let mut input = corpus::fixture("pocket").unwrap();
input.operations[0].source = Some(ProfileRef { sketch: 1, curves: vec!["wall".into()], occurrence: vec![] });
let f = Finished::new(&input, TOLERANCE).unwrap().unwrap();
assert!((f.radial_tolerance - (TOLERANCE / 2. - POST_ERROR - crate::profile_tolerance(TOLERANCE))).abs() < 1e-12);
assert_eq!(crate::profile_tolerance(TOLERANCE), 0.0005);
// A looser operation can coexist with a tighter program tolerance. Use
// the actual sampling budget of each source, not the program minimum.
input.operations[0].tolerance = 0.1;
let f = Finished::new(&input, 0.005).unwrap().unwrap();
assert!((f.radial_tolerance - 0.001).abs() < 1e-12);
let mut unused = input.tools[0].clone();
unused.id = 2;
unused.number = 2;
unused.kind = ToolKind::TwistDrill;
unused.tip_angle = 0.11;
input.tools.push(unused);
assert!(Finished::new(&input, 0.005).unwrap().is_some());
}
#[test]
fn wrong_milling_diameter_and_shifted_paths_are_detected() {
let mut input = corpus::fixture("contour").unwrap();
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
for diameter in [5.8, 6.2] {
let mut other = input.tools[0].clone();
other.id = 2;
other.number = 2;
other.diameter = diameter;
input.tools.truncate(1);
input.tools.push(other);
assert_eq!(simulate(&input, &code.replace("T1 M6", "T2 M6")).finished_dimensions, Status::Fail);
}
let mut shifted = nc::parse(&code, &input.tools).unwrap();
for record in &mut shifted.records {
match &mut record.motion {
Motion::Rapid { to } | Motion::Linear { to, .. } => to.x += 0.03,
Motion::Arc { to, center, .. } => {
to.x += 0.03;
center.x += 0.03;
}
_ => {}
}
}
assert_eq!(verify::simulate(&input, &shifted, Default::default()).unwrap().finished_dimensions, Status::Fail);
}
#[test]
fn hand_calculated_cone_and_diameter_band() {
let f = finished();
let mut work = 0;
assert_eq!(envelope(&f, [10., 10.], 0., &mut work, 100).unwrap().classify(5., 5.), Status::Pass);
let z = 5. + 0.25 / (59_f64.to_radians().tan());
assert_eq!(envelope(&f, [10.25, 10.], 0., &mut work, 100).unwrap().classify(z, z), Status::Pass);
assert_eq!(envelope(&f, [10.49, 10.], 0., &mut work, 100).unwrap().classify(6., 6.), Status::Fail);
assert_eq!(envelope(&f, [10.51, 10.], 0., &mut work, 100).unwrap().classify(5.3, 5.3), Status::Fail);
assert_eq!(envelope(&f, [10.5, 10.], 0., &mut work, 100).unwrap().classify(5.31, 6.), Status::Pass);
}
#[test]
fn boundary_uncertainty_and_resource_limits_never_pass() {
let f = finished();
let mut work = 0;
assert_eq!(envelope(&f, [10.5, 10.], 0.01, &mut work, 100).unwrap().classify(5.3, 6.), Status::Inconclusive);
assert!(envelope(&f, [10., 10.], 0., &mut work, 0).is_none());
}
fn program(tool: u32, x: f64, depth: f64) -> String {
format!(
"G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nT{tool} M6\nS6000 M3\nM9\nG0 X{x} Y10 Z15\nG1 Z6 F100\nG1 Z{depth} F100\nG0 Z15\nM5\nM9\nM30\n"
)
}
fn simulate(input: &JobInput, code: &str) -> crate::verify::Report {
let parsed = nc::parse(code, &input.tools).unwrap();
verify::simulate(input, &parsed, Default::default()).unwrap()
}
#[test]
fn blind_hole_certifies_and_bad_depth_position_diameter_fail() {
let mut input = corpus::fixture("drill").unwrap();
let correct = simulate(&input, &program(1, 10., 5.));
assert_eq!(correct.status, Status::Pass);
assert_eq!(correct.finished_dimensions, Status::Pass, "{:?}", correct.findings);
for (x, depth) in [(10., 5.1), (10., 4.9), (10.03, 5.)] {
assert_eq!(simulate(&input, &program(1, x, depth)).finished_dimensions, Status::Fail);
}
for diameter in [0.8, 1.2] {
let mut other = input.tools[0].clone();
other.id = 2;
other.number = 2;
other.diameter = diameter;
other.shank_diameter = diameter;
input.tools.truncate(1);
input.tools.push(other);
assert_eq!(simulate(&input, &program(2, 10., 5.)).finished_dimensions, Status::Fail);
}
}
#[test]
fn absent_hole_is_not_hidden_by_a_coarse_stock_cell() {
let input = corpus::fixture("drill").unwrap();
let code = "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n";
let report = simulate(&input, code);
assert_eq!(report.finished_dimensions, Status::Fail);
assert!(report.findings.iter().any(|f| f.kind == "finished_region_error"));
}
#[test]
fn full_diameter_blind_and_through_depths_are_certified() {
for bottom in [4.5, 0.] {
let mut input = corpus::fixture("drill").unwrap();
input.operations[0].bottom = bottom;
input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.]], peck: None, full_diameter: true };
for tri in &mut input.geometry.target {
for p in tri {
p.z = 0.;
}
}
let depth = bottom - input.tools[0].tip_length();
let report = simulate(&input, &program(1, 10., depth));
assert_eq!(report.status, Status::Pass, "{:?}", report.findings);
assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings);
}
}
#[test]
fn declared_drill_allowance_is_rejected_instead_of_ignored() {
let mut input = corpus::fixture("drill").unwrap();
input.operations[0].allowance = 0.1;
assert!(input.validate().is_err());
}
#[test]
fn facing_and_multiple_holes_compose_without_restoring_stock() {
let mut input = corpus::fixture("drill").unwrap();
for t in &mut input.geometry.target {
for p in t {
p.z = 0.0;
}
}
input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.], [12., 10.]], peck: Some(0.3), full_diameter: false };
let mut face = corpus::fixture("face").unwrap();
face.tools[0].id = 2;
face.tools[0].number = 2;
face.operations[0].id = 2;
face.operations[0].tool = 2;
face.operations[0].bottom = 5.8;
input.tools.push(face.tools[0].clone());
input.operations.push(face.operations[0].clone());
let path = generate::generate(input.clone()).unwrap();
let code = nc::post(&path, &input.setup).unwrap();
let report = simulate(&input, &code);
assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings);
}
#[test]
fn dimensional_work_yields_and_cancels() {
use std::{
future::Future,
task::{Context, Poll, Waker},
};
let mut f = finished();
f.features = (0..5000).map(|_| Feature::Plane(5.)).collect();
let cancel = std::cell::Cell::new(false);
let mut yield_once = || {
let mut first = true;
std::future::poll_fn(move |_| {
if first {
first = false;
Poll::Pending
} else {
Poll::Ready(())
}
})
};
let mut work = 0;
let cancelled = || cancel.get();
let mut future = std::pin::pin!(f.envelope(([10., 10.], 0.001), &mut work, 10000, &cancelled, &mut yield_once));
let mut context = Context::from_waker(Waker::noop());
assert!(future.as_mut().poll(&mut context).is_pending());
cancel.set(true);
assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_))));
}
#[test]
fn equivalent_inch_and_mm_holes_certify_the_same_dimensions() {
let input = corpus::fixture("drill").unwrap();
let inch = format!(
"G20 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z{}\nT1 M6\nS6000 M3\nM9\nG0 X{} Y{} Z{}\nG1 Z{} F{}\nG1 Z{}\nG0 Z{}\nM5\nM9\nM30\n",
15. / 25.4,
10. / 25.4,
10. / 25.4,
15. / 25.4,
6. / 25.4,
100. / 25.4,
5. / 25.4,
15. / 25.4
);
let metric = simulate(&input, &program(1, 10., 5.));
let imperial = simulate(&input, &inch);
assert_eq!(imperial.finished_dimensions, Status::Pass);
assert!((imperial.cutting_distance - metric.cutting_distance).abs() < 1e-10);
assert!((imperial.remaining_volume_high - metric.remaining_volume_high).abs() < 1e-8);
}
}
+680
View File
@@ -0,0 +1,680 @@
//! Toolpath construction. No verifier or NC interpreter algorithms live here.
use crate::*;
use cavalier_contours::polyline::{PlineSource, PlineSourceMut, Polyline, seg_arc_radius_and_center, seg_length};
use cavalier_contours::shape_algorithms::{Shape, ShapeOffsetOptions};
use solidcraft_geom::Vec3;
use std::{
future::Future,
pin::Pin,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
task::{Context, Poll, Waker},
};
/// A portable job advances a bounded emission/validation chunk or one bounded
/// library offset query per poll. No executor, OS thread or browser API is used.
pub struct Generator {
pending: Option<Pin<Box<dyn Future<Output = Result<Toolpath>> + Send>>>,
path: Option<Toolpath>,
progress: Arc<AtomicUsize>,
failed: Option<String>,
cancelled: bool,
}
impl Generator {
pub fn new(input: JobInput) -> Result<Self> {
input.validate()?;
if input.operations.is_empty() || input.operations.len() > MAX_ITEMS {
return invalid("empty or excessive operation list");
}
let progress = Arc::new(AtomicUsize::new(0));
let pending = Box::pin(generate_incremental(input, progress.clone()));
Ok(Self { pending: Some(pending), path: None, progress, failed: None, cancelled: false })
}
pub fn cancel(&mut self) {
self.cancelled = true;
self.pending = None;
self.path = None;
}
pub fn progress(&self) -> f64 {
self.progress.load(Ordering::Relaxed) as f64 / 1_000_000.
}
pub fn step(&mut self) -> Result<bool> {
if self.cancelled {
return invalid("generation cancelled");
}
if let Some(error) = &self.failed {
return invalid(error.clone());
}
if self.path.is_some() {
return Ok(true);
}
let future = self.pending.as_mut().ok_or_else(|| Error("generation has no pending work".into()))?;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| future.as_mut().poll(&mut Context::from_waker(Waker::noop()))));
match outcome {
Ok(Poll::Pending) => Ok(false),
Ok(Poll::Ready(Ok(path))) => {
self.pending = None;
self.path = Some(path);
self.progress.store(1_000_000, Ordering::Relaxed);
Ok(true)
}
result => {
let error = match result {
Ok(Poll::Ready(Err(e))) => e,
_ => Error("contour library failed; no path published".into()),
};
self.pending = None;
self.failed = Some(error.0.clone());
Err(error)
}
}
}
pub fn finish(self) -> Result<Toolpath> {
if self.cancelled {
return invalid("generation cancelled");
}
self.path.ok_or_else(|| Error(self.failed.unwrap_or_else(|| "generation incomplete".into())))
}
}
#[derive(Default)]
struct YieldOnce(bool);
impl Future for YieldOnce {
type Output = ();
fn poll(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> {
if self.0 {
Poll::Ready(())
} else {
self.0 = true;
Poll::Pending
}
}
}
#[derive(Clone)]
struct Progress {
value: Arc<AtomicUsize>,
operation: usize,
count: usize,
}
impl Progress {
fn set(&self, fraction: f64) {
self.value.fetch_max(((self.operation as f64 + fraction.clamp(0., 1.)) / self.count as f64 * 1_000_000.) as usize, Ordering::Relaxed);
}
}
async fn generate_incremental(input: JobInput, value: Arc<AtomicUsize>) -> Result<Toolpath> {
let mut path = Toolpath { fingerprint: input.fingerprint.clone(), blocks: Vec::new() };
for (index, o) in input.operations.iter().enumerate() {
YieldOnce::default().await;
let progress = Progress { value: value.clone(), operation: index, count: input.operations.len() };
let blocks = operation(&input, o, progress.clone()).await?;
if path.blocks.len() + blocks.len() > MAX_MOVES {
return invalid("toolpath exceeds motion limit");
}
path.blocks.extend(blocks);
progress.set(1.);
}
Ok(path)
}
pub fn generate(input: JobInput) -> Result<Toolpath> {
let mut g = Generator::new(input)?;
while !g.step()? {}
g.finish()
}
fn tool(i: &JobInput, id: u64) -> Result<&Tool> {
i.tools.iter().find(|t| t.id == id).ok_or_else(|| Error("unknown tool".into()))
}
struct Writer {
blocks: Vec<Block>,
op: u64,
pos: Vec3,
progress: Progress,
}
impl Writer {
async fn push(&mut self, motion: Motion) -> Result<()> {
if self.blocks.len() >= MAX_MOVES {
return invalid("operation exceeds motion limit");
}
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &motion {
crate::model::point(*to)?;
self.pos = *to;
}
self.blocks.push(Block { operation: self.op, motion });
if self.blocks.len().is_multiple_of(256) {
self.progress.set(0.3 + 0.65 * self.blocks.len() as f64 / (self.blocks.len() as f64 + 1024.));
YieldOnce::default().await;
}
Ok(())
}
async fn rapid(&mut self, p: Vec3) -> Result<()> {
self.push(Motion::Rapid { to: p }).await
}
async fn feed(&mut self, p: Vec3, f: f64) -> Result<()> {
self.push(Motion::Linear { to: p, feed: f }).await
}
async fn enter(&mut self, s: &Setup, p: Vec3, o: &Operation) -> Result<()> {
self.rapid(Vec3::new(self.pos.x, self.pos.y, s.clearance)).await?;
self.rapid(Vec3::new(p.x, p.y, s.clearance)).await?;
self.rapid(Vec3::new(p.x, p.y, s.retract)).await?;
self.feed(p, o.plunge_feed).await
}
}
async fn operation(input: &JobInput, o: &Operation, progress: Progress) -> Result<Vec<Block>> {
let s = &input.setup;
let t = tool(input, o.tool)?;
let mut w = Writer { blocks: Vec::new(), op: o.id, pos: Vec3::new(0.0, 0.0, s.clearance), progress: progress.clone() };
w.push(Motion::Tool { tool: t.id, number: t.number }).await?;
w.push(Motion::Spindle { rpm: o.spindle }).await?;
w.push(Motion::Coolant { on: o.coolant }).await?;
let levels = depths(o.top, o.bottom + o.allowance, o.stepdown)?;
match &o.geometry {
Strategy::Face => {
let (a, b) = (s.stock.min, s.stock.max);
let radius = t.diameter / 2.0;
let n = ((b.y - a.y) / o.stepover).ceil() as usize;
if n > 10_000 {
return invalid("facing exceeds pass limit");
}
for z in levels {
for j in 0..=n {
let y = a.y + (b.y - a.y) * j as f64 / n.max(1) as f64;
// M3/right-handed cutter: climb keeps uncut material on the
// right. Rows advance toward +Y, so climb travels toward -X.
let (x0, x1) = if o.climb { (b.x + radius, a.x - radius) } else { (a.x - radius, b.x + radius) };
w.enter(s, Vec3::new(x0, y, z), o).await?;
w.feed(Vec3::new(x1, y, z), o.feed).await?;
}
}
}
Strategy::Contour { boundary, inside } => {
if !boundary.islands.is_empty() {
return invalid("contour islands: select each contour as its own operation");
}
let shape = shape(boundary, progress.clone()).await?;
let d = (t.diameter / 2.0 + o.allowance) * if *inside { 1.0 } else { -1.0 };
YieldOnce::default().await;
let loops = shape.offset(d);
if loops.ccw_plines.is_empty() {
return invalid("tool cannot fit contour");
}
for z in levels {
for lp in &loops.ccw_plines {
// Offsets are CCW. Climb is CCW inside a wall and CW outside a boss.
trace(&mut w, s, o, lp, z, o.climb != *inside).await?;
}
}
}
Strategy::Pocket { boundary } => {
let original = shape(boundary, progress.clone()).await?;
let mut loops = Vec::new();
let mut vertices = 0usize;
let mut distance = t.diameter / 2.0 + o.allowance;
for pass in 0..10_000 {
progress.set(0.1 + 0.15 * pass as f64 / (pass as f64 + 8.));
YieldOnce::default().await;
// A 1e-6 mm clearance reserve keeps exact offset
// tangencies away from the shape stitcher's degenerate slices.
// It is 1/1000 of the post budget; independent verification still
// certifies the unmodified requested region and allowance.
let offset = original.offset(distance + 1e-6);
if offset.ccw_plines.is_empty() {
break;
}
for lp in offset.ccw_plines.iter().chain(&offset.cw_plines) {
vertices = vertices.checked_add(lp.vertex_count()).ok_or_else(|| Error("pocket vertex budget exhausted".into()))?;
if vertices > MAX_MOVES {
return invalid("pocket vertex budget exhausted");
}
loops.push(lp.clone());
}
if loops.len() > 10_000 {
return invalid("pocket exceeds pass limit");
}
distance += o.stepover;
if pass == 9999 {
return invalid("pocket offset budget exhausted");
}
}
if loops.is_empty() {
return invalid("tool cannot fit pocket");
}
// Innermost passes first; independent clearance retracts avoid island crossings.
for z in levels {
for lp in loops.iter().rev() {
// CCW outer loops and CW islands already put protected
// material on the right; reverse both for conventional cuts.
trace(&mut w, s, o, lp, z, !o.climb).await?;
}
}
}
Strategy::Drill { holes, peck, full_diameter } => {
let bottom = o.bottom - if *full_diameter { t.tip_length() } else { 0.0 };
let zs = depths(o.top, bottom, peck.unwrap_or(o.top - bottom))?;
for p in holes {
w.enter(s, Vec3::new(p[0], p[1], o.top), o).await?;
let mut previous = o.top;
for z in &zs {
if previous != o.top {
w.feed(Vec3::new(p[0], p[1], previous), o.plunge_feed).await?;
}
w.feed(Vec3::new(p[0], p[1], *z), o.plunge_feed).await?;
w.rapid(Vec3::new(p[0], p[1], s.retract)).await?;
previous = *z;
}
}
}
}
w.rapid(Vec3::new(w.pos.x, w.pos.y, s.clearance)).await?;
w.push(Motion::Coolant { on: false }).await?;
w.push(Motion::Spindle { rpm: 0.0 }).await?;
w.push(Motion::Stop).await?;
Ok(w.blocks)
}
fn depths(top: f64, bottom: f64, step: f64) -> Result<Vec<f64>> {
if bottom >= top {
return invalid("allowance leaves no cutting depth");
}
let n = ((top - bottom) / step).ceil() as usize;
if n > 10_000 {
return invalid("depth pass limit");
}
Ok((1..=n).map(|i| (top - step * i as f64).max(bottom)).collect())
}
async fn trace(w: &mut Writer, s: &Setup, o: &Operation, lp: &Polyline<f64>, z: f64, reverse: bool) -> Result<()> {
let mut lp = lp.clone();
if reverse {
lp.invert_direction_mut();
}
let start = lp.iter_vertexes().next().ok_or_else(|| Error("empty offset".into()))?;
let entry_z = if o.ramp_angle.is_some() { o.top } else { z };
let first = Vec3::new(start.x, start.y, entry_z);
let tangent = lp.iter_segments().next().map(|(a, b)| Vec3::new(b.x - a.x, b.y - a.y, 0.0)).and_then(|v| v.normalized()).unwrap_or(Vec3::X);
w.enter(s, first - tangent * o.lead_distance, o).await?;
if o.lead_distance > 0.0 {
w.feed(first, o.feed).await?;
}
if let Some(angle) = o.ramp_angle {
let perimeter = lp.iter_segments().map(|(a, b)| seg_length(a, b)).sum::<f64>();
if perimeter <= 1e-8 {
return invalid("ramp contour has no length");
}
let laps = ((o.top - z) / (perimeter * angle.tan())).ceil() as usize;
if laps > 1000 {
return invalid("entry ramp exceeds lap budget");
}
let mut height = o.top;
for _ in 0..laps {
for (a, b) in lp.iter_segments() {
let length = seg_length(a, b);
let bottom = (height - length * angle.tan()).max(z);
if a.bulge.abs() < 1e-12 || length <= POST_ERROR / 4. {
w.feed(Vec3::new(b.x, b.y, bottom), o.plunge_feed).await?;
} else {
let (radius, c) = seg_arc_radius_and_center(a, b);
let sweep = 4.0 * a.bulge.atan();
let start = (a.y - c.y).atan2(a.x - c.x);
let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6);
let n = (sweep.abs() / step).ceil() as usize;
if n > MAX_MOVES {
return invalid("ramp arc exceeds motion budget");
}
for k in 1..=n {
let t = k as f64 / n as f64;
let theta = start + sweep * t;
w.feed(Vec3::new(c.x + radius * theta.cos(), c.y + radius * theta.sin(), height + (bottom - height) * t), o.plunge_feed)
.await?;
}
}
height = bottom;
}
}
}
for (a, b) in lp.iter_segments() {
let to = Vec3::new(b.x, b.y, z);
// Offset topology may contain a vanishing arc at a tangency. Preserve
// its short motion as a line: rounded coincident G2/G3 endpoints would
// otherwise command a full circle. Its entire arc length bounds error.
if a.bulge.abs() < 1e-12 || seg_length(a, b) <= POST_ERROR / 4. {
w.feed(to, o.feed).await?;
} else {
let (_, c) = seg_arc_radius_and_center(a, b);
w.push(Motion::Arc { to, center: Vec3::new(c.x, c.y, z), clockwise: a.bulge < 0.0, feed: o.feed }).await?;
}
}
if o.lead_distance > 0.0 {
w.feed(Vec3::new(start.x, start.y, z) - tangent * o.lead_distance, o.feed).await?;
}
Ok(())
}
// Work in setup millimetres with predicate/stitch tolerances comfortably below
// the post budget. Upstream defaults can merge distinct small offset slices.
struct OffsetShape {
shape: Shape<f64>,
scale: f64,
}
struct OffsetLoops {
ccw_plines: Vec<Polyline<f64>>,
cw_plines: Vec<Polyline<f64>>,
}
impl OffsetShape {
fn offset(&self, distance: f64) -> OffsetLoops {
let scale = self.scale;
let options = ShapeOffsetOptions { pos_equal_eps: 1e-8 * scale, offset_dist_eps: 1e-7 * scale, slice_join_eps: 1e-8 * scale };
let result = self.shape.parallel_offset(distance * scale, &options);
let restore = |p: cavalier_contours::shape_algorithms::IndexedPolyline<f64>| {
let mut polyline = p.polyline;
polyline.scale_mut(1. / scale);
polyline
};
OffsetLoops {
ccw_plines: result.ccw_plines.into_iter().map(restore).collect(),
cw_plines: result.cw_plines.into_iter().map(restore).collect(),
}
}
}
async fn shape(b: &Boundary, progress: Progress) -> Result<OffsetShape> {
b.validate()?;
// Reject all crossings/touches, not just those the offset library happens to tolerate.
let rings: Vec<_> = std::iter::once(&b.outer).chain(&b.islands).collect();
let mut edges = Vec::new();
let mut extent = 1_f64;
let mut shortest = f64::MAX;
for (ring, points) in rings.iter().enumerate() {
for vertex in 0..points.len() {
if edges.len().is_multiple_of(256) {
YieldOnce::default().await;
}
let a = points[vertex];
let b = points[(vertex + 1) % points.len()];
let c = points[(vertex + 2) % points.len()];
if distance(a, b) < 1e-7 {
return invalid("zero-length boundary edge");
}
shortest = shortest.min(distance(a, b));
// Adjacent edges share their endpoint, but must not double back.
if robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] }) == 0.
&& (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0.
{
return invalid("backtracking boundary edge");
}
extent = extent.max(a[0].abs()).max(a[1].abs());
edges.push((ring, vertex, a, b));
}
}
YieldOnce::default().await;
let index = crate::spatial::Index::new(
edges.iter().enumerate().map(|(id, (_, _, a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(),
);
// Include every pair admitted by the existing near-touch predicate. The
// numerical reserve also covers rounding when expanding coordinate bounds.
let padding = 1e-8 + 128. * f64::EPSILON * extent;
let mut comparisons = 0usize;
let mut candidates = Vec::new();
for (id, &(ring, vertex, a, b)) in edges.iter().enumerate() {
if id.is_multiple_of(128) {
progress.set(0.1 * id as f64 / edges.len() as f64);
YieldOnce::default().await;
}
candidates.clear();
index.query([a[0].min(b[0]) - padding, a[1].min(b[1]) - padding], [a[0].max(b[0]) + padding, a[1].max(b[1]) + padding], &mut candidates);
for other in candidates.iter().copied().filter(|other| *other > id) {
let (other_ring, other_vertex, c, d) = edges[other];
let count = rings[ring].len();
if ring == other_ring && ((vertex + 1) % count == other_vertex || (other_vertex + 1) % count == vertex) {
continue;
}
comparisons += 1;
if comparisons.is_multiple_of(4096) {
YieldOnce::default().await;
}
if comparisons > 2_000_000 {
return invalid("boundary validation budget exceeded");
}
if intersects(a, b, c, d) {
return invalid("boundary intersections or touching loops");
}
}
}
for (i, hole) in b.islands.iter().enumerate() {
YieldOnce::default().await;
if !inside(hole[0], &b.outer) || b.islands.iter().enumerate().any(|(j, h)| i != j && inside(hole[0], h)) {
return invalid("islands must be disjoint and inside the outer boundary");
}
}
// Cavalier also applies its position epsilon to line cross products. Small
// segments can otherwise be classified as parallel (including a 10k-sided
// circle). Normalize the shortest input edge to at least one library unit.
// Powers of two preserve finite input coordinates exactly; no vertices are
// removed. Scale distance tolerances too, retaining their physical meaning.
let scale = 2_f64.powi((-shortest.log2()).ceil().max(0.) as i32);
let mut plines = Vec::new();
for (i, ring) in rings.iter().enumerate() {
YieldOnce::default().await;
let mut p = Polyline::new_closed();
for xy in ring.iter() {
p.add(xy[0], xy[1], 0.0);
}
if p.area().abs() < 1e-8 {
return invalid("zero-area boundary");
}
if (p.area() > 0.0) != (i == 0) {
p.invert_direction_mut();
}
p.scale_mut(scale);
plines.push(p);
}
Ok(OffsetShape { shape: Shape::from_plines(plines), scale })
}
fn distance(a: [f64; 2], b: [f64; 2]) -> f64 {
(a[0] - b[0]).hypot(a[1] - b[1])
}
fn cross(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
}
fn intersects(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let eps = 1e-8;
let overlap = (0..2).all(|k| a[k].min(b[k]) <= c[k].max(d[k]) + eps && c[k].min(d[k]) <= a[k].max(b[k]) + eps);
overlap && cross(a, b, c) * cross(a, b, d) <= eps && cross(c, d, a) * cross(c, d, b) <= eps
}
fn inside(p: [f64; 2], ring: &[[f64; 2]]) -> bool {
let mut yes = false;
for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()) {
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
yes = !yes;
}
}
yes
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod incremental_tests {
use super::*;
fn polar(count: usize, lobes: usize) -> Vec<[f64; 2]> {
(0..count)
.map(|i| {
let angle = i as f64 * std::f64::consts::TAU / count as f64;
let radius = if lobes == 0 { 8. } else { 8. - 0.6 * (1. + (lobes as f64 * angle).cos()) };
[10. + radius * angle.cos(), 10. + radius * angle.sin()]
})
.collect()
}
fn validate_shape(boundary: &Boundary) -> Result<OffsetShape> {
let progress = Progress { value: Arc::new(AtomicUsize::new(0)), operation: 0, count: 1 };
let mut future = std::pin::pin!(shape(boundary, progress));
for _ in 0..10_000 {
if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
return result;
}
}
invalid("test shape did not complete within checkpoint bound")
}
#[test]
fn dense_simple_boundaries_and_one_hundred_islands_fit_validation_budget() {
for lobes in [0, 7] {
let mut boundary = Boundary { outer: polar(10_000, lobes), islands: vec![] };
for _ in 0..2 {
let validated = validate_shape(&boundary).unwrap();
assert_eq!(validated.shape.ccw_plines.len(), 1);
assert!(validated.shape.cw_plines.is_empty());
boundary.outer.reverse();
}
let mut input = corpus::fixture("contour").unwrap();
input.operations[0].geometry = Strategy::Contour { boundary, inside: true };
let path = generate(input).unwrap();
assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Linear { .. } | Motion::Arc { .. })));
}
let islands = (0..100)
.map(|n| {
(0..99)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 99.;
[5. + (n % 10) as f64 * 10. + a.cos(), 5. + (n / 10) as f64 * 10. + a.sin()]
})
.collect()
})
.collect();
let boundary = Boundary { outer: vec![[0., 0.], [100., 0.], [100., 100.], [0., 100.]], islands };
let validated = validate_shape(&boundary).unwrap();
assert_eq!((validated.shape.ccw_plines.len(), validated.shape.cw_plines.len()), (1, 100));
let offset = validated.offset(1.);
assert_eq!((offset.ccw_plines.len(), offset.cw_plines.len()), (1, 100));
}
#[test]
fn normalized_offsets_preserve_dense_polygon_distance_and_coordinates() {
// Independent regular-polygon construction: inward offset preserves the
// apothem minus the cutter radius. Check every resulting vertex, not area.
for center in [[10., 10.], [-10_000., 20_000.]] {
for factor in [1., 0.125] {
let points = polar(10_000, 0).into_iter().map(|p| [center[0] + factor * (p[0] - 10.), center[1] + factor * (p[1] - 10.)]).collect();
let boundary = Boundary { outer: points, islands: vec![] };
let offset = validate_shape(&boundary).unwrap().offset(3. * factor);
assert_eq!(offset.ccw_plines.len(), 1);
assert!(offset.cw_plines.is_empty());
let expected = factor * (8. - 3. / (std::f64::consts::PI / 10_000.).cos());
for p in offset.ccw_plines[0].iter_vertexes() {
assert!(((p.x - center[0]).hypot(p.y - center[1]) - expected).abs() < 1e-7);
}
}
}
// Concave case: sample output vertices against all ORIGINAL input
// segments using an independently written point/segment projection.
let boundary = Boundary { outer: polar(10_000, 7), islands: vec![] };
let offset = validate_shape(&boundary).unwrap().offset(3.);
assert_eq!(offset.ccw_plines.len(), 1);
for p in offset.ccw_plines[0].iter_vertexes().step_by(64) {
let nearest = boundary
.outer
.iter()
.zip(boundary.outer.iter().cycle().skip(1))
.map(|(a, b)| {
let dx = b[0] - a[0];
let dy = b[1] - a[1];
let t = (((p.x - a[0]) * dx + (p.y - a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.);
(p.x - a[0] - t * dx).hypot(p.y - a[1] - t * dy)
})
.fold(f64::INFINITY, f64::min);
assert!((nearest - 3.).abs() < 1e-7, "offset distance {nearest}");
}
}
#[test]
fn indexed_validation_rejects_crossings_touches_and_backtracking() {
let square = |min: [f64; 2], max: [f64; 2]| vec![min, [max[0], min[1]], max, [min[0], max[1]]];
for boundary in [
Boundary { outer: vec![[0., 0.], [3., 3.], [0., 3.], [3., 0.]], islands: vec![] },
Boundary { outer: vec![[0., 0.], [3., 0.], [1., 0.], [3., 3.], [0., 3.]], islands: vec![] },
Boundary { outer: vec![[0., 0.], [3., 0.], [3., 0.], [0., 3.]], islands: vec![] },
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([0., 1.], [2., 3.])] },
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([5e-9, 1.], [2., 3.])] },
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [4., 4.]), square([4., 2.], [6., 4.])] },
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [8., 8.]), square([3., 3.], [4., 4.])] },
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([12., 2.], [14., 4.])] },
] {
assert!(validate_shape(&boundary).is_err(), "{boundary:?}");
}
// A non-adjacent touch hidden among thousands of otherwise valid edges.
let mut boundary = Boundary { outer: polar(10_000, 0), islands: vec![] };
boundary.outer[5000] = boundary.outer[0];
assert!(validate_shape(&boundary).is_err());
}
#[test]
fn cancellation_during_dense_boundary_construction_and_queries_publishes_nothing() {
for checkpoint in [1, 20, 80, 120] {
let mut input = corpus::fixture("contour").unwrap();
input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: polar(10_000, 7), islands: vec![] }, inside: true };
let mut generator = Generator::new(input).unwrap();
for _ in 0..checkpoint {
assert!(!generator.step().unwrap());
}
generator.cancel();
assert!(generator.step().is_err());
assert!(generator.finish().is_err());
}
}
#[test]
fn large_single_operations_yield_and_remain_deterministic() {
for name in ["face", "contour", "pocket", "drill"] {
let mut input = corpus::fixture(name).unwrap();
input.operations[0].stepdown = 0.02;
if name == "drill" {
input.operations[0].geometry = Strategy::Drill {
holes: (0..300).map(|n| [1. + (n % 18) as f64, 1. + (n / 18) as f64]).collect(),
peck: Some(0.05),
full_diameter: false,
};
}
let mut generator = Generator::new(input.clone()).unwrap();
let mut polls = 0;
let mut previous = 0.;
let mut intermediate = false;
while !generator.step().unwrap() {
polls += 1;
let progress = generator.progress();
assert!(progress >= previous && progress < 1.);
intermediate |= progress > 0.;
previous = progress;
assert!(polls < 100_000);
}
assert!(polls > 10 && intermediate, "{name}: {polls}");
assert_eq!(generator.progress(), 1.);
let path = generator.finish().unwrap();
assert_eq!(serde_json::to_value(path).unwrap(), serde_json::to_value(generate(input).unwrap()).unwrap());
}
}
#[test]
fn cancellation_during_one_operation_discards_partial_output() {
let mut input = corpus::fixture("face").unwrap();
input.operations[0].stepdown = 0.001;
let mut generator = Generator::new(input).unwrap();
for _ in 0..4 {
assert!(!generator.step().unwrap());
}
assert!(generator.progress() > 0. && generator.progress() < 1.);
generator.cancel();
assert!(generator.step().is_err());
assert!(generator.finish().is_err());
}
#[test]
fn incomplete_and_failed_jobs_never_publish_partial_paths() {
let mut generator = Generator::new(corpus::fixture("face").unwrap()).unwrap();
assert!(!generator.step().unwrap());
assert!(generator.finish().is_err());
let mut input = corpus::fixture("face").unwrap();
let mut bad = input.operations[0].clone();
bad.id = 2;
bad.allowance = 2.;
input.operations.push(bad);
let mut generator = Generator::new(input).unwrap();
let failed = loop {
match generator.step() {
Ok(false) => {}
result => break result,
}
};
assert!(failed.is_err());
assert!(generator.step().is_err());
assert!(generator.finish().is_err());
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Incremental encoding of the existing snapshot fingerprint, without a full JSON buffer.
use crate::{Error, JobInput, Result, VERSION, invalid};
use serde::Serialize;
use sha2::{Digest, Sha256};
struct Encoder(Sha256);
impl Encoder {
fn bytes(&mut self, bytes: &[u8]) {
self.0.update(bytes);
}
fn value(&mut self, value: &impl Serialize) -> Result<()> {
let bytes = serde_json::to_vec(value).map_err(|e| Error(e.to_string()))?;
self.bytes(&bytes);
Ok(())
}
async fn array<T: Serialize, F: std::future::Future<Output = ()>>(
&mut self,
values: &[T],
batch: usize,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<()> {
self.bytes(b"[");
for (index, chunk) in values.chunks(batch).enumerate() {
checkpoint(cancelled, yield_control).await?;
if index != 0 {
self.bytes(b",");
}
// Serialize each bounded slice using Serde's own number/string rules.
// Only its enclosing brackets are omitted when joining array chunks.
let bytes = serde_json::to_vec(chunk).map_err(|e| Error(e.to_string()))?;
let contents = bytes.get(1..bytes.len().saturating_sub(1)).ok_or_else(|| Error("invalid fingerprint array encoding".into()))?;
self.bytes(contents);
}
self.bytes(b"]");
Ok(())
}
}
async fn checkpoint<F: std::future::Future<Output = ()>>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> {
if cancelled() {
return invalid("fingerprint cancelled");
}
yield_control().await;
if cancelled() { invalid("fingerprint cancelled") } else { Ok(()) }
}
/// Hash a validated immutable job using exactly the bytes of `fingerprint(input)`.
/// Yields before each operation/tool and every 256 triangles. Encoding one setup
/// or operation remains atomic; this is not a hard elapsed-time guarantee.
/// Cancellation returns no digest and never mutates the input.
pub async fn fingerprint_job_cooperative<F: std::future::Future<Output = ()>>(
input: &JobInput,
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<String> {
checkpoint(&cancelled, &mut yield_control).await?;
let mut encoder = Encoder(Sha256::new());
encoder.bytes(b"[");
encoder.value(&VERSION)?;
// Field order deliberately matches the derived JobInput/Geometry encoders.
// Equality regressions below make schema additions or reorderings visible.
encoder.bytes(b",{\"setup\":");
encoder.value(&input.setup)?;
encoder.bytes(b",\"tools\":");
encoder.array(&input.tools, 1, &cancelled, &mut yield_control).await?;
encoder.bytes(b",\"operations\":");
encoder.array(&input.operations, 1, &cancelled, &mut yield_control).await?;
encoder.bytes(b",\"geometry\":{\"target\":");
encoder.array(&input.geometry.target, 256, &cancelled, &mut yield_control).await?;
encoder.bytes(b",\"fixtures\":");
encoder.array(&input.geometry.fixtures, 256, &cancelled, &mut yield_control).await?;
encoder.bytes(b",\"fixture_groups\":");
encoder.array(&input.geometry.fixture_groups, 256, &cancelled, &mut yield_control).await?;
encoder.bytes(b"},\"fingerprint\":");
encoder.value(&input.fingerprint)?;
encoder.bytes(b"}]");
checkpoint(&cancelled, &mut yield_control).await?;
Ok(format!("{:x}", encoder.0.finalize()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use std::{
cell::Cell,
future::Future,
task::{Context, Poll, Waker},
};
fn ready<T>(future: impl Future<Output = T>) -> T {
let mut future = std::pin::pin!(future);
match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
Poll::Ready(value) => value,
Poll::Pending => panic!("test checkpoint unexpectedly suspended"),
}
}
#[test]
fn cooperative_encoding_preserves_existing_snapshot_fingerprints() {
for name in crate::corpus::CASES {
let mut input = crate::corpus::fixture(name).unwrap();
input.setup.name = "Escaped \"setup\" \\ \nμ".into();
input.fingerprint = "Existing digest remains part of the serialized input".into();
let expected = crate::fingerprint(&input).unwrap();
let count = Cell::new(0);
let actual = ready(fingerprint_job_cooperative(
&input,
|| false,
|| {
count.set(count.get() + 1);
std::future::ready(())
},
))
.unwrap();
assert_eq!(actual, expected, "{name}");
assert!(count.get() >= 4);
}
}
#[test]
fn large_snapshot_hashing_yields_and_cancellation_never_returns_a_digest() {
let mut input = crate::corpus::fixture("face").unwrap();
input.geometry.target.resize(200_000, input.geometry.target[0]);
let expected = crate::fingerprint(&input).unwrap();
let count = Cell::new(0);
let actual = ready(fingerprint_job_cooperative(
&input,
|| false,
|| {
count.set(count.get() + 1);
std::future::ready(())
},
))
.unwrap();
assert_eq!(actual, expected);
assert!(count.get() > 780);
for stop in [0, 1, 400, count.get()] {
let checkpoints = Cell::new(0);
let result = ready(fingerprint_job_cooperative(
&input,
|| checkpoints.get() >= stop,
|| {
checkpoints.set(checkpoints.get() + 1);
std::future::ready(())
},
));
assert!(result.is_err(), "cancel at checkpoint {stop}");
}
assert_eq!(crate::fingerprint(&input).unwrap(), expected);
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Milling definitions, deterministic toolpaths and independent NC verification.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod collision;
pub mod compare;
pub mod corpus;
mod coverage;
mod dimensions;
mod hashing;
pub use hashing::fingerprint_job_cooperative;
pub mod generate;
mod model;
pub mod nc;
pub mod playback;
mod region;
mod spatial;
mod stock_mesh;
mod surface;
pub mod verify;
pub use model::*;
#[derive(Debug, thiserror::Error)]
#[error("CAM: {0}")]
pub struct Error(pub String);
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn invalid<T>(message: impl Into<String>) -> Result<T> {
Err(Error(message.into()))
}
pub const VERSION: &str = "cam/18";
pub const TOLERANCE: f64 = 0.01;
pub const POST_ERROR: f64 = 0.001;
pub const VERIFY_ERROR: f64 = 0.0025;
/// Maximum upward height change for closing tiny saddle contacts in stock exports.
/// Verification intervals are unchanged; exported meshes require independent comparison.
pub const STOCK_EXPORT_ERROR: f64 = POST_ERROR / 100.;
pub const MAX_MOVES: usize = 200_000;
pub const MAX_ITEMS: usize = 1000;
/// Maximum chord error when resolving an associated CAD profile. The independent
/// dimensional envelope reserves this in addition to post/interpolation error.
pub fn profile_tolerance(tolerance: f64) -> f64 {
(tolerance / 16.).min(POST_ERROR / 2.)
}
pub fn fingerprint<T: serde::Serialize>(value: &T) -> Result<String> {
use sha2::{Digest, Sha256};
let data = serde_json::to_vec(&(VERSION, value)).map_err(|e| Error(e.to_string()))?;
Ok(format!("{:x}", Sha256::digest(data)))
}
+523
View File
@@ -0,0 +1,523 @@
use crate::{MAX_ITEMS, Result, TOLERANCE, invalid};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manufacturing {
#[serde(default)]
pub setups: Vec<Setup>,
#[serde(default)]
pub tools: Vec<Tool>,
#[serde(default)]
pub operations: Vec<Operation>,
#[serde(default)]
pub programs: Vec<Program>,
}
impl Manufacturing {
pub fn is_empty(&self) -> bool {
self.setups.is_empty() && self.tools.is_empty() && self.operations.is_empty() && self.programs.is_empty()
}
pub fn validate(&self) -> Result<()> {
for ids in [
self.setups.iter().map(|x| x.id).collect::<Vec<_>>(),
self.tools.iter().map(|x| x.id).collect(),
self.operations.iter().map(|x| x.id).collect(),
self.programs.iter().map(|x| x.id).collect(),
] {
if ids.len() > MAX_ITEMS || ids.contains(&0) || ids.iter().collect::<std::collections::BTreeSet<_>>().len() != ids.len() {
return invalid("too many items or duplicate/zero IDs");
}
}
for s in &self.setups {
s.validate()?;
}
for t in &self.tools {
t.validate()?;
}
for o in &self.operations {
let s = self.setups.iter().find(|s| s.id == o.setup).ok_or_else(|| crate::Error("missing setup".into()))?;
let t = self.tools.iter().find(|t| t.id == o.tool).ok_or_else(|| crate::Error("missing tool".into()))?;
o.validate(s, t)?;
}
for p in &self.programs {
name(&p.name)?;
if p.operations.is_empty()
|| p.operations.len() > MAX_ITEMS
|| p.operations.iter().collect::<std::collections::BTreeSet<_>>().len() != p.operations.len()
{
return invalid("program needs unique ordered operations");
}
for id in &p.operations {
if !self.operations.iter().any(|o| o.id == *id && o.setup == p.setup) {
return invalid("program operation is missing or belongs to another setup");
}
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BodyRef {
pub body: String,
#[serde(default)]
pub occurrence: Vec<u64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub origin: Vec3,
pub x: Vec3,
pub y: Vec3,
pub z: Vec3,
}
impl Default for Frame {
fn default() -> Self {
Self { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y, z: Vec3::Z }
}
}
impl Frame {
pub fn validate(&self) -> Result<()> {
for v in [self.origin, self.x, self.y, self.z] {
point(v)?;
}
if (self.x.dot(self.x) - 1.0).abs() > 1e-8
|| (self.y.dot(self.y) - 1.0).abs() > 1e-8
|| (self.z.dot(self.z) - 1.0).abs() > 1e-8
|| self.x.dot(self.y).abs() > 1e-8
|| self.x.cross(self.y).dist(self.z) > 1e-8
{
return invalid("setup frame must be right-handed and orthonormal");
}
Ok(())
}
pub fn local(&self, p: Vec3) -> Vec3 {
let p = p - self.origin;
Vec3::new(p.dot(self.x), p.dot(self.y), p.dot(self.z))
}
pub fn world(&self, p: Vec3) -> Vec3 {
self.origin + self.x * p.x + self.y * p.y + self.z * p.z
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stock {
pub min: Vec3,
pub max: Vec3,
}
impl Stock {
pub fn validate(&self) -> Result<()> {
point(self.min)?;
point(self.max)?;
if self.min.x >= self.max.x || self.min.y >= self.max.y || self.min.z >= self.max.z {
return invalid("stock bounds must have positive size");
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Setup {
pub id: u64,
pub name: String,
pub stock: Stock,
#[serde(default)]
pub frame: Frame,
pub models: Vec<BodyRef>,
#[serde(default)]
pub fixtures: Vec<BodyRef>,
pub clearance: f64,
pub retract: f64,
#[serde(default = "work_offset")]
pub work_offset: u8,
}
fn work_offset() -> u8 {
54
}
impl Setup {
pub fn validate(&self) -> Result<()> {
name(&self.name)?;
self.stock.validate()?;
self.frame.validate()?;
if self.models.is_empty() || self.models.len() + self.fixtures.len() > MAX_ITEMS {
return invalid("setup needs model references (at most 1000)");
}
for r in self.models.iter().chain(&self.fixtures) {
name(&r.body)?;
if r.occurrence.len() > 64 {
return invalid("occurrence path too deep");
}
}
if !finite(self.clearance)
|| !finite(self.retract)
|| self.clearance < self.retract
|| self.retract <= self.stock.max.z
|| !(54..=59).contains(&self.work_offset)
{
return invalid("clearance >= retract > stock top; work offset must be G54–G59");
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolKind {
FlatEndMill,
TwistDrill,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tool {
pub id: u64,
pub name: String,
pub number: u32,
pub kind: ToolKind,
pub diameter: f64,
pub cutting_length: f64,
pub shank_diameter: f64,
pub overall_length: f64,
pub holder_diameter: f64,
pub holder_length: f64,
#[serde(default = "drill_angle")]
pub tip_angle: f64,
}
fn drill_angle() -> f64 {
118.0_f64.to_radians()
}
impl Tool {
pub fn validate(&self) -> Result<()> {
name(&self.name)?;
if self.number == 0
|| self.number > 9999
|| [self.diameter, self.cutting_length, self.shank_diameter, self.overall_length, self.holder_diameter, self.holder_length]
.iter()
.any(|v| !positive(*v))
|| self.overall_length < self.cutting_length
|| !self.tip_angle.is_finite()
|| !(0.1..3.1).contains(&self.tip_angle)
{
return invalid("invalid tool dimensions, number or drill angle (radians)");
}
Ok(())
}
pub fn tip_length(&self) -> f64 {
if self.kind == ToolKind::TwistDrill { self.diameter * 0.5 / (self.tip_angle * 0.5).tan() } else { 0.0 }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Boundary {
pub outer: Vec<[f64; 2]>,
#[serde(default)]
pub islands: Vec<Vec<[f64; 2]>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileRef {
pub sketch: u64,
pub curves: Vec<String>,
#[serde(default)]
pub occurrence: Vec<u64>,
}
/// Document-local identities only. The engine resolves these into setup XY centers.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum HoleSource {
Face { target: BodyRef, name: String },
Profile { profile: ProfileRef },
}
impl HoleSource {
pub fn validate(&self) -> Result<()> {
match self {
Self::Face { target, name: face } => {
name(&target.body)?;
name(face)?;
if target.occurrence.len() > 64 {
return invalid("hole occurrence path too deep");
}
}
Self::Profile { profile } => {
if profile.sketch == 0 || profile.curves.is_empty() || profile.curves.len() > 10_000 || profile.occurrence.len() > 64 {
return invalid("invalid circular profile reference");
}
for curve in &profile.curves {
name(curve)?;
}
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "strategy", rename_all = "snake_case", deny_unknown_fields)]
pub enum Strategy {
Face,
Contour {
boundary: Boundary,
inside: bool,
},
Pocket {
boundary: Boundary,
},
Drill {
holes: Vec<[f64; 2]>,
#[serde(default)]
peck: Option<f64>,
#[serde(default)]
full_diameter: bool,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Operation {
pub id: u64,
pub name: String,
pub setup: u64,
pub tool: u64,
pub geometry: Strategy,
#[serde(default)]
pub source: Option<ProfileRef>,
/// When nonempty, replaces every preview XY center during snapshot preparation.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hole_sources: Vec<HoleSource>,
pub top: f64,
pub bottom: f64,
pub stepdown: f64,
pub stepover: f64,
pub feed: f64,
pub plunge_feed: f64,
pub spindle: f64,
#[serde(default)]
pub allowance: f64,
#[serde(default = "yes")]
pub climb: bool,
#[serde(default = "tolerance")]
pub tolerance: f64,
#[serde(default)]
pub coolant: bool,
/// Optional entry ramp angle in radians; None uses a center-cutting plunge.
#[serde(default)]
pub ramp_angle: Option<f64>,
#[serde(default)]
pub lead_distance: f64,
}
fn yes() -> bool {
true
}
fn tolerance() -> f64 {
TOLERANCE
}
impl Operation {
pub fn validate(&self, s: &Setup, t: &Tool) -> Result<()> {
name(&self.name)?;
if !finite(self.lead_distance)
|| self.lead_distance < 0.0
|| self.ramp_angle.is_some_and(|a| !a.is_finite() || !(0.001..=0.174533).contains(&a))
{
return invalid("lead must be nonnegative; ramp angle must be 0.06–10 degrees");
}
if (self.ramp_angle.is_some() || self.lead_distance > 0.0) && matches!(self.geometry, Strategy::Face | Strategy::Drill { .. }) {
return invalid("lead/ramp entry is supported for contour and pocket operations");
}
if let Some(source) = &self.source {
if source.sketch == 0
|| source.curves.is_empty()
|| source.curves.len() > 10_000
|| source.occurrence.len() > 64
|| !matches!(self.geometry, Strategy::Contour { .. } | Strategy::Pocket { .. })
{
return invalid("profile association requires a bounded contour/pocket reference");
}
for c in &source.curves {
name(c)?;
}
}
if !self.hole_sources.is_empty() {
let Strategy::Drill { holes, .. } = &self.geometry else {
return invalid("hole references require drilling");
};
if self.hole_sources.len() > MAX_ITEMS || self.hole_sources.len() != holes.len() {
return invalid("each linked hole needs exactly one source reference");
}
for (index, source) in self.hole_sources.iter().enumerate() {
source.validate()?;
if self.hole_sources.iter().take(index).any(|old| old == source) {
return invalid("duplicate hole reference");
}
}
}
if [self.stepdown, self.stepover, self.feed, self.plunge_feed, self.spindle, self.tolerance].iter().any(|v| !positive(*v))
|| !finite(self.top)
|| !finite(self.bottom)
|| !finite(self.allowance)
|| self.allowance < 0.0
|| self.bottom >= self.top
|| self.top > s.stock.max.z
|| self.top < s.stock.min.z
|| self.stepover > t.diameter
|| self.top - self.bottom + t.tip_length() > t.cutting_length
|| (self.top - self.bottom) / self.stepdown > 10_000.0
|| self.tolerance < 1e-5
|| self.tolerance > 1.0
{
return invalid("invalid heights, feeds, step sizes, tolerance or cutting reach");
}
match &self.geometry {
Strategy::Drill { holes, peck, .. } => {
if t.kind != ToolKind::TwistDrill
|| self.allowance != 0.0
|| holes.is_empty()
|| holes.len() > MAX_ITEMS
|| peck.is_some_and(|v| !positive(v) || (self.top - self.bottom) / v > 10_000.0)
{
return invalid(
"drilling needs a drill, bounded holes, positive peck and zero stock allowance; set the required depth explicitly",
);
}
for p in holes {
point(Vec3::new(p[0], p[1], 0.0))?;
}
}
Strategy::Contour { boundary, .. } | Strategy::Pocket { boundary } => {
if t.kind != ToolKind::FlatEndMill {
return invalid("milling needs a flat end mill");
}
boundary.validate()?;
}
Strategy::Face => {
if t.kind != ToolKind::FlatEndMill {
return invalid("facing needs a flat end mill");
}
}
}
Ok(())
}
}
impl Boundary {
pub fn validate(&self) -> Result<()> {
if self.islands.len() > 100 {
return invalid("too many islands");
}
let mut total = 0;
for ring in std::iter::once(&self.outer).chain(&self.islands) {
total += ring.len();
if ring.len() < 3 || total > 10_000 {
return invalid("boundary needs 3–10000 vertices");
}
for p in ring {
point(Vec3::new(p[0], p[1], 0.0))?;
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Program {
pub id: u64,
pub name: String,
pub setup: u64,
pub operations: Vec<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Geometry {
pub target: Vec<[Vec3; 3]>,
pub fixtures: Vec<[Vec3; 3]>,
/// Triangle counts for consecutive fixture bodies. Shells of one body share
/// a winding sum so enclosed cavities remain empty. Legacy snapshots contain
/// one triangle soup; an empty list treats that soup as one body.
#[serde(default)]
pub fixture_groups: Vec<usize>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JobInput {
pub setup: Setup,
pub tools: Vec<Tool>,
pub operations: Vec<Operation>,
pub geometry: Geometry,
pub fingerprint: String,
}
impl JobInput {
pub fn validate(&self) -> Result<()> {
let manufacturing = Manufacturing {
setups: vec![self.setup.clone()],
tools: self.tools.clone(),
operations: self.operations.clone(),
programs: vec![Program {
id: 1,
name: "Snapshot".into(),
setup: self.setup.id,
operations: self.operations.iter().map(|o| o.id).collect(),
}],
};
manufacturing.validate()?;
if self.tools.iter().map(|t| t.number).collect::<std::collections::BTreeSet<_>>().len() != self.tools.len() {
return invalid("ambiguous tool numbers");
}
if self.geometry.target.is_empty() || self.geometry.target.len() + self.geometry.fixtures.len() > 200_000 {
return invalid("empty or excessive verification geometry");
}
if !self.geometry.fixture_groups.is_empty()
&& (self.geometry.fixture_groups.len() > MAX_ITEMS
|| self.geometry.fixture_groups.contains(&0)
|| self.geometry.fixture_groups.iter().try_fold(0usize, |sum, n| sum.checked_add(*n)) != Some(self.geometry.fixtures.len()))
{
return invalid("invalid fixture body triangle groups");
}
for triangle in self.geometry.target.iter().chain(&self.geometry.fixtures) {
for p in triangle {
point(*p)?;
}
}
for p in self.geometry.target.iter().flatten() {
let a = self.setup.stock.min;
let b = self.setup.stock.max;
if p.x < a.x - TOLERANCE
|| p.y < a.y - TOLERANCE
|| p.z < a.z - TOLERANCE
|| p.x > b.x + TOLERANCE
|| p.y > b.y + TOLERANCE
|| p.z > b.z + TOLERANCE
{
return invalid("rectangular stock must enclose selected model geometry");
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Motion {
Rapid { to: Vec3 },
Linear { to: Vec3, feed: f64 },
Arc { to: Vec3, center: Vec3, clockwise: bool, feed: f64 },
Tool { tool: u64, number: u32 },
Spindle { rpm: f64 },
Coolant { on: bool },
Stop,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Block {
pub operation: u64,
pub motion: Motion,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Toolpath {
pub fingerprint: String,
pub blocks: Vec<Block>,
}
pub(crate) fn finite(v: f64) -> bool {
v.is_finite() && v.abs() <= 1e6
}
pub(crate) fn positive(v: f64) -> bool {
finite(v) && v > 0.0
}
pub(crate) fn point(v: Vec3) -> Result<()> {
if [v.x, v.y, v.z].into_iter().all(finite) { Ok(()) } else { invalid("non-finite or excessive coordinate") }
}
fn name(s: &str) -> Result<()> {
if s.trim().is_empty() || s.len() > 256 || s.chars().any(char::is_control) { invalid("invalid name") } else { Ok(()) }
}
+712
View File
@@ -0,0 +1,712 @@
//! Strict reference-dialect post and independent modal NC reader.
//! The reader has no dependency on the toolpath generator.
use crate::*;
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use std::{
future::Future,
task::{Context, Poll, Waker},
};
pub const DIALECT: &str = "solidcraft-reference-3axis/2; not a qualified machine post";
// A maximum-size path also emits the three explicit startup events below.
const MAX_RECORDS: usize = MAX_MOVES + 3;
fn complete<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
let mut future = std::pin::pin!(future);
match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
Poll::Ready(result) => result,
Poll::Pending => invalid("synchronous NC checkpoint unexpectedly suspended"),
}
}
async fn checkpoint<F: Future<Output = ()>>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> {
if cancelled() {
return invalid("NC processing cancelled");
}
yield_control().await;
if cancelled() { invalid("NC processing cancelled") } else { Ok(()) }
}
fn startup(setup: &Setup) -> [Motion; 3] {
[Motion::Spindle { rpm: 0.0 }, Motion::Coolant { on: false }, Motion::Rapid { to: Vec3::new(0., 0., setup.clearance) }]
}
pub fn post(path: &Toolpath, setup: &Setup) -> Result<String> {
complete(post_cooperative(path, setup, || false, || std::future::ready(())))
}
/// Preserve post bytes while yielding every 256 blocks. No partial output on cancellation.
pub async fn post_cooperative<F: Future<Output = ()>>(
path: &Toolpath,
setup: &Setup,
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<String> {
checkpoint(&cancelled, &mut yield_control).await?;
if path.blocks.is_empty() || path.blocks.len() > MAX_MOVES {
return invalid("cannot post an empty or excessive path");
}
let mut out = format!("({DIALECT})\n(Fingerprint {})\nG21 G90 G17 G91.1 G94 G{} G40 G49 G80\nM5\nM9\n", path.fingerprint, setup.work_offset);
let mut current = Vec3::new(0.0, 0.0, setup.clearance);
out.push_str(&format!("G0 X0.000000 Y0.000000 Z{:.6}\n", setup.clearance));
for (index, block) in path.blocks.iter().enumerate() {
if index.is_multiple_of(256) {
checkpoint(&cancelled, &mut yield_control).await?;
}
out.push_str(&format!("(Operation {})\n", block.operation));
let line = match &block.motion {
Motion::Rapid { to } => format!("G0 X{:.6} Y{:.6} Z{:.6}", to.x, to.y, to.z),
Motion::Linear { to, feed } => format!("G1 X{:.6} Y{:.6} Z{:.6} F{:.6}", to.x, to.y, to.z, feed),
Motion::Arc { to, center, clockwise, feed } => format!(
"G{} X{:.6} Y{:.6} Z{:.6} I{:.6} J{:.6} F{:.6}",
if *clockwise { 2 } else { 3 },
to.x,
to.y,
to.z,
center.x - current.x,
center.y - current.y,
feed
),
Motion::Tool { number, .. } => format!("T{number} M6"),
Motion::Spindle { rpm } => {
if *rpm == 0.0 {
"M5".into()
} else {
format!("S{rpm:.6} M3")
}
}
Motion::Coolant { on } => {
if *on {
"M8".into()
} else {
"M9".into()
}
}
Motion::Stop => "M0".into(),
};
out.push_str(&line);
out.push('\n');
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &block.motion {
current = *to;
}
}
out.push_str("M30\n");
checkpoint(&cancelled, &mut yield_control).await?;
Ok(out)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Record {
pub line: usize,
pub motion: Motion,
}
/// Provenance established only after the independent reader matches a posted block.
/// `block` is one-based and also the completed-block count used by playback.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceLocation {
pub line: usize,
pub block: usize,
pub operation: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Parsed {
pub records: Vec<Record>,
pub work_offset: u8,
// Never accept provenance from comments or deserialized external programs.
#[serde(skip)]
sources: Vec<SourceLocation>,
}
impl Parsed {
pub fn source_for_line(&self, line: usize) -> Option<SourceLocation> {
self.sources.binary_search_by_key(&line, |s| s.line).ok().and_then(|i| self.sources.get(i).copied())
}
/// First interpreted record on a physical NC line, using a zero-based index.
pub fn record_for_line(&self, line: usize) -> Option<usize> {
let i = self.records.partition_point(|r| r.line < line);
self.records.get(i).filter(|r| r.line == line).map(|_| i)
}
}
/// Strictly consumes every word. Units and distance mode must be declared before motion.
pub fn parse(source: &str, tools: &[Tool]) -> Result<Parsed> {
complete(parse_cooperative(source, tools, || false, || std::future::ready(())))
}
/// Independent modal reader with cancellation and a checkpoint every 256 physical lines.
pub async fn parse_cooperative<F: Future<Output = ()>>(
source: &str,
tools: &[Tool],
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<Parsed> {
checkpoint(&cancelled, &mut yield_control).await?;
if source.len() > 32 * 1024 * 1024 {
return invalid("NC file too large");
}
let mut out = Vec::new();
let mut units = None;
let mut absolute = None;
let mut plane = false;
let mut per_minute = false;
let mut offset = None;
let mut pos = Vec3::ZERO;
let mut have_pos = false;
let mut mode = None;
let mut feed = 0.0;
let mut rpm = 0.0;
let mut spindle_on = false;
let mut coolant_on = false;
let mut selected = None;
let mut ended = false;
for (index, line) in source.lines().enumerate() {
if index.is_multiple_of(256) {
checkpoint(&cancelled, &mut yield_control).await?;
}
let line_number = index + 1;
let fail = |s: &str| Error(format!("NC line {line_number}: {s}"));
let mut words = words(line).map_err(|e| fail(&e.0))?;
// Modal changes apply to the whole block, independent of word order.
words.sort_by_key(|(letter, _)| if *letter == 'G' { 0 } else { 1 });
if words.is_empty() {
continue;
}
if ended {
return Err(fail("content after program end"));
}
let mut coordinates = [None; 3];
let mut ij = [None; 2];
let mut m = None;
let mut groups = std::collections::BTreeSet::new();
let mut seen = std::collections::BTreeSet::new();
for (letter, value) in words {
if letter != 'G' && !seen.insert(letter) {
return Err(fail("duplicate address"));
}
match letter {
'N' => {
if value.fract() != 0.0 || value < 0.0 {
return Err(fail("invalid line number"));
}
}
'G' => {
let group = match value {
0.0..=3.0 if value.fract() == 0.0 => {
mode = Some(value as u8);
0
}
17.0 => {
plane = true;
1
}
20.0 => {
units = Some(25.4);
2
}
21.0 => {
units = Some(1.0);
2
}
90.0 => {
absolute = Some(true);
3
}
91.0 => {
absolute = Some(false);
3
}
94.0 => {
per_minute = true;
4
}
54.0..=59.0 if value.fract() == 0.0 => {
if offset.is_some_and(|o| o != value as u8) {
return Err(fail("multiple work offsets unsupported"));
}
offset = Some(value as u8);
5
}
40.0 => 6,
49.0 => 7,
80.0 => {
mode = None;
0
}
91.1 => 9,
_ => return Err(fail("unsupported G code; no commands are silently discarded")),
};
if !groups.insert(group) {
return Err(fail("conflicting modal codes"));
}
}
'M' => {
if value.fract() != 0.0 {
return Err(fail("invalid M code"));
}
m = Some(value as i32);
}
'X' => coordinates[0] = Some(value),
'Y' => coordinates[1] = Some(value),
'Z' => coordinates[2] = Some(value),
'I' => ij[0] = Some(value),
'J' => ij[1] = Some(value),
'F' => {
let scale = units.ok_or_else(|| fail("declare units before feed"))?;
feed = value * scale;
if !crate::model::positive(feed) {
return Err(fail("feed must be positive"));
}
}
'S' => {
if value < 0.0 {
return Err(fail("negative spindle speed"));
}
rpm = value;
}
'T' => {
if value.fract() != 0.0 || value < 1.0 || value > 9999.0 {
return Err(fail("invalid tool number"));
}
selected = Some(value as u32);
}
_ => return Err(fail("unsupported address")),
}
}
let moving = coordinates.iter().any(Option::is_some) || ij.iter().any(Option::is_some);
if moving && m.is_some() {
return Err(fail("combined motion/M blocks unsupported by reference dialect"));
}
// Speed changes precede motion and coolant/stop commands in a block.
// Other spindle-changing M codes below already emit its final state.
if seen.contains(&'S') && spindle_on && !matches!(m, Some(3 | 5 | 6 | 2 | 30)) {
out.push(Record { line: line_number, motion: Motion::Spindle { rpm } });
}
if moving {
let scale = units.ok_or_else(|| fail("missing units"))?;
let abs = absolute.ok_or_else(|| fail("missing distance mode"))?;
if !plane || !per_minute || offset.is_none() {
return Err(fail("declare G17, G94 and work offset before motion"));
}
if !have_pos && (!abs || coordinates.iter().any(Option::is_none)) {
return Err(fail("first position requires absolute XYZ"));
}
let base = [pos.x, pos.y, pos.z];
let mut xyz = base;
for k in 0..3 {
if let Some(v) = coordinates[k] {
xyz[k] = v * scale + if abs { 0.0 } else { base[k] };
}
}
let to = Vec3::new(xyz[0], xyz[1], xyz[2]);
crate::model::point(to)?;
let motion = match mode.ok_or_else(|| fail("missing motion mode"))? {
0 => {
if ij.iter().any(Option::is_some) {
return Err(fail("arc center on rapid"));
}
Motion::Rapid { to }
}
1 => {
if ij.iter().any(Option::is_some) || feed <= 0.0 {
return Err(fail("invalid linear feed/center"));
}
Motion::Linear { to, feed }
}
arc => {
if !have_pos || feed <= 0.0 || ij.iter().all(Option::is_none) || (to.z - pos.z).abs() > 1e-8 {
return Err(fail("only planar XY I/J arcs with positive feed are supported"));
}
let center = Vec3::new(pos.x + ij[0].unwrap_or(0.0) * scale, pos.y + ij[1].unwrap_or(0.0) * scale, pos.z);
let r = pos.dist(center);
if r <= 1e-8 || (to.dist(center) - r).abs() > POST_ERROR {
return Err(fail("inconsistent arc radius"));
}
Motion::Arc { to, center, clockwise: arc == 2, feed }
}
};
out.push(Record { line: line_number, motion });
pos = to;
have_pos = true;
}
if let Some(m) = m {
let motion = match m {
0 => Some(Motion::Stop),
3 => {
if rpm <= 0.0 {
return Err(fail("spindle start requires positive speed"));
}
spindle_on = true;
Some(Motion::Spindle { rpm })
}
5 => {
spindle_on = false;
Some(Motion::Spindle { rpm: 0.0 })
}
6 => {
let n = selected.ok_or_else(|| fail("tool change needs T"))?;
let matches: Vec<_> = tools.iter().filter(|t| t.number == n).collect();
if matches.len() != 1 {
return Err(fail("unknown or ambiguous tool number"));
}
// NIST RS274/NGC §3.6.3: M6 stops the spindle without
// restarting it; programmed speed and coolant are retained.
if spindle_on {
out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } });
}
spindle_on = false;
Some(Motion::Tool { tool: matches[0].id, number: n })
}
8 => {
coolant_on = true;
Some(Motion::Coolant { on: true })
}
9 => {
coolant_on = false;
Some(Motion::Coolant { on: false })
}
2 | 30 => {
// Program end stops spindle and coolant in the interpreted state.
if spindle_on {
out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } });
}
if coolant_on {
out.push(Record { line: line_number, motion: Motion::Coolant { on: false } });
}
ended = true;
None
}
_ => return Err(fail("unsupported M code")),
};
if let Some(motion) = motion {
out.push(Record { line: line_number, motion });
}
}
if out.len() > MAX_RECORDS {
return Err(fail("NC motion limit"));
}
}
if !ended || !have_pos {
return invalid("NC program requires motion and an explicit program end");
}
checkpoint(&cancelled, &mut yield_control).await?;
Ok(Parsed { records: out, work_offset: offset.ok_or_else(|| Error("missing work offset".into()))?, sources: Vec::new() })
}
fn words(line: &str) -> Result<Vec<(char, f64)>> {
if line.len() > 4096 {
return invalid("NC line too long");
}
let mut clean = String::new();
let mut comment = false;
for c in line.chars() {
match c {
'(' if !comment => comment = true,
')' if comment => comment = false,
'(' | ')' => return invalid("malformed comment"),
';' if !comment => break,
_ if !comment => clean.push(c),
_ => {}
}
}
if comment {
return invalid("unterminated comment");
}
let clean = clean.trim();
if clean == "%" {
return Ok(Vec::new());
}
let chars: Vec<_> = clean.chars().collect();
let mut i = 0;
let mut result = Vec::new();
while i < chars.len() {
if chars[i].is_whitespace() {
i += 1;
continue;
}
let letter = chars[i].to_ascii_uppercase();
if !letter.is_ascii_uppercase() {
return invalid("expected address letter");
}
i += 1;
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
let start = i;
while chars.get(i).is_some_and(|c| c.is_ascii_digit() || matches!(c, '.' | '+' | '-')) {
i += 1;
}
let s: String = chars[start..i].iter().collect();
let v = s.parse::<f64>().map_err(|_| Error("invalid numeric word".into()))?;
if !crate::model::finite(v) {
return invalid("non-finite/excessive word");
}
result.push((letter, v));
}
Ok(result)
}
pub fn round_trip(path: &Toolpath, setup: &Setup, tools: &[Tool]) -> Result<Parsed> {
complete(round_trip_cooperative(path, setup, tools, || false, || std::future::ready(())))
}
/// Post, independently interpret and compare in bounded batches, retaining trusted line mappings.
pub async fn round_trip_cooperative<F: Future<Output = ()>>(
path: &Toolpath,
setup: &Setup,
tools: &[Tool],
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<Parsed> {
let source = post_cooperative(path, setup, &cancelled, &mut yield_control).await?;
let mut parsed = parse_cooperative(&source, tools, &cancelled, &mut yield_control).await?;
let startup = startup(setup);
if parsed.work_offset != setup.work_offset || parsed.records.len() != path.blocks.len() + startup.len() {
return invalid("post changed block count or work offset");
}
if !startup.iter().zip(&parsed.records).all(|(a, b)| equivalent(a, &b.motion)) {
return invalid("post changed startup state");
}
let mut original_position = Vec3::new(0., 0., setup.clearance);
let mut posted_position = original_position;
if let Some(Record { motion: Motion::Rapid { to }, .. }) = parsed.records.get(startup.len() - 1) {
posted_position = *to;
}
for (i, (a, b)) in path.blocks.iter().zip(parsed.records.iter().skip(startup.len())).enumerate() {
if i.is_multiple_of(256) {
checkpoint(&cancelled, &mut yield_control).await?;
}
if !equivalent(&a.motion, &b.motion) {
return invalid(format!("post mismatch at NC line {}", b.line));
}
if let (Motion::Arc { to, center, clockwise, .. }, Motion::Arc { to: posted_to, center: posted_center, .. }) = (&a.motion, &b.motion) {
let before = sweep(original_position, *to, *center, *clockwise);
let after = sweep(posted_position, *posted_to, *posted_center, *clockwise);
if (before - after).abs() * original_position.dist(*center) > POST_ERROR {
return invalid(format!("post changed arc sweep at NC line {}", b.line));
}
}
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = a.motion {
original_position = to;
}
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = b.motion {
posted_position = to;
}
parsed.sources.push(SourceLocation { line: b.line, block: i + 1, operation: a.operation });
}
checkpoint(&cancelled, &mut yield_control).await?;
Ok(parsed)
}
fn sweep(from: Vec3, to: Vec3, center: Vec3, clockwise: bool) -> f64 {
if from.x == to.x && from.y == to.y {
return if clockwise { -std::f64::consts::TAU } else { std::f64::consts::TAU };
}
let a = (from.y - center.y).atan2(from.x - center.x);
let b = (to.y - center.y).atan2(to.x - center.x);
if clockwise { -(a - b).rem_euclid(std::f64::consts::TAU) } else { (b - a).rem_euclid(std::f64::consts::TAU) }
}
fn equivalent(a: &Motion, b: &Motion) -> bool {
match (a, b) {
(Motion::Rapid { to: a }, Motion::Rapid { to: b }) => a.dist(*b) <= POST_ERROR,
(Motion::Linear { to: a, feed: f }, Motion::Linear { to: b, feed: g }) => a.dist(*b) <= POST_ERROR && (f - g).abs() <= 1e-6,
(Motion::Arc { to: a, center: c, clockwise: d, feed: f }, Motion::Arc { to: b, center: e, clockwise: h, feed: g }) => {
a.dist(*b) <= POST_ERROR && c.dist(*e) <= POST_ERROR && d == h && (f - g).abs() <= 1e-6
}
(Motion::Spindle { rpm: a }, Motion::Spindle { rpm: b }) => (a - b).abs() <= 1e-6,
_ => a == b,
}
}
#[derive(Clone, Debug)]
pub struct Segment {
pub from: Vec3,
pub to: Vec3,
pub rapid: bool,
pub feed: f64,
pub tool: u64,
pub line: usize,
}
/// Expand interpreted circular motion with a bounded chord error, independent of posting.
pub fn segments(parsed: &Parsed, setup: &Setup) -> Result<Vec<Segment>> {
let mut out = Vec::new();
let mut pos = None;
let mut tool = 0;
let mut spindle = 0.0;
for r in &parsed.records {
match &r.motion {
Motion::Tool { tool: id, .. } => {
if pos.is_none_or(|p: Vec3| p.z < setup.clearance - POST_ERROR) {
return invalid(format!("tool change below clearance at line {}", r.line));
}
if spindle != 0.0 {
return invalid("tool change with spindle running");
}
tool = *id;
}
Motion::Spindle { rpm } => spindle = *rpm,
Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } => {
let rapid = matches!(r.motion, Motion::Rapid { .. });
let feed = match r.motion {
Motion::Linear { feed, .. } | Motion::Arc { feed, .. } => feed,
_ => 0.0,
};
if !rapid && (tool == 0 || spindle <= 0.0) {
return invalid(format!("cutting without tool/spindle at line {}", r.line));
}
if let Some(from) = pos {
if tool == 0 {
return invalid(format!("motion before selecting a known tool at line {}", r.line));
}
if let Motion::Arc { center, clockwise, .. } = r.motion {
let a: f64 = (from.y - center.y).atan2(from.x - center.x);
let sweep = sweep(from, *to, center, clockwise);
let radius = from.dist(center);
let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6);
let count = (sweep.abs() / step).ceil() as usize;
if count > MAX_MOVES || out.len() + count > MAX_MOVES {
return invalid("arc subdivision limit");
}
let mut prev = from;
for k in 1..=count {
let angle = a + sweep * k as f64 / count as f64;
let p = if k == count { *to } else { Vec3::new(center.x + radius * angle.cos(), center.y + radius * angle.sin(), to.z) };
out.push(Segment { from: prev, to: p, rapid, feed, tool, line: r.line });
prev = p;
}
} else {
out.push(Segment { from, to: *to, rapid, feed, tool, line: r.line });
}
} else if !rapid || to.z < setup.clearance - POST_ERROR {
return invalid("initial position must be at clearance");
}
pos = Some(*to);
}
_ => {}
}
if out.len() > MAX_MOVES {
return invalid("simulation segment limit");
}
}
Ok(out)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod provenance_tests {
use super::*;
#[test]
fn tiny_arcs_cannot_round_into_unintended_full_circles() {
let input = corpus::fixture("face").unwrap();
for clockwise in [false, true] {
let to = Vec3::new(1., if clockwise { -1e-8 } else { 1e-8 }, 15.);
let path = Toolpath {
fingerprint: input.fingerprint.clone(),
blocks: vec![
Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } },
Block { operation: 1, motion: Motion::Spindle { rpm: 1000. } },
Block { operation: 1, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } },
Block { operation: 1, motion: Motion::Arc { to, center: Vec3::new(0., 0., 15.), clockwise, feed: 100. } },
Block { operation: 1, motion: Motion::Spindle { rpm: 0. } },
],
};
assert!(round_trip(&path, &input.setup, &input.tools).unwrap_err().0.contains("arc sweep"));
}
let raw = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X1 Y0.0000000000001 I-1 J0 F100\nM5\nM30";
let parsed = parse(raw, &input.tools).unwrap();
let distance: f64 = segments(&parsed, &input.setup).unwrap().iter().map(|s| s.from.dist(s.to)).sum();
assert!(distance < 1e-10, "a noncoincident tiny arc is not a full circle: {distance}");
}
#[test]
fn diagnostic_sources_follow_checked_records_and_ignore_untrusted_annotations() {
let input = corpus::fixture("face").unwrap();
let path = Toolpath {
fingerprint: input.fingerprint.clone(),
blocks: vec![
Block { operation: 10, motion: Motion::Tool { tool: 1, number: 1 } },
Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } },
Block { operation: 10, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } },
Block {
operation: 20,
motion: Motion::Arc { to: Vec3::new(1., 0., 15.), center: Vec3::new(0., 0., 15.), clockwise: true, feed: 100. },
},
Block { operation: 20, motion: Motion::Spindle { rpm: 0. } },
Block { operation: 20, motion: Motion::Stop },
],
};
let parsed = round_trip(&path, &input.setup, &input.tools).unwrap();
let prefix = startup(&input.setup).len();
for (i, record) in parsed.records.iter().skip(prefix).enumerate() {
assert_eq!(
parsed.source_for_line(record.line),
Some(SourceLocation { line: record.line, block: i + 1, operation: path.blocks[i].operation })
);
assert_eq!(parsed.record_for_line(record.line), Some(i + prefix));
}
for record in parsed.records.iter().take(prefix) {
assert!(parsed.source_for_line(record.line).is_none());
}
assert!(parsed.source_for_line(7).is_none()); // comment
let arc = &parsed.records[prefix + 3];
let segments = segments(&parsed, &input.setup).unwrap();
assert!(segments.iter().filter(|s| s.line == arc.line).count() > 10);
assert!(segments.iter().filter(|s| s.line == arc.line).all(|s| parsed.source_for_line(s.line).unwrap().block == 4));
let raw = parse(&post(&path, &input.setup).unwrap().replace("Operation 20", "Operation 999"), &input.tools).unwrap();
assert!(raw.source_for_line(arc.line).is_none());
let mut serialized = serde_json::to_value(&parsed).unwrap();
serialized["sources"] = serde_json::json!([{"line":arc.line,"block":4,"operation":999}]);
let decoded: Parsed = serde_json::from_value(serialized).unwrap();
assert!(decoded.source_for_line(arc.line).is_none());
}
#[test]
fn diagnostic_fault_findings_retain_checked_block_but_raw_nc_has_only_record() {
let input = corpus::fixture("face").unwrap();
let mut path = generate::generate(input.clone()).unwrap();
for block in &mut path.blocks {
if let Motion::Linear { to, .. } = &mut block.motion
&& to.z == 5.
{
to.z = 4.;
}
}
let parsed = round_trip(&path, &input.setup, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.status, verify::Status::Fail);
let finding = report.findings.iter().find(|f| f.kind == "gouge").unwrap();
assert_eq!(finding.severity, verify::FindingSeverity::Error);
assert_eq!(report.findings[0].severity, verify::FindingSeverity::Error);
let source = finding.source.unwrap();
assert_eq!(Some(source.line), finding.line);
assert_eq!(source.operation, path.blocks[source.block - 1].operation);
assert_eq!(finding.record, Some(source.block + startup(&input.setup).len()));
let raw = parse(&post(&path, &input.setup).unwrap(), &input.tools).unwrap();
let raw_report = verify::simulate(&input, &raw, Default::default()).unwrap();
assert_eq!(raw_report.status, report.status);
assert_eq!(raw_report.finished_dimensions, report.finished_dimensions);
assert!(raw_report.findings.iter().filter(|f| f.line.is_some()).all(|f| f.source.is_none() && f.record.is_some()));
let legacy: verify::Finding =
serde_json::from_value(serde_json::json!({"kind":"legacy","line":4,"at":[0,0,0],"detail":"old report"})).unwrap();
assert!(legacy.record.is_none() && legacy.source.is_none());
}
#[test]
fn diagnostic_retention_keeps_errors_and_budget_reasons_bounded() {
let input = corpus::fixture("face").unwrap();
let path = generate::generate(input.clone()).unwrap();
let parsed = round_trip(&path, &input.setup, &input.tools).unwrap();
let mut report = verify::simulate(&input, &parsed, Default::default()).unwrap();
report.findings.clear();
let finding = |line, severity, kind: &str| verify::Finding {
line,
severity,
kind: kind.into(),
record: None,
source: None,
at: Vec3::ZERO,
detail: "witness".into(),
};
for line in 1..=250 {
report.finding(finding(Some(line), verify::FindingSeverity::Unresolved, "clearance_unresolved"));
}
assert_eq!(report.findings.len(), 200);
report.finding(finding(None, verify::FindingSeverity::Unresolved, "budget_exhausted"));
report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge"));
for _ in 0..250 {
report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge"));
}
assert_eq!(report.findings.len(), 200);
assert_eq!(report.findings.iter().filter(|f| f.kind == "gouge").count(), 1);
assert!(report.findings.iter().any(|f| f.kind == "budget_exhausted"));
// Diagnostic storage is not an input to acceptance decisions.
assert_eq!(report.status, verify::Status::Pass);
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Machine state at a typed toolpath boundary, shared by inspection and playback.
use crate::*;
use solidcraft_geom::Vec3;
#[derive(Clone, Debug, serde::Serialize)]
pub struct State {
pub completed: usize,
pub position: Vec3,
pub operation: Option<u64>,
pub tool: Option<u64>,
pub spindle: f64,
pub coolant: bool,
pub feed: Option<f64>,
}
pub fn at(path: &Toolpath, setup: &Setup, completed: usize) -> Result<State> {
if completed > path.blocks.len() {
return invalid("playback motion exceeds toolpath length");
}
let mut state =
State { completed, position: Vec3::new(0., 0., setup.clearance), operation: None, tool: None, spindle: 0., coolant: false, feed: None };
for block in path.blocks.iter().take(completed) {
state.operation = Some(block.operation);
match block.motion {
Motion::Rapid { to } => state.position = to,
Motion::Linear { to, feed } | Motion::Arc { to, feed, .. } => {
state.position = to;
state.feed = Some(feed);
}
Motion::Tool { tool, .. } => state.tool = Some(tool),
Motion::Spindle { rpm } => state.spindle = rpm,
Motion::Coolant { on } => state.coolant = on,
Motion::Stop => {
state.spindle = 0.;
state.coolant = false;
}
}
}
Ok(state)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn initial_pose_tool_changes_and_shutdown() {
let input = corpus::fixture("face").unwrap();
let path = Toolpath {
fingerprint: "test".into(),
blocks: vec![
Block { operation: 10, motion: Motion::Tool { tool: 3, number: 7 } },
Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } },
Block { operation: 10, motion: Motion::Coolant { on: true } },
Block { operation: 10, motion: Motion::Linear { to: Vec3::new(2., 3., 4.), feed: 100. } },
Block { operation: 11, motion: Motion::Tool { tool: 4, number: 8 } },
Block { operation: 11, motion: Motion::Stop },
],
};
let start = at(&path, &input.setup, 0).unwrap();
assert_eq!(start.position, Vec3::new(0., 0., 15.));
assert!(start.tool.is_none());
let cut = at(&path, &input.setup, 4).unwrap();
assert_eq!(cut.position, Vec3::new(2., 3., 4.));
assert_eq!(cut.tool, Some(3));
assert_eq!(cut.spindle, 5000.);
assert!(cut.coolant);
assert_eq!(cut.feed, Some(100.));
let end = at(&path, &input.setup, 6).unwrap();
assert_eq!(end.tool, Some(4));
assert_eq!(end.operation, Some(11));
assert_eq!(end.spindle, 0.);
assert!(!end.coolant);
assert!(at(&path, &input.setup, 7).is_err());
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Independent planar-region predicates for finished dimensions. No offsets or
//! toolpath-generation routines are used. Winding ignores input ring direction.
use crate::{spatial::Index, *};
fn orient(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> f64 {
robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: p[0], y: p[1] })
}
fn contains(ring: &[[f64; 2]], p: [f64; 2]) -> bool {
ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).fold(false, |inside, (a, b)| inside ^ crossing(*a, *b, p))
}
fn crossing(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> bool {
(a[1] > p[1]) != (b[1] > p[1]) && if b[1] > a[1] { orient(a, b, p) > 0. } else { orient(a, b, p) < 0. }
}
struct Edge {
a: [f64; 2],
b: [f64; 2],
ring: usize,
vertex: usize,
count: usize,
}
pub(crate) struct Region {
edges: Vec<Edge>,
index: Index,
max_x: f64,
guard: f64,
}
impl Region {
pub(crate) fn new(boundary: &Boundary) -> Result<Self> {
boundary.validate()?;
let rings: Vec<_> = std::iter::once(&boundary.outer).chain(&boundary.islands).collect();
let mut edges = Vec::new();
let mut extent = 1_f64;
let mut max_x = f64::NEG_INFINITY;
for (r, ring) in rings.iter().enumerate() {
let mut area = 0.;
let origin = ring[0];
for (vertex, (&a, &b)) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).enumerate() {
if a.into_iter().any(|v| v != 0. && v.abs() < 1e-100) || (a[0] - b[0]).hypot(a[1] - b[1]) < 1e-7 {
return invalid("unresolvable dimensional boundary edge");
}
extent = extent.max(a[0].abs()).max(a[1].abs());
max_x = max_x.max(a[0]);
area += (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0]);
edges.push(Edge { a, b, ring: r, vertex, count: ring.len() });
}
if area.abs() < 1e-8 {
return invalid("zero-area dimensional boundary");
}
}
let index = Index::new(
edges
.iter()
.enumerate()
.map(|(id, e)| (id, [e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])]))
.collect(),
);
let mut work = 0;
for (id, e) in edges.iter().enumerate() {
let mut candidates = Vec::new();
index.query([e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])], &mut candidates);
for other in candidates.into_iter().filter(|other| *other > id) {
work += 1;
if work > 2_000_000 {
return invalid("dimensional boundary validation budget exceeded");
}
let f = &edges[other];
if e.ring == f.ring && ((e.vertex + 1) % e.count == f.vertex || (f.vertex + 1) % e.count == e.vertex) {
let (a, b, c) = if e.b == f.a { (e.a, e.b, f.b) } else { (f.a, f.b, e.b) };
if orient(a, b, c) == 0. && (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0. {
return invalid("backtracking dimensional boundary");
}
continue;
}
if (0..2).any(|k| e.a[k].max(e.b[k]) < f.a[k].min(f.b[k]) || f.a[k].max(f.b[k]) < e.a[k].min(e.b[k])) {
continue;
}
let opposite = |x: f64, y: f64| x == 0. || y == 0. || x.is_sign_positive() != y.is_sign_positive();
if opposite(orient(e.a, e.b, f.a), orient(e.a, e.b, f.b)) && opposite(orient(f.a, f.b, e.a), orient(f.a, f.b, e.b)) {
return invalid("intersecting or touching dimensional boundaries");
}
}
}
for (id, island) in boundary.islands.iter().enumerate() {
if !contains(&boundary.outer, island[0]) || boundary.islands.iter().enumerate().any(|(j, r)| id != j && contains(r, island[0])) {
return invalid("dimensional islands must be disjoint and inside the outer boundary");
}
}
Ok(Self { edges, index, max_x, guard: 128. * f64::EPSILON * extent })
}
/// Signed distance is positive in the material-removal region. Distance to
/// its boundary is 1-Lipschitz, so center distance +/- cell radius bounds the
/// whole cell. A capped nearest-edge query returns an infinite far endpoint.
pub(crate) async fn range<F: std::future::Future<Output = ()>>(
&self,
query: ([f64; 2], f64, f64),
work: &mut usize,
budget: usize,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Option<[f64; 2]>> {
let (p, half, reach) = query;
let guard = self.guard.max(128. * f64::EPSILON * p[0].abs().max(p[1].abs()));
let mut candidates = Vec::new();
self.index.query(p, [self.max_x.max(p[0]), p[1]], &mut candidates);
let mut inside = false;
for (n, id) in candidates.into_iter().enumerate() {
if n % 4096 == 4095 {
yield_control().await;
if cancelled() {
return invalid("dimensional verification cancelled");
}
}
*work += 1;
if *work > budget {
return Ok(None);
}
let e = &self.edges[id];
inside ^= crossing(e.a, e.b, p);
}
let radius = reach + half + guard;
let mut candidates = Vec::new();
self.index.query(p.map(|v| v - radius), p.map(|v| v + radius), &mut candidates);
let mut distance = radius;
for (n, id) in candidates.into_iter().enumerate() {
if n % 4096 == 4095 {
yield_control().await;
if cancelled() {
return invalid("dimensional verification cancelled");
}
}
*work += 1;
if *work > budget {
return Ok(None);
}
let e = &self.edges[id];
let dx = e.b[0] - e.a[0];
let dy = e.b[1] - e.a[1];
let t = (((p[0] - e.a[0]) * dx + (p[1] - e.a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.);
distance = distance.min((p[0] - e.a[0] - t * dx).hypot(p[1] - e.a[1] - t * dy));
}
let lo = (distance - half - guard).max(0.);
let hi = if distance == radius { f64::INFINITY } else { distance + half + guard };
if distance <= half + guard {
Ok(Some([-hi, hi]))
} else if inside {
Ok(Some([lo, hi]))
} else {
Ok(Some([-hi, -lo]))
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use std::{
future::Future,
task::{Context, Poll, Waker},
};
fn square(a: f64, b: f64) -> Vec<[f64; 2]> {
vec![[a, a], [b, a], [b, b], [a, b]]
}
fn range(r: &Region, p: [f64; 2], half: f64, budget: usize) -> Option<[f64; 2]> {
let mut ready = || std::future::ready(());
let mut work = 0;
let mut f = std::pin::pin!(r.range((p, half, 10.), &mut work, budget, &|| false, &mut ready));
match f.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
Poll::Ready(result) => result.unwrap(),
_ => None,
}
}
#[test]
fn signed_distances_cover_concavity_islands_and_winding() {
let mut boundary = Boundary { outer: square(0., 10.), islands: vec![square(4., 6.)] };
for _ in 0..2 {
let r = Region::new(&boundary).unwrap();
for (p, expected) in [([3., 5.], 1.), ([5., 5.], -1.), ([-1., 5.], -1.)] {
let [a, b] = range(&r, p, 0.1, 1000).unwrap();
assert!((a - (expected - 0.1)).abs() < 1e-10 && (b - (expected + 0.1)).abs() < 1e-10, "{a} {b}");
}
boundary.outer.reverse();
boundary.islands[0].reverse();
}
let r = Region::new(&Boundary { outer: vec![[0., 0.], [4., 0.], [4., 1.], [1., 1.], [1., 4.], [0., 4.]], islands: vec![] }).unwrap();
assert!((range(&r, [2., 2.], 0., 1000).unwrap()[0] + 1.).abs() < 1e-10);
assert!((range(&r, [0.5, 3.], 0., 1000).unwrap()[0] - 0.5).abs() < 1e-10);
assert!(range(&r, [0.5, 3.], 0., 0).is_none());
}
#[test]
fn invalid_regions_are_rejected_without_generator_validation() {
for b in [
Boundary { outer: vec![[0., 0.], [2., 2.], [0., 2.], [2., 0.]], islands: vec![] },
Boundary { outer: square(0., 10.), islands: vec![square(0., 2.)] },
Boundary { outer: square(0., 10.), islands: vec![square(2., 8.), square(3., 4.)] },
Boundary { outer: vec![[0., 0.], [2., 0.], [1., 0.], [2., 2.], [0., 2.]], islands: vec![] },
] {
assert!(Region::new(&b).is_err());
}
}
#[test]
fn large_boundary_queries_yield_and_cancel() {
let b = Boundary {
outer: (0..10_000)
.map(|i| {
let a = (i as f64 + 0.1) * std::f64::consts::TAU / 10_000.;
[a.cos() * 10., a.sin() * 10.]
})
.collect(),
islands: vec![],
};
let r = Region::new(&b).unwrap();
let mut work = 0;
let cancel = std::cell::Cell::new(false);
let cancelled = || cancel.get();
let mut yielding = || {
let mut first = true;
std::future::poll_fn(move |_| {
if first {
first = false;
Poll::Pending
} else {
Poll::Ready(())
}
})
};
let mut f = std::pin::pin!(r.range(([0., 0.], 20., 0.), &mut work, 100_000, &cancelled, &mut yielding));
let mut cx = Context::from_waker(Waker::noop());
assert!(f.as_mut().poll(&mut cx).is_pending());
cancel.set(true);
assert!(matches!(f.as_mut().poll(&mut cx), Poll::Ready(Err(_))));
}
}
+65
View File
@@ -0,0 +1,65 @@
// Independent median-split XY index: artifact comparison never calls CAM offsets.
pub(crate) struct Index {
min: [f64; 2],
max: [f64; 2],
items: Vec<(usize, [f64; 2], [f64; 2])>,
children: Vec<Index>,
}
impl Index {
pub(crate) fn new(mut items: Vec<(usize, [f64; 2], [f64; 2])>) -> Self {
let mut min = [f64::INFINITY; 2];
let mut max = [f64::NEG_INFINITY; 2];
for (_, a, b) in &items {
for k in 0..2 {
min[k] = min[k].min(a[k]);
max[k] = max[k].max(b[k]);
}
}
if items.len() <= 8 {
return Self { min, max, items, children: vec![] };
}
let axis = usize::from(max[1] - min[1] > max[0] - min[0]);
items.sort_unstable_by(|a, b| (a.1[axis] + a.2[axis]).total_cmp(&(b.1[axis] + b.2[axis])));
let rest = items.split_off(items.len() / 2);
Self { min, max, items: vec![], children: vec![Self::new(items), Self::new(rest)] }
}
pub(crate) fn query(&self, min: [f64; 2], max: [f64; 2], out: &mut Vec<usize>) {
if (0..2).any(|k| self.min[k] > max[k] || self.max[k] < min[k]) {
return;
}
out.extend(self.items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| id));
for child in &self.children {
child.query(min, max, out);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn indexed_candidates_include_every_brute_force_overlap() {
let items: Vec<_> = (0..1024)
.map(|i| {
let x = (i % 32) as f64 - 16.;
let y = (i / 32) as f64 - 16.;
(i, [x, y], [x + 0.5, y + 0.75])
})
.collect();
let index = Index::new(items.clone());
for (min, max) in [([-0.5, -2.], [3.5, 4.]), ([-16., -16.], [-16., -16.]), ([16., 16.], [17., 17.]), ([-4.75, -7.5], [-4.5, -7.25])] {
let mut actual = Vec::new();
index.query(min, max, &mut actual);
for (id, a, b) in &items {
if (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k]) {
assert!(actual.contains(id));
}
}
let expected: Vec<_> = items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| *id).collect();
actual.sort_unstable();
assert_eq!(actual, expected);
let unique: std::collections::BTreeSet<_> = actual.iter().collect();
assert_eq!(unique.len(), actual.len());
}
}
}
+728
View File
@@ -0,0 +1,728 @@
//! Conservative boundary extraction from non-overlapping rectangular height cells.
//! Shared planar edges and vertical steps are split before triangulation. No
//! XY snapping, interpolation or generation algorithm is involved. Tiny adaptive
//! saddle contacts may be filled upward within a declared export error budget.
use crate::{
Error, Result, invalid,
verify::{Cell, Report},
};
use solidcraft_geom::Vec3;
use std::{
collections::{BTreeMap, BTreeSet},
future::Future,
};
/// Count elementary topology work, including sorting and manifold validation.
/// A yield callback which is immediately ready retains the synchronous API.
struct Work<C, Y> {
cancelled: C,
yield_control: Y,
remaining: usize,
}
impl<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>> Work<C, Y> {
async fn tick(&mut self) -> Result<()> {
if self.remaining == 0 {
if (self.cancelled)() {
return invalid("stock extraction cancelled");
}
(self.yield_control)().await;
if (self.cancelled)() {
return invalid("stock extraction cancelled");
}
self.remaining = 256;
}
self.remaining -= 1;
Ok(())
}
// Sort small runs, then merge with checkpoints. A single standard-library
// sort of up to two million cell events otherwise monopolizes a browser poll.
async fn sort<T: Clone>(&mut self, values: &mut Vec<T>, compare: impl Fn(&T, &T) -> std::cmp::Ordering) -> Result<()> {
for run in values.chunks_mut(256) {
if run.len() == 256 {
self.remaining = 0;
}
self.tick().await?;
run.sort_unstable_by(&compare);
}
if values.len() <= 256 {
return Ok(());
}
let mut scratch = Vec::with_capacity(values.len());
let mut width = 256;
while width < values.len() {
for begin in (0..values.len()).step_by(width * 2) {
let middle = (begin + width).min(values.len());
let end = (middle + width).min(values.len());
let (mut a, mut b) = (begin, middle);
while a < middle || b < end {
self.tick().await?;
let take_a = b == end || (a < middle && compare(&values[a], &values[b]).is_le());
let index = if take_a {
let i = a;
a += 1;
i
} else {
let i = b;
b += 1;
i
};
scratch.push(values[index].clone());
}
}
std::mem::swap(values, &mut scratch);
scratch.clear();
width *= 2;
}
Ok(())
}
}
fn ready<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
let mut future = std::pin::pin!(future);
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
std::task::Poll::Ready(result) => result,
std::task::Poll::Pending => invalid("synchronous stock extraction unexpectedly yielded"),
}
}
const MAX_CELLS: usize = 60_000;
const MAX_TRIANGLES: usize = 500_000;
// One percent of the post budget, reserved only for conservative saddle closure.
// This is an artifact approximation; it never changes the verification report.
const MAX_SADDLE_FILL: f64 = crate::STOCK_EXPORT_ERROR;
#[derive(Clone, Copy, Debug, PartialEq)]
struct Coord(f64);
impl Eq for Coord {}
impl PartialOrd for Coord {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Coord {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) }
}
}
type Point = [Coord; 3];
type XY = [Coord; 2];
fn point(p: Vec3) -> Point {
[Coord(p.x), Coord(p.y), Coord(p.z)]
}
#[derive(Clone, Copy)]
struct Event {
at: Coord,
cell: usize,
side: usize,
start: bool,
}
struct Span {
axis: usize,
fixed: f64,
a: f64,
b: f64,
cells: [Option<usize>; 2],
}
impl Span {
fn xy(&self, t: f64) -> XY {
if self.axis == 0 { [Coord(self.fixed), Coord(t)] } else { [Coord(t), Coord(self.fixed)] }
}
fn vertex(&self, t: f64, z: f64) -> Vec3 {
let xy = self.xy(t);
Vec3::new(xy[0].0, xy[1].0, z)
}
}
async fn cells<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(report: &Report, bottom: f64, work: &mut Work<C, Y>) -> Result<Vec<Cell>> {
work.tick().await?;
if !bottom.is_finite() || report.cells.is_empty() || report.cells.len() > 1_000_000 {
return invalid("invalid stock export bounds");
}
if !report.stock_complete || report.findings.iter().any(|f| f.kind == "budget_exhausted") {
return invalid("stock verification is incomplete; cannot export remaining stock");
}
for c in &report.cells {
work.tick().await?;
for p in [Vec3::new(c.min[0], c.min[1], bottom), Vec3::new(c.max[0], c.max[1], c.high)] {
crate::model::point(p)?;
}
if c.min[0] >= c.max[0] || c.min[1] >= c.max[1] || !c.low.is_finite() || c.low > c.high || c.high < bottom {
return invalid("invalid stock cell");
}
}
// Reject overlap in O(n log n). End events precede starts at the same X.
let mut events = Vec::with_capacity(report.cells.len() * 2);
for (i, c) in report.cells.iter().enumerate() {
work.tick().await?;
events.push((Coord(c.min[0]), true, i));
events.push((Coord(c.max[0]), false, i));
}
work.sort(&mut events, Ord::cmp).await?;
let mut active = BTreeMap::<Coord, f64>::new();
for (_, start, i) in events {
work.tick().await?;
let c = &report.cells[i];
let y = Coord(c.min[1]);
if start {
if active.range(..=y).next_back().is_some_and(|(_, end)| *end > c.min[1])
|| active.range(y..).next().is_some_and(|(start, _)| start.0 < c.max[1])
{
return invalid("overlapping stock cells");
}
active.insert(y, c.max[1]);
} else {
active.remove(&y);
}
}
// Exact coplanar rectangle merging reduces output without changing height.
let mut cells = Vec::new();
for c in &report.cells {
work.tick().await?;
if c.high > bottom {
cells.push(c.clone());
}
}
for _ in 0..24 {
let before = cells.len();
for axis in 0..2 {
let other = 1 - axis;
work.sort(&mut cells, |a, b| {
a.min[other]
.total_cmp(&b.min[other])
.then(a.max[other].total_cmp(&b.max[other]))
.then(a.high.total_cmp(&b.high))
.then(a.min[axis].total_cmp(&b.min[axis]))
})
.await?;
let mut merged: Vec<Cell> = Vec::new();
for c in cells {
work.tick().await?;
if let Some(last) = merged.last_mut()
&& last.min[other] == c.min[other]
&& last.max[other] == c.max[other]
&& last.high == c.high
&& last.max[axis] == c.min[axis]
{
last.max[axis] = c.max[axis];
} else {
merged.push(c);
}
}
cells = merged;
}
if cells.len() == before {
break;
}
}
if cells.len() > MAX_CELLS {
return invalid(format!("stock mesh has {} merged cells, exceeding 60000; use validation JSON for this resolution", cells.len()));
}
Ok(cells)
}
async fn spans<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(cells: &[Cell], work: &mut Work<C, Y>) -> Result<Vec<Span>> {
let mut lines: BTreeMap<(usize, Coord), Vec<Event>> = BTreeMap::new();
for (i, c) in cells.iter().enumerate() {
work.tick().await?;
for axis in 0..2 {
for (fixed, side) in [(c.min[axis], 1), (c.max[axis], 0)] {
let events = lines.entry((axis, Coord(fixed))).or_default();
events.push(Event { at: Coord(c.min[1 - axis]), cell: i, side, start: true });
events.push(Event { at: Coord(c.max[1 - axis]), cell: i, side, start: false });
}
}
}
let mut spans = Vec::new();
for ((axis, fixed), mut events) in lines {
work.sort(&mut events, |a, b| (a.at, a.start).cmp(&(b.at, b.start))).await?;
let mut active = [None; 2];
let mut previous: Option<Coord> = None;
for event in events {
work.tick().await?;
if let Some(a) = previous
&& a < event.at
&& active != [None; 2]
{
spans.push(Span { axis, fixed: fixed.0, a: a.0, b: event.at.0, cells: active });
}
previous = Some(event.at);
if event.start {
if active[event.side].is_some() {
return invalid("overlapping stock boundaries");
}
active[event.side] = Some(event.cell);
} else {
active[event.side] = None;
}
}
}
Ok(spans)
}
async fn close_tiny_saddles<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(
cells: &mut [Cell],
spans: &[Span],
bottom: f64,
work: &mut Work<C, Y>,
) -> Result<()> {
// NW, NE, SE, SW occupancy at every boundary endpoint, including T-junctions.
let mut vertices = BTreeMap::<XY, [Option<usize>; 4]>::new();
for s in spans {
for (side, id) in s.cells.iter().enumerate() {
work.tick().await?;
let Some(id) = id else { continue };
let (a, b) = match (s.axis, side) {
(0, 0) => (0, 3),
(0, 1) => (1, 2),
(1, 0) => (2, 3),
_ => (1, 0),
};
vertices.entry(s.xy(s.a)).or_insert([None; 4])[a] = Some(*id);
vertices.entry(s.xy(s.b)).or_insert([None; 4])[b] = Some(*id);
}
}
let mut ceilings = Vec::with_capacity(cells.len());
for c in cells.iter() {
work.tick().await?;
ceilings.push(c.high + MAX_SADDLE_FILL);
}
// Monotone bounded closure. Stop rather than silently exceeding either the
// error or work bound; the final manifold validator rejects unresolved cases.
for _ in 0..8 {
let mut changed = false;
for ids in vertices.values() {
work.tick().await?;
let heights = ids.map(|id| id.map_or(bottom, |i| cells[i].high));
for (upper, lower) in [([0, 2], [1, 3]), ([1, 3], [0, 2])] {
let threshold = heights[upper[0]].min(heights[upper[1]]);
let pick = if heights[lower[0]] >= heights[lower[1]] { lower[0] } else { lower[1] };
if threshold > heights[pick]
&& let Some(id) = ids[pick]
&& threshold <= ceilings[id]
{
cells[id].high = threshold;
changed = true;
}
}
}
if !changed {
break;
}
}
for _ in vertices {
work.tick().await?;
}
Ok(())
}
pub(crate) fn validate_triangles(triangles: &[[Vec3; 3]]) -> Result<()> {
ready(validate(triangles, &mut Work { cancelled: || false, yield_control: || std::future::ready(()), remaining: 0 }))
}
async fn validate<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(triangles: &[[Vec3; 3]], work: &mut Work<C, Y>) -> Result<()> {
if triangles.len() > MAX_TRIANGLES {
return invalid("stock triangle budget exceeded");
}
for t in triangles {
work.tick().await?;
for p in t {
crate::model::point(*p)?;
}
if (t[1] - t[0]).cross(t[2] - t[0]).len2() == 0. {
return invalid("degenerate stock triangle");
}
}
// Edge incidence plus connected vertex links reject cracks, T-junctions,
// touching components and non-manifold saddle edges explicitly.
let mut edges = BTreeMap::<(Point, Point), (usize, i32)>::new();
let mut links = BTreeMap::<Point, Vec<(Point, Point)>>::new();
for t in triangles {
work.tick().await?;
let p = t.map(point);
for (a, b, c) in [(p[0], p[1], p[2]), (p[1], p[2], p[0]), (p[2], p[0], p[1])] {
let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) };
let edge = edges.entry(key).or_default();
edge.0 += 1;
edge.1 += sign;
if edge.0 > 2 {
return invalid(format!("stock boundary is non-manifold at {key:?}; use validation JSON"));
}
links.entry(a).or_default().push((b, c));
}
}
for (_, edge) in edges {
work.tick().await?;
if edge != (2, 0) {
return invalid("stock boundary has an unstitched or misoriented edge");
}
}
// Consume the maps incrementally too: dropping a large tree all at once
// can be as visible as constructing it on the browser's event loop.
for (_, link) in links {
let mut next = BTreeMap::new();
for (a, b) in &link {
work.tick().await?;
if next.insert(*a, *b).is_some() {
return invalid("non-manifold stock vertex");
}
}
let Some((&start, _)) = next.first_key_value() else {
continue;
};
let mut at = start;
for _ in 0..link.len() {
work.tick().await?;
let Some(b) = next.remove(&at) else {
return invalid("disconnected stock vertex surface; use validation JSON");
};
at = b;
}
if at != start || !next.is_empty() {
return invalid("open stock vertex surface");
}
}
Ok(())
}
struct Mesh {
triangles: Vec<[Vec3; 3]>,
}
impl Mesh {
fn triangle(&mut self, a: Vec3, b: Vec3, c: Vec3) -> Result<()> {
if self.triangles.len() >= MAX_TRIANGLES {
return invalid("stock mesh exceeds 500000 triangles");
}
if (b - a).cross(c - a).len2() == 0.0 {
return invalid("degenerate stock boundary triangle");
}
self.triangles.push([a, b, c]);
Ok(())
}
async fn convex<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(
&mut self,
boundary: &[Vec3],
reverse: bool,
work: &mut Work<C, Y>,
) -> Result<()> {
// All faces are rectangles with collinear boundary subdivisions. Clip
// the smallest nonzero corner first, preserving every boundary vertex.
// An ordered corner set bounds this to O(n log n), including long edges.
let n = boundary.len();
if !(3..=MAX_TRIANGLES).contains(&n) {
return invalid("invalid stock polygon size");
}
let mut prev: Vec<_> = (0..n).map(|i| (i + n - 1) % n).collect();
let mut next: Vec<_> = (0..n).map(|i| (i + 1) % n).collect();
let area = |a: usize, b: usize, c: usize| Coord((boundary[b] - boundary[a]).cross(boundary[c] - boundary[b]).len2());
let mut corners = BTreeSet::new();
for i in 0..n {
work.tick().await?;
let a = area(prev[i], i, next[i]);
if a.0 > 0.0 {
corners.insert((a, i));
}
}
for _ in 0..n - 2 {
work.tick().await?;
let (_, i) = corners.pop_first().ok_or_else(|| Error("degenerate stock polygon".into()))?;
let a = prev[i];
let b = next[i];
if reverse {
self.triangle(boundary[a], boundary[b], boundary[i])?;
} else {
self.triangle(boundary[a], boundary[i], boundary[b])?;
}
corners.remove(&(area(prev[a], a, next[a]), a));
corners.remove(&(area(prev[b], b, next[b]), b));
next[a] = b;
prev[b] = a;
for k in [a, b] {
let v = area(prev[k], k, next[k]);
if v.0 > 0.0 {
corners.insert((v, k));
}
}
}
Ok(())
}
}
async fn boundary<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(report: &Report, bottom: f64, work: &mut Work<C, Y>) -> Result<Mesh> {
let mut cells = cells(report, bottom, work).await?;
let spans = spans(&cells, work).await?;
close_tiny_saddles(&mut cells, &spans, bottom, work).await?;
let mut levels = BTreeMap::<XY, BTreeSet<Coord>>::new();
for s in &spans {
work.tick().await?;
for xy in [s.xy(s.a), s.xy(s.b)] {
let z = levels.entry(xy).or_default();
z.insert(Coord(bottom));
for id in s.cells.into_iter().flatten() {
z.insert(Coord(cells[id].high));
}
}
}
let mut mesh = Mesh { triangles: Vec::new() };
let mut tops: Vec<Vec<(usize, Coord, Vec3)>> = vec![Vec::new(); cells.len()];
for s in &spans {
work.tick().await?;
for (side, id) in s.cells.iter().enumerate() {
let Some(id) = id else {
continue;
};
let c = &cells[*id];
let edge = match (s.axis, side) {
(1, 1) => 0,
(0, 0) => 1,
(1, 0) => 2,
_ => 3,
};
let a = if edge < 2 { s.a } else { s.b };
tops[*id].push((edge, Coord(if edge < 2 { a } else { -a }), s.vertex(a, c.high)));
}
let heights = s.cells.map(|id| id.map_or(bottom, |id| cells[id].high));
if heights[0] == heights[1] {
continue;
}
let lo = heights[0].min(heights[1]);
let hi = heights[0].max(heights[1]);
let mut polygon = vec![s.vertex(s.a, lo), s.vertex(s.b, lo)];
let right = levels.get(&s.xy(s.b)).ok_or_else(|| Error("missing stock edge levels".into()))?;
for z in right.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))) {
work.tick().await?;
polygon.push(s.vertex(s.b, z.0));
}
polygon.push(s.vertex(s.b, hi));
polygon.push(s.vertex(s.a, hi));
let left = levels.get(&s.xy(s.a)).ok_or_else(|| Error("missing stock edge levels".into()))?;
for z in left.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))).rev() {
work.tick().await?;
polygon.push(s.vertex(s.a, z.0));
}
mesh.convex(&polygon, (s.axis == 0) != (heights[0] > heights[1]), work).await?;
}
for mut top in tops {
work.sort(&mut top, |a, b| (a.0, a.1).cmp(&(b.0, b.1))).await?;
let mut polygon: Vec<_> = top.into_iter().map(|p| p.2).collect();
mesh.convex(&polygon, false, work).await?;
for p in &mut polygon {
p.z = bottom;
}
mesh.convex(&polygon, true, work).await?;
}
for _ in levels {
work.tick().await?;
}
validate(&mesh.triangles, work).await?;
Ok(mesh)
}
pub(crate) fn triangles(report: &Report, bottom: f64) -> Result<Vec<[Vec3; 3]>> {
ready(triangles_cooperative(report, bottom, || false, || std::future::ready(())))
}
pub(crate) async fn triangles_cooperative<F: Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<Vec<[Vec3; 3]>> {
let mut work = Work { cancelled, yield_control, remaining: 0 };
Ok(boundary(report, bottom, &mut work).await?.triangles)
}
pub(crate) fn stl(report: &Report, bottom: f64) -> Result<String> {
ready(stl_cooperative(report, bottom, || false, || std::future::ready(())))
}
pub(crate) async fn stl_cooperative<F: Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<String> {
let mut work = Work { cancelled, yield_control, remaining: 0 };
let mesh = boundary(report, bottom, &mut work).await?.triangles;
let mut out = String::from("solid cam_remaining_stock\n");
for triangle in mesh {
work.tick().await?;
out.push_str("facet normal 0 0 0\nouter loop\n");
for p in triangle {
out.push_str(&format!("vertex {} {} {}\n", p.x, p.y, p.z));
}
out.push_str("endloop\nendfacet\n");
if out.len() > 128 * 1024 * 1024 {
return invalid("stock mesh exceeds 128 MiB");
}
}
out.push_str("endsolid cam_remaining_stock\n");
work.remaining = 0;
work.tick().await?;
Ok(out)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::verify::Status;
fn cell(min: [f64; 2], max: [f64; 2], high: f64) -> Cell {
Cell { min, max, low: high, high, target_low: high, target_high: high }
}
fn report(cells: Vec<Cell>) -> Report {
Report {
version: crate::VERSION.into(),
fingerprint: "analytic".into(),
status: Status::Pass,
finished_dimensions: Status::Pass,
post_version: "analytic".into(),
tolerance: 0.01,
uncertainty: 0.0025,
maximum_height_interval: 0.0,
stock_complete: true,
stock: None,
cells,
findings: vec![],
cutting_distance: 0.0,
rapid_distance: 0.0,
cutting_minutes: 0.0,
remaining_volume_low: 0.0,
remaining_volume_high: 0.0,
work: 0,
}
}
fn check(cells: Vec<Cell>, volume: f64, area: f64) -> Vec<[Vec3; 3]> {
let text = stl(&report(cells), 0.0).unwrap();
let mesh = crate::compare::read_stl(text.as_bytes()).unwrap();
let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum();
let a: f64 = mesh.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).len() / 2.0).sum();
assert!((v - volume).abs() < 1e-9, "volume {v} expected {volume}");
assert!((a - area).abs() < 1e-9, "area {a} expected {area}");
mesh
}
#[test]
fn box_and_step_have_only_exterior_faces() {
check(vec![cell([0., 0.], [1., 1.], 1.)], 1., 6.);
let mesh = check(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 0.], [2., 1.], 2.)], 3., 14.);
for t in mesh {
if t.iter().all(|p| p.x == 1.) {
assert!(t.iter().all(|p| p.z >= 1.));
}
}
}
#[test]
fn adaptive_t_junctions_and_three_height_vertices_are_stitched() {
check(vec![cell([0., 0.], [1., 2.], 1.), cell([1., 0.], [2., 1.], 2.), cell([1., 1.], [2., 2.], 3.)], 7., 26.);
}
#[test]
fn hole_and_disconnected_stock_are_watertight() {
let mut cells = Vec::new();
for x in 0..3 {
for y in 0..3 {
cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], if x == 1 && y == 1 { 0. } else { 1. }));
}
}
check(cells, 8., 32.);
check(vec![cell([0., 0.], [1., 1.], 1.), cell([2., 0.], [3., 1.], 1.)], 2., 12.);
}
#[test]
fn nonmanifold_touching_stock_is_explicitly_rejected() {
assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 1.], [2., 2.], 1.)]), 0.).is_err());
assert!(
stl(
&report(vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 1.), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)]),
0.
)
.is_err()
);
}
#[test]
fn tiny_adaptive_saddle_closure_is_conservative_and_bounded() {
let cells =
vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 2. - 4e-6), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)];
let mut original = report(cells);
original.stock = Some(crate::Stock { min: Vec3::new(0., 0., 0.), max: Vec3::new(2., 2., 2.) });
let mesh = triangles(&original, 0.).unwrap();
let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.).sum();
assert!((volume - 7.).abs() < 1e-12);
assert_eq!(original.cells[1].high, 2. - 4e-6);
assert_eq!(crate::compare::stock_mesh(&original, &mesh, 0., crate::TOLERANCE).unwrap().status, Status::Pass);
let mut too_large = original;
too_large.cells[1].high = 2. - 2. * MAX_SADDLE_FILL;
assert!(triangles(&too_large, 0.).is_err());
}
#[test]
fn hostile_and_overlapping_cells_are_rejected() {
for bad in [cell([0., 0.], [0., 1.], 1.), cell([0., 0.], [1., 1.], f64::NAN), cell([0., 0.], [1., 1.], -1.)] {
assert!(stl(&report(vec![bad]), 0.).is_err());
}
assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([1., 1.], [3., 3.], 2.)]), 0.).is_err());
assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([0., 0.], [2., 2.], 1.)]), 0.).is_err());
}
#[test]
fn zero_stock_and_translated_base_are_preserved() {
assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 0.)]), 0.).unwrap().lines().count() == 2);
let mesh = triangles(&report(vec![cell([-3., 2.], [-2., 3.], 7.)]), 5.).unwrap();
let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum();
assert!((v - 2.).abs() < 1e-9);
}
#[test]
fn incomplete_verification_cannot_export_partial_stock() {
let input = crate::corpus::fixture("face").unwrap();
let path = crate::generate::generate(input.clone()).unwrap();
let nc = crate::nc::round_trip(&path, &input.setup, &input.tools).unwrap();
for limits in [crate::verify::Limits { cells: 1, ..Default::default() }, crate::verify::Limits { work: 1, ..Default::default() }] {
let report = crate::verify::simulate(&input, &nc, limits).unwrap();
assert!(!report.stock_complete);
assert!(stl(&report, 0.).is_err());
}
}
#[test]
fn cooperative_mesh_matches_sync_and_cancels_during_topology_work() {
use std::task::{Context, Poll, Waker};
let report = report(
(0..32)
.rev()
.flat_map(|x| (0..32).rev().map(move |y| cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64)))
.collect(),
);
let expected = triangles(&report, 0.).unwrap();
let yield_once = || {
let mut first = true;
std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) })
};
let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || false, yield_once));
let mut cx = Context::from_waker(Waker::noop());
let mut polls = 0;
loop {
polls += 1;
if let Poll::Ready(result) = future.as_mut().poll(&mut cx) {
assert_eq!(result.unwrap(), expected);
break;
}
assert!(polls < 100_000);
}
assert!(polls > 100);
// Cancel in cell sorting, boundary work and late manifold validation.
// Each suspended future must fail on its next poll without a partial mesh.
for cancel_after in [1, polls / 2, polls - 1] {
let cancelled = std::cell::Cell::new(false);
let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || cancelled.get(), yield_once));
for _ in 0..cancel_after {
assert!(future.as_mut().poll(&mut cx).is_pending());
}
cancelled.set(true);
let result = future.as_mut().poll(&mut cx);
assert!(matches!(result, Poll::Ready(Err(e)) if e.0.contains("cancelled")));
}
}
#[test]
fn stepped_grid_preserves_volume_and_surface_area() {
let n = 16;
let mut cells = Vec::new();
for x in 0..n {
for y in 0..n {
cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64));
}
}
check(cells, (n * n * n) as f64, (6 * n * n + 2 * n * (n - 1)) as f64);
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Local no-gouge certificates for the vertical height-field target solid.
//!
//! To bound distance to the solid's complement, choose an XY point q with a
//! conservative upper target height H(q). Every point p in a candidate removed
//! column has an exterior point (q, max(p.z,H(q))) at distance no more than
//! hypot(max_xy_distance(p,q), max(H(q)-column_low,0)). This covers whole cells,
//! not sampled surface points, and remains valid for overlapping target bodies.
use crate::{
spatial::Index,
verify::{Limits, Status},
*,
};
use solidcraft_geom::Vec3;
pub(crate) struct Surface<'a> {
triangles: &'a [[Vec3; 3]],
index: Index,
bottom: f64,
guard: f64,
}
pub(crate) struct Region {
pub center: [f64; 2],
pub half: f64,
pub low: f64,
pub high: f64,
pub target_high: f64,
pub tolerance: f64,
}
impl<'a> Surface<'a> {
pub(crate) fn new(triangles: &'a [[Vec3; 3]], bottom: f64) -> Result<Self> {
let extent = triangles.iter().flatten().map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs())).fold(1., f64::max);
let guard = 128. * f64::EPSILON * extent;
let items: Vec<_> = triangles
.iter()
.enumerate()
.filter(|(_, t)| (t[1] - t[0]).cross(t[2] - t[0]).z > 0.)
.map(|(id, t)| {
let min = t.iter().copied().fold(t[0], Vec3::min);
let max = t.iter().copied().fold(t[0], Vec3::max);
(id, [min.x - guard, min.y - guard], [max.x + guard, max.y + guard])
})
.collect();
if items.is_empty() {
return invalid("protected target has no upward height-field surface");
}
Ok(Self { triangles, index: Index::new(items), bottom, guard })
}
pub(crate) fn bounds(&self, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) {
let mut ids = Vec::new();
self.index.query([p[0] - half, p[1] - half], [p[0] + half, p[1] + half], &mut ids);
let local: Vec<_> = ids.into_iter().map(|i| self.triangles[i]).collect();
crate::verify::target_height(p, half, &local, self.bottom, work, budget)
}
async fn height<F: std::future::Future<Output = ()>>(
&self,
p: [f64; 2],
work: &mut usize,
limits: Limits,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Option<f64>> {
let mut ids = Vec::new();
self.index.query([p[0] - self.guard, p[1] - self.guard], [p[0] + self.guard, p[1] + self.guard], &mut ids);
let mut height = self.bottom;
for (count, id) in ids.into_iter().enumerate() {
if count % 4096 == 4095 {
yield_control().await;
if cancelled() {
return invalid("surface verification cancelled");
}
}
*work += 1;
if *work > limits.work {
return Ok(None);
}
let t = &self.triangles[id];
if let Some((_, upper)) = height_interval(t, p, self.guard) {
height = height.max(upper);
}
}
Ok(Some(height + self.guard))
}
pub(crate) async fn protect<F: std::future::Future<Output = ()>>(
&self,
r: Region,
work: &mut usize,
limits: Limits,
cancelled: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<Status> {
let tolerance = r.tolerance - POST_ERROR - VERIFY_ERROR - self.guard;
if tolerance > 0. && r.target_high + self.guard - r.low <= tolerance {
return Ok(Status::Pass);
}
if r.half < tolerance {
// The search locates a certificate. Failure to find one never proves
// a gouge and never becomes a clearance pass.
let reach = tolerance - r.half;
for scale in [0., 0.5, 0.95] {
for direction in 0..8 {
if scale == 0. && direction != 0 {
continue;
}
let angle = direction as f64 * std::f64::consts::FRAC_PI_4;
let offset = scale * reach;
let q = [r.center[0] + offset * angle.cos(), r.center[1] + offset * angle.sin()];
let Some(height) = self.height(q, work, limits, cancelled, yield_control).await? else {
return Ok(Status::Inconclusive);
};
// Include coordinate rounding in the actual computed offset.
let lateral = r.half + (q[0] - r.center[0]).hypot(q[1] - r.center[1]);
if lateral.hypot((height - r.low).max(0.)) + self.guard <= tolerance {
return Ok(Status::Pass);
}
}
}
}
// A positive failure certificate: a ball larger than the tolerance is
// contained in one target triangle's downward extrusion, while its center
// is definitely removed. No coverage sums or assumed mesh watertightness.
let radius = r.tolerance + POST_ERROR + VERIFY_ERROR + self.guard;
let z = (r.high + self.guard).max(self.bottom + 2. * radius);
if z - radius > self.bottom {
let mut ids = Vec::new();
self.index.query(r.center, r.center, &mut ids);
for (count, id) in ids.into_iter().enumerate() {
if count % 4096 == 4095 {
yield_control().await;
if cancelled() {
return invalid("surface verification cancelled");
}
}
*work += 1;
if *work > limits.work {
return Ok(Status::Inconclusive);
}
let t = &self.triangles[id];
let covered = [[-radius, -radius], [radius, -radius], [radius, radius], [-radius, radius]]
.iter()
.all(|d| height_interval(t, [r.center[0] + d[0], r.center[1] + d[1]], self.guard).is_some_and(|(lower, _)| lower > z + radius));
if covered {
return Ok(Status::Fail);
}
}
}
Ok(Status::Inconclusive)
}
}
// Upper is conservative even on a projected triangle edge. Lower is -infinity
// near edges or degenerate projections so it cannot certify an interior ball.
fn height_interval(t: &[Vec3; 3], p: [f64; 2], guard: f64) -> Option<(f64, f64)> {
let n = (t[1] - t[0]).cross(t[2] - t[0]);
if n.z <= 0. {
return None;
}
let mut near = false;
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let edge = (b.x - a.x) * (p[1] - a.y) - (b.y - a.y) * (p[0] - a.x);
let error = guard * (1. + (b.x - a.x).abs() + (b.y - a.y).abs());
if edge < -error {
return None;
}
near |= edge <= error;
}
let hi = t.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max);
if near || n.z <= guard * guard {
return Some((f64::NEG_INFINITY, hi + guard));
}
let z = t[0].z + (n.x * (t[0].x - p[0]) + n.y * (t[0].y - p[1])) / n.z;
let error = guard * (1. + (n.x / n.z).abs() + (n.y / n.z).abs());
if !z.is_finite() || !error.is_finite() {
return Some((f64::NEG_INFINITY, hi + guard));
}
Some((z - error, (z + error).min(hi + guard)))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn plate(lo: f64, hi: f64, z: f64) -> Vec<[Vec3; 3]> {
let a = Vec3::new(lo, lo, z);
let b = Vec3::new(hi, lo, z);
let c = Vec3::new(hi, hi, z);
let d = Vec3::new(lo, hi, z);
vec![[a, b, c], [a, c, d]]
}
fn protect(s: &Surface, r: Region, budget: usize) -> Status {
use std::future::Future;
let mut ready = || std::future::ready(());
let mut work = 0;
let mut future = std::pin::pin!(s.protect(r, &mut work, Limits { work: budget, ..Default::default() }, &|| false, &mut ready));
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
std::task::Poll::Ready(r) => r.unwrap(),
_ => Status::Inconclusive,
}
}
fn region(x: f64, half: f64, low: f64, high: f64) -> Region {
Region { center: [x, 1.], half, low, high, target_high: 5., tolerance: 0.01 }
}
#[test]
fn vertical_boundary_is_certified_but_interior_gouge_fails() {
let tris = plate(0., 2., 5.);
let s = Surface::new(&tris, 0.).unwrap();
assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1000), Status::Pass);
assert_eq!(protect(&s, region(0.1, 0.001, 4.9, 4.9), 1000), Status::Fail);
assert_eq!(protect(&s, region(0.001, 0.1, 1., 5.), 1000), Status::Inconclusive);
}
#[test]
fn overlapping_top_faces_do_not_create_false_exterior_points() {
let mut tris = plate(0., 2., 5.);
tris.extend(plate(-1., 3., 6.));
let s = Surface::new(&tris, 0.).unwrap();
let mut r = region(0.001, 0.001, 4.9, 4.9);
r.target_high = 6.;
assert_eq!(protect(&s, r, 1000), Status::Fail);
}
#[test]
fn budget_exhaustion_is_inconclusive() {
let tris = plate(0., 2., 5.);
let s = Surface::new(&tris, 0.).unwrap();
assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1), Status::Inconclusive);
}
#[test]
fn missing_upward_faces_are_rejected() {
assert!(Surface::new(&[[Vec3::ZERO, Vec3::X, Vec3::X]], 0.).is_err());
}
#[test]
fn shifted_coordinates_preserve_height_bounds() {
let tris = plate(999999., 1000001., 5.);
let s = Surface::new(&tris, 0.).unwrap();
let mut work = 0;
let (lo, hi, uncertain) = s.bounds([1000000., 1000000.], 0.001, &mut work, 1000);
assert!(!uncertain);
assert_eq!(lo, 5.);
assert_eq!(hi, 5.);
}
fn drilled_plate() -> JobInput {
corpus::fixture("drill").unwrap()
}
#[test]
fn protected_drilled_plate_certifies_with_its_wall_and_cone() {
let input = drilled_plate();
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.status, Status::Pass, "{:?}", report.findings);
assert_eq!(report.finished_dimensions, Status::Pass);
}
#[test]
fn wrong_hole_against_protected_cad_surface_is_a_gouge() {
let input = drilled_plate();
let path = generate::generate(input.clone()).unwrap();
let code = nc::post(&path, &input.setup).unwrap().replace("X10.000000", "X10.100000");
let parsed = nc::parse(&code, &input.tools).unwrap();
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
assert_eq!(report.status, Status::Fail, "{:?}", report.findings);
assert!(report.findings.iter().any(|f| f.kind == "gouge"));
}
#[test]
fn through_gouges_and_sloping_surfaces_use_spatial_distance() {
let mut tris = plate(0., 2., 5.);
let s = Surface::new(&tris, 0.).unwrap();
assert_eq!(protect(&s, region(0.1, 0.001, 0., 0.), 1000), Status::Fail);
for t in &mut tris {
for p in t {
p.z = 5. + 10. * p.x;
}
}
let s = Surface::new(&tris, 0.).unwrap();
let mut r = region(0.1, 0.001, 5.97, 5.97);
r.target_high = 6.02;
assert_eq!(protect(&s, r, 1000), Status::Pass);
let mut r = region(0.1, 0.001, 5.8, 5.8);
r.target_high = 6.02;
assert_eq!(protect(&s, r, 1000), Status::Fail);
}
#[test]
fn surface_queries_yield_and_cancel() {
use std::{
future::Future,
task::{Context, Poll, Waker},
};
let tris: Vec<_> = (0..5000).flat_map(|_| plate(0., 2., 5.)).collect();
let s = Surface::new(&tris, 0.).unwrap();
let mut work = 0;
let cancel = std::cell::Cell::new(false);
let cancelled = || cancel.get();
let mut yielding = || {
let mut first = true;
std::future::poll_fn(move |_| {
if first {
first = false;
Poll::Pending
} else {
Poll::Ready(())
}
})
};
let mut future = std::pin::pin!(s.protect(region(0.1, 0.001, 4.9, 4.9), &mut work, Default::default(), &cancelled, &mut yielding));
let mut context = Context::from_waker(Waker::noop());
assert!(future.as_mut().poll(&mut context).is_pending());
cancel.set(true);
assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_))));
}
}
+644
View File
@@ -0,0 +1,644 @@
//! Independent bounded stock verifier. Adaptive cells carry height intervals;
//! unresolved intervals and exhausted work budgets never become a pass.
use crate::{
nc::{Parsed, Segment},
*,
};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Pass,
Fail,
Inconclusive,
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Information,
#[default]
Unresolved,
Error,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Finding {
#[serde(default)]
pub severity: FindingSeverity,
pub kind: String,
pub line: Option<usize>,
/// One-based interpreted NC record, not a tessellated arc segment.
#[serde(default)]
pub record: Option<usize>,
/// Present only for an independently checked SolidCraft post round trip.
#[serde(default)]
pub source: Option<nc::SourceLocation>,
pub at: Vec3,
pub detail: String,
}
impl Finding {
fn priority(&self) -> (FindingSeverity, bool) {
(self.severity, self.line.is_none())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Cell {
pub min: [f64; 2],
pub max: [f64; 2],
pub low: f64,
pub high: f64,
pub target_low: f64,
pub target_high: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Report {
pub version: String,
pub fingerprint: String,
pub status: Status,
/// Separate from path clearance. Unsupported finished surfaces stay inconclusive.
pub finished_dimensions: Status,
pub post_version: String,
pub tolerance: f64,
/// Requested spatial refinement budget, not a claim about height uncertainty.
pub uncertainty: f64,
pub maximum_height_interval: f64,
/// False when resource limits prevented evaluating all stock cells/motions.
#[serde(default)]
pub stock_complete: bool,
/// Comparison domain. Legacy reports without this cannot certify coverage.
#[serde(default)]
pub stock: Option<Stock>,
pub cells: Vec<Cell>,
pub findings: Vec<Finding>,
pub cutting_distance: f64,
pub rapid_distance: f64,
pub cutting_minutes: f64,
pub remaining_volume_low: f64,
pub remaining_volume_high: f64,
pub work: usize,
}
impl Report {
/// Bounded representative witnesses. Repeated cells on one NC line must not
/// crowd out a definite failure found later in the verification traversal.
pub(crate) fn finding(&mut self, finding: Finding) {
if self.findings.iter().any(|old| old.kind == finding.kind && old.line == finding.line && old.severity == finding.severity) {
return;
}
if self.findings.len() < 200 {
self.findings.push(finding);
} else if let Some((index, old)) = self.findings.iter().enumerate().min_by_key(|(_, old)| old.priority())
&& old.priority() < finding.priority()
&& let Some(slot) = self.findings.get_mut(index)
{
*slot = finding;
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Limits {
pub cells: usize,
pub work: usize,
pub uncertainty: f64,
}
impl Default for Limits {
fn default() -> Self {
Self { cells: 250_000, work: 30_000_000, uncertainty: VERIFY_ERROR }
}
}
pub fn simulate(input: &JobInput, nc: &Parsed, limits: Limits) -> Result<Report> {
simulate_cancellable(input, nc, limits, || false)
}
pub fn simulate_cancellable(input: &JobInput, nc: &Parsed, limits: Limits, cancelled: impl Fn() -> bool) -> Result<Report> {
use std::future::Future;
let mut future = std::pin::pin!(simulate_cooperative(input, nc, limits, cancelled, || std::future::ready(())));
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
std::task::Poll::Ready(result) => result,
std::task::Poll::Pending => invalid("synchronous verifier unexpectedly yielded"),
}
}
/// Portable host-driven verification. A host supplies a yield future to bound a poll's work.
/// This contains no executor, OS thread, browser or generator dependency.
pub async fn simulate_cooperative<F: std::future::Future<Output = ()>>(
input: &JobInput,
nc: &Parsed,
limits: Limits,
cancelled: impl Fn() -> bool,
mut yield_control: impl FnMut() -> F,
) -> Result<Report> {
input.validate()?;
if limits.cells == 0
|| limits.cells > 1_000_000
|| limits.work == 0
|| limits.work > 100_000_000
|| !limits.uncertainty.is_finite()
|| limits.uncertainty <= 0.0
|| limits.uncertainty > VERIFY_ERROR
{
return invalid("invalid verifier limits");
}
if nc.work_offset != input.setup.work_offset {
return invalid("NC/setup work-offset mismatch");
}
if input.geometry.target.is_empty() {
return invalid("no target geometry to verify");
}
if input.geometry.target.len() + input.geometry.fixtures.len() > 200_000 {
return invalid("verification mesh limit");
}
for t in input.geometry.target.iter().chain(&input.geometry.fixtures) {
for p in t {
crate::model::point(*p)?;
}
}
let segments = nc::segments(nc, &input.setup)?;
let tol = input.operations.iter().map(|o| o.tolerance).fold(TOLERANCE, f64::min);
let stock = input.setup.stock;
// Only motions whose cutter or non-cutting envelopes can reach stock need
// cell queries. Keep original sequence IDs for machine-state/withdrawal checks.
let motion_index = crate::spatial::Index::new(
segments
.iter()
.enumerate()
.filter_map(|(id, s)| {
let tool = input.tools.iter().find(|t| t.id == s.tool)?;
let z = s.from.z.min(s.to.z);
let mut radius = if z <= stock.max.z + POST_ERROR { tool.diameter / 2. + POST_ERROR } else { 0. };
for (r, lift) in [(tool.shank_diameter / 2., tool.cutting_length), (tool.holder_diameter / 2., tool.overall_length)] {
if z + lift <= stock.max.z + POST_ERROR {
radius = radius.max(r + POST_ERROR);
}
}
if radius == 0. {
return None;
}
Some((
id,
[s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius],
[s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius],
))
})
.collect(),
);
let surface = crate::surface::Surface::new(&input.geometry.target, stock.min.z)?;
let finished = crate::dimensions::Finished::new(input, tol)?;
let dimension_resolution = finished.as_ref().map_or(limits.uncertainty, |f| limits.uncertainty.min(f.refinement));
let mut r = Report {
version: VERSION.into(),
fingerprint: input.fingerprint.clone(),
status: Status::Pass,
finished_dimensions: if finished.is_some() { Status::Pass } else { Status::Inconclusive },
post_version: "reference-three-axis/1".into(),
tolerance: tol,
uncertainty: limits.uncertainty,
maximum_height_interval: 0.0,
stock_complete: true,
stock: Some(stock),
cells: Vec::new(),
findings: Vec::new(),
cutting_distance: 0.0,
rapid_distance: 0.0,
cutting_minutes: 0.0,
remaining_volume_low: 0.0,
remaining_volume_high: 0.0,
work: 0,
};
if finished.is_none() {
r.finding(Finding {
record: None,
severity: FindingSeverity::Unresolved,
source: None,
kind: "finished_region_unsupported".into(),
line: None,
at: stock.min,
detail: "Finished-region certification needs a tolerance larger than the post/interpolation error budget".into(),
});
}
for s in &segments {
let d = s.from.dist(s.to);
if s.rapid {
r.rapid_distance += d;
} else {
r.cutting_distance += d;
r.cutting_minutes += d / s.feed;
}
}
crate::collision::fixtures(input, &segments, limits, &cancelled, &mut yield_control, &mut r).await?;
let mut pending = vec![([stock.min.x, stock.min.y], [stock.max.x, stock.max.y], 0u8)];
let mut next_yield = 0;
while let Some((min, max, depth)) = pending.pop() {
if r.work >= next_yield {
next_yield = r.work.saturating_add(4096);
yield_control().await;
}
if cancelled() {
return invalid("simulation cancelled");
}
if r.work > limits.work || r.cells.len() + pending.len() >= limits.cells {
r.stock_complete = false;
r.status = if r.status == Status::Fail { Status::Fail } else { Status::Inconclusive };
r.finding(Finding {
record: None,
severity: FindingSeverity::Unresolved,
source: None,
kind: "budget_exhausted".into(),
line: None,
at: stock.min,
detail: "Adaptive verification incomplete; increase resource budget or simplify the job".into(),
});
break;
}
let center = [(min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0];
let half = ((max[0] - min[0]).hypot(max[1] - min[1])) / 2.0;
let mut low = stock.max.z;
let mut high = stock.max.z;
let mut collision = None;
let mut removal_line = None;
let mut candidates = Vec::new();
motion_index.query(min, max, &mut candidates);
candidates.sort_unstable();
for (query_index, segment_index) in candidates.into_iter().enumerate() {
let seg = &segments[segment_index];
r.work += 1;
if query_index % 4096 == 4095 {
yield_control().await;
if cancelled() {
return invalid("simulation cancelled");
}
}
if r.work > limits.work {
break;
}
let Some(tool) = input.tools.iter().find(|t| t.id == seg.tool) else {
continue;
};
let radius = tool.diameter / 2.0;
let (distance, zlo, zhi) = sweep(center, half, seg, radius);
// A vertical drill has exact radial extrema on a rectangular cell.
// Circumscribed disks are unnecessarily loose here and can create
// artificial saddles where coarse and fine cone cells meet.
let (near, far) = if tool.kind == ToolKind::TwistDrill && seg.from.x == seg.to.x && seg.from.y == seg.to.y {
radial_bounds(min, max, [seg.from.x, seg.from.y])
} else if seg.from.z == seg.to.z || (seg.from.x == seg.to.x && seg.from.y == seg.to.y) {
lateral_bounds(min, max, seg)
} else {
// Sloping motion still uses a common covering disk: its upper
// height bound must cover the entire cell at the same pose.
((distance - half).max(0.0), distance + half)
};
let cone_low = if tool.kind == ToolKind::TwistDrill { near / (tool.tip_angle / 2.0).tan() } else { 0.0 };
let cone_high = if tool.kind == ToolKind::TwistDrill { far / (tool.tip_angle / 2.0).tan() } else { 0.0 };
let overlaps = near <= radius + POST_ERROR;
// A vertical withdrawal starts at the endpoint of the preceding feed
// with the same tool; this exact geometric fact needs no raster estimate.
let withdrawal = segment_index.checked_sub(1).and_then(|i| segments.get(i)).is_some_and(|previous| {
!previous.rapid
&& previous.tool == seg.tool
&& previous.to == seg.from
&& seg.from.x == seg.to.x
&& seg.from.y == seg.to.y
&& seg.to.z >= seg.from.z
});
if seg.rapid && overlaps && !withdrawal {
if zhi + cone_high < low - tol && far <= radius {
collision = Some(("rapid_through_stock", seg.line));
} else if zlo + cone_low < high - tol {
collision = Some(("rapid_clearance_unresolved", seg.line));
}
} else if !seg.rapid && overlaps {
low = low.min(zlo + cone_low).max(stock.min.z);
if far <= radius {
let next = high.min(zhi + cone_high).max(stock.min.z);
if next < high {
removal_line = Some(seg.line);
}
high = next;
}
}
// Shank and holder sweeps against remaining stock, separate from cutter removal.
for (rad, lift, label) in [
(tool.shank_diameter / 2.0, tool.cutting_length, "shank_collision"),
(tool.holder_diameter / 2.0, tool.overall_length, "holder_collision"),
] {
let (d, z, _) = sweep(center, half, seg, rad);
if d + half < rad && z + lift < low - tol {
collision = Some((label, seg.line));
} else if d < rad + half && z + lift < high - tol {
collision = Some(("holder_clearance_unresolved", seg.line));
}
}
}
let (target_low, target_high, _) = surface.bounds(center, (max[0] - min[0]).max(max[1] - min[1]) / 2.0, &mut r.work, limits.work);
let protection = surface
.protect(
crate::surface::Region { center, half, low, high, target_high, tolerance: tol },
&mut r.work,
limits,
&cancelled,
&mut yield_control,
)
.await?;
let rest = low - target_high > tol;
let expected =
if let Some(f) = &finished { f.envelope((center, half), &mut r.work, limits.work, &cancelled, &mut yield_control).await? } else { None };
let dimension = expected.map(|e| e.classify(low, high));
// Refine both the machined result and the requested finished features:
// an omitted hole must not hide inside one large untouched stock cell.
let unresolved = ((high - low > tol / 2.0 || protection == Status::Inconclusive || collision.is_some()) && half > limits.uncertainty)
|| (dimension == Some(Status::Inconclusive) && half > dimension_resolution);
if unresolved && depth < 24 && r.work <= limits.work {
pending.extend([
(min, center, depth + 1),
([center[0], min[1]], [max[0], center[1]], depth + 1),
([min[0], center[1]], [center[0], max[1]], depth + 1),
(center, max, depth + 1),
]);
continue;
}
let at = Vec3::new(center[0], center[1], low);
if let Some(dimension) = dimension {
if dimension == Status::Fail {
r.finished_dimensions = Status::Fail;
r.finding(Finding {
record: None,
severity: FindingSeverity::Error,
source: None,
kind: "finished_region_error".into(),
line: removal_line,
at,
detail: expected.map_or_else(String::new, |e| e.description()),
});
} else if dimension == Status::Inconclusive && r.finished_dimensions == Status::Pass {
r.finished_dimensions = Status::Inconclusive;
}
}
if let Some((kind, line)) = collision {
if !kind.ends_with("unresolved") {
r.status = Status::Fail;
} else if r.status == Status::Pass {
r.status = Status::Inconclusive;
}
r.finding(Finding {
record: None,
severity: if kind.ends_with("unresolved") { FindingSeverity::Unresolved } else { FindingSeverity::Error },
source: None,
kind: kind.into(),
line: Some(line),
at,
detail: "Swept cutter or non-cutting tool envelope intersects remaining stock".into(),
});
}
if protection == Status::Fail {
r.status = Status::Fail;
r.finding(Finding {
record: None,
severity: FindingSeverity::Error,
source: None,
kind: "gouge".into(),
line: removal_line,
at,
detail: format!("A definitely removed point lies more than {:.6} mm inside the protected height-field solid", tol),
});
}
// Rest is reported separately: intermediate roughing is not required to finish a part.
if rest && !r.findings.iter().any(|f| f.kind == "remaining_material") {
r.finding(Finding {
record: None,
severity: FindingSeverity::Information,
source: None,
kind: "remaining_material".into(),
line: None,
at,
detail: format!("At least {:.6} mm above target; may be intentional outside selected finished regions", low - target_high),
});
}
if protection == Status::Inconclusive {
if r.status == Status::Pass {
r.status = Status::Inconclusive;
}
r.finding(Finding {
record: None,
severity: FindingSeverity::Unresolved,
source: None,
kind: "protected_surface_unresolved".into(),
line: removal_line,
at,
detail: "Local surface-distance bounds cannot yet certify the protected CAD boundary at this resolution or work budget".into(),
});
}
let area = (max[0] - min[0]) * (max[1] - min[1]);
r.remaining_volume_low += area * (low - stock.min.z);
r.remaining_volume_high += area * (high - stock.min.z);
r.maximum_height_interval = r.maximum_height_interval.max(high - low);
r.cells.push(Cell { min, max, low, high, target_low, target_high });
}
if r.work > limits.work {
r.stock_complete = false;
if r.status == Status::Pass {
r.status = Status::Inconclusive;
}
}
if (!r.stock_complete || r.status != Status::Pass) && r.finished_dimensions == Status::Pass {
r.finished_dimensions = Status::Inconclusive;
}
r.findings.sort_by_key(|finding| std::cmp::Reverse(finding.priority()));
for finding in &mut r.findings {
if let Some(line) = finding.line {
finding.record = nc.record_for_line(line).map(|i| i + 1);
finding.source = nc.source_for_line(line);
}
}
Ok(r)
}
/// Distance to XY sweep and conservative height range over the part of a segment
/// intersecting a disk expanded by the cell radius.
fn sweep(p: [f64; 2], half: f64, s: &Segment, radius: f64) -> (f64, f64, f64) {
let dx = s.to.x - s.from.x;
let dy = s.to.y - s.from.y;
let l2 = dx * dx + dy * dy;
let t = if l2 > 1e-20 { ((p[0] - s.from.x) * dx + (p[1] - s.from.y) * dy) / l2 } else { 0.0 };
let c = t.clamp(0.0, 1.0);
let d = (p[0] - s.from.x - c * dx).hypot(p[1] - s.from.y - c * dy);
let minimum = |r: f64| {
if l2 <= 1e-20 {
return s.from.z.min(s.to.z);
}
let perpendicular = ((p[0] - s.from.x).powi(2) + (p[1] - s.from.y).powi(2) - t * t * l2).max(0.0);
let dt = ((r * r - perpendicular).max(0.0) / l2).sqrt();
let z0 = s.from.z + (s.to.z - s.from.z) * (t - dt).clamp(0.0, 1.0);
let z1 = s.from.z + (s.to.z - s.from.z) * (t + dt).clamp(0.0, 1.0);
z0.min(z1)
};
(d, minimum(radius + half), minimum((radius - half).max(0.0)))
}
fn radial_bounds(min: [f64; 2], max: [f64; 2], center: [f64; 2]) -> (f64, f64) {
let axis = |i: usize| {
let near = (min[i] - center[i]).max(center[i] - max[i]).max(0.0);
let far = (min[i] - center[i]).abs().max((max[i] - center[i]).abs());
(near, far)
};
let (x, y) = (axis(0), axis(1));
(x.0.hypot(y.0), x.1.hypot(y.1))
}
/// Conservative rectangle-to-segment distance bounds. The segment AABB gives
/// an additional lower bound, exact for axis-aligned sweeps. Distance to a
/// convex segment is convex, so its maximum over a rectangle is attained at a
/// corner. Circumscribed disks alone invent overlap along long straight walls
/// and force unnecessary refinement of already resolved stock.
fn lateral_bounds(min: [f64; 2], max: [f64; 2], s: &Segment) -> (f64, f64) {
let a = [s.from.x, s.from.y];
let b = [s.to.x, s.to.y];
let delta = [b[0] - a[0], b[1] - a[1]];
let length2 = delta[0] * delta[0] + delta[1] * delta[1];
let gap = |k: usize| (min[k] - a[k].max(b[k])).max(a[k].min(b[k]) - max[k]).max(0.);
let distance = |p: [f64; 2]| {
let t = if length2 > 0. { (((p[0] - a[0]) * delta[0] + (p[1] - a[1]) * delta[1]) / length2).clamp(0., 1.) } else { 0. };
(p[0] - a[0] - t * delta[0]).hypot(p[1] - a[1] - t * delta[1])
};
let mut far = 0_f64;
for p in [min, [min[0], max[1]], max, [max[0], min[1]]] {
far = far.max(distance(p));
}
let center_distance = distance([(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]);
let half = (max[0] - min[0]).hypot(max[1] - min[1]) / 2.;
let extent = min.into_iter().chain(max).chain(a).chain(b).map(f64::abs).fold(1_f64, f64::max);
let guard = 128. * f64::EPSILON * extent;
(((center_distance - half).max(gap(0).hypot(gap(1))) - guard).max(0.), far + guard)
}
pub(crate) fn target_height(p: [f64; 2], half: f64, tris: &[[Vec3; 3]], bottom: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) {
let mut lo = f64::INFINITY;
let mut hi = bottom;
let mut area = 0.0;
for tri in tris {
*work += 1;
if *work > budget {
return (bottom, hi, true);
}
let [a, b, c] = *tri;
// Positive-Z faces form the top envelope of supported height-field solids.
if (b - a).cross(c - a).z <= 0.0 {
continue;
}
let mut poly = tri.to_vec();
for (axis, bound, greater) in [(0, p[0] - half, true), (0, p[0] + half, false), (1, p[1] - half, true), (1, p[1] + half, false)] {
let old = std::mem::take(&mut poly);
if old.is_empty() {
break;
}
let value = |v: Vec3| if axis == 0 { v.x } else { v.y };
let inside = |v: Vec3| if greater { value(v) >= bound } else { value(v) <= bound };
for (a, b) in old.iter().zip(old.iter().cycle().skip(1)).take(old.len()) {
if inside(*a) {
poly.push(*a);
}
if inside(*a) != inside(*b) {
let t = (bound - value(*a)) / (value(*b) - value(*a));
poly.push(*a + (*b - *a) * t);
}
}
}
if poly.len() < 3 {
continue;
}
let origin = poly[0];
let covered =
poly.iter().zip(poly.iter().cycle().skip(1)).take(poly.len()).map(|(a, b)| (*a - origin).cross(*b - origin).z).sum::<f64>().abs() / 2.0;
if covered <= 0.0 {
continue;
}
area += covered;
for v in poly {
lo = lo.min(v.z);
hi = hi.max(v.z);
}
}
if area == 0.0 {
return (bottom, bottom, false);
}
let complete = area >= 4.0 * half * half * (1.0 - 1e-8);
(if complete { lo } else { bottom }, hi, !complete)
}
/// Export a conforming conservative stock boundary. Tiny saddle closure may add
/// at most `STOCK_EXPORT_ERROR` to a cell height; it never removes stock.
pub fn stock_stl(report: &Report, bottom: f64) -> Result<String> {
crate::stock_mesh::stl(report, bottom)
}
/// Incremental extraction and ASCII serialization. Cancellation returns no partial artifact.
pub async fn stock_stl_cooperative<F: std::future::Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<String> {
crate::stock_mesh::stl_cooperative(report, bottom, cancelled, yield_control).await
}
/// The same conforming conservative boundary as `stock_stl`, without serialization.
/// Coordinates are in the setup frame. An empty mesh represents fully removed stock.
pub fn stock_triangles(report: &Report, bottom: f64) -> Result<Vec<[Vec3; 3]>> {
crate::stock_mesh::triangles(report, bottom)
}
/// Cancellation-aware boundary extraction, yielding during sorting, stitching,
/// triangulation and manifold checks. No partial mesh is returned.
pub async fn stock_triangles_cooperative<F: std::future::Future<Output = ()>>(
report: &Report,
bottom: f64,
cancelled: impl Fn() -> bool,
yield_control: impl FnMut() -> F,
) -> Result<Vec<[Vec3; 3]>> {
crate::stock_mesh::triangles_cooperative(report, bottom, cancelled, yield_control).await
}
/// Independently evaluate a point on the upper stock surface. Used for analytic
/// acceptance probes, never as a substitute for whole-region verification.
pub fn probe_height(input: &JobInput, nc: &Parsed, p: [f64; 2]) -> Result<f64> {
let mut z = input.setup.stock.max.z;
for s in nc::segments(nc, &input.setup)? {
if s.rapid {
continue;
}
let t = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| Error("unknown probe tool".into()))?;
let (d, low, _) = sweep(p, 0.0, &s, t.diameter / 2.0);
if d <= t.diameter / 2.0 {
let point = if t.kind == ToolKind::TwistDrill { d / (t.tip_angle / 2.0).tan() } else { 0.0 };
z = z.min(low + point);
}
}
Ok(z.max(input.setup.stock.min.z))
}
#[cfg(test)]
mod radial_tests {
use super::*;
#[test]
fn rectangle_radial_extrema_include_corners_and_interior_axis() {
assert_eq!(super::radial_bounds([3., 4.], [6., 8.], [0., 0.]), (5., 10.));
assert_eq!(super::radial_bounds([-3., -4.], [3., 4.], [0., 0.]), (0., 5.));
assert_eq!(super::radial_bounds([7., -2.], [10., 6.], [10., 2.]), (0., 5.));
}
#[test]
fn rectangle_sweep_bounds_cover_axis_diagonal_endcaps_and_translations() {
for offset in [0., 1_000_000.] {
let point = |x, y| Vec3::new(x + offset, y + offset, 0.);
for (a, b, min, max, expected) in [
([0., 0.], [10., 0.], [2., 3.], [4., 5.], (3., 5.)),
([0., 0.], [0., 10.], [3., 2.], [5., 4.], (3., 5.)),
([0., 0.], [0., 0.], [3., 4.], [6., 8.], (5., 10.)),
([0., 0.], [1., 0.], [4., 4.], [7., 8.], (5., 10.)),
([0., 0.], [10., 10.], [4., 4.], [6., 6.], (0., 2_f64.sqrt())),
] {
let s = Segment { from: point(a[0], a[1]), to: point(b[0], b[1]), rapid: false, feed: 100., tool: 1, line: 1 };
let (low, high) = lateral_bounds(min.map(|x| x + offset), max.map(|x| x + offset), &s);
assert!(low <= expected.0 && high >= expected.1, "{low} {high} {expected:?}");
assert!(expected.0 - low < 1e-6 && high - expected.1 < 1e-6);
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Hand-authored modal programs exercise NIST machine state independently of generation.
use solidcraft_cam::{Motion, corpus, generate, nc};
const HEADER: &str = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000 M3\nM8\n";
#[test]
fn tool_change_stops_spindle_preserves_coolant_and_requires_restart() {
let mut input = corpus::fixture("face").unwrap();
let mut second = input.tools[0].clone();
second.id = 2;
second.number = 2;
input.tools.push(second);
for number in [1, 2] {
let source = format!("{HEADER}T{number} M6\nS7000\nG1 X1 F100\nM30");
let parsed = nc::parse(&source, &input.tools).unwrap();
let change: Vec<_> = parsed.records.iter().filter(|r| r.line == 6).map(|r| &r.motion).collect();
assert_eq!(change, [&Motion::Spindle { rpm: 0. }, &Motion::Tool { tool: number as u64, number }]);
assert!(parsed.records.iter().all(|r| r.line != 7), "S alone cannot restart after M6");
assert_eq!(parsed.records.last().unwrap().motion, Motion::Coolant { on: false }, "M6 must preserve coolant until M30");
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 8"));
let restarted = source.replace("S7000\n", "M3\n");
let parsed = nc::parse(&restarted, &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 7).unwrap().motion, Motion::Spindle { rpm: 6000. });
let segments = nc::segments(&parsed, &input.setup).unwrap();
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].tool, number as u64);
assert_eq!(segments[0].to.x, 1.);
}
}
#[test]
fn same_block_speed_and_coolant_commands_keep_both_effects() {
let input = corpus::fixture("face").unwrap();
for coolant in ["M8", "M9", "M0"] {
let source = format!("{HEADER}S0 {coolant}\nG1 X1 F100\nM30");
let parsed = nc::parse(&source, &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 7"));
let source = source.replace("S0", "S12000");
let parsed = nc::parse(&source, &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 12000. });
assert_eq!(nc::segments(&parsed, &input.setup).unwrap().len(), 1);
}
let parsed = nc::parse(&format!("{HEADER}S0 G1 X1 F100\nM30"), &input.tools).unwrap();
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 6"));
}
#[test]
fn reference_post_initializes_state_before_pose_and_keeps_block_provenance() {
let input = corpus::fixture("face").unwrap();
let path = generate::generate(input.clone()).unwrap();
let code = nc::post(&path, &input.setup).unwrap();
let lines: Vec<_> = code.lines().filter(|l| !l.starts_with('(')).collect();
assert_eq!(lines[0], "G21 G90 G17 G91.1 G94 G54 G40 G49 G80");
assert_eq!(&lines[1..3], ["M5", "M9"]);
assert!(lines[3].starts_with("G0 X0.000000 Y0.000000 Z15.000000"));
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
assert_eq!(parsed.records.len(), path.blocks.len() + 3);
for record in &parsed.records[..3] {
assert!(parsed.source_for_line(record.line).is_none());
}
for (i, record) in parsed.records[3..].iter().enumerate() {
let source = parsed.source_for_line(record.line).unwrap();
assert_eq!(source.block, i + 1);
assert_eq!(source.operation, path.blocks[i].operation);
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Independent direction checks for a right-handed cutter with M3 spindle rotation.
//! A climb cut keeps the material being finished to the right of tool travel.
use solidcraft_cam::{Boundary, JobInput, Strategy, corpus, generate, nc};
fn segments(input: &JobInput) -> Vec<nc::Segment> {
let path = generate::generate(input.clone()).unwrap();
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
nc::segments(&parsed, &input.setup).unwrap()
}
fn square(lo: f64, hi: f64, reversed: bool) -> Vec<[f64; 2]> {
let mut ring = vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]];
if reversed {
ring.reverse();
}
ring
}
// Collect continuous constant-depth cuts from the independently interpreted NC.
// Signed polygon area checks orientation without calling the offset adapter.
fn loops(input: &JobInput) -> Vec<Vec<nc::Segment>> {
let mut result = Vec::new();
let mut current = Vec::new();
for s in segments(input) {
if !s.rapid && (s.from.z - 5.).abs() < 1e-8 && (s.to.z - 5.).abs() < 1e-8 {
current.push(s);
} else if !current.is_empty() {
result.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
result.push(current);
}
result
}
fn ccw(path: &[nc::Segment]) -> bool {
let origin = path[0].from;
assert!((path.last().unwrap().to - origin).len() < 1e-5, "cut must close");
let area = path
.iter()
.map(|s| {
let a = s.from - origin;
let b = s.to - origin;
a.x * b.y - b.x * a.y
})
.sum::<f64>();
assert!(area.abs() > 1.);
area > 0.
}
#[test]
fn facing_direction_matches_uncut_side_at_every_depth() {
for climb in [false, true] {
let mut input = corpus::fixture("face").unwrap();
input.operations[0].climb = climb;
input.operations[0].stepdown = 0.5;
let cuts: Vec<_> = segments(&input).into_iter().filter(|s| !s.rapid && (s.from.z - s.to.z).abs() < 1e-8 && s.to.z < 6.).collect();
assert_eq!(cuts.len(), 22);
for depth in cuts.as_chunks::<11>().0 {
assert!(depth.windows(2).all(|pair| pair[1].from.y > pair[0].from.y));
// Advancing rows toward +Y leaves uncut material north of the tool.
// Right of a -X move is +Y; right of a +X move is -Y.
assert!(depth.iter().all(|s| (s.to.x < s.from.x) == climb));
}
}
}
#[test]
fn contour_direction_matches_inside_and_outside_material() {
for reversed in [false, true] {
for inside in [false, true] {
for climb in [false, true] {
let mut input = corpus::fixture("face").unwrap();
input.tools[0].diameter = 2.;
input.operations[0].climb = climb;
input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: square(4., 16., reversed), islands: vec![] }, inside };
let paths = loops(&input);
assert_eq!(paths.len(), 1);
// Pocket wall: material outside => CCW climb. Boss: inside => CW climb.
assert_eq!(ccw(&paths[0]), inside == climb, "inside={inside}, climb={climb}, reversed={reversed}");
}
}
}
}
#[test]
fn pocket_direction_preserves_opposite_outer_and_island_winding() {
for reversed in [false, true] {
for climb in [false, true] {
let mut input = corpus::fixture("face").unwrap();
input.tools[0].diameter = 2.;
input.operations[0].climb = climb;
input.operations[0].geometry =
Strategy::Pocket { boundary: Boundary { outer: square(0., 20., reversed), islands: vec![square(4., 16., reversed)] } };
let paths = loops(&input);
assert_eq!(paths.len(), 2);
let mut outer = 0;
let mut island = 0;
for path in &paths {
let is_outer = path.iter().any(|s| s.from.x < 2.);
if is_outer {
outer += 1;
} else {
island += 1;
}
assert_eq!(ccw(path), is_outer == climb, "outer={is_outer}, climb={climb}, reversed={reversed}");
}
assert_eq!((outer, island), (1, 1));
}
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Original programs exercise cooperative NC processing and its exact boundary limits.
use solidcraft_cam::{self as cam, Block, Motion, Toolpath, nc};
use solidcraft_geom::Vec3;
use std::{
cell::Cell,
future::Future,
task::{Context, Poll, Waker},
};
fn yield_once() -> impl Future<Output = ()> {
let mut first = true;
std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) })
}
fn drive<T>(future: impl Future<Output = cam::Result<T>>) -> (cam::Result<T>, usize) {
let mut future = std::pin::pin!(future);
let mut polls = 0;
loop {
polls += 1;
assert!(polls < 20_000, "unbounded checkpoint count");
if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
return (result, polls);
}
}
}
fn path(count: usize) -> Toolpath {
let mut blocks = vec![
Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } },
Block { operation: 1, motion: Motion::Spindle { rpm: 6000. } },
Block { operation: 1, motion: Motion::Coolant { on: true } },
];
for i in 0..count - 6 {
blocks.push(Block {
operation: if i < count / 2 { 1 } else { 2 },
motion: Motion::Linear { to: Vec3::new(if i % 2 == 0 { 1. } else { 19. }, 10., 5.), feed: 100. },
});
}
blocks.extend([
Block { operation: 2, motion: Motion::Coolant { on: false } },
Block { operation: 2, motion: Motion::Spindle { rpm: 0. } },
Block { operation: 2, motion: Motion::Stop },
]);
Toolpath { fingerprint: "original-independent-test".into(), blocks }
}
#[test]
fn maximum_path_includes_startup_records_and_excess_is_rejected() {
let input = cam::corpus::fixture("face").unwrap();
let mut path = path(cam::MAX_MOVES);
let (parsed, polls) = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once));
let parsed = parsed.unwrap();
assert_eq!(parsed.records.len(), cam::MAX_MOVES + 3);
assert!(polls > 3000);
let last = parsed.records.last().unwrap();
let source = parsed.source_for_line(last.line).unwrap();
assert_eq!((source.block, source.operation), (cam::MAX_MOVES, 2));
path.blocks.push(path.blocks.last().unwrap().clone());
assert!(nc::post(&path, &input.setup).is_err());
// External NC records remain bounded independently of the posting limit.
let mut external = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\n".to_owned();
external.push_str(&"G0 Z15\n".repeat(cam::MAX_MOVES + 3));
external.push_str("M30\n");
assert!(nc::parse(&external, &input.tools).unwrap_err().to_string().contains("NC motion limit"));
}
#[test]
fn synchronous_and_cooperative_processing_keep_bytes_state_and_provenance() {
let input = cam::corpus::fixture("face").unwrap();
let path = path(1024);
let source = nc::post(&path, &input.setup).unwrap();
assert!(source.starts_with("(solidcraft-reference-3axis/2; not a qualified machine post)\n(Fingerprint original-independent-test)\nG21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\nG0 X0.000000 Y0.000000 Z15.000000\n(Operation 1)\nT1 M6\n"));
assert_eq!(drive(nc::post_cooperative(&path, &input.setup, || false, yield_once)).0.unwrap(), source);
let synchronous = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
let cooperative = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once)).0.unwrap();
assert_eq!(serde_json::to_value(&synchronous).unwrap(), serde_json::to_value(&cooperative).unwrap());
for record in &synchronous.records {
assert_eq!(synchronous.source_for_line(record.line), cooperative.source_for_line(record.line));
}
let broken = source.replace("(Operation 2)", "G123");
let ordinary = nc::parse(&broken, &input.tools).unwrap_err();
let incremental = drive(nc::parse_cooperative(&broken, &input.tools, || false, yield_once)).0.unwrap_err();
assert_eq!(ordinary.to_string(), incremental.to_string());
assert!(incremental.to_string().contains("unsupported G code"));
}
#[test]
fn cancellation_during_each_stage_returns_no_partial_program_or_line_mapping() {
let input = cam::corpus::fixture("face").unwrap();
let path = path(1024);
let count = Cell::new(0);
let full = drive(nc::round_trip_cooperative(
&path,
&input.setup,
&input.tools,
|| false,
|| {
count.set(count.get() + 1);
yield_once()
},
))
.0
.unwrap();
assert_eq!(full.records.len(), 1027);
// Every checkpoint, including post, reader and correspondence stages.
for stop in 0..=count.get() {
let at = Cell::new(0);
let result = drive(nc::round_trip_cooperative(
&path,
&input.setup,
&input.tools,
|| at.get() >= stop,
|| {
at.set(at.get() + 1);
yield_once()
},
))
.0;
assert!(result.unwrap_err().to_string().contains("cancelled"), "checkpoint {stop}");
}
}