//! 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> + Send>>>, path: Option, progress: Arc, failed: Option, cancelled: bool, } impl Generator { pub fn new(input: JobInput) -> Result { 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 { 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 { 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, 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) -> Result { 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 { 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, 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> { 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> { 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, 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::(); 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, scale: f64, } struct OffsetLoops { ccw_plines: Vec>, cw_plines: Vec>, } 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| { 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 { 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 { 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()); } }