1use crate::mesh::MeshSnapshot;
17use crate::model::{
18 Coolant, Feed, Heights, Machine, Motion, MotionKind, Operation, OperationId, OperationReport, OperationStatus,
19 ProgramMotion, Setup, SetupId, SpindleDirection, StockToLeave, Strategy, Tool, ToolId, Toolpath, WorkOffset,
20};
21use crate::CamError;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24
25pub mod sheet;
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Units {
31 #[default]
32 Mm,
33 In,
34}
35
36impl Units {
37 pub fn as_str(&self) -> &'static str {
38 match self {
39 Units::Mm => "mm",
40 Units::In => "in",
41 }
42 }
43}
44
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct OperationInput {
49 pub operation: Operation,
50 pub tool: Tool,
51 pub toolpath: Toolpath,
52}
53
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct SetupInput {
58 pub setup: Setup,
59 pub operations: Vec<OperationInput>,
60}
61
62#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ProgramInput {
66 #[serde(default)]
68 pub name: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub number: Option<u32>,
72 #[serde(default)]
73 pub units: Units,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub machine: Option<Machine>,
76 #[serde(default)]
77 pub setups: Vec<SetupInput>,
78 #[serde(default = "default_tool_change_seconds")]
80 pub tool_change_seconds: f64,
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87 pub protected: BTreeMap<SetupId, MeshSnapshot>,
88}
89
90fn default_tool_change_seconds() -> f64 {
91 5.0
92}
93
94#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct Polish {
98 pub smoothing_tolerance: f64,
100 pub arc_tolerance: f64,
102 pub points_removed: usize,
104 pub arcs_fitted: usize,
106 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109 pub feed_optimization: bool,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub certified_smoothing: Option<f64>,
115}
116
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Section {
121 pub index: usize,
123 pub operation: OperationId,
124 pub name: String,
125 pub strategy: String,
127 pub setup: SetupId,
128 pub setup_name: String,
129 pub wcs: WorkOffset,
130 pub tool: ToolId,
131 pub tool_number: u32,
132 pub length_offset: u32,
134 pub diameter_offset: u32,
136 pub tool_change: bool,
138 pub spindle_rpm: f64,
140 pub spindle_direction: SpindleDirection,
141 #[serde(default)]
143 pub css: bool,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub surface_speed: Option<f64>,
146 #[serde(default)]
148 pub cutting_feed: f64,
149 #[serde(default)]
150 pub plunge_feed: f64,
151 pub coolant: Coolant,
152 pub heights: Heights,
153 pub tolerance: f64,
154 pub stock_to_leave: StockToLeave,
155 pub first_motion: usize,
157 pub motion_count: usize,
159 pub cutting_length: f64,
160 pub estimated_seconds: f64,
162 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163 pub post_properties: BTreeMap<String, String>,
164 #[serde(default)]
166 pub polish: Polish,
167 pub report: OperationReport,
168}
169
170#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct ProgramSummary {
174 pub motion_count: usize,
175 pub cutting_length: f64,
176 pub estimated_seconds: f64,
177 pub tool_changes: usize,
178 pub warning_count: usize,
179 #[serde(default)]
181 pub points_removed: usize,
182 #[serde(default)]
184 pub arcs_fitted: usize,
185}
186
187#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct Program {
191 pub name: String,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub number: Option<u32>,
194 #[serde(default)]
195 pub comment: String,
196 pub units: Units,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub machine: Option<Machine>,
199 pub setups: Vec<Setup>,
201 pub tools: Vec<Tool>,
203 pub sections: Vec<Section>,
204 pub motions: Vec<ProgramMotion>,
205 pub summary: ProgramSummary,
206}
207
208impl Program {
209 pub fn section_motions(&self, section: &Section) -> &[ProgramMotion] {
211 &self.motions[section.first_motion..section.first_motion + section.motion_count]
212 }
213}
214
215fn invalid(m: String) -> CamError {
216 CamError::InvalidInput(m)
217}
218
219#[derive(Clone, Copy, Debug, PartialEq)]
221enum SpindleState {
222 Rpm(f64, SpindleDirection),
223 Css(f64, f64, SpindleDirection),
225}
226
227pub fn motion_length(from: Option<crate::model::Point3>, m: &Motion) -> f64 {
229 let Some(to) = m.end() else { return 0.0 };
230 let Some(from) = from else { return 0.0 };
231 match &m.kind {
232 MotionKind::ArcCw { center, plane, pitch, .. } | MotionKind::ArcCcw { center, plane, pitch, .. } => {
233 let cw = matches!(m.kind, MotionKind::ArcCw { .. });
234 match crate::arcfit::arc_sweep(from, to, *center, *plane, cw) {
235 Some((sweep, radius)) => {
236 let arc = sweep * radius;
237 let rise = pitch * sweep / std::f64::consts::TAU;
238 arc.hypot(rise)
239 }
240 None => from.distance(to),
241 }
242 }
243 MotionKind::Cycle { .. } => from.distance_xy(to),
244 _ => from.distance(to),
245 }
246}
247
248pub fn polish(op: &Operation, motions: &[Motion]) -> Result<(Vec<Motion>, Polish), CamError> {
260 let linking = &op.linking;
261 let wrap = |e: CamError| invalid(format!("program: operation {}: {e}", op.id));
262 let mut out = motions.to_vec();
263 let mut polish = Polish::default();
264 if linking.smoothing_tolerance > 0.0 {
265 let (filtered, removed) = crate::filter::filter_motions(&out, linking.smoothing_tolerance).map_err(wrap)?;
266 out = filtered;
267 polish.smoothing_tolerance = linking.smoothing_tolerance;
268 polish.points_removed = removed;
269 }
270 if linking.fit_arcs {
271 let tolerance = if linking.smoothing_tolerance > 0.0 { linking.smoothing_tolerance } else { op.tolerance };
272 let opts = crate::arcfit::ArcFitOptions::with_tolerance(tolerance);
273 let (fitted, count) = crate::arcfit::fit_motions(&out, &opts).map_err(wrap)?;
274 out = fitted;
275 polish.arc_tolerance = tolerance;
276 polish.arcs_fitted = count;
277 }
278 Ok((out, polish))
279}
280
281pub fn assemble(input: &ProgramInput) -> Result<Program, CamError> {
286 let mut sink = crate::NullSink;
287 assemble_with(input, &mut crate::Runner::detached(&mut sink))
288}
289
290pub fn assemble_with(input: &ProgramInput, runner: &mut crate::Runner<'_>) -> Result<Program, CamError> {
294 if !input.tool_change_seconds.is_finite() || input.tool_change_seconds < 0.0 {
295 return Err(invalid("program: tool change seconds must be finite and non-negative".into()));
296 }
297 if let Some(m) = &input.machine {
298 m.validate().map_err(|e| invalid(format!("program: machine: {e}")))?;
299 }
300 let rapid_rate = input.machine.as_ref().map_or(30_000.0, |m| m.feed.rapid);
301 let mut program = Program {
302 name: input.name.clone(),
303 number: input.number.or_else(|| input.setups.first().and_then(|s| s.setup.program.as_ref()).and_then(|p| p.number)),
304 comment: input.setups.first().and_then(|s| s.setup.program.as_ref()).map(|p| p.comment.clone()).unwrap_or_default(),
305 units: input.units,
306 machine: input.machine.clone(),
307 ..Default::default()
308 };
309 let mut tools: BTreeMap<ToolId, Tool> = BTreeMap::new();
310 let mut tool_order: Vec<ToolId> = Vec::new();
311 let mut current_tool: Option<ToolId> = None;
312 let mut current_spindle: Option<SpindleState> = None;
313 let mut current_coolant = Coolant::Off;
314 let mut seen_ops: Vec<OperationId> = Vec::new();
315 for setup_input in &input.setups {
316 let setup = &setup_input.setup;
317 setup.heights.validate().map_err(|e| invalid(format!("program: setup {}: {e}", setup.id)))?;
318 if program.setups.iter().any(|s| s.id == setup.id) {
319 return Err(invalid(format!("program: setup {} appears twice", setup.id)));
320 }
321 program.setups.push(setup.clone());
322 for op_input in &setup_input.operations {
323 let op = &op_input.operation;
324 if !op.enabled {
325 continue;
326 }
327 if op.setup != setup.id {
328 return Err(invalid(format!("program: operation {} belongs to setup {}, not {}", op.id, op.setup, setup.id)));
329 }
330 if seen_ops.contains(&op.id) {
331 return Err(invalid(format!("program: operation {} appears twice", op.id)));
332 }
333 seen_ops.push(op.id.clone());
334 let tool = &op_input.tool;
335 if tool.id != op.tool || op_input.toolpath.tool != tool.id || op_input.toolpath.operation != op.id {
336 return Err(invalid(format!("program: operation {} and its tool/toolpath identities disagree", op.id)));
337 }
338 match tools.get(&tool.id) {
339 Some(existing) if existing != tool => {
340 return Err(invalid(format!("program: tool {} is defined twice with different definitions", tool.id)));
341 }
342 Some(_) => {}
343 None => {
344 tools.insert(tool.id.clone(), tool.clone());
345 tool_order.push(tool.id.clone());
346 }
347 }
348 let path = &op_input.toolpath;
349 if !path.report.is_usable() {
350 return Err(invalid(format!("program: operation {} has no usable toolpath ({:?})", op.id, path.report.status)));
351 }
352 if let Some(i) = path.first_non_finite() {
353 return Err(invalid(format!("program: operation {} motion {i} is not finite", op.id)));
354 }
355 let feeds = op.feeds.as_ref().unwrap_or(&tool.feeds);
356 if matches!(op.strategy, Strategy::ManualNc(_)) {
357 if path.motions.is_empty() {
365 return Err(invalid(format!("program: manual NC operation {} has no entries", op.id)));
366 }
367 let first_motion = program.motions.len();
368 let mut tool_change = false;
369 let mut seconds = 0.0;
370 for m in &path.motions {
371 match &m.kind {
372 MotionKind::Comment { .. } | MotionKind::PassThrough { .. } => {}
373 MotionKind::Stop { .. } => {
374 current_spindle = None;
375 current_coolant = Coolant::Off;
376 }
377 MotionKind::Dwell { seconds: s } => seconds += s,
378 MotionKind::ToolChange { tool: t } => {
379 if *t != tool.id {
380 return Err(invalid(format!(
381 "program: manual NC operation {}: the tool change names {t}, not the operation's tool {} (the program carries only that definition)",
382 op.id, tool.id
383 )));
384 }
385 tool_change = true;
386 seconds += input.tool_change_seconds;
387 current_tool = Some(t.clone());
388 current_spindle = None;
389 current_coolant = Coolant::Off;
390 }
391 other => {
392 return Err(invalid(format!(
393 "program: manual NC operation {} carries a {} motion; manual entries are stop, dwell, comment, pass-through and tool change",
394 op.id,
395 other.kind_name()
396 )));
397 }
398 }
399 program.motions.push(ProgramMotion { operation: op.id.clone(), motion: m.clone() });
400 }
401 let (spindle_rpm, spindle_direction) = match current_spindle {
402 Some(SpindleState::Rpm(rpm, direction)) => (rpm, direction),
403 Some(SpindleState::Css(_, max, direction)) => (max, direction),
404 None => (0.0, SpindleDirection::default()),
405 };
406 program.sections.push(Section {
407 index: program.sections.len(),
408 operation: op.id.clone(),
409 name: op.name.clone(),
410 strategy: strategy_name(op),
411 setup: setup.id.clone(),
412 setup_name: setup.name.clone(),
413 wcs: setup.wcs.clone(),
414 tool: tool.id.clone(),
415 tool_number: tool.number,
416 length_offset: tool.length_offset.unwrap_or(tool.number),
417 diameter_offset: tool.diameter_offset.unwrap_or(tool.number),
418 tool_change,
419 spindle_rpm,
420 spindle_direction,
421 css: matches!(current_spindle, Some(SpindleState::Css(..))),
422 surface_speed: match current_spindle {
423 Some(SpindleState::Css(v, _, _)) => Some(v),
424 _ => None,
425 },
426 cutting_feed: feeds.cutting_feed,
427 plunge_feed: feeds.plunge_feed,
428 coolant: current_coolant,
429 heights: op.heights.clone().unwrap_or_else(|| setup.heights.clone()),
430 tolerance: op.tolerance,
431 stock_to_leave: op.stock_to_leave.clone(),
432 first_motion,
433 motion_count: program.motions.len() - first_motion,
434 cutting_length: 0.0,
435 estimated_seconds: seconds,
436 post_properties: op.post_properties.clone(),
437 polish: Polish::default(),
438 report: path.report.clone(),
439 });
440 continue;
441 }
442 feeds.validate().map_err(|e| invalid(format!("program: operation {}: {e}", op.id)))?;
443 op.linking.validate().map_err(|e| invalid(format!("program: operation {}: linking: {e}", op.id)))?;
444 if !op.tolerance.is_finite() || op.tolerance <= 0.0 {
445 return Err(invalid(format!("program: operation {}: tolerance must be finite and positive", op.id)));
446 }
447 let (polished, polish) = if op.polish.feed_optimization.enabled || op.polish.smoothing.enabled {
453 op.polish.validate().map_err(|e| invalid(format!("program: operation {}: polish: {e}", op.id)))?;
454 let empty = MeshSnapshot::empty();
457 let mesh = match input.protected.get(&setup.id) {
458 Some(mesh) => mesh,
459 None if !op.polish.smoothing.enabled => &empty,
460 None => {
461 return Err(invalid(format!(
462 "program: operation {}: certified smoothing needs the complete protected mesh of setup {} (ProgramInput::protected)",
463 op.id, setup.id
464 )));
465 }
466 };
467 let applied = crate::polish::apply(path, op, tool, mesh, crate::Tolerance::default(), runner)
468 .map_err(|e| invalid(format!("program: operation {}: {e}", op.id)))?;
469 if op.polish.smoothing.enabled {
470 let polish = Polish { feed_optimization: op.polish.feed_optimization.enabled, certified_smoothing: Some(op.polish.smoothing.tolerance), ..Default::default() };
471 (applied.motions, polish)
472 } else {
473 let (out, mut polish) = polish(op, &applied.motions)?;
474 polish.feed_optimization = true;
475 (out, polish)
476 }
477 } else {
478 polish(op, &path.motions)?
479 };
480 let first_motion = program.motions.len();
481 let mut push = |m: Motion| program.motions.push(ProgramMotion { operation: op.id.clone(), motion: m });
482 push(Motion::new(MotionKind::Comment { text: op.name.clone() }, Feed::RAPID));
483 let tool_change = current_tool.as_ref() != Some(&tool.id);
484 if tool_change {
485 push(Motion::new(MotionKind::ToolChange { tool: tool.id.clone() }, Feed::RAPID));
486 current_tool = Some(tool.id.clone());
487 current_spindle = None;
489 current_coolant = Coolant::Off;
490 }
491 let wanted = if feeds.css {
495 let (Some(v), Some(max)) = (feeds.surface_speed, feeds.surface_speed_limit_rpm) else {
496 return Err(invalid(format!("program: operation {}: constant surface speed needs a surface speed and an rpm limit", op.id)));
497 };
498 SpindleState::Css(v, max, feeds.spindle_direction)
499 } else {
500 SpindleState::Rpm(feeds.spindle_rpm, feeds.spindle_direction)
501 };
502 let prefix: Vec<&Motion> = path.motions.iter().take_while(|m| m.end().is_none()).collect();
503 let own = prefix.iter().rev().find_map(|m| match &m.kind {
504 MotionKind::SpindleOn { rpm, direction } => Some(SpindleState::Rpm(*rpm, *direction)),
505 MotionKind::SpindleSurfaceSpeed { surface_speed, max_rpm, direction } => Some(SpindleState::Css(*surface_speed, *max_rpm, *direction)),
506 _ => None,
507 });
508 match own {
509 Some(state) => current_spindle = Some(state),
510 None => {
511 if current_spindle != Some(wanted) {
512 push(match wanted {
513 SpindleState::Rpm(rpm, direction) => Motion::new(MotionKind::SpindleOn { rpm, direction }, Feed::RAPID),
514 SpindleState::Css(surface_speed, max_rpm, direction) => Motion::new(MotionKind::SpindleSurfaceSpeed { surface_speed, max_rpm, direction }, Feed::RAPID),
515 });
516 current_spindle = Some(wanted);
517 }
518 }
519 }
520 let spindle = match current_spindle.expect("set above") {
521 SpindleState::Rpm(rpm, direction) => (rpm, direction),
522 SpindleState::Css(_, max, direction) => (max, direction),
523 };
524 if current_coolant != tool.coolant {
525 push(Motion::new(MotionKind::Coolant { mode: tool.coolant }, Feed::RAPID));
526 current_coolant = tool.coolant;
527 }
528 let mut at = None;
530 let mut seconds = if tool_change { input.tool_change_seconds } else { 0.0 };
531 let mut cutting = 0.0;
532 for m in &polished {
533 let thread = match &m.kind {
536 MotionKind::TurnThreadCycle { cycle } => {
537 let passes = (cycle.depths.len() + cycle.spring_passes as usize) as f64;
538 let along = (cycle.start.z - cycle.end.z).abs() * passes;
539 let x = cycle.start.x + if cycle.internal { 1.0 } else { -1.0 } * cycle.depths.last().copied().unwrap_or(0.0);
540 let across = 2.0 * (cycle.clearance_x - x).abs() * passes;
541 Some((along, across))
542 }
543 _ => None,
544 };
545 let len = match thread {
546 Some((along, _)) => along,
547 None => motion_length(at, m),
548 };
549 if m.kind.is_cutting() {
550 cutting += len;
551 }
552 if let Some((_, across)) = thread {
553 seconds += across / rapid_rate * 60.0;
554 }
555 let rate = match m.feed {
556 Feed::Rapid(_) => rapid_rate,
557 Feed::Rate(r) => r.max(1e-9),
558 };
559 seconds += len / rate * 60.0;
560 match &m.kind {
561 MotionKind::Dwell { seconds: s } => seconds += s,
562 MotionKind::Cycle { at: p, cycle } => {
563 let depth = (p.z - cycle.depth_z()).abs() + (cycle.retract_z() - cycle.depth_z()).abs();
565 seconds += depth / feeds.plunge_feed.max(1e-9) * 60.0;
566 }
567 _ => {}
568 }
569 if let Some(p) = m.end() {
570 at = Some(p);
571 }
572 push(m.clone());
573 }
574 let count = program.motions.len() - first_motion;
575 program.sections.push(Section {
576 index: program.sections.len(),
577 operation: op.id.clone(),
578 name: op.name.clone(),
579 strategy: strategy_name(op),
580 setup: setup.id.clone(),
581 setup_name: setup.name.clone(),
582 wcs: setup.wcs.clone(),
583 tool: tool.id.clone(),
584 tool_number: tool.number,
585 length_offset: tool.length_offset.unwrap_or(tool.number),
586 diameter_offset: tool.diameter_offset.unwrap_or(tool.number),
587 tool_change,
588 spindle_rpm: spindle.0,
589 spindle_direction: spindle.1,
590 css: matches!(current_spindle, Some(SpindleState::Css(..))),
591 surface_speed: match current_spindle {
592 Some(SpindleState::Css(v, _, _)) => Some(v),
593 _ => None,
594 },
595 cutting_feed: feeds.cutting_feed,
596 plunge_feed: feeds.plunge_feed,
597 coolant: tool.coolant,
598 heights: op.heights.clone().unwrap_or_else(|| setup.heights.clone()),
599 tolerance: op.tolerance,
600 stock_to_leave: op.stock_to_leave.clone(),
601 first_motion,
602 motion_count: count,
603 cutting_length: cutting,
604 estimated_seconds: seconds,
605 post_properties: op.post_properties.clone(),
606 polish,
607 report: path.report.clone(),
608 });
609 }
610 }
611 if program.sections.is_empty() {
612 return Err(invalid("program: no enabled operations with a toolpath".into()));
613 }
614 let last_op = program.sections.last().map(|s| s.operation.clone()).expect("a section exists");
616 if current_coolant != Coolant::Off {
617 program.motions.push(ProgramMotion { operation: last_op.clone(), motion: Motion::new(MotionKind::Coolant { mode: Coolant::Off }, Feed::RAPID) });
618 }
619 program.motions.push(ProgramMotion { operation: last_op, motion: Motion::new(MotionKind::SpindleOff, Feed::RAPID) });
620 if let Some(last) = program.sections.last_mut() {
621 last.motion_count = program.motions.len() - last.first_motion;
622 }
623 program.tools = tool_order.iter().map(|id| tools[id].clone()).collect();
624 program.summary = ProgramSummary {
625 motion_count: program.motions.len(),
626 cutting_length: program.sections.iter().map(|s| s.cutting_length).sum(),
627 estimated_seconds: program.sections.iter().map(|s| s.estimated_seconds).sum(),
628 tool_changes: program.sections.iter().filter(|s| s.tool_change).count(),
629 warning_count: program.sections.iter().map(|s| s.report.warnings.len()).sum(),
630 points_removed: program.sections.iter().map(|s| s.polish.points_removed).sum(),
631 arcs_fitted: program.sections.iter().map(|s| s.polish.arcs_fitted).sum(),
632 };
633 let _ = OperationStatus::Ok;
634 Ok(program)
635}
636
637pub fn strategy_name(op: &Operation) -> String {
639 #[derive(Deserialize)]
640 struct Tag {
641 kind: String,
642 }
643 serde_json::to_value(&op.strategy)
644 .ok()
645 .and_then(|v| serde_json::from_value::<Tag>(v).ok())
646 .map(|t| t.kind)
647 .unwrap_or_else(|| "unknown".into())
648}
649