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,69 @@
|
||||
//! Hand-authored modal programs exercise NIST machine state independently of generation.
|
||||
use solidcraft_cam::{Motion, corpus, generate, nc};
|
||||
|
||||
const HEADER: &str = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\nT1 M6\nS6000 M3\nM8\n";
|
||||
|
||||
#[test]
|
||||
fn tool_change_stops_spindle_preserves_coolant_and_requires_restart() {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
let mut second = input.tools[0].clone();
|
||||
second.id = 2;
|
||||
second.number = 2;
|
||||
input.tools.push(second);
|
||||
for number in [1, 2] {
|
||||
let source = format!("{HEADER}T{number} M6\nS7000\nG1 X1 F100\nM30");
|
||||
let parsed = nc::parse(&source, &input.tools).unwrap();
|
||||
let change: Vec<_> = parsed.records.iter().filter(|r| r.line == 6).map(|r| &r.motion).collect();
|
||||
assert_eq!(change, [&Motion::Spindle { rpm: 0. }, &Motion::Tool { tool: number as u64, number }]);
|
||||
assert!(parsed.records.iter().all(|r| r.line != 7), "S alone cannot restart after M6");
|
||||
assert_eq!(parsed.records.last().unwrap().motion, Motion::Coolant { on: false }, "M6 must preserve coolant until M30");
|
||||
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 8"));
|
||||
|
||||
let restarted = source.replace("S7000\n", "M3\n");
|
||||
let parsed = nc::parse(&restarted, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 7).unwrap().motion, Motion::Spindle { rpm: 6000. });
|
||||
let segments = nc::segments(&parsed, &input.setup).unwrap();
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].tool, number as u64);
|
||||
assert_eq!(segments[0].to.x, 1.);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_block_speed_and_coolant_commands_keep_both_effects() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
for coolant in ["M8", "M9", "M0"] {
|
||||
let source = format!("{HEADER}S0 {coolant}\nG1 X1 F100\nM30");
|
||||
let parsed = nc::parse(&source, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
|
||||
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 7"));
|
||||
let source = source.replace("S0", "S12000");
|
||||
let parsed = nc::parse(&source, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 12000. });
|
||||
assert_eq!(nc::segments(&parsed, &input.setup).unwrap().len(), 1);
|
||||
}
|
||||
let parsed = nc::parse(&format!("{HEADER}S0 G1 X1 F100\nM30"), &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.iter().find(|r| r.line == 6).unwrap().motion, Motion::Spindle { rpm: 0. });
|
||||
assert!(nc::segments(&parsed, &input.setup).unwrap_err().0.contains("cutting without tool/spindle at line 6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_post_initializes_state_before_pose_and_keeps_block_provenance() {
|
||||
let input = corpus::fixture("face").unwrap();
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let code = nc::post(&path, &input.setup).unwrap();
|
||||
let lines: Vec<_> = code.lines().filter(|l| !l.starts_with('(')).collect();
|
||||
assert_eq!(lines[0], "G21 G90 G17 G91.1 G94 G54 G40 G49 G80");
|
||||
assert_eq!(&lines[1..3], ["M5", "M9"]);
|
||||
assert!(lines[3].starts_with("G0 X0.000000 Y0.000000 Z15.000000"));
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
assert_eq!(parsed.records.len(), path.blocks.len() + 3);
|
||||
for record in &parsed.records[..3] {
|
||||
assert!(parsed.source_for_line(record.line).is_none());
|
||||
}
|
||||
for (i, record) in parsed.records[3..].iter().enumerate() {
|
||||
let source = parsed.source_for_line(record.line).unwrap();
|
||||
assert_eq!(source.block, i + 1);
|
||||
assert_eq!(source.operation, path.blocks[i].operation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Independent direction checks for a right-handed cutter with M3 spindle rotation.
|
||||
//! A climb cut keeps the material being finished to the right of tool travel.
|
||||
use solidcraft_cam::{Boundary, JobInput, Strategy, corpus, generate, nc};
|
||||
|
||||
fn segments(input: &JobInput) -> Vec<nc::Segment> {
|
||||
let path = generate::generate(input.clone()).unwrap();
|
||||
let parsed = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
nc::segments(&parsed, &input.setup).unwrap()
|
||||
}
|
||||
|
||||
fn square(lo: f64, hi: f64, reversed: bool) -> Vec<[f64; 2]> {
|
||||
let mut ring = vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]];
|
||||
if reversed {
|
||||
ring.reverse();
|
||||
}
|
||||
ring
|
||||
}
|
||||
|
||||
// Collect continuous constant-depth cuts from the independently interpreted NC.
|
||||
// Signed polygon area checks orientation without calling the offset adapter.
|
||||
fn loops(input: &JobInput) -> Vec<Vec<nc::Segment>> {
|
||||
let mut result = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
for s in segments(input) {
|
||||
if !s.rapid && (s.from.z - 5.).abs() < 1e-8 && (s.to.z - 5.).abs() < 1e-8 {
|
||||
current.push(s);
|
||||
} else if !current.is_empty() {
|
||||
result.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
result.push(current);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn ccw(path: &[nc::Segment]) -> bool {
|
||||
let origin = path[0].from;
|
||||
assert!((path.last().unwrap().to - origin).len() < 1e-5, "cut must close");
|
||||
let area = path
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let a = s.from - origin;
|
||||
let b = s.to - origin;
|
||||
a.x * b.y - b.x * a.y
|
||||
})
|
||||
.sum::<f64>();
|
||||
assert!(area.abs() > 1.);
|
||||
area > 0.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facing_direction_matches_uncut_side_at_every_depth() {
|
||||
for climb in [false, true] {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.operations[0].climb = climb;
|
||||
input.operations[0].stepdown = 0.5;
|
||||
let cuts: Vec<_> = segments(&input).into_iter().filter(|s| !s.rapid && (s.from.z - s.to.z).abs() < 1e-8 && s.to.z < 6.).collect();
|
||||
assert_eq!(cuts.len(), 22);
|
||||
for depth in cuts.as_chunks::<11>().0 {
|
||||
assert!(depth.windows(2).all(|pair| pair[1].from.y > pair[0].from.y));
|
||||
// Advancing rows toward +Y leaves uncut material north of the tool.
|
||||
// Right of a -X move is +Y; right of a +X move is -Y.
|
||||
assert!(depth.iter().all(|s| (s.to.x < s.from.x) == climb));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_direction_matches_inside_and_outside_material() {
|
||||
for reversed in [false, true] {
|
||||
for inside in [false, true] {
|
||||
for climb in [false, true] {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.tools[0].diameter = 2.;
|
||||
input.operations[0].climb = climb;
|
||||
input.operations[0].geometry = Strategy::Contour { boundary: Boundary { outer: square(4., 16., reversed), islands: vec![] }, inside };
|
||||
let paths = loops(&input);
|
||||
assert_eq!(paths.len(), 1);
|
||||
// Pocket wall: material outside => CCW climb. Boss: inside => CW climb.
|
||||
assert_eq!(ccw(&paths[0]), inside == climb, "inside={inside}, climb={climb}, reversed={reversed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_direction_preserves_opposite_outer_and_island_winding() {
|
||||
for reversed in [false, true] {
|
||||
for climb in [false, true] {
|
||||
let mut input = corpus::fixture("face").unwrap();
|
||||
input.tools[0].diameter = 2.;
|
||||
input.operations[0].climb = climb;
|
||||
input.operations[0].geometry =
|
||||
Strategy::Pocket { boundary: Boundary { outer: square(0., 20., reversed), islands: vec![square(4., 16., reversed)] } };
|
||||
let paths = loops(&input);
|
||||
assert_eq!(paths.len(), 2);
|
||||
let mut outer = 0;
|
||||
let mut island = 0;
|
||||
for path in &paths {
|
||||
let is_outer = path.iter().any(|s| s.from.x < 2.);
|
||||
if is_outer {
|
||||
outer += 1;
|
||||
} else {
|
||||
island += 1;
|
||||
}
|
||||
assert_eq!(ccw(path), is_outer == climb, "outer={is_outer}, climb={climb}, reversed={reversed}");
|
||||
}
|
||||
assert_eq!((outer, island), (1, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Original programs exercise cooperative NC processing and its exact boundary limits.
|
||||
use solidcraft_cam::{self as cam, Block, Motion, Toolpath, nc};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
future::Future,
|
||||
task::{Context, Poll, Waker},
|
||||
};
|
||||
|
||||
fn yield_once() -> impl Future<Output = ()> {
|
||||
let mut first = true;
|
||||
std::future::poll_fn(move |_| if std::mem::take(&mut first) { Poll::Pending } else { Poll::Ready(()) })
|
||||
}
|
||||
fn drive<T>(future: impl Future<Output = cam::Result<T>>) -> (cam::Result<T>, usize) {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let mut polls = 0;
|
||||
loop {
|
||||
polls += 1;
|
||||
assert!(polls < 20_000, "unbounded checkpoint count");
|
||||
if let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
|
||||
return (result, polls);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn path(count: usize) -> Toolpath {
|
||||
let mut blocks = vec![
|
||||
Block { operation: 1, motion: Motion::Tool { tool: 1, number: 1 } },
|
||||
Block { operation: 1, motion: Motion::Spindle { rpm: 6000. } },
|
||||
Block { operation: 1, motion: Motion::Coolant { on: true } },
|
||||
];
|
||||
for i in 0..count - 6 {
|
||||
blocks.push(Block {
|
||||
operation: if i < count / 2 { 1 } else { 2 },
|
||||
motion: Motion::Linear { to: Vec3::new(if i % 2 == 0 { 1. } else { 19. }, 10., 5.), feed: 100. },
|
||||
});
|
||||
}
|
||||
blocks.extend([
|
||||
Block { operation: 2, motion: Motion::Coolant { on: false } },
|
||||
Block { operation: 2, motion: Motion::Spindle { rpm: 0. } },
|
||||
Block { operation: 2, motion: Motion::Stop },
|
||||
]);
|
||||
Toolpath { fingerprint: "original-independent-test".into(), blocks }
|
||||
}
|
||||
#[test]
|
||||
fn maximum_path_includes_startup_records_and_excess_is_rejected() {
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
let mut path = path(cam::MAX_MOVES);
|
||||
let (parsed, polls) = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once));
|
||||
let parsed = parsed.unwrap();
|
||||
assert_eq!(parsed.records.len(), cam::MAX_MOVES + 3);
|
||||
assert!(polls > 3000);
|
||||
let last = parsed.records.last().unwrap();
|
||||
let source = parsed.source_for_line(last.line).unwrap();
|
||||
assert_eq!((source.block, source.operation), (cam::MAX_MOVES, 2));
|
||||
path.blocks.push(path.blocks.last().unwrap().clone());
|
||||
assert!(nc::post(&path, &input.setup).is_err());
|
||||
// External NC records remain bounded independently of the posting limit.
|
||||
let mut external = "G21 G90 G17 G94 G54\nG0 X0 Y0 Z15\n".to_owned();
|
||||
external.push_str(&"G0 Z15\n".repeat(cam::MAX_MOVES + 3));
|
||||
external.push_str("M30\n");
|
||||
assert!(nc::parse(&external, &input.tools).unwrap_err().to_string().contains("NC motion limit"));
|
||||
}
|
||||
#[test]
|
||||
fn synchronous_and_cooperative_processing_keep_bytes_state_and_provenance() {
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
let path = path(1024);
|
||||
let source = nc::post(&path, &input.setup).unwrap();
|
||||
assert!(source.starts_with("(solidcraft-reference-3axis/2; not a qualified machine post)\n(Fingerprint original-independent-test)\nG21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\nG0 X0.000000 Y0.000000 Z15.000000\n(Operation 1)\nT1 M6\n"));
|
||||
assert_eq!(drive(nc::post_cooperative(&path, &input.setup, || false, yield_once)).0.unwrap(), source);
|
||||
let synchronous = nc::round_trip(&path, &input.setup, &input.tools).unwrap();
|
||||
let cooperative = drive(nc::round_trip_cooperative(&path, &input.setup, &input.tools, || false, yield_once)).0.unwrap();
|
||||
assert_eq!(serde_json::to_value(&synchronous).unwrap(), serde_json::to_value(&cooperative).unwrap());
|
||||
for record in &synchronous.records {
|
||||
assert_eq!(synchronous.source_for_line(record.line), cooperative.source_for_line(record.line));
|
||||
}
|
||||
let broken = source.replace("(Operation 2)", "G123");
|
||||
let ordinary = nc::parse(&broken, &input.tools).unwrap_err();
|
||||
let incremental = drive(nc::parse_cooperative(&broken, &input.tools, || false, yield_once)).0.unwrap_err();
|
||||
assert_eq!(ordinary.to_string(), incremental.to_string());
|
||||
assert!(incremental.to_string().contains("unsupported G code"));
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_during_each_stage_returns_no_partial_program_or_line_mapping() {
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
let path = path(1024);
|
||||
let count = Cell::new(0);
|
||||
let full = drive(nc::round_trip_cooperative(
|
||||
&path,
|
||||
&input.setup,
|
||||
&input.tools,
|
||||
|| false,
|
||||
|| {
|
||||
count.set(count.get() + 1);
|
||||
yield_once()
|
||||
},
|
||||
))
|
||||
.0
|
||||
.unwrap();
|
||||
assert_eq!(full.records.len(), 1027);
|
||||
// Every checkpoint, including post, reader and correspondence stages.
|
||||
for stop in 0..=count.get() {
|
||||
let at = Cell::new(0);
|
||||
let result = drive(nc::round_trip_cooperative(
|
||||
&path,
|
||||
&input.setup,
|
||||
&input.tools,
|
||||
|| at.get() >= stop,
|
||||
|| {
|
||||
at.set(at.get() + 1);
|
||||
yield_once()
|
||||
},
|
||||
))
|
||||
.0;
|
||||
assert!(result.unwrap_err().to_string().contains("cancelled"), "checkpoint {stop}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user