1use crate::cancel::{Cancelled, Runner};
17use crate::cutter::{Cutter, CutterProfile, ToolPart, ToolProfile};
18use crate::model::{Bounds3, Cycle, Motion, MotionKind, Point3};
19use crate::numeric::Tolerance;
20use crate::stock_sim::frame::RotaryFrame;
21use crate::stock_sim::removal::{arc_chords_in_plane, cut_back_bore, cut_linear, refuse, RemovalOptions, SimulationReport};
22use crate::stock_sim::DexelGrid;
23use serde::{Deserialize, Serialize};
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum CollisionPart {
29 CuttingOnRapid,
31 Shank,
32 Holder,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum CollisionAgainst {
39 Stock,
40 Protected,
41}
42
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Collision {
48 pub motion_index: usize,
49 pub part: CollisionPart,
50 pub against: CollisionAgainst,
51 pub position: Point3,
53 pub contact: Point3,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60#[serde(default)]
61pub struct CollisionOptions {
62 pub stop_at_first: bool,
64 pub sample_step: f64,
66 pub check_rapids: bool,
68 pub refine_steps: u32,
70}
71
72impl Default for CollisionOptions {
73 fn default() -> Self {
74 Self { stop_at_first: false, sample_step: 0.0, check_rapids: true, refine_steps: 30 }
75 }
76}
77
78#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct CheckedSimulation {
82 pub removal: SimulationReport,
83 pub collisions: Vec<Collision>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub stopped_at: Option<usize>,
87}
88
89fn touch(column: &crate::stock_sim::Column, c_z: f64, bands: &[([f64; 2], ToolPart)], credit: Option<[f64; 2]>, tangent: f64) -> Option<(f64, ToolPart)> {
95 let mut best: Option<(f64, ToolPart)> = None;
96 for &([h0, h1], part) in bands {
97 let (z0, z1) = (c_z + h0, c_z + h1);
98 for &[a, b] in &column.intervals {
99 let lo = a.max(z0);
101 let hi = b.min(z1);
102 if hi <= lo {
103 continue;
104 }
105 let pieces: Vec<[f64; 2]> = match credit {
107 Some([ca, cb]) if cb > lo && ca < hi => {
108 let mut v = Vec::new();
109 if lo < ca {
110 v.push([lo, ca]);
111 }
112 if cb < hi {
113 v.push([cb, hi]);
114 }
115 v
116 }
117 _ => vec![[lo, hi]],
118 };
119 for [p0, p1] in pieces {
120 if p1 - p0 > tangent && best.map_or(true, |(z, _)| p0 < z) {
121 best = Some((p0, part));
122 }
123 }
124 }
125 }
126 best
127}
128
129#[allow(clippy::too_many_arguments)]
134fn scan_move(
135 grid: &DexelGrid,
136 against: CollisionAgainst,
137 profile: &ToolProfile,
138 cutter: &Cutter,
139 cutting: bool,
140 start: Point3,
141 end: Point3,
142 motion_index: usize,
143 options: &CollisionOptions,
144 tol: &Tolerance,
145) -> Option<Collision> {
146 let r_max = profile.max_radius();
147 let footprint = Bounds3::from_points([start, end]).expanded(r_max);
148 let [ix0, ix1, iy0, iy1] = grid.columns_in(&footprint)?;
149 let d = end.sub(start);
150 let length = d.length();
151 let step = if options.sample_step > 0.0 { options.sample_step } else { grid.cell_x.min(grid.cell_y) };
152 let samples = if length > 0.0 { ((length / step).ceil() as usize).clamp(1, 1 << 20) } else { 1 };
153 let cut_len = cutter.cutting_length();
154 let cut_radius = cutter.radius();
155 let ncols = ((ix1 - ix0 + 1) * (iy1 - iy0 + 1)) as usize;
157 let mut credit: Vec<Option<[f64; 2]>> = vec![None; ncols];
158 let start_tangent = tol.distance_eps.max(0.0);
166 let collides_at = |t: f64, credit: &mut [Option<[f64; 2]>], update_credit: bool| -> Option<(f64, ToolPart, Point3)> {
167 let tangent = if t == 0.0 { start_tangent } else { 0.0 };
168 let c = start.add(d.scale(t));
169 let mut hit: Option<(f64, ToolPart, Point3)> = None;
170 let mut k = 0;
171 for iy in iy0..=iy1 {
172 for ix in ix0..=ix1 {
173 let (x, y) = grid.center(ix, iy);
174 let r = ((x - c.x).powi(2) + (y - c.y).powi(2)).sqrt();
175 let column = grid.column(ix, iy);
176 let slot = k;
177 k += 1;
178 if column.is_empty() || r > r_max {
179 continue;
180 }
181 if cutting && update_credit && r <= cut_radius {
183 let floor = c.z + cutter.height_at_radius(r).unwrap_or(0.0);
184 let top = c.z + cut_len;
185 credit[slot] = Some(match credit[slot] {
186 Some([f, tp]) => [f.min(floor), tp.max(top)],
187 None => [floor, top],
188 });
189 }
190 let bands = profile.height_bands_reaching(r);
191 let bands: Vec<([f64; 2], ToolPart)> = if cutting {
192 bands.into_iter().filter_map(|([h0, h1], part)| {
193 let lo = h0.max(cut_len);
194 (h1 > lo).then_some(([lo, h1], part))
195 }).collect()
196 } else {
197 bands
198 };
199 if let Some((z, part)) = touch(column, c.z, &bands, if cutting { credit[slot] } else { None }, tangent) {
200 if hit.map_or(true, |(hz, _, _)| z < hz) {
201 hit = Some((z, part, Point3::new(x, y, z)));
202 }
203 }
204 }
205 }
206 hit
207 };
208 let mut last_clear = None;
209 for k in 0..=samples {
210 let t = k as f64 / samples as f64;
211 if let Some((_, part, contact)) = collides_at(t, &mut credit, true) {
212 let (mut lo, mut hi) = (last_clear.unwrap_or(t), t);
214 let mut best = (part, contact);
215 if last_clear.is_some() {
216 let mut frozen = credit.clone();
217 for _ in 0..options.refine_steps {
218 let mid = 0.5 * (lo + hi);
219 match collides_at(mid, &mut frozen, false) {
220 Some((_, p, c)) => {
221 hi = mid;
222 best = (p, c);
223 }
224 None => lo = mid,
225 }
226 if (hi - lo) * length <= 1e-3 * tol.distance_eps {
227 break;
228 }
229 }
230 }
231 let position = start.add(d.scale(hi));
232 let part = match best.0 {
233 ToolPart::Cutting => CollisionPart::CuttingOnRapid,
234 ToolPart::Shank => CollisionPart::Shank,
235 ToolPart::Holder => CollisionPart::Holder,
236 };
237 return Some(Collision { motion_index, part, against, position, contact: best.1 });
238 }
239 last_clear = Some(t);
240 }
241 None
242}
243
244#[allow(clippy::too_many_arguments)]
252pub fn simulate_checked(
253 grid: &mut DexelGrid,
254 protected: Option<&DexelGrid>,
255 cutter: &Cutter,
256 profile: &ToolProfile,
257 motions: &[Motion],
258 tol: &Tolerance,
259 removal: &RemovalOptions,
260 options: &CollisionOptions,
261 runner: &mut Runner<'_>,
262) -> Result<CheckedSimulation, Cancelled> {
263 checked_impl(grid, protected, cutter, profile, motions, tol, removal, options, None, runner)
264}
265
266#[allow(clippy::too_many_arguments)]
271pub fn simulate_checked_on_machine(
272 grid: &mut DexelGrid,
273 protected: Option<&DexelGrid>,
274 cutter: &Cutter,
275 profile: &ToolProfile,
276 motions: &[Motion],
277 tol: &Tolerance,
278 removal: &RemovalOptions,
279 options: &CollisionOptions,
280 chain: &crate::kinematics::Chain,
281 runner: &mut Runner<'_>,
282) -> Result<CheckedSimulation, Cancelled> {
283 checked_impl(grid, protected, cutter, profile, motions, tol, removal, options, Some(chain), runner)
284}
285
286#[allow(clippy::too_many_arguments)]
287fn checked_impl(
288 grid: &mut DexelGrid,
289 protected: Option<&DexelGrid>,
290 cutter: &Cutter,
291 profile: &ToolProfile,
292 motions: &[Motion],
293 tol: &Tolerance,
294 removal: &RemovalOptions,
295 options: &CollisionOptions,
296 chain: Option<&crate::kinematics::Chain>,
297 runner: &mut Runner<'_>,
298) -> Result<CheckedSimulation, Cancelled> {
299 let mut out = CheckedSimulation { removal: SimulationReport { motions: motions.len() as u64, ..Default::default() }, ..Default::default() };
300 let mut frame = RotaryFrame::new(chain);
301 let mut position: Option<Point3> = None;
302 'motions: for (index, motion) in motions.iter().enumerate() {
303 runner.stage("stock-simulation", "checking and cutting motion", index as u64, motions.len() as u64)?;
304 let Some(end) = motion.end() else {
305 frame.commit(&motion.kind);
306 continue;
307 };
308 let Some(start) = position else {
309 position = Some(end);
310 frame.commit(&motion.kind);
311 continue;
312 };
313 if let MotionKind::Cycle { at, cycle: Cycle::BackBore { entry_z, depth_z, .. } } = &motion.kind {
314 if !at.is_finite() || !entry_z.is_finite() || !depth_z.is_finite()
315 || entry_z >= depth_z || cutter.kind() != crate::cutter::CutterKind::Flat
316 {
317 refuse(&mut out.removal, index, "back boring requires finite upward depths and a flat cutting profile".into());
318 position = Some(end);
319 frame.commit(&motion.kind);
320 continue;
321 }
322 let world = [(Point3::new(at.x, at.y, *entry_z), Point3::new(at.x, at.y, *depth_z))];
325 let part = match frame.to_part(motion, &world, removal.arc_chord, tol) {
326 Ok(part) => part,
327 Err(reason) => {
328 refuse(&mut out.removal, index, reason);
329 position = Some(end);
330 frame.commit(&motion.kind);
331 continue;
332 }
333 };
334 refuse(&mut out.removal, index, "asymmetric back-bore head entry/exit clearance is not modeled".into());
335 for (entry, finished) in part {
336 let cut = cut_back_bore(grid, cutter.radius(), entry, entry.z, finished.z, runner)?;
337 out.removal.removed_volume += cut.removed_volume;
338 out.removal.columns_touched += cut.columns_touched;
339 }
340 out.removal.cutting_motions += 1;
341 out.removal.warnings.push(crate::model::Diagnostic::new("back-bore-access-not-certified", "G87 upward removal is simulated; asymmetric shifted head entry/exit clearance is not modeled"));
342 position = Some(end);
343 frame.commit(&motion.kind);
344 continue;
345 }
346 let cutting = matches!(
347 motion.kind,
348 MotionKind::Plunge { .. }
349 | MotionKind::Cut { .. }
350 | MotionKind::ArcCw { .. }
351 | MotionKind::ArcCcw { .. }
352 | MotionKind::LeadIn { .. }
353 | MotionKind::LeadOut { .. }
354 | MotionKind::Cycle { .. }
355 | MotionKind::LinearRotary { .. }
356 );
357 let world: Vec<(Point3, Point3)> = match &motion.kind {
358 MotionKind::ArcCw { to, center, plane, pitch } | MotionKind::ArcCcw { to, center, plane, pitch } => {
359 let clockwise = matches!(motion.kind, MotionKind::ArcCw { .. });
360 let mut prev = start;
361 arc_chords_in_plane(start, *to, *center, *plane, clockwise, *pitch, removal.arc_chord)
362 .into_iter()
363 .map(|p| {
364 let seg = (prev, p);
365 prev = p;
366 seg
367 })
368 .collect()
369 }
370 MotionKind::Cycle { at, cycle } => {
371 let top = Point3::new(at.x, at.y, cycle.retract_z().max(at.z));
372 let depth = cycle.depth_z();
373 vec![(start, top), (top, Point3::new(at.x, at.y, depth))]
374 }
375 _ => vec![(start, end)],
376 };
377 let segments = match frame.to_part(motion, &world, removal.arc_chord, tol) {
378 Ok(part) => part,
379 Err(reason) => {
380 refuse(&mut out.removal, index, reason);
381 position = Some(end);
382 frame.commit(&motion.kind);
383 continue;
384 }
385 };
386 if cutting {
387 out.removal.cutting_motions += 1;
388 }
389 for (a, b) in segments {
390 if cutting || options.check_rapids {
391 let mut found: Vec<Collision> = Vec::new();
392 if let Some(c) = scan_move(grid, CollisionAgainst::Stock, profile, cutter, cutting, a, b, index, options, tol) {
393 found.push(c);
394 }
395 if let Some(p) = protected {
396 if let Some(c) = scan_move(p, CollisionAgainst::Protected, profile, cutter, false, a, b, index, options, tol) {
397 found.push(c);
398 }
399 }
400 if !found.is_empty() {
401 out.collisions.extend(found);
402 if options.stop_at_first {
403 out.stopped_at = Some(index);
404 break 'motions;
405 }
406 }
407 }
408 if cutting {
409 let cut = cut_linear(grid, cutter, a, b, tol, removal, runner)?;
410 out.removal.removed_volume += cut.removed_volume;
411 out.removal.columns_touched += cut.columns_touched;
412 out.removal.unconverged_columns += cut.unconverged_columns;
413 }
414 }
415 position = Some(end);
416 frame.commit(&motion.kind);
417 }
418 Ok(out)
419}
420