Add three-axis milling with independent CAM and Fusion validation (#1682)
Thanks @CypherNaught-0x.
This commit is contained in:
1 parent
dd5278a402
commit
737c9358d0
85 files changed
+14487
-43
No files matched your search
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "solidcraft-cam"
|
||||
description = "Portable milling toolpaths and independent NC verification"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
[dependencies]
|
||||
solidcraft-geom.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
sha2.workspace = true
|
||||
cavalier_contours = "=0.9.0"
|
||||
parry3d-f64 = { version = "=0.31.1", default-features = false, features = ["required-features", "std", "alloc", "enhanced-determinism"] }
|
||||
robust = "=1.2.0"
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Independent fixture collision adapter. No toolpath generation code is used.
|
||||
//! Surface casts and solid containment are both needed: a surface-only query can
|
||||
//! miss a tool that starts wholly inside a closed fixture.
|
||||
use crate::{
|
||||
JobInput, POST_ERROR, Result, Tool, ToolKind, invalid,
|
||||
nc::Segment,
|
||||
verify::{Finding, Limits, Report, Status},
|
||||
};
|
||||
use parry3d_f64::{
|
||||
math::{Pose, Vector},
|
||||
query::{ShapeCastOptions, ShapeCastStatus, cast_shapes, distance},
|
||||
shape::{Cone, Cylinder, Shape, Triangle},
|
||||
};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Bounds {
|
||||
min: Vec3,
|
||||
max: Vec3,
|
||||
}
|
||||
impl Bounds {
|
||||
fn point(p: Vec3) -> Self {
|
||||
Self { min: p, max: p }
|
||||
}
|
||||
fn include(&mut self, p: Vec3) {
|
||||
self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z));
|
||||
self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z));
|
||||
}
|
||||
fn overlaps(self, b: Self) -> bool {
|
||||
self.min.x <= b.max.x
|
||||
&& self.max.x >= b.min.x
|
||||
&& self.min.y <= b.max.y
|
||||
&& self.max.y >= b.min.y
|
||||
&& self.min.z <= b.max.z
|
||||
&& self.max.z >= b.min.z
|
||||
}
|
||||
}
|
||||
|
||||
struct Component {
|
||||
body: usize,
|
||||
triangles: Vec<usize>,
|
||||
bounds: Bounds,
|
||||
closed: bool,
|
||||
}
|
||||
struct Edge {
|
||||
first: usize,
|
||||
forward: bool,
|
||||
count: usize,
|
||||
consistent: bool,
|
||||
}
|
||||
type Vertex = [u64; 3];
|
||||
fn vertex(p: Vec3) -> Vertex {
|
||||
[p.x, p.y, p.z].map(|v| if v == 0.0 { 0 } else { v.to_bits() })
|
||||
}
|
||||
fn root(parents: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&p) = parents.get(i) {
|
||||
if p == i {
|
||||
break;
|
||||
}
|
||||
let next = parents.get(p).copied().unwrap_or(p);
|
||||
if let Some(slot) = parents.get_mut(i) {
|
||||
*slot = next;
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Parry's primitives have a Y axis. This orthogonal mapping puts SolidCraft's
|
||||
/// downward drill tip on +Y without introducing a rounded rotation transform.
|
||||
fn vector(p: Vec3) -> Vector {
|
||||
Vector::new(p.x, -p.z, p.y)
|
||||
}
|
||||
struct Part {
|
||||
radius: f64,
|
||||
low: f64,
|
||||
high: f64,
|
||||
cone: bool,
|
||||
label: &'static str,
|
||||
}
|
||||
fn parts(t: &Tool) -> Vec<Part> {
|
||||
let tip = t.tip_length();
|
||||
let mut out = Vec::with_capacity(4);
|
||||
if t.kind == ToolKind::TwistDrill {
|
||||
out.push(Part { radius: t.diameter / 2.0, low: 0.0, high: tip, cone: true, label: "cutter" });
|
||||
}
|
||||
if t.cutting_length > tip {
|
||||
out.push(Part { radius: t.diameter / 2.0, low: tip, high: t.cutting_length, cone: false, label: "cutter" });
|
||||
}
|
||||
if t.overall_length > t.cutting_length {
|
||||
out.push(Part { radius: t.shank_diameter / 2.0, low: t.cutting_length, high: t.overall_length, cone: false, label: "shank" });
|
||||
}
|
||||
out.push(Part { radius: t.holder_diameter / 2.0, low: t.overall_length, high: t.overall_length + t.holder_length, cone: false, label: "holder" });
|
||||
out
|
||||
}
|
||||
impl Part {
|
||||
fn center(&self, p: Vec3) -> Vec3 {
|
||||
p + Vec3::new(0.0, 0.0, (self.low + self.high) / 2.0)
|
||||
}
|
||||
fn bounds(&self, s: &Segment, margin: f64) -> Bounds {
|
||||
let radius = self.radius + margin;
|
||||
Bounds {
|
||||
min: Vec3::new(s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius, s.from.z.min(s.to.z) + self.low - margin),
|
||||
max: Vec3::new(s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius, s.from.z.max(s.to.z) + self.high + margin),
|
||||
}
|
||||
}
|
||||
fn cast(&self, s: &Segment, tri: &[Vec3; 3], margin: f64) -> Status {
|
||||
let center = vector(self.center(s.from));
|
||||
let position = Pose::translation(center.x, center.y, center.z);
|
||||
let triangle = Triangle::new(vector(tri[0]), vector(tri[1]), vector(tri[2]));
|
||||
let cylinder = Cylinder::new((self.high - self.low) / 2.0, self.radius);
|
||||
let cone = Cone::new((self.high - self.low) / 2.0, self.radius);
|
||||
let shape: &dyn Shape = if self.cone { &cone } else { &cylinder };
|
||||
let query = |distance| {
|
||||
cast_shapes(
|
||||
&position,
|
||||
vector(s.to - s.from),
|
||||
shape,
|
||||
&Pose::IDENTITY,
|
||||
Vector::ZERO,
|
||||
&triangle,
|
||||
ShapeCastOptions {
|
||||
max_time_of_impact: 1.0,
|
||||
target_distance: distance,
|
||||
stop_at_penetration: true,
|
||||
compute_impact_geometry_on_penetration: true,
|
||||
},
|
||||
)
|
||||
};
|
||||
// Third-party numerical errors or panics never certify clearance.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
// A zero-velocity shape cast may report no impact even for initial
|
||||
// penetration. Check the initial pose independently before sweeping.
|
||||
let initial = match distance(&position, shape, &Pose::IDENTITY, &triangle).map(|d| d.distance) {
|
||||
Ok(d) if d.is_finite() && d <= 1e-10 => return Status::Fail,
|
||||
Ok(d) if d.is_finite() && d > margin => Status::Pass,
|
||||
_ => Status::Inconclusive,
|
||||
};
|
||||
match query(margin) {
|
||||
Ok(None) => initial,
|
||||
Ok(Some(_)) => match query(0.0) {
|
||||
Ok(Some(hit))
|
||||
if matches!(hit.status, ShapeCastStatus::Converged | ShapeCastStatus::PenetratingOrWithinTargetDist)
|
||||
&& hit.time_of_impact.is_finite()
|
||||
&& (0.0..=1.0).contains(&hit.time_of_impact) =>
|
||||
{
|
||||
Status::Fail
|
||||
}
|
||||
_ => Status::Inconclusive,
|
||||
},
|
||||
Err(_) => Status::Inconclusive,
|
||||
}
|
||||
}))
|
||||
.unwrap_or(Status::Inconclusive)
|
||||
}
|
||||
}
|
||||
|
||||
fn finding(report: &mut Report, status: Status, s: &Segment, part: &Part, index: usize, detail: &str) {
|
||||
if status == Status::Fail || report.status == Status::Pass {
|
||||
report.status = status;
|
||||
}
|
||||
report.finding(Finding {
|
||||
severity: if status == Status::Fail { crate::verify::FindingSeverity::Error } else { crate::verify::FindingSeverity::Unresolved },
|
||||
record: None,
|
||||
source: None,
|
||||
kind: if status == Status::Fail { format!("fixture_{}_collision", part.label) } else { "fixture_clearance_unresolved".into() },
|
||||
line: Some(s.line),
|
||||
at: s.from,
|
||||
detail: format!("{} of tool {} at motion {}: {detail}", part.label, s.tool, index + 1),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn fixtures<F: std::future::Future<Output = ()>>(
|
||||
input: &JobInput,
|
||||
segments: &[Segment],
|
||||
limits: Limits,
|
||||
cancelled: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
let tris = &input.geometry.fixtures;
|
||||
if tris.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut parents: Vec<_> = (0..tris.len()).collect();
|
||||
let mut edges: BTreeMap<(usize, [Vertex; 2]), Edge> = BTreeMap::new();
|
||||
let bodies: Vec<usize> = if input.geometry.fixture_groups.is_empty() {
|
||||
vec![0; tris.len()]
|
||||
} else {
|
||||
input.geometry.fixture_groups.iter().enumerate().flat_map(|(body, &count)| std::iter::repeat_n(body, count)).collect()
|
||||
};
|
||||
let mut degenerate = Vec::new();
|
||||
for (i, tri) in tris.iter().enumerate() {
|
||||
if i.is_multiple_of(1024) {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
}
|
||||
report.work += 3;
|
||||
if report.work > limits.work {
|
||||
return Ok(());
|
||||
}
|
||||
if (tri[1] - tri[0]).cross(tri[2] - tri[0]).len() < 1e-12 {
|
||||
degenerate.push(i);
|
||||
}
|
||||
for (a, b) in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
|
||||
let a = vertex(a);
|
||||
let b = vertex(b);
|
||||
let forward = a < b;
|
||||
let key = (bodies.get(i).copied().unwrap_or(0), if forward { [a, b] } else { [b, a] });
|
||||
if let Some(e) = edges.get_mut(&key) {
|
||||
let a = root(&mut parents, e.first);
|
||||
let b = root(&mut parents, i);
|
||||
if let Some(slot) = parents.get_mut(b) {
|
||||
*slot = a;
|
||||
}
|
||||
e.count += 1;
|
||||
e.consistent &= e.forward != forward;
|
||||
} else {
|
||||
edges.insert(key, Edge { first: i, forward, count: 1, consistent: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut components: BTreeMap<usize, Component> = BTreeMap::new();
|
||||
for (i, tri) in tris.iter().enumerate() {
|
||||
let key = root(&mut parents, i);
|
||||
let c = components.entry(key).or_insert_with(|| Component {
|
||||
body: bodies.get(i).copied().unwrap_or(0),
|
||||
triangles: Vec::new(),
|
||||
bounds: Bounds::point(tri[0]),
|
||||
closed: true,
|
||||
});
|
||||
c.triangles.push(i);
|
||||
for p in tri {
|
||||
c.bounds.include(*p);
|
||||
}
|
||||
if i.is_multiple_of(1024) {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, e) in edges.values().enumerate() {
|
||||
if index.is_multiple_of(1024) {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
}
|
||||
if (e.count != 2 || !e.consistent)
|
||||
&& let Some(c) = components.get_mut(&root(&mut parents, e.first))
|
||||
{
|
||||
c.closed = false;
|
||||
}
|
||||
}
|
||||
for i in degenerate {
|
||||
if let Some(c) = components.get_mut(&root(&mut parents, i)) {
|
||||
c.closed = false;
|
||||
}
|
||||
}
|
||||
let mut body_closed = BTreeMap::new();
|
||||
for c in components.values() {
|
||||
let closed = body_closed.entry(c.body).or_insert(true);
|
||||
*closed &= c.closed;
|
||||
}
|
||||
let margin = POST_ERROR + limits.uncertainty;
|
||||
for (index, s) in segments.iter().enumerate() {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
let tool = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| crate::Error("unknown simulation tool".into()))?;
|
||||
for part in parts(tool) {
|
||||
let swept = part.bounds(s, margin);
|
||||
let mut windings: BTreeMap<usize, f64> = BTreeMap::new();
|
||||
for c in components.values() {
|
||||
report.work += 1;
|
||||
if report.work.is_multiple_of(1024) {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
}
|
||||
if report.work > limits.work {
|
||||
return Ok(());
|
||||
}
|
||||
if !swept.overlaps(c.bounds) {
|
||||
continue;
|
||||
}
|
||||
let center = part.center(s.from);
|
||||
let containment = Bounds::point(center).overlaps(c.bounds);
|
||||
let mut solid_angle = 0.0;
|
||||
let mut status = if c.closed { Status::Pass } else { Status::Inconclusive };
|
||||
for &i in &c.triangles {
|
||||
report.work += 1;
|
||||
if report.work.is_multiple_of(1024) {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
}
|
||||
if report.work > limits.work {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(tri) = tris.get(i) else {
|
||||
return invalid("invalid fixture triangle index");
|
||||
};
|
||||
let mut bounds = Bounds::point(tri[0]);
|
||||
bounds.include(tri[1]);
|
||||
bounds.include(tri[2]);
|
||||
if swept.overlaps(bounds) {
|
||||
let cast = part.cast(s, tri, margin);
|
||||
if cast == Status::Fail {
|
||||
status = Status::Fail;
|
||||
break;
|
||||
}
|
||||
if cast == Status::Inconclusive {
|
||||
status = Status::Inconclusive;
|
||||
}
|
||||
}
|
||||
if c.closed && containment {
|
||||
let [a, b, d] = tri.map(|v| v - center);
|
||||
let denominator = a.len() * b.len() * d.len() + a.dot(b) * d.len() + b.dot(d) * a.len() + d.dot(a) * b.len();
|
||||
solid_angle += 2.0 * a.dot(b.cross(d)).atan2(denominator);
|
||||
}
|
||||
}
|
||||
if status != Status::Fail && c.closed && containment {
|
||||
*windings.entry(c.body).or_default() += solid_angle / (4.0 * std::f64::consts::PI);
|
||||
}
|
||||
if status != Status::Pass {
|
||||
finding(
|
||||
report,
|
||||
status,
|
||||
s,
|
||||
&part,
|
||||
index,
|
||||
if status == Status::Fail {
|
||||
"swept shape contacts the fixture surface"
|
||||
} else {
|
||||
"clearance is within the error margin, the query did not converge, or fixture topology is open/degenerate"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
for (body, winding) in &windings {
|
||||
let status = if !body_closed.get(body).copied().unwrap_or(false) {
|
||||
Status::Inconclusive
|
||||
} else if winding.is_finite() && winding.abs() > 1.0 - 1e-6 {
|
||||
Status::Fail
|
||||
} else if !winding.is_finite() || winding.abs() > 1e-6 {
|
||||
Status::Inconclusive
|
||||
} else {
|
||||
Status::Pass
|
||||
};
|
||||
if status != Status::Pass {
|
||||
finding(report, status, s, &part, index, "initial tool volume is inside a fixture or its solid winding is unresolved");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{corpus, nc, verify};
|
||||
|
||||
fn box_mesh(min: Vec3, max: Vec3) -> Vec<[Vec3; 3]> {
|
||||
let p = [
|
||||
min,
|
||||
Vec3::new(max.x, min.y, min.z),
|
||||
Vec3::new(max.x, max.y, min.z),
|
||||
Vec3::new(min.x, max.y, min.z),
|
||||
Vec3::new(min.x, min.y, max.z),
|
||||
Vec3::new(max.x, min.y, max.z),
|
||||
max,
|
||||
Vec3::new(min.x, max.y, max.z),
|
||||
];
|
||||
[[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]
|
||||
.into_iter()
|
||||
.flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]])
|
||||
.collect()
|
||||
}
|
||||
fn run(mesh: Vec<[Vec3; 3]>, motion: &str) -> Report {
|
||||
run_groups(mesh, vec![], motion)
|
||||
}
|
||||
fn run_groups(mesh: Vec<[Vec3; 3]>, groups: Vec<usize>, motion: &str) -> Report {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.geometry.fixtures = mesh;
|
||||
input.geometry.fixture_groups = groups;
|
||||
let src = format!("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\n{motion}\nM30");
|
||||
let parsed = nc::parse(&src, &input.tools).unwrap();
|
||||
verify::simulate(&input, &parsed, Limits::default()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn near_fixture_is_clear_below_wide_holder() {
|
||||
// Old whole-tool AABB overlaps this fixture. Cutter/shank radius is 3;
|
||||
// the 10-radius holder starts at Z65 and cannot touch this low block.
|
||||
let r = run(box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0)), "G0 X1");
|
||||
assert_eq!(r.status, Status::Pass, "{:?}", r.findings);
|
||||
}
|
||||
#[test]
|
||||
fn thin_fixture_between_endpoints_is_a_collision() {
|
||||
let r = run(box_mesh(Vec3::new(5.0, -1.0, 16.0), Vec3::new(5.01, 1.0, 17.0)), "G0 X10");
|
||||
assert_eq!(r.status, Status::Fail);
|
||||
assert!(r.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(4)));
|
||||
}
|
||||
#[test]
|
||||
fn holder_and_shank_collisions_are_distinguished() {
|
||||
for (z, kind) in [(40.0, "shank"), (70.0, "holder")] {
|
||||
let r = run(box_mesh(Vec3::new(5.0, -1.0, z), Vec3::new(5.1, 1.0, z + 1.0)), "G0 X10");
|
||||
assert_eq!(r.status, Status::Fail);
|
||||
assert!(r.findings.iter().any(|f| f.kind == format!("fixture_{kind}_collision")));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn contained_tool_is_not_missed_and_winding_is_irrelevant() {
|
||||
let cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
|
||||
for reverse in [false, true] {
|
||||
let mesh = cube.iter().map(|t| if reverse { [t[2], t[1], t[0]] } else { *t }).collect();
|
||||
let r = run(mesh, "G0 X1");
|
||||
assert_eq!(r.status, Status::Fail, "{:?}", r.findings);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn overlapping_components_with_opposite_winding_do_not_cancel() {
|
||||
let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
|
||||
a.extend(box_mesh(Vec3::new(-21.0, -21.0, 9.0), Vec3::new(21.0, 21.0, 101.0)).into_iter().map(|t| [t[2], t[1], t[0]]));
|
||||
assert_eq!(run_groups(a, vec![12, 12], "G0 X1").status, Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn cavity_in_one_fixture_body_remains_empty() {
|
||||
let mut a = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
|
||||
a.extend(box_mesh(Vec3::new(-15.0, -15.0, 11.0), Vec3::new(15.0, 15.0, 99.0)).into_iter().map(|t| [t[2], t[1], t[0]]));
|
||||
assert_eq!(run_groups(a, vec![24], "G0 X1").status, Status::Pass);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_fixture_group_counts_are_rejected() {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.geometry.fixture_groups = vec![usize::MAX, 1];
|
||||
assert!(input.validate().is_err());
|
||||
input.geometry.fixture_groups = vec![0];
|
||||
assert!(input.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn open_containing_shell_is_inconclusive() {
|
||||
let mut cube = box_mesh(Vec3::new(-20.0, -20.0, 10.0), Vec3::new(20.0, 20.0, 100.0));
|
||||
cube.pop();
|
||||
assert_eq!(run(cube, "G0 X1").status, Status::Inconclusive);
|
||||
}
|
||||
#[test]
|
||||
fn cylinder_corners_and_margin_are_resolved() {
|
||||
let part = Part { radius: 1.0, low: 0.0, high: 2.0, cone: false, label: "cutter" };
|
||||
let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: true, feed: 0.0, tool: 1, line: 4 };
|
||||
let triangle = |x, y| [Vec3::new(x, y, 0.5), Vec3::new(x + 0.05, y, 1.5), Vec3::new(x, y + 0.05, 1.5)];
|
||||
assert_eq!(part.cast(&s, &triangle(0.8, 0.8), 0.0035), Status::Pass);
|
||||
assert_eq!(part.cast(&s, &triangle(0.5, 0.5), 0.0035), Status::Fail);
|
||||
assert_ne!(part.cast(&s, &triangle(1.001, 0.0), 0.0035), Status::Pass);
|
||||
}
|
||||
#[test]
|
||||
fn drill_point_shape_has_clearance_outside_its_cone() {
|
||||
let part = Part { radius: 1.0, low: 0.0, high: 1.0, cone: true, label: "cutter" };
|
||||
let s = Segment { from: Vec3::ZERO, to: Vec3::ZERO, rapid: false, feed: 100.0, tool: 1, line: 4 };
|
||||
let clear = [Vec3::new(0.8, 0.0, 0.1), Vec3::new(0.9, 0.0, 0.1), Vec3::new(0.8, 0.1, 0.2)];
|
||||
assert_eq!(part.cast(&s, &clear, 0.0035), Status::Pass);
|
||||
let contact = clear.map(|p| p + Vec3::new(0.0, 0.0, 0.8));
|
||||
assert_eq!(part.cast(&s, &contact, 0.0035), Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn fixture_budget_and_cancellation_never_pass() {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.geometry.fixtures = box_mesh(Vec3::new(4.5, 0.0, 16.0), Vec3::new(5.0, 1.0, 17.0));
|
||||
let parsed = nc::parse("G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X1\nM30", &input.tools).unwrap();
|
||||
let r = verify::simulate(&input, &parsed, Limits { work: 1, ..Limits::default() }).unwrap();
|
||||
assert_eq!(r.status, Status::Inconclusive);
|
||||
assert!(verify::simulate_cancellable(&input, &parsed, Limits::default(), || true).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//! Conservative artifact comparison, separate from toolpath generation.
|
||||
use crate::spatial::Index;
|
||||
use crate::{
|
||||
verify::{Report, Status},
|
||||
*,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Comparison {
|
||||
pub status: Status,
|
||||
pub maximum_possible_error: f64,
|
||||
pub maximum_definite_error: f64,
|
||||
pub checked_regions: usize,
|
||||
pub detail: String,
|
||||
}
|
||||
fn gaps(a: [f64; 2], b: [f64; 2]) -> (f64, f64) {
|
||||
((a[0] - b[1]).max(b[0] - a[1]).max(0.0), (a[0] - b[1]).abs().max((a[1] - b[0]).abs()))
|
||||
}
|
||||
fn result(detail: &str) -> Comparison {
|
||||
Comparison { status: Status::Pass, maximum_possible_error: 0., maximum_definite_error: 0., checked_regions: 0, detail: detail.into() }
|
||||
}
|
||||
fn inconclusive(detail: &str) -> Comparison {
|
||||
Comparison { status: Status::Inconclusive, ..result(detail) }
|
||||
}
|
||||
fn tolerance(value: f64) -> Result<()> {
|
||||
if !value.is_finite() || value <= 0. || value > 1e6 {
|
||||
return invalid("comparison tolerance must be positive and bounded");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Compare both remaining material and removed space, in both directions.
|
||||
/// Each passing region is a whole-column distance certificate, not a sample.
|
||||
pub fn stock_reports(a: &Report, b: &Report, tol: f64) -> Result<Comparison> {
|
||||
stock_reports_budget(a, b, tol, &mut 0, 30_000_000)
|
||||
}
|
||||
fn stock_reports_budget(a: &Report, b: &Report, tol: f64, work: &mut usize, budget: usize) -> Result<Comparison> {
|
||||
tolerance(tol)?;
|
||||
let (Some(stock), Some(other)) = (a.stock, b.stock) else {
|
||||
return Ok(inconclusive("missing declared stock bounds; regenerate legacy reports"));
|
||||
};
|
||||
if stock != other {
|
||||
return invalid("stock bounds differ; verify input units and coordinate frames");
|
||||
}
|
||||
let complete_a = crate::coverage::cells(&a.cells, stock)?;
|
||||
let complete_b = crate::coverage::cells(&b.cells, stock)?;
|
||||
if !complete_a || !complete_b || !a.stock_complete || !b.stock_complete || a.status != Status::Pass || b.status != Status::Pass {
|
||||
return Ok(inconclusive("stock verification or exact cell coverage is incomplete"));
|
||||
}
|
||||
let guard = 128. * f64::EPSILON * stock.min.len().max(stock.max.len()).max(1.);
|
||||
let mut r = result("Bidirectional whole-column distances for remaining material and removed space in declared setup coordinates");
|
||||
for (a, b) in [(a, b), (b, a)] {
|
||||
let index = Index::new(b.cells.iter().enumerate().map(|(id, c)| (id, c.min, c.max)).collect());
|
||||
for x in &a.cells {
|
||||
let mut ids = Vec::new();
|
||||
index.query(x.min, x.max, &mut ids);
|
||||
for id in ids {
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
r.status = Status::Inconclusive;
|
||||
r.detail = "stock comparison work budget exhausted".into();
|
||||
return Ok(r);
|
||||
}
|
||||
let y = &b.cells[id];
|
||||
let min = [x.min[0].max(y.min[0]), x.min[1].max(y.min[1])];
|
||||
let max = [x.max[0].min(y.max[0]), x.max[1].min(y.max[1])];
|
||||
if (0..2).any(|k| min[k] >= max[k]) {
|
||||
continue;
|
||||
}
|
||||
r.checked_regions += 1;
|
||||
let mut possible = gaps([x.low, x.high], [y.low, y.high]).1 + guard;
|
||||
if possible > tol {
|
||||
let mut candidates = Vec::new();
|
||||
index.query(min.map(|v| v - tol - guard), max.map(|v| v + tol + guard), &mut candidates);
|
||||
let mut material = (x.high - y.low).max(0.);
|
||||
// Outside the declared XY stock domain is guaranteed void.
|
||||
let exterior = (max[0] - stock.min.x).min(stock.max.x - min[0]).min(max[1] - stock.min.y).min(stock.max.y - min[1]);
|
||||
let mut removed = (y.high - x.low).max(0.).min(exterior);
|
||||
for id in candidates {
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
r.status = Status::Inconclusive;
|
||||
r.detail = "stock distance certificate budget exhausted".into();
|
||||
return Ok(r);
|
||||
}
|
||||
let c = &b.cells[id];
|
||||
// The farthest rectangle corner bounds the distance of
|
||||
// every XY point to this guaranteed material/void prism.
|
||||
let xy = [0, 1].map(|k| (c.min[k] - min[k]).max(max[k] - c.max[k]).max(0.));
|
||||
let lateral = xy[0].hypot(xy[1]);
|
||||
material = material.min(lateral.hypot((x.high - c.low).max(0.)));
|
||||
removed = removed.min(lateral.hypot((c.high - x.low).max(0.)));
|
||||
}
|
||||
possible = material.max(removed) + guard;
|
||||
if possible > tol {
|
||||
let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
|
||||
// A positive failure requires a definitely present
|
||||
// material/void point farther than tolerance from ALL
|
||||
// possibly corresponding prisms, not just a Z gap.
|
||||
let radius = 2. * tol + guard;
|
||||
let mut candidates = Vec::new();
|
||||
index.query(center.map(|v| v - radius), center.map(|v| v + radius), &mut candidates);
|
||||
let mut material_distance = radius;
|
||||
let mut removed_distance = radius
|
||||
.min(center[0] - stock.min.x)
|
||||
.min(stock.max.x - center[0])
|
||||
.min(center[1] - stock.min.y)
|
||||
.min(stock.max.y - center[1]);
|
||||
for id in candidates {
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
r.status = Status::Inconclusive;
|
||||
r.detail = "stock failure certificate budget exhausted".into();
|
||||
return Ok(r);
|
||||
}
|
||||
let c = &b.cells[id];
|
||||
let dx = (c.min[0] - center[0]).max(center[0] - c.max[0]).max(0.);
|
||||
let dy = (c.min[1] - center[1]).max(center[1] - c.max[1]).max(0.);
|
||||
material_distance = material_distance.min(dx.hypot(dy).hypot((x.low - c.high).max(0.)));
|
||||
removed_distance = removed_distance.min(dx.hypot(dy).hypot((c.low - x.high).max(0.)));
|
||||
}
|
||||
let definite = (material_distance.max(removed_distance) - guard).max(0.);
|
||||
r.maximum_definite_error = r.maximum_definite_error.max(definite);
|
||||
if definite > tol {
|
||||
r.status = Status::Fail;
|
||||
r.detail =
|
||||
format!("Certified local stock difference near X{} Y{}; lower distance bound {} mm", center[0], center[1], definite);
|
||||
r.maximum_possible_error = r.maximum_possible_error.max(possible);
|
||||
return Ok(r);
|
||||
}
|
||||
r.status = Status::Inconclusive;
|
||||
}
|
||||
}
|
||||
r.maximum_possible_error = r.maximum_possible_error.max(possible);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub fn stock_mesh(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64) -> Result<Comparison> {
|
||||
stock_mesh_budget(report, mesh, bottom, tol, 250_000, 30_000_000)
|
||||
}
|
||||
fn stock_mesh_budget(report: &Report, mesh: &[[Vec3; 3]], bottom: f64, tol: f64, cell_budget: usize, budget: usize) -> Result<Comparison> {
|
||||
tolerance(tol)?;
|
||||
if mesh.is_empty() || mesh.len() > 500_000 || !bottom.is_finite() {
|
||||
return invalid("reference stock mesh empty or too large, or invalid bottom");
|
||||
}
|
||||
let Some(stock) = report.stock else {
|
||||
return Ok(inconclusive("missing declared stock bounds; regenerate legacy report"));
|
||||
};
|
||||
if bottom != stock.min.z {
|
||||
return invalid("reference stock bottom differs from declared stock");
|
||||
}
|
||||
let field = crate::coverage::MeshField::new(mesh, stock)?;
|
||||
let mut cells = Vec::new();
|
||||
if !crate::coverage::cells(&report.cells, stock)? || !report.stock_complete || report.status != Status::Pass {
|
||||
return Ok(inconclusive("stock verification or exact cell coverage is incomplete"));
|
||||
}
|
||||
// Reuse only the spatial partition, never the report's heights as mesh
|
||||
// evidence. Every reference interval is calculated from its own triangles.
|
||||
let mut pending: Vec<_> = report.cells.iter().map(|c| (c.min, c.max, [c.low, c.high])).collect();
|
||||
let mut work = 0;
|
||||
while let Some((min, max, source)) = pending.pop() {
|
||||
if cells.len() + pending.len() >= cell_budget {
|
||||
return Ok(inconclusive("reference stock refinement cell budget exhausted"));
|
||||
}
|
||||
let Some([low, high]) = field.bounds(min, max, &mut work, budget) else {
|
||||
return Ok(inconclusive("reference stock refinement work budget exhausted"));
|
||||
};
|
||||
let size = (max[0] - min[0]).hypot(max[1] - min[1]);
|
||||
if gaps(source, [low, high]).1 > tol && high - low > VERIFY_ERROR.min(tol / 4.) && size > VERIFY_ERROR.min(tol / 4.) {
|
||||
let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
|
||||
if (0..2).any(|k| center[k] <= min[k] || center[k] >= max[k]) {
|
||||
return Ok(inconclusive("reference refinement reached coordinate precision limit"));
|
||||
}
|
||||
for (a, b) in [(min, center), ([center[0], min[1]], [max[0], center[1]]), (center, max), ([min[0], center[1]], [center[0], max[1]])] {
|
||||
pending.push((a, b, source));
|
||||
}
|
||||
} else {
|
||||
cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high });
|
||||
}
|
||||
}
|
||||
let reference = Report { cells, status: Status::Pass, stock_complete: true, ..report.clone() };
|
||||
stock_reports_budget(report, &reference, tol, &mut work, budget)
|
||||
}
|
||||
/// Strict STL decoding: malformed triangles are rejected, never silently dropped.
|
||||
pub fn read_stl(bytes: &[u8]) -> Result<Vec<[Vec3; 3]>> {
|
||||
if bytes.len() > 128 * 1024 * 1024 {
|
||||
return invalid("STL exceeds 128 MiB");
|
||||
}
|
||||
let mut triangles = Vec::new();
|
||||
let count = bytes.get(80..84).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes).map(|n| n as usize);
|
||||
if let Some(count) = count.filter(|n| 84usize.checked_add(n.saturating_mul(50)) == Some(bytes.len())) {
|
||||
if count > 500_000 {
|
||||
return invalid("STL triangle limit");
|
||||
}
|
||||
for chunk in bytes.get(84..).unwrap_or_default().as_chunks::<50>().0 {
|
||||
let mut pts = [Vec3::ZERO; 3];
|
||||
for (k, p) in pts.iter_mut().enumerate() {
|
||||
let mut xyz = [0.0; 3];
|
||||
for (j, v) in xyz.iter_mut().enumerate() {
|
||||
let start = 12 + k * 12 + j * 4;
|
||||
let b =
|
||||
chunk.get(start..start + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).ok_or_else(|| Error("truncated STL vertex".into()))?;
|
||||
*v = f32::from_le_bytes(b) as f64;
|
||||
}
|
||||
*p = Vec3::new(xyz[0], xyz[1], xyz[2]);
|
||||
crate::model::point(*p)?;
|
||||
}
|
||||
triangles.push(pts);
|
||||
}
|
||||
} else {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| Error("invalid STL encoding/length".into()))?;
|
||||
let mut lines = text.lines().map(|line| line.split_whitespace().collect::<Vec<_>>()).filter(|line| !line.is_empty());
|
||||
if lines.next().and_then(|line| line.first().copied()) != Some("solid") {
|
||||
return invalid("invalid ASCII STL header");
|
||||
}
|
||||
loop {
|
||||
let header = lines.next().ok_or_else(|| Error("missing STL terminator".into()))?;
|
||||
if header.first() == Some(&"endsolid") {
|
||||
if lines.next().is_some() {
|
||||
return invalid("data after STL terminator");
|
||||
}
|
||||
break;
|
||||
}
|
||||
if header.len() != 5 || header[..2] != ["facet", "normal"] || header[2..].iter().any(|v| !v.parse::<f64>().is_ok_and(f64::is_finite)) {
|
||||
return invalid("invalid STL facet header");
|
||||
}
|
||||
if lines.next().as_deref() != Some(&["outer", "loop"]) {
|
||||
return invalid("missing STL outer loop");
|
||||
}
|
||||
let mut points = [Vec3::ZERO; 3];
|
||||
for point in &mut points {
|
||||
let words = lines.next().ok_or_else(|| Error("incomplete STL triangle".into()))?;
|
||||
if words.len() != 4 || words[0] != "vertex" {
|
||||
return invalid("invalid STL vertex");
|
||||
}
|
||||
let mut xyz = [0.0; 3];
|
||||
for (i, word) in words[1..].iter().enumerate() {
|
||||
xyz[i] = word.parse::<f64>().map_err(|_| Error("invalid STL number".into()))?;
|
||||
}
|
||||
*point = Vec3::new(xyz[0], xyz[1], xyz[2]);
|
||||
crate::model::point(*point)?;
|
||||
}
|
||||
if lines.next().as_deref() != Some(&["endloop"]) || lines.next().as_deref() != Some(&["endfacet"]) {
|
||||
return invalid("incomplete STL facet");
|
||||
}
|
||||
triangles.push(points);
|
||||
if triangles.len() > 500_000 {
|
||||
return invalid("STL triangle limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
if triangles.is_empty() {
|
||||
return invalid("empty STL");
|
||||
}
|
||||
Ok(triangles)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn cell(x0: f64, x1: f64, low: f64, high: f64) -> verify::Cell {
|
||||
verify::Cell { min: [x0, 0.], max: [x1, 2.], low, high, target_low: low, target_high: high }
|
||||
}
|
||||
fn report(cells: Vec<verify::Cell>) -> Report {
|
||||
Report {
|
||||
version: VERSION.into(),
|
||||
fingerprint: "analytic".into(),
|
||||
status: Status::Pass,
|
||||
finished_dimensions: Status::Pass,
|
||||
post_version: "analytic".into(),
|
||||
tolerance: TOLERANCE,
|
||||
uncertainty: VERIFY_ERROR,
|
||||
maximum_height_interval: 0.,
|
||||
stock_complete: true,
|
||||
stock: Some(Stock { min: Vec3::ZERO, max: Vec3::new(2., 2., 3.) }),
|
||||
cells,
|
||||
findings: vec![],
|
||||
cutting_distance: 0.,
|
||||
rapid_distance: 0.,
|
||||
cutting_minutes: 0.,
|
||||
remaining_volume_low: 0.,
|
||||
remaining_volume_high: 0.,
|
||||
work: 0,
|
||||
}
|
||||
}
|
||||
fn mesh(report: &Report) -> Vec<[Vec3; 3]> {
|
||||
read_stl(verify::stock_stl(report, 0.).unwrap().as_bytes()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_open_and_misoriented_reference_triangles_cannot_pass() {
|
||||
let r = report(vec![cell(0., 2., 1., 1.)]);
|
||||
let tri = [Vec3::new(0., 0., 1.), Vec3::new(2., 0., 1.), Vec3::new(2., 2., 1.)];
|
||||
assert!(stock_mesh(&r, &[tri, tri], 0., TOLERANCE).is_err());
|
||||
let mut m = mesh(&r);
|
||||
m.pop();
|
||||
assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err());
|
||||
let mut m = mesh(&r);
|
||||
for t in &mut m {
|
||||
t.swap(0, 1);
|
||||
}
|
||||
assert!(stock_mesh(&r, &m, 0., TOLERANCE).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cell_coverage_has_no_area_epsilon_and_rejects_duplicates() {
|
||||
let full = report(vec![cell(0., 2., 1., 1.)]);
|
||||
let duplicate = report(vec![cell(0., 1., 1., 1.), cell(0., 1., 1., 1.)]);
|
||||
assert!(stock_reports(&full, &duplicate, TOLERANCE).is_err());
|
||||
let gap = report(vec![cell(0., 1., 1., 1.), cell(1. + 1e-10, 2., 1., 1.)]);
|
||||
assert_eq!(stock_reports(&full, &gap, TOLERANCE).unwrap().status, Status::Inconclusive);
|
||||
let mut legacy = full.clone();
|
||||
legacy.stock = None;
|
||||
assert_eq!(stock_reports(&full, &legacy, TOLERANCE).unwrap().status, Status::Inconclusive);
|
||||
let mut shifted = full.clone();
|
||||
shifted.stock.as_mut().unwrap().min.x = -1.;
|
||||
assert!(stock_reports(&full, &shifted, TOLERANCE).is_err());
|
||||
let mut bad = full.clone();
|
||||
bad.cells[0].high = f64::NAN;
|
||||
assert!(stock_reports(&full, &bad, TOLERANCE).is_err());
|
||||
let mut bad = full.clone();
|
||||
bad.cells[0].min[0] = 1e-200;
|
||||
assert!(stock_reports(&full, &bad, TOLERANCE).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn whole_column_certificates_resolve_wall_uncertainty_and_detect_wall_shift() {
|
||||
let exact = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]);
|
||||
let band = report(vec![cell(0., 0.999, 1., 1.), cell(0.999, 1.001, 1., 2.), cell(1.001, 2., 2., 2.)]);
|
||||
let r = stock_reports(&exact, &band, TOLERANCE).unwrap();
|
||||
assert_eq!(r.status, Status::Pass, "{r:?}");
|
||||
assert!(r.maximum_possible_error < 0.003);
|
||||
let shifted = report(vec![cell(0., 1.1, 1., 1.), cell(1.1, 2., 2., 2.)]);
|
||||
assert_eq!(stock_reports(&exact, &shifted, TOLERANCE).unwrap().status, Status::Fail);
|
||||
let wide = report(vec![cell(0., 2., 1., 2.)]);
|
||||
assert_eq!(stock_reports(&wide, &wide, TOLERANCE).unwrap().status, Status::Inconclusive);
|
||||
assert_eq!(stock_reports_budget(&exact, &exact, TOLERANCE, &mut 0, 1).unwrap().status, Status::Inconclusive);
|
||||
}
|
||||
#[test]
|
||||
fn removed_space_detects_a_narrow_deep_missing_hole() {
|
||||
let full = report(vec![cell(0., 2., 2., 2.)]);
|
||||
// A solid-only Hausdorff test could hide this narrow cut because all
|
||||
// missing material is within tolerance of a neighboring solid column.
|
||||
let hole = report(vec![cell(0., 0.999, 2., 2.), cell(0.999, 1.001, 0., 0.), cell(1.001, 2., 2., 2.)]);
|
||||
assert_eq!(stock_reports(&full, &hole, TOLERANCE).unwrap().status, Status::Fail);
|
||||
let rim = report(vec![cell(0., 0.001, 0., 0.), cell(0.001, 2., 2., 2.)]);
|
||||
assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Pass);
|
||||
let rim = report(vec![cell(0., 0.1, 0., 0.), cell(0.1, 2., 2., 2.)]);
|
||||
assert_eq!(stock_reports(&full, &rim, TOLERANCE).unwrap().status, Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn closed_stock_mesh_compares_at_vertical_walls_and_holes() {
|
||||
let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]);
|
||||
let m = mesh(&step);
|
||||
let comparison = stock_mesh(&step, &m, 0., TOLERANCE).unwrap();
|
||||
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
|
||||
let hole = report(vec![cell(0., 0.9, 2., 2.), cell(0.9, 1.1, 0., 0.), cell(1.1, 2., 2., 2.)]);
|
||||
let comparison = stock_mesh(&hole, &mesh(&hole), 0., TOLERANCE).unwrap();
|
||||
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
|
||||
assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 1, 100).unwrap().status, Status::Inconclusive);
|
||||
}
|
||||
#[test]
|
||||
fn shared_mesh_edges_do_not_refine_flat_cells_or_hide_thin_overlaps() {
|
||||
let step = report(vec![cell(0., 1., 1., 1.), cell(1., 2., 2., 2.)]);
|
||||
let m = mesh(&step);
|
||||
assert_eq!(stock_mesh_budget(&step, &m, 0., TOLERANCE, 3, 100_000).unwrap().status, Status::Pass);
|
||||
let field = crate::coverage::MeshField::new(&m, step.stock.unwrap()).unwrap();
|
||||
let flat = field.bounds([0., 0.], [1., 2.], &mut 0, 1000).unwrap();
|
||||
assert!((flat[0] - 1.).abs() < 1e-10 && (flat[1] - 1.).abs() < 1e-10);
|
||||
// Positive-area overlap has no area epsilon: even this tiny strip must
|
||||
// retain both heights, rather than treating it as a shared edge.
|
||||
let sliver = field.bounds([0., 0.], [1. + 1e-10, 2.], &mut 0, 1000).unwrap();
|
||||
assert!(sliver[0] <= 1. && sliver[1] >= 2.);
|
||||
}
|
||||
#[test]
|
||||
fn overlapping_closed_components_cannot_counterfeit_full_coverage() {
|
||||
let expected = report(vec![cell(0., 2., 1., 1.)]);
|
||||
let mut m = mesh(&report(vec![cell(0., 1., 1., 1.)]));
|
||||
m.extend(mesh(&report(vec![cell(0.1, 1.1, 1., 1.)])));
|
||||
let comparison = stock_mesh(&expected, &m, 0., TOLERANCE).unwrap();
|
||||
assert_eq!(comparison.status, Status::Fail, "{comparison:?}");
|
||||
let mut extra = mesh(&expected);
|
||||
for t in &mut extra {
|
||||
for p in t {
|
||||
p.x += 1.;
|
||||
}
|
||||
}
|
||||
assert!(stock_mesh(&expected, &extra, 0., TOLERANCE).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn generated_drill_stock_round_trip_is_spatially_certified() {
|
||||
let input = corpus::fixture("drill").unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let r = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
let m = mesh(&r);
|
||||
let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap();
|
||||
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
|
||||
}
|
||||
#[test]
|
||||
fn analytic_round_hole_matches_independently_faceted_closed_stock() {
|
||||
// Original analytic solid: 2x2 plate, top Z2, radius .25 bore, a
|
||||
// cone from tip Z1 to Z1.5, and a vertical wall to the plate top.
|
||||
// Intervals below use only radial extrema; no toolpath or STL exporter.
|
||||
let height = |d: f64| if d < 0.25 { 1. + 2. * d } else { 2. };
|
||||
let mut cells = Vec::new();
|
||||
let mut pending = vec![([0., 0.], [2., 2.])];
|
||||
while let Some((min, max)) = pending.pop() {
|
||||
let near: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).max(1. - max[k]).max(0.));
|
||||
let far: [f64; 2] = [0, 1].map(|k| (min[k] - 1_f64).abs().max((max[k] - 1.).abs()));
|
||||
let low = height(near[0].hypot(near[1]));
|
||||
let high = height(far[0].hypot(far[1]));
|
||||
if high - low > 0.002 && (max[0] - min[0]).hypot(max[1] - min[1]) > VERIFY_ERROR {
|
||||
let c = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
|
||||
pending.extend([(min, c), ([c[0], min[1]], [max[0], c[1]]), (c, max), ([min[0], c[1]], [c[0], max[1]])]);
|
||||
} else {
|
||||
cells.push(verify::Cell { min, max, low, high, target_low: low, target_high: high });
|
||||
}
|
||||
}
|
||||
let r = report(cells);
|
||||
let mut m = Vec::new();
|
||||
let ring = |i: usize| {
|
||||
let a = (i % 64) as f64 * std::f64::consts::TAU / 64.;
|
||||
let (y, x) = a.sin_cos();
|
||||
let outer =
|
||||
if x.abs() >= y.abs() { Vec3::new(1. + x.signum(), 1. + y / x.abs(), 2.) } else { Vec3::new(1. + x / y.abs(), 1. + y.signum(), 2.) };
|
||||
[Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 2.), Vec3::new(1. + 0.25 * x, 1. + 0.25 * y, 1.5), outer, Vec3::new(outer.x, outer.y, 0.)]
|
||||
};
|
||||
for i in 0..64 {
|
||||
let [a, lo, outer, base] = ring(i);
|
||||
let [b, lo_b, outer_b, base_b] = ring(i + 1);
|
||||
m.extend([
|
||||
[a, outer, outer_b],
|
||||
[a, outer_b, b],
|
||||
[a, b, lo_b],
|
||||
[a, lo_b, lo],
|
||||
[Vec3::new(1., 1., 1.), lo, lo_b],
|
||||
[outer, base, base_b],
|
||||
[outer, base_b, outer_b],
|
||||
[Vec3::new(1., 1., 0.), base_b, base],
|
||||
]);
|
||||
}
|
||||
let comparison = stock_mesh(&r, &m, 0., TOLERANCE).unwrap();
|
||||
assert_eq!(comparison.status, Status::Pass, "{comparison:?}");
|
||||
// Different facet decomposition must preserve the result.
|
||||
let split: Vec<_> = m
|
||||
.iter()
|
||||
.flat_map(|t| {
|
||||
if (t[1] - t[0]).cross(t[2] - t[0]).z == 0. {
|
||||
return vec![*t];
|
||||
}
|
||||
let c = (t[0] + t[1] + t[2]) / 3.;
|
||||
vec![[t[0], t[1], c], [t[1], t[2], c], [t[2], t[0], c]]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(stock_mesh(&r, &split, 0., TOLERANCE).unwrap().status, Status::Pass);
|
||||
let missed = report(vec![cell(0., 2., 2., 2.)]);
|
||||
assert_eq!(stock_mesh(&missed, &m, 0., TOLERANCE).unwrap().status, Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_stl_is_not_partially_accepted() {
|
||||
assert!(read_stl(b"solid x\nvertex NaN 0 0\nvertex 1 0 0\nvertex 0 1 0\nendsolid").is_err());
|
||||
assert!(read_stl(&[0; 84]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn interval_difference_catches_local_gouge() {
|
||||
assert!((gaps([4.0, 4.001], [5.0, 5.001]).0 - 0.999).abs() < 1e-10);
|
||||
assert!(gaps([4.99, 5.01], [5.0, 5.0]).1 > 0.009);
|
||||
}
|
||||
#[test]
|
||||
fn incomplete_coverage_is_symmetric_and_stl_is_bounded() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let full = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
let mut partial = full.clone();
|
||||
partial.cells.truncate(1);
|
||||
assert_eq!(stock_reports(&partial, &full, TOLERANCE).unwrap().status, Status::Inconclusive);
|
||||
assert_eq!(stock_reports(&full, &partial, TOLERANCE).unwrap().status, Status::Inconclusive);
|
||||
let stl = verify::stock_stl(&full, 0.0).unwrap();
|
||||
let mesh = read_stl(stl.as_bytes()).unwrap();
|
||||
assert_eq!(mesh.len(), 12); // exact coplanar merge, conforming boundary
|
||||
let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum();
|
||||
assert!((volume - 2000.0).abs() < 1e-9);
|
||||
assert!(read_stl(stl.replace("endfacet", "unknown").as_bytes()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Original analytic fixtures; never generated by a proprietary CAD application.
|
||||
use crate::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
mod channel;
|
||||
mod extended;
|
||||
mod exterior;
|
||||
mod through;
|
||||
|
||||
pub const CASES: &[&str] =
|
||||
&["face", "contour", "pocket", "drill", "concave-islands", "multi-operation", "clamped-through-holes", "external-contour", "narrow-channel"];
|
||||
|
||||
/// Independent point expectations, indexed by the completed operation count.
|
||||
pub fn probes(name: &str, completed: usize) -> Result<Vec<([f64; 2], f64)>> {
|
||||
match name {
|
||||
"face" | "pocket" | "drill" => Ok(vec![([10., 10.], 5.)]),
|
||||
"contour" => Ok(vec![([10., 10.], 6.)]),
|
||||
"narrow-channel" if completed == 1 => Ok(vec![([10., 10.], 5.), ([4., 10.], 5.), ([16., 10.], 5.), ([10., 11.2], 6.), ([3.8, 10.], 6.)]),
|
||||
"concave-islands" => Ok(vec![([6., 6.], 6.), ([6., 13.], 6.), ([14., 14.], 6.), ([15., 6.], 4.), ([6., 9.], 4.)]),
|
||||
"clamped-through-holes" if (1..=2).contains(&completed) => {
|
||||
Ok(vec![([6., 10.], 0.), ([14., 10.], if completed == 2 { 0. } else { 6. }), ([10., 10.], 6.), ([6.6, 10.], 6.)])
|
||||
}
|
||||
"external-contour" if (1..=2).contains(&completed) => {
|
||||
Ok(vec![([10., 10.], 6.), ([5.1, 10.], 6.), ([4.5, 10.], if completed == 1 { 4.2 } else { 4. }), ([1., 1.], 6.)])
|
||||
}
|
||||
"multi-operation" if (1..=4).contains(&completed) => {
|
||||
let floor = [5.5, 4.2, 4., 4.].get(completed - 1).copied().ok_or_else(|| Error("unknown stage".into()))?;
|
||||
Ok(vec![([1., 1.], 5.5), ([6., 6.], 5.5), ([14., 14.], 5.5), ([6., 9.], floor), ([15., 6.], if completed == 4 { 3. } else { floor })])
|
||||
}
|
||||
_ => invalid("unknown analytic stage"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fixture(strategy: &str) -> Result<JobInput> {
|
||||
if strategy == "narrow-channel" {
|
||||
return channel::fixture();
|
||||
}
|
||||
if strategy == "external-contour" {
|
||||
return exterior::fixture();
|
||||
}
|
||||
if strategy == "clamped-through-holes" {
|
||||
return through::fixture();
|
||||
}
|
||||
if matches!(strategy, "concave-islands" | "multi-operation") {
|
||||
return extended::fixture(strategy);
|
||||
}
|
||||
let tool = Tool {
|
||||
id: 1,
|
||||
name: "6 mm center-cutting end mill".into(),
|
||||
number: 1,
|
||||
kind: ToolKind::FlatEndMill,
|
||||
diameter: 6.0,
|
||||
cutting_length: 20.0,
|
||||
shank_diameter: 6.0,
|
||||
overall_length: 50.0,
|
||||
holder_diameter: 20.0,
|
||||
holder_length: 30.0,
|
||||
tip_angle: 118_f64.to_radians(),
|
||||
};
|
||||
let setup = Setup {
|
||||
id: 1,
|
||||
name: "Analytic plate".into(),
|
||||
stock: Stock { min: Vec3::ZERO, max: Vec3::new(20.0, 20.0, 6.0) },
|
||||
frame: Frame::default(),
|
||||
models: vec![BodyRef { body: "Plate".into(), occurrence: vec![] }],
|
||||
fixtures: vec![],
|
||||
clearance: 15.0,
|
||||
retract: 10.0,
|
||||
work_offset: 54,
|
||||
};
|
||||
let boundary = Boundary {
|
||||
outer: (0..128)
|
||||
.map(|i| {
|
||||
let a = i as f64 * std::f64::consts::TAU / 128.;
|
||||
[10. + 8. * a.cos(), 10. + 8. * a.sin()]
|
||||
})
|
||||
.collect(),
|
||||
islands: vec![],
|
||||
};
|
||||
let geometry = match strategy {
|
||||
"face" => Strategy::Face,
|
||||
"contour" => Strategy::Contour { boundary, inside: true },
|
||||
"pocket" => Strategy::Pocket { boundary },
|
||||
"drill" => Strategy::Drill { holes: vec![[10.0, 10.0]], peck: Some(1.0), full_diameter: false },
|
||||
_ => return invalid("unknown analytic fixture"),
|
||||
};
|
||||
let mut tool = tool;
|
||||
if strategy == "drill" {
|
||||
tool.kind = ToolKind::TwistDrill;
|
||||
tool.diameter = 1.0;
|
||||
tool.shank_diameter = 1.0;
|
||||
tool.name = "1 mm twist drill".into();
|
||||
}
|
||||
let operation = Operation {
|
||||
id: 1,
|
||||
name: strategy.into(),
|
||||
setup: 1,
|
||||
tool: 1,
|
||||
geometry,
|
||||
source: None,
|
||||
hole_sources: vec![],
|
||||
top: 6.0,
|
||||
bottom: 5.0,
|
||||
stepdown: 1.0,
|
||||
stepover: if strategy == "drill" { 0.5 } else { 2.0 },
|
||||
feed: 300.0,
|
||||
plunge_feed: 100.0,
|
||||
spindle: 6000.0,
|
||||
allowance: 0.0,
|
||||
climb: true,
|
||||
tolerance: TOLERANCE,
|
||||
coolant: false,
|
||||
ramp_angle: None,
|
||||
lead_distance: 0.0,
|
||||
};
|
||||
let a = Vec3::new(0.0, 0.0, 5.0);
|
||||
let b = Vec3::new(20.0, 0.0, 5.0);
|
||||
let c = Vec3::new(20.0, 20.0, 5.0);
|
||||
let d = Vec3::new(0.0, 20.0, 5.0);
|
||||
let mut input = JobInput {
|
||||
setup,
|
||||
tools: vec![tool],
|
||||
operations: vec![operation],
|
||||
geometry: Geometry { target: vec![[a, b, c], [a, c, d]], fixtures: vec![], fixture_groups: vec![] },
|
||||
fingerprint: String::new(),
|
||||
};
|
||||
if strategy == "drill" {
|
||||
input.geometry.target = drilled_plate_target();
|
||||
}
|
||||
if matches!(strategy, "contour" | "pocket") {
|
||||
input.geometry.target = round_pocket_target();
|
||||
}
|
||||
input.fingerprint = crate::fingerprint(&input)?;
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
// Original circular pocket with the same faceted boundary as its input recipe.
|
||||
// Chord error at radius 8 with 128 segments is 0.002410 mm; the recipe itself is
|
||||
// this polygon, not an unrecorded exact circle.
|
||||
fn round_pocket_target() -> Vec<[Vec3; 3]> {
|
||||
let mut triangles = Vec::new();
|
||||
let ring = |i: usize| {
|
||||
let a = (i % 128) as f64 * std::f64::consts::TAU / 128.;
|
||||
let (y, x) = a.sin_cos();
|
||||
let outer = if x.abs() >= y.abs() {
|
||||
[10. + 10. * x.signum(), 10. + 10. * y / x.abs(), 6.]
|
||||
} else {
|
||||
[10. + 10. * x / y.abs(), 10. + 10. * y.signum(), 6.]
|
||||
};
|
||||
([10. + 8. * x, 10. + 8. * y, 6.], outer)
|
||||
};
|
||||
for i in 0..128 {
|
||||
let (a, c) = ring(i);
|
||||
let (b, d) = ring(i + 1);
|
||||
triangles.extend([[a.into(), c.into(), d.into()], [a.into(), d.into(), b.into()]]);
|
||||
let e = [a[0], a[1], 5.];
|
||||
let f = [b[0], b[1], 5.];
|
||||
triangles.extend([[[10., 10., 5.].into(), e.into(), f.into()], [a.into(), b.into(), f.into()], [a.into(), f.into(), e.into()]]);
|
||||
}
|
||||
triangles
|
||||
}
|
||||
|
||||
// Original faceted plate with a blind drilled hole, vertical wall and cone.
|
||||
fn drilled_plate_target() -> Vec<[Vec3; 3]> {
|
||||
let mut triangles = Vec::new();
|
||||
let n = 48;
|
||||
let slope = 1.0 / (59_f64.to_radians().tan());
|
||||
for k in 0..n {
|
||||
let point = |index: usize, r: f64, z: f64| {
|
||||
let a = index as f64 * std::f64::consts::TAU / n as f64;
|
||||
[10. + r * a.cos(), 10. + r * a.sin(), z]
|
||||
};
|
||||
let outer = |index: usize| {
|
||||
let a = index as f64 * std::f64::consts::TAU / n as f64;
|
||||
point(index, 10. / a.cos().abs().max(a.sin().abs()), 6.)
|
||||
};
|
||||
let a = point(k, 0.5, 6.);
|
||||
let b = point(k + 1, 0.5, 6.);
|
||||
let c = outer(k);
|
||||
let d = outer(k + 1);
|
||||
triangles.push([a.into(), c.into(), d.into()]);
|
||||
triangles.push([a.into(), d.into(), b.into()]);
|
||||
let e = point(k, 0.5, 5. + 0.5 * slope);
|
||||
let f = point(k + 1, 0.5, 5. + 0.5 * slope);
|
||||
triangles.push([[10., 10., 5.].into(), e.into(), f.into()]);
|
||||
triangles.push([a.into(), b.into(), f.into()]);
|
||||
triangles.push([a.into(), f.into(), e.into()]);
|
||||
}
|
||||
triangles
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn all_strategies_round_trip() {
|
||||
for name in ["face", "contour", "pocket", "drill"] {
|
||||
let input = fixture(name).unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
assert!(!nc::segments(&parsed, &input.setup).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn missing_modes_and_unknown_words_fail() {
|
||||
let input = fixture("face").unwrap();
|
||||
for code in [
|
||||
"G0 X0 Y0 Z10\nM30",
|
||||
"G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG43 H1\nM30",
|
||||
"G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nG0 XNaN\nM30",
|
||||
"G21 G90 G91\nM30",
|
||||
] {
|
||||
assert!(nc::parse(code, &input.tools).is_err(), "{code}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cancelled_job_cannot_publish() {
|
||||
let mut g = generate::Generator::new(fixture("face").unwrap()).unwrap();
|
||||
g.cancel();
|
||||
assert!(g.step().is_err());
|
||||
assert!(g.finish().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_geometry_rejected() {
|
||||
let mut i = fixture("pocket").unwrap();
|
||||
if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry {
|
||||
boundary.outer = vec![[0.0, 0.0], [10.0, 10.0], [0.0, 10.0], [10.0, 0.0]];
|
||||
}
|
||||
assert!(generate::generate(i).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn zero_budget_cannot_pass() {
|
||||
let i = fixture("face").unwrap();
|
||||
let p = generate::generate(i.clone()).unwrap();
|
||||
let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap();
|
||||
let r = verify::simulate(&i, &nc, verify::Limits { work: 1, ..Default::default() }).unwrap();
|
||||
assert_ne!(r.status, verify::Status::Pass);
|
||||
}
|
||||
#[test]
|
||||
fn equivalent_units() {
|
||||
let i = fixture("face").unwrap();
|
||||
let a = nc::parse("G21 G90 G17 G94 G54\nG0 X25.4 Y0 Z25.4\nM30", &i.tools).unwrap();
|
||||
let b = nc::parse("G20 G90 G17 G94 G54\nG0 X1 Y0 Z1\nM30", &i.tools).unwrap();
|
||||
assert_eq!(a.records[0].motion, b.records[0].motion);
|
||||
}
|
||||
#[test]
|
||||
fn direction_changes_arc_sweep() {
|
||||
let i = fixture("face").unwrap();
|
||||
let src = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X0 Y1 I-1 J0 F100\nM5\nM30";
|
||||
let a = nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).unwrap();
|
||||
let b = nc::segments(&nc::parse(&src.replace("G3 ", "G2 "), &i.tools).unwrap(), &i.setup).unwrap();
|
||||
let len = |s: Vec<nc::Segment>| s.iter().map(|x| x.from.dist(x.to)).sum::<f64>();
|
||||
assert!((len(b) / len(a) - 3.0).abs() < 0.01);
|
||||
}
|
||||
#[test]
|
||||
fn speed_selection_does_not_start_a_stopped_spindle() {
|
||||
let i = fixture("face").unwrap();
|
||||
let src = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000\nG1 X2 F100\nM30";
|
||||
assert!(nc::segments(&nc::parse(src, &i.tools).unwrap(), &i.setup).is_err());
|
||||
let running = src.replace("S6000", "S6000 M3");
|
||||
let parsed = nc::parse(&running, &i.tools).unwrap();
|
||||
assert_eq!(parsed.records.last().unwrap().motion, Motion::Spindle { rpm: 0.0 });
|
||||
}
|
||||
#[test]
|
||||
fn unsafe_rapid_and_wide_shank_do_not_pass() {
|
||||
let mut i = fixture("face").unwrap();
|
||||
let rapid = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nG0 X10 Y10 Z2\nM30";
|
||||
let p = nc::parse(rapid, &i.tools).unwrap();
|
||||
assert_ne!(
|
||||
verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap().status,
|
||||
verify::Status::Pass
|
||||
);
|
||||
i.tools[0].diameter = 1.0;
|
||||
i.tools[0].cutting_length = 1.0;
|
||||
i.operations[0].stepover = 0.5;
|
||||
let shank = "G21 G90 G17 G94 G54\nG0 X-1 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z3 F100\nM5\nM30";
|
||||
let p = nc::parse(shank, &i.tools).unwrap();
|
||||
let r = verify::simulate(&i, &p, verify::Limits { cells: 2000, work: 100000, ..Default::default() }).unwrap();
|
||||
assert_ne!(r.status, verify::Status::Pass);
|
||||
assert!(r.findings.iter().any(|f| f.kind.contains("shank") || f.kind.contains("holder") || f.kind == "budget_exhausted"));
|
||||
}
|
||||
#[test]
|
||||
fn gouge_and_fixture_overlap_are_blocked() {
|
||||
let mut i = fixture("face").unwrap();
|
||||
let path = generate::generate(i.clone()).unwrap();
|
||||
let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z4.000000");
|
||||
let p = nc::parse(&code, &i.tools).unwrap();
|
||||
let r = verify::simulate(&i, &p, Default::default()).unwrap();
|
||||
assert_eq!(r.status, verify::Status::Fail);
|
||||
i.geometry.fixtures.push([Vec3::new(9.0, 9.0, 6.0), Vec3::new(11.0, 9.0, 6.0), Vec3::new(10.0, 11.0, 10.0)]);
|
||||
let p = nc::round_trip(&path, &i.setup, &i.tools).unwrap();
|
||||
let r = verify::simulate(&i, &p, Default::default()).unwrap();
|
||||
assert_ne!(r.status, verify::Status::Pass);
|
||||
}
|
||||
#[test]
|
||||
fn simulation_observes_cancellation() {
|
||||
let i = fixture("face").unwrap();
|
||||
let p = generate::generate(i.clone()).unwrap();
|
||||
let nc = nc::round_trip(&p, &i.setup, &i.tools).unwrap();
|
||||
assert!(verify::simulate_cancellable(&i, &nc, Default::default(), || true).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn islands_winding_ramps_and_tool_fit() {
|
||||
let mut i = fixture("pocket").unwrap();
|
||||
if let Strategy::Pocket { boundary } = &mut i.operations[0].geometry {
|
||||
boundary.outer.reverse();
|
||||
boundary.islands.push(vec![[9.0, 9.0], [11.0, 9.0], [11.0, 11.0], [9.0, 11.0]]);
|
||||
}
|
||||
i.tools[0].diameter = 2.0;
|
||||
i.operations[0].stepover = 0.5;
|
||||
i.operations[0].ramp_angle = Some(5_f64.to_radians());
|
||||
let path = generate::generate(i.clone()).unwrap();
|
||||
nc::round_trip(&path, &i.setup, &i.tools).unwrap();
|
||||
i.tools[0].diameter = 30.0;
|
||||
assert!(generate::generate(i).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn hand_calculated_stock_and_drill_cone() {
|
||||
let i = fixture("face").unwrap();
|
||||
let path = generate::generate(i.clone()).unwrap();
|
||||
let nc = nc::round_trip(&path, &i.setup, &i.tools).unwrap();
|
||||
let report = verify::simulate(&i, &nc, Default::default()).unwrap();
|
||||
assert_eq!(report.status, verify::Status::Pass);
|
||||
assert!((report.remaining_volume_low - 2000.0).abs() < 1e-6);
|
||||
assert!((report.remaining_volume_high - 2000.0).abs() < 1e-6);
|
||||
let i = fixture("drill").unwrap();
|
||||
// Authored NC, independent of the generator. A 118-degree cone rises
|
||||
// r / tan(59 degrees) from its tip at every radial position r.
|
||||
let source = "G21 G90 G17 G94 G54\nG0 X10 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z5 F100\nG0 Z15\nM5\nM30";
|
||||
let nc = nc::parse(source, &i.tools).unwrap();
|
||||
let actual = verify::probe_height(&i, &nc, [10.25, 10.0]).unwrap();
|
||||
assert!((actual - (5.0 + 0.25 / 59_f64.to_radians().tan())).abs() < 1e-9);
|
||||
assert_eq!(verify::probe_height(&i, &nc, [11.0, 10.0]).unwrap(), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn climb_pocket_follows_counterclockwise_outer_wall() {
|
||||
let i = fixture("pocket").unwrap();
|
||||
let path = generate::generate(i).unwrap();
|
||||
let points: Vec<_> = path
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b.motion {
|
||||
Motion::Linear { to, feed: 300.0 } => Some(to),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// With M3, the pocket wall lies to the right of a counterclockwise cut.
|
||||
let last = &points[points.len() - 128..];
|
||||
let area: f64 = last.iter().zip(last.iter().cycle().skip(1)).take(128).map(|(a, b)| a.x * b.y - a.y * b.x).sum();
|
||||
assert!(area > 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn cooperative_verification_yields_and_cancels() {
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let input = fixture("face").unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let nc = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let cancel = std::cell::Cell::new(false);
|
||||
let yield_once = || {
|
||||
let mut first = true;
|
||||
std::future::poll_fn(move |_| {
|
||||
if first {
|
||||
first = false;
|
||||
Poll::Pending
|
||||
} else {
|
||||
Poll::Ready(())
|
||||
}
|
||||
})
|
||||
};
|
||||
let mut future = std::pin::pin!(verify::simulate_cooperative(&input, &nc, Default::default(), || cancel.get(), yield_once));
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
assert!(future.as_mut().poll(&mut cx).is_pending());
|
||||
cancel.set(true);
|
||||
assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_))));
|
||||
}
|
||||
#[test]
|
||||
fn excess_facing_stock_is_a_dimensional_failure() {
|
||||
let i = fixture("face").unwrap();
|
||||
let path = generate::generate(i.clone()).unwrap();
|
||||
let code = nc::post(&path, &i.setup).unwrap().replace("Z5.000000", "Z5.100000");
|
||||
let nc = nc::parse(&code, &i.tools).unwrap();
|
||||
let report = verify::simulate(&i, &nc, Default::default()).unwrap();
|
||||
assert_eq!(report.status, verify::Status::Pass); // no gouge or collision
|
||||
assert_eq!(report.finished_dimensions, verify::Status::Fail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Original narrow-channel recipes with independently triangulated protected stock.
|
||||
use crate::*;
|
||||
|
||||
fn capsule() -> Boundary {
|
||||
let mut outer = Vec::new();
|
||||
for (x, start) in [(5., 90_f64), (15., 270_f64)] {
|
||||
for i in 0..=64 {
|
||||
let a = (start + 180. * i as f64 / 64.).to_radians();
|
||||
outer.push([x + 1.1 * a.cos(), 10. + 1.1 * a.sin()]);
|
||||
}
|
||||
}
|
||||
Boundary { outer, islands: vec![] }
|
||||
}
|
||||
fn with_boundary(boundary: Boundary) -> Result<JobInput> {
|
||||
let mut input = super::fixture("pocket")?;
|
||||
input.setup.name = "2.2 mm capsule channel".into();
|
||||
input.tools[0].name = "2 mm center-cutting end mill".into();
|
||||
input.tools[0].diameter = 2.;
|
||||
input.tools[0].shank_diameter = 2.;
|
||||
input.operations[0].name = "Finish narrow channel".into();
|
||||
input.operations[0].stepover = 0.75;
|
||||
input.operations[0].geometry = Strategy::Pocket { boundary: boundary.clone() };
|
||||
input.geometry.target.clear();
|
||||
super::extended::flat(&mut input.geometry.target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), 6.);
|
||||
super::extended::flat(&mut input.geometry.target, &boundary.outer, &[], 5.);
|
||||
super::extended::wall(&mut input.geometry.target, &boundary.outer, 6., 5.);
|
||||
input.fingerprint.clear();
|
||||
input.fingerprint = crate::fingerprint(&input)?;
|
||||
Ok(input)
|
||||
}
|
||||
pub(super) fn fixture() -> Result<JobInput> {
|
||||
with_boundary(capsule())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::verify::Status;
|
||||
|
||||
fn program(input: &JobInput) -> nc::Parsed {
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
nc::round_trip(&path, &input.setup, &input.tools).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_channel_finishes_protected_walls_and_floor() {
|
||||
let input = fixture().unwrap();
|
||||
// A rectangle plus two polygonal semicircles, each with 64 segments.
|
||||
let area = 22. + 64. * 1.1_f64.powi(2) * (std::f64::consts::PI / 64.).sin();
|
||||
let volume: f64 =
|
||||
input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum();
|
||||
assert!((volume - (2400. - area)).abs() < 1e-8);
|
||||
let parsed = program(&input);
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!((report.status, report.finished_dimensions), (Status::Pass, Status::Pass), "{:?}", report.findings);
|
||||
for (xy, expected) in super::super::probes("narrow-channel", 1).unwrap() {
|
||||
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_tool_and_inaccessible_neck_cannot_succeed() {
|
||||
let mut input = fixture().unwrap();
|
||||
input.tools[0].diameter = 2.4;
|
||||
input.tools[0].shank_diameter = 2.4;
|
||||
assert!(generate::generate(input).unwrap_err().0.contains("tool cannot fit pocket"));
|
||||
|
||||
// R2 lobes linked by a 1.6 mm wide, >6 mm long neck. A 2 mm tool
|
||||
// can enter both lobes but cannot reach the center of the neck.
|
||||
let angle = 0.4_f64.asin();
|
||||
let mut outer = Vec::new();
|
||||
for (x, start) in [(5., angle), (15., std::f64::consts::PI + angle)] {
|
||||
for i in 0..=128 {
|
||||
let a = start + (std::f64::consts::TAU - 2. * angle) * i as f64 / 128.;
|
||||
outer.push([x + 2. * a.cos(), 10. + 2. * a.sin()]);
|
||||
}
|
||||
}
|
||||
let input = with_boundary(Boundary { outer, islands: vec![] }).unwrap();
|
||||
let parsed = program(&input);
|
||||
assert_eq!(verify::probe_height(&input, &parsed, [10., 10.]).unwrap(), 6.);
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.finished_dimensions, Status::Fail, "{:?}", report.findings);
|
||||
assert!(report.findings.iter().any(|f| f.kind == "finished_region_error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_lead_is_rejected_against_original_protected_geometry() {
|
||||
let mut input = fixture().unwrap();
|
||||
input.operations[0].lead_distance = 5.;
|
||||
input.fingerprint.clear();
|
||||
input.fingerprint = crate::fingerprint(&input).unwrap();
|
||||
let parsed = program(&input);
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.status, Status::Fail, "{:?}", report.findings);
|
||||
assert!(report.findings.iter().any(|f| f.severity == verify::FindingSeverity::Error && f.source.is_some()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Original dimensioned recipes. Analytic surfaces are triangulated independently
|
||||
//! of the CAM offset/generation algorithms; no proprietary artifacts are used.
|
||||
use crate::*;
|
||||
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
fn circle(center: [f64; 2], radius: f64) -> Vec<[f64; 2]> {
|
||||
(0..96)
|
||||
.map(|i| {
|
||||
let a = i as f64 * std::f64::consts::TAU / 96.;
|
||||
[center[0] + radius * a.cos(), center[1] + radius * a.sin()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// An L with R2 convex corners and an R2 reentrant corner. The sampled polygon
|
||||
// itself is the recipe, so there is no unstated curve approximation in its input.
|
||||
fn boundary() -> Boundary {
|
||||
let mut outer = Vec::new();
|
||||
for (center, start, sweep) in
|
||||
[([4., 4.], 180., 90.), ([16., 4.], 270., 90.), ([16., 8.], 0., 90.), ([12., 12.], 270., -90.), ([8., 16.], 0., 90.), ([4., 16.], 90., 90.)]
|
||||
{
|
||||
for i in 0..=32 {
|
||||
let a = (start + sweep * i as f64 / 32.) * std::f64::consts::PI / 180.;
|
||||
outer.push([center[0] + 2. * a.cos(), center[1] + 2. * a.sin()]);
|
||||
}
|
||||
}
|
||||
Boundary { outer, islands: vec![circle([6., 6.], 0.8), circle([6., 13.], 0.8)] }
|
||||
}
|
||||
fn ring(points: &[[f64; 2]]) -> Loop2 {
|
||||
Loop2 {
|
||||
segs: points
|
||||
.iter()
|
||||
.zip(points.iter().cycle().skip(1))
|
||||
.take(points.len())
|
||||
.map(|(a, b)| Seg2::Line { a: Vec2::new(a[0], a[1]), b: Vec2::new(b[0], b[1]) })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub(super) fn flat(out: &mut Vec<[Vec3; 3]>, outer: &[[f64; 2]], holes: &[Vec<[f64; 2]>], z: f64) {
|
||||
let region = Region2 { outer: ring(outer), holes: holes.iter().map(|h| ring(h)).collect() };
|
||||
for t in region.triangulate(1e-6) {
|
||||
if (t[1] - t[0]).cross(t[2] - t[0]).abs() > 1e-14 {
|
||||
out.push(t.map(|p| Vec3::new(p.x, p.y, z)));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn wall(out: &mut Vec<[Vec3; 3]>, points: &[[f64; 2]], top: f64, bottom: f64) {
|
||||
for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
|
||||
let (a, b) = (Vec3::new(a[0], a[1], top), Vec3::new(b[0], b[1], top));
|
||||
let (c, d) = (Vec3::new(a.x, a.y, bottom), Vec3::new(b.x, b.y, bottom));
|
||||
out.extend([[a, b, d], [a, d, c]]);
|
||||
}
|
||||
}
|
||||
fn target(boundary: &Boundary, top: f64, drilled: bool) -> Vec<[Vec3; 3]> {
|
||||
let mut out = Vec::new();
|
||||
flat(&mut out, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&boundary.outer), top);
|
||||
let mut holes = boundary.islands.clone();
|
||||
let drill = circle([15., 6.], 0.5);
|
||||
if drilled {
|
||||
holes.push(drill.clone());
|
||||
}
|
||||
flat(&mut out, &boundary.outer, &holes, 4.);
|
||||
wall(&mut out, &boundary.outer, top, 4.);
|
||||
for island in &boundary.islands {
|
||||
flat(&mut out, island, &[], top);
|
||||
wall(&mut out, island, top, 4.);
|
||||
}
|
||||
if drilled {
|
||||
let shoulder = 3. + 0.5 / 59_f64.to_radians().tan();
|
||||
wall(&mut out, &drill, 4., shoulder);
|
||||
for (a, b) in drill.iter().zip(drill.iter().cycle().skip(1)).take(drill.len()) {
|
||||
out.push([Vec3::new(15., 6., 3.), Vec3::new(a[0], a[1], shoulder), Vec3::new(b[0], b[1], shoulder)]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn fixture(name: &str) -> Result<JobInput> {
|
||||
let mut input = super::fixture("pocket")?;
|
||||
let mut boundary = boundary();
|
||||
input.setup.name = "Rounded L with two islands".into();
|
||||
input.tools[0].diameter = 2.;
|
||||
input.tools[0].shank_diameter = 2.;
|
||||
input.tools[0].name = "2 mm center-cutting end mill".into();
|
||||
let mut pocket = input.operations[0].clone();
|
||||
pocket.name = "Concave pocket and islands".into();
|
||||
pocket.bottom = 4.;
|
||||
pocket.stepdown = 0.5;
|
||||
pocket.stepover = 0.75;
|
||||
// Exercise winding normalization independently for outer and island loops.
|
||||
boundary.outer.reverse();
|
||||
if let Some(first) = boundary.islands.first_mut() {
|
||||
first.reverse();
|
||||
}
|
||||
pocket.geometry = Strategy::Pocket { boundary: boundary.clone() };
|
||||
input.geometry.target = target(&boundary, if name == "multi-operation" { 5.5 } else { 6. }, name == "multi-operation");
|
||||
input.operations = vec![pocket.clone()];
|
||||
if name == "multi-operation" {
|
||||
let mut face = super::fixture("face")?;
|
||||
face.tools[0].id = 2;
|
||||
face.tools[0].number = 2;
|
||||
face.operations[0].id = 1;
|
||||
face.operations[0].tool = 2;
|
||||
face.operations[0].bottom = 5.5;
|
||||
pocket.id = 2;
|
||||
pocket.top = 5.5;
|
||||
pocket.allowance = 0.2;
|
||||
pocket.coolant = true;
|
||||
let mut finish = pocket.clone();
|
||||
finish.id = 3;
|
||||
finish.name = "Finish pocket".into();
|
||||
finish.allowance = 0.;
|
||||
finish.climb = false;
|
||||
let mut drill = super::fixture("drill")?;
|
||||
drill.tools[0].id = 3;
|
||||
drill.tools[0].number = 3;
|
||||
drill.operations[0].id = 4;
|
||||
drill.operations[0].tool = 3;
|
||||
drill.operations[0].top = 4.;
|
||||
drill.operations[0].bottom = 3.;
|
||||
drill.operations[0].geometry = Strategy::Drill { holes: vec![[15., 6.]], peck: Some(0.3), full_diameter: false };
|
||||
input.tools.extend([face.tools.remove(0), drill.tools.remove(0)]);
|
||||
input.operations = vec![face.operations.remove(0), pocket, finish, drill.operations.remove(0)];
|
||||
input.setup.frame.origin = Vec3::new(35., -20., 10.);
|
||||
input.setup.work_offset = 55;
|
||||
}
|
||||
input.fingerprint.clear();
|
||||
input.fingerprint = crate::fingerprint(&input)?;
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn protected_target_volume_matches_the_original_recipe() {
|
||||
// Polygonal R2 quarter arcs contain 32 equal angle segments; islands
|
||||
// and the drill use 96. These areas come from triangle sector formulae.
|
||||
let pocket_area = 176. + 256. * (std::f64::consts::PI / 64.).sin() - 96. * 0.8_f64.powi(2) * (std::f64::consts::TAU / 96.).sin();
|
||||
for name in ["concave-islands", "multi-operation"] {
|
||||
let input = fixture(name).unwrap();
|
||||
let volume: f64 =
|
||||
input.geometry.target.iter().map(|t| ((t[1] - t[0]).cross(t[2] - t[0]).z / 2.).abs() * (t[0].z + t[1].z + t[2].z) / 3.).sum();
|
||||
let expected = if name == "concave-islands" {
|
||||
2400. - 2. * pocket_area
|
||||
} else {
|
||||
let hole_area = 12. * (std::f64::consts::TAU / 96.).sin();
|
||||
2200. - 1.5 * pocket_area - hole_area * (1. - 1. / (3. * 59_f64.to_radians().tan()))
|
||||
};
|
||||
assert!((volume - expected).abs() < 1e-8, "{name}: {volume} != {expected}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn extended_fixtures_certify_complete_protected_surfaces() {
|
||||
for name in ["concave-islands", "multi-operation"] {
|
||||
let input = fixture(name).unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(
|
||||
(report.status, report.finished_dimensions),
|
||||
(verify::Status::Pass, verify::Status::Pass),
|
||||
"{name} {} cells {} work {:?}",
|
||||
report.cells.len(),
|
||||
report.work,
|
||||
report.findings.iter().take(4).collect::<Vec<_>>()
|
||||
);
|
||||
for (xy, expected) in super::super::probes(name, input.operations.len()).unwrap() {
|
||||
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE, "{name} {xy:?}");
|
||||
}
|
||||
// Adaptive drill-tip cells must form a conforming stock surface,
|
||||
// including where their refinement meets the pocket floor.
|
||||
verify::stock_triangles(&report, input.setup.stock.min.z).unwrap();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn intermediate_roughing_is_allowed_but_missing_finishing_is_not() {
|
||||
let mut input = fixture("multi-operation").unwrap();
|
||||
input.operations.truncate(2);
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let rough = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(
|
||||
(rough.status, rough.finished_dimensions),
|
||||
(verify::Status::Pass, verify::Status::Pass),
|
||||
"{:?}",
|
||||
rough.findings.iter().take(5).collect::<Vec<_>>()
|
||||
);
|
||||
assert!((verify::probe_height(&input, &parsed, [6., 9.]).unwrap() - 4.2).abs() < 1e-8);
|
||||
let mut finished = fixture("multi-operation").unwrap();
|
||||
finished.operations.truncate(3);
|
||||
let missing = verify::simulate(&finished, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(missing.finished_dimensions, verify::Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn removed_islands_wrong_tool_and_wrong_work_offset_are_rejected() {
|
||||
let input = fixture("concave-islands").unwrap();
|
||||
let mut damaged = input.clone();
|
||||
if let Strategy::Pocket { boundary } = &mut damaged.operations[0].geometry {
|
||||
boundary.islands.clear();
|
||||
}
|
||||
let path = generate::generate(damaged).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let result = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(result.status, verify::Status::Fail);
|
||||
assert_eq!(result.finished_dimensions, verify::Status::Fail);
|
||||
let input = fixture("multi-operation").unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let tools: Vec<_> = path.blocks.iter().filter_map(|b| if let Motion::Tool { number, .. } = b.motion { Some(number) } else { None }).collect();
|
||||
assert_eq!(tools, vec![2, 1, 1, 3]);
|
||||
let code = nc::post(&path, &input.setup).unwrap();
|
||||
let wrong = nc::parse(&code.replace("T3 M6", "T1 M6"), &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &wrong, Default::default()).unwrap();
|
||||
assert_eq!(report.status, verify::Status::Fail);
|
||||
assert_eq!(report.finished_dimensions, verify::Status::Fail);
|
||||
let wrong = nc::parse(&code.replace("G55", "G54"), &input.tools).unwrap();
|
||||
assert!(verify::simulate(&input, &wrong, Default::default()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Outside contour of a square boss, with allowance roughing then finishing.
|
||||
use crate::*;
|
||||
|
||||
pub(super) fn fixture() -> Result<JobInput> {
|
||||
let mut input = super::fixture("contour")?;
|
||||
input.setup.name = "External square boss".into();
|
||||
input.tools[0].diameter = 2.;
|
||||
input.tools[0].shank_diameter = 2.;
|
||||
input.tools[0].name = "2 mm center-cutting end mill".into();
|
||||
let square = vec![[5., 5.], [15., 5.], [15., 15.], [5., 15.]];
|
||||
let mut target = Vec::new();
|
||||
super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], std::slice::from_ref(&square), 4.);
|
||||
super::extended::flat(&mut target, &square, &[], 6.);
|
||||
super::extended::wall(&mut target, &square, 6., 4.);
|
||||
input.geometry.target = target;
|
||||
let mut rough = input.operations[0].clone();
|
||||
rough.name = "External rough contour".into();
|
||||
rough.geometry = Strategy::Contour { boundary: Boundary { outer: square.into_iter().rev().collect(), islands: vec![] }, inside: false };
|
||||
rough.bottom = 4.;
|
||||
rough.stepdown = 0.5;
|
||||
rough.stepover = 1.;
|
||||
rough.allowance = 0.2;
|
||||
rough.lead_distance = 0.5;
|
||||
rough.ramp_angle = Some(5_f64.to_radians());
|
||||
let mut finish = rough.clone();
|
||||
finish.id = 2;
|
||||
finish.name = "External finish contour".into();
|
||||
finish.allowance = 0.;
|
||||
finish.climb = false;
|
||||
finish.coolant = true;
|
||||
input.operations = vec![rough, finish];
|
||||
input.fingerprint.clear();
|
||||
input.fingerprint = crate::fingerprint(&input)?;
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_contour_finishes_walls_with_leads_and_multiple_depths() {
|
||||
let input = fixture().unwrap();
|
||||
let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum();
|
||||
assert!((volume - 1800.).abs() < 1e-9);
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Arc { .. })));
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(
|
||||
(report.status, report.finished_dimensions),
|
||||
(verify::Status::Pass, verify::Status::Pass),
|
||||
"cells={} work={} blocks={} {:?}",
|
||||
report.cells.len(),
|
||||
report.work,
|
||||
path.blocks.len(),
|
||||
report.findings
|
||||
);
|
||||
for (xy, expected) in super::super::probes("external-contour", 2).unwrap() {
|
||||
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_contour_side_gouges_and_omitted_finish_leaves_allowance() {
|
||||
let input = fixture().unwrap();
|
||||
let mut wrong = input.clone();
|
||||
wrong.operations.truncate(1);
|
||||
if let Strategy::Contour { inside, .. } = &mut wrong.operations[0].geometry {
|
||||
*inside = true;
|
||||
}
|
||||
let path = generate::generate(wrong).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.status, verify::Status::Fail);
|
||||
let mut rough = input.clone();
|
||||
rough.operations.truncate(1);
|
||||
let path = generate::generate(rough).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.finished_dimensions, verify::Status::Fail, "cells={} work={} {:?}", report.cells.len(), report.work, report.findings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Two through holes in a plate with a raised clamp between their axes.
|
||||
//! Geometry and analytic expectations are independent of toolpath generation.
|
||||
use crate::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
pub(super) fn fixture() -> Result<JobInput> {
|
||||
let mut input = super::fixture("drill")?;
|
||||
input.setup.name = "Clamped through-hole plate".into();
|
||||
input.setup.fixtures = vec![BodyRef { body: "Center clamp".into(), occurrence: vec![] }];
|
||||
let mut simple = input.operations[0].clone();
|
||||
simple.name = "Simple through hole".into();
|
||||
simple.bottom = -0.25;
|
||||
simple.geometry = Strategy::Drill { holes: vec![[6., 10.]], peck: None, full_diameter: true };
|
||||
let mut peck = simple.clone();
|
||||
peck.id = 2;
|
||||
peck.name = "Peck through hole".into();
|
||||
peck.coolant = true;
|
||||
peck.geometry = Strategy::Drill { holes: vec![[14., 10.]], peck: Some(2.), full_diameter: true };
|
||||
input.operations = vec![simple, peck];
|
||||
let holes: Vec<Vec<[f64; 2]>> = [6., 14.]
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
(0..96)
|
||||
.map(|i| {
|
||||
let a = i as f64 * std::f64::consts::TAU / 96.;
|
||||
[x + 0.5 * a.cos(), 10. + 0.5 * a.sin()]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let mut target = Vec::new();
|
||||
super::extended::flat(&mut target, &[[0., 0.], [20., 0.], [20., 20.], [0., 20.]], &holes, 6.);
|
||||
for hole in &holes {
|
||||
super::extended::wall(&mut target, hole, 6., 0.);
|
||||
}
|
||||
input.geometry.target = target;
|
||||
// Closed, outward-oriented 2 x 2 x 2 mm clamp on the plate top. A
|
||||
// clearance-height transfer passes over it; a transfer at Z7 cannot.
|
||||
let p = [[9., 9., 6.], [11., 9., 6.], [11., 11., 6.], [9., 11., 6.], [9., 9., 8.], [11., 9., 8.], [11., 11., 8.], [9., 11., 8.]].map(Vec3::from);
|
||||
input.geometry.fixtures = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]
|
||||
.into_iter()
|
||||
.flat_map(|q| [[p[q[0]], p[q[1]], p[q[2]]], [p[q[0]], p[q[2]], p[q[3]]]])
|
||||
.collect();
|
||||
input.geometry.fixture_groups = vec![12];
|
||||
input.fingerprint.clear();
|
||||
input.fingerprint = crate::fingerprint(&input)?;
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clamped_through_holes_preserve_surfaces_and_export_open_bores() {
|
||||
let input = fixture().unwrap();
|
||||
// Each inscribed 96-gon has area 96/2 * r^2 * sin(2pi/96).
|
||||
let expected = 2400. - 6. * 24. * (std::f64::consts::TAU / 96.).sin();
|
||||
let volume: f64 = input.geometry.target.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).z.abs() / 2. * (t[0].z + t[1].z + t[2].z) / 3.).sum();
|
||||
assert!((volume - expected).abs() < 1e-8);
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!((report.status, report.finished_dimensions), (verify::Status::Pass, verify::Status::Pass), "{:?}", report.findings);
|
||||
for (xy, expected) in super::super::probes("clamped-through-holes", 2).unwrap() {
|
||||
assert!((verify::probe_height(&input, &parsed, xy).unwrap() - expected).abs() < TOLERANCE);
|
||||
}
|
||||
let stock = verify::stock_stl(&report, 0.).unwrap();
|
||||
let mesh = compare::read_stl(stock.as_bytes()).unwrap();
|
||||
assert_eq!(compare::stock_mesh(&report, &mesh, 0., TOLERANCE).unwrap().status, verify::Status::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_breakthrough_and_low_clamp_transfer_do_not_pass() {
|
||||
let input = fixture().unwrap();
|
||||
// Authored NC: the tip reaches the bottom but the full diameter does
|
||||
// not. Both bores retain a cone of material and must fail dimensions.
|
||||
let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nS6000 M3\nG1 Z0 F100\nG0 Z15\nG0 X14\nG1 Z0 F100\nG0 Z15\nM5\nM30";
|
||||
let parsed = nc::parse(code, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.finished_dimensions, verify::Status::Fail);
|
||||
// An otherwise clear rapid at Z7 crosses the raised clamp between
|
||||
// axes. Require the actual source line, not merely a non-pass status.
|
||||
let code = "G21 G90 G17 G94 G54\nG0 X6 Y10 Z15\nT1 M6\nG0 Z7\nG0 X14\nG0 Z15\nM30";
|
||||
let parsed = nc::parse(code, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.status, verify::Status::Fail);
|
||||
assert!(report.findings.iter().any(|f| f.kind == "fixture_cutter_collision" && f.line == Some(5)));
|
||||
let mut short = input;
|
||||
short.tools[0].cutting_length = 6.;
|
||||
assert!(generate::generate(short).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Coverage certificates use boundary incidence, never summed overlapping area.
|
||||
use crate::{spatial::Index, verify::Cell, *};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Coord(f64);
|
||||
impl Eq for Coord {}
|
||||
impl PartialOrd for Coord {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl Ord for Coord {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) }
|
||||
}
|
||||
}
|
||||
type Point = [Coord; 2];
|
||||
fn xy(p: Vec3) -> Point {
|
||||
[Coord(p.x), Coord(p.y)]
|
||||
}
|
||||
fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
|
||||
robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] })
|
||||
}
|
||||
fn corners(min: [f64; 2], max: [f64; 2]) -> [[f64; 2]; 4] {
|
||||
[min, [max[0], min[1]], max, [min[0], max[1]]]
|
||||
}
|
||||
fn predicate_coordinates(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
// Avoid underflow in adaptive predicate expansions. Such coordinates are
|
||||
// far below the supported machining error budget; reject, never snap.
|
||||
if values.into_iter().any(|v| v != 0. && v.abs() < 1e-100) {
|
||||
return invalid("comparison coordinates are below the predicate numeric range");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject overlaps and hostile intervals; certify exact rectangular tiling in
|
||||
/// O(n log n). Missing strips of any width stay missing, with no area epsilon.
|
||||
pub(crate) fn cells(cells: &[Cell], stock: Stock) -> Result<bool> {
|
||||
stock.validate()?;
|
||||
if cells.is_empty() || cells.len() > 1_000_000 {
|
||||
return invalid("invalid comparison cell count");
|
||||
}
|
||||
let mut events = Vec::with_capacity(cells.len() * 2);
|
||||
for (id, c) in cells.iter().enumerate() {
|
||||
for p in [Vec3::new(c.min[0], c.min[1], c.low), Vec3::new(c.max[0], c.max[1], c.high)] {
|
||||
model::point(p)?;
|
||||
}
|
||||
predicate_coordinates(c.min.into_iter().chain(c.max))?;
|
||||
if c.min[0] >= c.max[0]
|
||||
|| c.min[1] >= c.max[1]
|
||||
|| c.low > c.high
|
||||
|| c.low < stock.min.z
|
||||
|| c.high > stock.max.z
|
||||
|| c.min[0] < stock.min.x
|
||||
|| c.min[1] < stock.min.y
|
||||
|| c.max[0] > stock.max.x
|
||||
|| c.max[1] > stock.max.y
|
||||
{
|
||||
return invalid("comparison cell outside declared stock or invalid interval");
|
||||
}
|
||||
events.push((Coord(c.min[0]), true, id));
|
||||
events.push((Coord(c.max[0]), false, id));
|
||||
}
|
||||
events.sort_unstable();
|
||||
let mut active = BTreeMap::<Coord, Coord>::new();
|
||||
let mut gaps = 1isize;
|
||||
let mut previous = stock.min.x;
|
||||
let mut complete = true;
|
||||
for (at, start, id) in events {
|
||||
if at.0 > previous && gaps != 0 {
|
||||
complete = false;
|
||||
}
|
||||
previous = at.0;
|
||||
let c = &cells[id];
|
||||
let a = Coord(c.min[1]);
|
||||
let b = Coord(c.max[1]);
|
||||
if start {
|
||||
let left = active.range(..=a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end);
|
||||
let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start);
|
||||
if left > a || right < b {
|
||||
return invalid("overlapping comparison cells");
|
||||
}
|
||||
gaps += isize::from(left != a) + isize::from(b != right) - isize::from(left != right);
|
||||
active.insert(a, b);
|
||||
} else {
|
||||
if active.remove(&a) != Some(b) {
|
||||
return invalid("invalid comparison cell sweep");
|
||||
}
|
||||
let left = active.range(..a).next_back().map_or(Coord(stock.min.y), |(_, end)| *end);
|
||||
let right = active.range(a..).next().map_or(Coord(stock.max.y), |(start, _)| *start);
|
||||
gaps += isize::from(left != right) - isize::from(left != a) - isize::from(b != right);
|
||||
}
|
||||
}
|
||||
Ok(complete && previous == stock.max.x)
|
||||
}
|
||||
|
||||
pub(crate) struct MeshField<'a> {
|
||||
triangles: &'a [[Vec3; 3]],
|
||||
index: Index,
|
||||
boundary: Vec<([f64; 2], [f64; 2])>,
|
||||
boundary_index: Index,
|
||||
stock: Stock,
|
||||
guard: f64,
|
||||
}
|
||||
impl<'a> MeshField<'a> {
|
||||
pub(crate) fn new(triangles: &'a [[Vec3; 3]], stock: Stock) -> Result<Self> {
|
||||
stock.validate()?;
|
||||
crate::stock_mesh::validate_triangles(triangles)?;
|
||||
let mut edges = BTreeMap::<(Point, Point), i32>::new();
|
||||
let mut items = Vec::new();
|
||||
let extent = stock.min.len().max(stock.max.len()).max(1.);
|
||||
for (id, t) in triangles.iter().enumerate() {
|
||||
for p in t {
|
||||
predicate_coordinates([p.x, p.y])?;
|
||||
if p.x < stock.min.x || p.y < stock.min.y || p.z < stock.min.z || p.x > stock.max.x || p.y > stock.max.y || p.z > stock.max.z {
|
||||
return invalid("reference stock mesh exceeds declared stock; check units and frame");
|
||||
}
|
||||
}
|
||||
let p = t.map(|v| [v.x, v.y]);
|
||||
let orientation = orient(p[0], p[1], p[2]);
|
||||
if orientation < 0. && t.iter().any(|p| p.z != stock.min.z) {
|
||||
return invalid("reference stock is not a bottom-supported vertical height field");
|
||||
}
|
||||
if orientation <= 0. {
|
||||
continue;
|
||||
}
|
||||
let min = t.iter().copied().fold(t[0], Vec3::min);
|
||||
let max = t.iter().copied().fold(t[0], Vec3::max);
|
||||
items.push((id, [min.x, min.y], [max.x, max.y]));
|
||||
for (a, b) in [(xy(t[0]), xy(t[1])), (xy(t[1]), xy(t[2])), (xy(t[2]), xy(t[0]))] {
|
||||
let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) };
|
||||
*edges.entry(key).or_default() += sign;
|
||||
}
|
||||
}
|
||||
let boundary: Vec<_> = edges.into_iter().filter(|(_, n)| *n != 0).map(|((a, b), _)| (a.map(|v| v.0), b.map(|v| v.0))).collect();
|
||||
let boundary_index = Index::new(
|
||||
boundary.iter().enumerate().map(|(id, (a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(),
|
||||
);
|
||||
Ok(Self { triangles, index: Index::new(items), boundary, boundary_index, stock, guard: 128. * f64::EPSILON * extent })
|
||||
}
|
||||
/// Bounds the maximum upward surface everywhere in a rectangle, including
|
||||
/// holes (height = stock bottom). Shared projected edges cancel exactly;
|
||||
/// unmatched edges crossing the interior forbid a full-coverage certificate.
|
||||
pub(crate) fn bounds(&self, min: [f64; 2], max: [f64; 2], work: &mut usize, budget: usize) -> Option<[f64; 2]> {
|
||||
let rectangle = corners(min, max);
|
||||
let center = [(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.];
|
||||
let mut ids = Vec::new();
|
||||
self.boundary_index.query(min, max, &mut ids);
|
||||
let mut crossed = false;
|
||||
for id in ids {
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = self.boundary[id];
|
||||
if (0..2).any(|k| a[k].max(b[k]) <= min[k] || a[k].min(b[k]) >= max[k]) {
|
||||
continue;
|
||||
}
|
||||
let signs = rectangle.map(|c| orient(a, b, c));
|
||||
if signs.iter().any(|v| *v < 0.) && signs.iter().any(|v| *v > 0.) {
|
||||
crossed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
self.index.query(min, max, &mut ids);
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = self.stock.min.z;
|
||||
let mut inside = false;
|
||||
for id in ids {
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
return None;
|
||||
}
|
||||
let t = &self.triangles[id];
|
||||
let p = t.map(|p| [p.x, p.y]);
|
||||
// These cells form a tiling whose closed columns are united later.
|
||||
// A triangle touching only a cell edge belongs to the adjacent cell;
|
||||
// including its height here creates false interior discontinuities.
|
||||
// Positive-area overlap is decided with exact orientation predicates.
|
||||
if (0..2).any(|k| p.iter().all(|v| v[k] <= min[k]) || p.iter().all(|v| v[k] >= max[k])) {
|
||||
continue;
|
||||
}
|
||||
let edges = [(p[0], p[1]), (p[1], p[2]), (p[2], p[0])];
|
||||
if edges.iter().any(|(a, b)| rectangle.iter().all(|c| orient(*a, *b, *c) <= 0.)) {
|
||||
continue;
|
||||
}
|
||||
inside |= edges.iter().all(|(a, b)| orient(*a, *b, center) >= 0.);
|
||||
let mut lo = t.iter().map(|v| v.z).fold(f64::INFINITY, f64::min);
|
||||
let mut hi = t.iter().map(|v| v.z).fold(f64::NEG_INFINITY, f64::max);
|
||||
let n = (t[1] - t[0]).cross(t[2] - t[0]);
|
||||
let normal_error = 4. * self.guard * (1. + (t[1] - t[0]).len() + (t[2] - t[0]).len());
|
||||
if n.z > normal_error {
|
||||
let slope = [n.x / n.z, n.y / n.z];
|
||||
let slope_error = slope.map(|s| normal_error * (1. + s.abs()) / (n.z - normal_error));
|
||||
let error = self.guard * (1. + slope[0].abs() + slope[1].abs())
|
||||
+ slope_error[0] * (t[0].x - min[0]).abs().max((t[0].x - max[0]).abs())
|
||||
+ slope_error[1] * (t[0].y - min[1]).abs().max((t[0].y - max[1]).abs());
|
||||
let z = rectangle.map(|p| t[0].z + slope[0] * (t[0].x - p[0]) + slope[1] * (t[0].y - p[1]));
|
||||
if error.is_finite() && z.iter().all(|v| v.is_finite()) {
|
||||
lo = lo.max(z.iter().copied().fold(f64::INFINITY, f64::min) - error);
|
||||
hi = hi.min(z.iter().copied().fold(f64::NEG_INFINITY, f64::max) + error);
|
||||
}
|
||||
}
|
||||
low = low.min(lo);
|
||||
high = high.max(hi);
|
||||
}
|
||||
if !inside || crossed {
|
||||
low = self.stock.min.z;
|
||||
}
|
||||
Some([(low - self.guard).max(self.stock.min.z), (high + self.guard).min(self.stock.max.z)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! Independent analytic finished-height envelopes. No path-generation or offset
|
||||
//! code is used here. Radial bands limit diameter error; depth is checked in Z.
|
||||
use crate::{verify::Status, *};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Envelope {
|
||||
lower_min: f64,
|
||||
lower_max: f64,
|
||||
upper_min: f64,
|
||||
upper_max: f64,
|
||||
}
|
||||
impl Envelope {
|
||||
fn range(lo: f64, hi: f64, tolerance: f64) -> Self {
|
||||
Self { lower_min: lo - tolerance, lower_max: hi - tolerance, upper_min: lo + tolerance, upper_max: hi + tolerance }
|
||||
}
|
||||
fn alternatives(self, b: Self) -> Self {
|
||||
Self {
|
||||
lower_min: self.lower_min.min(b.lower_min),
|
||||
lower_max: self.lower_max.max(b.lower_max),
|
||||
upper_min: self.upper_min.min(b.upper_min),
|
||||
upper_max: self.upper_max.max(b.upper_max),
|
||||
}
|
||||
}
|
||||
fn cut(self, b: Self) -> Self {
|
||||
Self {
|
||||
lower_min: self.lower_min.min(b.lower_min),
|
||||
lower_max: self.lower_max.min(b.lower_max),
|
||||
upper_min: self.upper_min.min(b.upper_min),
|
||||
upper_max: self.upper_max.min(b.upper_max),
|
||||
}
|
||||
}
|
||||
pub(crate) fn classify(self, low: f64, high: f64) -> Status {
|
||||
if high < self.lower_min || low > self.upper_max {
|
||||
Status::Fail
|
||||
} else if low >= self.lower_max && high <= self.upper_min {
|
||||
Status::Pass
|
||||
} else {
|
||||
Status::Inconclusive
|
||||
}
|
||||
}
|
||||
pub(crate) fn description(self) -> String {
|
||||
format!(
|
||||
"Finished-region height must lie within [{:.6}, {:.6}] mm; cell-wide certified range [{:.6}, {:.6}] mm",
|
||||
self.lower_min, self.upper_max, self.lower_max, self.upper_min
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum Feature {
|
||||
Plane(f64),
|
||||
Hole { center: [f64; 2], radius: f64, tip: f64, slope: f64 },
|
||||
Mill { region: crate::region::Region, outside: bool, width: Option<f64>, allowance: f64, depth: f64 },
|
||||
}
|
||||
pub(crate) struct Finished {
|
||||
features: Vec<Feature>,
|
||||
bottom: f64,
|
||||
top: f64,
|
||||
tolerance: f64,
|
||||
radial_tolerance: f64,
|
||||
pub(crate) refinement: f64,
|
||||
}
|
||||
impl Finished {
|
||||
pub(crate) fn new(input: &JobInput, tolerance: f64) -> Result<Option<Self>> {
|
||||
// Reserve radial error on both sides of a diameter, and convert XY
|
||||
// interpolation error into a conservative cone-height error as well.
|
||||
let has_walls = input.operations.iter().any(|o| !matches!(o.geometry, Strategy::Face));
|
||||
let profile_error = input.operations.iter().filter(|o| o.source.is_some()).map(|o| crate::profile_tolerance(o.tolerance)).fold(0.0, f64::max);
|
||||
let radial_tolerance = tolerance / 2.0 - POST_ERROR - profile_error;
|
||||
let slope = input
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|o| matches!(o.geometry, Strategy::Drill { .. }))
|
||||
.filter_map(|o| input.tools.iter().find(|t| t.id == o.tool))
|
||||
.map(|t| 1.0 / (t.tip_angle / 2.0).tan())
|
||||
.fold(0.0, f64::max);
|
||||
let tolerance = tolerance - POST_ERROR * (1.0 + slope);
|
||||
if tolerance <= 0.0 || (has_walls && radial_tolerance <= 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut features = Vec::new();
|
||||
for op in &input.operations {
|
||||
match &op.geometry {
|
||||
Strategy::Face => features.push(Feature::Plane(op.bottom + op.allowance)),
|
||||
Strategy::Drill { holes, full_diameter, .. } => {
|
||||
let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?;
|
||||
let tip = op.bottom - if *full_diameter { tool.tip_length() } else { 0.0 };
|
||||
for center in holes {
|
||||
if features.len() >= MAX_MOVES {
|
||||
return invalid("dimensional feature budget exceeded");
|
||||
}
|
||||
features.push(Feature::Hole { center: *center, radius: tool.diameter / 2.0, tip, slope: 1.0 / (tool.tip_angle / 2.0).tan() });
|
||||
}
|
||||
}
|
||||
Strategy::Contour { boundary, inside } => {
|
||||
if !boundary.islands.is_empty() {
|
||||
return invalid("select contour islands as separate operations");
|
||||
}
|
||||
let tool = input.tools.iter().find(|t| t.id == op.tool).ok_or_else(|| Error("missing dimensional tool".into()))?;
|
||||
features.push(Feature::Mill {
|
||||
region: crate::region::Region::new(boundary)?,
|
||||
outside: !inside,
|
||||
width: Some(tool.diameter / 2.),
|
||||
allowance: op.allowance,
|
||||
depth: op.bottom + op.allowance,
|
||||
});
|
||||
}
|
||||
Strategy::Pocket { boundary } => features.push(Feature::Mill {
|
||||
region: crate::region::Region::new(boundary)?,
|
||||
outside: false,
|
||||
width: None,
|
||||
allowance: op.allowance,
|
||||
depth: op.bottom + op.allowance,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(Some(Self {
|
||||
features,
|
||||
bottom: input.setup.stock.min.z,
|
||||
top: input.setup.stock.max.z,
|
||||
tolerance,
|
||||
radial_tolerance,
|
||||
refinement: if has_walls { VERIFY_ERROR.min(tolerance / 8.0).min(radial_tolerance / 4.0) } else { VERIFY_ERROR },
|
||||
}))
|
||||
}
|
||||
pub(crate) async fn envelope<F: std::future::Future<Output = ()>>(
|
||||
&self,
|
||||
region: ([f64; 2], f64),
|
||||
work: &mut usize,
|
||||
budget: usize,
|
||||
cancelled: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<Option<Envelope>> {
|
||||
let (p, half) = region;
|
||||
let unchanged = Envelope::range(self.top, self.top, self.tolerance);
|
||||
let mut combined = unchanged;
|
||||
for (index, feature) in self.features.iter().enumerate() {
|
||||
if index % 4096 == 4095 {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("dimensional verification cancelled");
|
||||
}
|
||||
}
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
return Ok(None);
|
||||
}
|
||||
let allowed = match feature {
|
||||
Feature::Plane(z) => {
|
||||
let z = z.clamp(self.bottom, self.top);
|
||||
Envelope::range(z, z, self.tolerance)
|
||||
}
|
||||
Feature::Mill { region, outside, width, allowance, depth } => {
|
||||
let reach = allowance + width.unwrap_or(0.) + self.radial_tolerance;
|
||||
let Some(mut d) = region.range((p, half, reach), work, budget, cancelled, yield_control).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if *outside {
|
||||
d = [-d[1], -d[0]];
|
||||
}
|
||||
let floor = Envelope::range(depth.clamp(self.bottom, self.top), depth.clamp(self.bottom, self.top), self.tolerance);
|
||||
// A contour finishes the wall and adjacent floor out to one
|
||||
// tool radius. Farther stock on the cutting side is optional
|
||||
// roughing stock; it must never be cut below the stated floor.
|
||||
let optional = Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..floor };
|
||||
let mut possible: Option<Envelope> = None;
|
||||
let mut add = |v| possible = Some(possible.map_or(v, |old| old.alternatives(v)));
|
||||
if d[0] <= allowance - self.radial_tolerance {
|
||||
add(unchanged);
|
||||
}
|
||||
if d[1] >= allowance - self.radial_tolerance && d[0] <= allowance + self.radial_tolerance {
|
||||
add(optional);
|
||||
}
|
||||
if d[1] >= allowance + self.radial_tolerance && width.is_none_or(|w| d[0] <= allowance + w) {
|
||||
add(floor);
|
||||
}
|
||||
if width.is_some_and(|w| d[1] >= allowance + w) {
|
||||
add(optional);
|
||||
}
|
||||
let Some(allowed) = possible else {
|
||||
return Ok(None);
|
||||
};
|
||||
allowed
|
||||
}
|
||||
Feature::Hole { center, radius, tip, slope } => {
|
||||
let r = (p[0] - center[0]).hypot(p[1] - center[1]);
|
||||
let lo = (r - half).max(0.0);
|
||||
let hi = r + half;
|
||||
let zlo = (tip + lo.min(*radius) * slope).clamp(self.bottom, self.top);
|
||||
let zhi = (tip + hi.min(*radius) * slope).clamp(self.bottom, self.top);
|
||||
let cut = Envelope::range(zlo, zhi, self.tolerance);
|
||||
let mut possible: Option<Envelope> = None;
|
||||
let mut add = |v| {
|
||||
possible = Some(possible.map_or(v, |old| old.alternatives(v)));
|
||||
};
|
||||
if lo <= radius - self.radial_tolerance {
|
||||
add(cut);
|
||||
}
|
||||
if hi >= radius + self.radial_tolerance {
|
||||
add(unchanged);
|
||||
}
|
||||
if hi >= radius - self.radial_tolerance && lo <= radius + self.radial_tolerance {
|
||||
add(Envelope { upper_min: unchanged.upper_min, upper_max: unchanged.upper_max, ..cut });
|
||||
}
|
||||
let Some(allowed) = possible else {
|
||||
return Ok(None);
|
||||
};
|
||||
allowed
|
||||
}
|
||||
};
|
||||
combined = combined.cut(allowed);
|
||||
}
|
||||
Ok(Some(combined))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn envelope(f: &Finished, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> Option<Envelope> {
|
||||
use std::future::Future;
|
||||
let mut ready = || std::future::ready(());
|
||||
let mut future = std::pin::pin!(f.envelope((p, half), work, budget, &|| false, &mut ready));
|
||||
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
|
||||
std::task::Poll::Ready(result) => result.unwrap(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn finished() -> Finished {
|
||||
Finished::new(&corpus::fixture("drill").unwrap(), TOLERANCE).unwrap().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn generated_contours_and_pockets_certify_selected_finished_regions() {
|
||||
for name in ["contour", "pocket"] {
|
||||
let input = corpus::fixture(name).unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let code = nc::post(&path, &input.setup).unwrap();
|
||||
let report = simulate(&input, &code);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
Status::Pass,
|
||||
"{name} cells {} work {} {:?}",
|
||||
report.cells.len(),
|
||||
report.work,
|
||||
report.findings.iter().take(4).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
report.finished_dimensions,
|
||||
Status::Pass,
|
||||
"{name} cells {} work {} {:?}",
|
||||
report.cells.len(),
|
||||
report.work,
|
||||
report.findings.iter().take(4).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn missed_milling_wrong_floor_and_inaccessible_corners_fail() {
|
||||
for name in ["contour", "pocket"] {
|
||||
let input = corpus::fixture(name).unwrap();
|
||||
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
|
||||
let wrong = simulate(&input, &code.replace("Z5.000000", "Z5.100000"));
|
||||
assert_eq!(wrong.finished_dimensions, Status::Fail, "{name}");
|
||||
let missed = simulate(&input, "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n");
|
||||
assert_eq!(missed.finished_dimensions, Status::Fail, "{name}");
|
||||
}
|
||||
let mut input = corpus::fixture("pocket").unwrap();
|
||||
input.operations[0].geometry =
|
||||
Strategy::Pocket { boundary: Boundary { outer: vec![[2., 2.], [18., 2.], [18., 18.], [2., 18.]], islands: vec![] } };
|
||||
// A round cutter cannot finish sharp internal corners. Protected geometry
|
||||
// is independently relaxed here to isolate the dimensional failure.
|
||||
for t in &mut input.geometry.target {
|
||||
for p in t {
|
||||
p.z = 0.;
|
||||
}
|
||||
}
|
||||
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
|
||||
assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn allowance_winding_multiple_depths_and_external_contours() {
|
||||
let mut input = corpus::fixture("contour").unwrap();
|
||||
input.operations[0].allowance = 0.2;
|
||||
input.operations[0].stepdown = 0.3;
|
||||
if let Strategy::Contour { boundary, .. } = &mut input.operations[0].geometry {
|
||||
boundary.outer.reverse();
|
||||
}
|
||||
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
|
||||
let r = simulate(&input, &code);
|
||||
assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings);
|
||||
if let Strategy::Contour { inside, .. } = &mut input.operations[0].geometry {
|
||||
*inside = false;
|
||||
}
|
||||
// The separate protected-CAD tests exercise real walls; this case checks
|
||||
// only the independent outside/inside selection and allowance contract.
|
||||
for t in &mut input.geometry.target {
|
||||
for p in t {
|
||||
p.z = 0.;
|
||||
}
|
||||
}
|
||||
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
|
||||
let r = simulate(&input, &code);
|
||||
assert_eq!(r.finished_dimensions, Status::Pass, "{:?}", r.findings);
|
||||
}
|
||||
#[test]
|
||||
fn pocket_islands_are_protected_and_finished() {
|
||||
let mut input = corpus::fixture("pocket").unwrap();
|
||||
if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry {
|
||||
boundary.islands.push(
|
||||
(0..48)
|
||||
.map(|i| {
|
||||
let a = i as f64 * std::f64::consts::TAU / 48.;
|
||||
[10. + a.cos(), 10. + a.sin()]
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
input.tools[0].diameter = 2.;
|
||||
input.operations[0].stepover = 0.75;
|
||||
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
|
||||
let r = simulate(&input, &code);
|
||||
assert_eq!(
|
||||
r.finished_dimensions,
|
||||
Status::Pass,
|
||||
"cells {} work {} {:?}",
|
||||
r.cells.len(),
|
||||
r.work,
|
||||
r.findings.iter().take(3).collect::<Vec<_>>()
|
||||
);
|
||||
let mut without = input.clone();
|
||||
if let Strategy::Pocket { boundary } = &mut without.operations[0].geometry {
|
||||
boundary.islands.clear();
|
||||
}
|
||||
let code = nc::post(&generate::generate(without.clone()).unwrap(), &without.setup).unwrap();
|
||||
assert_eq!(simulate(&input, &code).finished_dimensions, Status::Fail);
|
||||
if let Strategy::Pocket { boundary } = &mut input.operations[0].geometry {
|
||||
boundary.islands = [8., 12.]
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
(0..48)
|
||||
.map(|i| {
|
||||
let a = i as f64 * std::f64::consts::TAU / 48.;
|
||||
[x + 0.8 * a.cos(), 10. + 0.8 * a.sin()]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
|
||||
let r = simulate(&input, &code);
|
||||
assert_eq!(
|
||||
r.finished_dimensions,
|
||||
Status::Pass,
|
||||
"cells {} work {} {:?}",
|
||||
r.cells.len(),
|
||||
r.work,
|
||||
r.findings.iter().take(3).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn associated_profile_chord_error_has_a_separate_budget() {
|
||||
let mut input = corpus::fixture("pocket").unwrap();
|
||||
input.operations[0].source = Some(ProfileRef { sketch: 1, curves: vec!["wall".into()], occurrence: vec![] });
|
||||
let f = Finished::new(&input, TOLERANCE).unwrap().unwrap();
|
||||
assert!((f.radial_tolerance - (TOLERANCE / 2. - POST_ERROR - crate::profile_tolerance(TOLERANCE))).abs() < 1e-12);
|
||||
assert_eq!(crate::profile_tolerance(TOLERANCE), 0.0005);
|
||||
// A looser operation can coexist with a tighter program tolerance. Use
|
||||
// the actual sampling budget of each source, not the program minimum.
|
||||
input.operations[0].tolerance = 0.1;
|
||||
let f = Finished::new(&input, 0.005).unwrap().unwrap();
|
||||
assert!((f.radial_tolerance - 0.001).abs() < 1e-12);
|
||||
let mut unused = input.tools[0].clone();
|
||||
unused.id = 2;
|
||||
unused.number = 2;
|
||||
unused.kind = ToolKind::TwistDrill;
|
||||
unused.tip_angle = 0.11;
|
||||
input.tools.push(unused);
|
||||
assert!(Finished::new(&input, 0.005).unwrap().is_some());
|
||||
}
|
||||
#[test]
|
||||
fn wrong_milling_diameter_and_shifted_paths_are_detected() {
|
||||
let mut input = corpus::fixture("contour").unwrap();
|
||||
let code = nc::post(&generate::generate(input.clone()).unwrap(), &input.setup).unwrap();
|
||||
for diameter in [5.8, 6.2] {
|
||||
let mut other = input.tools[0].clone();
|
||||
other.id = 2;
|
||||
other.number = 2;
|
||||
other.diameter = diameter;
|
||||
input.tools.truncate(1);
|
||||
input.tools.push(other);
|
||||
assert_eq!(simulate(&input, &code.replace("T1 M6", "T2 M6")).finished_dimensions, Status::Fail);
|
||||
}
|
||||
let mut shifted = nc::parse(&code, &input.tools).unwrap();
|
||||
for record in &mut shifted.records {
|
||||
match &mut record.motion {
|
||||
Motion::Rapid { to } | Motion::Linear { to, .. } => to.x += 0.03,
|
||||
Motion::Arc { to, center, .. } => {
|
||||
to.x += 0.03;
|
||||
center.x += 0.03;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(verify::simulate(&input, &shifted, Default::default()).unwrap().finished_dimensions, Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn hand_calculated_cone_and_diameter_band() {
|
||||
let f = finished();
|
||||
let mut work = 0;
|
||||
assert_eq!(envelope(&f, [10., 10.], 0., &mut work, 100).unwrap().classify(5., 5.), Status::Pass);
|
||||
let z = 5. + 0.25 / (59_f64.to_radians().tan());
|
||||
assert_eq!(envelope(&f, [10.25, 10.], 0., &mut work, 100).unwrap().classify(z, z), Status::Pass);
|
||||
assert_eq!(envelope(&f, [10.49, 10.], 0., &mut work, 100).unwrap().classify(6., 6.), Status::Fail);
|
||||
assert_eq!(envelope(&f, [10.51, 10.], 0., &mut work, 100).unwrap().classify(5.3, 5.3), Status::Fail);
|
||||
assert_eq!(envelope(&f, [10.5, 10.], 0., &mut work, 100).unwrap().classify(5.31, 6.), Status::Pass);
|
||||
}
|
||||
#[test]
|
||||
fn boundary_uncertainty_and_resource_limits_never_pass() {
|
||||
let f = finished();
|
||||
let mut work = 0;
|
||||
assert_eq!(envelope(&f, [10.5, 10.], 0.01, &mut work, 100).unwrap().classify(5.3, 6.), Status::Inconclusive);
|
||||
assert!(envelope(&f, [10., 10.], 0., &mut work, 0).is_none());
|
||||
}
|
||||
fn program(tool: u32, x: f64, depth: f64) -> String {
|
||||
format!(
|
||||
"G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nT{tool} M6\nS6000 M3\nM9\nG0 X{x} Y10 Z15\nG1 Z6 F100\nG1 Z{depth} F100\nG0 Z15\nM5\nM9\nM30\n"
|
||||
)
|
||||
}
|
||||
fn simulate(input: &JobInput, code: &str) -> crate::verify::Report {
|
||||
let parsed = nc::parse(code, &input.tools).unwrap();
|
||||
verify::simulate(input, &parsed, Default::default()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn blind_hole_certifies_and_bad_depth_position_diameter_fail() {
|
||||
let mut input = corpus::fixture("drill").unwrap();
|
||||
let correct = simulate(&input, &program(1, 10., 5.));
|
||||
assert_eq!(correct.status, Status::Pass);
|
||||
assert_eq!(correct.finished_dimensions, Status::Pass, "{:?}", correct.findings);
|
||||
for (x, depth) in [(10., 5.1), (10., 4.9), (10.03, 5.)] {
|
||||
assert_eq!(simulate(&input, &program(1, x, depth)).finished_dimensions, Status::Fail);
|
||||
}
|
||||
for diameter in [0.8, 1.2] {
|
||||
let mut other = input.tools[0].clone();
|
||||
other.id = 2;
|
||||
other.number = 2;
|
||||
other.diameter = diameter;
|
||||
other.shank_diameter = diameter;
|
||||
input.tools.truncate(1);
|
||||
input.tools.push(other);
|
||||
assert_eq!(simulate(&input, &program(2, 10., 5.)).finished_dimensions, Status::Fail);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn absent_hole_is_not_hidden_by_a_coarse_stock_cell() {
|
||||
let input = corpus::fixture("drill").unwrap();
|
||||
let code = "G21 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z15\nM30\n";
|
||||
let report = simulate(&input, code);
|
||||
assert_eq!(report.finished_dimensions, Status::Fail);
|
||||
assert!(report.findings.iter().any(|f| f.kind == "finished_region_error"));
|
||||
}
|
||||
#[test]
|
||||
fn full_diameter_blind_and_through_depths_are_certified() {
|
||||
for bottom in [4.5, 0.] {
|
||||
let mut input = corpus::fixture("drill").unwrap();
|
||||
input.operations[0].bottom = bottom;
|
||||
input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.]], peck: None, full_diameter: true };
|
||||
for tri in &mut input.geometry.target {
|
||||
for p in tri {
|
||||
p.z = 0.;
|
||||
}
|
||||
}
|
||||
let depth = bottom - input.tools[0].tip_length();
|
||||
let report = simulate(&input, &program(1, 10., depth));
|
||||
assert_eq!(report.status, Status::Pass, "{:?}", report.findings);
|
||||
assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn declared_drill_allowance_is_rejected_instead_of_ignored() {
|
||||
let mut input = corpus::fixture("drill").unwrap();
|
||||
input.operations[0].allowance = 0.1;
|
||||
assert!(input.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn facing_and_multiple_holes_compose_without_restoring_stock() {
|
||||
let mut input = corpus::fixture("drill").unwrap();
|
||||
for t in &mut input.geometry.target {
|
||||
for p in t {
|
||||
p.z = 0.0;
|
||||
}
|
||||
}
|
||||
input.operations[0].geometry = Strategy::Drill { holes: vec![[10., 10.], [12., 10.]], peck: Some(0.3), full_diameter: false };
|
||||
let mut face = corpus::fixture("face").unwrap();
|
||||
face.tools[0].id = 2;
|
||||
face.tools[0].number = 2;
|
||||
face.operations[0].id = 2;
|
||||
face.operations[0].tool = 2;
|
||||
face.operations[0].bottom = 5.8;
|
||||
input.tools.push(face.tools[0].clone());
|
||||
input.operations.push(face.operations[0].clone());
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let code = nc::post(&path, &input.setup).unwrap();
|
||||
let report = simulate(&input, &code);
|
||||
assert_eq!(report.finished_dimensions, Status::Pass, "{:?}", report.findings);
|
||||
}
|
||||
#[test]
|
||||
fn dimensional_work_yields_and_cancels() {
|
||||
use std::{
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
let mut f = finished();
|
||||
f.features = (0..5000).map(|_| Feature::Plane(5.)).collect();
|
||||
let cancel = std::cell::Cell::new(false);
|
||||
let mut yield_once = || {
|
||||
let mut first = true;
|
||||
std::future::poll_fn(move |_| {
|
||||
if first {
|
||||
first = false;
|
||||
Poll::Pending
|
||||
} else {
|
||||
Poll::Ready(())
|
||||
}
|
||||
})
|
||||
};
|
||||
let mut work = 0;
|
||||
let cancelled = || cancel.get();
|
||||
let mut future = std::pin::pin!(f.envelope(([10., 10.], 0.001), &mut work, 10000, &cancelled, &mut yield_once));
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(future.as_mut().poll(&mut context).is_pending());
|
||||
cancel.set(true);
|
||||
assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_))));
|
||||
}
|
||||
#[test]
|
||||
fn equivalent_inch_and_mm_holes_certify_the_same_dimensions() {
|
||||
let input = corpus::fixture("drill").unwrap();
|
||||
let inch = format!(
|
||||
"G20 G90 G17 G94 G54 G40 G49 G80\nG0 X0 Y0 Z{}\nT1 M6\nS6000 M3\nM9\nG0 X{} Y{} Z{}\nG1 Z{} F{}\nG1 Z{}\nG0 Z{}\nM5\nM9\nM30\n",
|
||||
15. / 25.4,
|
||||
10. / 25.4,
|
||||
10. / 25.4,
|
||||
15. / 25.4,
|
||||
6. / 25.4,
|
||||
100. / 25.4,
|
||||
5. / 25.4,
|
||||
15. / 25.4
|
||||
);
|
||||
let metric = simulate(&input, &program(1, 10., 5.));
|
||||
let imperial = simulate(&input, &inch);
|
||||
assert_eq!(imperial.finished_dimensions, Status::Pass);
|
||||
assert!((imperial.cutting_distance - metric.cutting_distance).abs() < 1e-10);
|
||||
assert!((imperial.remaining_volume_high - metric.remaining_volume_high).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
//! Toolpath construction. No verifier or NC interpreter algorithms live here.
|
||||
use crate::*;
|
||||
use cavalier_contours::polyline::{PlineSource, PlineSourceMut, Polyline, seg_arc_radius_and_center, seg_length};
|
||||
use cavalier_contours::shape_algorithms::{Shape, ShapeOffsetOptions};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
|
||||
/// A portable job advances a bounded emission/validation chunk or one bounded
|
||||
/// library offset query per poll. No executor, OS thread or browser API is used.
|
||||
pub struct Generator {
|
||||
pending: Option<Pin<Box<dyn Future<Output = Result<Toolpath>> + Send>>>,
|
||||
path: Option<Toolpath>,
|
||||
progress: Arc<AtomicUsize>,
|
||||
failed: Option<String>,
|
||||
cancelled: bool,
|
||||
}
|
||||
impl Generator {
|
||||
pub fn new(input: JobInput) -> Result<Self> {
|
||||
input.validate()?;
|
||||
if input.operations.is_empty() || input.operations.len() > MAX_ITEMS {
|
||||
return invalid("empty or excessive operation list");
|
||||
}
|
||||
let progress = Arc::new(AtomicUsize::new(0));
|
||||
let pending = Box::pin(generate_incremental(input, progress.clone()));
|
||||
Ok(Self { pending: Some(pending), path: None, progress, failed: None, cancelled: false })
|
||||
}
|
||||
pub fn cancel(&mut self) {
|
||||
self.cancelled = true;
|
||||
self.pending = None;
|
||||
self.path = None;
|
||||
}
|
||||
pub fn progress(&self) -> f64 {
|
||||
self.progress.load(Ordering::Relaxed) as f64 / 1_000_000.
|
||||
}
|
||||
pub fn step(&mut self) -> Result<bool> {
|
||||
if self.cancelled {
|
||||
return invalid("generation cancelled");
|
||||
}
|
||||
if let Some(error) = &self.failed {
|
||||
return invalid(error.clone());
|
||||
}
|
||||
if self.path.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
let future = self.pending.as_mut().ok_or_else(|| Error("generation has no pending work".into()))?;
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| future.as_mut().poll(&mut Context::from_waker(Waker::noop()))));
|
||||
match outcome {
|
||||
Ok(Poll::Pending) => Ok(false),
|
||||
Ok(Poll::Ready(Ok(path))) => {
|
||||
self.pending = None;
|
||||
self.path = Some(path);
|
||||
self.progress.store(1_000_000, Ordering::Relaxed);
|
||||
Ok(true)
|
||||
}
|
||||
result => {
|
||||
let error = match result {
|
||||
Ok(Poll::Ready(Err(e))) => e,
|
||||
_ => Error("contour library failed; no path published".into()),
|
||||
};
|
||||
self.pending = None;
|
||||
self.failed = Some(error.0.clone());
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn finish(self) -> Result<Toolpath> {
|
||||
if self.cancelled {
|
||||
return invalid("generation cancelled");
|
||||
}
|
||||
self.path.ok_or_else(|| Error(self.failed.unwrap_or_else(|| "generation incomplete".into())))
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct YieldOnce(bool);
|
||||
impl Future for YieldOnce {
|
||||
type Output = ();
|
||||
fn poll(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> {
|
||||
if self.0 {
|
||||
Poll::Ready(())
|
||||
} else {
|
||||
self.0 = true;
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Progress {
|
||||
value: Arc<AtomicUsize>,
|
||||
operation: usize,
|
||||
count: usize,
|
||||
}
|
||||
impl Progress {
|
||||
fn set(&self, fraction: f64) {
|
||||
self.value.fetch_max(((self.operation as f64 + fraction.clamp(0., 1.)) / self.count as f64 * 1_000_000.) as usize, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
async fn generate_incremental(input: JobInput, value: Arc<AtomicUsize>) -> Result<Toolpath> {
|
||||
let mut path = Toolpath { fingerprint: input.fingerprint.clone(), blocks: Vec::new() };
|
||||
for (index, o) in input.operations.iter().enumerate() {
|
||||
YieldOnce::default().await;
|
||||
let progress = Progress { value: value.clone(), operation: index, count: input.operations.len() };
|
||||
let blocks = operation(&input, o, progress.clone()).await?;
|
||||
if path.blocks.len() + blocks.len() > MAX_MOVES {
|
||||
return invalid("toolpath exceeds motion limit");
|
||||
}
|
||||
path.blocks.extend(blocks);
|
||||
progress.set(1.);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
pub fn generate(input: JobInput) -> Result<Toolpath> {
|
||||
let mut g = Generator::new(input)?;
|
||||
while !g.step()? {}
|
||||
g.finish()
|
||||
}
|
||||
fn tool(i: &JobInput, id: u64) -> Result<&Tool> {
|
||||
i.tools.iter().find(|t| t.id == id).ok_or_else(|| Error("unknown tool".into()))
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
blocks: Vec<Block>,
|
||||
op: u64,
|
||||
pos: Vec3,
|
||||
progress: Progress,
|
||||
}
|
||||
impl Writer {
|
||||
async fn push(&mut self, motion: Motion) -> Result<()> {
|
||||
if self.blocks.len() >= MAX_MOVES {
|
||||
return invalid("operation exceeds motion limit");
|
||||
}
|
||||
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &motion {
|
||||
crate::model::point(*to)?;
|
||||
self.pos = *to;
|
||||
}
|
||||
self.blocks.push(Block { operation: self.op, motion });
|
||||
if self.blocks.len().is_multiple_of(256) {
|
||||
self.progress.set(0.3 + 0.65 * self.blocks.len() as f64 / (self.blocks.len() as f64 + 1024.));
|
||||
YieldOnce::default().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn rapid(&mut self, p: Vec3) -> Result<()> {
|
||||
self.push(Motion::Rapid { to: p }).await
|
||||
}
|
||||
async fn feed(&mut self, p: Vec3, f: f64) -> Result<()> {
|
||||
self.push(Motion::Linear { to: p, feed: f }).await
|
||||
}
|
||||
async fn enter(&mut self, s: &Setup, p: Vec3, o: &Operation) -> Result<()> {
|
||||
self.rapid(Vec3::new(self.pos.x, self.pos.y, s.clearance)).await?;
|
||||
self.rapid(Vec3::new(p.x, p.y, s.clearance)).await?;
|
||||
self.rapid(Vec3::new(p.x, p.y, s.retract)).await?;
|
||||
self.feed(p, o.plunge_feed).await
|
||||
}
|
||||
}
|
||||
async fn operation(input: &JobInput, o: &Operation, progress: Progress) -> Result<Vec<Block>> {
|
||||
let s = &input.setup;
|
||||
let t = tool(input, o.tool)?;
|
||||
let mut w = Writer { blocks: Vec::new(), op: o.id, pos: Vec3::new(0.0, 0.0, s.clearance), progress: progress.clone() };
|
||||
w.push(Motion::Tool { tool: t.id, number: t.number }).await?;
|
||||
w.push(Motion::Spindle { rpm: o.spindle }).await?;
|
||||
w.push(Motion::Coolant { on: o.coolant }).await?;
|
||||
let levels = depths(o.top, o.bottom + o.allowance, o.stepdown)?;
|
||||
match &o.geometry {
|
||||
Strategy::Face => {
|
||||
let (a, b) = (s.stock.min, s.stock.max);
|
||||
let radius = t.diameter / 2.0;
|
||||
let n = ((b.y - a.y) / o.stepover).ceil() as usize;
|
||||
if n > 10_000 {
|
||||
return invalid("facing exceeds pass limit");
|
||||
}
|
||||
for z in levels {
|
||||
for j in 0..=n {
|
||||
let y = a.y + (b.y - a.y) * j as f64 / n.max(1) as f64;
|
||||
// M3/right-handed cutter: climb keeps uncut material on the
|
||||
// right. Rows advance toward +Y, so climb travels toward -X.
|
||||
let (x0, x1) = if o.climb { (b.x + radius, a.x - radius) } else { (a.x - radius, b.x + radius) };
|
||||
w.enter(s, Vec3::new(x0, y, z), o).await?;
|
||||
w.feed(Vec3::new(x1, y, z), o.feed).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Strategy::Contour { boundary, inside } => {
|
||||
if !boundary.islands.is_empty() {
|
||||
return invalid("contour islands: select each contour as its own operation");
|
||||
}
|
||||
let shape = shape(boundary, progress.clone()).await?;
|
||||
let d = (t.diameter / 2.0 + o.allowance) * if *inside { 1.0 } else { -1.0 };
|
||||
YieldOnce::default().await;
|
||||
let loops = shape.offset(d);
|
||||
if loops.ccw_plines.is_empty() {
|
||||
return invalid("tool cannot fit contour");
|
||||
}
|
||||
for z in levels {
|
||||
for lp in &loops.ccw_plines {
|
||||
// Offsets are CCW. Climb is CCW inside a wall and CW outside a boss.
|
||||
trace(&mut w, s, o, lp, z, o.climb != *inside).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Strategy::Pocket { boundary } => {
|
||||
let original = shape(boundary, progress.clone()).await?;
|
||||
let mut loops = Vec::new();
|
||||
let mut vertices = 0usize;
|
||||
let mut distance = t.diameter / 2.0 + o.allowance;
|
||||
for pass in 0..10_000 {
|
||||
progress.set(0.1 + 0.15 * pass as f64 / (pass as f64 + 8.));
|
||||
YieldOnce::default().await;
|
||||
// A 1e-6 mm clearance reserve keeps exact offset
|
||||
// tangencies away from the shape stitcher's degenerate slices.
|
||||
// It is 1/1000 of the post budget; independent verification still
|
||||
// certifies the unmodified requested region and allowance.
|
||||
let offset = original.offset(distance + 1e-6);
|
||||
if offset.ccw_plines.is_empty() {
|
||||
break;
|
||||
}
|
||||
for lp in offset.ccw_plines.iter().chain(&offset.cw_plines) {
|
||||
vertices = vertices.checked_add(lp.vertex_count()).ok_or_else(|| Error("pocket vertex budget exhausted".into()))?;
|
||||
if vertices > MAX_MOVES {
|
||||
return invalid("pocket vertex budget exhausted");
|
||||
}
|
||||
loops.push(lp.clone());
|
||||
}
|
||||
if loops.len() > 10_000 {
|
||||
return invalid("pocket exceeds pass limit");
|
||||
}
|
||||
distance += o.stepover;
|
||||
if pass == 9999 {
|
||||
return invalid("pocket offset budget exhausted");
|
||||
}
|
||||
}
|
||||
if loops.is_empty() {
|
||||
return invalid("tool cannot fit pocket");
|
||||
}
|
||||
// Innermost passes first; independent clearance retracts avoid island crossings.
|
||||
for z in levels {
|
||||
for lp in loops.iter().rev() {
|
||||
// CCW outer loops and CW islands already put protected
|
||||
// material on the right; reverse both for conventional cuts.
|
||||
trace(&mut w, s, o, lp, z, !o.climb).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Strategy::Drill { holes, peck, full_diameter } => {
|
||||
let bottom = o.bottom - if *full_diameter { t.tip_length() } else { 0.0 };
|
||||
let zs = depths(o.top, bottom, peck.unwrap_or(o.top - bottom))?;
|
||||
for p in holes {
|
||||
w.enter(s, Vec3::new(p[0], p[1], o.top), o).await?;
|
||||
let mut previous = o.top;
|
||||
for z in &zs {
|
||||
if previous != o.top {
|
||||
w.feed(Vec3::new(p[0], p[1], previous), o.plunge_feed).await?;
|
||||
}
|
||||
w.feed(Vec3::new(p[0], p[1], *z), o.plunge_feed).await?;
|
||||
w.rapid(Vec3::new(p[0], p[1], s.retract)).await?;
|
||||
previous = *z;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.rapid(Vec3::new(w.pos.x, w.pos.y, s.clearance)).await?;
|
||||
w.push(Motion::Coolant { on: false }).await?;
|
||||
w.push(Motion::Spindle { rpm: 0.0 }).await?;
|
||||
w.push(Motion::Stop).await?;
|
||||
Ok(w.blocks)
|
||||
}
|
||||
fn depths(top: f64, bottom: f64, step: f64) -> Result<Vec<f64>> {
|
||||
if bottom >= top {
|
||||
return invalid("allowance leaves no cutting depth");
|
||||
}
|
||||
let n = ((top - bottom) / step).ceil() as usize;
|
||||
if n > 10_000 {
|
||||
return invalid("depth pass limit");
|
||||
}
|
||||
Ok((1..=n).map(|i| (top - step * i as f64).max(bottom)).collect())
|
||||
}
|
||||
async fn trace(w: &mut Writer, s: &Setup, o: &Operation, lp: &Polyline<f64>, z: f64, reverse: bool) -> Result<()> {
|
||||
let mut lp = lp.clone();
|
||||
if reverse {
|
||||
lp.invert_direction_mut();
|
||||
}
|
||||
let start = lp.iter_vertexes().next().ok_or_else(|| Error("empty offset".into()))?;
|
||||
let entry_z = if o.ramp_angle.is_some() { o.top } else { z };
|
||||
let first = Vec3::new(start.x, start.y, entry_z);
|
||||
let tangent = lp.iter_segments().next().map(|(a, b)| Vec3::new(b.x - a.x, b.y - a.y, 0.0)).and_then(|v| v.normalized()).unwrap_or(Vec3::X);
|
||||
w.enter(s, first - tangent * o.lead_distance, o).await?;
|
||||
if o.lead_distance > 0.0 {
|
||||
w.feed(first, o.feed).await?;
|
||||
}
|
||||
if let Some(angle) = o.ramp_angle {
|
||||
let perimeter = lp.iter_segments().map(|(a, b)| seg_length(a, b)).sum::<f64>();
|
||||
if perimeter <= 1e-8 {
|
||||
return invalid("ramp contour has no length");
|
||||
}
|
||||
let laps = ((o.top - z) / (perimeter * angle.tan())).ceil() as usize;
|
||||
if laps > 1000 {
|
||||
return invalid("entry ramp exceeds lap budget");
|
||||
}
|
||||
let mut height = o.top;
|
||||
for _ in 0..laps {
|
||||
for (a, b) in lp.iter_segments() {
|
||||
let length = seg_length(a, b);
|
||||
let bottom = (height - length * angle.tan()).max(z);
|
||||
if a.bulge.abs() < 1e-12 || length <= POST_ERROR / 4. {
|
||||
w.feed(Vec3::new(b.x, b.y, bottom), o.plunge_feed).await?;
|
||||
} else {
|
||||
let (radius, c) = seg_arc_radius_and_center(a, b);
|
||||
let sweep = 4.0 * a.bulge.atan();
|
||||
let start = (a.y - c.y).atan2(a.x - c.x);
|
||||
let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6);
|
||||
let n = (sweep.abs() / step).ceil() as usize;
|
||||
if n > MAX_MOVES {
|
||||
return invalid("ramp arc exceeds motion budget");
|
||||
}
|
||||
for k in 1..=n {
|
||||
let t = k as f64 / n as f64;
|
||||
let theta = start + sweep * t;
|
||||
w.feed(Vec3::new(c.x + radius * theta.cos(), c.y + radius * theta.sin(), height + (bottom - height) * t), o.plunge_feed)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
height = bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (a, b) in lp.iter_segments() {
|
||||
let to = Vec3::new(b.x, b.y, z);
|
||||
// Offset topology may contain a vanishing arc at a tangency. Preserve
|
||||
// its short motion as a line: rounded coincident G2/G3 endpoints would
|
||||
// otherwise command a full circle. Its entire arc length bounds error.
|
||||
if a.bulge.abs() < 1e-12 || seg_length(a, b) <= POST_ERROR / 4. {
|
||||
w.feed(to, o.feed).await?;
|
||||
} else {
|
||||
let (_, c) = seg_arc_radius_and_center(a, b);
|
||||
w.push(Motion::Arc { to, center: Vec3::new(c.x, c.y, z), clockwise: a.bulge < 0.0, feed: o.feed }).await?;
|
||||
}
|
||||
}
|
||||
if o.lead_distance > 0.0 {
|
||||
w.feed(Vec3::new(start.x, start.y, z) - tangent * o.lead_distance, o.feed).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// Work in setup millimetres with predicate/stitch tolerances comfortably below
|
||||
// the post budget. Upstream defaults can merge distinct small offset slices.
|
||||
struct OffsetShape {
|
||||
shape: Shape<f64>,
|
||||
scale: f64,
|
||||
}
|
||||
struct OffsetLoops {
|
||||
ccw_plines: Vec<Polyline<f64>>,
|
||||
cw_plines: Vec<Polyline<f64>>,
|
||||
}
|
||||
impl OffsetShape {
|
||||
fn offset(&self, distance: f64) -> OffsetLoops {
|
||||
let scale = self.scale;
|
||||
let options = ShapeOffsetOptions { pos_equal_eps: 1e-8 * scale, offset_dist_eps: 1e-7 * scale, slice_join_eps: 1e-8 * scale };
|
||||
let result = self.shape.parallel_offset(distance * scale, &options);
|
||||
let restore = |p: cavalier_contours::shape_algorithms::IndexedPolyline<f64>| {
|
||||
let mut polyline = p.polyline;
|
||||
polyline.scale_mut(1. / scale);
|
||||
polyline
|
||||
};
|
||||
OffsetLoops {
|
||||
ccw_plines: result.ccw_plines.into_iter().map(restore).collect(),
|
||||
cw_plines: result.cw_plines.into_iter().map(restore).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn shape(b: &Boundary, progress: Progress) -> Result<OffsetShape> {
|
||||
b.validate()?;
|
||||
// Reject all crossings/touches, not just those the offset library happens to tolerate.
|
||||
let rings: Vec<_> = std::iter::once(&b.outer).chain(&b.islands).collect();
|
||||
let mut edges = Vec::new();
|
||||
let mut extent = 1_f64;
|
||||
let mut shortest = f64::MAX;
|
||||
for (ring, points) in rings.iter().enumerate() {
|
||||
for vertex in 0..points.len() {
|
||||
if edges.len().is_multiple_of(256) {
|
||||
YieldOnce::default().await;
|
||||
}
|
||||
let a = points[vertex];
|
||||
let b = points[(vertex + 1) % points.len()];
|
||||
let c = points[(vertex + 2) % points.len()];
|
||||
if distance(a, b) < 1e-7 {
|
||||
return invalid("zero-length boundary edge");
|
||||
}
|
||||
shortest = shortest.min(distance(a, b));
|
||||
// Adjacent edges share their endpoint, but must not double back.
|
||||
if robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: c[0], y: c[1] }) == 0.
|
||||
&& (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0.
|
||||
{
|
||||
return invalid("backtracking boundary edge");
|
||||
}
|
||||
extent = extent.max(a[0].abs()).max(a[1].abs());
|
||||
edges.push((ring, vertex, a, b));
|
||||
}
|
||||
}
|
||||
YieldOnce::default().await;
|
||||
let index = crate::spatial::Index::new(
|
||||
edges.iter().enumerate().map(|(id, (_, _, a, b))| (id, [a[0].min(b[0]), a[1].min(b[1])], [a[0].max(b[0]), a[1].max(b[1])])).collect(),
|
||||
);
|
||||
// Include every pair admitted by the existing near-touch predicate. The
|
||||
// numerical reserve also covers rounding when expanding coordinate bounds.
|
||||
let padding = 1e-8 + 128. * f64::EPSILON * extent;
|
||||
let mut comparisons = 0usize;
|
||||
let mut candidates = Vec::new();
|
||||
for (id, &(ring, vertex, a, b)) in edges.iter().enumerate() {
|
||||
if id.is_multiple_of(128) {
|
||||
progress.set(0.1 * id as f64 / edges.len() as f64);
|
||||
YieldOnce::default().await;
|
||||
}
|
||||
candidates.clear();
|
||||
index.query([a[0].min(b[0]) - padding, a[1].min(b[1]) - padding], [a[0].max(b[0]) + padding, a[1].max(b[1]) + padding], &mut candidates);
|
||||
for other in candidates.iter().copied().filter(|other| *other > id) {
|
||||
let (other_ring, other_vertex, c, d) = edges[other];
|
||||
let count = rings[ring].len();
|
||||
if ring == other_ring && ((vertex + 1) % count == other_vertex || (other_vertex + 1) % count == vertex) {
|
||||
continue;
|
||||
}
|
||||
comparisons += 1;
|
||||
if comparisons.is_multiple_of(4096) {
|
||||
YieldOnce::default().await;
|
||||
}
|
||||
if comparisons > 2_000_000 {
|
||||
return invalid("boundary validation budget exceeded");
|
||||
}
|
||||
if intersects(a, b, c, d) {
|
||||
return invalid("boundary intersections or touching loops");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, hole) in b.islands.iter().enumerate() {
|
||||
YieldOnce::default().await;
|
||||
if !inside(hole[0], &b.outer) || b.islands.iter().enumerate().any(|(j, h)| i != j && inside(hole[0], h)) {
|
||||
return invalid("islands must be disjoint and inside the outer boundary");
|
||||
}
|
||||
}
|
||||
// Cavalier also applies its position epsilon to line cross products. Small
|
||||
// segments can otherwise be classified as parallel (including a 10k-sided
|
||||
// circle). Normalize the shortest input edge to at least one library unit.
|
||||
// Powers of two preserve finite input coordinates exactly; no vertices are
|
||||
// removed. Scale distance tolerances too, retaining their physical meaning.
|
||||
let scale = 2_f64.powi((-shortest.log2()).ceil().max(0.) as i32);
|
||||
let mut plines = Vec::new();
|
||||
for (i, ring) in rings.iter().enumerate() {
|
||||
YieldOnce::default().await;
|
||||
let mut p = Polyline::new_closed();
|
||||
for xy in ring.iter() {
|
||||
p.add(xy[0], xy[1], 0.0);
|
||||
}
|
||||
if p.area().abs() < 1e-8 {
|
||||
return invalid("zero-area boundary");
|
||||
}
|
||||
if (p.area() > 0.0) != (i == 0) {
|
||||
p.invert_direction_mut();
|
||||
}
|
||||
p.scale_mut(scale);
|
||||
plines.push(p);
|
||||
}
|
||||
Ok(OffsetShape { shape: Shape::from_plines(plines), scale })
|
||||
}
|
||||
fn distance(a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
(a[0] - b[0]).hypot(a[1] - b[1])
|
||||
}
|
||||
fn cross(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
|
||||
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
}
|
||||
fn intersects(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
|
||||
let eps = 1e-8;
|
||||
let overlap = (0..2).all(|k| a[k].min(b[k]) <= c[k].max(d[k]) + eps && c[k].min(d[k]) <= a[k].max(b[k]) + eps);
|
||||
overlap && cross(a, b, c) * cross(a, b, d) <= eps && cross(c, d, a) * cross(c, d, b) <= eps
|
||||
}
|
||||
fn inside(p: [f64; 2], ring: &[[f64; 2]]) -> bool {
|
||||
let mut yes = false;
|
||||
for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()) {
|
||||
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
|
||||
yes = !yes;
|
||||
}
|
||||
}
|
||||
yes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod incremental_tests {
|
||||
use super::*;
|
||||
fn polar(count: usize, lobes: usize) -> Vec<[f64; 2]> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let angle = i as f64 * std::f64::consts::TAU / count as f64;
|
||||
let radius = if lobes == 0 { 8. } else { 8. - 0.6 * (1. + (lobes as f64 * angle).cos()) };
|
||||
[10. + radius * angle.cos(), 10. + radius * angle.sin()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn validate_shape(boundary: &Boundary) -> Result<OffsetShape> {
|
||||
let progress = Progress { value: Arc::new(AtomicUsize::new(0)), operation: 0, count: 1 };
|
||||
let mut future = std::pin::pin!(shape(boundary, progress));
|
||||
for _ in 0..10_000 {
|
||||
if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
invalid("test shape did not complete within checkpoint bound")
|
||||
}
|
||||
#[test]
|
||||
fn dense_simple_boundaries_and_one_hundred_islands_fit_validation_budget() {
|
||||
for lobes in [0, 7] {
|
||||
let mut boundary = Boundary { outer: polar(10_000, lobes), islands: vec![] };
|
||||
for _ in 0..2 {
|
||||
let validated = validate_shape(&boundary).unwrap();
|
||||
assert_eq!(validated.shape.ccw_plines.len(), 1);
|
||||
assert!(validated.shape.cw_plines.is_empty());
|
||||
boundary.outer.reverse();
|
||||
}
|
||||
let mut input = corpus::fixture("contour").unwrap();
|
||||
input.operations[0].geometry = Strategy::Contour { boundary, inside: true };
|
||||
let path = generate(input).unwrap();
|
||||
assert!(path.blocks.iter().any(|b| matches!(b.motion, Motion::Linear { .. } | Motion::Arc { .. })));
|
||||
}
|
||||
let islands = (0..100)
|
||||
.map(|n| {
|
||||
(0..99)
|
||||
.map(|i| {
|
||||
let a = i as f64 * std::f64::consts::TAU / 99.;
|
||||
[5. + (n % 10) as f64 * 10. + a.cos(), 5. + (n / 10) as f64 * 10. + a.sin()]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let boundary = Boundary { outer: vec![[0., 0.], [100., 0.], [100., 100.], [0., 100.]], islands };
|
||||
let validated = validate_shape(&boundary).unwrap();
|
||||
assert_eq!((validated.shape.ccw_plines.len(), validated.shape.cw_plines.len()), (1, 100));
|
||||
let offset = validated.offset(1.);
|
||||
assert_eq!((offset.ccw_plines.len(), offset.cw_plines.len()), (1, 100));
|
||||
}
|
||||
#[test]
|
||||
fn normalized_offsets_preserve_dense_polygon_distance_and_coordinates() {
|
||||
// Independent regular-polygon construction: inward offset preserves the
|
||||
// apothem minus the cutter radius. Check every resulting vertex, not area.
|
||||
for center in [[10., 10.], [-10_000., 20_000.]] {
|
||||
for factor in [1., 0.125] {
|
||||
let points = polar(10_000, 0).into_iter().map(|p| [center[0] + factor * (p[0] - 10.), center[1] + factor * (p[1] - 10.)]).collect();
|
||||
let boundary = Boundary { outer: points, islands: vec![] };
|
||||
let offset = validate_shape(&boundary).unwrap().offset(3. * factor);
|
||||
assert_eq!(offset.ccw_plines.len(), 1);
|
||||
assert!(offset.cw_plines.is_empty());
|
||||
let expected = factor * (8. - 3. / (std::f64::consts::PI / 10_000.).cos());
|
||||
for p in offset.ccw_plines[0].iter_vertexes() {
|
||||
assert!(((p.x - center[0]).hypot(p.y - center[1]) - expected).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Concave case: sample output vertices against all ORIGINAL input
|
||||
// segments using an independently written point/segment projection.
|
||||
let boundary = Boundary { outer: polar(10_000, 7), islands: vec![] };
|
||||
let offset = validate_shape(&boundary).unwrap().offset(3.);
|
||||
assert_eq!(offset.ccw_plines.len(), 1);
|
||||
for p in offset.ccw_plines[0].iter_vertexes().step_by(64) {
|
||||
let nearest = boundary
|
||||
.outer
|
||||
.iter()
|
||||
.zip(boundary.outer.iter().cycle().skip(1))
|
||||
.map(|(a, b)| {
|
||||
let dx = b[0] - a[0];
|
||||
let dy = b[1] - a[1];
|
||||
let t = (((p.x - a[0]) * dx + (p.y - a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.);
|
||||
(p.x - a[0] - t * dx).hypot(p.y - a[1] - t * dy)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
assert!((nearest - 3.).abs() < 1e-7, "offset distance {nearest}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn indexed_validation_rejects_crossings_touches_and_backtracking() {
|
||||
let square = |min: [f64; 2], max: [f64; 2]| vec![min, [max[0], min[1]], max, [min[0], max[1]]];
|
||||
for boundary in [
|
||||
Boundary { outer: vec![[0., 0.], [3., 3.], [0., 3.], [3., 0.]], islands: vec![] },
|
||||
Boundary { outer: vec![[0., 0.], [3., 0.], [1., 0.], [3., 3.], [0., 3.]], islands: vec![] },
|
||||
Boundary { outer: vec![[0., 0.], [3., 0.], [3., 0.], [0., 3.]], islands: vec![] },
|
||||
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([0., 1.], [2., 3.])] },
|
||||
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([5e-9, 1.], [2., 3.])] },
|
||||
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [4., 4.]), square([4., 2.], [6., 4.])] },
|
||||
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([2., 2.], [8., 8.]), square([3., 3.], [4., 4.])] },
|
||||
Boundary { outer: square([0., 0.], [10., 10.]), islands: vec![square([12., 2.], [14., 4.])] },
|
||||
] {
|
||||
assert!(validate_shape(&boundary).is_err(), "{boundary:?}");
|
||||
}
|
||||
// A non-adjacent touch hidden among thousands of otherwise valid edges.
|
||||
let mut boundary = Boundary { outer: polar(10_000, 0), islands: vec![] };
|
||||
boundary.outer[5000] = boundary.outer[0];
|
||||
assert!(validate_shape(&boundary).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_during_dense_boundary_construction_and_queries_publishes_nothing() {
|
||||
for checkpoint in [1, 20, 80, 120] {
|
||||
let mut input = corpus::fixture("contour").unwrap();
|
||||
input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: polar(10_000, 7), islands: vec![] }, inside: true };
|
||||
let mut generator = Generator::new(input).unwrap();
|
||||
for _ in 0..checkpoint {
|
||||
assert!(!generator.step().unwrap());
|
||||
}
|
||||
generator.cancel();
|
||||
assert!(generator.step().is_err());
|
||||
assert!(generator.finish().is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn large_single_operations_yield_and_remain_deterministic() {
|
||||
for name in ["face", "contour", "pocket", "drill"] {
|
||||
let mut input = corpus::fixture(name).unwrap();
|
||||
input.operations[0].stepdown = 0.02;
|
||||
if name == "drill" {
|
||||
input.operations[0].geometry = Strategy::Drill {
|
||||
holes: (0..300).map(|n| [1. + (n % 18) as f64, 1. + (n / 18) as f64]).collect(),
|
||||
peck: Some(0.05),
|
||||
full_diameter: false,
|
||||
};
|
||||
}
|
||||
let mut generator = Generator::new(input.clone()).unwrap();
|
||||
let mut polls = 0;
|
||||
let mut previous = 0.;
|
||||
let mut intermediate = false;
|
||||
while !generator.step().unwrap() {
|
||||
polls += 1;
|
||||
let progress = generator.progress();
|
||||
assert!(progress >= previous && progress < 1.);
|
||||
intermediate |= progress > 0.;
|
||||
previous = progress;
|
||||
assert!(polls < 100_000);
|
||||
}
|
||||
assert!(polls > 10 && intermediate, "{name}: {polls}");
|
||||
assert_eq!(generator.progress(), 1.);
|
||||
let path = generator.finish().unwrap();
|
||||
assert_eq!(serde_json::to_value(path).unwrap(), serde_json::to_value(generate(input).unwrap()).unwrap());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_during_one_operation_discards_partial_output() {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.operations[0].stepdown = 0.001;
|
||||
let mut generator = Generator::new(input).unwrap();
|
||||
for _ in 0..4 {
|
||||
assert!(!generator.step().unwrap());
|
||||
}
|
||||
assert!(generator.progress() > 0. && generator.progress() < 1.);
|
||||
generator.cancel();
|
||||
assert!(generator.step().is_err());
|
||||
assert!(generator.finish().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn incomplete_and_failed_jobs_never_publish_partial_paths() {
|
||||
let mut generator = Generator::new(corpus::fixture("face").unwrap()).unwrap();
|
||||
assert!(!generator.step().unwrap());
|
||||
assert!(generator.finish().is_err());
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
let mut bad = input.operations[0].clone();
|
||||
bad.id = 2;
|
||||
bad.allowance = 2.;
|
||||
input.operations.push(bad);
|
||||
let mut generator = Generator::new(input).unwrap();
|
||||
let failed = loop {
|
||||
match generator.step() {
|
||||
Ok(false) => {}
|
||||
result => break result,
|
||||
}
|
||||
};
|
||||
assert!(failed.is_err());
|
||||
assert!(generator.step().is_err());
|
||||
assert!(generator.finish().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Incremental encoding of the existing snapshot fingerprint, without a full JSON buffer.
|
||||
use crate::{Error, JobInput, Result, VERSION, invalid};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
struct Encoder(Sha256);
|
||||
impl Encoder {
|
||||
fn bytes(&mut self, bytes: &[u8]) {
|
||||
self.0.update(bytes);
|
||||
}
|
||||
fn value(&mut self, value: &impl Serialize) -> Result<()> {
|
||||
let bytes = serde_json::to_vec(value).map_err(|e| Error(e.to_string()))?;
|
||||
self.bytes(&bytes);
|
||||
Ok(())
|
||||
}
|
||||
async fn array<T: Serialize, F: std::future::Future<Output = ()>>(
|
||||
&mut self,
|
||||
values: &[T],
|
||||
batch: usize,
|
||||
cancelled: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<()> {
|
||||
self.bytes(b"[");
|
||||
for (index, chunk) in values.chunks(batch).enumerate() {
|
||||
checkpoint(cancelled, yield_control).await?;
|
||||
if index != 0 {
|
||||
self.bytes(b",");
|
||||
}
|
||||
// Serialize each bounded slice using Serde's own number/string rules.
|
||||
// Only its enclosing brackets are omitted when joining array chunks.
|
||||
let bytes = serde_json::to_vec(chunk).map_err(|e| Error(e.to_string()))?;
|
||||
let contents = bytes.get(1..bytes.len().saturating_sub(1)).ok_or_else(|| Error("invalid fingerprint array encoding".into()))?;
|
||||
self.bytes(contents);
|
||||
}
|
||||
self.bytes(b"]");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
async fn checkpoint<F: std::future::Future<Output = ()>>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> {
|
||||
if cancelled() {
|
||||
return invalid("fingerprint cancelled");
|
||||
}
|
||||
yield_control().await;
|
||||
if cancelled() { invalid("fingerprint cancelled") } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Hash a validated immutable job using exactly the bytes of `fingerprint(input)`.
|
||||
/// Yields before each operation/tool and every 256 triangles. Encoding one setup
|
||||
/// or operation remains atomic; this is not a hard elapsed-time guarantee.
|
||||
/// Cancellation returns no digest and never mutates the input.
|
||||
pub async fn fingerprint_job_cooperative<F: std::future::Future<Output = ()>>(
|
||||
input: &JobInput,
|
||||
cancelled: impl Fn() -> bool,
|
||||
mut yield_control: impl FnMut() -> F,
|
||||
) -> Result<String> {
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
let mut encoder = Encoder(Sha256::new());
|
||||
encoder.bytes(b"[");
|
||||
encoder.value(&VERSION)?;
|
||||
// Field order deliberately matches the derived JobInput/Geometry encoders.
|
||||
// Equality regressions below make schema additions or reorderings visible.
|
||||
encoder.bytes(b",{\"setup\":");
|
||||
encoder.value(&input.setup)?;
|
||||
encoder.bytes(b",\"tools\":");
|
||||
encoder.array(&input.tools, 1, &cancelled, &mut yield_control).await?;
|
||||
encoder.bytes(b",\"operations\":");
|
||||
encoder.array(&input.operations, 1, &cancelled, &mut yield_control).await?;
|
||||
encoder.bytes(b",\"geometry\":{\"target\":");
|
||||
encoder.array(&input.geometry.target, 256, &cancelled, &mut yield_control).await?;
|
||||
encoder.bytes(b",\"fixtures\":");
|
||||
encoder.array(&input.geometry.fixtures, 256, &cancelled, &mut yield_control).await?;
|
||||
encoder.bytes(b",\"fixture_groups\":");
|
||||
encoder.array(&input.geometry.fixture_groups, 256, &cancelled, &mut yield_control).await?;
|
||||
encoder.bytes(b"},\"fingerprint\":");
|
||||
encoder.value(&input.fingerprint)?;
|
||||
encoder.bytes(b"}]");
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
Ok(format!("{:x}", encoder.0.finalize()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
|
||||
fn ready<T>(future: impl Future<Output = T>) -> T {
|
||||
let mut future = std::pin::pin!(future);
|
||||
match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
|
||||
Poll::Ready(value) => value,
|
||||
Poll::Pending => panic!("test checkpoint unexpectedly suspended"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cooperative_encoding_preserves_existing_snapshot_fingerprints() {
|
||||
for name in crate::corpus::CASES {
|
||||
let mut input = crate::corpus::fixture(name).unwrap();
|
||||
input.setup.name = "Escaped \"setup\" \\ \nμ".into();
|
||||
input.fingerprint = "Existing digest remains part of the serialized input".into();
|
||||
let expected = crate::fingerprint(&input).unwrap();
|
||||
let count = Cell::new(0);
|
||||
let actual = ready(fingerprint_job_cooperative(
|
||||
&input,
|
||||
|| false,
|
||||
|| {
|
||||
count.set(count.get() + 1);
|
||||
std::future::ready(())
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
assert!(count.get() >= 4);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn large_snapshot_hashing_yields_and_cancellation_never_returns_a_digest() {
|
||||
let mut input = crate::corpus::fixture("face").unwrap();
|
||||
input.geometry.target.resize(200_000, input.geometry.target[0]);
|
||||
let expected = crate::fingerprint(&input).unwrap();
|
||||
let count = Cell::new(0);
|
||||
let actual = ready(fingerprint_job_cooperative(
|
||||
&input,
|
||||
|| false,
|
||||
|| {
|
||||
count.set(count.get() + 1);
|
||||
std::future::ready(())
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
assert!(count.get() > 780);
|
||||
for stop in [0, 1, 400, count.get()] {
|
||||
let checkpoints = Cell::new(0);
|
||||
let result = ready(fingerprint_job_cooperative(
|
||||
&input,
|
||||
|| checkpoints.get() >= stop,
|
||||
|| {
|
||||
checkpoints.set(checkpoints.get() + 1);
|
||||
std::future::ready(())
|
||||
},
|
||||
));
|
||||
assert!(result.is_err(), "cancel at checkpoint {stop}");
|
||||
}
|
||||
assert_eq!(crate::fingerprint(&input).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Milling definitions, deterministic toolpaths and independent NC verification.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod collision;
|
||||
pub mod compare;
|
||||
pub mod corpus;
|
||||
mod coverage;
|
||||
mod dimensions;
|
||||
mod hashing;
|
||||
pub use hashing::fingerprint_job_cooperative;
|
||||
pub mod generate;
|
||||
mod model;
|
||||
pub mod nc;
|
||||
pub mod playback;
|
||||
mod region;
|
||||
mod spatial;
|
||||
mod stock_mesh;
|
||||
mod surface;
|
||||
pub mod verify;
|
||||
pub use model::*;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("CAM: {0}")]
|
||||
pub struct Error(pub String);
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
pub(crate) fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||
Err(Error(message.into()))
|
||||
}
|
||||
|
||||
pub const VERSION: &str = "cam/18";
|
||||
pub const TOLERANCE: f64 = 0.01;
|
||||
pub const POST_ERROR: f64 = 0.001;
|
||||
pub const VERIFY_ERROR: f64 = 0.0025;
|
||||
/// Maximum upward height change for closing tiny saddle contacts in stock exports.
|
||||
/// Verification intervals are unchanged; exported meshes require independent comparison.
|
||||
pub const STOCK_EXPORT_ERROR: f64 = POST_ERROR / 100.;
|
||||
pub const MAX_MOVES: usize = 200_000;
|
||||
pub const MAX_ITEMS: usize = 1000;
|
||||
|
||||
/// Maximum chord error when resolving an associated CAD profile. The independent
|
||||
/// dimensional envelope reserves this in addition to post/interpolation error.
|
||||
pub fn profile_tolerance(tolerance: f64) -> f64 {
|
||||
(tolerance / 16.).min(POST_ERROR / 2.)
|
||||
}
|
||||
|
||||
pub fn fingerprint<T: serde::Serialize>(value: &T) -> Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let data = serde_json::to_vec(&(VERSION, value)).map_err(|e| Error(e.to_string()))?;
|
||||
Ok(format!("{:x}", Sha256::digest(data)))
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use crate::{MAX_ITEMS, Result, TOLERANCE, invalid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manufacturing {
|
||||
#[serde(default)]
|
||||
pub setups: Vec<Setup>,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<Tool>,
|
||||
#[serde(default)]
|
||||
pub operations: Vec<Operation>,
|
||||
#[serde(default)]
|
||||
pub programs: Vec<Program>,
|
||||
}
|
||||
impl Manufacturing {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.setups.is_empty() && self.tools.is_empty() && self.operations.is_empty() && self.programs.is_empty()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for ids in [
|
||||
self.setups.iter().map(|x| x.id).collect::<Vec<_>>(),
|
||||
self.tools.iter().map(|x| x.id).collect(),
|
||||
self.operations.iter().map(|x| x.id).collect(),
|
||||
self.programs.iter().map(|x| x.id).collect(),
|
||||
] {
|
||||
if ids.len() > MAX_ITEMS || ids.contains(&0) || ids.iter().collect::<std::collections::BTreeSet<_>>().len() != ids.len() {
|
||||
return invalid("too many items or duplicate/zero IDs");
|
||||
}
|
||||
}
|
||||
for s in &self.setups {
|
||||
s.validate()?;
|
||||
}
|
||||
for t in &self.tools {
|
||||
t.validate()?;
|
||||
}
|
||||
for o in &self.operations {
|
||||
let s = self.setups.iter().find(|s| s.id == o.setup).ok_or_else(|| crate::Error("missing setup".into()))?;
|
||||
let t = self.tools.iter().find(|t| t.id == o.tool).ok_or_else(|| crate::Error("missing tool".into()))?;
|
||||
o.validate(s, t)?;
|
||||
}
|
||||
for p in &self.programs {
|
||||
name(&p.name)?;
|
||||
if p.operations.is_empty()
|
||||
|| p.operations.len() > MAX_ITEMS
|
||||
|| p.operations.iter().collect::<std::collections::BTreeSet<_>>().len() != p.operations.len()
|
||||
{
|
||||
return invalid("program needs unique ordered operations");
|
||||
}
|
||||
for id in &p.operations {
|
||||
if !self.operations.iter().any(|o| o.id == *id && o.setup == p.setup) {
|
||||
return invalid("program operation is missing or belongs to another setup");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BodyRef {
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub occurrence: Vec<u64>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Frame {
|
||||
pub origin: Vec3,
|
||||
pub x: Vec3,
|
||||
pub y: Vec3,
|
||||
pub z: Vec3,
|
||||
}
|
||||
impl Default for Frame {
|
||||
fn default() -> Self {
|
||||
Self { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y, z: Vec3::Z }
|
||||
}
|
||||
}
|
||||
impl Frame {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for v in [self.origin, self.x, self.y, self.z] {
|
||||
point(v)?;
|
||||
}
|
||||
if (self.x.dot(self.x) - 1.0).abs() > 1e-8
|
||||
|| (self.y.dot(self.y) - 1.0).abs() > 1e-8
|
||||
|| (self.z.dot(self.z) - 1.0).abs() > 1e-8
|
||||
|| self.x.dot(self.y).abs() > 1e-8
|
||||
|| self.x.cross(self.y).dist(self.z) > 1e-8
|
||||
{
|
||||
return invalid("setup frame must be right-handed and orthonormal");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn local(&self, p: Vec3) -> Vec3 {
|
||||
let p = p - self.origin;
|
||||
Vec3::new(p.dot(self.x), p.dot(self.y), p.dot(self.z))
|
||||
}
|
||||
pub fn world(&self, p: Vec3) -> Vec3 {
|
||||
self.origin + self.x * p.x + self.y * p.y + self.z * p.z
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Stock {
|
||||
pub min: Vec3,
|
||||
pub max: Vec3,
|
||||
}
|
||||
impl Stock {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
point(self.min)?;
|
||||
point(self.max)?;
|
||||
if self.min.x >= self.max.x || self.min.y >= self.max.y || self.min.z >= self.max.z {
|
||||
return invalid("stock bounds must have positive size");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Setup {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub stock: Stock,
|
||||
#[serde(default)]
|
||||
pub frame: Frame,
|
||||
pub models: Vec<BodyRef>,
|
||||
#[serde(default)]
|
||||
pub fixtures: Vec<BodyRef>,
|
||||
pub clearance: f64,
|
||||
pub retract: f64,
|
||||
#[serde(default = "work_offset")]
|
||||
pub work_offset: u8,
|
||||
}
|
||||
fn work_offset() -> u8 {
|
||||
54
|
||||
}
|
||||
impl Setup {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
name(&self.name)?;
|
||||
self.stock.validate()?;
|
||||
self.frame.validate()?;
|
||||
if self.models.is_empty() || self.models.len() + self.fixtures.len() > MAX_ITEMS {
|
||||
return invalid("setup needs model references (at most 1000)");
|
||||
}
|
||||
for r in self.models.iter().chain(&self.fixtures) {
|
||||
name(&r.body)?;
|
||||
if r.occurrence.len() > 64 {
|
||||
return invalid("occurrence path too deep");
|
||||
}
|
||||
}
|
||||
if !finite(self.clearance)
|
||||
|| !finite(self.retract)
|
||||
|| self.clearance < self.retract
|
||||
|| self.retract <= self.stock.max.z
|
||||
|| !(54..=59).contains(&self.work_offset)
|
||||
{
|
||||
return invalid("clearance >= retract > stock top; work offset must be G54–G59");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolKind {
|
||||
FlatEndMill,
|
||||
TwistDrill,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Tool {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub number: u32,
|
||||
pub kind: ToolKind,
|
||||
pub diameter: f64,
|
||||
pub cutting_length: f64,
|
||||
pub shank_diameter: f64,
|
||||
pub overall_length: f64,
|
||||
pub holder_diameter: f64,
|
||||
pub holder_length: f64,
|
||||
#[serde(default = "drill_angle")]
|
||||
pub tip_angle: f64,
|
||||
}
|
||||
fn drill_angle() -> f64 {
|
||||
118.0_f64.to_radians()
|
||||
}
|
||||
impl Tool {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
name(&self.name)?;
|
||||
if self.number == 0
|
||||
|| self.number > 9999
|
||||
|| [self.diameter, self.cutting_length, self.shank_diameter, self.overall_length, self.holder_diameter, self.holder_length]
|
||||
.iter()
|
||||
.any(|v| !positive(*v))
|
||||
|| self.overall_length < self.cutting_length
|
||||
|| !self.tip_angle.is_finite()
|
||||
|| !(0.1..3.1).contains(&self.tip_angle)
|
||||
{
|
||||
return invalid("invalid tool dimensions, number or drill angle (radians)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn tip_length(&self) -> f64 {
|
||||
if self.kind == ToolKind::TwistDrill { self.diameter * 0.5 / (self.tip_angle * 0.5).tan() } else { 0.0 }
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Boundary {
|
||||
pub outer: Vec<[f64; 2]>,
|
||||
#[serde(default)]
|
||||
pub islands: Vec<Vec<[f64; 2]>>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProfileRef {
|
||||
pub sketch: u64,
|
||||
pub curves: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub occurrence: Vec<u64>,
|
||||
}
|
||||
/// Document-local identities only. The engine resolves these into setup XY centers.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum HoleSource {
|
||||
Face { target: BodyRef, name: String },
|
||||
Profile { profile: ProfileRef },
|
||||
}
|
||||
impl HoleSource {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::Face { target, name: face } => {
|
||||
name(&target.body)?;
|
||||
name(face)?;
|
||||
if target.occurrence.len() > 64 {
|
||||
return invalid("hole occurrence path too deep");
|
||||
}
|
||||
}
|
||||
Self::Profile { profile } => {
|
||||
if profile.sketch == 0 || profile.curves.is_empty() || profile.curves.len() > 10_000 || profile.occurrence.len() > 64 {
|
||||
return invalid("invalid circular profile reference");
|
||||
}
|
||||
for curve in &profile.curves {
|
||||
name(curve)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "strategy", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Strategy {
|
||||
Face,
|
||||
Contour {
|
||||
boundary: Boundary,
|
||||
inside: bool,
|
||||
},
|
||||
Pocket {
|
||||
boundary: Boundary,
|
||||
},
|
||||
Drill {
|
||||
holes: Vec<[f64; 2]>,
|
||||
#[serde(default)]
|
||||
peck: Option<f64>,
|
||||
#[serde(default)]
|
||||
full_diameter: bool,
|
||||
},
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Operation {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub setup: u64,
|
||||
pub tool: u64,
|
||||
pub geometry: Strategy,
|
||||
#[serde(default)]
|
||||
pub source: Option<ProfileRef>,
|
||||
/// When nonempty, replaces every preview XY center during snapshot preparation.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub hole_sources: Vec<HoleSource>,
|
||||
pub top: f64,
|
||||
pub bottom: f64,
|
||||
pub stepdown: f64,
|
||||
pub stepover: f64,
|
||||
pub feed: f64,
|
||||
pub plunge_feed: f64,
|
||||
pub spindle: f64,
|
||||
#[serde(default)]
|
||||
pub allowance: f64,
|
||||
#[serde(default = "yes")]
|
||||
pub climb: bool,
|
||||
#[serde(default = "tolerance")]
|
||||
pub tolerance: f64,
|
||||
#[serde(default)]
|
||||
pub coolant: bool,
|
||||
/// Optional entry ramp angle in radians; None uses a center-cutting plunge.
|
||||
#[serde(default)]
|
||||
pub ramp_angle: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lead_distance: f64,
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn tolerance() -> f64 {
|
||||
TOLERANCE
|
||||
}
|
||||
impl Operation {
|
||||
pub fn validate(&self, s: &Setup, t: &Tool) -> Result<()> {
|
||||
name(&self.name)?;
|
||||
if !finite(self.lead_distance)
|
||||
|| self.lead_distance < 0.0
|
||||
|| self.ramp_angle.is_some_and(|a| !a.is_finite() || !(0.001..=0.174533).contains(&a))
|
||||
{
|
||||
return invalid("lead must be nonnegative; ramp angle must be 0.06–10 degrees");
|
||||
}
|
||||
if (self.ramp_angle.is_some() || self.lead_distance > 0.0) && matches!(self.geometry, Strategy::Face | Strategy::Drill { .. }) {
|
||||
return invalid("lead/ramp entry is supported for contour and pocket operations");
|
||||
}
|
||||
if let Some(source) = &self.source {
|
||||
if source.sketch == 0
|
||||
|| source.curves.is_empty()
|
||||
|| source.curves.len() > 10_000
|
||||
|| source.occurrence.len() > 64
|
||||
|| !matches!(self.geometry, Strategy::Contour { .. } | Strategy::Pocket { .. })
|
||||
{
|
||||
return invalid("profile association requires a bounded contour/pocket reference");
|
||||
}
|
||||
for c in &source.curves {
|
||||
name(c)?;
|
||||
}
|
||||
}
|
||||
if !self.hole_sources.is_empty() {
|
||||
let Strategy::Drill { holes, .. } = &self.geometry else {
|
||||
return invalid("hole references require drilling");
|
||||
};
|
||||
if self.hole_sources.len() > MAX_ITEMS || self.hole_sources.len() != holes.len() {
|
||||
return invalid("each linked hole needs exactly one source reference");
|
||||
}
|
||||
for (index, source) in self.hole_sources.iter().enumerate() {
|
||||
source.validate()?;
|
||||
if self.hole_sources.iter().take(index).any(|old| old == source) {
|
||||
return invalid("duplicate hole reference");
|
||||
}
|
||||
}
|
||||
}
|
||||
if [self.stepdown, self.stepover, self.feed, self.plunge_feed, self.spindle, self.tolerance].iter().any(|v| !positive(*v))
|
||||
|| !finite(self.top)
|
||||
|| !finite(self.bottom)
|
||||
|| !finite(self.allowance)
|
||||
|| self.allowance < 0.0
|
||||
|| self.bottom >= self.top
|
||||
|| self.top > s.stock.max.z
|
||||
|| self.top < s.stock.min.z
|
||||
|| self.stepover > t.diameter
|
||||
|| self.top - self.bottom + t.tip_length() > t.cutting_length
|
||||
|| (self.top - self.bottom) / self.stepdown > 10_000.0
|
||||
|| self.tolerance < 1e-5
|
||||
|| self.tolerance > 1.0
|
||||
{
|
||||
return invalid("invalid heights, feeds, step sizes, tolerance or cutting reach");
|
||||
}
|
||||
match &self.geometry {
|
||||
Strategy::Drill { holes, peck, .. } => {
|
||||
if t.kind != ToolKind::TwistDrill
|
||||
|| self.allowance != 0.0
|
||||
|| holes.is_empty()
|
||||
|| holes.len() > MAX_ITEMS
|
||||
|| peck.is_some_and(|v| !positive(v) || (self.top - self.bottom) / v > 10_000.0)
|
||||
{
|
||||
return invalid(
|
||||
"drilling needs a drill, bounded holes, positive peck and zero stock allowance; set the required depth explicitly",
|
||||
);
|
||||
}
|
||||
for p in holes {
|
||||
point(Vec3::new(p[0], p[1], 0.0))?;
|
||||
}
|
||||
}
|
||||
Strategy::Contour { boundary, .. } | Strategy::Pocket { boundary } => {
|
||||
if t.kind != ToolKind::FlatEndMill {
|
||||
return invalid("milling needs a flat end mill");
|
||||
}
|
||||
boundary.validate()?;
|
||||
}
|
||||
Strategy::Face => {
|
||||
if t.kind != ToolKind::FlatEndMill {
|
||||
return invalid("facing needs a flat end mill");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Boundary {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.islands.len() > 100 {
|
||||
return invalid("too many islands");
|
||||
}
|
||||
let mut total = 0;
|
||||
for ring in std::iter::once(&self.outer).chain(&self.islands) {
|
||||
total += ring.len();
|
||||
if ring.len() < 3 || total > 10_000 {
|
||||
return invalid("boundary needs 3–10000 vertices");
|
||||
}
|
||||
for p in ring {
|
||||
point(Vec3::new(p[0], p[1], 0.0))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Program {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub setup: u64,
|
||||
pub operations: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Geometry {
|
||||
pub target: Vec<[Vec3; 3]>,
|
||||
pub fixtures: Vec<[Vec3; 3]>,
|
||||
/// Triangle counts for consecutive fixture bodies. Shells of one body share
|
||||
/// a winding sum so enclosed cavities remain empty. Legacy snapshots contain
|
||||
/// one triangle soup; an empty list treats that soup as one body.
|
||||
#[serde(default)]
|
||||
pub fixture_groups: Vec<usize>,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct JobInput {
|
||||
pub setup: Setup,
|
||||
pub tools: Vec<Tool>,
|
||||
pub operations: Vec<Operation>,
|
||||
pub geometry: Geometry,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
impl JobInput {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let manufacturing = Manufacturing {
|
||||
setups: vec![self.setup.clone()],
|
||||
tools: self.tools.clone(),
|
||||
operations: self.operations.clone(),
|
||||
programs: vec![Program {
|
||||
id: 1,
|
||||
name: "Snapshot".into(),
|
||||
setup: self.setup.id,
|
||||
operations: self.operations.iter().map(|o| o.id).collect(),
|
||||
}],
|
||||
};
|
||||
manufacturing.validate()?;
|
||||
if self.tools.iter().map(|t| t.number).collect::<std::collections::BTreeSet<_>>().len() != self.tools.len() {
|
||||
return invalid("ambiguous tool numbers");
|
||||
}
|
||||
if self.geometry.target.is_empty() || self.geometry.target.len() + self.geometry.fixtures.len() > 200_000 {
|
||||
return invalid("empty or excessive verification geometry");
|
||||
}
|
||||
if !self.geometry.fixture_groups.is_empty()
|
||||
&& (self.geometry.fixture_groups.len() > MAX_ITEMS
|
||||
|| self.geometry.fixture_groups.contains(&0)
|
||||
|| self.geometry.fixture_groups.iter().try_fold(0usize, |sum, n| sum.checked_add(*n)) != Some(self.geometry.fixtures.len()))
|
||||
{
|
||||
return invalid("invalid fixture body triangle groups");
|
||||
}
|
||||
for triangle in self.geometry.target.iter().chain(&self.geometry.fixtures) {
|
||||
for p in triangle {
|
||||
point(*p)?;
|
||||
}
|
||||
}
|
||||
for p in self.geometry.target.iter().flatten() {
|
||||
let a = self.setup.stock.min;
|
||||
let b = self.setup.stock.max;
|
||||
if p.x < a.x - TOLERANCE
|
||||
|| p.y < a.y - TOLERANCE
|
||||
|| p.z < a.z - TOLERANCE
|
||||
|| p.x > b.x + TOLERANCE
|
||||
|| p.y > b.y + TOLERANCE
|
||||
|| p.z > b.z + TOLERANCE
|
||||
{
|
||||
return invalid("rectangular stock must enclose selected model geometry");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Motion {
|
||||
Rapid { to: Vec3 },
|
||||
Linear { to: Vec3, feed: f64 },
|
||||
Arc { to: Vec3, center: Vec3, clockwise: bool, feed: f64 },
|
||||
Tool { tool: u64, number: u32 },
|
||||
Spindle { rpm: f64 },
|
||||
Coolant { on: bool },
|
||||
Stop,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Block {
|
||||
pub operation: u64,
|
||||
pub motion: Motion,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Toolpath {
|
||||
pub fingerprint: String,
|
||||
pub blocks: Vec<Block>,
|
||||
}
|
||||
pub(crate) fn finite(v: f64) -> bool {
|
||||
v.is_finite() && v.abs() <= 1e6
|
||||
}
|
||||
pub(crate) fn positive(v: f64) -> bool {
|
||||
finite(v) && v > 0.0
|
||||
}
|
||||
pub(crate) fn point(v: Vec3) -> Result<()> {
|
||||
if [v.x, v.y, v.z].into_iter().all(finite) { Ok(()) } else { invalid("non-finite or excessive coordinate") }
|
||||
}
|
||||
fn name(s: &str) -> Result<()> {
|
||||
if s.trim().is_empty() || s.len() > 256 || s.chars().any(char::is_control) { invalid("invalid name") } else { Ok(()) }
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Strict reference-dialect post and independent modal NC reader.
|
||||
//! The reader has no dependency on the toolpath generator.
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::{
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
|
||||
pub const DIALECT: &str = "solidcraft-reference-3axis/2; not a qualified machine post";
|
||||
// A maximum-size path also emits the three explicit startup events below.
|
||||
const MAX_RECORDS: usize = MAX_MOVES + 3;
|
||||
fn complete<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
|
||||
let mut future = std::pin::pin!(future);
|
||||
match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
|
||||
Poll::Ready(result) => result,
|
||||
Poll::Pending => invalid("synchronous NC checkpoint unexpectedly suspended"),
|
||||
}
|
||||
}
|
||||
async fn checkpoint<F: Future<Output = ()>>(cancelled: &impl Fn() -> bool, yield_control: &mut impl FnMut() -> F) -> Result<()> {
|
||||
if cancelled() {
|
||||
return invalid("NC processing cancelled");
|
||||
}
|
||||
yield_control().await;
|
||||
if cancelled() { invalid("NC processing cancelled") } else { Ok(()) }
|
||||
}
|
||||
fn startup(setup: &Setup) -> [Motion; 3] {
|
||||
[Motion::Spindle { rpm: 0.0 }, Motion::Coolant { on: false }, Motion::Rapid { to: Vec3::new(0., 0., setup.clearance) }]
|
||||
}
|
||||
pub fn post(path: &Toolpath, setup: &Setup) -> Result<String> {
|
||||
complete(post_cooperative(path, setup, || false, || std::future::ready(())))
|
||||
}
|
||||
/// Preserve post bytes while yielding every 256 blocks. No partial output on cancellation.
|
||||
pub async fn post_cooperative<F: Future<Output = ()>>(
|
||||
path: &Toolpath,
|
||||
setup: &Setup,
|
||||
cancelled: impl Fn() -> bool,
|
||||
mut yield_control: impl FnMut() -> F,
|
||||
) -> Result<String> {
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
if path.blocks.is_empty() || path.blocks.len() > MAX_MOVES {
|
||||
return invalid("cannot post an empty or excessive path");
|
||||
}
|
||||
let mut out = format!("({DIALECT})\n(Fingerprint {})\nG21 G90 G17 G91.1 G94 G{} G40 G49 G80\nM5\nM9\n", path.fingerprint, setup.work_offset);
|
||||
let mut current = Vec3::new(0.0, 0.0, setup.clearance);
|
||||
out.push_str(&format!("G0 X0.000000 Y0.000000 Z{:.6}\n", setup.clearance));
|
||||
for (index, block) in path.blocks.iter().enumerate() {
|
||||
if index.is_multiple_of(256) {
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
}
|
||||
out.push_str(&format!("(Operation {})\n", block.operation));
|
||||
let line = match &block.motion {
|
||||
Motion::Rapid { to } => format!("G0 X{:.6} Y{:.6} Z{:.6}", to.x, to.y, to.z),
|
||||
Motion::Linear { to, feed } => format!("G1 X{:.6} Y{:.6} Z{:.6} F{:.6}", to.x, to.y, to.z, feed),
|
||||
Motion::Arc { to, center, clockwise, feed } => format!(
|
||||
"G{} X{:.6} Y{:.6} Z{:.6} I{:.6} J{:.6} F{:.6}",
|
||||
if *clockwise { 2 } else { 3 },
|
||||
to.x,
|
||||
to.y,
|
||||
to.z,
|
||||
center.x - current.x,
|
||||
center.y - current.y,
|
||||
feed
|
||||
),
|
||||
Motion::Tool { number, .. } => format!("T{number} M6"),
|
||||
Motion::Spindle { rpm } => {
|
||||
if *rpm == 0.0 {
|
||||
"M5".into()
|
||||
} else {
|
||||
format!("S{rpm:.6} M3")
|
||||
}
|
||||
}
|
||||
Motion::Coolant { on } => {
|
||||
if *on {
|
||||
"M8".into()
|
||||
} else {
|
||||
"M9".into()
|
||||
}
|
||||
}
|
||||
Motion::Stop => "M0".into(),
|
||||
};
|
||||
out.push_str(&line);
|
||||
out.push('\n');
|
||||
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = &block.motion {
|
||||
current = *to;
|
||||
}
|
||||
}
|
||||
out.push_str("M30\n");
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Record {
|
||||
pub line: usize,
|
||||
pub motion: Motion,
|
||||
}
|
||||
/// Provenance established only after the independent reader matches a posted block.
|
||||
/// `block` is one-based and also the completed-block count used by playback.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceLocation {
|
||||
pub line: usize,
|
||||
pub block: usize,
|
||||
pub operation: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Parsed {
|
||||
pub records: Vec<Record>,
|
||||
pub work_offset: u8,
|
||||
// Never accept provenance from comments or deserialized external programs.
|
||||
#[serde(skip)]
|
||||
sources: Vec<SourceLocation>,
|
||||
}
|
||||
impl Parsed {
|
||||
pub fn source_for_line(&self, line: usize) -> Option<SourceLocation> {
|
||||
self.sources.binary_search_by_key(&line, |s| s.line).ok().and_then(|i| self.sources.get(i).copied())
|
||||
}
|
||||
/// First interpreted record on a physical NC line, using a zero-based index.
|
||||
pub fn record_for_line(&self, line: usize) -> Option<usize> {
|
||||
let i = self.records.partition_point(|r| r.line < line);
|
||||
self.records.get(i).filter(|r| r.line == line).map(|_| i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strictly consumes every word. Units and distance mode must be declared before motion.
|
||||
pub fn parse(source: &str, tools: &[Tool]) -> Result<Parsed> {
|
||||
complete(parse_cooperative(source, tools, || false, || std::future::ready(())))
|
||||
}
|
||||
/// Independent modal reader with cancellation and a checkpoint every 256 physical lines.
|
||||
pub async fn parse_cooperative<F: Future<Output = ()>>(
|
||||
source: &str,
|
||||
tools: &[Tool],
|
||||
cancelled: impl Fn() -> bool,
|
||||
mut yield_control: impl FnMut() -> F,
|
||||
) -> Result<Parsed> {
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
if source.len() > 32 * 1024 * 1024 {
|
||||
return invalid("NC file too large");
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut units = None;
|
||||
let mut absolute = None;
|
||||
let mut plane = false;
|
||||
let mut per_minute = false;
|
||||
let mut offset = None;
|
||||
let mut pos = Vec3::ZERO;
|
||||
let mut have_pos = false;
|
||||
let mut mode = None;
|
||||
let mut feed = 0.0;
|
||||
let mut rpm = 0.0;
|
||||
let mut spindle_on = false;
|
||||
let mut coolant_on = false;
|
||||
let mut selected = None;
|
||||
let mut ended = false;
|
||||
for (index, line) in source.lines().enumerate() {
|
||||
if index.is_multiple_of(256) {
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
}
|
||||
let line_number = index + 1;
|
||||
let fail = |s: &str| Error(format!("NC line {line_number}: {s}"));
|
||||
let mut words = words(line).map_err(|e| fail(&e.0))?;
|
||||
// Modal changes apply to the whole block, independent of word order.
|
||||
words.sort_by_key(|(letter, _)| if *letter == 'G' { 0 } else { 1 });
|
||||
if words.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if ended {
|
||||
return Err(fail("content after program end"));
|
||||
}
|
||||
let mut coordinates = [None; 3];
|
||||
let mut ij = [None; 2];
|
||||
let mut m = None;
|
||||
let mut groups = std::collections::BTreeSet::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for (letter, value) in words {
|
||||
if letter != 'G' && !seen.insert(letter) {
|
||||
return Err(fail("duplicate address"));
|
||||
}
|
||||
match letter {
|
||||
'N' => {
|
||||
if value.fract() != 0.0 || value < 0.0 {
|
||||
return Err(fail("invalid line number"));
|
||||
}
|
||||
}
|
||||
'G' => {
|
||||
let group = match value {
|
||||
0.0..=3.0 if value.fract() == 0.0 => {
|
||||
mode = Some(value as u8);
|
||||
0
|
||||
}
|
||||
17.0 => {
|
||||
plane = true;
|
||||
1
|
||||
}
|
||||
20.0 => {
|
||||
units = Some(25.4);
|
||||
2
|
||||
}
|
||||
21.0 => {
|
||||
units = Some(1.0);
|
||||
2
|
||||
}
|
||||
90.0 => {
|
||||
absolute = Some(true);
|
||||
3
|
||||
}
|
||||
91.0 => {
|
||||
absolute = Some(false);
|
||||
3
|
||||
}
|
||||
94.0 => {
|
||||
per_minute = true;
|
||||
4
|
||||
}
|
||||
54.0..=59.0 if value.fract() == 0.0 => {
|
||||
if offset.is_some_and(|o| o != value as u8) {
|
||||
return Err(fail("multiple work offsets unsupported"));
|
||||
}
|
||||
offset = Some(value as u8);
|
||||
5
|
||||
}
|
||||
40.0 => 6,
|
||||
49.0 => 7,
|
||||
80.0 => {
|
||||
mode = None;
|
||||
0
|
||||
}
|
||||
91.1 => 9,
|
||||
_ => return Err(fail("unsupported G code; no commands are silently discarded")),
|
||||
};
|
||||
if !groups.insert(group) {
|
||||
return Err(fail("conflicting modal codes"));
|
||||
}
|
||||
}
|
||||
'M' => {
|
||||
if value.fract() != 0.0 {
|
||||
return Err(fail("invalid M code"));
|
||||
}
|
||||
m = Some(value as i32);
|
||||
}
|
||||
'X' => coordinates[0] = Some(value),
|
||||
'Y' => coordinates[1] = Some(value),
|
||||
'Z' => coordinates[2] = Some(value),
|
||||
'I' => ij[0] = Some(value),
|
||||
'J' => ij[1] = Some(value),
|
||||
'F' => {
|
||||
let scale = units.ok_or_else(|| fail("declare units before feed"))?;
|
||||
feed = value * scale;
|
||||
if !crate::model::positive(feed) {
|
||||
return Err(fail("feed must be positive"));
|
||||
}
|
||||
}
|
||||
'S' => {
|
||||
if value < 0.0 {
|
||||
return Err(fail("negative spindle speed"));
|
||||
}
|
||||
rpm = value;
|
||||
}
|
||||
'T' => {
|
||||
if value.fract() != 0.0 || value < 1.0 || value > 9999.0 {
|
||||
return Err(fail("invalid tool number"));
|
||||
}
|
||||
selected = Some(value as u32);
|
||||
}
|
||||
_ => return Err(fail("unsupported address")),
|
||||
}
|
||||
}
|
||||
let moving = coordinates.iter().any(Option::is_some) || ij.iter().any(Option::is_some);
|
||||
if moving && m.is_some() {
|
||||
return Err(fail("combined motion/M blocks unsupported by reference dialect"));
|
||||
}
|
||||
// Speed changes precede motion and coolant/stop commands in a block.
|
||||
// Other spindle-changing M codes below already emit its final state.
|
||||
if seen.contains(&'S') && spindle_on && !matches!(m, Some(3 | 5 | 6 | 2 | 30)) {
|
||||
out.push(Record { line: line_number, motion: Motion::Spindle { rpm } });
|
||||
}
|
||||
if moving {
|
||||
let scale = units.ok_or_else(|| fail("missing units"))?;
|
||||
let abs = absolute.ok_or_else(|| fail("missing distance mode"))?;
|
||||
if !plane || !per_minute || offset.is_none() {
|
||||
return Err(fail("declare G17, G94 and work offset before motion"));
|
||||
}
|
||||
if !have_pos && (!abs || coordinates.iter().any(Option::is_none)) {
|
||||
return Err(fail("first position requires absolute XYZ"));
|
||||
}
|
||||
let base = [pos.x, pos.y, pos.z];
|
||||
let mut xyz = base;
|
||||
for k in 0..3 {
|
||||
if let Some(v) = coordinates[k] {
|
||||
xyz[k] = v * scale + if abs { 0.0 } else { base[k] };
|
||||
}
|
||||
}
|
||||
let to = Vec3::new(xyz[0], xyz[1], xyz[2]);
|
||||
crate::model::point(to)?;
|
||||
let motion = match mode.ok_or_else(|| fail("missing motion mode"))? {
|
||||
0 => {
|
||||
if ij.iter().any(Option::is_some) {
|
||||
return Err(fail("arc center on rapid"));
|
||||
}
|
||||
Motion::Rapid { to }
|
||||
}
|
||||
1 => {
|
||||
if ij.iter().any(Option::is_some) || feed <= 0.0 {
|
||||
return Err(fail("invalid linear feed/center"));
|
||||
}
|
||||
Motion::Linear { to, feed }
|
||||
}
|
||||
arc => {
|
||||
if !have_pos || feed <= 0.0 || ij.iter().all(Option::is_none) || (to.z - pos.z).abs() > 1e-8 {
|
||||
return Err(fail("only planar XY I/J arcs with positive feed are supported"));
|
||||
}
|
||||
let center = Vec3::new(pos.x + ij[0].unwrap_or(0.0) * scale, pos.y + ij[1].unwrap_or(0.0) * scale, pos.z);
|
||||
let r = pos.dist(center);
|
||||
if r <= 1e-8 || (to.dist(center) - r).abs() > POST_ERROR {
|
||||
return Err(fail("inconsistent arc radius"));
|
||||
}
|
||||
Motion::Arc { to, center, clockwise: arc == 2, feed }
|
||||
}
|
||||
};
|
||||
out.push(Record { line: line_number, motion });
|
||||
pos = to;
|
||||
have_pos = true;
|
||||
}
|
||||
if let Some(m) = m {
|
||||
let motion = match m {
|
||||
0 => Some(Motion::Stop),
|
||||
3 => {
|
||||
if rpm <= 0.0 {
|
||||
return Err(fail("spindle start requires positive speed"));
|
||||
}
|
||||
spindle_on = true;
|
||||
Some(Motion::Spindle { rpm })
|
||||
}
|
||||
5 => {
|
||||
spindle_on = false;
|
||||
Some(Motion::Spindle { rpm: 0.0 })
|
||||
}
|
||||
6 => {
|
||||
let n = selected.ok_or_else(|| fail("tool change needs T"))?;
|
||||
let matches: Vec<_> = tools.iter().filter(|t| t.number == n).collect();
|
||||
if matches.len() != 1 {
|
||||
return Err(fail("unknown or ambiguous tool number"));
|
||||
}
|
||||
// NIST RS274/NGC §3.6.3: M6 stops the spindle without
|
||||
// restarting it; programmed speed and coolant are retained.
|
||||
if spindle_on {
|
||||
out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } });
|
||||
}
|
||||
spindle_on = false;
|
||||
Some(Motion::Tool { tool: matches[0].id, number: n })
|
||||
}
|
||||
8 => {
|
||||
coolant_on = true;
|
||||
Some(Motion::Coolant { on: true })
|
||||
}
|
||||
9 => {
|
||||
coolant_on = false;
|
||||
Some(Motion::Coolant { on: false })
|
||||
}
|
||||
2 | 30 => {
|
||||
// Program end stops spindle and coolant in the interpreted state.
|
||||
if spindle_on {
|
||||
out.push(Record { line: line_number, motion: Motion::Spindle { rpm: 0.0 } });
|
||||
}
|
||||
if coolant_on {
|
||||
out.push(Record { line: line_number, motion: Motion::Coolant { on: false } });
|
||||
}
|
||||
ended = true;
|
||||
None
|
||||
}
|
||||
_ => return Err(fail("unsupported M code")),
|
||||
};
|
||||
if let Some(motion) = motion {
|
||||
out.push(Record { line: line_number, motion });
|
||||
}
|
||||
}
|
||||
if out.len() > MAX_RECORDS {
|
||||
return Err(fail("NC motion limit"));
|
||||
}
|
||||
}
|
||||
if !ended || !have_pos {
|
||||
return invalid("NC program requires motion and an explicit program end");
|
||||
}
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
Ok(Parsed { records: out, work_offset: offset.ok_or_else(|| Error("missing work offset".into()))?, sources: Vec::new() })
|
||||
}
|
||||
fn words(line: &str) -> Result<Vec<(char, f64)>> {
|
||||
if line.len() > 4096 {
|
||||
return invalid("NC line too long");
|
||||
}
|
||||
let mut clean = String::new();
|
||||
let mut comment = false;
|
||||
for c in line.chars() {
|
||||
match c {
|
||||
'(' if !comment => comment = true,
|
||||
')' if comment => comment = false,
|
||||
'(' | ')' => return invalid("malformed comment"),
|
||||
';' if !comment => break,
|
||||
_ if !comment => clean.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if comment {
|
||||
return invalid("unterminated comment");
|
||||
}
|
||||
let clean = clean.trim();
|
||||
if clean == "%" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let chars: Vec<_> = clean.chars().collect();
|
||||
let mut i = 0;
|
||||
let mut result = Vec::new();
|
||||
while i < chars.len() {
|
||||
if chars[i].is_whitespace() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let letter = chars[i].to_ascii_uppercase();
|
||||
if !letter.is_ascii_uppercase() {
|
||||
return invalid("expected address letter");
|
||||
}
|
||||
i += 1;
|
||||
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
|
||||
i += 1;
|
||||
}
|
||||
let start = i;
|
||||
while chars.get(i).is_some_and(|c| c.is_ascii_digit() || matches!(c, '.' | '+' | '-')) {
|
||||
i += 1;
|
||||
}
|
||||
let s: String = chars[start..i].iter().collect();
|
||||
let v = s.parse::<f64>().map_err(|_| Error("invalid numeric word".into()))?;
|
||||
if !crate::model::finite(v) {
|
||||
return invalid("non-finite/excessive word");
|
||||
}
|
||||
result.push((letter, v));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn round_trip(path: &Toolpath, setup: &Setup, tools: &[Tool]) -> Result<Parsed> {
|
||||
complete(round_trip_cooperative(path, setup, tools, || false, || std::future::ready(())))
|
||||
}
|
||||
/// Post, independently interpret and compare in bounded batches, retaining trusted line mappings.
|
||||
pub async fn round_trip_cooperative<F: Future<Output = ()>>(
|
||||
path: &Toolpath,
|
||||
setup: &Setup,
|
||||
tools: &[Tool],
|
||||
cancelled: impl Fn() -> bool,
|
||||
mut yield_control: impl FnMut() -> F,
|
||||
) -> Result<Parsed> {
|
||||
let source = post_cooperative(path, setup, &cancelled, &mut yield_control).await?;
|
||||
let mut parsed = parse_cooperative(&source, tools, &cancelled, &mut yield_control).await?;
|
||||
let startup = startup(setup);
|
||||
if parsed.work_offset != setup.work_offset || parsed.records.len() != path.blocks.len() + startup.len() {
|
||||
return invalid("post changed block count or work offset");
|
||||
}
|
||||
if !startup.iter().zip(&parsed.records).all(|(a, b)| equivalent(a, &b.motion)) {
|
||||
return invalid("post changed startup state");
|
||||
}
|
||||
let mut original_position = Vec3::new(0., 0., setup.clearance);
|
||||
let mut posted_position = original_position;
|
||||
if let Some(Record { motion: Motion::Rapid { to }, .. }) = parsed.records.get(startup.len() - 1) {
|
||||
posted_position = *to;
|
||||
}
|
||||
for (i, (a, b)) in path.blocks.iter().zip(parsed.records.iter().skip(startup.len())).enumerate() {
|
||||
if i.is_multiple_of(256) {
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
}
|
||||
if !equivalent(&a.motion, &b.motion) {
|
||||
return invalid(format!("post mismatch at NC line {}", b.line));
|
||||
}
|
||||
if let (Motion::Arc { to, center, clockwise, .. }, Motion::Arc { to: posted_to, center: posted_center, .. }) = (&a.motion, &b.motion) {
|
||||
let before = sweep(original_position, *to, *center, *clockwise);
|
||||
let after = sweep(posted_position, *posted_to, *posted_center, *clockwise);
|
||||
if (before - after).abs() * original_position.dist(*center) > POST_ERROR {
|
||||
return invalid(format!("post changed arc sweep at NC line {}", b.line));
|
||||
}
|
||||
}
|
||||
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = a.motion {
|
||||
original_position = to;
|
||||
}
|
||||
if let Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } = b.motion {
|
||||
posted_position = to;
|
||||
}
|
||||
parsed.sources.push(SourceLocation { line: b.line, block: i + 1, operation: a.operation });
|
||||
}
|
||||
checkpoint(&cancelled, &mut yield_control).await?;
|
||||
Ok(parsed)
|
||||
}
|
||||
fn sweep(from: Vec3, to: Vec3, center: Vec3, clockwise: bool) -> f64 {
|
||||
if from.x == to.x && from.y == to.y {
|
||||
return if clockwise { -std::f64::consts::TAU } else { std::f64::consts::TAU };
|
||||
}
|
||||
let a = (from.y - center.y).atan2(from.x - center.x);
|
||||
let b = (to.y - center.y).atan2(to.x - center.x);
|
||||
if clockwise { -(a - b).rem_euclid(std::f64::consts::TAU) } else { (b - a).rem_euclid(std::f64::consts::TAU) }
|
||||
}
|
||||
fn equivalent(a: &Motion, b: &Motion) -> bool {
|
||||
match (a, b) {
|
||||
(Motion::Rapid { to: a }, Motion::Rapid { to: b }) => a.dist(*b) <= POST_ERROR,
|
||||
(Motion::Linear { to: a, feed: f }, Motion::Linear { to: b, feed: g }) => a.dist(*b) <= POST_ERROR && (f - g).abs() <= 1e-6,
|
||||
(Motion::Arc { to: a, center: c, clockwise: d, feed: f }, Motion::Arc { to: b, center: e, clockwise: h, feed: g }) => {
|
||||
a.dist(*b) <= POST_ERROR && c.dist(*e) <= POST_ERROR && d == h && (f - g).abs() <= 1e-6
|
||||
}
|
||||
(Motion::Spindle { rpm: a }, Motion::Spindle { rpm: b }) => (a - b).abs() <= 1e-6,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Segment {
|
||||
pub from: Vec3,
|
||||
pub to: Vec3,
|
||||
pub rapid: bool,
|
||||
pub feed: f64,
|
||||
pub tool: u64,
|
||||
pub line: usize,
|
||||
}
|
||||
/// Expand interpreted circular motion with a bounded chord error, independent of posting.
|
||||
pub fn segments(parsed: &Parsed, setup: &Setup) -> Result<Vec<Segment>> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = None;
|
||||
let mut tool = 0;
|
||||
let mut spindle = 0.0;
|
||||
for r in &parsed.records {
|
||||
match &r.motion {
|
||||
Motion::Tool { tool: id, .. } => {
|
||||
if pos.is_none_or(|p: Vec3| p.z < setup.clearance - POST_ERROR) {
|
||||
return invalid(format!("tool change below clearance at line {}", r.line));
|
||||
}
|
||||
if spindle != 0.0 {
|
||||
return invalid("tool change with spindle running");
|
||||
}
|
||||
tool = *id;
|
||||
}
|
||||
Motion::Spindle { rpm } => spindle = *rpm,
|
||||
Motion::Rapid { to } | Motion::Linear { to, .. } | Motion::Arc { to, .. } => {
|
||||
let rapid = matches!(r.motion, Motion::Rapid { .. });
|
||||
let feed = match r.motion {
|
||||
Motion::Linear { feed, .. } | Motion::Arc { feed, .. } => feed,
|
||||
_ => 0.0,
|
||||
};
|
||||
if !rapid && (tool == 0 || spindle <= 0.0) {
|
||||
return invalid(format!("cutting without tool/spindle at line {}", r.line));
|
||||
}
|
||||
if let Some(from) = pos {
|
||||
if tool == 0 {
|
||||
return invalid(format!("motion before selecting a known tool at line {}", r.line));
|
||||
}
|
||||
if let Motion::Arc { center, clockwise, .. } = r.motion {
|
||||
let a: f64 = (from.y - center.y).atan2(from.x - center.x);
|
||||
let sweep = sweep(from, *to, center, clockwise);
|
||||
let radius = from.dist(center);
|
||||
let step = (2.0 * (1.0 - POST_ERROR / radius).clamp(-1.0, 1.0).acos()).max(1e-6);
|
||||
let count = (sweep.abs() / step).ceil() as usize;
|
||||
if count > MAX_MOVES || out.len() + count > MAX_MOVES {
|
||||
return invalid("arc subdivision limit");
|
||||
}
|
||||
let mut prev = from;
|
||||
for k in 1..=count {
|
||||
let angle = a + sweep * k as f64 / count as f64;
|
||||
let p = if k == count { *to } else { Vec3::new(center.x + radius * angle.cos(), center.y + radius * angle.sin(), to.z) };
|
||||
out.push(Segment { from: prev, to: p, rapid, feed, tool, line: r.line });
|
||||
prev = p;
|
||||
}
|
||||
} else {
|
||||
out.push(Segment { from, to: *to, rapid, feed, tool, line: r.line });
|
||||
}
|
||||
} else if !rapid || to.z < setup.clearance - POST_ERROR {
|
||||
return invalid("initial position must be at clearance");
|
||||
}
|
||||
pos = Some(*to);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if out.len() > MAX_MOVES {
|
||||
return invalid("simulation segment limit");
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod provenance_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn tiny_arcs_cannot_round_into_unintended_full_circles() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
for clockwise in [false, true] {
|
||||
let to = Vec3::new(1., if clockwise { -1e-8 } else { 1e-8 }, 15.);
|
||||
let path = Toolpath {
|
||||
fingerprint: input.fingerprint.clone(),
|
||||
blocks: vec![
|
||||
Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } },
|
||||
Block { operation: 1, motion: Motion::Spindle { rpm: 1000. } },
|
||||
Block { operation: 1, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } },
|
||||
Block { operation: 1, motion: Motion::Arc { to, center: Vec3::new(0., 0., 15.), clockwise, feed: 100. } },
|
||||
Block { operation: 1, motion: Motion::Spindle { rpm: 0. } },
|
||||
],
|
||||
};
|
||||
assert!(round_trip(&path, &input.setup, &input.tools).unwrap_err().0.contains("arc sweep"));
|
||||
}
|
||||
let raw = "G21 G90 G17 G94 G54\nG0 X1 Y0 Z15\nT1 M6\nS1000 M3\nG3 X1 Y0.0000000000001 I-1 J0 F100\nM5\nM30";
|
||||
let parsed = parse(raw, &input.tools).unwrap();
|
||||
let distance: f64 = segments(&parsed, &input.setup).unwrap().iter().map(|s| s.from.dist(s.to)).sum();
|
||||
assert!(distance < 1e-10, "a noncoincident tiny arc is not a full circle: {distance}");
|
||||
}
|
||||
#[test]
|
||||
fn diagnostic_sources_follow_checked_records_and_ignore_untrusted_annotations() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
let path = Toolpath {
|
||||
fingerprint: input.fingerprint.clone(),
|
||||
blocks: vec![
|
||||
Block { operation: 10, motion: Motion::Tool { tool: 1, number: 1 } },
|
||||
Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } },
|
||||
Block { operation: 10, motion: Motion::Rapid { to: Vec3::new(1., 0., 15.) } },
|
||||
Block {
|
||||
operation: 20,
|
||||
motion: Motion::Arc { to: Vec3::new(1., 0., 15.), center: Vec3::new(0., 0., 15.), clockwise: true, feed: 100. },
|
||||
},
|
||||
Block { operation: 20, motion: Motion::Spindle { rpm: 0. } },
|
||||
Block { operation: 20, motion: Motion::Stop },
|
||||
],
|
||||
};
|
||||
let parsed = round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let prefix = startup(&input.setup).len();
|
||||
for (i, record) in parsed.records.iter().skip(prefix).enumerate() {
|
||||
assert_eq!(
|
||||
parsed.source_for_line(record.line),
|
||||
Some(SourceLocation { line: record.line, block: i + 1, operation: path.blocks[i].operation })
|
||||
);
|
||||
assert_eq!(parsed.record_for_line(record.line), Some(i + prefix));
|
||||
}
|
||||
for record in parsed.records.iter().take(prefix) {
|
||||
assert!(parsed.source_for_line(record.line).is_none());
|
||||
}
|
||||
assert!(parsed.source_for_line(7).is_none()); // comment
|
||||
let arc = &parsed.records[prefix + 3];
|
||||
let segments = segments(&parsed, &input.setup).unwrap();
|
||||
assert!(segments.iter().filter(|s| s.line == arc.line).count() > 10);
|
||||
assert!(segments.iter().filter(|s| s.line == arc.line).all(|s| parsed.source_for_line(s.line).unwrap().block == 4));
|
||||
let raw = parse(&post(&path, &input.setup).unwrap().replace("Operation 20", "Operation 999"), &input.tools).unwrap();
|
||||
assert!(raw.source_for_line(arc.line).is_none());
|
||||
let mut serialized = serde_json::to_value(&parsed).unwrap();
|
||||
serialized["sources"] = serde_json::json!([{"line":arc.line,"block":4,"operation":999}]);
|
||||
let decoded: Parsed = serde_json::from_value(serialized).unwrap();
|
||||
assert!(decoded.source_for_line(arc.line).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn diagnostic_fault_findings_retain_checked_block_but_raw_nc_has_only_record() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
let mut path = generate::generate(input.clone()).unwrap();
|
||||
for block in &mut path.blocks {
|
||||
if let Motion::Linear { to, .. } = &mut block.motion
|
||||
&& to.z == 5.
|
||||
{
|
||||
to.z = 4.;
|
||||
}
|
||||
}
|
||||
let parsed = round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.status, verify::Status::Fail);
|
||||
let finding = report.findings.iter().find(|f| f.kind == "gouge").unwrap();
|
||||
assert_eq!(finding.severity, verify::FindingSeverity::Error);
|
||||
assert_eq!(report.findings[0].severity, verify::FindingSeverity::Error);
|
||||
let source = finding.source.unwrap();
|
||||
assert_eq!(Some(source.line), finding.line);
|
||||
assert_eq!(source.operation, path.blocks[source.block - 1].operation);
|
||||
assert_eq!(finding.record, Some(source.block + startup(&input.setup).len()));
|
||||
let raw = parse(&post(&path, &input.setup).unwrap(), &input.tools).unwrap();
|
||||
let raw_report = verify::simulate(&input, &raw, Default::default()).unwrap();
|
||||
assert_eq!(raw_report.status, report.status);
|
||||
assert_eq!(raw_report.finished_dimensions, report.finished_dimensions);
|
||||
assert!(raw_report.findings.iter().filter(|f| f.line.is_some()).all(|f| f.source.is_none() && f.record.is_some()));
|
||||
let legacy: verify::Finding =
|
||||
serde_json::from_value(serde_json::json!({"kind":"legacy","line":4,"at":[0,0,0],"detail":"old report"})).unwrap();
|
||||
assert!(legacy.record.is_none() && legacy.source.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn diagnostic_retention_keeps_errors_and_budget_reasons_bounded() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let mut report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
report.findings.clear();
|
||||
let finding = |line, severity, kind: &str| verify::Finding {
|
||||
line,
|
||||
severity,
|
||||
kind: kind.into(),
|
||||
record: None,
|
||||
source: None,
|
||||
at: Vec3::ZERO,
|
||||
detail: "witness".into(),
|
||||
};
|
||||
for line in 1..=250 {
|
||||
report.finding(finding(Some(line), verify::FindingSeverity::Unresolved, "clearance_unresolved"));
|
||||
}
|
||||
assert_eq!(report.findings.len(), 200);
|
||||
report.finding(finding(None, verify::FindingSeverity::Unresolved, "budget_exhausted"));
|
||||
report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge"));
|
||||
for _ in 0..250 {
|
||||
report.finding(finding(Some(999), verify::FindingSeverity::Error, "gouge"));
|
||||
}
|
||||
assert_eq!(report.findings.len(), 200);
|
||||
assert_eq!(report.findings.iter().filter(|f| f.kind == "gouge").count(), 1);
|
||||
assert!(report.findings.iter().any(|f| f.kind == "budget_exhausted"));
|
||||
// Diagnostic storage is not an input to acceptance decisions.
|
||||
assert_eq!(report.status, verify::Status::Pass);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Machine state at a typed toolpath boundary, shared by inspection and playback.
|
||||
use crate::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct State {
|
||||
pub completed: usize,
|
||||
pub position: Vec3,
|
||||
pub operation: Option<u64>,
|
||||
pub tool: Option<u64>,
|
||||
pub spindle: f64,
|
||||
pub coolant: bool,
|
||||
pub feed: Option<f64>,
|
||||
}
|
||||
pub fn at(path: &Toolpath, setup: &Setup, completed: usize) -> Result<State> {
|
||||
if completed > path.blocks.len() {
|
||||
return invalid("playback motion exceeds toolpath length");
|
||||
}
|
||||
let mut state =
|
||||
State { completed, position: Vec3::new(0., 0., setup.clearance), operation: None, tool: None, spindle: 0., coolant: false, feed: None };
|
||||
for block in path.blocks.iter().take(completed) {
|
||||
state.operation = Some(block.operation);
|
||||
match block.motion {
|
||||
Motion::Rapid { to } => state.position = to,
|
||||
Motion::Linear { to, feed } | Motion::Arc { to, feed, .. } => {
|
||||
state.position = to;
|
||||
state.feed = Some(feed);
|
||||
}
|
||||
Motion::Tool { tool, .. } => state.tool = Some(tool),
|
||||
Motion::Spindle { rpm } => state.spindle = rpm,
|
||||
Motion::Coolant { on } => state.coolant = on,
|
||||
Motion::Stop => {
|
||||
state.spindle = 0.;
|
||||
state.coolant = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn initial_pose_tool_changes_and_shutdown() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
let path = Toolpath {
|
||||
fingerprint: "test".into(),
|
||||
blocks: vec![
|
||||
Block { operation: 10, motion: Motion::Tool { tool: 3, number: 7 } },
|
||||
Block { operation: 10, motion: Motion::Spindle { rpm: 5000. } },
|
||||
Block { operation: 10, motion: Motion::Coolant { on: true } },
|
||||
Block { operation: 10, motion: Motion::Linear { to: Vec3::new(2., 3., 4.), feed: 100. } },
|
||||
Block { operation: 11, motion: Motion::Tool { tool: 4, number: 8 } },
|
||||
Block { operation: 11, motion: Motion::Stop },
|
||||
],
|
||||
};
|
||||
let start = at(&path, &input.setup, 0).unwrap();
|
||||
assert_eq!(start.position, Vec3::new(0., 0., 15.));
|
||||
assert!(start.tool.is_none());
|
||||
let cut = at(&path, &input.setup, 4).unwrap();
|
||||
assert_eq!(cut.position, Vec3::new(2., 3., 4.));
|
||||
assert_eq!(cut.tool, Some(3));
|
||||
assert_eq!(cut.spindle, 5000.);
|
||||
assert!(cut.coolant);
|
||||
assert_eq!(cut.feed, Some(100.));
|
||||
let end = at(&path, &input.setup, 6).unwrap();
|
||||
assert_eq!(end.tool, Some(4));
|
||||
assert_eq!(end.operation, Some(11));
|
||||
assert_eq!(end.spindle, 0.);
|
||||
assert!(!end.coolant);
|
||||
assert!(at(&path, &input.setup, 7).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Independent planar-region predicates for finished dimensions. No offsets or
|
||||
//! toolpath-generation routines are used. Winding ignores input ring direction.
|
||||
use crate::{spatial::Index, *};
|
||||
|
||||
fn orient(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> f64 {
|
||||
robust::orient2d(robust::Coord { x: a[0], y: a[1] }, robust::Coord { x: b[0], y: b[1] }, robust::Coord { x: p[0], y: p[1] })
|
||||
}
|
||||
fn contains(ring: &[[f64; 2]], p: [f64; 2]) -> bool {
|
||||
ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).fold(false, |inside, (a, b)| inside ^ crossing(*a, *b, p))
|
||||
}
|
||||
fn crossing(a: [f64; 2], b: [f64; 2], p: [f64; 2]) -> bool {
|
||||
(a[1] > p[1]) != (b[1] > p[1]) && if b[1] > a[1] { orient(a, b, p) > 0. } else { orient(a, b, p) < 0. }
|
||||
}
|
||||
struct Edge {
|
||||
a: [f64; 2],
|
||||
b: [f64; 2],
|
||||
ring: usize,
|
||||
vertex: usize,
|
||||
count: usize,
|
||||
}
|
||||
pub(crate) struct Region {
|
||||
edges: Vec<Edge>,
|
||||
index: Index,
|
||||
max_x: f64,
|
||||
guard: f64,
|
||||
}
|
||||
impl Region {
|
||||
pub(crate) fn new(boundary: &Boundary) -> Result<Self> {
|
||||
boundary.validate()?;
|
||||
let rings: Vec<_> = std::iter::once(&boundary.outer).chain(&boundary.islands).collect();
|
||||
let mut edges = Vec::new();
|
||||
let mut extent = 1_f64;
|
||||
let mut max_x = f64::NEG_INFINITY;
|
||||
for (r, ring) in rings.iter().enumerate() {
|
||||
let mut area = 0.;
|
||||
let origin = ring[0];
|
||||
for (vertex, (&a, &b)) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).enumerate() {
|
||||
if a.into_iter().any(|v| v != 0. && v.abs() < 1e-100) || (a[0] - b[0]).hypot(a[1] - b[1]) < 1e-7 {
|
||||
return invalid("unresolvable dimensional boundary edge");
|
||||
}
|
||||
extent = extent.max(a[0].abs()).max(a[1].abs());
|
||||
max_x = max_x.max(a[0]);
|
||||
area += (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0]);
|
||||
edges.push(Edge { a, b, ring: r, vertex, count: ring.len() });
|
||||
}
|
||||
if area.abs() < 1e-8 {
|
||||
return invalid("zero-area dimensional boundary");
|
||||
}
|
||||
}
|
||||
let index = Index::new(
|
||||
edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, e)| (id, [e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])]))
|
||||
.collect(),
|
||||
);
|
||||
let mut work = 0;
|
||||
for (id, e) in edges.iter().enumerate() {
|
||||
let mut candidates = Vec::new();
|
||||
index.query([e.a[0].min(e.b[0]), e.a[1].min(e.b[1])], [e.a[0].max(e.b[0]), e.a[1].max(e.b[1])], &mut candidates);
|
||||
for other in candidates.into_iter().filter(|other| *other > id) {
|
||||
work += 1;
|
||||
if work > 2_000_000 {
|
||||
return invalid("dimensional boundary validation budget exceeded");
|
||||
}
|
||||
let f = &edges[other];
|
||||
if e.ring == f.ring && ((e.vertex + 1) % e.count == f.vertex || (f.vertex + 1) % e.count == e.vertex) {
|
||||
let (a, b, c) = if e.b == f.a { (e.a, e.b, f.b) } else { (f.a, f.b, e.b) };
|
||||
if orient(a, b, c) == 0. && (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1]) <= 0. {
|
||||
return invalid("backtracking dimensional boundary");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (0..2).any(|k| e.a[k].max(e.b[k]) < f.a[k].min(f.b[k]) || f.a[k].max(f.b[k]) < e.a[k].min(e.b[k])) {
|
||||
continue;
|
||||
}
|
||||
let opposite = |x: f64, y: f64| x == 0. || y == 0. || x.is_sign_positive() != y.is_sign_positive();
|
||||
if opposite(orient(e.a, e.b, f.a), orient(e.a, e.b, f.b)) && opposite(orient(f.a, f.b, e.a), orient(f.a, f.b, e.b)) {
|
||||
return invalid("intersecting or touching dimensional boundaries");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, island) in boundary.islands.iter().enumerate() {
|
||||
if !contains(&boundary.outer, island[0]) || boundary.islands.iter().enumerate().any(|(j, r)| id != j && contains(r, island[0])) {
|
||||
return invalid("dimensional islands must be disjoint and inside the outer boundary");
|
||||
}
|
||||
}
|
||||
Ok(Self { edges, index, max_x, guard: 128. * f64::EPSILON * extent })
|
||||
}
|
||||
/// Signed distance is positive in the material-removal region. Distance to
|
||||
/// its boundary is 1-Lipschitz, so center distance +/- cell radius bounds the
|
||||
/// whole cell. A capped nearest-edge query returns an infinite far endpoint.
|
||||
pub(crate) async fn range<F: std::future::Future<Output = ()>>(
|
||||
&self,
|
||||
query: ([f64; 2], f64, f64),
|
||||
work: &mut usize,
|
||||
budget: usize,
|
||||
cancelled: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<Option<[f64; 2]>> {
|
||||
let (p, half, reach) = query;
|
||||
let guard = self.guard.max(128. * f64::EPSILON * p[0].abs().max(p[1].abs()));
|
||||
let mut candidates = Vec::new();
|
||||
self.index.query(p, [self.max_x.max(p[0]), p[1]], &mut candidates);
|
||||
let mut inside = false;
|
||||
for (n, id) in candidates.into_iter().enumerate() {
|
||||
if n % 4096 == 4095 {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("dimensional verification cancelled");
|
||||
}
|
||||
}
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = &self.edges[id];
|
||||
inside ^= crossing(e.a, e.b, p);
|
||||
}
|
||||
let radius = reach + half + guard;
|
||||
let mut candidates = Vec::new();
|
||||
self.index.query(p.map(|v| v - radius), p.map(|v| v + radius), &mut candidates);
|
||||
let mut distance = radius;
|
||||
for (n, id) in candidates.into_iter().enumerate() {
|
||||
if n % 4096 == 4095 {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("dimensional verification cancelled");
|
||||
}
|
||||
}
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = &self.edges[id];
|
||||
let dx = e.b[0] - e.a[0];
|
||||
let dy = e.b[1] - e.a[1];
|
||||
let t = (((p[0] - e.a[0]) * dx + (p[1] - e.a[1]) * dy) / (dx * dx + dy * dy)).clamp(0., 1.);
|
||||
distance = distance.min((p[0] - e.a[0] - t * dx).hypot(p[1] - e.a[1] - t * dy));
|
||||
}
|
||||
let lo = (distance - half - guard).max(0.);
|
||||
let hi = if distance == radius { f64::INFINITY } else { distance + half + guard };
|
||||
if distance <= half + guard {
|
||||
Ok(Some([-hi, hi]))
|
||||
} else if inside {
|
||||
Ok(Some([lo, hi]))
|
||||
} else {
|
||||
Ok(Some([-hi, -lo]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
fn square(a: f64, b: f64) -> Vec<[f64; 2]> {
|
||||
vec![[a, a], [b, a], [b, b], [a, b]]
|
||||
}
|
||||
fn range(r: &Region, p: [f64; 2], half: f64, budget: usize) -> Option<[f64; 2]> {
|
||||
let mut ready = || std::future::ready(());
|
||||
let mut work = 0;
|
||||
let mut f = std::pin::pin!(r.range((p, half, 10.), &mut work, budget, &|| false, &mut ready));
|
||||
match f.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
|
||||
Poll::Ready(result) => result.unwrap(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn signed_distances_cover_concavity_islands_and_winding() {
|
||||
let mut boundary = Boundary { outer: square(0., 10.), islands: vec![square(4., 6.)] };
|
||||
for _ in 0..2 {
|
||||
let r = Region::new(&boundary).unwrap();
|
||||
for (p, expected) in [([3., 5.], 1.), ([5., 5.], -1.), ([-1., 5.], -1.)] {
|
||||
let [a, b] = range(&r, p, 0.1, 1000).unwrap();
|
||||
assert!((a - (expected - 0.1)).abs() < 1e-10 && (b - (expected + 0.1)).abs() < 1e-10, "{a} {b}");
|
||||
}
|
||||
boundary.outer.reverse();
|
||||
boundary.islands[0].reverse();
|
||||
}
|
||||
let r = Region::new(&Boundary { outer: vec![[0., 0.], [4., 0.], [4., 1.], [1., 1.], [1., 4.], [0., 4.]], islands: vec![] }).unwrap();
|
||||
assert!((range(&r, [2., 2.], 0., 1000).unwrap()[0] + 1.).abs() < 1e-10);
|
||||
assert!((range(&r, [0.5, 3.], 0., 1000).unwrap()[0] - 0.5).abs() < 1e-10);
|
||||
assert!(range(&r, [0.5, 3.], 0., 0).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn invalid_regions_are_rejected_without_generator_validation() {
|
||||
for b in [
|
||||
Boundary { outer: vec![[0., 0.], [2., 2.], [0., 2.], [2., 0.]], islands: vec![] },
|
||||
Boundary { outer: square(0., 10.), islands: vec![square(0., 2.)] },
|
||||
Boundary { outer: square(0., 10.), islands: vec![square(2., 8.), square(3., 4.)] },
|
||||
Boundary { outer: vec![[0., 0.], [2., 0.], [1., 0.], [2., 2.], [0., 2.]], islands: vec![] },
|
||||
] {
|
||||
assert!(Region::new(&b).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn large_boundary_queries_yield_and_cancel() {
|
||||
let b = Boundary {
|
||||
outer: (0..10_000)
|
||||
.map(|i| {
|
||||
let a = (i as f64 + 0.1) * std::f64::consts::TAU / 10_000.;
|
||||
[a.cos() * 10., a.sin() * 10.]
|
||||
})
|
||||
.collect(),
|
||||
islands: vec![],
|
||||
};
|
||||
let r = Region::new(&b).unwrap();
|
||||
let mut work = 0;
|
||||
let cancel = std::cell::Cell::new(false);
|
||||
let cancelled = || cancel.get();
|
||||
let mut yielding = || {
|
||||
let mut first = true;
|
||||
std::future::poll_fn(move |_| {
|
||||
if first {
|
||||
first = false;
|
||||
Poll::Pending
|
||||
} else {
|
||||
Poll::Ready(())
|
||||
}
|
||||
})
|
||||
};
|
||||
let mut f = std::pin::pin!(r.range(([0., 0.], 20., 0.), &mut work, 100_000, &cancelled, &mut yielding));
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
assert!(f.as_mut().poll(&mut cx).is_pending());
|
||||
cancel.set(true);
|
||||
assert!(matches!(f.as_mut().poll(&mut cx), Poll::Ready(Err(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Independent median-split XY index: artifact comparison never calls CAM offsets.
|
||||
pub(crate) struct Index {
|
||||
min: [f64; 2],
|
||||
max: [f64; 2],
|
||||
items: Vec<(usize, [f64; 2], [f64; 2])>,
|
||||
children: Vec<Index>,
|
||||
}
|
||||
impl Index {
|
||||
pub(crate) fn new(mut items: Vec<(usize, [f64; 2], [f64; 2])>) -> Self {
|
||||
let mut min = [f64::INFINITY; 2];
|
||||
let mut max = [f64::NEG_INFINITY; 2];
|
||||
for (_, a, b) in &items {
|
||||
for k in 0..2 {
|
||||
min[k] = min[k].min(a[k]);
|
||||
max[k] = max[k].max(b[k]);
|
||||
}
|
||||
}
|
||||
if items.len() <= 8 {
|
||||
return Self { min, max, items, children: vec![] };
|
||||
}
|
||||
let axis = usize::from(max[1] - min[1] > max[0] - min[0]);
|
||||
items.sort_unstable_by(|a, b| (a.1[axis] + a.2[axis]).total_cmp(&(b.1[axis] + b.2[axis])));
|
||||
let rest = items.split_off(items.len() / 2);
|
||||
Self { min, max, items: vec![], children: vec![Self::new(items), Self::new(rest)] }
|
||||
}
|
||||
pub(crate) fn query(&self, min: [f64; 2], max: [f64; 2], out: &mut Vec<usize>) {
|
||||
if (0..2).any(|k| self.min[k] > max[k] || self.max[k] < min[k]) {
|
||||
return;
|
||||
}
|
||||
out.extend(self.items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| id));
|
||||
for child in &self.children {
|
||||
child.query(min, max, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn indexed_candidates_include_every_brute_force_overlap() {
|
||||
let items: Vec<_> = (0..1024)
|
||||
.map(|i| {
|
||||
let x = (i % 32) as f64 - 16.;
|
||||
let y = (i / 32) as f64 - 16.;
|
||||
(i, [x, y], [x + 0.5, y + 0.75])
|
||||
})
|
||||
.collect();
|
||||
let index = Index::new(items.clone());
|
||||
for (min, max) in [([-0.5, -2.], [3.5, 4.]), ([-16., -16.], [-16., -16.]), ([16., 16.], [17., 17.]), ([-4.75, -7.5], [-4.5, -7.25])] {
|
||||
let mut actual = Vec::new();
|
||||
index.query(min, max, &mut actual);
|
||||
for (id, a, b) in &items {
|
||||
if (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k]) {
|
||||
assert!(actual.contains(id));
|
||||
}
|
||||
}
|
||||
let expected: Vec<_> = items.iter().filter(|(_, a, b)| (0..2).all(|k| a[k] <= max[k] && b[k] >= min[k])).map(|(id, _, _)| *id).collect();
|
||||
actual.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
let unique: std::collections::BTreeSet<_> = actual.iter().collect();
|
||||
assert_eq!(unique.len(), actual.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Conservative boundary extraction from non-overlapping rectangular height cells.
|
||||
//! Shared planar edges and vertical steps are split before triangulation. No
|
||||
//! XY snapping, interpolation or generation algorithm is involved. Tiny adaptive
|
||||
//! saddle contacts may be filled upward within a declared export error budget.
|
||||
use crate::{
|
||||
Error, Result, invalid,
|
||||
verify::{Cell, Report},
|
||||
};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
future::Future,
|
||||
};
|
||||
|
||||
/// Count elementary topology work, including sorting and manifold validation.
|
||||
/// A yield callback which is immediately ready retains the synchronous API.
|
||||
struct Work<C, Y> {
|
||||
cancelled: C,
|
||||
yield_control: Y,
|
||||
remaining: usize,
|
||||
}
|
||||
impl<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>> Work<C, Y> {
|
||||
async fn tick(&mut self) -> Result<()> {
|
||||
if self.remaining == 0 {
|
||||
if (self.cancelled)() {
|
||||
return invalid("stock extraction cancelled");
|
||||
}
|
||||
(self.yield_control)().await;
|
||||
if (self.cancelled)() {
|
||||
return invalid("stock extraction cancelled");
|
||||
}
|
||||
self.remaining = 256;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Ok(())
|
||||
}
|
||||
// Sort small runs, then merge with checkpoints. A single standard-library
|
||||
// sort of up to two million cell events otherwise monopolizes a browser poll.
|
||||
async fn sort<T: Clone>(&mut self, values: &mut Vec<T>, compare: impl Fn(&T, &T) -> std::cmp::Ordering) -> Result<()> {
|
||||
for run in values.chunks_mut(256) {
|
||||
if run.len() == 256 {
|
||||
self.remaining = 0;
|
||||
}
|
||||
self.tick().await?;
|
||||
run.sort_unstable_by(&compare);
|
||||
}
|
||||
if values.len() <= 256 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut scratch = Vec::with_capacity(values.len());
|
||||
let mut width = 256;
|
||||
while width < values.len() {
|
||||
for begin in (0..values.len()).step_by(width * 2) {
|
||||
let middle = (begin + width).min(values.len());
|
||||
let end = (middle + width).min(values.len());
|
||||
let (mut a, mut b) = (begin, middle);
|
||||
while a < middle || b < end {
|
||||
self.tick().await?;
|
||||
let take_a = b == end || (a < middle && compare(&values[a], &values[b]).is_le());
|
||||
let index = if take_a {
|
||||
let i = a;
|
||||
a += 1;
|
||||
i
|
||||
} else {
|
||||
let i = b;
|
||||
b += 1;
|
||||
i
|
||||
};
|
||||
scratch.push(values[index].clone());
|
||||
}
|
||||
}
|
||||
std::mem::swap(values, &mut scratch);
|
||||
scratch.clear();
|
||||
width *= 2;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn ready<T>(future: impl Future<Output = Result<T>>) -> Result<T> {
|
||||
let mut future = std::pin::pin!(future);
|
||||
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
|
||||
std::task::Poll::Ready(result) => result,
|
||||
std::task::Poll::Pending => invalid("synchronous stock extraction unexpectedly yielded"),
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CELLS: usize = 60_000;
|
||||
const MAX_TRIANGLES: usize = 500_000;
|
||||
// One percent of the post budget, reserved only for conservative saddle closure.
|
||||
// This is an artifact approximation; it never changes the verification report.
|
||||
const MAX_SADDLE_FILL: f64 = crate::STOCK_EXPORT_ERROR;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Coord(f64);
|
||||
impl Eq for Coord {}
|
||||
impl PartialOrd for Coord {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl Ord for Coord {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
if self.0 == other.0 { std::cmp::Ordering::Equal } else { self.0.total_cmp(&other.0) }
|
||||
}
|
||||
}
|
||||
type Point = [Coord; 3];
|
||||
type XY = [Coord; 2];
|
||||
fn point(p: Vec3) -> Point {
|
||||
[Coord(p.x), Coord(p.y), Coord(p.z)]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Event {
|
||||
at: Coord,
|
||||
cell: usize,
|
||||
side: usize,
|
||||
start: bool,
|
||||
}
|
||||
struct Span {
|
||||
axis: usize,
|
||||
fixed: f64,
|
||||
a: f64,
|
||||
b: f64,
|
||||
cells: [Option<usize>; 2],
|
||||
}
|
||||
impl Span {
|
||||
fn xy(&self, t: f64) -> XY {
|
||||
if self.axis == 0 { [Coord(self.fixed), Coord(t)] } else { [Coord(t), Coord(self.fixed)] }
|
||||
}
|
||||
fn vertex(&self, t: f64, z: f64) -> Vec3 {
|
||||
let xy = self.xy(t);
|
||||
Vec3::new(xy[0].0, xy[1].0, z)
|
||||
}
|
||||
}
|
||||
|
||||
async fn cells<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(report: &Report, bottom: f64, work: &mut Work<C, Y>) -> Result<Vec<Cell>> {
|
||||
work.tick().await?;
|
||||
if !bottom.is_finite() || report.cells.is_empty() || report.cells.len() > 1_000_000 {
|
||||
return invalid("invalid stock export bounds");
|
||||
}
|
||||
if !report.stock_complete || report.findings.iter().any(|f| f.kind == "budget_exhausted") {
|
||||
return invalid("stock verification is incomplete; cannot export remaining stock");
|
||||
}
|
||||
for c in &report.cells {
|
||||
work.tick().await?;
|
||||
for p in [Vec3::new(c.min[0], c.min[1], bottom), Vec3::new(c.max[0], c.max[1], c.high)] {
|
||||
crate::model::point(p)?;
|
||||
}
|
||||
if c.min[0] >= c.max[0] || c.min[1] >= c.max[1] || !c.low.is_finite() || c.low > c.high || c.high < bottom {
|
||||
return invalid("invalid stock cell");
|
||||
}
|
||||
}
|
||||
// Reject overlap in O(n log n). End events precede starts at the same X.
|
||||
let mut events = Vec::with_capacity(report.cells.len() * 2);
|
||||
for (i, c) in report.cells.iter().enumerate() {
|
||||
work.tick().await?;
|
||||
events.push((Coord(c.min[0]), true, i));
|
||||
events.push((Coord(c.max[0]), false, i));
|
||||
}
|
||||
work.sort(&mut events, Ord::cmp).await?;
|
||||
let mut active = BTreeMap::<Coord, f64>::new();
|
||||
for (_, start, i) in events {
|
||||
work.tick().await?;
|
||||
let c = &report.cells[i];
|
||||
let y = Coord(c.min[1]);
|
||||
if start {
|
||||
if active.range(..=y).next_back().is_some_and(|(_, end)| *end > c.min[1])
|
||||
|| active.range(y..).next().is_some_and(|(start, _)| start.0 < c.max[1])
|
||||
{
|
||||
return invalid("overlapping stock cells");
|
||||
}
|
||||
active.insert(y, c.max[1]);
|
||||
} else {
|
||||
active.remove(&y);
|
||||
}
|
||||
}
|
||||
// Exact coplanar rectangle merging reduces output without changing height.
|
||||
let mut cells = Vec::new();
|
||||
for c in &report.cells {
|
||||
work.tick().await?;
|
||||
if c.high > bottom {
|
||||
cells.push(c.clone());
|
||||
}
|
||||
}
|
||||
for _ in 0..24 {
|
||||
let before = cells.len();
|
||||
for axis in 0..2 {
|
||||
let other = 1 - axis;
|
||||
work.sort(&mut cells, |a, b| {
|
||||
a.min[other]
|
||||
.total_cmp(&b.min[other])
|
||||
.then(a.max[other].total_cmp(&b.max[other]))
|
||||
.then(a.high.total_cmp(&b.high))
|
||||
.then(a.min[axis].total_cmp(&b.min[axis]))
|
||||
})
|
||||
.await?;
|
||||
let mut merged: Vec<Cell> = Vec::new();
|
||||
for c in cells {
|
||||
work.tick().await?;
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& last.min[other] == c.min[other]
|
||||
&& last.max[other] == c.max[other]
|
||||
&& last.high == c.high
|
||||
&& last.max[axis] == c.min[axis]
|
||||
{
|
||||
last.max[axis] = c.max[axis];
|
||||
} else {
|
||||
merged.push(c);
|
||||
}
|
||||
}
|
||||
cells = merged;
|
||||
}
|
||||
if cells.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cells.len() > MAX_CELLS {
|
||||
return invalid(format!("stock mesh has {} merged cells, exceeding 60000; use validation JSON for this resolution", cells.len()));
|
||||
}
|
||||
Ok(cells)
|
||||
}
|
||||
|
||||
async fn spans<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(cells: &[Cell], work: &mut Work<C, Y>) -> Result<Vec<Span>> {
|
||||
let mut lines: BTreeMap<(usize, Coord), Vec<Event>> = BTreeMap::new();
|
||||
for (i, c) in cells.iter().enumerate() {
|
||||
work.tick().await?;
|
||||
for axis in 0..2 {
|
||||
for (fixed, side) in [(c.min[axis], 1), (c.max[axis], 0)] {
|
||||
let events = lines.entry((axis, Coord(fixed))).or_default();
|
||||
events.push(Event { at: Coord(c.min[1 - axis]), cell: i, side, start: true });
|
||||
events.push(Event { at: Coord(c.max[1 - axis]), cell: i, side, start: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut spans = Vec::new();
|
||||
for ((axis, fixed), mut events) in lines {
|
||||
work.sort(&mut events, |a, b| (a.at, a.start).cmp(&(b.at, b.start))).await?;
|
||||
let mut active = [None; 2];
|
||||
let mut previous: Option<Coord> = None;
|
||||
for event in events {
|
||||
work.tick().await?;
|
||||
if let Some(a) = previous
|
||||
&& a < event.at
|
||||
&& active != [None; 2]
|
||||
{
|
||||
spans.push(Span { axis, fixed: fixed.0, a: a.0, b: event.at.0, cells: active });
|
||||
}
|
||||
previous = Some(event.at);
|
||||
if event.start {
|
||||
if active[event.side].is_some() {
|
||||
return invalid("overlapping stock boundaries");
|
||||
}
|
||||
active[event.side] = Some(event.cell);
|
||||
} else {
|
||||
active[event.side] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(spans)
|
||||
}
|
||||
|
||||
async fn close_tiny_saddles<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(
|
||||
cells: &mut [Cell],
|
||||
spans: &[Span],
|
||||
bottom: f64,
|
||||
work: &mut Work<C, Y>,
|
||||
) -> Result<()> {
|
||||
// NW, NE, SE, SW occupancy at every boundary endpoint, including T-junctions.
|
||||
let mut vertices = BTreeMap::<XY, [Option<usize>; 4]>::new();
|
||||
for s in spans {
|
||||
for (side, id) in s.cells.iter().enumerate() {
|
||||
work.tick().await?;
|
||||
let Some(id) = id else { continue };
|
||||
let (a, b) = match (s.axis, side) {
|
||||
(0, 0) => (0, 3),
|
||||
(0, 1) => (1, 2),
|
||||
(1, 0) => (2, 3),
|
||||
_ => (1, 0),
|
||||
};
|
||||
vertices.entry(s.xy(s.a)).or_insert([None; 4])[a] = Some(*id);
|
||||
vertices.entry(s.xy(s.b)).or_insert([None; 4])[b] = Some(*id);
|
||||
}
|
||||
}
|
||||
let mut ceilings = Vec::with_capacity(cells.len());
|
||||
for c in cells.iter() {
|
||||
work.tick().await?;
|
||||
ceilings.push(c.high + MAX_SADDLE_FILL);
|
||||
}
|
||||
// Monotone bounded closure. Stop rather than silently exceeding either the
|
||||
// error or work bound; the final manifold validator rejects unresolved cases.
|
||||
for _ in 0..8 {
|
||||
let mut changed = false;
|
||||
for ids in vertices.values() {
|
||||
work.tick().await?;
|
||||
let heights = ids.map(|id| id.map_or(bottom, |i| cells[i].high));
|
||||
for (upper, lower) in [([0, 2], [1, 3]), ([1, 3], [0, 2])] {
|
||||
let threshold = heights[upper[0]].min(heights[upper[1]]);
|
||||
let pick = if heights[lower[0]] >= heights[lower[1]] { lower[0] } else { lower[1] };
|
||||
if threshold > heights[pick]
|
||||
&& let Some(id) = ids[pick]
|
||||
&& threshold <= ceilings[id]
|
||||
{
|
||||
cells[id].high = threshold;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for _ in vertices {
|
||||
work.tick().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_triangles(triangles: &[[Vec3; 3]]) -> Result<()> {
|
||||
ready(validate(triangles, &mut Work { cancelled: || false, yield_control: || std::future::ready(()), remaining: 0 }))
|
||||
}
|
||||
async fn validate<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(triangles: &[[Vec3; 3]], work: &mut Work<C, Y>) -> Result<()> {
|
||||
if triangles.len() > MAX_TRIANGLES {
|
||||
return invalid("stock triangle budget exceeded");
|
||||
}
|
||||
for t in triangles {
|
||||
work.tick().await?;
|
||||
for p in t {
|
||||
crate::model::point(*p)?;
|
||||
}
|
||||
if (t[1] - t[0]).cross(t[2] - t[0]).len2() == 0. {
|
||||
return invalid("degenerate stock triangle");
|
||||
}
|
||||
}
|
||||
|
||||
// Edge incidence plus connected vertex links reject cracks, T-junctions,
|
||||
// touching components and non-manifold saddle edges explicitly.
|
||||
let mut edges = BTreeMap::<(Point, Point), (usize, i32)>::new();
|
||||
let mut links = BTreeMap::<Point, Vec<(Point, Point)>>::new();
|
||||
for t in triangles {
|
||||
work.tick().await?;
|
||||
let p = t.map(point);
|
||||
for (a, b, c) in [(p[0], p[1], p[2]), (p[1], p[2], p[0]), (p[2], p[0], p[1])] {
|
||||
let (key, sign) = if a < b { ((a, b), 1) } else { ((b, a), -1) };
|
||||
let edge = edges.entry(key).or_default();
|
||||
edge.0 += 1;
|
||||
edge.1 += sign;
|
||||
if edge.0 > 2 {
|
||||
return invalid(format!("stock boundary is non-manifold at {key:?}; use validation JSON"));
|
||||
}
|
||||
links.entry(a).or_default().push((b, c));
|
||||
}
|
||||
}
|
||||
for (_, edge) in edges {
|
||||
work.tick().await?;
|
||||
if edge != (2, 0) {
|
||||
return invalid("stock boundary has an unstitched or misoriented edge");
|
||||
}
|
||||
}
|
||||
// Consume the maps incrementally too: dropping a large tree all at once
|
||||
// can be as visible as constructing it on the browser's event loop.
|
||||
for (_, link) in links {
|
||||
let mut next = BTreeMap::new();
|
||||
for (a, b) in &link {
|
||||
work.tick().await?;
|
||||
if next.insert(*a, *b).is_some() {
|
||||
return invalid("non-manifold stock vertex");
|
||||
}
|
||||
}
|
||||
let Some((&start, _)) = next.first_key_value() else {
|
||||
continue;
|
||||
};
|
||||
let mut at = start;
|
||||
for _ in 0..link.len() {
|
||||
work.tick().await?;
|
||||
let Some(b) = next.remove(&at) else {
|
||||
return invalid("disconnected stock vertex surface; use validation JSON");
|
||||
};
|
||||
at = b;
|
||||
}
|
||||
if at != start || !next.is_empty() {
|
||||
return invalid("open stock vertex surface");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Mesh {
|
||||
triangles: Vec<[Vec3; 3]>,
|
||||
}
|
||||
impl Mesh {
|
||||
fn triangle(&mut self, a: Vec3, b: Vec3, c: Vec3) -> Result<()> {
|
||||
if self.triangles.len() >= MAX_TRIANGLES {
|
||||
return invalid("stock mesh exceeds 500000 triangles");
|
||||
}
|
||||
if (b - a).cross(c - a).len2() == 0.0 {
|
||||
return invalid("degenerate stock boundary triangle");
|
||||
}
|
||||
self.triangles.push([a, b, c]);
|
||||
Ok(())
|
||||
}
|
||||
async fn convex<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(
|
||||
&mut self,
|
||||
boundary: &[Vec3],
|
||||
reverse: bool,
|
||||
work: &mut Work<C, Y>,
|
||||
) -> Result<()> {
|
||||
// All faces are rectangles with collinear boundary subdivisions. Clip
|
||||
// the smallest nonzero corner first, preserving every boundary vertex.
|
||||
// An ordered corner set bounds this to O(n log n), including long edges.
|
||||
let n = boundary.len();
|
||||
if !(3..=MAX_TRIANGLES).contains(&n) {
|
||||
return invalid("invalid stock polygon size");
|
||||
}
|
||||
let mut prev: Vec<_> = (0..n).map(|i| (i + n - 1) % n).collect();
|
||||
let mut next: Vec<_> = (0..n).map(|i| (i + 1) % n).collect();
|
||||
let area = |a: usize, b: usize, c: usize| Coord((boundary[b] - boundary[a]).cross(boundary[c] - boundary[b]).len2());
|
||||
let mut corners = BTreeSet::new();
|
||||
for i in 0..n {
|
||||
work.tick().await?;
|
||||
let a = area(prev[i], i, next[i]);
|
||||
if a.0 > 0.0 {
|
||||
corners.insert((a, i));
|
||||
}
|
||||
}
|
||||
for _ in 0..n - 2 {
|
||||
work.tick().await?;
|
||||
let (_, i) = corners.pop_first().ok_or_else(|| Error("degenerate stock polygon".into()))?;
|
||||
let a = prev[i];
|
||||
let b = next[i];
|
||||
if reverse {
|
||||
self.triangle(boundary[a], boundary[b], boundary[i])?;
|
||||
} else {
|
||||
self.triangle(boundary[a], boundary[i], boundary[b])?;
|
||||
}
|
||||
corners.remove(&(area(prev[a], a, next[a]), a));
|
||||
corners.remove(&(area(prev[b], b, next[b]), b));
|
||||
next[a] = b;
|
||||
prev[b] = a;
|
||||
for k in [a, b] {
|
||||
let v = area(prev[k], k, next[k]);
|
||||
if v.0 > 0.0 {
|
||||
corners.insert((v, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn boundary<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>>(report: &Report, bottom: f64, work: &mut Work<C, Y>) -> Result<Mesh> {
|
||||
let mut cells = cells(report, bottom, work).await?;
|
||||
let spans = spans(&cells, work).await?;
|
||||
close_tiny_saddles(&mut cells, &spans, bottom, work).await?;
|
||||
let mut levels = BTreeMap::<XY, BTreeSet<Coord>>::new();
|
||||
for s in &spans {
|
||||
work.tick().await?;
|
||||
for xy in [s.xy(s.a), s.xy(s.b)] {
|
||||
let z = levels.entry(xy).or_default();
|
||||
z.insert(Coord(bottom));
|
||||
for id in s.cells.into_iter().flatten() {
|
||||
z.insert(Coord(cells[id].high));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut mesh = Mesh { triangles: Vec::new() };
|
||||
let mut tops: Vec<Vec<(usize, Coord, Vec3)>> = vec![Vec::new(); cells.len()];
|
||||
for s in &spans {
|
||||
work.tick().await?;
|
||||
for (side, id) in s.cells.iter().enumerate() {
|
||||
let Some(id) = id else {
|
||||
continue;
|
||||
};
|
||||
let c = &cells[*id];
|
||||
let edge = match (s.axis, side) {
|
||||
(1, 1) => 0,
|
||||
(0, 0) => 1,
|
||||
(1, 0) => 2,
|
||||
_ => 3,
|
||||
};
|
||||
let a = if edge < 2 { s.a } else { s.b };
|
||||
tops[*id].push((edge, Coord(if edge < 2 { a } else { -a }), s.vertex(a, c.high)));
|
||||
}
|
||||
let heights = s.cells.map(|id| id.map_or(bottom, |id| cells[id].high));
|
||||
if heights[0] == heights[1] {
|
||||
continue;
|
||||
}
|
||||
let lo = heights[0].min(heights[1]);
|
||||
let hi = heights[0].max(heights[1]);
|
||||
let mut polygon = vec![s.vertex(s.a, lo), s.vertex(s.b, lo)];
|
||||
let right = levels.get(&s.xy(s.b)).ok_or_else(|| Error("missing stock edge levels".into()))?;
|
||||
for z in right.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))) {
|
||||
work.tick().await?;
|
||||
polygon.push(s.vertex(s.b, z.0));
|
||||
}
|
||||
polygon.push(s.vertex(s.b, hi));
|
||||
polygon.push(s.vertex(s.a, hi));
|
||||
let left = levels.get(&s.xy(s.a)).ok_or_else(|| Error("missing stock edge levels".into()))?;
|
||||
for z in left.range((std::ops::Bound::Excluded(Coord(lo)), std::ops::Bound::Excluded(Coord(hi)))).rev() {
|
||||
work.tick().await?;
|
||||
polygon.push(s.vertex(s.a, z.0));
|
||||
}
|
||||
mesh.convex(&polygon, (s.axis == 0) != (heights[0] > heights[1]), work).await?;
|
||||
}
|
||||
for mut top in tops {
|
||||
work.sort(&mut top, |a, b| (a.0, a.1).cmp(&(b.0, b.1))).await?;
|
||||
let mut polygon: Vec<_> = top.into_iter().map(|p| p.2).collect();
|
||||
mesh.convex(&polygon, false, work).await?;
|
||||
for p in &mut polygon {
|
||||
p.z = bottom;
|
||||
}
|
||||
mesh.convex(&polygon, true, work).await?;
|
||||
}
|
||||
for _ in levels {
|
||||
work.tick().await?;
|
||||
}
|
||||
validate(&mesh.triangles, work).await?;
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
pub(crate) fn triangles(report: &Report, bottom: f64) -> Result<Vec<[Vec3; 3]>> {
|
||||
ready(triangles_cooperative(report, bottom, || false, || std::future::ready(())))
|
||||
}
|
||||
pub(crate) async fn triangles_cooperative<F: Future<Output = ()>>(
|
||||
report: &Report,
|
||||
bottom: f64,
|
||||
cancelled: impl Fn() -> bool,
|
||||
yield_control: impl FnMut() -> F,
|
||||
) -> Result<Vec<[Vec3; 3]>> {
|
||||
let mut work = Work { cancelled, yield_control, remaining: 0 };
|
||||
Ok(boundary(report, bottom, &mut work).await?.triangles)
|
||||
}
|
||||
|
||||
pub(crate) fn stl(report: &Report, bottom: f64) -> Result<String> {
|
||||
ready(stl_cooperative(report, bottom, || false, || std::future::ready(())))
|
||||
}
|
||||
pub(crate) async fn stl_cooperative<F: Future<Output = ()>>(
|
||||
report: &Report,
|
||||
bottom: f64,
|
||||
cancelled: impl Fn() -> bool,
|
||||
yield_control: impl FnMut() -> F,
|
||||
) -> Result<String> {
|
||||
let mut work = Work { cancelled, yield_control, remaining: 0 };
|
||||
let mesh = boundary(report, bottom, &mut work).await?.triangles;
|
||||
let mut out = String::from("solid cam_remaining_stock\n");
|
||||
for triangle in mesh {
|
||||
work.tick().await?;
|
||||
out.push_str("facet normal 0 0 0\nouter loop\n");
|
||||
for p in triangle {
|
||||
out.push_str(&format!("vertex {} {} {}\n", p.x, p.y, p.z));
|
||||
}
|
||||
out.push_str("endloop\nendfacet\n");
|
||||
if out.len() > 128 * 1024 * 1024 {
|
||||
return invalid("stock mesh exceeds 128 MiB");
|
||||
}
|
||||
}
|
||||
out.push_str("endsolid cam_remaining_stock\n");
|
||||
work.remaining = 0;
|
||||
work.tick().await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::verify::Status;
|
||||
fn cell(min: [f64; 2], max: [f64; 2], high: f64) -> Cell {
|
||||
Cell { min, max, low: high, high, target_low: high, target_high: high }
|
||||
}
|
||||
fn report(cells: Vec<Cell>) -> Report {
|
||||
Report {
|
||||
version: crate::VERSION.into(),
|
||||
fingerprint: "analytic".into(),
|
||||
status: Status::Pass,
|
||||
finished_dimensions: Status::Pass,
|
||||
post_version: "analytic".into(),
|
||||
tolerance: 0.01,
|
||||
uncertainty: 0.0025,
|
||||
maximum_height_interval: 0.0,
|
||||
stock_complete: true,
|
||||
stock: None,
|
||||
cells,
|
||||
findings: vec![],
|
||||
cutting_distance: 0.0,
|
||||
rapid_distance: 0.0,
|
||||
cutting_minutes: 0.0,
|
||||
remaining_volume_low: 0.0,
|
||||
remaining_volume_high: 0.0,
|
||||
work: 0,
|
||||
}
|
||||
}
|
||||
fn check(cells: Vec<Cell>, volume: f64, area: f64) -> Vec<[Vec3; 3]> {
|
||||
let text = stl(&report(cells), 0.0).unwrap();
|
||||
let mesh = crate::compare::read_stl(text.as_bytes()).unwrap();
|
||||
let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum();
|
||||
let a: f64 = mesh.iter().map(|t| (t[1] - t[0]).cross(t[2] - t[0]).len() / 2.0).sum();
|
||||
assert!((v - volume).abs() < 1e-9, "volume {v} expected {volume}");
|
||||
assert!((a - area).abs() < 1e-9, "area {a} expected {area}");
|
||||
mesh
|
||||
}
|
||||
#[test]
|
||||
fn box_and_step_have_only_exterior_faces() {
|
||||
check(vec![cell([0., 0.], [1., 1.], 1.)], 1., 6.);
|
||||
let mesh = check(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 0.], [2., 1.], 2.)], 3., 14.);
|
||||
for t in mesh {
|
||||
if t.iter().all(|p| p.x == 1.) {
|
||||
assert!(t.iter().all(|p| p.z >= 1.));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn adaptive_t_junctions_and_three_height_vertices_are_stitched() {
|
||||
check(vec![cell([0., 0.], [1., 2.], 1.), cell([1., 0.], [2., 1.], 2.), cell([1., 1.], [2., 2.], 3.)], 7., 26.);
|
||||
}
|
||||
#[test]
|
||||
fn hole_and_disconnected_stock_are_watertight() {
|
||||
let mut cells = Vec::new();
|
||||
for x in 0..3 {
|
||||
for y in 0..3 {
|
||||
cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], if x == 1 && y == 1 { 0. } else { 1. }));
|
||||
}
|
||||
}
|
||||
check(cells, 8., 32.);
|
||||
check(vec![cell([0., 0.], [1., 1.], 1.), cell([2., 0.], [3., 1.], 1.)], 2., 12.);
|
||||
}
|
||||
#[test]
|
||||
fn nonmanifold_touching_stock_is_explicitly_rejected() {
|
||||
assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 1.), cell([1., 1.], [2., 2.], 1.)]), 0.).is_err());
|
||||
assert!(
|
||||
stl(
|
||||
&report(vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 1.), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)]),
|
||||
0.
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tiny_adaptive_saddle_closure_is_conservative_and_bounded() {
|
||||
let cells =
|
||||
vec![cell([0., 0.], [1., 1.], 2.), cell([1., 0.], [2., 1.], 2. - 4e-6), cell([0., 1.], [1., 2.], 1.), cell([1., 1.], [2., 2.], 2.)];
|
||||
let mut original = report(cells);
|
||||
original.stock = Some(crate::Stock { min: Vec3::new(0., 0., 0.), max: Vec3::new(2., 2., 2.) });
|
||||
let mesh = triangles(&original, 0.).unwrap();
|
||||
let volume: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.).sum();
|
||||
assert!((volume - 7.).abs() < 1e-12);
|
||||
assert_eq!(original.cells[1].high, 2. - 4e-6);
|
||||
assert_eq!(crate::compare::stock_mesh(&original, &mesh, 0., crate::TOLERANCE).unwrap().status, Status::Pass);
|
||||
let mut too_large = original;
|
||||
too_large.cells[1].high = 2. - 2. * MAX_SADDLE_FILL;
|
||||
assert!(triangles(&too_large, 0.).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn hostile_and_overlapping_cells_are_rejected() {
|
||||
for bad in [cell([0., 0.], [0., 1.], 1.), cell([0., 0.], [1., 1.], f64::NAN), cell([0., 0.], [1., 1.], -1.)] {
|
||||
assert!(stl(&report(vec![bad]), 0.).is_err());
|
||||
}
|
||||
assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([1., 1.], [3., 3.], 2.)]), 0.).is_err());
|
||||
assert!(stl(&report(vec![cell([0., 0.], [2., 2.], 1.), cell([0., 0.], [2., 2.], 1.)]), 0.).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn zero_stock_and_translated_base_are_preserved() {
|
||||
assert!(stl(&report(vec![cell([0., 0.], [1., 1.], 0.)]), 0.).unwrap().lines().count() == 2);
|
||||
let mesh = triangles(&report(vec![cell([-3., 2.], [-2., 3.], 7.)]), 5.).unwrap();
|
||||
let v: f64 = mesh.iter().map(|t| t[0].dot(t[1].cross(t[2])) / 6.0).sum();
|
||||
assert!((v - 2.).abs() < 1e-9);
|
||||
}
|
||||
#[test]
|
||||
fn incomplete_verification_cannot_export_partial_stock() {
|
||||
let input = crate::corpus::fixture("face").unwrap();
|
||||
let path = crate::generate::generate(input.clone()).unwrap();
|
||||
let nc = crate::nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
for limits in [crate::verify::Limits { cells: 1, ..Default::default() }, crate::verify::Limits { work: 1, ..Default::default() }] {
|
||||
let report = crate::verify::simulate(&input, &nc, limits).unwrap();
|
||||
assert!(!report.stock_complete);
|
||||
assert!(stl(&report, 0.).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cooperative_mesh_matches_sync_and_cancels_during_topology_work() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let report = report(
|
||||
(0..32)
|
||||
.rev()
|
||||
.flat_map(|x| (0..32).rev().map(move |y| cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64)))
|
||||
.collect(),
|
||||
);
|
||||
let expected = triangles(&report, 0.).unwrap();
|
||||
let yield_once = || {
|
||||
let mut first = true;
|
||||
std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) })
|
||||
};
|
||||
let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || false, yield_once));
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
let mut polls = 0;
|
||||
loop {
|
||||
polls += 1;
|
||||
if let Poll::Ready(result) = future.as_mut().poll(&mut cx) {
|
||||
assert_eq!(result.unwrap(), expected);
|
||||
break;
|
||||
}
|
||||
assert!(polls < 100_000);
|
||||
}
|
||||
assert!(polls > 100);
|
||||
// Cancel in cell sorting, boundary work and late manifold validation.
|
||||
// Each suspended future must fail on its next poll without a partial mesh.
|
||||
for cancel_after in [1, polls / 2, polls - 1] {
|
||||
let cancelled = std::cell::Cell::new(false);
|
||||
let mut future = std::pin::pin!(triangles_cooperative(&report, 0., || cancelled.get(), yield_once));
|
||||
for _ in 0..cancel_after {
|
||||
assert!(future.as_mut().poll(&mut cx).is_pending());
|
||||
}
|
||||
cancelled.set(true);
|
||||
let result = future.as_mut().poll(&mut cx);
|
||||
assert!(matches!(result, Poll::Ready(Err(e)) if e.0.contains("cancelled")));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stepped_grid_preserves_volume_and_surface_area() {
|
||||
let n = 16;
|
||||
let mut cells = Vec::new();
|
||||
for x in 0..n {
|
||||
for y in 0..n {
|
||||
cells.push(cell([x as f64, y as f64], [(x + 1) as f64, (y + 1) as f64], (x + y + 1) as f64));
|
||||
}
|
||||
}
|
||||
check(cells, (n * n * n) as f64, (6 * n * n + 2 * n * (n - 1)) as f64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Local no-gouge certificates for the vertical height-field target solid.
|
||||
//!
|
||||
//! To bound distance to the solid's complement, choose an XY point q with a
|
||||
//! conservative upper target height H(q). Every point p in a candidate removed
|
||||
//! column has an exterior point (q, max(p.z,H(q))) at distance no more than
|
||||
//! hypot(max_xy_distance(p,q), max(H(q)-column_low,0)). This covers whole cells,
|
||||
//! not sampled surface points, and remains valid for overlapping target bodies.
|
||||
use crate::{
|
||||
spatial::Index,
|
||||
verify::{Limits, Status},
|
||||
*,
|
||||
};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
pub(crate) struct Surface<'a> {
|
||||
triangles: &'a [[Vec3; 3]],
|
||||
index: Index,
|
||||
bottom: f64,
|
||||
guard: f64,
|
||||
}
|
||||
pub(crate) struct Region {
|
||||
pub center: [f64; 2],
|
||||
pub half: f64,
|
||||
pub low: f64,
|
||||
pub high: f64,
|
||||
pub target_high: f64,
|
||||
pub tolerance: f64,
|
||||
}
|
||||
impl<'a> Surface<'a> {
|
||||
pub(crate) fn new(triangles: &'a [[Vec3; 3]], bottom: f64) -> Result<Self> {
|
||||
let extent = triangles.iter().flatten().map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs())).fold(1., f64::max);
|
||||
let guard = 128. * f64::EPSILON * extent;
|
||||
let items: Vec<_> = triangles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| (t[1] - t[0]).cross(t[2] - t[0]).z > 0.)
|
||||
.map(|(id, t)| {
|
||||
let min = t.iter().copied().fold(t[0], Vec3::min);
|
||||
let max = t.iter().copied().fold(t[0], Vec3::max);
|
||||
(id, [min.x - guard, min.y - guard], [max.x + guard, max.y + guard])
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return invalid("protected target has no upward height-field surface");
|
||||
}
|
||||
Ok(Self { triangles, index: Index::new(items), bottom, guard })
|
||||
}
|
||||
pub(crate) fn bounds(&self, p: [f64; 2], half: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) {
|
||||
let mut ids = Vec::new();
|
||||
self.index.query([p[0] - half, p[1] - half], [p[0] + half, p[1] + half], &mut ids);
|
||||
let local: Vec<_> = ids.into_iter().map(|i| self.triangles[i]).collect();
|
||||
crate::verify::target_height(p, half, &local, self.bottom, work, budget)
|
||||
}
|
||||
async fn height<F: std::future::Future<Output = ()>>(
|
||||
&self,
|
||||
p: [f64; 2],
|
||||
work: &mut usize,
|
||||
limits: Limits,
|
||||
cancelled: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<Option<f64>> {
|
||||
let mut ids = Vec::new();
|
||||
self.index.query([p[0] - self.guard, p[1] - self.guard], [p[0] + self.guard, p[1] + self.guard], &mut ids);
|
||||
let mut height = self.bottom;
|
||||
for (count, id) in ids.into_iter().enumerate() {
|
||||
if count % 4096 == 4095 {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("surface verification cancelled");
|
||||
}
|
||||
}
|
||||
*work += 1;
|
||||
if *work > limits.work {
|
||||
return Ok(None);
|
||||
}
|
||||
let t = &self.triangles[id];
|
||||
if let Some((_, upper)) = height_interval(t, p, self.guard) {
|
||||
height = height.max(upper);
|
||||
}
|
||||
}
|
||||
Ok(Some(height + self.guard))
|
||||
}
|
||||
pub(crate) async fn protect<F: std::future::Future<Output = ()>>(
|
||||
&self,
|
||||
r: Region,
|
||||
work: &mut usize,
|
||||
limits: Limits,
|
||||
cancelled: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<Status> {
|
||||
let tolerance = r.tolerance - POST_ERROR - VERIFY_ERROR - self.guard;
|
||||
if tolerance > 0. && r.target_high + self.guard - r.low <= tolerance {
|
||||
return Ok(Status::Pass);
|
||||
}
|
||||
if r.half < tolerance {
|
||||
// The search locates a certificate. Failure to find one never proves
|
||||
// a gouge and never becomes a clearance pass.
|
||||
let reach = tolerance - r.half;
|
||||
for scale in [0., 0.5, 0.95] {
|
||||
for direction in 0..8 {
|
||||
if scale == 0. && direction != 0 {
|
||||
continue;
|
||||
}
|
||||
let angle = direction as f64 * std::f64::consts::FRAC_PI_4;
|
||||
let offset = scale * reach;
|
||||
let q = [r.center[0] + offset * angle.cos(), r.center[1] + offset * angle.sin()];
|
||||
let Some(height) = self.height(q, work, limits, cancelled, yield_control).await? else {
|
||||
return Ok(Status::Inconclusive);
|
||||
};
|
||||
// Include coordinate rounding in the actual computed offset.
|
||||
let lateral = r.half + (q[0] - r.center[0]).hypot(q[1] - r.center[1]);
|
||||
if lateral.hypot((height - r.low).max(0.)) + self.guard <= tolerance {
|
||||
return Ok(Status::Pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A positive failure certificate: a ball larger than the tolerance is
|
||||
// contained in one target triangle's downward extrusion, while its center
|
||||
// is definitely removed. No coverage sums or assumed mesh watertightness.
|
||||
let radius = r.tolerance + POST_ERROR + VERIFY_ERROR + self.guard;
|
||||
let z = (r.high + self.guard).max(self.bottom + 2. * radius);
|
||||
if z - radius > self.bottom {
|
||||
let mut ids = Vec::new();
|
||||
self.index.query(r.center, r.center, &mut ids);
|
||||
for (count, id) in ids.into_iter().enumerate() {
|
||||
if count % 4096 == 4095 {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("surface verification cancelled");
|
||||
}
|
||||
}
|
||||
*work += 1;
|
||||
if *work > limits.work {
|
||||
return Ok(Status::Inconclusive);
|
||||
}
|
||||
let t = &self.triangles[id];
|
||||
let covered = [[-radius, -radius], [radius, -radius], [radius, radius], [-radius, radius]]
|
||||
.iter()
|
||||
.all(|d| height_interval(t, [r.center[0] + d[0], r.center[1] + d[1]], self.guard).is_some_and(|(lower, _)| lower > z + radius));
|
||||
if covered {
|
||||
return Ok(Status::Fail);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Status::Inconclusive)
|
||||
}
|
||||
}
|
||||
|
||||
// Upper is conservative even on a projected triangle edge. Lower is -infinity
|
||||
// near edges or degenerate projections so it cannot certify an interior ball.
|
||||
fn height_interval(t: &[Vec3; 3], p: [f64; 2], guard: f64) -> Option<(f64, f64)> {
|
||||
let n = (t[1] - t[0]).cross(t[2] - t[0]);
|
||||
if n.z <= 0. {
|
||||
return None;
|
||||
}
|
||||
let mut near = false;
|
||||
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
let edge = (b.x - a.x) * (p[1] - a.y) - (b.y - a.y) * (p[0] - a.x);
|
||||
let error = guard * (1. + (b.x - a.x).abs() + (b.y - a.y).abs());
|
||||
if edge < -error {
|
||||
return None;
|
||||
}
|
||||
near |= edge <= error;
|
||||
}
|
||||
let hi = t.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max);
|
||||
if near || n.z <= guard * guard {
|
||||
return Some((f64::NEG_INFINITY, hi + guard));
|
||||
}
|
||||
let z = t[0].z + (n.x * (t[0].x - p[0]) + n.y * (t[0].y - p[1])) / n.z;
|
||||
let error = guard * (1. + (n.x / n.z).abs() + (n.y / n.z).abs());
|
||||
if !z.is_finite() || !error.is_finite() {
|
||||
return Some((f64::NEG_INFINITY, hi + guard));
|
||||
}
|
||||
Some((z - error, (z + error).min(hi + guard)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn plate(lo: f64, hi: f64, z: f64) -> Vec<[Vec3; 3]> {
|
||||
let a = Vec3::new(lo, lo, z);
|
||||
let b = Vec3::new(hi, lo, z);
|
||||
let c = Vec3::new(hi, hi, z);
|
||||
let d = Vec3::new(lo, hi, z);
|
||||
vec![[a, b, c], [a, c, d]]
|
||||
}
|
||||
fn protect(s: &Surface, r: Region, budget: usize) -> Status {
|
||||
use std::future::Future;
|
||||
let mut ready = || std::future::ready(());
|
||||
let mut work = 0;
|
||||
let mut future = std::pin::pin!(s.protect(r, &mut work, Limits { work: budget, ..Default::default() }, &|| false, &mut ready));
|
||||
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
|
||||
std::task::Poll::Ready(r) => r.unwrap(),
|
||||
_ => Status::Inconclusive,
|
||||
}
|
||||
}
|
||||
fn region(x: f64, half: f64, low: f64, high: f64) -> Region {
|
||||
Region { center: [x, 1.], half, low, high, target_high: 5., tolerance: 0.01 }
|
||||
}
|
||||
#[test]
|
||||
fn vertical_boundary_is_certified_but_interior_gouge_fails() {
|
||||
let tris = plate(0., 2., 5.);
|
||||
let s = Surface::new(&tris, 0.).unwrap();
|
||||
assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1000), Status::Pass);
|
||||
assert_eq!(protect(&s, region(0.1, 0.001, 4.9, 4.9), 1000), Status::Fail);
|
||||
assert_eq!(protect(&s, region(0.001, 0.1, 1., 5.), 1000), Status::Inconclusive);
|
||||
}
|
||||
#[test]
|
||||
fn overlapping_top_faces_do_not_create_false_exterior_points() {
|
||||
let mut tris = plate(0., 2., 5.);
|
||||
tris.extend(plate(-1., 3., 6.));
|
||||
let s = Surface::new(&tris, 0.).unwrap();
|
||||
let mut r = region(0.001, 0.001, 4.9, 4.9);
|
||||
r.target_high = 6.;
|
||||
assert_eq!(protect(&s, r, 1000), Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn budget_exhaustion_is_inconclusive() {
|
||||
let tris = plate(0., 2., 5.);
|
||||
let s = Surface::new(&tris, 0.).unwrap();
|
||||
assert_eq!(protect(&s, region(0.001, 0.001, 1., 5.), 1), Status::Inconclusive);
|
||||
}
|
||||
#[test]
|
||||
fn missing_upward_faces_are_rejected() {
|
||||
assert!(Surface::new(&[[Vec3::ZERO, Vec3::X, Vec3::X]], 0.).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn shifted_coordinates_preserve_height_bounds() {
|
||||
let tris = plate(999999., 1000001., 5.);
|
||||
let s = Surface::new(&tris, 0.).unwrap();
|
||||
let mut work = 0;
|
||||
let (lo, hi, uncertain) = s.bounds([1000000., 1000000.], 0.001, &mut work, 1000);
|
||||
assert!(!uncertain);
|
||||
assert_eq!(lo, 5.);
|
||||
assert_eq!(hi, 5.);
|
||||
}
|
||||
fn drilled_plate() -> JobInput {
|
||||
corpus::fixture("drill").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_drilled_plate_certifies_with_its_wall_and_cone() {
|
||||
let input = drilled_plate();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.status, Status::Pass, "{:?}", report.findings);
|
||||
assert_eq!(report.finished_dimensions, Status::Pass);
|
||||
}
|
||||
#[test]
|
||||
fn wrong_hole_against_protected_cad_surface_is_a_gouge() {
|
||||
let input = drilled_plate();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let code = nc::post(&path, &input.setup).unwrap().replace("X10.000000", "X10.100000");
|
||||
let parsed = nc::parse(&code, &input.tools).unwrap();
|
||||
let report = verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
assert_eq!(report.status, Status::Fail, "{:?}", report.findings);
|
||||
assert!(report.findings.iter().any(|f| f.kind == "gouge"));
|
||||
}
|
||||
#[test]
|
||||
fn through_gouges_and_sloping_surfaces_use_spatial_distance() {
|
||||
let mut tris = plate(0., 2., 5.);
|
||||
let s = Surface::new(&tris, 0.).unwrap();
|
||||
assert_eq!(protect(&s, region(0.1, 0.001, 0., 0.), 1000), Status::Fail);
|
||||
for t in &mut tris {
|
||||
for p in t {
|
||||
p.z = 5. + 10. * p.x;
|
||||
}
|
||||
}
|
||||
let s = Surface::new(&tris, 0.).unwrap();
|
||||
let mut r = region(0.1, 0.001, 5.97, 5.97);
|
||||
r.target_high = 6.02;
|
||||
assert_eq!(protect(&s, r, 1000), Status::Pass);
|
||||
let mut r = region(0.1, 0.001, 5.8, 5.8);
|
||||
r.target_high = 6.02;
|
||||
assert_eq!(protect(&s, r, 1000), Status::Fail);
|
||||
}
|
||||
#[test]
|
||||
fn surface_queries_yield_and_cancel() {
|
||||
use std::{
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
let tris: Vec<_> = (0..5000).flat_map(|_| plate(0., 2., 5.)).collect();
|
||||
let s = Surface::new(&tris, 0.).unwrap();
|
||||
let mut work = 0;
|
||||
let cancel = std::cell::Cell::new(false);
|
||||
let cancelled = || cancel.get();
|
||||
let mut yielding = || {
|
||||
let mut first = true;
|
||||
std::future::poll_fn(move |_| {
|
||||
if first {
|
||||
first = false;
|
||||
Poll::Pending
|
||||
} else {
|
||||
Poll::Ready(())
|
||||
}
|
||||
})
|
||||
};
|
||||
let mut future = std::pin::pin!(s.protect(region(0.1, 0.001, 4.9, 4.9), &mut work, Default::default(), &cancelled, &mut yielding));
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(future.as_mut().poll(&mut context).is_pending());
|
||||
cancel.set(true);
|
||||
assert!(matches!(future.as_mut().poll(&mut context), Poll::Ready(Err(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
//! Independent bounded stock verifier. Adaptive cells carry height intervals;
|
||||
//! unresolved intervals and exhausted work budgets never become a pass.
|
||||
use crate::{
|
||||
nc::{Parsed, Segment},
|
||||
*,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
Pass,
|
||||
Fail,
|
||||
Inconclusive,
|
||||
}
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FindingSeverity {
|
||||
Information,
|
||||
#[default]
|
||||
Unresolved,
|
||||
Error,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Finding {
|
||||
#[serde(default)]
|
||||
pub severity: FindingSeverity,
|
||||
pub kind: String,
|
||||
pub line: Option<usize>,
|
||||
/// One-based interpreted NC record, not a tessellated arc segment.
|
||||
#[serde(default)]
|
||||
pub record: Option<usize>,
|
||||
/// Present only for an independently checked SolidCraft post round trip.
|
||||
#[serde(default)]
|
||||
pub source: Option<nc::SourceLocation>,
|
||||
pub at: Vec3,
|
||||
pub detail: String,
|
||||
}
|
||||
impl Finding {
|
||||
fn priority(&self) -> (FindingSeverity, bool) {
|
||||
(self.severity, self.line.is_none())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Cell {
|
||||
pub min: [f64; 2],
|
||||
pub max: [f64; 2],
|
||||
pub low: f64,
|
||||
pub high: f64,
|
||||
pub target_low: f64,
|
||||
pub target_high: f64,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub version: String,
|
||||
pub fingerprint: String,
|
||||
pub status: Status,
|
||||
/// Separate from path clearance. Unsupported finished surfaces stay inconclusive.
|
||||
pub finished_dimensions: Status,
|
||||
pub post_version: String,
|
||||
pub tolerance: f64,
|
||||
/// Requested spatial refinement budget, not a claim about height uncertainty.
|
||||
pub uncertainty: f64,
|
||||
pub maximum_height_interval: f64,
|
||||
/// False when resource limits prevented evaluating all stock cells/motions.
|
||||
#[serde(default)]
|
||||
pub stock_complete: bool,
|
||||
/// Comparison domain. Legacy reports without this cannot certify coverage.
|
||||
#[serde(default)]
|
||||
pub stock: Option<Stock>,
|
||||
pub cells: Vec<Cell>,
|
||||
pub findings: Vec<Finding>,
|
||||
pub cutting_distance: f64,
|
||||
pub rapid_distance: f64,
|
||||
pub cutting_minutes: f64,
|
||||
pub remaining_volume_low: f64,
|
||||
pub remaining_volume_high: f64,
|
||||
pub work: usize,
|
||||
}
|
||||
impl Report {
|
||||
/// Bounded representative witnesses. Repeated cells on one NC line must not
|
||||
/// crowd out a definite failure found later in the verification traversal.
|
||||
pub(crate) fn finding(&mut self, finding: Finding) {
|
||||
if self.findings.iter().any(|old| old.kind == finding.kind && old.line == finding.line && old.severity == finding.severity) {
|
||||
return;
|
||||
}
|
||||
if self.findings.len() < 200 {
|
||||
self.findings.push(finding);
|
||||
} else if let Some((index, old)) = self.findings.iter().enumerate().min_by_key(|(_, old)| old.priority())
|
||||
&& old.priority() < finding.priority()
|
||||
&& let Some(slot) = self.findings.get_mut(index)
|
||||
{
|
||||
*slot = finding;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
pub cells: usize,
|
||||
pub work: usize,
|
||||
pub uncertainty: f64,
|
||||
}
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { cells: 250_000, work: 30_000_000, uncertainty: VERIFY_ERROR }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simulate(input: &JobInput, nc: &Parsed, limits: Limits) -> Result<Report> {
|
||||
simulate_cancellable(input, nc, limits, || false)
|
||||
}
|
||||
pub fn simulate_cancellable(input: &JobInput, nc: &Parsed, limits: Limits, cancelled: impl Fn() -> bool) -> Result<Report> {
|
||||
use std::future::Future;
|
||||
let mut future = std::pin::pin!(simulate_cooperative(input, nc, limits, cancelled, || std::future::ready(())));
|
||||
match future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
|
||||
std::task::Poll::Ready(result) => result,
|
||||
std::task::Poll::Pending => invalid("synchronous verifier unexpectedly yielded"),
|
||||
}
|
||||
}
|
||||
/// Portable host-driven verification. A host supplies a yield future to bound a poll's work.
|
||||
/// This contains no executor, OS thread, browser or generator dependency.
|
||||
pub async fn simulate_cooperative<F: std::future::Future<Output = ()>>(
|
||||
input: &JobInput,
|
||||
nc: &Parsed,
|
||||
limits: Limits,
|
||||
cancelled: impl Fn() -> bool,
|
||||
mut yield_control: impl FnMut() -> F,
|
||||
) -> Result<Report> {
|
||||
input.validate()?;
|
||||
if limits.cells == 0
|
||||
|| limits.cells > 1_000_000
|
||||
|| limits.work == 0
|
||||
|| limits.work > 100_000_000
|
||||
|| !limits.uncertainty.is_finite()
|
||||
|| limits.uncertainty <= 0.0
|
||||
|| limits.uncertainty > VERIFY_ERROR
|
||||
{
|
||||
return invalid("invalid verifier limits");
|
||||
}
|
||||
if nc.work_offset != input.setup.work_offset {
|
||||
return invalid("NC/setup work-offset mismatch");
|
||||
}
|
||||
if input.geometry.target.is_empty() {
|
||||
return invalid("no target geometry to verify");
|
||||
}
|
||||
if input.geometry.target.len() + input.geometry.fixtures.len() > 200_000 {
|
||||
return invalid("verification mesh limit");
|
||||
}
|
||||
for t in input.geometry.target.iter().chain(&input.geometry.fixtures) {
|
||||
for p in t {
|
||||
crate::model::point(*p)?;
|
||||
}
|
||||
}
|
||||
let segments = nc::segments(nc, &input.setup)?;
|
||||
let tol = input.operations.iter().map(|o| o.tolerance).fold(TOLERANCE, f64::min);
|
||||
let stock = input.setup.stock;
|
||||
// Only motions whose cutter or non-cutting envelopes can reach stock need
|
||||
// cell queries. Keep original sequence IDs for machine-state/withdrawal checks.
|
||||
let motion_index = crate::spatial::Index::new(
|
||||
segments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(id, s)| {
|
||||
let tool = input.tools.iter().find(|t| t.id == s.tool)?;
|
||||
let z = s.from.z.min(s.to.z);
|
||||
let mut radius = if z <= stock.max.z + POST_ERROR { tool.diameter / 2. + POST_ERROR } else { 0. };
|
||||
for (r, lift) in [(tool.shank_diameter / 2., tool.cutting_length), (tool.holder_diameter / 2., tool.overall_length)] {
|
||||
if z + lift <= stock.max.z + POST_ERROR {
|
||||
radius = radius.max(r + POST_ERROR);
|
||||
}
|
||||
}
|
||||
if radius == 0. {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
id,
|
||||
[s.from.x.min(s.to.x) - radius, s.from.y.min(s.to.y) - radius],
|
||||
[s.from.x.max(s.to.x) + radius, s.from.y.max(s.to.y) + radius],
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let surface = crate::surface::Surface::new(&input.geometry.target, stock.min.z)?;
|
||||
let finished = crate::dimensions::Finished::new(input, tol)?;
|
||||
let dimension_resolution = finished.as_ref().map_or(limits.uncertainty, |f| limits.uncertainty.min(f.refinement));
|
||||
let mut r = Report {
|
||||
version: VERSION.into(),
|
||||
fingerprint: input.fingerprint.clone(),
|
||||
status: Status::Pass,
|
||||
finished_dimensions: if finished.is_some() { Status::Pass } else { Status::Inconclusive },
|
||||
post_version: "reference-three-axis/1".into(),
|
||||
tolerance: tol,
|
||||
uncertainty: limits.uncertainty,
|
||||
maximum_height_interval: 0.0,
|
||||
stock_complete: true,
|
||||
stock: Some(stock),
|
||||
cells: Vec::new(),
|
||||
findings: Vec::new(),
|
||||
cutting_distance: 0.0,
|
||||
rapid_distance: 0.0,
|
||||
cutting_minutes: 0.0,
|
||||
remaining_volume_low: 0.0,
|
||||
remaining_volume_high: 0.0,
|
||||
work: 0,
|
||||
};
|
||||
if finished.is_none() {
|
||||
r.finding(Finding {
|
||||
record: None,
|
||||
severity: FindingSeverity::Unresolved,
|
||||
source: None,
|
||||
kind: "finished_region_unsupported".into(),
|
||||
line: None,
|
||||
at: stock.min,
|
||||
detail: "Finished-region certification needs a tolerance larger than the post/interpolation error budget".into(),
|
||||
});
|
||||
}
|
||||
for s in &segments {
|
||||
let d = s.from.dist(s.to);
|
||||
if s.rapid {
|
||||
r.rapid_distance += d;
|
||||
} else {
|
||||
r.cutting_distance += d;
|
||||
r.cutting_minutes += d / s.feed;
|
||||
}
|
||||
}
|
||||
crate::collision::fixtures(input, &segments, limits, &cancelled, &mut yield_control, &mut r).await?;
|
||||
let mut pending = vec![([stock.min.x, stock.min.y], [stock.max.x, stock.max.y], 0u8)];
|
||||
let mut next_yield = 0;
|
||||
while let Some((min, max, depth)) = pending.pop() {
|
||||
if r.work >= next_yield {
|
||||
next_yield = r.work.saturating_add(4096);
|
||||
yield_control().await;
|
||||
}
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
if r.work > limits.work || r.cells.len() + pending.len() >= limits.cells {
|
||||
r.stock_complete = false;
|
||||
r.status = if r.status == Status::Fail { Status::Fail } else { Status::Inconclusive };
|
||||
r.finding(Finding {
|
||||
record: None,
|
||||
severity: FindingSeverity::Unresolved,
|
||||
source: None,
|
||||
kind: "budget_exhausted".into(),
|
||||
line: None,
|
||||
at: stock.min,
|
||||
detail: "Adaptive verification incomplete; increase resource budget or simplify the job".into(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let center = [(min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0];
|
||||
let half = ((max[0] - min[0]).hypot(max[1] - min[1])) / 2.0;
|
||||
let mut low = stock.max.z;
|
||||
let mut high = stock.max.z;
|
||||
let mut collision = None;
|
||||
let mut removal_line = None;
|
||||
let mut candidates = Vec::new();
|
||||
motion_index.query(min, max, &mut candidates);
|
||||
candidates.sort_unstable();
|
||||
for (query_index, segment_index) in candidates.into_iter().enumerate() {
|
||||
let seg = &segments[segment_index];
|
||||
r.work += 1;
|
||||
if query_index % 4096 == 4095 {
|
||||
yield_control().await;
|
||||
if cancelled() {
|
||||
return invalid("simulation cancelled");
|
||||
}
|
||||
}
|
||||
if r.work > limits.work {
|
||||
break;
|
||||
}
|
||||
let Some(tool) = input.tools.iter().find(|t| t.id == seg.tool) else {
|
||||
continue;
|
||||
};
|
||||
let radius = tool.diameter / 2.0;
|
||||
let (distance, zlo, zhi) = sweep(center, half, seg, radius);
|
||||
// A vertical drill has exact radial extrema on a rectangular cell.
|
||||
// Circumscribed disks are unnecessarily loose here and can create
|
||||
// artificial saddles where coarse and fine cone cells meet.
|
||||
let (near, far) = if tool.kind == ToolKind::TwistDrill && seg.from.x == seg.to.x && seg.from.y == seg.to.y {
|
||||
radial_bounds(min, max, [seg.from.x, seg.from.y])
|
||||
} else if seg.from.z == seg.to.z || (seg.from.x == seg.to.x && seg.from.y == seg.to.y) {
|
||||
lateral_bounds(min, max, seg)
|
||||
} else {
|
||||
// Sloping motion still uses a common covering disk: its upper
|
||||
// height bound must cover the entire cell at the same pose.
|
||||
((distance - half).max(0.0), distance + half)
|
||||
};
|
||||
let cone_low = if tool.kind == ToolKind::TwistDrill { near / (tool.tip_angle / 2.0).tan() } else { 0.0 };
|
||||
let cone_high = if tool.kind == ToolKind::TwistDrill { far / (tool.tip_angle / 2.0).tan() } else { 0.0 };
|
||||
let overlaps = near <= radius + POST_ERROR;
|
||||
// A vertical withdrawal starts at the endpoint of the preceding feed
|
||||
// with the same tool; this exact geometric fact needs no raster estimate.
|
||||
let withdrawal = segment_index.checked_sub(1).and_then(|i| segments.get(i)).is_some_and(|previous| {
|
||||
!previous.rapid
|
||||
&& previous.tool == seg.tool
|
||||
&& previous.to == seg.from
|
||||
&& seg.from.x == seg.to.x
|
||||
&& seg.from.y == seg.to.y
|
||||
&& seg.to.z >= seg.from.z
|
||||
});
|
||||
if seg.rapid && overlaps && !withdrawal {
|
||||
if zhi + cone_high < low - tol && far <= radius {
|
||||
collision = Some(("rapid_through_stock", seg.line));
|
||||
} else if zlo + cone_low < high - tol {
|
||||
collision = Some(("rapid_clearance_unresolved", seg.line));
|
||||
}
|
||||
} else if !seg.rapid && overlaps {
|
||||
low = low.min(zlo + cone_low).max(stock.min.z);
|
||||
if far <= radius {
|
||||
let next = high.min(zhi + cone_high).max(stock.min.z);
|
||||
if next < high {
|
||||
removal_line = Some(seg.line);
|
||||
}
|
||||
high = next;
|
||||
}
|
||||
}
|
||||
// Shank and holder sweeps against remaining stock, separate from cutter removal.
|
||||
for (rad, lift, label) in [
|
||||
(tool.shank_diameter / 2.0, tool.cutting_length, "shank_collision"),
|
||||
(tool.holder_diameter / 2.0, tool.overall_length, "holder_collision"),
|
||||
] {
|
||||
let (d, z, _) = sweep(center, half, seg, rad);
|
||||
if d + half < rad && z + lift < low - tol {
|
||||
collision = Some((label, seg.line));
|
||||
} else if d < rad + half && z + lift < high - tol {
|
||||
collision = Some(("holder_clearance_unresolved", seg.line));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (target_low, target_high, _) = surface.bounds(center, (max[0] - min[0]).max(max[1] - min[1]) / 2.0, &mut r.work, limits.work);
|
||||
let protection = surface
|
||||
.protect(
|
||||
crate::surface::Region { center, half, low, high, target_high, tolerance: tol },
|
||||
&mut r.work,
|
||||
limits,
|
||||
&cancelled,
|
||||
&mut yield_control,
|
||||
)
|
||||
.await?;
|
||||
let rest = low - target_high > tol;
|
||||
let expected =
|
||||
if let Some(f) = &finished { f.envelope((center, half), &mut r.work, limits.work, &cancelled, &mut yield_control).await? } else { None };
|
||||
let dimension = expected.map(|e| e.classify(low, high));
|
||||
// Refine both the machined result and the requested finished features:
|
||||
// an omitted hole must not hide inside one large untouched stock cell.
|
||||
let unresolved = ((high - low > tol / 2.0 || protection == Status::Inconclusive || collision.is_some()) && half > limits.uncertainty)
|
||||
|| (dimension == Some(Status::Inconclusive) && half > dimension_resolution);
|
||||
if unresolved && depth < 24 && r.work <= limits.work {
|
||||
pending.extend([
|
||||
(min, center, depth + 1),
|
||||
([center[0], min[1]], [max[0], center[1]], depth + 1),
|
||||
([min[0], center[1]], [center[0], max[1]], depth + 1),
|
||||
(center, max, depth + 1),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
let at = Vec3::new(center[0], center[1], low);
|
||||
if let Some(dimension) = dimension {
|
||||
if dimension == Status::Fail {
|
||||
r.finished_dimensions = Status::Fail;
|
||||
r.finding(Finding {
|
||||
record: None,
|
||||
severity: FindingSeverity::Error,
|
||||
source: None,
|
||||
kind: "finished_region_error".into(),
|
||||
line: removal_line,
|
||||
at,
|
||||
detail: expected.map_or_else(String::new, |e| e.description()),
|
||||
});
|
||||
} else if dimension == Status::Inconclusive && r.finished_dimensions == Status::Pass {
|
||||
r.finished_dimensions = Status::Inconclusive;
|
||||
}
|
||||
}
|
||||
if let Some((kind, line)) = collision {
|
||||
if !kind.ends_with("unresolved") {
|
||||
r.status = Status::Fail;
|
||||
} else if r.status == Status::Pass {
|
||||
r.status = Status::Inconclusive;
|
||||
}
|
||||
r.finding(Finding {
|
||||
record: None,
|
||||
severity: if kind.ends_with("unresolved") { FindingSeverity::Unresolved } else { FindingSeverity::Error },
|
||||
source: None,
|
||||
kind: kind.into(),
|
||||
line: Some(line),
|
||||
at,
|
||||
detail: "Swept cutter or non-cutting tool envelope intersects remaining stock".into(),
|
||||
});
|
||||
}
|
||||
if protection == Status::Fail {
|
||||
r.status = Status::Fail;
|
||||
r.finding(Finding {
|
||||
record: None,
|
||||
severity: FindingSeverity::Error,
|
||||
source: None,
|
||||
kind: "gouge".into(),
|
||||
line: removal_line,
|
||||
at,
|
||||
detail: format!("A definitely removed point lies more than {:.6} mm inside the protected height-field solid", tol),
|
||||
});
|
||||
}
|
||||
// Rest is reported separately: intermediate roughing is not required to finish a part.
|
||||
if rest && !r.findings.iter().any(|f| f.kind == "remaining_material") {
|
||||
r.finding(Finding {
|
||||
record: None,
|
||||
severity: FindingSeverity::Information,
|
||||
source: None,
|
||||
kind: "remaining_material".into(),
|
||||
line: None,
|
||||
at,
|
||||
detail: format!("At least {:.6} mm above target; may be intentional outside selected finished regions", low - target_high),
|
||||
});
|
||||
}
|
||||
if protection == Status::Inconclusive {
|
||||
if r.status == Status::Pass {
|
||||
r.status = Status::Inconclusive;
|
||||
}
|
||||
r.finding(Finding {
|
||||
record: None,
|
||||
severity: FindingSeverity::Unresolved,
|
||||
source: None,
|
||||
kind: "protected_surface_unresolved".into(),
|
||||
line: removal_line,
|
||||
at,
|
||||
detail: "Local surface-distance bounds cannot yet certify the protected CAD boundary at this resolution or work budget".into(),
|
||||
});
|
||||
}
|
||||
let area = (max[0] - min[0]) * (max[1] - min[1]);
|
||||
r.remaining_volume_low += area * (low - stock.min.z);
|
||||
r.remaining_volume_high += area * (high - stock.min.z);
|
||||
r.maximum_height_interval = r.maximum_height_interval.max(high - low);
|
||||
r.cells.push(Cell { min, max, low, high, target_low, target_high });
|
||||
}
|
||||
if r.work > limits.work {
|
||||
r.stock_complete = false;
|
||||
if r.status == Status::Pass {
|
||||
r.status = Status::Inconclusive;
|
||||
}
|
||||
}
|
||||
if (!r.stock_complete || r.status != Status::Pass) && r.finished_dimensions == Status::Pass {
|
||||
r.finished_dimensions = Status::Inconclusive;
|
||||
}
|
||||
r.findings.sort_by_key(|finding| std::cmp::Reverse(finding.priority()));
|
||||
for finding in &mut r.findings {
|
||||
if let Some(line) = finding.line {
|
||||
finding.record = nc.record_for_line(line).map(|i| i + 1);
|
||||
finding.source = nc.source_for_line(line);
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Distance to XY sweep and conservative height range over the part of a segment
|
||||
/// intersecting a disk expanded by the cell radius.
|
||||
fn sweep(p: [f64; 2], half: f64, s: &Segment, radius: f64) -> (f64, f64, f64) {
|
||||
let dx = s.to.x - s.from.x;
|
||||
let dy = s.to.y - s.from.y;
|
||||
let l2 = dx * dx + dy * dy;
|
||||
let t = if l2 > 1e-20 { ((p[0] - s.from.x) * dx + (p[1] - s.from.y) * dy) / l2 } else { 0.0 };
|
||||
let c = t.clamp(0.0, 1.0);
|
||||
let d = (p[0] - s.from.x - c * dx).hypot(p[1] - s.from.y - c * dy);
|
||||
let minimum = |r: f64| {
|
||||
if l2 <= 1e-20 {
|
||||
return s.from.z.min(s.to.z);
|
||||
}
|
||||
let perpendicular = ((p[0] - s.from.x).powi(2) + (p[1] - s.from.y).powi(2) - t * t * l2).max(0.0);
|
||||
let dt = ((r * r - perpendicular).max(0.0) / l2).sqrt();
|
||||
let z0 = s.from.z + (s.to.z - s.from.z) * (t - dt).clamp(0.0, 1.0);
|
||||
let z1 = s.from.z + (s.to.z - s.from.z) * (t + dt).clamp(0.0, 1.0);
|
||||
z0.min(z1)
|
||||
};
|
||||
(d, minimum(radius + half), minimum((radius - half).max(0.0)))
|
||||
}
|
||||
fn radial_bounds(min: [f64; 2], max: [f64; 2], center: [f64; 2]) -> (f64, f64) {
|
||||
let axis = |i: usize| {
|
||||
let near = (min[i] - center[i]).max(center[i] - max[i]).max(0.0);
|
||||
let far = (min[i] - center[i]).abs().max((max[i] - center[i]).abs());
|
||||
(near, far)
|
||||
};
|
||||
let (x, y) = (axis(0), axis(1));
|
||||
(x.0.hypot(y.0), x.1.hypot(y.1))
|
||||
}
|
||||
|
||||
/// Conservative rectangle-to-segment distance bounds. The segment AABB gives
|
||||
/// an additional lower bound, exact for axis-aligned sweeps. Distance to a
|
||||
/// convex segment is convex, so its maximum over a rectangle is attained at a
|
||||
/// corner. Circumscribed disks alone invent overlap along long straight walls
|
||||
/// and force unnecessary refinement of already resolved stock.
|
||||
fn lateral_bounds(min: [f64; 2], max: [f64; 2], s: &Segment) -> (f64, f64) {
|
||||
let a = [s.from.x, s.from.y];
|
||||
let b = [s.to.x, s.to.y];
|
||||
let delta = [b[0] - a[0], b[1] - a[1]];
|
||||
let length2 = delta[0] * delta[0] + delta[1] * delta[1];
|
||||
let gap = |k: usize| (min[k] - a[k].max(b[k])).max(a[k].min(b[k]) - max[k]).max(0.);
|
||||
let distance = |p: [f64; 2]| {
|
||||
let t = if length2 > 0. { (((p[0] - a[0]) * delta[0] + (p[1] - a[1]) * delta[1]) / length2).clamp(0., 1.) } else { 0. };
|
||||
(p[0] - a[0] - t * delta[0]).hypot(p[1] - a[1] - t * delta[1])
|
||||
};
|
||||
let mut far = 0_f64;
|
||||
for p in [min, [min[0], max[1]], max, [max[0], min[1]]] {
|
||||
far = far.max(distance(p));
|
||||
}
|
||||
let center_distance = distance([(min[0] + max[0]) / 2., (min[1] + max[1]) / 2.]);
|
||||
let half = (max[0] - min[0]).hypot(max[1] - min[1]) / 2.;
|
||||
let extent = min.into_iter().chain(max).chain(a).chain(b).map(f64::abs).fold(1_f64, f64::max);
|
||||
let guard = 128. * f64::EPSILON * extent;
|
||||
(((center_distance - half).max(gap(0).hypot(gap(1))) - guard).max(0.), far + guard)
|
||||
}
|
||||
|
||||
pub(crate) fn target_height(p: [f64; 2], half: f64, tris: &[[Vec3; 3]], bottom: f64, work: &mut usize, budget: usize) -> (f64, f64, bool) {
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = bottom;
|
||||
let mut area = 0.0;
|
||||
for tri in tris {
|
||||
*work += 1;
|
||||
if *work > budget {
|
||||
return (bottom, hi, true);
|
||||
}
|
||||
let [a, b, c] = *tri;
|
||||
// Positive-Z faces form the top envelope of supported height-field solids.
|
||||
if (b - a).cross(c - a).z <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut poly = tri.to_vec();
|
||||
for (axis, bound, greater) in [(0, p[0] - half, true), (0, p[0] + half, false), (1, p[1] - half, true), (1, p[1] + half, false)] {
|
||||
let old = std::mem::take(&mut poly);
|
||||
if old.is_empty() {
|
||||
break;
|
||||
}
|
||||
let value = |v: Vec3| if axis == 0 { v.x } else { v.y };
|
||||
let inside = |v: Vec3| if greater { value(v) >= bound } else { value(v) <= bound };
|
||||
for (a, b) in old.iter().zip(old.iter().cycle().skip(1)).take(old.len()) {
|
||||
if inside(*a) {
|
||||
poly.push(*a);
|
||||
}
|
||||
if inside(*a) != inside(*b) {
|
||||
let t = (bound - value(*a)) / (value(*b) - value(*a));
|
||||
poly.push(*a + (*b - *a) * t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if poly.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let origin = poly[0];
|
||||
let covered =
|
||||
poly.iter().zip(poly.iter().cycle().skip(1)).take(poly.len()).map(|(a, b)| (*a - origin).cross(*b - origin).z).sum::<f64>().abs() / 2.0;
|
||||
if covered <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
area += covered;
|
||||
for v in poly {
|
||||
lo = lo.min(v.z);
|
||||
hi = hi.max(v.z);
|
||||
}
|
||||
}
|
||||
if area == 0.0 {
|
||||
return (bottom, bottom, false);
|
||||
}
|
||||
let complete = area >= 4.0 * half * half * (1.0 - 1e-8);
|
||||
(if complete { lo } else { bottom }, hi, !complete)
|
||||
}
|
||||
/// Export a conforming conservative stock boundary. Tiny saddle closure may add
|
||||
/// at most `STOCK_EXPORT_ERROR` to a cell height; it never removes stock.
|
||||
pub fn stock_stl(report: &Report, bottom: f64) -> Result<String> {
|
||||
crate::stock_mesh::stl(report, bottom)
|
||||
}
|
||||
|
||||
/// Incremental extraction and ASCII serialization. Cancellation returns no partial artifact.
|
||||
pub async fn stock_stl_cooperative<F: std::future::Future<Output = ()>>(
|
||||
report: &Report,
|
||||
bottom: f64,
|
||||
cancelled: impl Fn() -> bool,
|
||||
yield_control: impl FnMut() -> F,
|
||||
) -> Result<String> {
|
||||
crate::stock_mesh::stl_cooperative(report, bottom, cancelled, yield_control).await
|
||||
}
|
||||
|
||||
/// The same conforming conservative boundary as `stock_stl`, without serialization.
|
||||
/// Coordinates are in the setup frame. An empty mesh represents fully removed stock.
|
||||
pub fn stock_triangles(report: &Report, bottom: f64) -> Result<Vec<[Vec3; 3]>> {
|
||||
crate::stock_mesh::triangles(report, bottom)
|
||||
}
|
||||
|
||||
/// Cancellation-aware boundary extraction, yielding during sorting, stitching,
|
||||
/// triangulation and manifold checks. No partial mesh is returned.
|
||||
pub async fn stock_triangles_cooperative<F: std::future::Future<Output = ()>>(
|
||||
report: &Report,
|
||||
bottom: f64,
|
||||
cancelled: impl Fn() -> bool,
|
||||
yield_control: impl FnMut() -> F,
|
||||
) -> Result<Vec<[Vec3; 3]>> {
|
||||
crate::stock_mesh::triangles_cooperative(report, bottom, cancelled, yield_control).await
|
||||
}
|
||||
|
||||
/// Independently evaluate a point on the upper stock surface. Used for analytic
|
||||
/// acceptance probes, never as a substitute for whole-region verification.
|
||||
pub fn probe_height(input: &JobInput, nc: &Parsed, p: [f64; 2]) -> Result<f64> {
|
||||
let mut z = input.setup.stock.max.z;
|
||||
for s in nc::segments(nc, &input.setup)? {
|
||||
if s.rapid {
|
||||
continue;
|
||||
}
|
||||
let t = input.tools.iter().find(|t| t.id == s.tool).ok_or_else(|| Error("unknown probe tool".into()))?;
|
||||
let (d, low, _) = sweep(p, 0.0, &s, t.diameter / 2.0);
|
||||
if d <= t.diameter / 2.0 {
|
||||
let point = if t.kind == ToolKind::TwistDrill { d / (t.tip_angle / 2.0).tan() } else { 0.0 };
|
||||
z = z.min(low + point);
|
||||
}
|
||||
}
|
||||
Ok(z.max(input.setup.stock.min.z))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod radial_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rectangle_radial_extrema_include_corners_and_interior_axis() {
|
||||
assert_eq!(super::radial_bounds([3., 4.], [6., 8.], [0., 0.]), (5., 10.));
|
||||
assert_eq!(super::radial_bounds([-3., -4.], [3., 4.], [0., 0.]), (0., 5.));
|
||||
assert_eq!(super::radial_bounds([7., -2.], [10., 6.], [10., 2.]), (0., 5.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_sweep_bounds_cover_axis_diagonal_endcaps_and_translations() {
|
||||
for offset in [0., 1_000_000.] {
|
||||
let point = |x, y| Vec3::new(x + offset, y + offset, 0.);
|
||||
for (a, b, min, max, expected) in [
|
||||
([0., 0.], [10., 0.], [2., 3.], [4., 5.], (3., 5.)),
|
||||
([0., 0.], [0., 10.], [3., 2.], [5., 4.], (3., 5.)),
|
||||
([0., 0.], [0., 0.], [3., 4.], [6., 8.], (5., 10.)),
|
||||
([0., 0.], [1., 0.], [4., 4.], [7., 8.], (5., 10.)),
|
||||
([0., 0.], [10., 10.], [4., 4.], [6., 6.], (0., 2_f64.sqrt())),
|
||||
] {
|
||||
let s = Segment { from: point(a[0], a[1]), to: point(b[0], b[1]), rapid: false, feed: 100., tool: 1, line: 1 };
|
||||
let (low, high) = lateral_bounds(min.map(|x| x + offset), max.map(|x| x + offset), &s);
|
||||
assert!(low <= expected.0 && high >= expected.1, "{low} {high} {expected:?}");
|
||||
assert!(expected.0 - low < 1e-6 && high - expected.1 < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Hand-authored modal programs exercise NIST machine state independently of generation.
|
||||
use solidcraft_cam::{Motion, corpus, generate, nc};
|
||||
|
||||
const HEADER: &str = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000 M3\nM8\n";
|
||||
|
||||
#[test]
|
||||
fn tool_change_stops_spindle_preserves_coolant_and_requires_restart() {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
let mut second = input.tools[0].clone();
|
||||
second.id = 2;
|
||||
second.number = 2;
|
||||
input.tools.push(second);
|
||||
for number in [1, 2] {
|
||||
let source = format!("{HEADER}T{number} M6\nS7000\nG1 X1 F100\nM30");
|
||||
let parsed = nc::parse(&source, &input.tools).unwrap();
|
||||
let change: Vec<_> = parsed.records.iter().filter(|r| r.line == 6).map(|r| &r.motion).collect();
|
||||
assert_eq!(change, [&Motion::Spindle { rpm: 0. }, &Motion::Tool { tool: number as u64, number }]);
|
||||
assert!(parsed.records.iter().all(|r| r.line != 7), "S alone cannot restart after M6");
|
||||
assert_eq!(parsed.records.last().unwrap().motion, Motion::Coolant { on: false }, "M6 must preserve coolant until M30");
|
||||
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 8"));
|
||||
|
||||
let restarted = source.replace("S7000\n", "M3\n");
|
||||
let parsed = nc::parse(&restarted, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 7).unwrap().motion, Motion::Spindle { rpm: 6000. });
|
||||
let segments = nc::segments(&parsed, &input.setup).unwrap();
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].tool, number as u64);
|
||||
assert_eq!(segments[0].to.x, 1.);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_block_speed_and_coolant_commands_keep_both_effects() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
for coolant in ["M8", "M9", "M0"] {
|
||||
let source = format!("{HEADER}S0 {coolant}\nG1 X1 F100\nM30");
|
||||
let parsed = nc::parse(&source, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
|
||||
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 7"));
|
||||
let source = source.replace("S0", "S12000");
|
||||
let parsed = nc::parse(&source, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 12000. });
|
||||
assert_eq!(nc::segments(&parsed, &input.setup).unwrap().len(), 1);
|
||||
}
|
||||
let parsed = nc::parse(&format!("{HEADER}S0 G1 X1 F100\nM30"), &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
|
||||
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_post_initializes_state_before_pose_and_keeps_block_provenance() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let code = nc::post(&path, &input.setup).unwrap();
|
||||
let lines: Vec<_> = code.lines().filter(|l| !l.starts_with('(')).collect();
|
||||
assert_eq!(lines[0], "G21 G90 G17 G91.1 G94 G54 G40 G49 G80");
|
||||
assert_eq!(&lines[1..3], ["M5", "M9"]);
|
||||
assert!(lines[3].starts_with("G0 X0.000000 Y0.000000 Z15.000000"));
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.len(), path.blocks.len() + 3);
|
||||
for record in &parsed.records[..3] {
|
||||
assert!(parsed.source_for_line(record.line).is_none());
|
||||
}
|
||||
for (i, record) in parsed.records[3..].iter().enumerate() {
|
||||
let source = parsed.source_for_line(record.line).unwrap();
|
||||
assert_eq!(source.block, i + 1);
|
||||
assert_eq!(source.operation, path.blocks[i].operation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Independent direction checks for a right-handed cutter with M3 spindle rotation.
|
||||
//! A climb cut keeps the material being finished to the right of tool travel.
|
||||
use solidcraft_cam::{Boundary, JobInput, Strategy, corpus, generate, nc};
|
||||
|
||||
fn segments(input: &JobInput) -> Vec<nc::Segment> {
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
nc::segments(&parsed, &input.setup).unwrap()
|
||||
}
|
||||
|
||||
fn square(lo: f64, hi: f64, reversed: bool) -> Vec<[f64; 2]> {
|
||||
let mut ring = vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]];
|
||||
if reversed {
|
||||
ring.reverse();
|
||||
}
|
||||
ring
|
||||
}
|
||||
|
||||
// Collect continuous constant-depth cuts from the independently interpreted NC.
|
||||
// Signed polygon area checks orientation without calling the offset adapter.
|
||||
fn loops(input: &JobInput) -> Vec<Vec<nc::Segment>> {
|
||||
let mut result = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
for s in segments(input) {
|
||||
if !s.rapid && (s.from.z - 5.).abs() < 1e-8 && (s.to.z - 5.).abs() < 1e-8 {
|
||||
current.push(s);
|
||||
} else if !current.is_empty() {
|
||||
result.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
result.push(current);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn ccw(path: &[nc::Segment]) -> bool {
|
||||
let origin = path[0].from;
|
||||
assert!((path.last().unwrap().to - origin).len() < 1e-5, "cut must close");
|
||||
let area = path
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let a = s.from - origin;
|
||||
let b = s.to - origin;
|
||||
a.x * b.y - b.x * a.y
|
||||
})
|
||||
.sum::<f64>();
|
||||
assert!(area.abs() > 1.);
|
||||
area > 0.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facing_direction_matches_uncut_side_at_every_depth() {
|
||||
for climb in [false, true] {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.operations[0].climb = climb;
|
||||
input.operations[0].stepdown = 0.5;
|
||||
let cuts: Vec<_> = segments(&input).into_iter().filter(|s| !s.rapid && (s.from.z - s.to.z).abs() < 1e-8 && s.to.z < 6.).collect();
|
||||
assert_eq!(cuts.len(), 22);
|
||||
for depth in cuts.as_chunks::<11>().0 {
|
||||
assert!(depth.windows(2).all(|pair| pair[1].from.y > pair[0].from.y));
|
||||
// Advancing rows toward +Y leaves uncut material north of the tool.
|
||||
// Right of a -X move is +Y; right of a +X move is -Y.
|
||||
assert!(depth.iter().all(|s| (s.to.x < s.from.x) == climb));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_direction_matches_inside_and_outside_material() {
|
||||
for reversed in [false, true] {
|
||||
for inside in [false, true] {
|
||||
for climb in [false, true] {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.tools[0].diameter = 2.;
|
||||
input.operations[0].climb = climb;
|
||||
input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: square(4., 16., reversed), islands: vec![] }, inside };
|
||||
let paths = loops(&input);
|
||||
assert_eq!(paths.len(), 1);
|
||||
// Pocket wall: material outside => CCW climb. Boss: inside => CW climb.
|
||||
assert_eq!(ccw(&paths[0]), inside == climb, "inside={inside}, climb={climb}, reversed={reversed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_direction_preserves_opposite_outer_and_island_winding() {
|
||||
for reversed in [false, true] {
|
||||
for climb in [false, true] {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.tools[0].diameter = 2.;
|
||||
input.operations[0].climb = climb;
|
||||
input.operations[0].geometry =
|
||||
Strategy::Pocket { boundary: Boundary { outer: square(0., 20., reversed), islands: vec![square(4., 16., reversed)] } };
|
||||
let paths = loops(&input);
|
||||
assert_eq!(paths.len(), 2);
|
||||
let mut outer = 0;
|
||||
let mut island = 0;
|
||||
for path in &paths {
|
||||
let is_outer = path.iter().any(|s| s.from.x < 2.);
|
||||
if is_outer {
|
||||
outer += 1;
|
||||
} else {
|
||||
island += 1;
|
||||
}
|
||||
assert_eq!(ccw(path), is_outer == climb, "outer={is_outer}, climb={climb}, reversed={reversed}");
|
||||
}
|
||||
assert_eq!((outer, island), (1, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Original programs exercise cooperative NC processing and its exact boundary limits.
|
||||
use solidcraft_cam::{self as cam, Block, Motion, Toolpath, nc};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
|
||||
fn yield_once() -> impl Future<Output = ()> {
|
||||
let mut first = true;
|
||||
std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) })
|
||||
}
|
||||
fn drive<T>(future: impl Future<Output = cam::Result<T>>) -> (cam::Result<T>, usize) {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let mut polls = 0;
|
||||
loop {
|
||||
polls += 1;
|
||||
assert!(polls < 20_000, "unbounded checkpoint count");
|
||||
if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
|
||||
return (result, polls);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn path(count: usize) -> Toolpath {
|
||||
let mut blocks = vec![
|
||||
Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } },
|
||||
Block { operation: 1, motion: Motion::Spindle { rpm: 6000. } },
|
||||
Block { operation: 1, motion: Motion::Coolant { on: true } },
|
||||
];
|
||||
for i in 0..count - 6 {
|
||||
blocks.push(Block {
|
||||
operation: if i < count / 2 { 1 } else { 2 },
|
||||
motion: Motion::Linear { to: Vec3::new(if i % 2 == 0 { 1. } else { 19. }, 10., 5.), feed: 100. },
|
||||
});
|
||||
}
|
||||
blocks.extend([
|
||||
Block { operation: 2, motion: Motion::Coolant { on: false } },
|
||||
Block { operation: 2, motion: Motion::Spindle { rpm: 0. } },
|
||||
Block { operation: 2, motion: Motion::Stop },
|
||||
]);
|
||||
Toolpath { fingerprint: "original-independent-test".into(), blocks }
|
||||
}
|
||||
#[test]
|
||||
fn maximum_path_includes_startup_records_and_excess_is_rejected() {
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
let mut path = path(cam::MAX_MOVES);
|
||||
let (parsed, polls) = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once));
|
||||
let parsed = parsed.unwrap();
|
||||
assert_eq!(parsed.records.len(), cam::MAX_MOVES + 3);
|
||||
assert!(polls > 3000);
|
||||
let last = parsed.records.last().unwrap();
|
||||
let source = parsed.source_for_line(last.line).unwrap();
|
||||
assert_eq!((source.block, source.operation), (cam::MAX_MOVES, 2));
|
||||
path.blocks.push(path.blocks.last().unwrap().clone());
|
||||
assert!(nc::post(&path, &input.setup).is_err());
|
||||
// External NC records remain bounded independently of the posting limit.
|
||||
let mut external = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\n".to_owned();
|
||||
external.push_str(&"G0 Z15\n".repeat(cam::MAX_MOVES + 3));
|
||||
external.push_str("M30\n");
|
||||
assert!(nc::parse(&external, &input.tools).unwrap_err().to_string().contains("NC motion limit"));
|
||||
}
|
||||
#[test]
|
||||
fn synchronous_and_cooperative_processing_keep_bytes_state_and_provenance() {
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
let path = path(1024);
|
||||
let source = nc::post(&path, &input.setup).unwrap();
|
||||
assert!(source.starts_with("(solidcraft-reference-3axis/2; not a qualified machine post)\n(Fingerprint original-independent-test)\nG21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\nG0 X0.000000 Y0.000000 Z15.000000\n(Operation 1)\nT1 M6\n"));
|
||||
assert_eq!(drive(nc::post_cooperative(&path, &input.setup, || false, yield_once)).0.unwrap(), source);
|
||||
let synchronous = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let cooperative = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once)).0.unwrap();
|
||||
assert_eq!(serde_json::to_value(&synchronous).unwrap(), serde_json::to_value(&cooperative).unwrap());
|
||||
for record in &synchronous.records {
|
||||
assert_eq!(synchronous.source_for_line(record.line), cooperative.source_for_line(record.line));
|
||||
}
|
||||
let broken = source.replace("(Operation 2)", "G123");
|
||||
let ordinary = nc::parse(&broken, &input.tools).unwrap_err();
|
||||
let incremental = drive(nc::parse_cooperative(&broken, &input.tools, || false, yield_once)).0.unwrap_err();
|
||||
assert_eq!(ordinary.to_string(), incremental.to_string());
|
||||
assert!(incremental.to_string().contains("unsupported G code"));
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_during_each_stage_returns_no_partial_program_or_line_mapping() {
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
let path = path(1024);
|
||||
let count = Cell::new(0);
|
||||
let full = drive(nc::round_trip_cooperative(
|
||||
&path,
|
||||
&input.setup,
|
||||
&input.tools,
|
||||
|| false,
|
||||
|| {
|
||||
count.set(count.get() + 1);
|
||||
yield_once()
|
||||
},
|
||||
))
|
||||
.0
|
||||
.unwrap();
|
||||
assert_eq!(full.records.len(), 1027);
|
||||
// Every checkpoint, including post, reader and correspondence stages.
|
||||
for stop in 0..=count.get() {
|
||||
let at = Cell::new(0);
|
||||
let result = drive(nc::round_trip_cooperative(
|
||||
&path,
|
||||
&input.setup,
|
||||
&input.tools,
|
||||
|| at.get() >= stop,
|
||||
|| {
|
||||
at.set(at.get() + 1);
|
||||
yield_once()
|
||||
},
|
||||
))
|
||||
.0;
|
||||
assert!(result.unwrap_err().to_string().contains("cancelled"), "checkpoint {stop}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user