1use super::{Chain, Loop, PlanarError, Region, Vec2};
15use serde::{Serialize, Deserialize};
16use crate::arcfit::ArcFitOptions;
17use crate::drop::DropCutter;
18use crate::link::{connect, LinkContext, LinkOptions, Pass};
19use crate::model::{
20 CutDirection, Depths, Diagnostic, FeedsSpeeds, Heights, Motion, Operation, Point2, Point3, Setup, Tab, Tool,
21 Toolpath,
22};
23use crate::push::PushContext;
24use crate::{CamError, Cutter, CutterProfile, MeshSnapshot, Runner, Tolerance, TriangleIndex};
25
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
29pub struct PlanarRegion {
30 pub region: Region,
31 pub z: f64,
33 pub upward: bool,
35}
36
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39pub struct PlanarChain {
40 pub chain: Chain,
41 pub z: f64,
42}
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct Circle2d {
47 pub center: Point2,
48 pub radius: f64,
50 pub top: f64,
52 pub bottom: f64,
54 pub internal: bool,
56 pub outer_radius: Option<f64>,
58}
59
60impl Circle2d {
61 pub fn hole(center: Point2, radius: f64, top: f64, bottom: f64) -> Self {
62 Self { center, radius, top, bottom, internal: true, outer_radius: None }
63 }
64
65 pub fn boss(center: Point2, radius: f64, top: f64, bottom: f64, outer_radius: f64) -> Self {
66 Self { center, radius, top, bottom, internal: false, outer_radius: Some(outer_radius) }
67 }
68
69 pub fn validate(&self, strategy: &str) -> Result<(), CamError> {
70 if !self.center.is_finite() || !self.radius.is_finite() || !self.top.is_finite() || !self.bottom.is_finite() {
71 return Err(invalid(strategy, "a circle has a non-finite parameter"));
72 }
73 if self.radius <= 0.0 {
74 return Err(invalid(strategy, "a circle has a non-positive radius"));
75 }
76 if self.bottom > self.top {
77 return Err(invalid(strategy, "a circle's bottom is above its top"));
78 }
79 if let Some(o) = self.outer_radius {
80 if !o.is_finite() || o <= self.radius {
81 return Err(invalid(strategy, "a boss's outer radius must exceed its radius"));
82 }
83 }
84 Ok(())
85 }
86}
87
88#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
93pub struct PlanarInput {
94 pub regions: Vec<PlanarRegion>,
95 pub chains: Vec<PlanarChain>,
96 pub circles: Vec<Circle2d>,
97 pub stock_top: Option<f64>,
99}
100
101pub struct OpContext<'a> {
103 pub operation: &'a Operation,
104 pub setup: &'a Setup,
105 pub tool: &'a Tool,
106 pub mesh: &'a MeshSnapshot,
107 pub index: Option<&'a dyn TriangleIndex>,
108 pub tolerance: Tolerance,
109 pub cutter: Cutter,
110 pub heights: Heights,
111 pub feeds: FeedsSpeeds,
112 pub strategy: &'static str,
113 pub material_top: std::cell::Cell<Option<f64>>,
118}
119
120pub fn invalid(strategy: &str, message: impl std::fmt::Display) -> CamError {
121 CamError::InvalidInput(format!("{strategy}: {message}"))
122}
123
124impl<'a> OpContext<'a> {
125 pub fn new(
127 strategy: &'static str,
128 operation: &'a Operation,
129 setup: &'a Setup,
130 tool: &'a Tool,
131 mesh: &'a MeshSnapshot,
132 index: Option<&'a dyn TriangleIndex>,
133 tolerance: Tolerance,
134 ) -> Result<Self, CamError> {
135 tolerance.validate()?;
136 operation.validate().map_err(|m| invalid(strategy, m))?;
137 if !operation.enabled {
138 return Err(invalid(strategy, "operation is disabled"));
139 }
140 if operation.setup != setup.id || operation.tool != tool.id {
141 return Err(invalid(strategy, "setup or tool identity mismatch"));
142 }
143 if let Some(axis) = operation.tool_axis {
144 if axis.normalized().map_or(true, |a| a.distance(Point3::Z) > tolerance.angle_eps) {
145 return Err(invalid(strategy, "requires the machine +Z tool axis; transform indexed setups first"));
146 }
147 }
148 if operation.stock_to_leave.radial < 0.0 || operation.stock_to_leave.axial < 0.0 {
149 return Err(invalid(strategy, "stock to leave must not be negative"));
150 }
151 let cutter = Cutter::from_tool(tool)?;
152 let heights = operation.heights.clone().unwrap_or_else(|| setup.heights.clone());
153 heights.validate().map_err(|m| invalid(strategy, m))?;
154 let feeds = operation.feeds.clone().unwrap_or_else(|| tool.feeds.clone());
155 feeds.validate().map_err(|m| invalid(strategy, m))?;
156 if mesh.is_empty() {
157 return Err(CamError::EmptyTarget { operation_id: operation.id.to_string() });
158 }
159 for solid in operation.targets.protected_bodies() {
160 if !mesh.faces.iter().any(|f| f.solid == solid) {
161 return Err(CamError::UnresolvedTarget { solid, face: None });
162 }
163 }
164 for face in operation.targets.drive_faces.iter().chain(&operation.targets.avoid_faces) {
165 if mesh.resolve_faces(face).is_empty() {
166 return Err(CamError::UnresolvedTarget { solid: face.solid.clone(), face: face.face.clone() });
167 }
168 }
169 if heights.clearance <= mesh.bounds.max.z + tolerance.distance_eps {
170 return Err(invalid(strategy, "clearance height must be above the full protected mesh"));
171 }
172 DropCutter::new(mesh, &cutter, index, tolerance)?;
174 Ok(Self { operation, setup, tool, mesh, index, tolerance, cutter, heights, feeds, strategy, material_top: std::cell::Cell::new(None) })
175 }
176
177 pub fn radius(&self) -> f64 {
178 self.cutter.radius()
179 }
180
181 pub fn top_of_cut(&self, depths: &Depths, input: &PlanarInput) -> f64 {
184 let top = depths.top.or(self.heights.top).or(input.stock_top).unwrap_or(self.mesh.bounds.max.z);
185 let material = [depths.top, self.heights.top, input.stock_top].into_iter().flatten().fold(top, f64::max);
186 self.note_material_top(material);
187 top
188 }
189
190 pub fn note_material_top(&self, z: f64) {
193 self.material_top.set(Some(self.material_top.get().map_or(z, |m: f64| m.max(z))));
194 }
195
196 pub fn material_over(&self, depths: &Depths, input: &PlanarInput, local_top: f64) -> f64 {
200 self.top_of_cut(depths, input);
201 self.note_material_top(local_top);
202 self.material_top.get().unwrap_or(local_top)
203 }
204
205 pub fn certify(&self, points: &[Point3], report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
209 self.certify_with(points, false, report, runner)
210 }
211
212 pub fn certify_with(&self, points: &[Point3], fixtures_only: bool, report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
213 self.certify_with_cutter(points, &self.cutter, fixtures_only, report, runner)
214 }
215
216 pub fn certify_with_cutter(&self, points: &[Point3], cutter: &Cutter, fixtures_only: bool, report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
220 if points.len() < 2 {
221 return Ok(());
222 }
223 let tol = self.tolerance;
224 let z = points[0].z;
225 if points.iter().any(|p| (p.z - z).abs() > tol.distance_eps) {
226 return Err(invalid(self.strategy, "a pass is not at constant Z"));
227 }
228 if fixtures_only {
229 let keep: Vec<bool> = self
231 .mesh
232 .faces
233 .iter()
234 .map(|f| f.protected_only || self.operation.targets.protected_solids.contains(&f.solid) || self.operation.targets.avoid_faces.iter().any(|a| a.solid == f.solid && a.face.as_ref().map_or(true, |n| *n == f.face)))
235 .collect();
236 if !keep.iter().any(|k| *k) {
237 return Ok(());
238 }
239 let mut builder = crate::MeshBuilder::new(tol);
240 let mut map = vec![u32::MAX; self.mesh.faces.len()];
241 for (i, f) in self.mesh.faces.iter().enumerate() {
242 if keep[i] {
243 map[i] = builder.add_face(f.clone());
244 }
245 }
246 for t in &self.mesh.triangles {
247 if keep[t.face as usize] {
248 builder.add_triangle(map[t.face as usize], t.a, t.b, t.c).map_err(|e| invalid(self.strategy, e))?;
249 }
250 }
251 let sub = builder.finish();
252 if sub.is_empty() {
253 return Ok(());
254 }
255 return certify_against(&sub, cutter, None, tol, self.operation.tolerance, points, self.strategy, report, runner);
256 }
257 certify_against(self.mesh, cutter, self.index, tol, self.operation.tolerance, points, self.strategy, report, runner)
258 }
259
260 pub fn certify_3d(&self, points: &[Point3], fixtures_only: bool, report: &mut crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<(), CamError> {
263 for w in points.windows(2) {
264 let z = w[0].z.min(w[1].z);
265 let flat = [Point3::new(w[0].x, w[0].y, z), Point3::new(w[1].x, w[1].y, z)];
266 self.certify_with(&flat, fixtures_only, report, runner)?;
267 }
268 Ok(())
269 }
270
271 pub fn direct(&self, motions: Vec<Motion>, passes: u64, mut report: crate::model::OperationReport) -> Result<Toolpath, CamError> {
274 if motions.is_empty() {
275 return Err(invalid(self.strategy, "no motions produced"));
276 }
277 let mut path = Toolpath::new(self.operation.id.clone(), self.tool.id.clone());
278 path.motions = motions;
279 if let Some(i) = path.first_non_finite() {
280 return Err(CamError::NonFinite { what: format!("motion {i}") });
281 }
282 report.counts.triangles = self.mesh.triangle_count() as u64;
283 report.counts.passes = passes;
284 report.counts.motions = path.motions.len() as u64;
285 report.finish();
286 path.report = report;
287 Ok(path)
288 }
289
290 pub fn finish(&self, passes: &[Pass], reorder: bool, mut report: crate::model::OperationReport, runner: &mut Runner<'_>) -> Result<Toolpath, CamError> {
294 if passes.is_empty() {
295 return Err(invalid(self.strategy, "no passes to link (empty or unreachable region)"));
296 }
297 let options = LinkOptions {
298 chord_tolerance: self.operation.tolerance,
299 reorder,
300 allow_reverse: false,
301 material_top: self.material_top.get(),
302 ..LinkOptions::default()
303 };
304 let linked = crate::link::with_current_pre_op_stock(|stock| {
305 let ctx = LinkContext {
306 mesh: self.mesh,
307 cutter: &self.cutter,
308 index: self.index,
309 tolerance: self.tolerance,
310 heights: &self.heights,
311 feeds: &self.feeds,
312 params: &self.operation.linking,
313 options,
314 stock,
315 };
316 connect(passes, &ctx, runner)
317 })?;
318 report.warnings.extend(linked.report.warnings);
319 report.counts.samples += linked.report.counts.samples;
320 report.counts.candidates += linked.report.counts.candidates;
321 report.counts.contacts += linked.report.counts.contacts;
322 let mut path = Toolpath::new(self.operation.id.clone(), self.tool.id.clone());
323 path.motions = linked.motions;
324 path.report = report;
325 let filtered = crate::filter::filter_toolpath(&path, self.tolerance.distance_eps)?;
326 let mut out = if self.operation.linking.fit_arcs {
327 crate::arcfit::fit_toolpath(&filtered, &ArcFitOptions::with_tolerance(self.operation.tolerance))?
328 } else {
329 filtered
330 };
331 out.report.counts.triangles = self.mesh.triangle_count() as u64;
332 out.report.counts.passes = passes.len() as u64;
333 out.report.counts.motions = out.motions.len() as u64;
334 out.report.finish();
335 Ok(out)
336 }
337}
338
339#[allow(clippy::too_many_arguments)]
341fn certify_against(
342 mesh: &MeshSnapshot,
343 cutter: &Cutter,
344 index: Option<&dyn TriangleIndex>,
345 tol: Tolerance,
346 machining_tolerance: f64,
347 points: &[Point3],
348 strategy: &str,
349 report: &mut crate::model::OperationReport,
350 runner: &mut Runner<'_>,
351) -> Result<(), CamError> {
352 let z = points[0].z;
353 let push = PushContext { mesh, cutter, index, tolerance: tol, allowance: 0.0 };
354 push.validate()?;
355 let lift = machining_tolerance.max(2.0 * tol.distance_eps);
356 let drop = DropCutter::new(mesh, cutter, index, tol)?;
357 let floor = mesh.bounds.min.z - cutter.total_height() - 1.0;
358 for (i, p) in points.iter().enumerate() {
359 report.counts.samples += 1;
360 let (clear, candidates) = drop.bound_at_most(p.x, p.y, z + lift, runner)?;
363 if clear {
364 report.counts.candidates += candidates;
365 continue;
366 }
367 let bound = drop.point(p.x, p.y, floor, runner)?;
368 report.counts.candidates += bound.candidate_count;
369 report.counts.contacts += bound.contact_count;
370 if bound.point.z > z + lift {
371 let what = bound.point.contact.as_ref().map(|c| format!("{:?} contact with triangle {:?} at {:?}", c.contact_type, c.triangle_id, c.point)).unwrap_or_default();
372 let d = Diagnostic::new("gouge-detected", format!("{strategy}: the cutter at point {i} of a pass would cut {:.4} below the model surface ({what})", bound.point.z - z)).at(*p);
373 let text = format!("a pass gouges the model at ({:.4}, {:.4}, {:.4}): {}", p.x, p.y, p.z, d.message);
374 report.fail(d);
375 return Err(invalid(strategy, text));
376 }
377 }
378 for w in points.windows(2) {
379 let (a, b) = (Point3::new(w[0].x, w[0].y, z + lift), Point3::new(w[1].x, w[1].y, z + lift));
380 runner.work(1, "certify-chords", 0, 0)?;
381 report.counts.samples += 1;
382 if let Some((at, triangle, dist, radius)) = chord_collision(mesh, cutter, index, a, b, tol) {
383 let d = Diagnostic::new(
384 "gouge-detected",
385 format!("{strategy}: a pass chord collides with protected geometry above the cut: triangle {triangle} comes within {dist:.4} of the chord where the cutter is {radius:.4} wide"),
386 )
387 .at(at);
388 let text = format!("a pass collides with the model or a fixture at ({:.4}, {:.4}, {:.4}): {}", at.x, at.y, at.z, d.message);
389 report.fail(d);
390 return Err(invalid(strategy, text));
391 }
392 }
393 let _ = &push;
394 Ok(())
395}
396
397pub fn chord_collision(mesh: &MeshSnapshot, cutter: &Cutter, index: Option<&dyn TriangleIndex>, a: Point3, b: Point3, tol: Tolerance) -> Option<(Point3, u32, f64, f64)> {
404 let z0 = a.z;
405 let height = cutter.total_height();
406 let radius = cutter.radius().max(cutter.shaft_radius());
407 let lo = Point3::new(a.x.min(b.x) - radius, a.y.min(b.y) - radius, z0);
408 let hi = Point3::new(a.x.max(b.x) + radius, a.y.max(b.y) + radius, z0 + height);
409 let bounds = crate::model::Bounds3::new(lo, hi);
410 let ids: Vec<u32> = match index {
411 Some(i) => i.query(&bounds, crate::ProjectionMode::Xyz),
412 None => (0..mesh.triangle_count() as u32).collect(),
413 };
414 let mut breaks: Vec<f64> = vec![0.0, cutter.cutting_length(), height];
417 for s in cutter.segments() {
418 breaks.extend(s.height_range);
419 }
420 breaks.retain(|h| h.is_finite() && *h >= 0.0 && *h <= height);
421 breaks.sort_by(f64::total_cmp);
422 breaks.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
423 let mut slabs: Vec<(f64, f64, f64)> = Vec::new(); for w in breaks.windows(2) {
425 let (h0, h1) = (w[0], w[1]);
426 if h1 - h0 <= 0.0 {
427 continue;
428 }
429 let r0 = cutter.max_radius_at_height(h0.max(1e-12)).unwrap_or(0.0);
430 let r1 = cutter.max_radius_at_height(h1).unwrap_or(radius);
431 let parts = if (r1 - r0).abs() <= tol.distance_eps { 1 } else { 16 };
432 for k in 0..parts {
433 let s0 = h0 + (h1 - h0) * k as f64 / parts as f64;
434 let s1 = h0 + (h1 - h0) * (k + 1) as f64 / parts as f64;
435 let rb = cutter.max_radius_at_height(s1).unwrap_or(radius).max(cutter.max_radius_at_height(s0.max(1e-12)).unwrap_or(0.0));
436 slabs.push((s0, s1, rb));
437 }
438 }
439 let chord = (Point2::new(a.x, a.y), Point2::new(b.x, b.y));
440 let mut worst: Option<(Point3, u32, f64, f64)> = None;
441 for id in ids {
442 let t = mesh.triangle(id);
443 if t.max_z() <= z0 + tol.distance_eps || t.min_z() >= z0 + height {
444 continue;
445 }
446 if t.bounds.min.x > hi.x || t.bounds.max.x < lo.x || t.bounds.min.y > hi.y || t.bounds.max.y < lo.y {
447 continue;
448 }
449 for &(h0, h1, rb) in &slabs {
450 let (zlo, zhi) = (z0 + h0, z0 + h1);
451 if t.max_z() <= zlo + tol.distance_eps || t.min_z() >= zhi - tol.distance_eps {
452 continue;
453 }
454 let poly = clip_to_slab(&t.vertices(), zlo + tol.distance_eps, zhi);
455 if poly.len() < 2 {
456 continue;
457 }
458 let (d, at) = polygon_chord_distance(&poly, chord);
459 if d < rb - tol.distance_eps && worst.as_ref().map_or(true, |w| d < w.2) {
460 worst = Some((at, id, d, rb));
461 }
462 }
463 }
464 worst
465}
466
467fn clip_to_slab(tri: &[Point3; 3], zlo: f64, zhi: f64) -> Vec<Point3> {
469 let mut poly: Vec<Point3> = tri.to_vec();
470 for (level, keep_above) in [(zlo, true), (zhi, false)] {
471 let mut out = Vec::with_capacity(poly.len() + 2);
472 let n = poly.len();
473 for i in 0..n {
474 let p = poly[i];
475 let q = poly[(i + 1) % n];
476 let inside = |v: Point3| if keep_above { v.z >= level } else { v.z <= level };
477 if inside(p) {
478 out.push(p);
479 }
480 if inside(p) != inside(q) && (q.z - p.z).abs() > 0.0 {
481 let f = (level - p.z) / (q.z - p.z);
482 out.push(p.lerp(q, f.clamp(0.0, 1.0)));
483 }
484 }
485 poly = out;
486 if poly.is_empty() {
487 break;
488 }
489 }
490 poly
491}
492
493fn polygon_chord_distance(poly: &[Point3], chord: (Point2, Point2)) -> (f64, Point3) {
496 let n = poly.len();
497 let mut best = (f64::INFINITY, poly[0]);
498 let xy = |p: Point3| Point2::new(p.x, p.y);
499 if n >= 3 {
501 for c in [chord.0, chord.1] {
502 let mut sign = 0.0f64;
503 let mut inside = true;
504 for i in 0..n {
505 let e = xy(poly[(i + 1) % n]).sub(xy(poly[i]));
506 let s = e.cross(c.sub(xy(poly[i])));
507 if sign == 0.0 {
508 sign = s;
509 } else if s * sign < 0.0 {
510 inside = false;
511 break;
512 }
513 }
514 if inside && sign != 0.0 {
515 return (0.0, Point3::new(c.x, c.y, poly[0].z));
516 }
517 }
518 }
519 let edges = if n >= 3 { n } else { 1 };
520 for i in 0..edges {
521 let p = poly[i];
522 let q = poly[(i + 1) % n];
523 let (d, at) = segment_segment_distance(xy(p), xy(q), chord.0, chord.1);
524 if d < best.0 {
525 let f = if xy(q).distance(xy(p)) > 0.0 { at.distance(xy(p)) / xy(q).distance(xy(p)) } else { 0.0 };
526 best = (d, p.lerp(q, f.clamp(0.0, 1.0)));
527 }
528 }
529 best
530}
531
532fn segment_segment_distance(p0: Point2, p1: Point2, q0: Point2, q1: Point2) -> (f64, Point2) {
535 let sp = super::Segment::line(p0, p1);
536 let sq = super::Segment::line(q0, q1);
537 let cross = |a: Point2, b: Point2| a.x * b.y - a.y * b.x;
538 let d1 = p1.sub(p0);
540 let d2 = q1.sub(q0);
541 let denom = cross(d1, d2);
542 if denom.abs() > 1e-18 {
543 let w = q0.sub(p0);
544 let t = cross(w, d2) / denom;
545 let u = cross(w, d1) / denom;
546 if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
547 return (0.0, sp.point_at(t));
548 }
549 }
550 let mut best = (f64::INFINITY, p0);
551 for (seg, pts, on_p) in [(&sq, [p0, p1], true), (&sp, [q0, q1], false)] {
552 for e in pts {
553 let (near, _) = seg.nearest(e);
554 let d = near.distance(e);
555 if d < best.0 {
556 best = (d, if on_p { e } else { near });
557 }
558 }
559 }
560 best
561}
562
563pub fn depth_levels(top: f64, bottom: f64, depths: &Depths, strategy: &str) -> Result<Vec<f64>, CamError> {
567 if !top.is_finite() || !bottom.is_finite() {
568 return Err(invalid(strategy, "top or bottom of cut is not finite"));
569 }
570 if bottom > top + 1e-9 {
571 return Err(invalid(strategy, format!("bottom of cut {bottom} is above the top of cut {top}")));
572 }
573 let finishing = if depths.finishing_passes > 0 && depths.finishing_step_down > 0.0 {
574 (depths.finishing_passes as f64 * depths.finishing_step_down).min(top - bottom)
575 } else {
576 0.0
577 };
578 let rough_bottom = bottom + finishing;
579 let mut levels = Vec::new();
580 let span = top - rough_bottom;
581 if span > 1e-9 {
582 let n = if depths.step_down > 0.0 { (span / depths.step_down - 1e-9).ceil().max(1.0) } else { 1.0 };
583 if n > 100_000.0 {
584 return Err(invalid(strategy, "depth level budget exceeded"));
585 }
586 let n = n as usize;
587 for i in 1..=n {
588 levels.push(top - span * i as f64 / n as f64);
589 }
590 } else {
591 levels.push(rough_bottom);
592 }
593 if finishing > 0.0 {
594 let n = depths.finishing_passes as usize;
595 for i in 1..=n {
596 levels.push(rough_bottom - finishing * i as f64 / n as f64);
597 }
598 }
599 if let Some(last) = levels.last_mut() {
601 *last = bottom;
602 }
603 levels.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
604 Ok(levels)
605}
606
607pub fn orient_for(loop_: &Loop, material_right: bool, direction: CutDirection, alternate: bool) -> (Loop, bool) {
613 let climb = match direction {
614 CutDirection::Climb => true,
615 CutDirection::Conventional => false,
616 CutDirection::Either => !alternate,
617 };
618 if climb == material_right {
619 (loop_.clone(), material_right)
620 } else {
621 (loop_.reversed(), !material_right)
622 }
623}
624
625fn pass_points(seg: &super::Segment, chord_tol: f64, material_right: Option<bool>) -> Vec<Point2> {
629 match (seg.arc, material_right) {
630 (Some(a), Some(mr)) if (!a.ccw) == mr => seg.discretize_outward(chord_tol),
631 _ => seg.discretize(chord_tol),
632 }
633}
634
635pub fn loop_pass(loop_: &Loop, z: f64, chord_tol: f64, overlap: f64, path_id: u32, material_right: Option<bool>) -> Pass {
638 let mut pts: Vec<Point2> = Vec::new();
639 for s in &loop_.segments {
640 let p = pass_points(s, chord_tol, material_right);
641 pts.extend_from_slice(&p[..p.len() - 1]);
642 }
643 pts.push(pts[0]);
644 if overlap > 0.0 {
645 let mut acc = 0.0;
646 let mut extra = Vec::new();
647 for w in pts.clone().windows(2) {
648 let d = w[0].distance(w[1]);
649 if acc + d >= overlap {
650 let f = ((overlap - acc) / d).clamp(0.0, 1.0);
651 extra.push(w[0].add(w[1].sub(w[0]).scale(f)));
652 break;
653 }
654 acc += d;
655 extra.push(w[1]);
656 }
657 pts.extend(extra);
658 }
659 Pass::new(pts.iter().map(|p| Point3::new(p.x, p.y, z)).collect(), Some(path_id))
660}
661
662pub fn chain_pass(chain: &Chain, z: f64, chord_tol: f64, path_id: u32, material_right: Option<bool>) -> Pass {
664 let mut pts: Vec<Point2> = Vec::new();
665 for (i, s) in chain.segments.iter().enumerate() {
666 let p = pass_points(s, chord_tol, material_right);
667 if i == 0 {
668 pts.push(p[0]);
669 }
670 pts.extend_from_slice(&p[1..]);
671 }
672 Pass::new(pts.iter().map(|p| Point3::new(p.x, p.y, z)).collect(), Some(path_id))
673}
674
675pub fn circle_points(center: Point2, rho: f64, ccw: bool, z: f64, chord_tol: f64, material_inside: bool) -> Vec<Point3> {
678 let l = Loop::circle(center, rho, ccw);
679 let mr = if material_inside { Some(!ccw) } else { Some(ccw) };
681 let mut pts: Vec<Point2> = Vec::new();
682 for s in &l.segments {
683 let p = pass_points(s, chord_tol, mr);
684 pts.extend_from_slice(&p[..p.len() - 1]);
685 }
686 pts.push(pts[0]);
687 pts.iter().map(|p| Point3::new(p.x, p.y, z)).collect()
688}
689
690pub fn apply_tabs(pass: &mut Pass, tabs: &[Tab], bottom: f64) {
695 if tabs.is_empty() {
696 return;
697 }
698 let total: f64 = pass.points.windows(2).map(|w| w[0].distance_xy(w[1])).sum();
699 if total <= 0.0 {
700 return;
701 }
702 let z = pass.points[0].z;
703 let mut out: Vec<Point3> = Vec::new();
704 for tab in tabs {
705 let ztab = bottom + tab.height;
706 if ztab <= z || tab.width <= 0.0 {
707 continue;
708 }
709 let s0 = (tab.at.rem_euclid(1.0) * total - 0.5 * tab.width).max(0.0);
710 let s1 = (s0 + tab.width).min(total);
711 out.clear();
712 let mut acc = 0.0;
713 let pts = pass.points.clone();
714 for (i, w) in pts.windows(2).enumerate() {
715 let a = w[0];
716 let b = w[1];
717 let d = a.distance_xy(b);
718 if i == 0 {
719 out.push(a);
720 }
721 let mut push_at = |s: f64, zz: f64| {
722 let f = if d > 0.0 { ((s - acc) / d).clamp(0.0, 1.0) } else { 0.0 };
723 let p = a.lerp(b, f);
724 out.push(Point3::new(p.x, p.y, zz));
725 };
726 if s0 > acc && s0 < acc + d {
727 push_at(s0, z);
728 push_at(s0, ztab);
729 }
730 if s1 > acc && s1 < acc + d {
731 push_at(s1, ztab);
732 push_at(s1, z);
733 }
734 let end_z = if acc + d > s0 && acc + d < s1 { ztab } else { b.z };
735 out.push(Point3::new(b.x, b.y, end_z));
736 acc += d;
737 }
738 pass.points = out.clone();
739 }
740}
741
742pub fn non_empty(passes: &[Pass], strategy: &str) -> Result<(), CamError> {
744 if passes.is_empty() {
745 Err(invalid(strategy, "the operation produced no passes: empty or unreachable region"))
746 } else {
747 Ok(())
748 }
749}
750
751impl From<PlanarError> for crate::model::Diagnostic {
752 fn from(e: PlanarError) -> Self {
753 Diagnostic::new("planar", e.to_string())
754 }
755}
756
757pub fn renumber_paths(motions: &mut [Motion]) {
760 let mut next = 0u32;
761 let mut last: Option<u32> = None;
762 for m in motions.iter_mut() {
763 if let Some(id) = m.path_id {
764 if last != Some(id) {
765 last = Some(id);
766 next += 1;
767 }
768 m.path_id = Some(next - 1);
769 }
770 }
771}