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}");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-cam = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -2225,12 +2225,21 @@ pub struct DesignLink {
|
||||
pub feature: u64,
|
||||
}
|
||||
|
||||
fn read_manufacturing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<solidcraft_cam::Manufacturing, D::Error> {
|
||||
let manufacturing = <solidcraft_cam::Manufacturing as Deserialize>::deserialize(d)?;
|
||||
manufacturing.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(manufacturing)
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
/// Named, renderer-independent setups. Device/job state is never saved here.
|
||||
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
|
||||
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
|
||||
/// Manufacturing definitions; generated toolpaths remain session caches.
|
||||
#[serde(default, deserialize_with = "read_manufacturing", skip_serializing_if = "solidcraft_cam::Manufacturing::is_empty")]
|
||||
pub manufacturing: solidcraft_cam::Manufacturing,
|
||||
#[serde(default = "default_format")]
|
||||
pub format: String,
|
||||
/// The changes of `format` this design has while they are pending (no release has fixed
|
||||
@@ -2453,6 +2462,7 @@ impl Document {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Document {
|
||||
render: Default::default(),
|
||||
manufacturing: Default::default(),
|
||||
format: default_format(),
|
||||
format_pending: crate::format::pending_names(),
|
||||
name: name.into(),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Persistent milling setups, stock, fixtures, local tools, ordered operations, NC programs
|
||||
//! and durable profile/hole references. Older designs get empty manufacturing; explicit hole
|
||||
//! coordinates without references stay explicit. Generated paths and validation are not saved.
|
||||
//! Earlier released readers refuse the new format instead of discarding manufacturing.
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-cam = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Manufacturing commands shared by UI, CLI and MCP.
|
||||
use super::CommandSpec;
|
||||
use crate::{EngineError, Result, Session, cam, manufacturing as jobs};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("cam.holes.from_selection", "Use Selected Holes", from_holes)
|
||||
.noundo()
|
||||
.params("setup: ID, selections: [{kind:face,body,index,occurrence?:[IDs]}|{kind:profile,sketch,index,occurrence?:[IDs]}]; returns ordered holes, diameters and durable sources; face body is a world label unless occurrence is explicit"),
|
||||
CommandSpec::new("cam.boundary.from_profile", "Use Sketch Profile", from_profile)
|
||||
.noundo()
|
||||
.params("setup: ID, sketch: feature ID, profile: index, occurrence?: [IDs]; returns boundary and durable source reference"),
|
||||
CommandSpec::new("cam.setup.put", "Milling Setup", setup)
|
||||
.at("MILLING", "SETUP")
|
||||
.icon("box")
|
||||
.params("setup: complete Setup object (create or replace by ID)"),
|
||||
CommandSpec::new("cam.tool.put", "Tool Library", tool).at("MILLING", "SETUP").icon("hole").params("tool: complete Tool object"),
|
||||
CommandSpec::new("cam.operation.put", "Milling Operation", operation)
|
||||
.at("MILLING", "TOOLPATHS")
|
||||
.icon("cam_path")
|
||||
.params("operation: complete Operation object; strategy face, contour, pocket or drill"),
|
||||
CommandSpec::new("cam.program.put", "NC Program", program)
|
||||
.at("MILLING", "SETUP")
|
||||
.icon("save")
|
||||
.params("program: {id,name,setup,operations:[ordered IDs]}"),
|
||||
CommandSpec::new("cam.operation.order", "Reorder Operations", order).params("program: ID, operations: permutation of existing operation IDs"),
|
||||
CommandSpec::new("cam.delete", "Delete Manufacturing Item", delete)
|
||||
.params("kind: setup|tool|operation|program, id; referenced items cannot be deleted"),
|
||||
CommandSpec::new("cam.inspect", "Inspect Manufacturing", inspect)
|
||||
.noundo()
|
||||
.params("job?: ID; include_path?: bool; include_stock?: bool (world-space display mesh); motion?: completed block count; finding?: zero-based verification finding index"),
|
||||
CommandSpec::new("cam.generate", "Generate Toolpaths", generate)
|
||||
.at("MILLING", "TOOLPATHS")
|
||||
.icon("cam_generate")
|
||||
.noundo()
|
||||
.params("program: ID, background?: bool (default false); returns job ID"),
|
||||
CommandSpec::new("cam.job.status", "CAM Job Status", status).noundo().params("job: ID"),
|
||||
CommandSpec::new("cam.job.cancel", "Cancel CAM Job", cancel).noundo().params("job: ID"),
|
||||
CommandSpec::new("cam.simulate", "Simulate Toolpaths", simulate)
|
||||
.at("MILLING", "VERIFY")
|
||||
.icon("cam_verify")
|
||||
.noundo()
|
||||
.params("job: ID, background?: bool"),
|
||||
CommandSpec::new("cam.post", "Post Reference G-code", post)
|
||||
.at("MILLING", "EXPORT")
|
||||
.icon("save")
|
||||
.noundo()
|
||||
.params("job: ID, path?: string, background?: bool; requires current passing verification; background output is published by cam.export.result"),
|
||||
CommandSpec::new("cam.export", "Export CAM Artifact", export)
|
||||
.noundo()
|
||||
.params("job: ID, format: toolpath|setup_sheet|stock|validation, path?: string, background?: bool; background output is published by cam.export.result"),
|
||||
CommandSpec::new("cam.export.result", "Collect CAM Export", export_result).noundo().params("job: ID; consumes completed output after checking freshness; writes the requested path or returns content"),
|
||||
];
|
||||
fn from_holes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let setup = id(p, "setup")?;
|
||||
let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?;
|
||||
let (sources, selected) = jobs::holes::select(s, setup, decode(p, "selections")?)?;
|
||||
Ok(
|
||||
json!({"sources":sources,"holes":selected.iter().map(|h|h.center).collect::<Vec<_>>(),"diameters":selected.iter().map(|h|h.diameter).collect::<Vec<_>>()}),
|
||||
)
|
||||
}
|
||||
fn from_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let setup = id(p, "setup")?;
|
||||
let sketch = id(p, "sketch")?;
|
||||
let profile = id(p, "profile")? as usize;
|
||||
let state = s.model.state();
|
||||
let profile = state
|
||||
.sketches
|
||||
.iter()
|
||||
.find(|x| x.feature == sketch)
|
||||
.and_then(|s| s.profiles.get(profile))
|
||||
.ok_or_else(|| EngineError::Other("selected profile not found".into()))?;
|
||||
let source = cam::ProfileRef {
|
||||
sketch,
|
||||
curves: profile.curve_ids(),
|
||||
occurrence: p.get("occurrence").map(|_| decode(p, "occurrence")).transpose()?.unwrap_or_default(),
|
||||
};
|
||||
let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?;
|
||||
let boundary = jobs::profile_boundary(s, setup, &source, cam::TOLERANCE)?;
|
||||
Ok(json!({"boundary":boundary,"source":source}))
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(p: &Value, key: &str) -> Result<T> {
|
||||
serde_json::from_value(p.get(key).cloned().ok_or_else(|| EngineError::Other(format!("missing {key}")))?)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
fn id(p: &Value, key: &str) -> Result<u64> {
|
||||
p.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("{key} must be an ID")))
|
||||
}
|
||||
macro_rules! put {($fn:ident,$key:literal,$ty:ty,$field:ident)=>{fn $fn(s:&mut Session,p:&Value)->Result<Value>{
|
||||
let item:$ty=decode(p,$key)?;let id=item.id;let mut m=s.doc.manufacturing.clone();
|
||||
if let Some(old)=m.$field.iter_mut().find(|x|x.id==id){*old=item;}else{m.$field.push(item);}
|
||||
m.validate().map_err(jobs::error)?;s.doc_mut().manufacturing=m;Ok(json!({"id":id}))
|
||||
}};}
|
||||
put!(setup, "setup", cam::Setup, setups);
|
||||
put!(tool, "tool", cam::Tool, tools);
|
||||
put!(operation, "operation", cam::Operation, operations);
|
||||
put!(program, "program", cam::Program, programs);
|
||||
fn order(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let program = id(p, "program")?;
|
||||
let ids: Vec<u64> = decode(p, "operations")?;
|
||||
let mut m = s.doc.manufacturing.clone();
|
||||
let target = m.programs.iter_mut().find(|x| x.id == program).ok_or_else(|| EngineError::Other("program not found".into()))?;
|
||||
let mut a = target.operations.clone();
|
||||
let mut b = ids.clone();
|
||||
a.sort();
|
||||
b.sort();
|
||||
if a != b {
|
||||
return Err(EngineError::Other("order must be a permutation of the program operations".into()));
|
||||
}
|
||||
target.operations = ids;
|
||||
m.validate().map_err(jobs::error)?;
|
||||
s.doc_mut().manufacturing = m;
|
||||
Ok(json!({"program":program}))
|
||||
}
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id(p, "id")?;
|
||||
let mut m = s.doc.manufacturing.clone();
|
||||
let removed = match p.get("kind").and_then(Value::as_str) {
|
||||
Some("setup") => {
|
||||
let n = m.setups.len();
|
||||
m.setups.retain(|x| x.id != id);
|
||||
n != m.setups.len()
|
||||
}
|
||||
Some("tool") => {
|
||||
let n = m.tools.len();
|
||||
m.tools.retain(|x| x.id != id);
|
||||
n != m.tools.len()
|
||||
}
|
||||
Some("operation") => {
|
||||
let n = m.operations.len();
|
||||
m.operations.retain(|x| x.id != id);
|
||||
n != m.operations.len()
|
||||
}
|
||||
Some("program") => {
|
||||
let n = m.programs.len();
|
||||
m.programs.retain(|x| x.id != id);
|
||||
n != m.programs.len()
|
||||
}
|
||||
_ => return Err(EngineError::Other("unknown CAM item kind".into())),
|
||||
};
|
||||
if !removed {
|
||||
return Err(EngineError::Other("CAM item not found".into()));
|
||||
}
|
||||
m.validate().map_err(jobs::error)?;
|
||||
s.doc_mut().manufacturing = m;
|
||||
Ok(json!({"deleted":id}))
|
||||
}
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("job").is_none() {
|
||||
if p.get("finding").is_some()
|
||||
|| p.get("motion").is_some()
|
||||
|| ["include_path", "include_stock"].iter().any(|key| p.get(key).and_then(Value::as_bool) == Some(true))
|
||||
{
|
||||
return Err(EngineError::Other("job is required for result inspection".into()));
|
||||
}
|
||||
return Ok(json!({"manufacturing":s.doc.manufacturing,"jobs":s.cam_jobs.jobs.keys().collect::<Vec<_>>()}));
|
||||
}
|
||||
let job = jobs::job(s, id(p, "job")?)?;
|
||||
let mut result = status(s, p)?;
|
||||
match job.state()? {
|
||||
jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) if p.get("include_path").and_then(Value::as_bool) == Some(true) => {
|
||||
result["toolpath"] = json!(path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(motion) = p.get("motion") {
|
||||
jobs::current(s, &job)?;
|
||||
let motion = motion
|
||||
.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.ok_or_else(|| EngineError::Other("motion must be a nonnegative block count".into()))?;
|
||||
let path = match job.state()? {
|
||||
jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) => path,
|
||||
_ => return Err(EngineError::Other("generate before inspecting playback".into())),
|
||||
};
|
||||
let state = cam::playback::at(&path, &job.input()?.setup, motion).map_err(jobs::error)?;
|
||||
result["playback"] = json!(state);
|
||||
let operation = job.input()?.operations.iter().find(|o| Some(o.id) == state.operation);
|
||||
result["source"] = json!(operation.and_then(|o| o.source.as_ref()));
|
||||
result["hole_sources"] = json!(operation.map(|o| &o.hole_sources));
|
||||
}
|
||||
if let Some(index) = p.get("finding") {
|
||||
jobs::current(s, &job)?;
|
||||
let index =
|
||||
index.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| EngineError::Other("finding must be a nonnegative index".into()))?;
|
||||
let jobs::JobState::Verified(path, report) = job.state()? else {
|
||||
return Err(EngineError::Other("simulate before inspecting findings".into()));
|
||||
};
|
||||
let finding = report.findings.get(index).ok_or_else(|| EngineError::Other("finding index out of range".into()))?;
|
||||
let input = job.input()?;
|
||||
let operation = finding.source.and_then(|source| input.operations.iter().find(|o| o.id == source.operation));
|
||||
let code = cam::nc::post(&path, &input.setup).map_err(jobs::error)?;
|
||||
let line = finding.line.and_then(|line| line.checked_sub(1)).and_then(|index| code.lines().nth(index));
|
||||
result["diagnostic"] = json!({"index":index,"finding":finding,"world":input.setup.frame.world(finding.at),"operation":operation,
|
||||
"source":operation.and_then(|o|o.source.as_ref()),"hole_sources":operation.map(|o|&o.hole_sources),"gcode_line":line,
|
||||
"playback":finding.source.map(|source|cam::playback::at(&path,&input.setup,source.block)).transpose().map_err(jobs::error)?});
|
||||
}
|
||||
if p.get("include_stock").and_then(Value::as_bool) == Some(true) {
|
||||
jobs::current(s, &job)?;
|
||||
result["stock_mesh"] = serde_json::to_value(job.stock_mesh()?.as_deref()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = jobs::start(s, id(p, "program")?, p.get("background").and_then(Value::as_bool).unwrap_or(false))?;
|
||||
status(s, &json!({"job":id}))
|
||||
}
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id(p, "job")?;
|
||||
let job = jobs::job(s, id)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
jobs::advance(&job);
|
||||
let stale = jobs::current(s, &job).is_err();
|
||||
let mut v = json!({"job":id,"stale":stale,"progress":job.progress.load(Ordering::Relaxed),"fingerprint":job.input().ok().map(|input| &input.fingerprint)});
|
||||
match job.state()? {
|
||||
jobs::JobState::Verifying(_) => v["state"] = json!("simulating"),
|
||||
jobs::JobState::Preparing => v["state"] = json!("preparing"),
|
||||
jobs::JobState::Running => v["state"] = json!("running"),
|
||||
jobs::JobState::Cancelled => v["state"] = json!("cancelled"),
|
||||
jobs::JobState::Failed(e) => {
|
||||
v["state"] = json!("failed");
|
||||
v["error"] = json!(e);
|
||||
}
|
||||
jobs::JobState::Generated(path) => {
|
||||
v["state"] = json!("generated");
|
||||
v["blocks"] = json!(path.blocks.len());
|
||||
}
|
||||
jobs::JobState::Verified(path, r) => {
|
||||
v["state"] = json!("verified");
|
||||
v["blocks"] = json!(path.blocks.len());
|
||||
v["stock_preview"] = match job.stock_mesh() {
|
||||
Ok(Some(mesh)) => json!({"available":true,"triangles":mesh.triangles.len()}),
|
||||
Ok(None) => json!({"available":false}),
|
||||
Err(e) => json!({"available":false,"error":e.to_string()}),
|
||||
};
|
||||
v["verification"] = json!({"status":r.status,"finished_dimensions":r.finished_dimensions,"stock_complete":r.stock_complete,
|
||||
"findings":r.findings,"cells":r.cells.len(),"work":r.work});
|
||||
}
|
||||
}
|
||||
v["export"] = jobs::exports::status(&job)?;
|
||||
Ok(v)
|
||||
}
|
||||
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let job = jobs::job(s, id(p, "job")?)?;
|
||||
job.cancelled.store(true, Ordering::Relaxed);
|
||||
job.set(jobs::JobState::Cancelled);
|
||||
status(s, p)
|
||||
}
|
||||
fn simulate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let job = jobs::job(s, id(p, "job")?)?;
|
||||
jobs::current(s, &job)?;
|
||||
if jobs::exports::busy(&job)? {
|
||||
return Err(EngineError::Other("finish or cancel the current export first".into()));
|
||||
}
|
||||
let path = match job.state()? {
|
||||
jobs::JobState::Generated(p) | jobs::JobState::Verified(p, _) => p,
|
||||
_ => return Err(EngineError::Other("generate a path first; another job may still be running".into())),
|
||||
};
|
||||
job.set(jobs::JobState::Verifying(path));
|
||||
jobs::dispatch(job, p.get("background").and_then(Value::as_bool).unwrap_or(false), true)?;
|
||||
status(s, p)
|
||||
}
|
||||
fn output(p: &Value, content: String) -> Result<Value> {
|
||||
if let Some(path) = p.get("path").and_then(Value::as_str) {
|
||||
crate::io::write_atomic(std::path::Path::new(path), content.as_bytes(), false).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok(json!({"path":path,"bytes":content.len()}))
|
||||
} else {
|
||||
Ok(json!({"content":content}))
|
||||
}
|
||||
}
|
||||
fn post(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let job = jobs::job(s, id(p, "job")?)?;
|
||||
jobs::current(s, &job)?;
|
||||
let jobs::JobState::Verified(path, report) = job.state()? else {
|
||||
return Err(EngineError::Other("simulate before posting".into()));
|
||||
};
|
||||
if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete {
|
||||
return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into()));
|
||||
}
|
||||
if p.get("background").and_then(Value::as_bool).unwrap_or(false) {
|
||||
jobs::exports::start(job, "gcode".into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?;
|
||||
return status(s, p);
|
||||
}
|
||||
cam::nc::round_trip(&path, &job.input()?.setup, &job.input()?.tools).map_err(jobs::error)?;
|
||||
output(p, cam::nc::post(&path, &job.input()?.setup).map_err(jobs::error)?)
|
||||
}
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let job = jobs::job(s, id(p, "job")?)?;
|
||||
jobs::current(s, &job)?;
|
||||
let (path, report) = match job.state()? {
|
||||
jobs::JobState::Generated(p) => (p, None),
|
||||
jobs::JobState::Verified(p, r) => (p, Some(r)),
|
||||
_ => return Err(EngineError::Other("no generated result".into())),
|
||||
};
|
||||
let format = p.get("format").and_then(Value::as_str).unwrap_or("validation");
|
||||
if p.get("background").and_then(Value::as_bool).unwrap_or(false) {
|
||||
jobs::exports::start(job, format.into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?;
|
||||
return status(s, p);
|
||||
}
|
||||
output(p, crate::io::cam::artifact(job.input()?, &path, report.as_deref(), format)?)
|
||||
}
|
||||
|
||||
fn export_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let job = jobs::job(s, id(p, "job")?)?;
|
||||
jobs::current(s, &job)?;
|
||||
let (content, destination) = jobs::exports::take(&job)?;
|
||||
output(&destination.map_or_else(|| json!({}), |path| json!({"path":path})), content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn wait_export(s: &mut Session, job: u64) -> Value {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
let status = s.execute("cam.job.status", &json!({"job":job})).unwrap();
|
||||
if status["export"]["state"] != "running" {
|
||||
return status;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "export worker timeout");
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn background_exports_only_publish_complete_current_results_once() {
|
||||
let mut s = configured();
|
||||
let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap();
|
||||
s.execute("cam.simulate", &json!({"job":job})).unwrap();
|
||||
for format in ["toolpath", "validation", "setup_sheet", "stock", "gcode"] {
|
||||
let cmd = if format == "gcode" { "cam.post" } else { "cam.export" };
|
||||
let expected = s.execute(cmd, &json!({"job":job,"format":format})).unwrap()["content"].as_str().unwrap().to_owned();
|
||||
s.execute(cmd, &json!({"job":job,"format":format,"background":true})).unwrap();
|
||||
assert!(s.execute(cmd, &json!({"job":job,"format":format,"background":true})).is_err());
|
||||
let status = wait_export(&mut s, job);
|
||||
assert_eq!(status["export"]["state"], "ready", "{status}");
|
||||
let actual = s.execute("cam.export.result", &json!({"job":job})).unwrap()["content"].as_str().unwrap().to_owned();
|
||||
match format {
|
||||
"toolpath" | "validation" => {
|
||||
assert_eq!(serde_json::from_str::<Value>(&actual).unwrap(), serde_json::from_str::<Value>(&expected).unwrap())
|
||||
}
|
||||
"setup_sheet" => assert!(actual.contains("<h2>Operation settings and selected geometry</h2>")),
|
||||
_ => assert_eq!(actual, expected),
|
||||
}
|
||||
assert!(s.execute("cam.export.result", &json!({"job":job})).is_err());
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-cam-export-{}.nc", std::process::id()));
|
||||
s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap();
|
||||
assert_eq!(wait_export(&mut s, job)["export"]["state"], "ready");
|
||||
assert!(!path.exists());
|
||||
s.execute("cam.export.result", &json!({"job":job})).unwrap();
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("G21"));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap();
|
||||
wait_export(&mut s, job);
|
||||
let mut tool = s.doc.manufacturing.tools[0].clone();
|
||||
tool.diameter += 0.1;
|
||||
s.execute("cam.tool.put", &json!({"tool":tool})).unwrap();
|
||||
assert!(s.execute("cam.export.result", &json!({"job":job})).unwrap_err().to_string().contains("stale"));
|
||||
assert!(!path.exists());
|
||||
}
|
||||
#[test]
|
||||
fn cancelling_a_background_export_cannot_publish_or_restart_it() {
|
||||
let mut s = configured();
|
||||
let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap();
|
||||
s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).unwrap();
|
||||
s.execute("cam.job.cancel", &json!({"job":job})).unwrap();
|
||||
assert_eq!(wait_export(&mut s, job)["export"]["state"], "cancelled");
|
||||
assert!(s.execute("cam.export.result", &json!({"job":job})).is_err());
|
||||
assert!(s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).is_err());
|
||||
}
|
||||
fn configured() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":20,"height":4})).unwrap();
|
||||
let mut i = cam::corpus::fixture("face").unwrap();
|
||||
i.setup.models[0].body = s.model.state().bodies[0].name.clone();
|
||||
let bounds = s.model.state().bodies[0].mesh().bounds();
|
||||
i.setup.stock.min = bounds.min;
|
||||
i.setup.stock.max = bounds.max + crate::geom::Vec3::Z;
|
||||
i.operations[0].top = bounds.max.z + 1.0;
|
||||
i.operations[0].bottom = bounds.max.z;
|
||||
s.execute("cam.setup.put", &json!({"setup":i.setup})).unwrap();
|
||||
s.execute("cam.tool.put", &json!({"tool":i.tools[0]})).unwrap();
|
||||
s.execute("cam.operation.put", &json!({"operation":i.operations[0]})).unwrap();
|
||||
s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Plate","setup":1,"operations":[1]}})).unwrap();
|
||||
s
|
||||
}
|
||||
#[test]
|
||||
fn save_undo_and_stale_generation() {
|
||||
let mut s = configured();
|
||||
let saved = crate::io::write_design(&s.doc);
|
||||
assert_eq!(crate::io::read_design(&saved).unwrap().manufacturing, s.doc.manufacturing);
|
||||
let r = s.execute("cam.generate", &json!({"program":1})).unwrap();
|
||||
assert_eq!(r["state"], "generated");
|
||||
let id = r["job"].clone();
|
||||
s.execute("cam.operation.order", &json!({"program":1,"operations":[1]})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap();
|
||||
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
|
||||
assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], false);
|
||||
}
|
||||
#[test]
|
||||
fn operation_order_survives_reopen_undo_redo_and_controls_generated_sequence() {
|
||||
let mut s = configured();
|
||||
let mut second = s.doc.manufacturing.operations[0].clone();
|
||||
second.id = 2;
|
||||
second.name = "Second facing pass".into();
|
||||
second.feed += 25.;
|
||||
s.execute("cam.operation.put", &json!({"operation":second})).unwrap();
|
||||
s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Ordered milling","setup":1,"operations":[1,2]}})).unwrap();
|
||||
let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone();
|
||||
for invalid in [json!([1, 1]), json!([2]), json!([1, 3])] {
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("cam.operation.order", &json!({"program":1,"operations":invalid})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
s.execute("cam.operation.order", &json!({"program":1,"operations":[2,1]})).unwrap();
|
||||
assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.manufacturing.programs[0].operations, vec![1, 2]);
|
||||
assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], false);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.manufacturing.programs[0].operations, vec![2, 1]);
|
||||
assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true);
|
||||
let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap();
|
||||
let mut reopened = Session::new(doc);
|
||||
let id = reopened.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap();
|
||||
let state = jobs::job(&reopened, id).unwrap().state().unwrap();
|
||||
let path = match state {
|
||||
jobs::JobState::Generated(path) => Some(path),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap();
|
||||
let mut sequence = path.blocks.iter().map(|b| b.operation).collect::<Vec<_>>();
|
||||
sequence.dedup();
|
||||
assert_eq!(sequence, vec![2, 1]);
|
||||
assert_eq!(reopened.doc.manufacturing.programs[0].operations, vec![2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referenced_tool_delete_rolls_back() {
|
||||
let mut s = configured();
|
||||
let d = s.doc.clone();
|
||||
assert!(s.execute("cam.delete", &json!({"kind":"tool","id":1})).is_err());
|
||||
assert_eq!(s.doc, d);
|
||||
}
|
||||
#[test]
|
||||
fn missing_body_is_not_guessed() {
|
||||
let mut s = configured();
|
||||
s.doc_mut().manufacturing.setups[0].models[0].body = "missing".into();
|
||||
assert!(s.execute("cam.generate", &json!({"program":1})).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn generate_simulate_post_and_reopen() {
|
||||
let mut s = configured();
|
||||
let r = s.execute("cam.generate", &json!({"program":1})).unwrap();
|
||||
let id = r["job"].clone();
|
||||
let v = s.execute("cam.simulate", &json!({"job":id})).unwrap();
|
||||
assert_eq!(v["verification"]["status"], "pass", "{v}");
|
||||
let posted = s.execute("cam.post", &json!({"job":id})).unwrap();
|
||||
assert!(posted["content"].as_str().unwrap().contains("G21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\n"));
|
||||
for format in ["toolpath", "setup_sheet", "validation", "stock"] {
|
||||
assert!(!s.execute("cam.export", &json!({"job":id,"format":format})).unwrap()["content"].as_str().unwrap().is_empty());
|
||||
}
|
||||
let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap();
|
||||
let mut reopened = Session::new(doc);
|
||||
assert!(reopened.execute("cam.post", &json!({"job":id})).is_err());
|
||||
assert_eq!(reopened.execute("cam.generate", &json!({"program":1})).unwrap()["state"], "generated");
|
||||
s.execute("cam.job.cancel", &json!({"job":id})).unwrap();
|
||||
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_document_has_no_manufacturing() {
|
||||
let mut value: Value = serde_json::from_slice(&crate::io::write_design(&solidcraft_doc::Document::default())).unwrap();
|
||||
value["format"] = json!("solidcraft/11");
|
||||
value.as_object_mut().unwrap().remove("manufacturing");
|
||||
let migrated = crate::io::read_design(value.to_string().as_bytes()).unwrap();
|
||||
assert!(migrated.manufacturing.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn inconclusive_dimensions_cannot_be_posted() {
|
||||
let mut s = configured();
|
||||
let r = s.execute("cam.generate", &json!({"program":1})).unwrap();
|
||||
let id = r["job"].as_u64().unwrap();
|
||||
s.execute("cam.simulate", &json!({"job":id})).unwrap();
|
||||
let job = jobs::job(&s, id).unwrap();
|
||||
if let jobs::JobState::Verified(path, report) = job.state().unwrap() {
|
||||
let mut report = (*report).clone();
|
||||
report.finished_dimensions = cam::verify::Status::Inconclusive;
|
||||
job.set(jobs::JobState::Verified(path, std::sync::Arc::new(report)));
|
||||
}
|
||||
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn drilled_cad_body_generates_verifies_posts_and_reopens() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap();
|
||||
s.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118})).unwrap();
|
||||
let mut input = cam::corpus::fixture("drill").unwrap();
|
||||
input.setup.models[0].body = s.model.state().bodies[0].name.clone();
|
||||
input.operations[0].geometry = cam::Strategy::Drill { holes: vec![[10., 10.]], peck: Some(0.3), full_diameter: true };
|
||||
s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap();
|
||||
let body = s.model.state().bodies[0].clone();
|
||||
let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap();
|
||||
let selected =
|
||||
s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face}]})).unwrap();
|
||||
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
|
||||
assert!((selected["diameters"][0].as_f64().unwrap() - 1.).abs() < 1e-8);
|
||||
let center: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap();
|
||||
assert!((center[0][0] - 10.).abs() < 1e-8 && (center[0][1] - 10.).abs() < 1e-8);
|
||||
s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap();
|
||||
s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap();
|
||||
s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Drilled plate","setup":1,"operations":[1]}})).unwrap();
|
||||
let generated = s.execute("cam.generate", &json!({"program":1})).unwrap();
|
||||
let id = generated["job"].clone();
|
||||
let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap();
|
||||
assert_eq!(verified["verification"]["status"], "pass", "{verified}");
|
||||
assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{verified}");
|
||||
assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30"));
|
||||
let saved = crate::io::write_design(&s.doc);
|
||||
let mut reopened = Session::new(crate::io::read_design(&saved).unwrap());
|
||||
let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap();
|
||||
let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap();
|
||||
assert_eq!(verified["verification"]["finished_dimensions"], "pass");
|
||||
}
|
||||
#[test]
|
||||
fn milled_cad_pocket_and_contour_generate_verify_post_and_reopen() {
|
||||
let mut model = Session::default();
|
||||
model.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap();
|
||||
model.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":16,"depth":1,"type":"simple"})).unwrap();
|
||||
model.execute("sketch.create", &json!({"plane":"XY","name":"Pocket boundary"})).unwrap();
|
||||
model.execute("sketch.circle.center", &json!({"center":[10,10],"radius":8,"id":"wall"})).unwrap();
|
||||
model.execute("sketch.finish", &json!({})).unwrap();
|
||||
let sketch = model.model.state().sketches.last().unwrap().feature;
|
||||
for name in ["contour", "pocket"] {
|
||||
let mut s = Session::new((*model.doc).clone());
|
||||
let mut input = cam::corpus::fixture(name).unwrap();
|
||||
input.setup.models[0].body = s.model.state().bodies[0].name.clone();
|
||||
input.operations[0].ramp_angle = Some(5_f64.to_radians());
|
||||
s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap();
|
||||
let selected = s.execute("cam.boundary.from_profile", &json!({"setup":1,"sketch":sketch,"profile":0})).unwrap();
|
||||
input.operations[0].source = Some(serde_json::from_value(selected["source"].clone()).unwrap());
|
||||
match &mut input.operations[0].geometry {
|
||||
cam::Strategy::Contour { boundary, .. } | cam::Strategy::Pocket { boundary } => {
|
||||
*boundary = serde_json::from_value(selected["boundary"].clone()).unwrap()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap();
|
||||
s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap();
|
||||
s.execute("cam.program.put", &json!({"program":{"id":1,"name":name,"setup":1,"operations":[1]}})).unwrap();
|
||||
let generated = s.execute("cam.generate", &json!({"program":1})).unwrap();
|
||||
let id = generated["job"].clone();
|
||||
let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap();
|
||||
assert_eq!(verified["verification"]["status"], "pass", "{name}: {verified}");
|
||||
assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}");
|
||||
assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30"));
|
||||
let mut reopened = Session::new(crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap());
|
||||
let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap();
|
||||
let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap();
|
||||
assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn diagnostic_inspection_identifies_posted_operation_and_rejects_stale_results() {
|
||||
let mut s = configured();
|
||||
assert!(s.execute("cam.inspect", &json!({"finding":0})).is_err());
|
||||
let origin = crate::geom::Vec3::new(100., 200., 0.);
|
||||
let mut setup = s.doc.manufacturing.setups[0].clone();
|
||||
setup.frame.origin = origin;
|
||||
setup.stock.min -= origin;
|
||||
setup.stock.max -= origin;
|
||||
s.execute("cam.setup.put", &json!({"setup":setup})).unwrap();
|
||||
let mut operation = s.doc.manufacturing.operations[0].clone();
|
||||
operation.bottom = 3.;
|
||||
s.execute("cam.operation.put", &json!({"operation":operation})).unwrap();
|
||||
let id = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone();
|
||||
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":0})).is_err());
|
||||
let report = s.execute("cam.simulate", &json!({"job":id})).unwrap();
|
||||
assert_eq!(report["verification"]["status"], "fail");
|
||||
let index = report["verification"]["findings"].as_array().unwrap().iter().position(|f| f["source"].is_object()).unwrap();
|
||||
let inspected = s.execute("cam.inspect", &json!({"job":id,"finding":index})).unwrap();
|
||||
let diagnostic = &inspected["diagnostic"];
|
||||
assert_eq!(diagnostic["operation"]["id"], 1);
|
||||
assert_eq!(diagnostic["playback"]["completed"], diagnostic["finding"]["source"]["block"]);
|
||||
assert!(diagnostic["gcode_line"].as_str().unwrap().starts_with("G1"));
|
||||
let at: crate::geom::Vec3 = serde_json::from_value(diagnostic["finding"]["at"].clone()).unwrap();
|
||||
assert_eq!(diagnostic["world"], json!(at + origin));
|
||||
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":-1})).is_err());
|
||||
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":999999})).is_err());
|
||||
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
|
||||
let exported = s.execute("cam.export", &json!({"job":id,"format":"validation"})).unwrap();
|
||||
assert!(exported["content"].as_str().unwrap().contains("source"));
|
||||
s.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap();
|
||||
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":index})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cam_holes_tests.rs"]
|
||||
mod holes_tests;
|
||||
@@ -0,0 +1,201 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use crate::geom::Vec3;
|
||||
fn run(s: &mut Session, cmd: &str, value: Value) -> Value {
|
||||
s.execute(cmd, &value).unwrap()
|
||||
}
|
||||
fn linked_profiles() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[4,5],"radius":0.5,"id":"a"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[12,15],"radius":0.5,"id":"b"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let state = s.model.state();
|
||||
let sketch = state.sketches.last().unwrap();
|
||||
let id = sketch.feature;
|
||||
let a = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"a".to_string())).unwrap();
|
||||
let b = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"b".to_string())).unwrap();
|
||||
let mut input = cam::corpus::fixture("drill").unwrap();
|
||||
input.setup.models[0].body = state.bodies[0].name.clone();
|
||||
input.setup.frame.origin = Vec3::new(100., -50., 0.);
|
||||
input.setup.stock.min -= input.setup.frame.origin;
|
||||
input.setup.stock.max -= input.setup.frame.origin;
|
||||
run(&mut s, "cam.setup.put", json!({"setup":input.setup}));
|
||||
let selected = run(
|
||||
&mut s,
|
||||
"cam.holes.from_selection",
|
||||
json!({"setup":1,"selections":[
|
||||
{"kind":"profile","sketch":id,"index":b},{"kind":"profile","sketch":id,"index":a}
|
||||
]}),
|
||||
);
|
||||
let holes: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap();
|
||||
assert_eq!(holes, vec![[-88., 65.], [-96., 55.]]);
|
||||
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
|
||||
input.operations[0].geometry = cam::Strategy::Drill { holes, peck: Some(0.3), full_diameter: false };
|
||||
run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]}));
|
||||
run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]}));
|
||||
run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Selected holes","setup":1,"operations":[1]}}));
|
||||
(s, id)
|
||||
}
|
||||
fn centers(s: &mut Session) -> (u64, Vec<[f64; 2]>) {
|
||||
let generated = run(s, "cam.generate", json!({"program":1}));
|
||||
assert_eq!(generated["state"], "generated", "{generated}");
|
||||
let id = generated["job"].as_u64().unwrap();
|
||||
let job = jobs::job(s, id).unwrap();
|
||||
let op = &job.input().unwrap().operations[0];
|
||||
let holes = match &op.geometry {
|
||||
cam::Strategy::Drill { holes, .. } => holes.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
(id, holes)
|
||||
}
|
||||
#[test]
|
||||
fn hole_selection_follows_profile_edits_frames_undo_and_reopen() {
|
||||
let (mut s, sketch) = linked_profiles();
|
||||
let (old, before) = centers(&mut s);
|
||||
let saved = crate::io::write_design(&s.doc);
|
||||
let mut reopened = Session::new(crate::io::read_design(&saved).unwrap());
|
||||
assert_eq!(centers(&mut reopened).1, before);
|
||||
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
|
||||
run(&mut s, "sketch.move", json!({"entities":["a"],"translate":[2,3]}));
|
||||
assert_eq!(run(&mut s, "cam.job.status", json!({"job":old}))["stale"], true);
|
||||
assert!(s.execute("cam.post", &json!({"job":old})).is_err());
|
||||
assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(centers(&mut s).1, before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]);
|
||||
// Unassociated drilling preserves manually authored coordinates on reopen.
|
||||
let mut legacy: Value = serde_json::from_slice(&crate::io::write_design(&s.doc)).unwrap();
|
||||
legacy["manufacturing"]["operations"][0].as_object_mut().unwrap().remove("hole_sources");
|
||||
let doc = crate::io::read_design(legacy.to_string().as_bytes()).unwrap();
|
||||
assert!(doc.manufacturing.operations[0].hole_sources.is_empty());
|
||||
assert_eq!(doc.manufacturing.operations[0].geometry, s.doc.manufacturing.operations[0].geometry);
|
||||
}
|
||||
#[test]
|
||||
fn hole_selection_rejects_missing_duplicate_tilted_and_wrong_size_sources() {
|
||||
let (mut s, sketch) = linked_profiles();
|
||||
let pick = json!({"kind":"profile","sketch":sketch,"index":0});
|
||||
for picks in [json!([]), json!([pick.clone(), pick.clone()]), json!([{"kind":"profile","sketch":sketch,"index":999}])] {
|
||||
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":picks})).is_err());
|
||||
}
|
||||
let excessive = vec![pick.clone(); cam::MAX_ITEMS + 1];
|
||||
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":excessive})).is_err());
|
||||
let mut invalid = s.doc.manufacturing.operations[0].clone();
|
||||
invalid.hole_sources.pop();
|
||||
assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err());
|
||||
let mut invalid = s.doc.manufacturing.operations[0].clone();
|
||||
invalid.hole_sources[1] = invalid.hole_sources[0].clone();
|
||||
assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err());
|
||||
let mut setup = s.doc.manufacturing.setups[0].clone();
|
||||
setup.frame = cam::Frame { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z, z: -Vec3::Y };
|
||||
run(&mut s, "cam.setup.put", json!({"setup":setup}));
|
||||
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[pick]})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let mut tool = s.doc.manufacturing.tools[0].clone();
|
||||
tool.diameter = 0.8;
|
||||
run(&mut s, "cam.tool.put", json!({"tool":tool}));
|
||||
let failed = run(&mut s, "cam.generate", json!({"program":1}));
|
||||
assert_eq!(failed["state"], "failed");
|
||||
assert!(failed["error"].as_str().unwrap().contains("diameter"));
|
||||
assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let mut op = s.doc.manufacturing.operations[0].clone();
|
||||
if let cam::HoleSource::Profile { profile } = &mut op.hole_sources[0] {
|
||||
profile.curves = vec!["missing".into()];
|
||||
}
|
||||
run(&mut s, "cam.operation.put", json!({"operation":op}));
|
||||
assert!(s.execute("cam.generate", &json!({"program":1})).unwrap_err().to_string().contains("reselect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_selection_keeps_exact_occurrence_and_rejects_lost_faces() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Drilled part"}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6}));
|
||||
run(&mut s, "solid.hole", json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":component["component"],"translate":[30,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let body = s.model.state().bodies[0].clone();
|
||||
let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap();
|
||||
let mut input = cam::corpus::fixture("drill").unwrap();
|
||||
input.setup.models = vec![cam::BodyRef { body: body.name.clone(), occurrence: vec![second] }];
|
||||
input.setup.frame.origin = Vec3::new(20., 0., 0.);
|
||||
input.setup.stock.min.x += 10.;
|
||||
input.setup.stock.max.x += 20.;
|
||||
run(&mut s, "cam.setup.put", json!({"setup":input.setup}));
|
||||
let selected = run(
|
||||
&mut s,
|
||||
"cam.holes.from_selection",
|
||||
json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face,"occurrence":[second]}]}),
|
||||
);
|
||||
// Viewport labels resolve through the same setup occurrence as explicit command picks.
|
||||
let state = s.model.state();
|
||||
let placed = s.placed_document();
|
||||
let label = placed.world_body_name(&state, &body.name, placed.model_body_component(&body), &[second]);
|
||||
let from_viewport = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":[{"kind":"face","body":label,"index":face}]}));
|
||||
assert_eq!(from_viewport, selected);
|
||||
assert!(crate::kernel::hole_axis(&body.body, face, f64::NAN).is_err());
|
||||
assert!(crate::kernel::hole_axis(&body.body, usize::MAX, 0.0005).is_err());
|
||||
let plane = (0..body.body.face_count())
|
||||
.find(|i| matches!(crate::kernel::face_surface(&body.body, *i), Some(crate::kernel::FaceSurface::Plane { .. })))
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.execute(
|
||||
"cam.holes.from_selection",
|
||||
&json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":plane,"occurrence":[second]}]})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
|
||||
run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]}));
|
||||
run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]}));
|
||||
run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Placed holes","setup":1,"operations":[1]}}));
|
||||
let (job, before) = centers(&mut s);
|
||||
assert!((before[0][0] - 20.).abs() < 1e-8 && (before[0][1] - 10.).abs() < 1e-8);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
assert_eq!(run(&mut s, "cam.job.status", json!({"job":job}))["stale"], true);
|
||||
let moved = centers(&mut s).1;
|
||||
assert!((moved[0][0] - 25.).abs() < 1e-8);
|
||||
let mut op = s.doc.manufacturing.operations[0].clone();
|
||||
if let cam::HoleSource::Face { name, .. } = &mut op.hole_sources[0] {
|
||||
*name = "missing:face".into();
|
||||
}
|
||||
run(&mut s, "cam.operation.put", json!({"operation":op}));
|
||||
let failed = run(&mut s, "cam.generate", json!({"program":1}));
|
||||
assert_eq!(failed["state"], "failed");
|
||||
assert!(failed["error"].as_str().unwrap().contains("reselect"));
|
||||
assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_selection_combines_seam_faces_but_rejects_exterior_cylinders() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let body = s.model.state().bodies[0].clone();
|
||||
let mut setup = cam::corpus::fixture("drill").unwrap().setup;
|
||||
setup.models[0].body = body.name.clone();
|
||||
run(&mut s, "cam.setup.put", json!({"setup":setup}));
|
||||
let mut picks = vec![];
|
||||
for index in 0..body.body.face_count() {
|
||||
if let Ok((origin, _, _)) = crate::kernel::hole_axis(&body.body, index, 0.0005) {
|
||||
if (origin.x - 68.).abs() < 1e-8 {
|
||||
picks.push(json!({"kind":"face","body":body.name,"index":index}));
|
||||
}
|
||||
} else if matches!(crate::kernel::face_surface(&body.body, index), Some(crate::kernel::FaceSurface::Cylinder { .. })) {
|
||||
assert!(
|
||||
s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":index}]})).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(picks.len() >= 2, "the sample must exercise multiple seam faces on one drilled wall");
|
||||
let mut duplicate = picks.clone();
|
||||
duplicate.push(picks[1].clone());
|
||||
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":duplicate})).is_err());
|
||||
let result = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":picks}));
|
||||
let centers: Vec<[f64; 2]> = serde_json::from_value(result["holes"].clone()).unwrap();
|
||||
assert_eq!(centers.len(), 1);
|
||||
assert!((centers[0][0] - 68.).abs() < 1e-8 && (centers[0][1] - 25.).abs() < 1e-8);
|
||||
assert_eq!(result["sources"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ macro_rules! planned {
|
||||
}
|
||||
|
||||
mod browser;
|
||||
mod cam;
|
||||
mod clipboard;
|
||||
mod compare;
|
||||
mod component;
|
||||
@@ -252,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(component_link::COMMANDS.iter());
|
||||
v.extend(joints::COMMANDS.iter());
|
||||
v.extend(motion::COMMANDS.iter());
|
||||
v.extend(cam::COMMANDS.iter());
|
||||
v.extend(config::COMMANDS.iter());
|
||||
v.extend(parts::COMMANDS.iter());
|
||||
v.extend(browser::COMMANDS.iter());
|
||||
|
||||
@@ -14,6 +14,8 @@ pub mod crash;
|
||||
pub mod frames;
|
||||
pub mod fuzz;
|
||||
pub mod licences;
|
||||
pub mod manufacturing;
|
||||
pub use solidcraft_cam as cam;
|
||||
pub mod occurrences;
|
||||
pub mod params;
|
||||
pub mod product_scene;
|
||||
@@ -252,6 +254,8 @@ pub struct Session {
|
||||
/// Host-owned viewport metadata, never saved or included in history.
|
||||
render_viewport: Option<Arc<render::ExecutorDescriptor>>,
|
||||
render_jobs: render_jobs::Jobs,
|
||||
/// Derived, immutable CAM jobs; not persisted or copied into undo snapshots.
|
||||
pub cam_jobs: manufacturing::Jobs,
|
||||
/// The visibility the design file holds (what `is_dirty` compares with).
|
||||
saved_visibility: Visibility,
|
||||
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
|
||||
@@ -377,6 +381,7 @@ impl Session {
|
||||
camera: None,
|
||||
render_viewport: None,
|
||||
render_jobs: Default::default(),
|
||||
cam_jobs: Default::default(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
crash_journal: false,
|
||||
@@ -844,6 +849,7 @@ impl Session {
|
||||
camera: self.camera,
|
||||
render_viewport: self.render_viewport.clone(),
|
||||
render_jobs: self.render_jobs.scratch(),
|
||||
cam_jobs: Default::default(),
|
||||
saved_visibility: self.saved_visibility.clone(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
//! Immutable CAM jobs, independent of the desktop UI and document undo state.
|
||||
use crate::{EngineError, Result, Session, cam};
|
||||
pub mod exports;
|
||||
pub(crate) mod holes;
|
||||
mod snapshot;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Jobs {
|
||||
pub jobs: BTreeMap<u64, Arc<Job>>,
|
||||
pub next: u64,
|
||||
}
|
||||
pub struct Job {
|
||||
pub source_doc: Arc<solidcraft_doc::Document>,
|
||||
pub source_moves: BTreeMap<u64, solidcraft_doc::Mat>,
|
||||
input: Arc<OnceLock<cam::JobInput>>,
|
||||
snapshot: Mutex<Option<snapshot::Snapshot>>,
|
||||
stock: Arc<Mutex<Option<std::result::Result<Arc<crate::geom::Mesh>, String>>>>,
|
||||
pub(crate) export: Arc<Mutex<exports::State>>,
|
||||
pub cancelled: Arc<AtomicBool>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pending: Mutex<Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<JobState>>>>>>,
|
||||
pub progress: Arc<AtomicUsize>,
|
||||
pub result: Arc<Mutex<JobState>>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub enum JobState {
|
||||
Preparing,
|
||||
Running,
|
||||
Generated(Arc<cam::Toolpath>),
|
||||
Verifying(Arc<cam::Toolpath>),
|
||||
Verified(Arc<cam::Toolpath>, Arc<cam::verify::Report>),
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
impl Job {
|
||||
/// Display geometry is a derived cache. It never substitutes for a verification report.
|
||||
pub fn stock_mesh(&self) -> Result<Option<Arc<crate::geom::Mesh>>> {
|
||||
if !matches!(self.state()?, JobState::Verified(..)) {
|
||||
return Ok(None);
|
||||
}
|
||||
match self.stock.lock().map_err(|_| EngineError::Other("CAM stock display unavailable".into()))?.as_ref() {
|
||||
Some(Ok(mesh)) => Ok(Some(mesh.clone())),
|
||||
Some(Err(e)) => Err(EngineError::Other(e.clone())),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
pub fn input(&self) -> Result<&cam::JobInput> {
|
||||
self.input.get().ok_or_else(|| EngineError::Other("CAM input is still being prepared".into()))
|
||||
}
|
||||
pub fn state(&self) -> Result<JobState> {
|
||||
self.result.lock().map(|s| s.clone()).map_err(|_| EngineError::Other("CAM worker state unavailable".into()))
|
||||
}
|
||||
pub fn set(&self, state: JobState) {
|
||||
if let Ok(mut s) = self.result.lock() {
|
||||
*s = if self.cancelled.load(Ordering::Relaxed) { JobState::Cancelled } else { state };
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn error(e: cam::Error) -> EngineError {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
pub fn input(s: &Session, program: u64) -> Result<cam::JobInput> {
|
||||
snapshot::ready(snapshot::Snapshot::capture(s, program)?.build(&|| false, &|_| {}, &mut || std::future::ready(())))
|
||||
}
|
||||
pub fn profile_boundary(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result<cam::Boundary> {
|
||||
snapshot::ready(snapshot::Profile::capture(s, setup, source, tolerance)?.sample(&|| false, &mut || std::future::ready(())))
|
||||
}
|
||||
pub fn start(s: &mut Session, program: u64, background: bool) -> Result<u64> {
|
||||
if s.cam_jobs.jobs.len() >= 16 {
|
||||
let removable = s.cam_jobs.jobs.iter().find_map(|(id, j)| match j.state() {
|
||||
Ok(JobState::Preparing | JobState::Running | JobState::Verifying(_)) => None,
|
||||
Ok(JobState::Generated(_) | JobState::Verified(..)) if exports::busy(j).unwrap_or(true) => None,
|
||||
_ => Some(*id),
|
||||
});
|
||||
if let Some(id) = removable {
|
||||
s.cam_jobs.jobs.remove(&id);
|
||||
} else {
|
||||
return Err(EngineError::Other("too many running CAM jobs".into()));
|
||||
}
|
||||
}
|
||||
let snapshot = snapshot::Snapshot::capture(s, program)?;
|
||||
let job = Arc::new(Job {
|
||||
source_doc: s.doc.clone(),
|
||||
source_moves: s.pending_moves.clone(),
|
||||
input: Arc::new(OnceLock::new()),
|
||||
snapshot: Mutex::new(Some(snapshot)),
|
||||
stock: Arc::new(Mutex::new(None)),
|
||||
export: Arc::new(Mutex::new(exports::State::Idle)),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pending: Mutex::new(None),
|
||||
progress: Arc::new(AtomicUsize::new(0)),
|
||||
result: Arc::new(Mutex::new(JobState::Preparing)),
|
||||
});
|
||||
s.cam_jobs.next = s.cam_jobs.next.checked_add(1).ok_or_else(|| EngineError::Other("CAM job ID exhausted".into()))?;
|
||||
let id = s.cam_jobs.next;
|
||||
s.cam_jobs.jobs.insert(id, job.clone());
|
||||
dispatch(job, background, false)?;
|
||||
Ok(id)
|
||||
}
|
||||
pub fn dispatch(job: Arc<Job>, background: bool, verify: bool) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if background {
|
||||
let failed = job.clone();
|
||||
if let Err(e) = std::thread::Builder::new().name("cam".into()).spawn(move || run(job, verify)) {
|
||||
failed.set(JobState::Failed(e.to_string()));
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if background {
|
||||
let future = task(&job, verify)?;
|
||||
*job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future));
|
||||
return Ok(());
|
||||
}
|
||||
run(job, verify);
|
||||
Ok(())
|
||||
}
|
||||
// Capture owned task components instead of Job itself: a browser's pending
|
||||
// future must not keep its owning Job alive through an Arc cycle.
|
||||
fn task(job: &Job, verify: bool) -> Result<impl std::future::Future<Output = Result<JobState>> + use<>> {
|
||||
let input = job.input.clone();
|
||||
let cancelled = job.cancelled.clone();
|
||||
let progress = job.progress.clone();
|
||||
let state = job.result.clone();
|
||||
let stock = job.stock.clone();
|
||||
let path = if verify {
|
||||
match job.state()? {
|
||||
JobState::Verifying(path) => Some(path),
|
||||
_ => return Err(EngineError::Other("generate a path before simulation".into())),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let snapshot = if verify { None } else { job.snapshot.lock().map_err(|_| EngineError::Other("CAM snapshot unavailable".into()))?.take() };
|
||||
Ok(async move {
|
||||
YieldOnce::default().await;
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
if let Some(path) = path {
|
||||
let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?;
|
||||
let parsed = cam::nc::round_trip_cooperative(&path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default)
|
||||
.await
|
||||
.map_err(error)?;
|
||||
let report =
|
||||
cam::verify::simulate_cooperative(input, &parsed, Default::default(), || cancelled.load(Ordering::Relaxed), YieldOnce::default)
|
||||
.await
|
||||
.map_err(error)?;
|
||||
YieldOnce::default().await;
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
let display = stock_display(&report, input.setup.frame, input.setup.stock.min.z, &cancelled).await;
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
if let Ok(mut cache) = stock.lock() {
|
||||
*cache = Some(display.map(Arc::new).map_err(|e| e.to_string()));
|
||||
}
|
||||
Ok(JobState::Verified(path, Arc::new(report)))
|
||||
} else {
|
||||
let snapshot = snapshot.ok_or_else(|| EngineError::Other("CAM snapshot already consumed".into()))?;
|
||||
let prepared = snapshot
|
||||
.build(
|
||||
&|| cancelled.load(Ordering::Relaxed),
|
||||
&|n| {
|
||||
progress.store(n, Ordering::Relaxed);
|
||||
},
|
||||
&mut YieldOnce::default,
|
||||
)
|
||||
.await?;
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
input.set(prepared).map_err(|_| EngineError::Other("CAM snapshot already prepared".into()))?;
|
||||
if let Ok(mut state) = state.lock() {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
*state = JobState::Running;
|
||||
}
|
||||
let mut generator =
|
||||
cam::generate::Generator::new(input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?.clone())
|
||||
.map_err(error)?;
|
||||
loop {
|
||||
YieldOnce::default().await;
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
let done = generator.step().map_err(error)?;
|
||||
progress.store(20 + (generator.progress() * 80.) as usize, Ordering::Relaxed);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(JobState::Generated(Arc::new(generator.finish().map_err(error)?)))
|
||||
}
|
||||
})
|
||||
}
|
||||
async fn stock_display(report: &cam::verify::Report, frame: cam::Frame, bottom: f64, cancelled: &AtomicBool) -> Result<crate::geom::Mesh> {
|
||||
let triangles =
|
||||
cam::verify::stock_triangles_cooperative(report, bottom, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error)?;
|
||||
let mut mesh = crate::geom::Mesh::default();
|
||||
for (i, triangle) in triangles.into_iter().enumerate() {
|
||||
if i.is_multiple_of(256) {
|
||||
YieldOnce::default().await;
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err(EngineError::Other("CAM stock display cancelled".into()));
|
||||
}
|
||||
}
|
||||
let triangle = triangle.map(|p| frame.world(p));
|
||||
let normal = (triangle[1] - triangle[0])
|
||||
.cross(triangle[2] - triangle[0])
|
||||
.normalized()
|
||||
.ok_or_else(|| EngineError::Other("invalid stock display triangle".into()))?;
|
||||
let base = mesh.positions.len() as u32;
|
||||
mesh.positions.extend(triangle);
|
||||
mesh.normals.extend([normal; 3]);
|
||||
mesh.triangles.push([base, base + 1, base + 2]);
|
||||
}
|
||||
Ok(mesh)
|
||||
}
|
||||
fn run(job: Arc<Job>, verify: bool) {
|
||||
match task(&job, verify) {
|
||||
Ok(future) => run_future(job, future, true),
|
||||
Err(e) => job.set(JobState::Failed(e.to_string())),
|
||||
}
|
||||
}
|
||||
fn run_future(job: Arc<Job>, future: impl std::future::Future<Output = Result<JobState>>, publish_state: bool) {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<JobState> {
|
||||
let mut future = std::pin::pin!(future);
|
||||
loop {
|
||||
if job.cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
if let std::task::Poll::Ready(result) =
|
||||
std::future::Future::poll(future.as_mut(), &mut std::task::Context::from_waker(std::task::Waker::noop()))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}));
|
||||
let state = match outcome {
|
||||
Ok(Ok(state)) => state,
|
||||
Ok(Err(e)) => JobState::Failed(e.to_string()),
|
||||
Err(_) => JobState::Failed("CAM worker failed; no result published".into()),
|
||||
};
|
||||
if publish_state {
|
||||
job.set(state);
|
||||
} else if let JobState::Failed(error) = state
|
||||
&& let Ok(mut export) = job.export.lock()
|
||||
{
|
||||
*export = exports::State::Failed(error);
|
||||
}
|
||||
}
|
||||
pub fn job(s: &Session, id: u64) -> Result<Arc<Job>> {
|
||||
s.cam_jobs.jobs.get(&id).cloned().ok_or_else(|| EngineError::Other("CAM job missing; regenerate after reopening".into()))
|
||||
}
|
||||
pub fn current(s: &Session, job: &Job) -> Result<()> {
|
||||
if (!Arc::ptr_eq(&s.doc, &job.source_doc) && *s.doc != *job.source_doc) || s.pending_moves != job.source_moves {
|
||||
Err(EngineError::Other("CAM result is stale; regenerate after editing the design, tools or setup".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume one browser job checkpoint. Returns true while more work is pending.
|
||||
/// The host controls its frame budget; status queries can still advance one step.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn advance(job: &Job) -> bool {
|
||||
let Ok(mut pending) = job.pending.lock() else { return false };
|
||||
if job.cancelled.load(Ordering::Relaxed) {
|
||||
*pending = None;
|
||||
job.set(JobState::Cancelled);
|
||||
return false;
|
||||
}
|
||||
let Some(future) = pending.as_mut() else { return false };
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
|
||||
}));
|
||||
let state = match result {
|
||||
Ok(std::task::Poll::Pending) => return true,
|
||||
Ok(std::task::Poll::Ready(Ok(state))) => state,
|
||||
Ok(std::task::Poll::Ready(Err(error))) => JobState::Failed(error.to_string()),
|
||||
Err(_) => JobState::Failed("CAM worker failed; no result published".into()),
|
||||
};
|
||||
*pending = None;
|
||||
job.progress.store(100, Ordering::Relaxed);
|
||||
job.set(state);
|
||||
false
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct YieldOnce(bool);
|
||||
impl std::future::Future for YieldOnce {
|
||||
type Output = ();
|
||||
fn poll(mut self: std::pin::Pin<&mut Self>, _: &mut std::task::Context<'_>) -> std::task::Poll<()> {
|
||||
if self.0 {
|
||||
std::task::Poll::Ready(())
|
||||
} else {
|
||||
self.0 = true;
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
fn captured() -> (Session, Arc<Job>) {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":20,"height":5})).unwrap();
|
||||
let mut input = cam::corpus::fixture("face").unwrap();
|
||||
input.setup.models[0].body = s.model.state().bodies[0].name.clone();
|
||||
let m = &mut s.doc_mut().manufacturing;
|
||||
m.setups.push(input.setup);
|
||||
m.tools = input.tools;
|
||||
m.operations = input.operations;
|
||||
m.programs.push(cam::Program { id: 1, name: "Test".into(), setup: 1, operations: vec![1] });
|
||||
let job = Arc::new(Job {
|
||||
source_doc: s.doc.clone(),
|
||||
source_moves: s.pending_moves.clone(),
|
||||
input: Arc::new(OnceLock::new()),
|
||||
snapshot: Mutex::new(Some(snapshot::Snapshot::capture(&s, 1).unwrap())),
|
||||
stock: Arc::new(Mutex::new(None)),
|
||||
export: Arc::new(Mutex::new(exports::State::Idle)),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pending: Mutex::new(None),
|
||||
progress: Arc::new(AtomicUsize::new(0)),
|
||||
result: Arc::new(Mutex::new(JobState::Preparing)),
|
||||
});
|
||||
(s, job)
|
||||
}
|
||||
#[test]
|
||||
fn completed_export_worker_cannot_restore_state_after_result_collection() {
|
||||
let (_, job) = captured();
|
||||
let ready = Arc::new(std::sync::Barrier::new(2));
|
||||
let collected = Arc::new(std::sync::Barrier::new(2));
|
||||
let worker_job = job.clone();
|
||||
let worker_ready = ready.clone();
|
||||
let worker_collected = collected.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let result = worker_job.export.clone();
|
||||
run_future(
|
||||
worker_job,
|
||||
async move {
|
||||
*result.lock().unwrap() = exports::State::Ready { content: "complete".into(), destination: None };
|
||||
worker_ready.wait();
|
||||
worker_collected.wait();
|
||||
Ok(JobState::Preparing)
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
ready.wait();
|
||||
assert_eq!(exports::take(&job).unwrap().0, "complete");
|
||||
job.set(JobState::Running);
|
||||
collected.wait();
|
||||
worker.join().unwrap();
|
||||
assert!(matches!(job.state().unwrap(), JobState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_is_immutable_and_preparation_is_deferred() {
|
||||
let (mut s, job) = captured();
|
||||
let expected = input(&s, 1).unwrap();
|
||||
let mut future = std::pin::pin!(task(&job, false).unwrap());
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
assert!(future.as_mut().poll(&mut cx).is_pending());
|
||||
assert!(job.input().is_err());
|
||||
s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap();
|
||||
s.doc_mut().manufacturing.operations[0].feed = 900.;
|
||||
let mut polls = 0;
|
||||
let mut previous = 0;
|
||||
let state = loop {
|
||||
match future.as_mut().poll(&mut cx) {
|
||||
Poll::Pending => {
|
||||
polls += 1;
|
||||
let progress = job.progress.load(Ordering::Relaxed);
|
||||
assert!(progress >= previous);
|
||||
previous = progress;
|
||||
}
|
||||
Poll::Ready(result) => break result.unwrap(),
|
||||
}
|
||||
};
|
||||
assert!(polls > 2);
|
||||
assert!(matches!(state, JobState::Generated(_)));
|
||||
assert_eq!(serde_json::to_value(job.input().unwrap()).unwrap(), serde_json::to_value(&expected).unwrap());
|
||||
assert!(current(&s, &job).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn preparation_cancellation_never_publishes_or_revives_job() {
|
||||
let (_, job) = captured();
|
||||
let mut future = std::pin::pin!(task(&job, false).unwrap());
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
for _ in 0..2 {
|
||||
assert!(future.as_mut().poll(&mut cx).is_pending());
|
||||
}
|
||||
job.cancelled.store(true, Ordering::Relaxed);
|
||||
job.set(JobState::Cancelled);
|
||||
let result = loop {
|
||||
if let Poll::Ready(result) = future.as_mut().poll(&mut cx) {
|
||||
break result;
|
||||
}
|
||||
};
|
||||
assert!(result.is_err() || matches!(result, Ok(JobState::Cancelled)));
|
||||
assert!(job.input().is_err());
|
||||
job.set(JobState::Running);
|
||||
assert!(matches!(job.state().unwrap(), JobState::Cancelled));
|
||||
}
|
||||
#[test]
|
||||
fn native_worker_prepares_generates_and_releases_its_snapshot() {
|
||||
let (_, job) = captured();
|
||||
let worker = job.clone();
|
||||
std::thread::spawn(move || run(worker, false)).join().unwrap();
|
||||
assert!(matches!(job.state().unwrap(), JobState::Generated(_)));
|
||||
assert!(job.input().is_ok());
|
||||
assert!(job.snapshot.lock().unwrap().is_none());
|
||||
assert_eq!(job.progress.load(Ordering::Relaxed), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cam_snapshot_uses_fixed_tolerance_for_large_curved_geometry() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.cylinder", &json!({"radius":5000,"height":6})).unwrap();
|
||||
let mut recipe = cam::corpus::fixture("face").unwrap();
|
||||
recipe.setup.models[0].body = session.model.state().bodies[0].name.clone();
|
||||
recipe.setup.stock.min = crate::geom::Vec3::new(-5000., -5000., 0.);
|
||||
recipe.setup.stock.max = crate::geom::Vec3::new(5000., 5000., 7.);
|
||||
let manufacturing = &mut session.doc_mut().manufacturing;
|
||||
manufacturing.setups.push(recipe.setup);
|
||||
manufacturing.tools = recipe.tools;
|
||||
manufacturing.operations = recipe.operations;
|
||||
manufacturing.programs.push(cam::Program { id: 1, name: "Large cylinder".into(), setup: 1, operations: vec![1] });
|
||||
let prepared = input(&session, 1).unwrap();
|
||||
let mut checked = 0;
|
||||
for triangle in &prepared.geometry.target {
|
||||
if triangle.iter().any(|p| p.z < 1.) && triangle.iter().any(|p| p.z > 5.) {
|
||||
for i in 0..3 {
|
||||
let midpoint = (triangle[i] + triangle[(i + 1) % 3]) * 0.5;
|
||||
assert!((5000. - midpoint.x.hypot(midpoint.y)).abs() <= cam::VERIFY_ERROR / 2.);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Artifact jobs retain immutable source data; only a fresh result command may publish output.
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) enum State {
|
||||
#[default]
|
||||
Idle,
|
||||
Running,
|
||||
Ready {
|
||||
content: String,
|
||||
destination: Option<String>,
|
||||
},
|
||||
Failed(String),
|
||||
}
|
||||
pub fn status(job: &Job) -> Result<serde_json::Value> {
|
||||
if job.cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(serde_json::json!({"state":"cancelled"}));
|
||||
}
|
||||
let state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
|
||||
Ok(match &*state {
|
||||
State::Idle => serde_json::json!({"state":"idle"}),
|
||||
State::Running => serde_json::json!({"state":"running"}),
|
||||
State::Ready { content, .. } => serde_json::json!({"state":"ready","bytes":content.len()}),
|
||||
State::Failed(error) => serde_json::json!({"state":"failed","error":error}),
|
||||
})
|
||||
}
|
||||
pub fn busy(job: &Job) -> Result<bool> {
|
||||
Ok(matches!(*job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?, State::Running | State::Ready { .. }))
|
||||
}
|
||||
pub fn take(job: &Job) -> Result<(String, Option<String>)> {
|
||||
if job.cancelled.load(Ordering::Relaxed) {
|
||||
return Err(EngineError::Other("CAM export cancelled".into()));
|
||||
}
|
||||
let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
|
||||
if !matches!(*state, State::Ready { .. }) {
|
||||
return Err(EngineError::Other("CAM export is not ready".into()));
|
||||
}
|
||||
match std::mem::take(&mut *state) {
|
||||
State::Ready { content, destination } => Ok((content, destination)),
|
||||
_ => Err(EngineError::Other("CAM export is not ready".into())),
|
||||
}
|
||||
}
|
||||
pub fn start(job: Arc<Job>, format: String, destination: Option<String>) -> Result<()> {
|
||||
if !matches!(format.as_str(), "gcode" | "toolpath" | "validation" | "stock" | "setup_sheet") {
|
||||
return Err(EngineError::Other("unknown CAM artifact format".into()));
|
||||
}
|
||||
let original = job.state()?;
|
||||
if !matches!(original, JobState::Generated(_) | JobState::Verified(..)) {
|
||||
return Err(EngineError::Other("no generated result".into()));
|
||||
}
|
||||
{
|
||||
let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
|
||||
if matches!(*state, State::Running | State::Ready { .. }) {
|
||||
return Err(EngineError::Other("finish or cancel the current export first".into()));
|
||||
}
|
||||
*state = State::Running;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let failed = job.clone();
|
||||
if let Err(e) = std::thread::Builder::new().name("cam-export".into()).spawn(move || {
|
||||
let future = task(&job, original, format, destination);
|
||||
// A ready result may already have been collected and another job started.
|
||||
// Completing an export must never restore its old generation/verification state.
|
||||
run_future(job, future, false);
|
||||
}) {
|
||||
if let Ok(mut state) = failed.export.lock() {
|
||||
*state = State::Failed(e.to_string());
|
||||
}
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let future = task(&job, original, format, destination);
|
||||
*job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn task(job: &Job, original: JobState, format: String, destination: Option<String>) -> impl std::future::Future<Output = Result<JobState>> + use<> {
|
||||
let input = job.input.clone();
|
||||
let cancelled = job.cancelled.clone();
|
||||
let result = job.export.clone();
|
||||
async move {
|
||||
YieldOnce::default().await;
|
||||
let produce = async {
|
||||
let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?;
|
||||
let (path, report) = match &original {
|
||||
JobState::Generated(path) => (path, None),
|
||||
JobState::Verified(path, report) => (path, Some(report.as_ref())),
|
||||
_ => return Err(EngineError::Other("no generated result".into())),
|
||||
};
|
||||
if format == "gcode" {
|
||||
let report = report.ok_or_else(|| EngineError::Other("simulate before posting".into()))?;
|
||||
if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete {
|
||||
return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into()));
|
||||
}
|
||||
cam::nc::round_trip_cooperative(path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default)
|
||||
.await
|
||||
.map_err(error)?;
|
||||
cam::nc::post_cooperative(path, &input.setup, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error)
|
||||
} else {
|
||||
crate::io::cam::artifact_cooperative(input, path, report, &format, || cancelled.load(Ordering::Relaxed), YieldOnce::default)
|
||||
.await
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
}
|
||||
.await;
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Ok(JobState::Cancelled);
|
||||
}
|
||||
let mut state = result.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
|
||||
*state = match produce {
|
||||
Ok(content) => State::Ready { content, destination },
|
||||
Err(error) => State::Failed(error.to_string()),
|
||||
};
|
||||
Ok(original)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Explicit hole picks and immutable resolution of their persistent identities.
|
||||
use super::snapshot::{PlacedBody, Profile};
|
||||
use crate::{
|
||||
EngineError, Result, Session, cam,
|
||||
geom::{Seg2, Vec3},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_doc::apply_point;
|
||||
|
||||
fn bad(message: &str) -> EngineError {
|
||||
EngineError::Other(message.into())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub(crate) enum Pick {
|
||||
Face {
|
||||
body: String,
|
||||
index: usize,
|
||||
#[serde(default)]
|
||||
occurrence: Option<Vec<u64>>,
|
||||
},
|
||||
Profile {
|
||||
sketch: u64,
|
||||
index: usize,
|
||||
#[serde(default)]
|
||||
occurrence: Vec<u64>,
|
||||
},
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct Selected {
|
||||
pub center: [f64; 2],
|
||||
pub diameter: f64,
|
||||
}
|
||||
pub(super) enum Hole {
|
||||
Face { body: Box<PlacedBody>, name: String },
|
||||
Profile(Profile),
|
||||
}
|
||||
impl Hole {
|
||||
pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::HoleSource, tolerance: f64) -> Result<Self> {
|
||||
source.validate().map_err(super::error)?;
|
||||
match source {
|
||||
cam::HoleSource::Face { target, name } => {
|
||||
if !setup.models.contains(target) {
|
||||
return Err(bad("select a hole on one of this setup's model occurrences"));
|
||||
}
|
||||
Ok(Self::Face { body: Box::new(PlacedBody::capture(s, target)?), name: name.clone() })
|
||||
}
|
||||
cam::HoleSource::Profile { profile } => Ok(Self::Profile(Profile::capture(s, setup, profile, tolerance)?)),
|
||||
}
|
||||
}
|
||||
pub(super) fn resolve(self, frame: cam::Frame, tolerance: f64) -> Result<Selected> {
|
||||
let (origin, axis, diameter) = match self {
|
||||
Self::Face { body, name } => {
|
||||
let names = solidcraft_doc::naming::face_names(&body.body);
|
||||
let mut indices = names.iter().enumerate().filter_map(|(i, n)| (*n == name).then_some(i));
|
||||
let index = indices.next().ok_or_else(|| bad("CAM hole face missing; reselect it"))?;
|
||||
if indices.next().is_some() {
|
||||
return Err(bad("CAM hole face is ambiguous; reselect it"));
|
||||
}
|
||||
let (origin, axis, radius) = crate::kernel::hole_axis(&body.body.body, index, cam::profile_tolerance(tolerance))?;
|
||||
let point = |p| frame.local(apply_point(&body.placement, p));
|
||||
let center = point(origin);
|
||||
let x = axis.any_perp();
|
||||
let y = axis.cross(x);
|
||||
let dx = point(origin + x) - center;
|
||||
let dy = point(origin + y) - center;
|
||||
if (dx.len() - dy.len()).abs() > 1e-9 || dx.dot(dy).abs() > 1e-9 {
|
||||
return Err(bad("non-circular placed hole; select a rigid occurrence"));
|
||||
}
|
||||
(center, point(origin + axis) - center, 2. * radius * dx.len())
|
||||
}
|
||||
Self::Profile(profile) => {
|
||||
if !profile.region.holes.is_empty() {
|
||||
return Err(bad("select one circular profile per drill center"));
|
||||
}
|
||||
let Some(Seg2::Arc { center, radius, .. }) = profile.region.outer.segs.first() else {
|
||||
return Err(bad("drilling profile must be circular"));
|
||||
};
|
||||
let (center, radius) = (*center, *radius);
|
||||
let mut sweep = 0.;
|
||||
let segments = &profile.region.outer.segs;
|
||||
for (a, next) in segments.iter().zip(segments.iter().cycle().skip(1)).take(segments.len()) {
|
||||
let Seg2::Arc { center: c, radius: r, sweep: angle, .. } = a else {
|
||||
return Err(bad("drilling profile must be circular"));
|
||||
};
|
||||
if c.dist(center) > 1e-9 || (r - radius).abs() > 1e-9 || a.end().dist(next.start()) > 1e-8 {
|
||||
return Err(bad("drilling profile must be a closed circle"));
|
||||
}
|
||||
sweep += angle;
|
||||
}
|
||||
if !sweep.is_finite() || (sweep.abs() - std::f64::consts::TAU).abs() > 1e-8 || radius <= 0. {
|
||||
return Err(bad("drilling profile must be one complete circle"));
|
||||
}
|
||||
let (x, y) = (profile.plane.x, profile.plane.y);
|
||||
if (x.len() - y.len()).abs() > 1e-9 || x.dot(y).abs() > 1e-9 {
|
||||
return Err(bad("non-circular placed profile; select a rigid occurrence"));
|
||||
}
|
||||
(profile.plane.to_world(center), x.cross(y), 2. * radius * x.len())
|
||||
}
|
||||
};
|
||||
let axis = axis.normalized().ok_or_else(|| bad("invalid hole axis"))?;
|
||||
if !origin.is_finite() || !axis.is_finite() || !diameter.is_finite() || diameter <= 0. || axis.cross(Vec3::Z).len() > 1e-9 {
|
||||
return Err(bad("drilling needs a finite circular hole parallel to setup Z"));
|
||||
}
|
||||
Ok(Selected { center: [origin.x, origin.y], diameter })
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select(s: &Session, setup: &cam::Setup, picks: Vec<Pick>) -> Result<(Vec<cam::HoleSource>, Vec<Selected>)> {
|
||||
if picks.is_empty() || picks.len() > cam::MAX_ITEMS {
|
||||
return Err(bad("select between one and 1000 holes"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let doc = s.placed_document();
|
||||
let mut sources = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
let mut selected = Vec::new();
|
||||
for pick in picks {
|
||||
let source = match pick {
|
||||
Pick::Face { body, index, occurrence } => {
|
||||
let target = if let Some(occurrence) = occurrence {
|
||||
cam::BodyRef { body, occurrence }
|
||||
} else {
|
||||
if setup.models.len().saturating_mul(doc.occurrences.len() + state.bodies.len() + 64) > 2_000_000 {
|
||||
return Err(bad("hole selection work limit; provide a definition body and occurrence path"));
|
||||
}
|
||||
// Only these explicit model placements may be selected. Do not expand
|
||||
// an entire shared assembly merely to interpret a viewport label.
|
||||
let mut matching = setup.models.iter().filter(|target| {
|
||||
state.body(&target.body).is_some_and(|model| {
|
||||
doc.world_body_name(&state, &model.name, doc.model_body_component(model), &target.occurrence) == body
|
||||
})
|
||||
});
|
||||
let target = matching.next().ok_or_else(|| bad("selected hole body is not in this setup; reselect its model occurrence"))?;
|
||||
if matching.next().is_some() {
|
||||
return Err(bad("selected hole body ambiguous; provide its occurrence path"));
|
||||
}
|
||||
target.clone()
|
||||
};
|
||||
let placed = PlacedBody::capture(s, &target)?;
|
||||
let name = solidcraft_doc::naming::face_names(&placed.body).get(index).cloned().ok_or_else(|| bad("selected hole face missing"))?;
|
||||
cam::HoleSource::Face { target, name }
|
||||
}
|
||||
Pick::Profile { sketch, index, occurrence } => {
|
||||
let profile = state
|
||||
.sketches
|
||||
.iter()
|
||||
.find(|s| s.feature == sketch)
|
||||
.and_then(|s| s.profiles.get(index))
|
||||
.ok_or_else(|| bad("selected circular profile missing"))?;
|
||||
cam::HoleSource::Profile { profile: cam::ProfileRef { sketch, curves: profile.curve_ids(), occurrence } }
|
||||
}
|
||||
};
|
||||
if seen.contains(&source) {
|
||||
return Err(bad("duplicate hole selection"));
|
||||
}
|
||||
seen.push(source.clone());
|
||||
let hole = Hole::capture(s, setup, &source, cam::TOLERANCE)?.resolve(setup.frame, cam::TOLERANCE)?;
|
||||
if let Some((old, prior)) = sources
|
||||
.iter()
|
||||
.zip(&selected)
|
||||
.find(|(_, p): &(_, &Selected)| (p.center[0] - hole.center[0]).hypot(p.center[1] - hole.center[1]) < cam::TOLERANCE)
|
||||
{
|
||||
// A viewport pick expands a cylinder across its topological seam. Its
|
||||
// faces still describe one hole on one explicitly placed body.
|
||||
if matches!((old, &source), (cam::HoleSource::Face { target: a, .. }, cam::HoleSource::Face { target: b, .. }) if a == b)
|
||||
&& (prior.center[0] - hole.center[0]).hypot(prior.center[1] - hole.center[1]) < 1e-8
|
||||
&& (prior.diameter - hole.diameter).abs() < 1e-8
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Err(bad("selected hole axes coincide; remove duplicate selections"));
|
||||
}
|
||||
sources.push(source);
|
||||
selected.push(hole);
|
||||
}
|
||||
Ok((sources, selected))
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Cheap immutable capture on the session thread; curve sampling, tessellation,
|
||||
//! transforms and hashing happen during preparation on the job's worker.
|
||||
use crate::{
|
||||
EngineError, Result, Session, cam,
|
||||
geom::{Plane, Region2, Seg2, Vec2, Vec3},
|
||||
};
|
||||
use solidcraft_doc::{Mat, apply_point};
|
||||
|
||||
pub(super) struct Snapshot {
|
||||
setup: cam::Setup,
|
||||
tools: Vec<cam::Tool>,
|
||||
operations: Vec<cam::Operation>,
|
||||
profiles: Vec<(usize, Profile)>,
|
||||
holes: Vec<(usize, Vec<super::holes::Hole>)>,
|
||||
models: Vec<PlacedBody>,
|
||||
fixtures: Vec<PlacedBody>,
|
||||
}
|
||||
pub(super) struct PlacedBody {
|
||||
pub(super) body: solidcraft_doc::ModelBody,
|
||||
pub(super) placement: Mat,
|
||||
}
|
||||
pub(super) struct Profile {
|
||||
pub(super) region: Region2,
|
||||
pub(super) plane: Plane,
|
||||
tolerance: f64,
|
||||
}
|
||||
fn error(message: &str) -> EngineError {
|
||||
EngineError::Other(message.into())
|
||||
}
|
||||
fn cancelled(check: &impl Fn() -> bool) -> Result<()> {
|
||||
if check() { Err(error("CAM preparation cancelled")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub(super) fn capture(s: &Session, program: u64) -> Result<Self> {
|
||||
s.doc.manufacturing.validate().map_err(super::error)?;
|
||||
let m = &s.doc.manufacturing;
|
||||
let p = m.programs.iter().find(|p| p.id == program).ok_or_else(|| error("CAM program not found"))?;
|
||||
let setup = m.setups.iter().find(|x| x.id == p.setup).cloned().ok_or_else(|| error("CAM setup missing"))?;
|
||||
let operations = p
|
||||
.operations
|
||||
.iter()
|
||||
.map(|id| m.operations.iter().find(|o| o.id == *id).cloned().ok_or_else(|| error("CAM operation missing")))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut profiles = Vec::new();
|
||||
for (i, o) in operations.iter().enumerate() {
|
||||
if let Some(source) = &o.source {
|
||||
profiles.push((i, Profile::capture(s, &setup, source, o.tolerance)?));
|
||||
}
|
||||
}
|
||||
let mut holes = Vec::new();
|
||||
for (index, operation) in operations.iter().enumerate() {
|
||||
if !operation.hole_sources.is_empty() {
|
||||
holes.push((
|
||||
index,
|
||||
operation
|
||||
.hole_sources
|
||||
.iter()
|
||||
.map(|source| super::holes::Hole::capture(s, &setup, source, operation.tolerance))
|
||||
.collect::<Result<_>>()?,
|
||||
));
|
||||
}
|
||||
}
|
||||
let tools = m.tools.iter().filter(|t| operations.iter().any(|o| o.tool == t.id)).cloned().collect::<Vec<_>>();
|
||||
if tools.iter().map(|t| t.number).collect::<std::collections::BTreeSet<_>>().len() != tools.len() {
|
||||
return Err(error("CAM tools must have unique tool numbers within a program"));
|
||||
}
|
||||
let models = setup.models.iter().map(|r| PlacedBody::capture(s, r)).collect::<Result<_>>()?;
|
||||
let fixtures = setup.fixtures.iter().map(|r| PlacedBody::capture(s, r)).collect::<Result<_>>()?;
|
||||
Ok(Self { setup, tools, operations, profiles, holes, models, fixtures })
|
||||
}
|
||||
pub(super) async fn build<F: std::future::Future<Output = ()>>(
|
||||
mut self,
|
||||
check: &impl Fn() -> bool,
|
||||
progress: &impl Fn(usize),
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<cam::JobInput> {
|
||||
let total =
|
||||
self.profiles.len() + self.holes.iter().map(|(_, holes)| holes.len()).sum::<usize>() + self.models.len() + self.fixtures.len() + 1;
|
||||
let mut done = 0;
|
||||
for (index, p) in self.profiles {
|
||||
cancelled(check)?;
|
||||
yield_control().await;
|
||||
let boundary = p.sample(check, yield_control).await?;
|
||||
let op = self.operations.get_mut(index).ok_or_else(|| error("CAM operation missing from snapshot"))?;
|
||||
match &mut op.geometry {
|
||||
cam::Strategy::Contour { boundary: b, .. } | cam::Strategy::Pocket { boundary: b } => *b = boundary,
|
||||
_ => return Err(error("profile bound to unsupported operation")),
|
||||
}
|
||||
done += 1;
|
||||
progress(done * 20 / total);
|
||||
}
|
||||
for (index, holes) in self.holes {
|
||||
let operation = self.operations.get_mut(index).ok_or_else(|| error("CAM hole operation missing"))?;
|
||||
let tool = self.tools.iter().find(|t| t.id == operation.tool).ok_or_else(|| error("CAM drill missing"))?;
|
||||
let mut centers = Vec::new();
|
||||
for hole in holes {
|
||||
cancelled(check)?;
|
||||
yield_control().await;
|
||||
let selected = hole.resolve(self.setup.frame, operation.tolerance)?;
|
||||
if (selected.diameter - tool.diameter).abs() > operation.tolerance {
|
||||
return Err(error("selected hole diameter differs from the drill; choose a matching tool or unlink the hole"));
|
||||
}
|
||||
if centers.iter().any(|p: &[f64; 2]| (p[0] - selected.center[0]).hypot(p[1] - selected.center[1]) <= operation.tolerance) {
|
||||
return Err(error("selected hole axes coincide; remove duplicate selections"));
|
||||
}
|
||||
centers.push(selected.center);
|
||||
done += 1;
|
||||
progress(done * 20 / total);
|
||||
}
|
||||
let cam::Strategy::Drill { holes, .. } = &mut operation.geometry else {
|
||||
return Err(error("hole references require drilling"));
|
||||
};
|
||||
*holes = centers;
|
||||
}
|
||||
let mut target = Vec::new();
|
||||
let mut fixtures = Vec::new();
|
||||
let mut fixture_groups = Vec::new();
|
||||
for body in self.models {
|
||||
body.append(self.setup.frame, &mut target, check, yield_control).await?;
|
||||
done += 1;
|
||||
progress(done * 20 / total);
|
||||
}
|
||||
for body in self.fixtures {
|
||||
let before = fixtures.len();
|
||||
body.append(self.setup.frame, &mut fixtures, check, yield_control).await?;
|
||||
fixture_groups.push(fixtures.len() - before);
|
||||
done += 1;
|
||||
progress(done * 20 / total);
|
||||
}
|
||||
if target.len() + fixtures.len() > 200_000 {
|
||||
return Err(error("CAM mesh exceeds verification budget"));
|
||||
}
|
||||
cancelled(check)?;
|
||||
yield_control().await;
|
||||
let mut input = cam::JobInput {
|
||||
setup: self.setup,
|
||||
tools: self.tools,
|
||||
operations: self.operations,
|
||||
geometry: cam::Geometry { target, fixtures, fixture_groups },
|
||||
fingerprint: String::new(),
|
||||
};
|
||||
input.validate().map_err(super::error)?;
|
||||
input.fingerprint = cam::fingerprint_job_cooperative(&input, check, yield_control).await.map_err(super::error)?;
|
||||
cancelled(check)?;
|
||||
progress(20);
|
||||
Ok(input)
|
||||
}
|
||||
}
|
||||
impl PlacedBody {
|
||||
pub(super) fn capture(s: &Session, r: &cam::BodyRef) -> Result<Self> {
|
||||
let state = s.model.state();
|
||||
let candidates: Vec<_> = state.bodies.iter().filter(|b| b.name == r.body).collect();
|
||||
if candidates.len() != 1 {
|
||||
return Err(error(&format!("CAM body `{}` missing or ambiguous; reselect it", r.body)));
|
||||
}
|
||||
let b = candidates[0];
|
||||
let component = s.doc.model_body_component(b);
|
||||
let placement = if r.occurrence.is_empty() {
|
||||
if component != 0 {
|
||||
return Err(error("CAM component body requires an occurrence path"));
|
||||
}
|
||||
solidcraft_doc::IDENTITY
|
||||
} else {
|
||||
let p = s.occurrence_placement(&r.occurrence)?;
|
||||
if p.component != component {
|
||||
return Err(error("CAM body no longer belongs to selected occurrence"));
|
||||
}
|
||||
p.world
|
||||
};
|
||||
let placement = s.doc.model_body_offset(b).map(|offset| solidcraft_doc::mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
Ok(Self { body: b.clone(), placement })
|
||||
}
|
||||
async fn append<F: std::future::Future<Output = ()>>(
|
||||
self,
|
||||
frame: cam::Frame,
|
||||
out: &mut Vec<[Vec3; 3]>,
|
||||
check: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<()> {
|
||||
cancelled(check)?;
|
||||
yield_control().await;
|
||||
cancelled(check)?;
|
||||
let mesh = self.body.body.tessellate_for_verification(cam::VERIFY_ERROR / 2., 200_000usize.saturating_sub(out.len()))?;
|
||||
if out.len() + mesh.triangles.len() > 200_000 {
|
||||
return Err(error("CAM mesh exceeds verification budget"));
|
||||
}
|
||||
for (i, indices) in mesh.triangles.iter().enumerate() {
|
||||
if i.is_multiple_of(256) {
|
||||
cancelled(check)?;
|
||||
yield_control().await;
|
||||
}
|
||||
let triangle = mesh.tri(indices).ok_or_else(|| error("CAM mesh contains invalid indices"))?;
|
||||
out.push(triangle.map(|v| frame.local(apply_point(&self.placement, v))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Profile {
|
||||
pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result<Self> {
|
||||
let state = s.model.state();
|
||||
let sketch = state.sketches.iter().find(|x| x.feature == source.sketch).ok_or_else(|| error("CAM sketch missing; reselect the profile"))?;
|
||||
let mut wanted = source.curves.clone();
|
||||
wanted.sort();
|
||||
let candidates: Vec<_> = sketch
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let mut ids = p.curve_ids();
|
||||
ids.sort();
|
||||
ids == wanted
|
||||
})
|
||||
.collect();
|
||||
if candidates.len() != 1 {
|
||||
return Err(error("CAM profile missing or ambiguous; reselect it"));
|
||||
}
|
||||
let component = s.doc.feature(source.sketch).map(|f| f.component).ok_or_else(|| error("CAM sketch feature missing"))?;
|
||||
let transform = if source.occurrence.is_empty() {
|
||||
if component != 0 {
|
||||
return Err(error("CAM sketch needs an explicit occurrence path"));
|
||||
}
|
||||
solidcraft_doc::IDENTITY
|
||||
} else {
|
||||
let p = s.occurrence_placement(&source.occurrence)?;
|
||||
if p.component != component {
|
||||
return Err(error("CAM sketch occurrence mismatch"));
|
||||
}
|
||||
p.world
|
||||
};
|
||||
let point = |p| setup.frame.local(apply_point(&transform, p));
|
||||
let origin = point(sketch.plane.origin);
|
||||
let plane =
|
||||
Plane { origin, x: point(sketch.plane.origin + sketch.plane.x) - origin, y: point(sketch.plane.origin + sketch.plane.y) - origin };
|
||||
Ok(Self { region: candidates[0].region.clone(), plane, tolerance: cam::profile_tolerance(tolerance) })
|
||||
}
|
||||
fn segments(&self, s: &Seg2) -> Result<usize> {
|
||||
let v = |p: Vec2| self.plane.x * p.x + self.plane.y * p.y;
|
||||
let m = match s {
|
||||
Seg2::Line { .. } => 0.,
|
||||
Seg2::Arc { radius, sweep, .. } => radius.abs() * sweep.powi(2) * (self.plane.x.len2() + self.plane.y.len2()).sqrt(),
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => 6. * v(*p0 - *p1 * 2. + *p2).len().max(v(*p1 - *p2 * 2. + *p3).len()),
|
||||
Seg2::Bezier { n, p } => {
|
||||
let points = p.get(..=*n as usize).filter(|p| p.len() >= 2).ok_or_else(|| error("invalid CAM Bezier degree"))?;
|
||||
*n as f64 * (*n as f64 - 1.) * points.windows(3).map(|p| v(p[0] - p[1] * 2. + p[2]).len()).fold(0., f64::max)
|
||||
}
|
||||
Seg2::Conic { a, apex, b, w } => {
|
||||
if !w.is_finite() || *w <= -1. {
|
||||
return Err(error("singular CAM conic profile"));
|
||||
}
|
||||
let u = v((*apex - *a) * *w);
|
||||
let b = v(*b - *a);
|
||||
let d = 1_f64.min((1. + w) / 2.);
|
||||
let n = u.len().max(b.len());
|
||||
let n1 = 2. * u.len().max((b - u).len());
|
||||
let n2 = 2. * (b - u * 2.).len();
|
||||
let d1 = 2. * (w - 1.).abs();
|
||||
let d2 = 4. * (w - 1.).abs();
|
||||
n2 / d + 2. * n1 * d1 / d.powi(2) + n * d2 / d.powi(2) + 2. * n * d1.powi(2) / d.powi(3)
|
||||
}
|
||||
};
|
||||
// ||curve''||/(8 n²) bounds the chord error on every parameter interval.
|
||||
// The reserve covers evaluation/placement rounding, rather than silently
|
||||
// clamping the segment count or increasing the requested tolerance.
|
||||
let size = |p: Vec2| self.plane.x.len() * p.x.abs() + self.plane.y.len() * p.y.abs();
|
||||
let magnitude = match s {
|
||||
Seg2::Line { a, b } => size(*a) + size(*b),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
size(*center) + radius.abs() * (1. + start.abs() + sweep.abs()) * (self.plane.x.len() + self.plane.y.len())
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => size(*p0) + size(*p1) + size(*p2) + size(*p3),
|
||||
Seg2::Bezier { n, p } => p.get(..=*n as usize).ok_or_else(|| error("invalid CAM Bezier degree"))?.iter().map(|p| size(*p)).sum(),
|
||||
Seg2::Conic { a, apex, b, w } => (size(*a) + w.abs() * size(*apex) + size(*b)) / 1_f64.min((1. + w) / 2.),
|
||||
};
|
||||
let guard = 256. * f64::EPSILON * (1. + self.plane.origin.len() + magnitude + m.abs());
|
||||
let budget = self.tolerance * 15. / 16. - guard;
|
||||
if !m.is_finite() || !budget.is_finite() || budget <= 0. {
|
||||
return Err(error("CAM profile precision cannot meet the requested tolerance"));
|
||||
}
|
||||
let count = ((m * (1. + 256. * f64::EPSILON) / (8. * budget)).sqrt().ceil()).max(1.);
|
||||
if !count.is_finite() || count > 10_000. {
|
||||
return Err(error("CAM profile exceeds 10000 vertices at the required tolerance"));
|
||||
}
|
||||
Ok(count as usize)
|
||||
}
|
||||
pub(super) async fn sample<F: std::future::Future<Output = ()>>(
|
||||
&self,
|
||||
check: &impl Fn() -> bool,
|
||||
yield_control: &mut impl FnMut() -> F,
|
||||
) -> Result<cam::Boundary> {
|
||||
if !self.plane.origin.is_finite() || !self.plane.x.is_finite() || !self.plane.y.is_finite() || self.plane.normal().len() < 1e-12 {
|
||||
return Err(error("degenerate CAM profile placement"));
|
||||
}
|
||||
if self.plane.x.z.abs() > 1e-10 * self.plane.x.len() || self.plane.y.z.abs() > 1e-10 * self.plane.y.len() {
|
||||
return Err(error("CAM profile must be perpendicular to setup Z"));
|
||||
}
|
||||
let mut rings = Vec::new();
|
||||
let mut total = 0;
|
||||
for ring in std::iter::once(&self.region.outer).chain(&self.region.holes) {
|
||||
let mut points = Vec::new();
|
||||
for (a, b) in ring.segs.iter().zip(ring.segs.iter().cycle().skip(1)).take(ring.segs.len()) {
|
||||
let gap = self.plane.dir_to_world(a.end() - b.start()).len();
|
||||
if !gap.is_finite() || gap > self.tolerance / 16. {
|
||||
return Err(error("disconnected CAM profile segments"));
|
||||
}
|
||||
}
|
||||
for curve in &ring.segs {
|
||||
cancelled(check)?;
|
||||
yield_control().await;
|
||||
let count = self.segments(curve)?;
|
||||
total += count;
|
||||
if total > 10_000 {
|
||||
return Err(error("CAM profile exceeds 10000 vertices at the required tolerance"));
|
||||
}
|
||||
for i in 0..count {
|
||||
if i.is_multiple_of(256) {
|
||||
cancelled(check)?;
|
||||
yield_control().await;
|
||||
}
|
||||
let p = self.plane.to_world(curve.point_at(i as f64 / count as f64));
|
||||
if !p.is_finite() {
|
||||
return Err(error("non-finite CAM profile point"));
|
||||
}
|
||||
points.push([p.x, p.y]);
|
||||
}
|
||||
}
|
||||
if points.first() == points.last() {
|
||||
points.pop();
|
||||
}
|
||||
rings.push(points);
|
||||
}
|
||||
let mut rings = rings.into_iter();
|
||||
let boundary = cam::Boundary { outer: rings.next().ok_or_else(|| error("empty CAM profile"))?, islands: rings.collect() };
|
||||
boundary.validate().map_err(super::error)?;
|
||||
Ok(boundary)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ready<T>(future: impl std::future::Future<Output = Result<T>>) -> Result<T> {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let mut future = std::pin::pin!(future);
|
||||
match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
|
||||
Poll::Ready(result) => result,
|
||||
Poll::Pending => Err(error("synchronous CAM preparation unexpectedly yielded")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::Loop2;
|
||||
fn profile() -> Profile {
|
||||
Profile { region: Region2 { outer: Loop2::circle(Vec2::ZERO, 8.), holes: vec![] }, plane: Plane::XY, tolerance: cam::profile_tolerance(0.01) }
|
||||
}
|
||||
#[test]
|
||||
fn bounded_sampler_meets_transformed_curve_chord_budget() {
|
||||
let mut p = profile();
|
||||
p.plane.x = Vec3::new(3., 1., 0.);
|
||||
p.plane.y = Vec3::new(-1., 0.5, 0.);
|
||||
p.plane.origin = Vec3::new(100., -120., 2.);
|
||||
let a = Vec2::new(2., -1.);
|
||||
let b = Vec2::new(-2., 3.);
|
||||
let c = Vec2::new(4., 8.);
|
||||
let mut curves = vec![
|
||||
Seg2::Line { a, b },
|
||||
Seg2::Arc { center: a, radius: 8., start: 0.2, sweep: -5. },
|
||||
Seg2::Cubic { p0: a, p1: c, p2: b, p3: Vec2::ZERO },
|
||||
Seg2::Bezier { n: 7, p: [a, b, c, a, b, c, a, b, c, a] },
|
||||
Seg2::Bezier { n: 9, p: [a, b, c, a, b, c, a, b, c, a] },
|
||||
];
|
||||
for w in [-0.5, 0., 0.7, 1., 5.] {
|
||||
curves.push(Seg2::Conic { a, apex: c, b, w });
|
||||
}
|
||||
for curve in curves {
|
||||
let n = p.segments(&curve).unwrap();
|
||||
for i in 0..n {
|
||||
let a = p.plane.to_world(curve.point_at(i as f64 / n as f64));
|
||||
let b = p.plane.to_world(curve.point_at((i + 1) as f64 / n as f64));
|
||||
for k in 1..8 {
|
||||
let fraction = k as f64 / 8.;
|
||||
let exact = p.plane.to_world(curve.point_at((i as f64 + fraction) / n as f64));
|
||||
assert!(exact.dist(a + (b - a) * fraction) <= p.tolerance, "{curve:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn precision_and_vertex_limits_fail_without_relaxing_tolerance() {
|
||||
let mut p = profile();
|
||||
let circle = Seg2::Arc { center: Vec2::ZERO, radius: 1e6, start: 0., sweep: std::f64::consts::TAU };
|
||||
assert!(p.segments(&circle).is_err());
|
||||
p.plane.origin = Vec3::new(1e15, 0., 0.);
|
||||
assert!(p.segments(&Seg2::Line { a: Vec2::ZERO, b: Vec2::X }).is_err());
|
||||
let p = profile();
|
||||
assert!(p.segments(&Seg2::Bezier { n: 255, p: Default::default() }).is_err());
|
||||
assert!(p.segments(&Seg2::Conic { a: Vec2::ZERO, apex: Vec2::X, b: Vec2::Y, w: -1. }).is_err());
|
||||
assert!(p.segments(&Seg2::Line { a: Vec2::new(f64::NAN, 0.), b: Vec2::ZERO }).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn profile_sampling_yields_and_honours_cancellation_and_planarity() {
|
||||
use std::{
|
||||
future::Future,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
let p = profile();
|
||||
let cancel = AtomicBool::new(false);
|
||||
let check = || cancel.load(Ordering::Relaxed);
|
||||
let mut yields = super::super::YieldOnce::default;
|
||||
let mut future = std::pin::pin!(p.sample(&check, &mut yields));
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
assert!(future.as_mut().poll(&mut cx).is_pending());
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_))));
|
||||
let mut p = profile();
|
||||
p.plane = Plane::XZ;
|
||||
assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err());
|
||||
p.plane = Plane::XY;
|
||||
p.region.outer.segs.pop();
|
||||
assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-cam = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
solidcraft-doc = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! CAM artifact serialization. The engine owns freshness and posting gates.
|
||||
mod incremental;
|
||||
pub use incremental::artifact_cooperative;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::IoError;
|
||||
use crate::Result;
|
||||
use solidcraft_cam::{JobInput, Toolpath, verify::Report};
|
||||
pub fn escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'")
|
||||
}
|
||||
/// Synchronous compatibility wrapper; interactive clients use the cooperative entry point.
|
||||
pub fn artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result<String> {
|
||||
let mut future = std::pin::pin!(artifact_cooperative(input, path, report, format, || false, || std::future::ready(())));
|
||||
loop {
|
||||
if let std::task::Poll::Ready(result) =
|
||||
std::future::Future::poll(future.as_mut(), &mut std::task::Context::from_waker(std::task::Waker::noop()))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Original serializer retained only as an independent schema/escaping oracle in tests.
|
||||
#[cfg(test)]
|
||||
fn reference_artifact(input: &JobInput, path: &Toolpath, report: Option<&Report>, format: &str) -> Result<String> {
|
||||
let json = |v: serde_json::Value| serde_json::to_string_pretty(&v).map_err(|e| IoError::Invalid(e.to_string()));
|
||||
match format {
|
||||
"toolpath" => json(serde_json::json!({"version":solidcraft_cam::VERSION,"input":input,"toolpath":path})),
|
||||
"validation" => json(
|
||||
serde_json::to_value(report.ok_or_else(|| IoError::Invalid("simulate first".into()))?).map_err(|e| IoError::Invalid(e.to_string()))?,
|
||||
),
|
||||
"stock" => solidcraft_cam::verify::stock_stl(report.ok_or_else(|| IoError::Invalid("simulate first".into()))?, input.setup.stock.min.z)
|
||||
.map_err(|e| IoError::Invalid(e.to_string())),
|
||||
"setup_sheet" => {
|
||||
let mut html = format!(
|
||||
"<!doctype html><meta charset=utf-8><title>{}</title><style>body{{font:16px system-ui;max-width:1000px;margin:40px auto}}table{{border-collapse:collapse;width:100%}}td,th{{padding:8px;border:1px solid #ccc;text-align:left}}pre{{white-space:pre-wrap}}</style><h1>{}</h1><p>Reference three-axis milling — not a qualified machine post.</p><p>Fingerprint: {}</p><h2>Setup</h2><pre>{}</pre><h2>Operations</h2><table><tr><th>Name</th><th>Tool</th><th>Feed mm/min</th><th>Spindle rpm</th><th>Bottom mm</th></tr>",
|
||||
escape(&input.setup.name),
|
||||
escape(&input.setup.name),
|
||||
escape(&path.fingerprint),
|
||||
escape(&serde_json::to_string_pretty(&input.setup).map_err(|e| IoError::Invalid(e.to_string()))?)
|
||||
);
|
||||
for o in &input.operations {
|
||||
html.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
escape(&o.name),
|
||||
o.tool,
|
||||
o.feed,
|
||||
o.spindle,
|
||||
o.bottom
|
||||
));
|
||||
}
|
||||
html.push_str("</table><h2>Tools</h2><pre>");
|
||||
html.push_str(&escape(&serde_json::to_string_pretty(&input.tools).map_err(|e| IoError::Invalid(e.to_string()))?));
|
||||
html.push_str("</pre><h2>Operation settings and selected geometry</h2><pre>");
|
||||
html.push_str(&escape(&serde_json::to_string_pretty(&input.operations).map_err(|e| IoError::Invalid(e.to_string()))?));
|
||||
html.push_str("</pre>");
|
||||
Ok(html)
|
||||
}
|
||||
_ => Err(IoError::Invalid("unknown CAM artifact format".into())),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Bounded artifact serialization. Large arrays never become an intermediate JSON Value.
|
||||
use super::{JobInput, Report, Toolpath, escape};
|
||||
use crate::{IoError, Result};
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
|
||||
const MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
struct Writer<C, Y> {
|
||||
out: String,
|
||||
cancelled: C,
|
||||
yield_control: Y,
|
||||
}
|
||||
impl<C: Fn() -> bool, Y: FnMut() -> F, F: Future<Output = ()>> Writer<C, Y> {
|
||||
async fn checkpoint(&mut self) -> Result<()> {
|
||||
if (self.cancelled)() {
|
||||
return Err(IoError::Invalid("CAM export cancelled".into()));
|
||||
}
|
||||
if self.out.len() > MAX_BYTES {
|
||||
return Err(IoError::Invalid("CAM artifact exceeds 128 MiB".into()));
|
||||
}
|
||||
(self.yield_control)().await;
|
||||
if (self.cancelled)() {
|
||||
return Err(IoError::Invalid("CAM export cancelled".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn value(&mut self, v: &impl Serialize) -> Result<()> {
|
||||
self.out.push_str(&serde_json::to_string(v).map_err(|e| IoError::Invalid(e.to_string()))?);
|
||||
Ok(())
|
||||
}
|
||||
async fn array<T: Serialize>(&mut self, values: &[T], batch: usize) -> Result<()> {
|
||||
self.out.push('[');
|
||||
for (i, value) in values.iter().enumerate() {
|
||||
if i.is_multiple_of(batch) {
|
||||
self.checkpoint().await?;
|
||||
}
|
||||
if i != 0 {
|
||||
self.out.push(',');
|
||||
}
|
||||
self.value(value)?;
|
||||
}
|
||||
self.out.push(']');
|
||||
Ok(())
|
||||
}
|
||||
async fn html_array<T: Serialize>(&mut self, values: &[T]) -> Result<()> {
|
||||
self.out.push('[');
|
||||
for (i, value) in values.iter().enumerate() {
|
||||
self.checkpoint().await?;
|
||||
if i != 0 {
|
||||
self.out.push(',');
|
||||
}
|
||||
self.out.push('\n');
|
||||
self.out.push_str(&escape(&serde_json::to_string_pretty(value).map_err(|e| IoError::Invalid(e.to_string()))?));
|
||||
}
|
||||
self.out.push_str("\n]");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a complete artifact, yielding between bounded array batches and observing cancellation.
|
||||
/// JSON field values match `artifact`; whitespace/object order may differ. No partial output escapes.
|
||||
pub async fn artifact_cooperative<F: Future<Output = ()>>(
|
||||
input: &JobInput,
|
||||
path: &Toolpath,
|
||||
report: Option<&Report>,
|
||||
format: &str,
|
||||
cancelled: impl Fn() -> bool,
|
||||
yield_control: impl FnMut() -> F,
|
||||
) -> Result<String> {
|
||||
if format == "stock" {
|
||||
return solidcraft_cam::verify::stock_stl_cooperative(
|
||||
report.ok_or_else(|| IoError::Invalid("simulate first".into()))?,
|
||||
input.setup.stock.min.z,
|
||||
cancelled,
|
||||
yield_control,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| IoError::Invalid(e.to_string()));
|
||||
}
|
||||
let mut w = Writer { out: String::new(), cancelled, yield_control };
|
||||
w.checkpoint().await?;
|
||||
match format {
|
||||
"toolpath" => {
|
||||
w.out.push_str("{\"version\":");
|
||||
w.value(&solidcraft_cam::VERSION)?;
|
||||
w.out.push_str(",\"input\":{\"setup\":");
|
||||
w.value(&input.setup)?;
|
||||
w.out.push_str(",\"tools\":");
|
||||
w.array(&input.tools, 1).await?;
|
||||
w.out.push_str(",\"operations\":");
|
||||
w.array(&input.operations, 1).await?;
|
||||
w.out.push_str(",\"fingerprint\":");
|
||||
w.value(&input.fingerprint)?;
|
||||
w.out.push_str(",\"geometry\":{\"target\":");
|
||||
w.array(&input.geometry.target, 256).await?;
|
||||
w.out.push_str(",\"fixtures\":");
|
||||
w.array(&input.geometry.fixtures, 256).await?;
|
||||
w.out.push_str(",\"fixture_groups\":");
|
||||
w.array(&input.geometry.fixture_groups, 256).await?;
|
||||
w.out.push_str("}},\"toolpath\":{\"fingerprint\":");
|
||||
w.value(&path.fingerprint)?;
|
||||
w.out.push_str(",\"blocks\":");
|
||||
w.array(&path.blocks, 256).await?;
|
||||
w.out.push_str("}}");
|
||||
}
|
||||
"validation" => {
|
||||
let r = report.ok_or_else(|| IoError::Invalid("simulate first".into()))?;
|
||||
// This small metadata object excludes the potentially large cell array.
|
||||
let metadata = serde_json::json!({
|
||||
"version":r.version,"fingerprint":r.fingerprint,"status":r.status,
|
||||
"finished_dimensions":r.finished_dimensions,"post_version":r.post_version,
|
||||
"tolerance":r.tolerance,"uncertainty":r.uncertainty,"maximum_height_interval":r.maximum_height_interval,
|
||||
"stock_complete":r.stock_complete,"stock":r.stock,"findings":r.findings,
|
||||
"cutting_distance":r.cutting_distance,"rapid_distance":r.rapid_distance,"cutting_minutes":r.cutting_minutes,
|
||||
"remaining_volume_low":r.remaining_volume_low,"remaining_volume_high":r.remaining_volume_high,"work":r.work,
|
||||
});
|
||||
w.value(&metadata)?;
|
||||
w.out.pop();
|
||||
w.out.push_str(",\"cells\":");
|
||||
w.array(&r.cells, 256).await?;
|
||||
w.out.push('}');
|
||||
}
|
||||
"setup_sheet" => {
|
||||
w.out = format!(
|
||||
"<!doctype html><meta charset=utf-8><title>{}</title><style>body{{font:16px system-ui;max-width:1000px;margin:40px auto}}table{{border-collapse:collapse;width:100%}}td,th{{padding:8px;border:1px solid #ccc;text-align:left}}pre{{white-space:pre-wrap}}</style><h1>{}</h1><p>Reference three-axis milling — not a qualified machine post.</p><p>Fingerprint: {}</p><h2>Setup</h2><pre>{}</pre><h2>Operations</h2><table><tr><th>Name</th><th>Tool</th><th>Feed mm/min</th><th>Spindle rpm</th><th>Bottom mm</th></tr>",
|
||||
escape(&input.setup.name),
|
||||
escape(&input.setup.name),
|
||||
escape(&path.fingerprint),
|
||||
escape(&serde_json::to_string_pretty(&input.setup).map_err(|e| IoError::Invalid(e.to_string()))?),
|
||||
);
|
||||
for o in &input.operations {
|
||||
w.checkpoint().await?;
|
||||
w.out.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
escape(&o.name),
|
||||
o.tool,
|
||||
o.feed,
|
||||
o.spindle,
|
||||
o.bottom
|
||||
));
|
||||
}
|
||||
w.out.push_str("</table><h2>Tools</h2><pre>");
|
||||
w.html_array(&input.tools).await?;
|
||||
w.out.push_str("</pre><h2>Operation settings and selected geometry</h2><pre>");
|
||||
w.html_array(&input.operations).await?;
|
||||
w.out.push_str("</pre>");
|
||||
}
|
||||
_ => return Err(IoError::Invalid("unknown CAM artifact format".into())),
|
||||
}
|
||||
w.checkpoint().await?;
|
||||
Ok(w.out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
fn ready<T>(future: impl Future<Output = T>) -> T {
|
||||
let mut future = std::pin::pin!(future);
|
||||
loop {
|
||||
if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fixture() -> (JobInput, Toolpath, Report) {
|
||||
let input = solidcraft_cam::corpus::fixture("face").unwrap();
|
||||
let path = solidcraft_cam::generate::generate(input.clone()).unwrap();
|
||||
let parsed = solidcraft_cam::nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let report = solidcraft_cam::verify::simulate(&input, &parsed, Default::default()).unwrap();
|
||||
(input, path, report)
|
||||
}
|
||||
fn pre_values(html: &str) -> Vec<serde_json::Value> {
|
||||
html.split("<pre>")
|
||||
.skip(1)
|
||||
.map(|part| {
|
||||
let text = part
|
||||
.split("</pre>")
|
||||
.next()
|
||||
.unwrap()
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&");
|
||||
serde_json::from_str(&text).unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn cooperative_artifacts_preserve_every_field_geometry_and_html_escaping() {
|
||||
let (mut input, mut path, mut report) = fixture();
|
||||
input.operations[0].name = "ø <script>&\"'".into();
|
||||
input.setup.name = "stock </pre> &".into();
|
||||
path.blocks.extend(std::iter::repeat_n(path.blocks[0].clone(), 1025));
|
||||
input.geometry.fixture_groups = vec![input.geometry.target.len()];
|
||||
input.geometry.fixtures = input.geometry.target.clone();
|
||||
report.findings.push(solidcraft_cam::verify::Finding {
|
||||
severity: solidcraft_cam::verify::FindingSeverity::Information,
|
||||
kind: "original".into(),
|
||||
line: Some(42),
|
||||
record: Some(17),
|
||||
source: None,
|
||||
at: solidcraft_geom::Vec3::new(1., 2., 3.),
|
||||
detail: "example".into(),
|
||||
});
|
||||
for format in ["toolpath", "validation", "setup_sheet", "stock"] {
|
||||
let expected = super::super::reference_artifact(&input, &path, Some(&report), format).unwrap();
|
||||
let checkpoints = Cell::new(0);
|
||||
let actual = ready(artifact_cooperative(
|
||||
&input,
|
||||
&path,
|
||||
Some(&report),
|
||||
format,
|
||||
|| false,
|
||||
|| {
|
||||
checkpoints.set(checkpoints.get() + 1);
|
||||
std::future::ready(())
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert!(checkpoints.get() >= 2);
|
||||
match format {
|
||||
"stock" => assert_eq!(actual, expected),
|
||||
"setup_sheet" => {
|
||||
assert_eq!(pre_values(&actual), pre_values(&expected));
|
||||
assert!(!actual.contains("<script>"));
|
||||
assert!(actual.contains("<script>"));
|
||||
}
|
||||
_ => assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&actual).unwrap(),
|
||||
serde_json::from_str::<serde_json::Value>(&expected).unwrap()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_at_each_checkpoint_never_returns_a_partial_artifact() {
|
||||
let (input, path, report) = fixture();
|
||||
for format in ["toolpath", "validation", "setup_sheet", "stock"] {
|
||||
let ticks = Cell::new(0);
|
||||
ready(artifact_cooperative(
|
||||
&input,
|
||||
&path,
|
||||
Some(&report),
|
||||
format,
|
||||
|| false,
|
||||
|| {
|
||||
ticks.set(ticks.get() + 1);
|
||||
std::future::ready(())
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
for stop in 0..=ticks.get() {
|
||||
let current = Cell::new(0);
|
||||
let result = ready(artifact_cooperative(
|
||||
&input,
|
||||
&path,
|
||||
Some(&report),
|
||||
format,
|
||||
|| current.get() >= stop,
|
||||
|| {
|
||||
current.set(current.get() + 1);
|
||||
std::future::ready(())
|
||||
},
|
||||
));
|
||||
assert!(result.unwrap_err().to_string().contains("cancelled"), "{format} checkpoint{stop}");
|
||||
}
|
||||
}
|
||||
for format in ["validation", "stock"] {
|
||||
assert!(ready(artifact_cooperative(&input, &path, None, format, || false, || std::future::ready(()))).is_err());
|
||||
}
|
||||
assert!(ready(artifact_cooperative(&input, &path, Some(&report), "unknown", || false, || std::future::ready(()))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod cam;
|
||||
pub mod catalog;
|
||||
mod design;
|
||||
mod design_metadata;
|
||||
|
||||
@@ -1612,3 +1612,9 @@ mod display_mesh_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "regularization_protection_tests.rs"]
|
||||
mod regularization_protection_tests;
|
||||
|
||||
#[path = "body_verification.rs"]
|
||||
mod verification;
|
||||
#[cfg(test)]
|
||||
#[path = "body_verification_tests.rs"]
|
||||
mod verification_tests;
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Manufacturing mesh path: no display LOD, tolerance floor or missing-face fallback.
|
||||
use super::*;
|
||||
|
||||
impl Body {
|
||||
/// Mesh at a fixed physical chord tolerance. Refinement/output exhaustion is an error.
|
||||
pub fn tessellate_for_verification(&self, tol: f64, triangle_limit: usize) -> Result<Mesh> {
|
||||
if !tol.is_finite() || tol <= 0. || triangle_limit == 0 || triangle_limit > 2_000_000 {
|
||||
return Err(KernelError::Invalid("verification tessellation needs positive finite tolerance and 1–2000000 triangles".into()));
|
||||
}
|
||||
if let Some(mesh) = &self.mesh {
|
||||
if mesh.triangles.len() > triangle_limit {
|
||||
return Err(KernelError::Failed("verification tessellation exceeds triangle budget".into()));
|
||||
}
|
||||
return Ok(mesh.to_mesh());
|
||||
}
|
||||
guard("verification tessellation", || {
|
||||
let mut out = Mesh::default();
|
||||
let mut face_index = 0_u32;
|
||||
for shell in self.solid.boundaries() {
|
||||
let mesh = truck_meshalgo::tessellation::robust_triangulation_checked(shell, tol)
|
||||
.ok_or_else(|| KernelError::Failed("verification tessellation cannot meet tolerance within refinement budget".into()))?;
|
||||
if mesh.face_iter().count() != shell.face_iter().count() {
|
||||
return Err(KernelError::Failed("verification tessellation has missing faces".into()));
|
||||
}
|
||||
for face in mesh.face_iter() {
|
||||
let pm =
|
||||
face.surface().ok_or_else(|| KernelError::Failed(format!("verification tessellation could not mesh face {face_index}")))?;
|
||||
let count = pm.faces().triangle_iter().count();
|
||||
if count == 0 || count > triangle_limit.saturating_sub(out.triangles.len()) {
|
||||
return Err(KernelError::Failed("verification tessellation has empty face or exceeds triangle budget".into()));
|
||||
}
|
||||
let flip = !face.orientation();
|
||||
for tri in pm.faces().triangle_iter() {
|
||||
let mut indices = [0; 3];
|
||||
for (slot, vertex) in indices.iter_mut().zip(tri) {
|
||||
let p = pm.positions().get(vertex.pos).ok_or_else(|| KernelError::Failed("invalid verification vertex".into()))?;
|
||||
let p = Vec3::new(p.x, p.y, p.z);
|
||||
if ![p.x, p.y, p.z].into_iter().all(f64::is_finite) {
|
||||
return Err(KernelError::Failed("non-finite verification vertex".into()));
|
||||
}
|
||||
*slot = u32::try_from(out.positions.len()).map_err(|_| KernelError::Failed("verification vertex limit".into()))?;
|
||||
out.positions.push(p);
|
||||
let n = vertex.nor.and_then(|i| pm.normals().get(i)).map(|n| Vec3::new(n.x, n.y, n.z)).unwrap_or(Vec3::Z);
|
||||
out.normals.push(if flip { -n } else { n });
|
||||
}
|
||||
out.triangles.push(if flip { [indices[0], indices[2], indices[1]] } else { indices });
|
||||
out.tri_face.push(face_index);
|
||||
}
|
||||
face_index += 1;
|
||||
}
|
||||
}
|
||||
if face_index == 0 {
|
||||
return Err(KernelError::Failed("verification tessellation has no faces".into()));
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Independent analytic checks for the fixed-tolerance verification mesh.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
// Run the original candidate-index differential tests in ordinary workspace CI;
|
||||
// the vendored crate's standalone test targets are intentionally disabled.
|
||||
#[path = "../../../vendor/truck-meshalgo/src/tessellation/boundary_index.rs"]
|
||||
mod boundary_index;
|
||||
|
||||
fn radial_error(mesh: &Mesh, radius: f64, center: Vec3) -> f64 {
|
||||
let mut error = 0_f64;
|
||||
for indices in &mesh.triangles {
|
||||
let points = mesh.tri(indices).unwrap();
|
||||
// The cylinder side spans both cap heights. Check every side edge,
|
||||
// including triangle diagonals, against the analytic radial distance.
|
||||
if points.iter().any(|p| p.z < center.z + 1.) && points.iter().any(|p| p.z > center.z + 5.) {
|
||||
for i in 0..3 {
|
||||
let midpoint = (points[i] + points[(i + 1) % 3]) * 0.5 - center;
|
||||
error = error.max((radius - midpoint.x.hypot(midpoint.y)).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_tessellation_keeps_physical_tolerance_for_large_curved_bodies() {
|
||||
let tolerance = 0.00125;
|
||||
let radius = 5000.;
|
||||
let center = Vec3::new(20_000., -10_000., 3.);
|
||||
let body = crate::cylinder(center, Vec3::Z, radius, 6.).unwrap();
|
||||
let display = body.tessellate(tolerance).unwrap();
|
||||
assert!(radial_error(&display, radius, center) > tolerance * 4.);
|
||||
let mesh = body.tessellate_for_verification(tolerance, 200_000).unwrap();
|
||||
assert!(mesh.triangles.len() > display.triangles.len());
|
||||
let volume = mesh.measure().volume;
|
||||
assert!(volume >= std::f64::consts::PI * (radius - tolerance).powi(2) * 6.);
|
||||
assert!(volume <= std::f64::consts::PI * (radius + tolerance).powi(2) * 6.);
|
||||
assert!(radial_error(&mesh, radius, center) <= tolerance, "{}", radial_error(&mesh, radius, center));
|
||||
assert_eq!(mesh.tri_face.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), body.face_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_tessellation_rejects_bad_settings_and_excessive_output() {
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 6.)).unwrap();
|
||||
for tolerance in [0., -1., f64::NAN, f64::INFINITY] {
|
||||
assert!(body.tessellate_for_verification(tolerance, 100).is_err());
|
||||
}
|
||||
for limit in [0, 2_000_001] {
|
||||
assert!(body.tessellate_for_verification(0.00125, limit).is_err());
|
||||
}
|
||||
assert!(body.tessellate_for_verification(0.00125, 11).unwrap_err().to_string().contains("triangle budget"));
|
||||
let mesh = body.tessellate_for_verification(0.00125, 12).unwrap();
|
||||
assert_eq!(mesh.triangles.len(), 12);
|
||||
let imported = crate::mesh_body(&mesh.positions, &mesh.triangles).unwrap();
|
||||
assert!(imported.tessellate_for_verification(0.00125, 11).is_err());
|
||||
assert_eq!(imported.tessellate_for_verification(0.00125, 12).unwrap().triangles.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_tessellation_does_not_accept_refinement_budget_fallback() {
|
||||
let body = crate::cylinder(Vec3::ZERO, Vec3::Z, 5000., 6.).unwrap();
|
||||
let error = body.tessellate_for_verification(1e-10, 200_000).unwrap_err();
|
||||
assert!(error.to_string().contains("refinement budget"), "{error}");
|
||||
// Curved in both parameters: the surface grid exhausts its budget before
|
||||
// any circular boundary needs the edge's 20,000-point limit.
|
||||
let sphere = crate::sphere(Vec3::ZERO, 10.).unwrap();
|
||||
let error = sphere.tessellate_for_verification(1e-5, 200_000).unwrap_err();
|
||||
assert!(error.to_string().contains("refinement budget"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_tessellation_preserves_concave_material_and_multiple_holes() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let polygon = |points: &[(f64, f64)]| Loop2::polygon(&points.iter().map(|&(x, y)| Vec2::new(x, y)).collect::<Vec<_>>());
|
||||
let region = Region2 {
|
||||
outer: polygon(&[(0., 0.), (20., 0.), (20., 10.), (10., 10.), (10., 20.), (0., 20.)]),
|
||||
holes: vec![polygon(&[(2., 2.), (2., 4.), (4., 4.), (4., 2.)]), polygon(&[(12., 2.), (12., 4.), (14., 4.), (14., 2.)])],
|
||||
};
|
||||
let bodies = crate::extrude(&Plane::XY, &[region], 0., 6.).unwrap();
|
||||
assert_eq!(bodies.len(), 1);
|
||||
let mesh = bodies.first().unwrap().tessellate_for_verification(0.00125, 200_000).unwrap();
|
||||
assert!((mesh.measure().volume - 1752.).abs() < 1e-6);
|
||||
for (point, material) in [
|
||||
(Vec3::new(1., 1., 3.), true),
|
||||
(Vec3::new(19., 9., 3.), true),
|
||||
(Vec3::new(9., 19., 3.), true),
|
||||
(Vec3::new(3., 3., 3.), false),
|
||||
(Vec3::new(13., 3., 3.), false),
|
||||
(Vec3::new(15., 15., 3.), false),
|
||||
] {
|
||||
assert_eq!(mesh.contains(point), material, "{point:?}");
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ pub use step_in::{
|
||||
};
|
||||
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
|
||||
pub use surface_extend::{ExtendLimit, ExtendMode, ExtendOptions, extend_surface};
|
||||
pub use surface_kind::{EdgeCircle, FaceSurface, edge_circle, face_surface};
|
||||
pub use surface_kind::{EdgeCircle, FaceSurface, edge_circle, face_surface, hole_axis};
|
||||
pub use surface_offset::offset_surface;
|
||||
pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface};
|
||||
pub use surface_untrim::{UntrimHoles, untrim};
|
||||
|
||||
@@ -165,6 +165,21 @@ impl FaceSurface {
|
||||
}
|
||||
}
|
||||
|
||||
/// An inward cylindrical face's axis and radius. The caller still verifies the
|
||||
/// machined result against the complete target, including trimmed face bounds.
|
||||
pub fn hole_axis(b: &Body, face: usize, tolerance: f64) -> crate::Result<(Vec3, Vec3, f64)> {
|
||||
if !tolerance.is_finite() || tolerance <= 0. {
|
||||
return Err(crate::KernelError::Invalid("invalid hole tolerance".into()));
|
||||
}
|
||||
crate::guard("hole face axis", || {
|
||||
let f = b.solid.face_iter().nth(face).ok_or_else(|| crate::KernelError::Invalid("hole face no longer exists".into()))?;
|
||||
match surf_of(f, tolerance) {
|
||||
Some(Surf::Cylinder { o, a, r, convex: false }) => Ok((o, a, r)),
|
||||
_ => Err(crate::KernelError::Invalid("select an inward cylindrical hole face".into())),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -253,6 +253,17 @@ fn groups(t: &Tokens) -> Vec<Group> {
|
||||
let ic = &t.icons;
|
||||
let a = &t.analysis;
|
||||
let mut out = vec![
|
||||
group(
|
||||
"manufacturing",
|
||||
&[
|
||||
("cam_stock", t.cam_stock),
|
||||
("cam_fixture", t.cam_fixture),
|
||||
("cam_cut", t.cam_cut),
|
||||
("cam_rapid", t.cam_rapid),
|
||||
("cam_tool", t.cam_tool),
|
||||
],
|
||||
&[("viewport_top", t.viewport_top), ("viewport_bottom", t.viewport_bottom)],
|
||||
),
|
||||
group(
|
||||
"sketch lines",
|
||||
&[
|
||||
@@ -611,7 +622,9 @@ fn changed_fields(a: &Tokens, b: &Tokens) -> BTreeSet<String> {
|
||||
|
||||
#[test]
|
||||
fn each_mode_changes_only_its_table() {
|
||||
let set = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<BTreeSet<_>>();
|
||||
let set = |s: &[&str]| {
|
||||
s.iter().copied().chain(["cam_stock", "cam_fixture", "cam_cut", "cam_rapid", "cam_tool"]).map(str::to_string).collect::<BTreeSet<_>>()
|
||||
};
|
||||
let red_green_both = [
|
||||
"triad",
|
||||
"gizmo",
|
||||
|
||||
@@ -342,13 +342,23 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
Target::Workspace => {
|
||||
let rendering = crate::render_workspace::active(app);
|
||||
let mut v = vec![
|
||||
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
|
||||
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering || app.ui.tab == "MILLING" {
|
||||
""
|
||||
} else {
|
||||
"current"
|
||||
}),
|
||||
act("ui.workspace.render", "Render", "camera")
|
||||
.with(json!({"workspace":"render"}))
|
||||
.key(if rendering { "current" } else { "" })
|
||||
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
|
||||
];
|
||||
for w in ["Animation", "Simulation", "Manufacture", "Drawing"] {
|
||||
v.push(
|
||||
act("ui.workspace.manufacture", "Manufacture", "box")
|
||||
.with(json!({"workspace":"manufacture"}))
|
||||
.key(if app.ui.tab == "MILLING" { "current" } else { "" })
|
||||
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
|
||||
);
|
||||
for w in ["Animation", "Simulation", "Drawing"] {
|
||||
v.push(act("ui.workspace", w, "").key("coming later").on(false));
|
||||
}
|
||||
v
|
||||
@@ -1042,11 +1052,16 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.toolbarCustomize" | "ui.shortcutPin" => {
|
||||
crate::toolbar_custom::run(app, &item.id, &p)
|
||||
}
|
||||
"ui.workspace" | "ui.workspace.design" | "ui.workspace.render" => {
|
||||
if let Some(workspace) = p.get("workspace").and_then(Value::as_str)
|
||||
&& let Err(e) = crate::render_workspace::enter(app, workspace == "render")
|
||||
{
|
||||
app.set_status(e, true);
|
||||
"ui.workspace" | "ui.workspace.design" | "ui.workspace.render" | "ui.workspace.manufacture" => {
|
||||
if let Some(workspace) = p.get("workspace").and_then(Value::as_str) {
|
||||
if let Err(e) = crate::render_workspace::enter(app, workspace == "render") {
|
||||
app.set_status(e, true);
|
||||
} else {
|
||||
crate::manufacturing::cancel_editor(app);
|
||||
if workspace != "render" {
|
||||
app.ui.tab = if workspace == "manufacture" { "MILLING" } else { "SOLID" }.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"ui.findJoint" => {
|
||||
|
||||
@@ -176,14 +176,17 @@ pub fn request_scope(app: &SolveApp, params: &Value) -> Result<Option<vfs::PathS
|
||||
/// mode, the section the viewport shows: an open Section Analysis dialog's preview, a sketch's
|
||||
/// Slice), with the grid and the origin's axes as `guides` says.
|
||||
pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides) -> solidcraft_engine::render::Scene {
|
||||
solidcraft_engine::view::scene_with_palette_and_guides(
|
||||
let mut scene = solidcraft_engine::view::scene_with_palette_and_guides(
|
||||
&app.session,
|
||||
&app.cam,
|
||||
crate::viewport::section_plane(app),
|
||||
solidcraft_engine::view::world_curve_parts,
|
||||
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
|
||||
guides,
|
||||
)
|
||||
);
|
||||
crate::manufacturing::view::cpu(app, &mut scene);
|
||||
crate::manufacturing::view::cpu_overlays(app, &mut scene);
|
||||
scene
|
||||
}
|
||||
|
||||
/// The grid and the origin's axes as the window shows them (the Grid and Origin switches).
|
||||
@@ -224,14 +227,20 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
match method {
|
||||
"ui.workspace" => {
|
||||
if let Some(workspace) = s("workspace") {
|
||||
if !matches!(workspace, "design" | "render") {
|
||||
return err("workspace must be design or render");
|
||||
if !matches!(workspace, "design" | "render" | "manufacture") {
|
||||
return err("workspace must be design, render or manufacture");
|
||||
}
|
||||
if let Err(e) = crate::render_workspace::enter(app, workspace == "render") {
|
||||
return err(e);
|
||||
}
|
||||
crate::manufacturing::cancel_editor(app);
|
||||
if workspace != "render" {
|
||||
app.ui.tab = if workspace == "manufacture" { "MILLING" } else { "SOLID" }.into();
|
||||
}
|
||||
}
|
||||
ok(json!({"workspace":if crate::render_workspace::active(app) {"render"} else {"design"}}))
|
||||
ok(
|
||||
json!({"workspace":if crate::render_workspace::active(app) {"render"} else if app.ui.tab == "MILLING" {"manufacture"} else {"design"}}),
|
||||
)
|
||||
}
|
||||
"engine.execute" | "command" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
@@ -267,6 +276,18 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||
"camera": app.cam,
|
||||
"tool": app.tool.as_ref().map(|t| t.cmd),
|
||||
"manufacturing": {
|
||||
"program": app.manufacturing.program,
|
||||
"job": app.manufacturing.job,
|
||||
"completed_motions": app.manufacturing.playback,
|
||||
"finding": app.manufacturing.finding,
|
||||
"playing": app.manufacturing.playing,
|
||||
"workpiece": format!("{:?}", app.manufacturing.view.workpiece),
|
||||
"fixtures": app.manufacturing.view.fixtures,
|
||||
"paths": app.manufacturing.view.paths,
|
||||
"stock_boundary": app.manufacturing.view.boundary,
|
||||
"events_per_second": app.manufacturing.view.speed,
|
||||
},
|
||||
"dialog": app.dialog.as_ref().map(|d| format!("{d:?}")),
|
||||
"status": app.status.as_ref().map(|(text, _, error)| json!({"text": text, "error": error})),
|
||||
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
|
||||
|
||||
@@ -3130,6 +3130,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
|
||||
/// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back.
|
||||
pub fn cancel(app: &mut SolveApp) {
|
||||
crate::manufacturing::cancel_editor(app);
|
||||
app.applying = None;
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
crate::dialogs_motion::closed(app, &mut d, false);
|
||||
|
||||
@@ -92,6 +92,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
// Whatever was going on in the design leaves with it.
|
||||
crate::sketch_text_move::cancel_drag(app);
|
||||
crate::dialogs::cancel(app);
|
||||
app.manufacturing = Default::default();
|
||||
crate::dim_view::cancel();
|
||||
app.tool = None;
|
||||
app.params = None;
|
||||
@@ -166,6 +167,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
if active_is_blank(app) {
|
||||
let rev = app.session.revision;
|
||||
crate::dialogs::cancel(app);
|
||||
app.manufacturing = Default::default();
|
||||
app.tool = None;
|
||||
app.menu.props = None;
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
@@ -251,6 +253,7 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
|
||||
crate::sketch_text_move::cancel_drag(app);
|
||||
let rev = app.session.revision;
|
||||
crate::dialogs::cancel(app);
|
||||
app.manufacturing = Default::default();
|
||||
app.tool = None;
|
||||
app.session.replace_design(Session::default());
|
||||
app.session.revision = rev + 1;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Native file actions must share the toolbar flow while JSON calls remain dialog-free.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
fn manufacturing(h: &mut Harness) {
|
||||
let mut input = solidcraft_engine::cam::corpus::fixture("face").unwrap();
|
||||
h.app.run("solid.box", json!({"length":20,"width":20,"height":5})).unwrap();
|
||||
input.setup.models[0].body = h.app.session.model.state().bodies[0].name.clone();
|
||||
h.app.run("cam.setup.put", json!({"setup":input.setup})).unwrap();
|
||||
h.app.run("cam.tool.put", json!({"tool":input.tools[0]})).unwrap();
|
||||
h.app.run("cam.operation.put", json!({"operation":input.operations[0]})).unwrap();
|
||||
h.app.run("cam.program.put", json!({"program":{"id":1,"name":"Facing","setup":1,"operations":[1]}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_save_and_save_as_preserve_cam_definitions_and_cancel_cleanly() {
|
||||
let mut h = Harness::new();
|
||||
manufacturing(&mut h);
|
||||
let before = serde_json::to_value(&h.app.session.doc.manufacturing).unwrap();
|
||||
let first = h.dir.join("first.solidcraft");
|
||||
let second = h.dir.join("second.solidcraft");
|
||||
let picked = first.clone();
|
||||
h.app.services.pick_save = Some(Box::new(move |_, _| {
|
||||
let path = picked.to_string_lossy().into_owned();
|
||||
Box::pin(async move { Some(path) })
|
||||
}));
|
||||
// Programmatic commands must never invoke the picker.
|
||||
assert!(h.app.run("file.save", json!({})).is_err());
|
||||
assert!(!first.exists());
|
||||
h.app.start("file.save");
|
||||
h.frames(2);
|
||||
assert!(first.exists());
|
||||
assert!(!h.app.session.is_dirty());
|
||||
h.app.run("solid.box", json!({"length":1,"width":1,"height":1})).unwrap();
|
||||
h.app.services.pick_save = Some(Box::new(|_, _| Box::pin(async { None })));
|
||||
h.app.start("file.save_as");
|
||||
h.frames(2);
|
||||
assert!(h.app.session.is_dirty());
|
||||
assert_eq!(h.app.session.path.as_deref(), first.to_str());
|
||||
let picked = second.clone();
|
||||
h.app.services.pick_save = Some(Box::new(move |_, _| {
|
||||
let path = picked.to_string_lossy().into_owned();
|
||||
Box::pin(async move { Some(path) })
|
||||
}));
|
||||
h.app.start("file.save_as");
|
||||
h.frames(2);
|
||||
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&second).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&reopened.manufacturing).unwrap(), before);
|
||||
assert_eq!(reopened.features.len(), 2);
|
||||
assert!(!h.app.session.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_save_uses_key_event_modifiers_after_same_frame_release() {
|
||||
for modifiers in [egui::Modifiers { command: true, mac_cmd: true, ..Default::default() }, egui::Modifiers::CTRL] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let count = calls.clone();
|
||||
h.app.services.pick_save = Some(Box::new(move |_, _| {
|
||||
count.set(count.get() + 1);
|
||||
Box::pin(async { None })
|
||||
}));
|
||||
let modifiers = egui::Modifiers { command: true, ..modifiers };
|
||||
let raw = egui::RawInput {
|
||||
focused: true,
|
||||
events: vec![
|
||||
egui::Event::ModifiersChanged(modifiers),
|
||||
egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::ModifiersChanged(egui::Modifiers::default()),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let _ = h.ctx.run_ui(raw, |ui| crate::toolbar::shortcuts(&mut h.app, ui.ctx()));
|
||||
assert_eq!(calls.get(), 1, "the Save picker must open, rather than plain-S command search");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_open_uses_background_loader_and_keeps_current_design_on_cancel() {
|
||||
let mut h = Harness::new();
|
||||
manufacturing(&mut h);
|
||||
let path = h.dir.join("cam.solidcraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
let before = serde_json::to_value(&h.app.session.doc.manufacturing).unwrap();
|
||||
h.app.services.pick_open = Some(Box::new(|| Box::pin(async { None })));
|
||||
h.app.start("doc.open");
|
||||
h.frames(2);
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.manufacturing).unwrap(), before);
|
||||
crate::documents::new_design(&mut h.app);
|
||||
let picked = path.clone();
|
||||
h.app.services.pick_open = Some(Box::new(move || {
|
||||
let path = picked.to_string_lossy().into_owned();
|
||||
Box::pin(async move { Some(path) })
|
||||
}));
|
||||
h.app.start("doc.open");
|
||||
h.frames(2);
|
||||
let started = std::time::Instant::now();
|
||||
while h.app.file_picker_busy() || h.app.file_load.busy() {
|
||||
h.frames(1);
|
||||
assert!(started.elapsed().as_secs() < 30);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.manufacturing).unwrap(), before);
|
||||
assert_eq!(h.app.session.path.as_deref(), path.to_str());
|
||||
}
|
||||
@@ -65,6 +65,7 @@ pub mod install;
|
||||
pub mod issue_report;
|
||||
pub mod joint_drag;
|
||||
pub mod keymap;
|
||||
pub mod manufacturing;
|
||||
mod material_card_review;
|
||||
#[cfg(test)]
|
||||
mod measurement_ui_tests;
|
||||
@@ -107,6 +108,8 @@ pub(crate) mod display_lod;
|
||||
#[cfg(test)]
|
||||
mod file_load_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod file_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod fillet_spline_handle_tests;
|
||||
pub(crate) mod navigation;
|
||||
pub mod palette;
|
||||
@@ -271,6 +274,7 @@ pub struct Services {
|
||||
}
|
||||
|
||||
pub struct SolveApp {
|
||||
pub manufacturing: manufacturing::State,
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub cam: Camera,
|
||||
@@ -420,6 +424,7 @@ impl SolveApp {
|
||||
file_picker: None,
|
||||
viewport: viewport::ViewportState::default(),
|
||||
render_workspace,
|
||||
manufacturing: Default::default(),
|
||||
tool: None,
|
||||
dialog: None,
|
||||
params: None,
|
||||
@@ -709,6 +714,31 @@ impl SolveApp {
|
||||
self.set_status(format!("{label}: {why}"), true);
|
||||
return;
|
||||
}
|
||||
match id {
|
||||
"file.save" => {
|
||||
self.home.open = false;
|
||||
return toolbar::save(self);
|
||||
}
|
||||
"file.save_as" => {
|
||||
self.home.open = false;
|
||||
return toolbar::save_as(self);
|
||||
}
|
||||
"doc.open" => {
|
||||
self.pick_open(|app, path| {
|
||||
if let Some(path) = path {
|
||||
app.open_path_async(&path);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
manufacturing::cancel_editor(self);
|
||||
if id.starts_with("cam.") {
|
||||
self.home.open = false;
|
||||
manufacturing::start(self, id);
|
||||
return;
|
||||
}
|
||||
if id == "material.properties.validate" {
|
||||
self.material_card_review = Some(material_card_review::ReviewWindow::default());
|
||||
self.home.open = false;
|
||||
@@ -1221,6 +1251,9 @@ impl SolveApp {
|
||||
|
||||
/// Per-frame logic before layout.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
manufacturing::update_exports(self, ctx);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
manufacturing::advance(self, ctx);
|
||||
file_picker::tick(self, ctx);
|
||||
render_workspace::sync_backend(self);
|
||||
render_workspace::tick(self, ctx);
|
||||
@@ -1358,7 +1391,9 @@ impl SolveApp {
|
||||
timeline::timeline(self, ui);
|
||||
}
|
||||
palette::palette_bar(self, ui);
|
||||
if self.ui.show_browser {
|
||||
if self.ui.tab == "MILLING" {
|
||||
manufacturing::panel(self, ui);
|
||||
} else if self.ui.show_browser {
|
||||
if left_timeline {
|
||||
browser::browser_below_timeline(self, ui);
|
||||
} else {
|
||||
@@ -1380,6 +1415,7 @@ impl SolveApp {
|
||||
}
|
||||
});
|
||||
dialogs::show(self, ui.ctx());
|
||||
manufacturing::editor(self, ui.ctx());
|
||||
issue_report::CadInput::block(self, ui, report_input);
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
self.resolve_fit(ui.ctx());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,96 @@
|
||||
//! Clock-independent cooperative scheduling; the web host supplies its deadline.
|
||||
|
||||
/// Poll a round-robin sequence until all jobs settle or the host budget expires.
|
||||
/// `poll` returns true for pending work. The count cap also bounds a stalled clock.
|
||||
pub(super) fn tick(cursor: &mut usize, count: usize, mut poll: impl FnMut(usize) -> bool, mut has_time: impl FnMut() -> bool) -> bool {
|
||||
if count == 0 {
|
||||
*cursor = 0;
|
||||
return false;
|
||||
}
|
||||
let mut idle = 0;
|
||||
for _ in 0..65_536 {
|
||||
if !has_time() {
|
||||
return true;
|
||||
}
|
||||
let index = *cursor % count;
|
||||
*cursor = (index + 1) % count;
|
||||
if poll(index) {
|
||||
idle = 0;
|
||||
} else {
|
||||
idle += 1;
|
||||
if idle == count {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn short_slices_do_not_starve_jobs_and_settled_jobs_stop_repainting() {
|
||||
let mut cursor = 0;
|
||||
let mut remaining = [7_usize, 1, 5];
|
||||
let mut order = Vec::new();
|
||||
for _ in 0..30 {
|
||||
let mut budget = 1;
|
||||
let pending = tick(
|
||||
&mut cursor,
|
||||
remaining.len(),
|
||||
|i| {
|
||||
order.push(i);
|
||||
remaining[i] = remaining[i].saturating_sub(1usize);
|
||||
remaining[i] > 0
|
||||
},
|
||||
|| {
|
||||
let more = budget > 0;
|
||||
budget -= usize::from(more);
|
||||
more
|
||||
},
|
||||
);
|
||||
assert!(pending);
|
||||
}
|
||||
assert!(order.as_chunks::<3>().0.iter().all(|chunk| *chunk == [0, 1, 2]));
|
||||
assert_eq!(remaining, [0; 3]);
|
||||
assert!(!tick(&mut cursor, 3, |_| false, || true));
|
||||
// Removing completed jobs must tolerate a cursor past the new length.
|
||||
cursor = 12;
|
||||
assert!(!tick(
|
||||
&mut cursor,
|
||||
1,
|
||||
|i| {
|
||||
assert_eq!(i, 0);
|
||||
false
|
||||
},
|
||||
|| true
|
||||
));
|
||||
assert!(!tick(&mut cursor, 0, |_| true, || true));
|
||||
}
|
||||
#[test]
|
||||
fn clock_stall_and_empty_budget_are_bounded() {
|
||||
let mut cursor = 0;
|
||||
let mut polls = 0;
|
||||
assert!(tick(
|
||||
&mut cursor,
|
||||
2,
|
||||
|_| {
|
||||
polls += 1;
|
||||
true
|
||||
},
|
||||
|| false
|
||||
));
|
||||
assert_eq!(polls, 0);
|
||||
assert!(tick(
|
||||
&mut cursor,
|
||||
2,
|
||||
|_| {
|
||||
polls += 1;
|
||||
true
|
||||
},
|
||||
|| true
|
||||
));
|
||||
assert_eq!(polls, 65_536);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Manufacture display choices. Geometry comes from immutable engine jobs;
|
||||
//! this module changes neither the model nor the verification result.
|
||||
use super::*;
|
||||
use crate::theme::Tokens;
|
||||
use solidcraft_engine::{
|
||||
geom::{Aabb3, Mesh},
|
||||
render::{Rgb, Scene, SceneMesh},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
hash::{Hash, Hasher},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Workpiece {
|
||||
#[default]
|
||||
Model,
|
||||
InitialStock,
|
||||
RemainingStock,
|
||||
}
|
||||
pub struct View {
|
||||
pub workpiece: Workpiece,
|
||||
pub fixtures: bool,
|
||||
pub paths: bool,
|
||||
pub boundary: bool,
|
||||
pub speed: f64,
|
||||
pub loaded_job: Option<u64>,
|
||||
pub fraction: f64,
|
||||
}
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
Self { workpiece: Workpiece::Model, fixtures: true, paths: true, boundary: true, speed: 60., loaded_job: None, fraction: 0. }
|
||||
}
|
||||
}
|
||||
pub fn setup(app: &SolveApp) -> Option<&cam::Setup> {
|
||||
if let Some(Editor::Setup(setup)) = &app.manufacturing.editor {
|
||||
return Some(setup);
|
||||
}
|
||||
let m = &app.session.doc.manufacturing;
|
||||
let program = m.programs.iter().find(|p| p.id == app.manufacturing.program).or_else(|| m.programs.first());
|
||||
program.and_then(|p| m.setups.iter().find(|s| s.id == p.setup)).or_else(|| m.setups.first())
|
||||
}
|
||||
pub fn corners(stock: cam::Stock) -> [Vec3; 8] {
|
||||
let a = stock.min;
|
||||
let b = stock.max;
|
||||
[
|
||||
Vec3::new(a.x, a.y, a.z),
|
||||
Vec3::new(b.x, a.y, a.z),
|
||||
Vec3::new(b.x, b.y, a.z),
|
||||
Vec3::new(a.x, b.y, a.z),
|
||||
Vec3::new(a.x, a.y, b.z),
|
||||
Vec3::new(b.x, a.y, b.z),
|
||||
Vec3::new(b.x, b.y, b.z),
|
||||
Vec3::new(a.x, b.y, b.z),
|
||||
]
|
||||
}
|
||||
pub fn bounds(app: &SolveApp) -> Aabb3 {
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
if app.ui.tab != "MILLING" {
|
||||
return bounds;
|
||||
}
|
||||
if let Some(setup) = setup(app).filter(|s| s.validate().is_ok()) {
|
||||
for p in corners(setup.stock) {
|
||||
bounds.add(setup.frame.world(p));
|
||||
}
|
||||
for p in corners(cam::Stock { min: setup.stock.min, max: Vec3::new(setup.stock.max.x, setup.stock.max.y, setup.clearance) }) {
|
||||
bounds.add(setup.frame.world(p));
|
||||
}
|
||||
}
|
||||
bounds
|
||||
}
|
||||
pub fn fresh_job(app: &SolveApp) -> Option<Arc<solidcraft_engine::manufacturing::Job>> {
|
||||
let job = solidcraft_engine::manufacturing::job(&app.session, app.manufacturing.job?).ok()?;
|
||||
solidcraft_engine::manufacturing::current(&app.session, &job).ok()?;
|
||||
Some(job)
|
||||
}
|
||||
pub fn stock(app: &SolveApp) -> Option<Arc<Mesh>> {
|
||||
if app.ui.tab != "MILLING" || matches!(app.manufacturing.editor, Some(Editor::Setup(_))) {
|
||||
return None;
|
||||
}
|
||||
match app.manufacturing.view.workpiece {
|
||||
Workpiece::Model => None,
|
||||
Workpiece::RemainingStock => fresh_job(app)?.stock_mesh().ok().flatten(),
|
||||
Workpiece::InitialStock => {
|
||||
let setup = setup(app).filter(|s| s.validate().is_ok())?;
|
||||
let points = corners(setup.stock).map(|p| setup.frame.world(p));
|
||||
let mut mesh = Mesh::default();
|
||||
for face in [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]] {
|
||||
for indices in [[face[0], face[1], face[2]], [face[0], face[2], face[3]]] {
|
||||
let t = indices.map(|i| points[i]);
|
||||
let n = (t[1] - t[0]).cross(t[2] - t[0]).normalized().unwrap_or(Vec3::Z);
|
||||
let base = mesh.positions.len() as u32;
|
||||
mesh.positions.extend(t);
|
||||
mesh.normals.extend([n; 3]);
|
||||
mesh.triangles.push([base, base + 1, base + 2]);
|
||||
}
|
||||
}
|
||||
Some(Arc::new(mesh))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn names(app: &SolveApp, refs: &[cam::BodyRef]) -> BTreeSet<String> {
|
||||
refs.iter()
|
||||
.filter_map(|r| {
|
||||
if r.occurrence.is_empty() {
|
||||
Some(r.body.clone())
|
||||
} else {
|
||||
app.session.occurrence_placement(&r.occurrence).ok()?.bodies.into_iter().find(|b| b.source == r.body).map(|b| b.world)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub struct Bodies {
|
||||
models: BTreeSet<String>,
|
||||
fixtures: BTreeSet<String>,
|
||||
active: bool,
|
||||
show_model: bool,
|
||||
show_fixtures: bool,
|
||||
}
|
||||
impl Bodies {
|
||||
pub fn new(app: &SolveApp) -> Self {
|
||||
let setup = setup(app).filter(|_| app.ui.tab == "MILLING");
|
||||
Self {
|
||||
models: setup.map(|s| names(app, &s.models)).unwrap_or_default(),
|
||||
fixtures: setup.map(|s| names(app, &s.fixtures)).unwrap_or_default(),
|
||||
active: setup.is_some() && !matches!(app.manufacturing.editor, Some(Editor::Setup(_))),
|
||||
show_model: stock(app).is_none(),
|
||||
show_fixtures: app.manufacturing.view.fixtures,
|
||||
}
|
||||
}
|
||||
pub fn visible(&self, name: &str) -> bool {
|
||||
!self.active || (self.fixtures.contains(name) && self.show_fixtures) || (self.models.contains(name) && self.show_model)
|
||||
}
|
||||
pub fn color(&self, name: &str) -> Option<Color32> {
|
||||
(self.active && self.fixtures.contains(name)).then_some(Tokens::get().cam_fixture)
|
||||
}
|
||||
}
|
||||
pub fn key(app: &SolveApp, h: &mut impl Hasher) {
|
||||
let active = app.ui.tab == "MILLING";
|
||||
active.hash(h);
|
||||
if !active {
|
||||
return;
|
||||
}
|
||||
matches!(app.manufacturing.editor, Some(Editor::Setup(_))).hash(h);
|
||||
if let Some(Editor::Setup(setup)) = &app.manufacturing.editor {
|
||||
for r in &setup.fixtures {
|
||||
r.body.hash(h);
|
||||
r.occurrence.hash(h);
|
||||
}
|
||||
}
|
||||
app.manufacturing.program.hash(h);
|
||||
app.manufacturing.job.hash(h);
|
||||
app.manufacturing.view.workpiece.hash(h);
|
||||
app.manufacturing.view.fixtures.hash(h);
|
||||
fresh_job(app).and_then(|j| j.stock_mesh().ok().flatten()).map(|m| Arc::as_ptr(&m) as usize).hash(h);
|
||||
}
|
||||
pub fn gpu(app: &SolveApp, scene: &mut crate::gpu::GpuScene) {
|
||||
let Some(mesh) = stock(app) else { return };
|
||||
let color = Tokens::get().cam_stock.to_array();
|
||||
for indices in &mesh.triangles {
|
||||
for i in indices {
|
||||
if let (Some(p), Some(n)) = (mesh.positions.get(*i as usize), mesh.normals.get(*i as usize)) {
|
||||
scene.tri(p.to_f32(), n.to_f32(), color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn cpu(app: &SolveApp, scene: &mut Scene) {
|
||||
if app.ui.tab != "MILLING" {
|
||||
return;
|
||||
}
|
||||
// Replace the model mesh list with the selected machining bodies, preserving
|
||||
// occurrence meshes and applying the same visibility/color choices as GPU rendering.
|
||||
let bodies = Bodies::new(app);
|
||||
let world = app.session.world_state();
|
||||
scene.meshes.clear();
|
||||
for body in &world.bodies {
|
||||
if !app.session.body_visible(&body.name) || !bodies.visible(&body.name) {
|
||||
continue;
|
||||
}
|
||||
let c = bodies.color(&body.name).unwrap_or(Tokens::get().body);
|
||||
scene.meshes.push(SceneMesh { mesh: body.mesh(), color: Rgb(c.r(), c.g(), c.b()), face_colors: vec![] });
|
||||
}
|
||||
// CAD edge lines would reveal hidden target geometry inside the stock preview.
|
||||
// CAM outlines and paths are drawn by the shared overlay afterwards.
|
||||
scene.lines.clear();
|
||||
if let Some(mesh) = stock(app) {
|
||||
let c = Tokens::get().cam_stock;
|
||||
scene.meshes.push(SceneMesh { mesh, color: Rgb(c.r(), c.g(), c.b()), face_colors: vec![] });
|
||||
}
|
||||
scene.radius = scene.radius.max(bounds(app).diagonal());
|
||||
}
|
||||
|
||||
/// Complete motion blocks. Zero is the initial pose, never a sentinel for all.
|
||||
pub fn advance(state: &mut State, count: usize, dt: f64) {
|
||||
if state.view.loaded_job != state.job {
|
||||
state.view.loaded_job = state.job;
|
||||
state.playback = count;
|
||||
state.playing = false;
|
||||
state.view.fraction = 0.;
|
||||
}
|
||||
state.playback = state.playback.min(count);
|
||||
if !state.playing {
|
||||
return;
|
||||
}
|
||||
state.view.fraction += dt.clamp(0., 0.1) * state.view.speed.clamp(1., 1000.);
|
||||
let steps = state.view.fraction.floor() as usize;
|
||||
state.view.fraction -= steps as f64;
|
||||
state.playback = state.playback.saturating_add(steps).min(count);
|
||||
if state.playback == count {
|
||||
state.playing = false;
|
||||
state.view.fraction = 0.;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the same motion prefix for native overlays and CPU snapshots.
|
||||
pub fn segments(path: &cam::Toolpath, setup: &cam::Setup, completed: usize, mut line: impl FnMut(Vec3, Vec3, bool)) {
|
||||
let mut pos = Vec3::new(0., 0., setup.clearance);
|
||||
for block in path.blocks.iter().take(completed) {
|
||||
match block.motion {
|
||||
cam::Motion::Rapid { to } | cam::Motion::Linear { to, .. } => {
|
||||
line(setup.frame.world(pos), setup.frame.world(to), matches!(block.motion, cam::Motion::Rapid { .. }));
|
||||
pos = to;
|
||||
}
|
||||
cam::Motion::Arc { to, center, clockwise, .. } => {
|
||||
let start = (pos.y - center.y).atan2(pos.x - center.x);
|
||||
let end = (to.y - center.y).atan2(to.x - center.x);
|
||||
let delta = (end - start).rem_euclid(std::f64::consts::TAU);
|
||||
let sweep = if clockwise {
|
||||
if delta < 1e-12 { -std::f64::consts::TAU } else { delta - std::f64::consts::TAU }
|
||||
} else if delta < 1e-12 {
|
||||
std::f64::consts::TAU
|
||||
} else {
|
||||
delta
|
||||
};
|
||||
let radius = (pos.x - center.x).hypot(pos.y - center.y);
|
||||
let angle = (2. * (1. - 0.02 / radius).clamp(-1., 1.).acos()).max(0.001);
|
||||
let n = (sweep.abs() / angle).ceil().clamp(1., 512.) as usize;
|
||||
let mut previous = pos;
|
||||
for i in 1..=n {
|
||||
let theta = start + sweep * i as f64 / n as f64;
|
||||
let p = if i == n { to } else { Vec3::new(center.x + radius * theta.cos(), center.y + radius * theta.sin(), to.z) };
|
||||
line(setup.frame.world(previous), setup.frame.world(p), false);
|
||||
previous = p;
|
||||
}
|
||||
pos = to;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn severity_color(severity: cam::verify::FindingSeverity) -> Color32 {
|
||||
match severity {
|
||||
cam::verify::FindingSeverity::Error => Tokens::get().error,
|
||||
cam::verify::FindingSeverity::Unresolved => Tokens::get().warning,
|
||||
cam::verify::FindingSeverity::Information => Tokens::get().cam_stock,
|
||||
}
|
||||
}
|
||||
pub fn finding_marker(app: &SolveApp) -> Option<(Vec3, Color32)> {
|
||||
let index = app.manufacturing.finding?;
|
||||
let job = fresh_job(app)?;
|
||||
let JobState::Verified(_, report) = job.state().ok()? else { return None };
|
||||
let finding = report.findings.get(index)?;
|
||||
Some((job.input().ok()?.setup.frame.world(finding.at), severity_color(finding.severity)))
|
||||
}
|
||||
pub fn cpu_overlays(app: &SolveApp, scene: &mut Scene) {
|
||||
if app.ui.tab != "MILLING" {
|
||||
return;
|
||||
}
|
||||
let tokens = Tokens::get();
|
||||
let mut line = |a: Vec3, b: Vec3, c: Color32| {
|
||||
scene.lines.push(solidcraft_engine::render::SceneLine { points: vec![a, b], color: Rgb(c.r(), c.g(), c.b()), width: 1.2, on_top: true })
|
||||
};
|
||||
if app.manufacturing.view.boundary
|
||||
&& let Some(setup) = setup(app)
|
||||
{
|
||||
let points = corners(setup.stock).map(|p| setup.frame.world(p));
|
||||
for (a, b) in [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)] {
|
||||
line(points[a], points[b], tokens.cam_stock);
|
||||
}
|
||||
}
|
||||
if let Some((point, color)) = finding_marker(app) {
|
||||
let size = setup(app).map_or(1., |s| (s.stock.max - s.stock.min).len() / 100.);
|
||||
for delta in [Vec3::X * size, Vec3::Y * size, Vec3::Z * size] {
|
||||
line(point - delta, point + delta, color);
|
||||
}
|
||||
}
|
||||
if !app.manufacturing.view.paths {
|
||||
return;
|
||||
}
|
||||
let Some(job) = fresh_job(app) else { return };
|
||||
let Ok(input) = job.input() else { return };
|
||||
let path = match job.state() {
|
||||
Ok(JobState::Generated(p) | JobState::Verified(p, _)) => p,
|
||||
_ => return,
|
||||
};
|
||||
segments(&path, &input.setup, app.manufacturing.playback, |a, b, rapid| line(a, b, if rapid { tokens.cam_rapid } else { tokens.cam_cut }));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn playback_zero_is_initial_and_rate_is_independent_of_frames() {
|
||||
let mut s = State { job: Some(1), ..Default::default() };
|
||||
advance(&mut s, 100, 0.);
|
||||
assert_eq!(s.playback, 100);
|
||||
s.playback = 0;
|
||||
advance(&mut s, 100, 0.1);
|
||||
assert_eq!(s.playback, 0);
|
||||
for fps in [10, 30, 60] {
|
||||
s.playback = 0;
|
||||
s.playing = true;
|
||||
s.view.fraction = 0.;
|
||||
for _ in 0..fps {
|
||||
advance(&mut s, 100, 1. / fps as f64);
|
||||
}
|
||||
assert_eq!(s.playback, 60);
|
||||
}
|
||||
s.job = Some(2);
|
||||
advance(&mut s, 10, 0.1);
|
||||
assert_eq!(s.playback, 10);
|
||||
assert!(!s.playing);
|
||||
}
|
||||
#[test]
|
||||
fn full_circle_playback_preserves_direction_and_empty_prefix() {
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
for clockwise in [true, false] {
|
||||
let path = cam::Toolpath {
|
||||
fingerprint: "test".into(),
|
||||
blocks: vec![
|
||||
cam::Block { operation: 1, motion: cam::Motion::Rapid { to: Vec3::new(1., 0., 0.) } },
|
||||
cam::Block { operation: 1, motion: cam::Motion::Arc { to: Vec3::new(1., 0., 0.), center: Vec3::ZERO, clockwise, feed: 100. } },
|
||||
],
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
segments(&path, &input.setup, 0, |a, b, _| lines.push((a, b)));
|
||||
assert!(lines.is_empty());
|
||||
segments(&path, &input.setup, 2, |a, b, rapid| {
|
||||
if !rapid {
|
||||
lines.push((a, b))
|
||||
}
|
||||
});
|
||||
assert!(lines.len() > 8);
|
||||
assert_eq!(lines.last().unwrap().1, Vec3::new(1., 0., 0.));
|
||||
assert_eq!(lines[0].1.y < 0., clockwise);
|
||||
let length: f64 = lines.iter().map(|(a, b)| a.dist(*b)).sum();
|
||||
assert!((length - std::f64::consts::TAU).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stock_display_uses_setup_frame_and_respects_fixture_visibility() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":20,"width":20,"height":5})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":3,"width":3,"height":3})).unwrap();
|
||||
let names: Vec<_> = h.app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let mut input = cam::corpus::fixture("face").unwrap();
|
||||
input.setup.models[0].body = names[0].clone();
|
||||
input.setup.fixtures = vec![cam::BodyRef { body: names[1].clone(), occurrence: vec![] }];
|
||||
input.setup.frame = cam::Frame { origin: Vec3::new(40., 30., 2.), x: Vec3::Y, y: -Vec3::X, z: Vec3::Z };
|
||||
h.app.run("cam.setup.put", json!({"setup":input.setup})).unwrap();
|
||||
h.app.ui.tab = "MILLING".into();
|
||||
h.app.manufacturing.view.workpiece = Workpiece::InitialStock;
|
||||
let mesh = stock(&h.app).unwrap();
|
||||
let b = mesh.bounds();
|
||||
assert_eq!(b.min, Vec3::new(20., 30., 2.));
|
||||
assert_eq!(b.max, Vec3::new(40., 50., 8.));
|
||||
assert!((mesh.measure().volume - 2400.).abs() < 1e-8);
|
||||
let bodies = Bodies::new(&h.app);
|
||||
assert!(!bodies.visible(&names[0]));
|
||||
assert!(bodies.visible(&names[1]));
|
||||
assert_eq!(bodies.color(&names[1]), Some(Tokens::get().cam_fixture));
|
||||
h.app.manufacturing.view.fixtures = false;
|
||||
assert!(!Bodies::new(&h.app).visible(&names[1]));
|
||||
let mut scene = solidcraft_engine::view::scene(&h.app.session, &h.app.cam);
|
||||
cpu(&h.app, &mut scene);
|
||||
assert_eq!(scene.meshes.len(), 1);
|
||||
assert_eq!(scene.meshes[0].mesh.bounds().min, b.min);
|
||||
h.app.manufacturing.editor = Some(Editor::Setup(input.setup));
|
||||
assert!(Bodies::new(&h.app).visible(&names[1]));
|
||||
assert!(stock(&h.app).is_none());
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,12 @@ pub struct Tokens {
|
||||
pub preview_sketch: Color32,
|
||||
/// Removed material in a feature preview (translucent).
|
||||
pub preview_cut: Color32,
|
||||
// Manufacturing display.
|
||||
pub cam_stock: Color32,
|
||||
pub cam_fixture: Color32,
|
||||
pub cam_cut: Color32,
|
||||
pub cam_rapid: Color32,
|
||||
pub cam_tool: Color32,
|
||||
/// Navigation cube faces, outline and labels.
|
||||
pub cube_face: Color32,
|
||||
pub cube_border: Color32,
|
||||
|
||||
@@ -51,6 +51,7 @@ pub fn panels(tab: &str) -> &'static [&'static str] {
|
||||
return p;
|
||||
}
|
||||
match tab {
|
||||
"MILLING" => &["SETUP", "TOOLPATHS", "VERIFY", "EXPORT"],
|
||||
"SOLID" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
"SKETCH" => &["CREATE", "MODIFY", "PATTERN", "CONSTRAINTS", "INSPECT", "INSERT", "SELECT", "FINISH SKETCH"],
|
||||
"SURFACE" => &["CREATE", "MODIFY", "ASSEMBLE", "CONSTRUCT", "INSPECT", "INSERT", "SELECT"],
|
||||
@@ -545,7 +546,7 @@ pub fn save(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_as(app: &mut SolveApp) {
|
||||
pub(crate) fn save_as(app: &mut SolveApp) {
|
||||
save_as_with_extension(app, solidcraft_engine::io::DESIGN_EXT);
|
||||
}
|
||||
|
||||
@@ -601,7 +602,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
painter.text(
|
||||
pos2(ws.center().x - 5.0, ws.top() + 52.0),
|
||||
Align2::CENTER_CENTER,
|
||||
app.tr_label(if crate::render_workspace::active(app) { "RENDER" } else { crate::toolbar_custom::WORKSPACE }),
|
||||
app.tr_label(if app.ui.tab == "MILLING" {
|
||||
"Manufacture"
|
||||
} else if crate::render_workspace::active(app) {
|
||||
"RENDER"
|
||||
} else {
|
||||
crate::toolbar_custom::WORKSPACE
|
||||
}),
|
||||
FontId::proportional(12.0),
|
||||
t.text,
|
||||
);
|
||||
@@ -617,7 +624,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if sketching {
|
||||
tabs.push("SKETCH");
|
||||
}
|
||||
if crate::render_workspace::active(app) {
|
||||
if app.ui.tab == "MILLING" {
|
||||
tabs.push("MILLING");
|
||||
} else if crate::render_workspace::active(app) {
|
||||
tabs.push("RENDER");
|
||||
} else {
|
||||
tabs.extend(TABS.iter());
|
||||
@@ -1292,10 +1301,12 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if ctx.egui_wants_keyboard_input() || crate::dim_view::editing() || crate::dialogs_save_mesh::popup_open(ctx) {
|
||||
return;
|
||||
}
|
||||
let (keys, mods) = ctx.input(|i| {
|
||||
let keys: Vec<egui::Key> =
|
||||
i.events.iter().filter_map(|e| if let egui::Event::Key { key, pressed: true, .. } = e { Some(*key) } else { None }).collect();
|
||||
(keys, i.modifiers)
|
||||
// A modifier released later in this frame must not turn Cmd+S into plain S.
|
||||
let keys: Vec<_> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
|
||||
.collect()
|
||||
});
|
||||
// Home hides the design: only New, Open and Preferences work there.
|
||||
let shown = design_shown(app);
|
||||
@@ -1304,7 +1315,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
crate::edit_clipboard::flush(ctx);
|
||||
}
|
||||
for k in keys {
|
||||
for (k, mods) in keys {
|
||||
use egui::Key;
|
||||
if shown && crate::navigation::view_key(app, ctx, k, mods) {
|
||||
continue;
|
||||
|
||||
@@ -280,6 +280,7 @@ pub fn view_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 {
|
||||
fn scene_key(app: &SolveApp) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
app.session.revision.hash(&mut h);
|
||||
crate::manufacturing::view::key(app, &mut h);
|
||||
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
|
||||
app.ui.show_grid.hash(&mut h);
|
||||
app.ui.visual_style.hash(&mut h);
|
||||
@@ -617,6 +618,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let s = &app.session;
|
||||
// Bodies where their occurrences place them.
|
||||
let st = s.world_state();
|
||||
let cam_bodies = crate::manufacturing::view::Bodies::new(app);
|
||||
let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok());
|
||||
let (shaded, edges) = match app.ui.visual_style {
|
||||
1 => (true, false),
|
||||
@@ -633,16 +635,17 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let mut run: Vec<BodyDraw> = Vec::new();
|
||||
let mut run_key = std::collections::hash_map::DefaultHasher::new();
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) {
|
||||
if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) || !cam_bodies.visible(&b.name) {
|
||||
continue;
|
||||
}
|
||||
let faded = body_faded(app, b, &active_bodies);
|
||||
let base = body_color(&s.doc, b).unwrap_or(tk.body);
|
||||
let cam_color = cam_bodies.color(&b.name);
|
||||
let base = cam_color.or_else(|| body_color(&s.doc, b)).unwrap_or(tk.body);
|
||||
let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) };
|
||||
// Appearances: the body's opacity, and faces with looks of their own.
|
||||
let opacity = s.doc.model_body_opacity(b);
|
||||
let face_looks: std::collections::HashMap<usize, solidcraft_engine::doc::appearance::Look> =
|
||||
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||||
if shaded && cam_color.is_none() { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||||
let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999);
|
||||
let planned = drawn_plan(app, b, opaque);
|
||||
let on_gpu = planned.is_some();
|
||||
@@ -673,13 +676,14 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
if let Ok(mut b) = app.viewport.blocks.lock() {
|
||||
*b = made;
|
||||
}
|
||||
crate::manufacturing::view::gpu(app, &mut sc);
|
||||
if app.ui.ground_shadow && app.ui.visual_style != 2 && app.cam.pitch > 0.0 {
|
||||
ground_shadow(&mut sc, app, &st);
|
||||
}
|
||||
// Section: the outline of each body where the plane cuts it.
|
||||
if let Some((o, n)) = section_plane(app) {
|
||||
let col = c4(tk.text.gamma_multiply(0.9));
|
||||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) {
|
||||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && cam_bodies.visible(&b.name)) {
|
||||
for (a, c) in solidcraft_engine::view::section_segments(&b.mesh(), o, n) {
|
||||
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
|
||||
}
|
||||
@@ -787,8 +791,12 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &std::sync::Arc<solidcra
|
||||
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
|
||||
let mut b = solidcraft_engine::geom::Aabb3::EMPTY;
|
||||
let boxes = body_bounds(app, st);
|
||||
for (x, (_, bb)) in st.bodies.iter().zip(boxes.iter()) {
|
||||
if bb.is_empty() || !app.session.body_visible(&x.name) {
|
||||
let bodies = crate::manufacturing::view::Bodies::new(app);
|
||||
let stock = crate::manufacturing::view::stock(app);
|
||||
let visible =
|
||||
st.bodies.iter().zip(boxes.iter()).filter(|(x, _)| app.session.body_visible(&x.name) && bodies.visible(&x.name)).map(|(_, (_, bb))| *bb);
|
||||
for bb in visible.chain(stock.iter().map(|mesh| mesh.bounds())) {
|
||||
if bb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for i in 0..8 {
|
||||
@@ -1010,7 +1018,9 @@ type BodyBounds = (std::sync::Arc<solidcraft_engine::doc::ModelState>, std::sync
|
||||
/// The model's extent: bodies (from the cached boxes) and sketches.
|
||||
pub fn model_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 {
|
||||
let st = app.session.world_state();
|
||||
let mut b = body_bounds(app, &st).iter().fold(solidcraft_engine::geom::Aabb3::EMPTY, |a, (_, x)| a.union(x));
|
||||
let bodies = crate::manufacturing::view::Bodies::new(app);
|
||||
let mut b =
|
||||
body_bounds(app, &st).iter().filter(|(name, _)| bodies.visible(name)).fold(crate::manufacturing::view::bounds(app), |a, (_, x)| a.union(x));
|
||||
for ss in &st.sketches {
|
||||
if let Some((lo, hi)) = ss.sketch.bounds() {
|
||||
for p in [lo, hi] {
|
||||
@@ -1083,6 +1093,7 @@ pub(crate) fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
app.session.revision.hash(&mut h);
|
||||
crate::manufacturing::view::key(app, &mut h);
|
||||
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
crate::sketch3d::drawing(app).hash(&mut h);
|
||||
@@ -1179,6 +1190,7 @@ impl<T: PartialOrd + Copy> Near<T> {
|
||||
}
|
||||
|
||||
fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec<Hit> {
|
||||
let cam_bodies = crate::manufacturing::view::Bodies::new(app);
|
||||
let s = &app.session;
|
||||
let st = s.world_state();
|
||||
let bounds = body_bounds(app, &st);
|
||||
@@ -1248,7 +1260,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
|
||||
let mut bestf: Option<(f64, Hit)> = None;
|
||||
let mut faces = Near::new(all);
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
if !app.session.body_visible(&b.name) || !cam_bodies.visible(&b.name) {
|
||||
continue;
|
||||
}
|
||||
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped.
|
||||
@@ -1280,7 +1292,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
|
||||
let mut bestv = Near::new(all);
|
||||
let mut beste = Near::new(all);
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
if !app.session.body_visible(&b.name) || !cam_bodies.visible(&b.name) {
|
||||
continue;
|
||||
}
|
||||
// Only bodies whose outline on screen is near the cursor can have an edge under it.
|
||||
@@ -1812,6 +1824,7 @@ fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32) {
|
||||
fn highlight_key(app: &SolveApp) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
app.session.revision.hash(&mut h);
|
||||
crate::manufacturing::view::key(app, &mut h);
|
||||
serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h);
|
||||
format!("{:?}", app.viewport.hover).hash(&mut h);
|
||||
app.viewport.hover_feature.hash(&mut h);
|
||||
@@ -1837,6 +1850,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
|
||||
|
||||
/// The highlight scene: origin widget, construction planes, selection and hover.
|
||||
fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
let cam_bodies = crate::manufacturing::view::Bodies::new(app);
|
||||
let t = Tokens::get();
|
||||
let mut sc = GpuScene::default();
|
||||
let st = app.session.world_state();
|
||||
@@ -1847,12 +1861,23 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
let mut sel = Vec::new();
|
||||
for item in app.highlighted() {
|
||||
for piece in displayed_selection(app, &item) {
|
||||
let body = match &piece {
|
||||
Sel::Body { name } => Some(name),
|
||||
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body),
|
||||
_ => None,
|
||||
};
|
||||
if body.is_some_and(|name| !cam_bodies.visible(name)) {
|
||||
continue;
|
||||
}
|
||||
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
|
||||
sel.push(piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
let hover = app.viewport.hover.as_ref();
|
||||
let hover = app.viewport.hover.as_ref().filter(|h| match h {
|
||||
Hit::Face { body, .. } | Hit::Edge { body, .. } | Hit::Vertex { body, .. } => cam_bodies.visible(body),
|
||||
_ => true,
|
||||
});
|
||||
let size = origin_size(app.cam.half_height());
|
||||
// Origin planes and construction planes.
|
||||
let plane_state = |name: &str| {
|
||||
@@ -1980,7 +2005,8 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
// Timeline modifiers light only their resulting faces; browser rows light whole bodies.
|
||||
let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default();
|
||||
hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() }));
|
||||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name)) {
|
||||
for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name) && cam_bodies.visible(&b.name))
|
||||
{
|
||||
let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
|
||||
let faces: Vec<_> = if whole {
|
||||
(0..b.body.face_count()).collect()
|
||||
@@ -2788,6 +2814,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||
if app.applying.is_some() {
|
||||
return;
|
||||
}
|
||||
let bodies = crate::manufacturing::view::Bodies::new(app);
|
||||
let st = app.session.world_state();
|
||||
let mut out: Vec<Sel> = Vec::new();
|
||||
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };
|
||||
@@ -2851,7 +2878,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||
} else {
|
||||
let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES);
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
if !app.session.body_visible(&b.name) || !bodies.visible(&b.name) {
|
||||
continue;
|
||||
}
|
||||
let m = b.mesh();
|
||||
@@ -2938,6 +2965,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||
/// Screen-space dots: the origin point, and hovered or selected body vertices.
|
||||
fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let t = Tokens::get();
|
||||
let bodies = crate::manufacturing::view::Bodies::new(app);
|
||||
if app.origin_visible()
|
||||
&& !app.session.visibility.hidden_origin.iter().any(|h| h == "O")
|
||||
&& let Some(o) = proj.to_screen(Vec3::ZERO)
|
||||
@@ -2945,13 +2973,15 @@ fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110)));
|
||||
}
|
||||
for x in app.highlighted() {
|
||||
if let Sel::Vertex { point, .. } = x
|
||||
if let Sel::Vertex { point, body, .. } = x
|
||||
&& bodies.visible(&body)
|
||||
&& let Some(p) = proj.to_screen(point)
|
||||
{
|
||||
painter.circle(p, 4.0, t.sel_vertex, Stroke::new(1.5, t.sel_edge));
|
||||
}
|
||||
}
|
||||
if let Some(Hit::Vertex { point, .. }) = &app.viewport.hover
|
||||
if let Some(Hit::Vertex { point, body, .. }) = &app.viewport.hover
|
||||
&& bodies.visible(body)
|
||||
&& let Some(p) = proj.to_screen(*point)
|
||||
{
|
||||
painter.circle(p, 4.5, t.hover_edge_halo, Stroke::new(1.5, t.hover_edge));
|
||||
@@ -2986,6 +3016,7 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
|
||||
/// Sketch points and the status of the active sketch (constraint glyphs: `sketch_tools`).
|
||||
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
crate::manufacturing::overlay(app, painter, proj);
|
||||
let t = Tokens::get();
|
||||
let st = app.session.world_state();
|
||||
for item in &app.session.selection {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Original native CAD cases driven through the shared UI command and widget paths.
|
||||
//! Headless frames exercise layout/input; GPU snapshots exercise the stock display.
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::cam;
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
#[test]
|
||||
fn four_strategies_generate_simulate_post_and_display_through_ui() {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, camera, width, height| {
|
||||
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, camera, width, height)?;
|
||||
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
|
||||
}));
|
||||
for name in ["face", "contour", "pocket", "drill"] {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.resize", json!({"width":1500,"height":1200}));
|
||||
h.app.run("solid.box", json!({"length":20,"width":20,"height":if name=="face" {5} else {6}})).unwrap();
|
||||
if name != "face" {
|
||||
h.app.run("solid.hole",json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":if name=="drill" {1} else {16},"depth":1,"type":if name=="drill" {"drilled"} else {"simple"},"tip_angle":118})).unwrap();
|
||||
}
|
||||
let mut input = cam::corpus::fixture(name).unwrap();
|
||||
input.setup.models[0].body = h.app.session.model.state().bodies[0].name.clone();
|
||||
h.app.run("cam.setup.put", json!({"setup":input.setup})).unwrap();
|
||||
if name == "contour" || name == "pocket" {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Pocket boundary"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[10,10],"radius":8,"id":"wall"})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let sketch = h.app.session.model.state().sketches.last().unwrap().feature;
|
||||
let b = h.app.run("cam.boundary.from_profile", json!({"setup":1,"sketch":sketch,"profile":0})).unwrap();
|
||||
input.operations[0].source = Some(serde_json::from_value(b["source"].clone()).unwrap());
|
||||
match &mut input.operations[0].geometry {
|
||||
cam::Strategy::Contour { boundary, .. } | cam::Strategy::Pocket { boundary } => {
|
||||
*boundary = serde_json::from_value(b["boundary"].clone()).unwrap()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
input.operations[0].ramp_angle = Some(5_f64.to_radians());
|
||||
}
|
||||
if name == "drill" {
|
||||
input.operations[0].geometry = cam::Strategy::Drill { holes: vec![[10., 10.]], peck: Some(0.3), full_diameter: true };
|
||||
}
|
||||
h.app.run("cam.tool.put", json!({"tool":input.tools[0]})).unwrap();
|
||||
h.app.run("cam.operation.put", json!({"operation":input.operations[0]})).unwrap();
|
||||
h.app.run("cam.program.put", json!({"program":{"id":1,"name":name,"setup":1,"operations":[1]}})).unwrap();
|
||||
h.call("ui.set", json!({"tab":"MILLING","showGrid":false,"showOrigin":false,"showSketches":false}));
|
||||
if name == "drill" {
|
||||
let body = h.app.session.model.state().bodies[0].clone();
|
||||
let face = (0..body.body.face_count()).find(|i| solidcraft_engine::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap();
|
||||
h.app.session.selection =
|
||||
vec![solidcraft_engine::Sel::Face { body: body.name, index: face, point: solidcraft_engine::geom::Vec3::new(10.5, 10., 5.5) }];
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":"Analytic plate"})).unwrap();
|
||||
h.step(&json!({"widget":"drill"})).unwrap();
|
||||
h.frames(20);
|
||||
h.step(&json!({"widget":"Use selected holes"})).unwrap();
|
||||
h.step(&json!({"widget":"Save"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.manufacturing.operations[0].hole_sources.len(), 1);
|
||||
}
|
||||
h.step(&json!({"start":"cam.generate"})).unwrap();
|
||||
let id = h.app.manufacturing.job.unwrap();
|
||||
wait(&mut h, id, "generated");
|
||||
h.step(&json!({"start":"cam.simulate"})).unwrap();
|
||||
let report = wait(&mut h, id, "verified");
|
||||
assert_eq!(report["verification"]["status"], "pass", "{name}: {report}");
|
||||
assert_eq!(report["verification"]["finished_dimensions"], "pass", "{name}: {report}");
|
||||
h.app.run("cam.post", json!({"job":id,"path":h.dir.join(format!("{name}.nc"))})).unwrap();
|
||||
h.step(&json!({"widget":"Target model"})).unwrap();
|
||||
h.step(&json!({"widget":"Remaining stock"})).unwrap();
|
||||
h.step(&json!({"widget":"To start"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
let out = h.call("ui.render", json!({"path":h.dir.join(format!("{name}.png")),"width":1000,"height":750}));
|
||||
assert_eq!(out["ok"], true, "{out}");
|
||||
let state = h.call("ui.inspect", json!({}));
|
||||
assert_eq!(state["result"]["manufacturing"]["completed_motions"], 0);
|
||||
assert_eq!(state["result"]["manufacturing"]["workpiece"], "RemainingStock", "{name}: {state}");
|
||||
assert!(std::fs::read(h.dir.join(format!("{name}.png"))).unwrap().starts_with(b"\x89PNG"));
|
||||
// Optional public-safe review evidence from these original fixtures only.
|
||||
if let Some(directory) = std::env::var_os("CAM_UI_EVIDENCE") {
|
||||
let directory = std::path::PathBuf::from(directory);
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
std::fs::copy(h.dir.join(format!("{name}.png")), directory.join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn wait(h: &mut Harness, id: u64, want: &str) -> serde_json::Value {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
h.frames(1);
|
||||
let state = h.app.run("cam.job.status", json!({"job":id})).unwrap();
|
||||
let s = state["state"].as_str().unwrap();
|
||||
if s == want {
|
||||
// The worker may finish between the last frame and the status read.
|
||||
// Lay out the completed-job controls before resolving widget coordinates.
|
||||
h.frames(3);
|
||||
return state;
|
||||
}
|
||||
assert!(s != "failed" && s != "cancelled", "{state}");
|
||||
assert!(start.elapsed().as_secs() < 180, "{state}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user