1use std::collections::{BTreeMap, BTreeSet, HashMap};
14
15use brep_cam::collision::{simulate_checked, simulate_checked_on_machine, CollisionOptions};
16use brep_cam::cutter::ToolProfile;
17use brep_cam::model::{Bounds3, Machine, Point3, RigidTransform, Setup, Stock};
18use brep_cam::kinematics::post::{resolve_program, ResolveOptions};
19use brep_cam::kinematics::Chain;
20use brep_cam::multiaxis::sim::{
21 check_machine, DriverOptions, MachineCheckOptions, Player, PlayState, Position, RangeCutter, SimDriver, StockModel, Timeline, TriDexel,
22 TriDexelOptions,
23};
24use brep_render::runner::{CamSimJob, CamSimRequest};
25use brep_cam::program::{assemble, OperationInput, ProgramInput, SetupInput};
26use brep_cam::stock::resolve_stock;
27use brep_cam::stock_sim::mesh::StockMesh;
28use brep_cam::stock_sim::removal::RemovalOptions;
29use brep_cam::stock_sim::{DexelGrid, DexelOptions};
30use brep_cam::verify::{deviation_map, DeviationMap, DeviationOptions};
31use brep_cam::stock_sim::remaining::{remaining_stock_mesh, RemainingStockMesh};
32use brep_cam::{BruteForceIndex, Cutter, MeshBuilder, MeshSnapshot, NullSink, Runner, Tolerance, TreeIndex, TreeOptions};
33use brep_render::cam_sim::{marks_group, remaining_group, remaining_legend, stock_group, stock_stl, tool_group, Marker, MARKS_GROUP, STOCK_GROUP, TOOL_GROUP};
34use brep_render::engine_state::EngineState;
35use egui;
36use serde_json::{json, Value};
37
38use crate::automation::hit_keys::HitKeyDoc;
39use crate::document::cam::CamDocument;
40use crate::panels::file::{SaveBody, SaveRequest};
41
42#[derive(Clone, Debug)]
44pub struct RangeTool {
45 pub operation: String,
46 pub first: usize,
47 pub count: usize,
48 pub cutter: Option<Cutter>,
49 pub profile: Option<ToolProfile>,
50 pub turning: Option<brep_cam::multiaxis::sim::player::TurningRange>,
51 pub tolerance: f64,
53}
54
55pub struct SimSession {
57 pub setup: String,
58 pub player: Player,
59 pub driver: SimDriver,
60 pub ranges: Vec<RangeTool>,
61 pub to_model: RigidTransform,
62 pub marks: Vec<Marker>,
65 pub collision_motions: BTreeSet<usize>,
66 pub collisions: Vec<(usize, String, String)>,
68 pub range_cutters: Vec<RangeCutter>,
70 load_stock: StockModel,
72 sim_machine: Option<Machine>,
74 pub remote: Option<RemoteSim>,
77 pub multi_axis: bool,
78 pub part: Option<MeshSnapshot>,
80 pub deviation: Option<DeviationMap>,
81 pub legend: Option<Value>,
82 mesh: StockMesh,
83 mesh_version: Option<u64>,
84 part_index: Option<TreeIndex>,
86 pub remaining: Option<RemainingStockMesh>,
89 remaining_key: Option<(u64, u64)>,
90 epoch: u64,
92}
93
94static NEXT_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
95
96pub const DEFAULT_TOLERANCE: f64 = 0.05;
98
99#[derive(Debug, Default)]
101pub struct RemoteSim {
102 pub session: u64,
103 seq: u64,
104 pub in_flight: Option<(u64, usize)>,
106 dropped: BTreeSet<u64>,
108 pub loaded: bool,
110 pub submitted: u64,
112 pub accepted: u64,
113 pub cancelled: u64,
114 pub failed: Option<String>,
118}
119
120static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
121
122impl SimSession {
123 pub fn mesh(&mut self) -> &StockMesh {
125 if self.mesh_version != Some(self.driver.version) {
126 self.mesh = self.driver.stock.mesh();
127 self.mesh_version = Some(self.driver.version);
128 }
129 &self.mesh
130 }
131
132 pub fn range_of(&self, motion: usize) -> Option<&RangeTool> {
134 self.ranges.iter().find(|r| motion >= r.first && motion < r.first + r.count)
135 }
136
137 pub fn caught_up(&self) -> bool {
140 self.driver.applied == self.player.completed() && self.remote.as_ref().map_or(true, |r| r.in_flight.is_none() && r.loaded)
141 }
142
143 pub fn drive(&mut self, state: &mut EngineState, budget: usize, chunk: usize) -> Result<bool, String> {
147 let target = self.player.completed();
148 let tolerance = Tolerance::default();
149 let Some(remote) = self.remote.as_mut() else {
150 if self.driver.applied != target {
151 self.driver
152 .advance_ranges(&self.player.timeline, &self.range_cutters, target, budget, &tolerance, &mut |_| true)
153 .map_err(|e| e.to_string())?;
154 }
155 return Ok(self.driver.applied != target);
156 };
157 let _ = state.cam_sim_poll_progress();
158 if let Some(failed) = &remote.failed {
159 return Err(failed.clone());
160 }
161 while let Some(reply) = state.cam_sim_poll() {
162 if reply.session != remote.session || remote.dropped.remove(&reply.seq) || remote.in_flight.map(|f| f.0) != Some(reply.seq) {
163 continue;
164 }
165 remote.in_flight = None;
166 if let Some(error) = reply.error {
167 if error.contains("no simulation session") {
168 remote.loaded = false;
170 continue;
171 }
172 remote.failed = Some(error.clone());
173 self.player.pause();
174 return Err(error);
175 }
176 remote.accepted += 1;
177 remote.loaded = true;
178 let d = &mut self.driver;
179 d.applied = reply.applied;
180 d.version = reply.version;
181 d.removed_volume = reply.removed_volume;
182 d.unsupported = reply.unsupported;
183 d.warnings = reply.warnings;
184 if let Some(stock) = reply.stock {
185 d.stock = stock;
186 self.mesh_version = None;
187 }
188 }
189 if remote.in_flight.is_none() {
190 remote.seq += 1;
191 let seq = remote.seq;
192 let request = if !remote.loaded {
193 Some(CamSimRequest::Load {
194 timeline: self.player.timeline.clone(),
195 stock: self.load_stock.clone(),
196 ranges: self.range_cutters.clone(),
197 options: self.driver.options.clone(),
198 machine: self.sim_machine.clone(),
199 })
200 } else if self.driver.applied != target {
201 Some(CamSimRequest::Advance { target, budget: chunk, stock: true, known_version: self.driver.version })
202 } else {
203 None
204 };
205 if let Some(request) = request {
206 if let CamSimRequest::Load { .. } = &request {
207 self.driver.stock = self.load_stock.clone();
209 self.driver.applied = 0;
210 self.driver.version = 0;
211 self.driver.removed_volume = 0.0;
212 self.driver.unsupported.clear();
213 self.driver.warnings.clear();
214 self.mesh_version = None;
215 }
216 if state.cam_sim_submit(CamSimJob { session: remote.session, seq, request }) {
217 remote.in_flight = Some((seq, target));
218 remote.submitted += 1;
219 } else {
220 return Err("the runner refused the simulation command".into());
221 }
222 }
223 }
224 Ok(!self.caught_up())
225 }
226
227 pub fn cancel(&mut self, state: &mut EngineState) -> bool {
231 let mut stopped = false;
232 if let Some(remote) = self.remote.as_mut() {
233 if let Some((seq, _)) = remote.in_flight.take() {
234 state.cam_sim_cancel();
235 remote.dropped.insert(seq);
236 remote.cancelled += 1;
237 stopped = true;
238 }
239 }
240 let applied = self.driver.applied;
241 if self.player.completed() != applied || stopped {
242 self.player.pause();
243 self.player.position = if applied == 0 { Position { motion: 0, fraction: 0.0 } } else { Position { motion: applied - 1, fraction: 1.0 } };
244 stopped = true;
245 }
246 stopped
247 }
248
249 pub fn compare(&mut self, options: &DeviationOptions, scale: f64) -> Result<(), String> {
251 let part = self.part.as_ref().ok_or("no part mesh: the setup's target bodies are not on screen")?;
252 let grid = match &self.driver.stock {
253 StockModel::Dexel(g) => g.clone(),
254 StockModel::TriDexel(t) => t.to_dexel_grid(),
255 StockModel::Turning(t) => t.view.clone(),
256 };
257 let index = BruteForceIndex::build(part);
258 let map = deviation_map(&grid, part, &index, options);
259 self.legend = Some(brep_render::cam_sim::deviation_legend(&map, scale));
260 if let StockModel::Turning(stock)=&self.driver.stock {
261 use brep_cam::planar::{Region,Loop};
262 let tol=Tolerance::default();
263 if !options.tolerance.is_finite() || options.tolerance<=0. {return Err("turning deviation tolerance must be positive".into());}
264 let (target_min,target_max)=stock.target.bounds();let (_,stock_max)=stock.section.bounds();
265 let domain=Region::new(vec![Loop::rectangle(brep_cam::Point2::new(0.,target_min.y),brep_cam::Point2::new(stock_max.x.max(target_max.x)+1.,stock_max.y.max(target_max.y)+1.))],&tol).map_err(|e|e.to_string())?;
266 let machined=stock.section.intersection(&domain,&tol).map_err(|e|e.to_string())?;
267 let rear=stock.section.difference(&domain,&tol).map_err(|e|e.to_string())?;
268 let missing=stock.target.difference(&machined,&tol).map_err(|e|e.to_string())?;
269 let excess=machined.difference(&stock.target,&tol).map_err(|e|e.to_string())?;
270 let outer=stock.target.offset(options.tolerance,&tol).map_err(|e|e.to_string())?;
271 let inner=stock.target.offset(-options.tolerance,&tol).map_err(|e|e.to_string())?;
272 let within=machined.difference(&outer,&tol).map_err(|e|e.to_string())?.is_empty() && inner.difference(&machined,&tol).map_err(|e|e.to_string())?.is_empty();
273 let rear_volume=brep_cam::turning::sim::StockState::new(rear,tol).map_err(|e|e.to_string())?.volume();
274 self.legend.as_mut().unwrap()["axial"]=json!({"withinTolerance":within,"tolerance":options.tolerance,"machinedFromZ":target_min.y,"machinedExcessArea":excess.area(),"machinedMissingArea":missing.area(),"rearRemainingVolume":rear_volume,"totalRemainingVolume":stock.volume(),"scope":"certified target meridian from its rear face forward; rear bar extension reported separately"});
275 }
276 self.deviation = Some(map);
277 Ok(())
278 }
279
280 pub fn tolerance_here(&self) -> f64 {
282 self.range_of(self.player.position.motion).map_or(DEFAULT_TOLERANCE, |r| r.tolerance)
283 }
284
285 pub fn refresh_remaining(&mut self) -> Result<bool, String> {
290 let tolerance = self.tolerance_here();
291 let key = (self.driver.version, tolerance.to_bits());
292 if self.remaining_key == Some(key) {
293 return Ok(false);
294 }
295 let part = self.part.as_ref().ok_or("no part mesh: the setup's target bodies are not on screen")?;
296 let index = self.part_index.get_or_insert_with(|| TreeIndex::build(part, TreeOptions::default()));
297 let grid = match &self.driver.stock {
298 StockModel::Dexel(g) => g.clone(),
299 StockModel::TriDexel(t) => t.to_dexel_grid(),
300 StockModel::Turning(t) => t.view.clone(),
301 };
302 let options = DeviationOptions { tolerance, max_distance: 1_000.0 };
303 self.remaining = Some(remaining_stock_mesh(&grid, part, index, &options));
304 self.remaining_key = Some(key);
305 Ok(true)
306 }
307
308 pub fn state_json(&self) -> Value {
310 let p = &self.player;
311 let op = p.timeline.operation_of(p.position.motion).map(|i| p.timeline.operations[i].operation.to_string());
312 json!({
313 "setup": self.setup,
314 "multiAxis": self.multi_axis,
315 "playing": p.state == PlayState::Playing,
316 "speed": p.speed,
317 "motion": p.position.motion,
318 "fraction": p.position.fraction,
319 "time": p.time(),
320 "totalSeconds": p.timeline.total_seconds,
321 "progress": p.progress(),
322 "motions": p.timeline.len(),
323 "completed": p.completed(),
324 "applied": self.driver.applied,
325 "caughtUp": self.caught_up(),
326 "runner": self.remote.as_ref().map(|r| json!({ "session": r.session, "loaded": r.loaded, "inFlight": r.in_flight.map(|f| f.1), "submitted": r.submitted, "accepted": r.accepted, "cancelled": r.cancelled, "failed": r.failed })),
327 "removedVolume": self.driver.removed_volume,
328 "stockVolume": self.driver.stock.volume(),
329 "unsupported": self.driver.unsupported,
330 "warnings": self.driver.warnings.len(),
331 "operation": op,
332 "operations": p.timeline.operations.iter().map(|r| json!({ "operation": r.operation.to_string(), "first": r.first, "count": r.count, "seconds": p.timeline.end_times.get(r.first + r.count.saturating_sub(1)).copied().unwrap_or(0.0) - p.timeline.start_time(r.first) })).collect::<Vec<_>>(),
333 "marks": self.marks.len(),
334 "collisionMotions": self.collision_motions.iter().collect::<Vec<_>>(),
335 "collisions": self.collisions.iter().map(|(m, p, a)| json!({ "motion": m, "part": p, "against": a })).collect::<Vec<_>>(),
336 "deviation": self.legend,
337 "stockVersion": self.driver.version,
338 "remaining": self.remaining.as_ref().map(|r| {
339 let mut legend = remaining_legend(r);
340 legend["stockVersion"] = json!(self.remaining_key.map(|k| k.0));
341 legend
342 }),
343 })
344 }
345}
346
347fn snapshot_of_solids(state: &EngineState, names: &[String], tolerance: Tolerance) -> Option<MeshSnapshot> {
349 let mut builder = MeshBuilder::new(tolerance);
350 let mut any = false;
351 for name in names {
352 let Some(solid) = state.scene.solid(name) else { continue };
353 let mesh = &solid.mesh;
354 let positions: Vec<f64> = mesh.positions.iter().flat_map(|p| [p[0] as f64, p[1] as f64, p[2] as f64]).collect();
355 let max_face = mesh.face_ids.iter().copied().max().map_or(0, |m| m as usize + 1);
356 let face_names: Vec<String> = (0..max_face).map(|i| solid.faces.get(i).map(|f| f.name.clone()).unwrap_or_else(|| format!("f{i}"))).collect();
357 if builder.add_body_buffers(name, false, &positions, &mesh.indices, &mesh.face_ids, &face_names).is_ok() {
358 any = true;
359 }
360 }
361 if any {
362 Some(builder.finish())
363 } else {
364 None
365 }
366}
367
368fn bounds_of_solids(state: &EngineState, names: &[String]) -> Bounds3 {
370 let mut b = Bounds3::empty();
371 for name in names {
372 if let Some(solid) = state.scene.solid(name) {
373 for p in &solid.mesh.positions {
374 b.include(Point3::new(p[0] as f64, p[1] as f64, p[2] as f64));
375 }
376 }
377 }
378 b
379}
380
381pub fn build_session(
387 state: &EngineState,
388 doc: &CamDocument,
389 setup_id: Option<&str>,
390 cell: f64,
391 machine: Option<&Machine>,
392 toolpaths: &BTreeMap<String, brep_cam::model::Toolpath>,
393) -> Result<SimSession, String> {
394 let toolpath_of = |id: &str| -> Option<brep_cam::model::Toolpath> {
395 toolpaths.get(id).cloned().or_else(|| state.cam.results.get(id).and_then(|r| r.toolpath.clone()))
396 };
397 let generated: Vec<&brep_cam::model::Operation> = doc
398 .operations
399 .iter()
400 .filter(|op| toolpaths.contains_key(op.id.as_str()) || state.cam.results.get(op.id.as_str()).map_or(false, |r| r.toolpath.is_some()))
401 .collect();
402 if generated.is_empty() {
403 return Err("nothing to simulate: generate an operation first".into());
404 }
405 let setup_id = match setup_id {
406 Some(id) => id.to_string(),
407 None => generated[0].setup.to_string(),
408 };
409 let setup: &Setup = doc.setup(&setup_id).ok_or_else(|| format!("no setup `{setup_id}`"))?;
410 let ops: Vec<&brep_cam::model::Operation> = generated.into_iter().filter(|op| op.setup.as_str() == setup_id).collect();
411 if ops.is_empty() {
412 return Err(format!("setup `{setup_id}` has no generated operation"));
413 }
414 let mut inputs = Vec::new();
415 let mut multi_axis = false;
416 for op in &ops {
417 let tool = doc.tool(op.tool.as_str()).ok_or_else(|| format!("operation `{}` names a missing tool", op.id))?;
418 let toolpath = toolpath_of(op.id.as_str()).ok_or_else(|| format!("operation `{}` has no toolpath", op.id))?;
419 if toolpath.motions.iter().any(|m| m.tool_axis.map_or(false, |a| a.normalized().map_or(false, |u| u.distance(Point3::Z) > 1e-9))) {
420 multi_axis = true;
421 }
422 inputs.push(OperationInput { operation: (*op).clone(), tool: tool.clone(), toolpath });
423 }
424 let turning_program=inputs.iter().any(|i|matches!(i.operation.strategy,brep_cam::Strategy::TurnFace(_)|brep_cam::Strategy::TurnProfile(_)));
425 let turning_machine=if turning_program {machine.cloned().or_else(||doc.setup_machine(&setup_id))}else{None};
426 let program = assemble(&ProgramInput { name: format!("sim-{setup_id}"), machine:turning_machine, setups: vec![SetupInput { setup: setup.clone(), operations: inputs }], ..Default::default() })
427 .map_err(|e| format!("program: {e}"))?;
428 let setup_machine = doc.setup_machine(&setup_id);
431 let machine = machine.cloned().or(setup_machine);
432 let machine = machine.as_ref();
433 let timeline = Timeline::from_program(&program, machine.unwrap_or(&Machine::default()));
434 let mut ranges = Vec::new();
436 let mut turning_target = None;
437 for (section, range) in program.sections.iter().zip(&timeline.operations) {
438 let tool = program.tools.iter().find(|t| t.id == section.tool).ok_or("program tool missing")?;
439 let tolerance = doc.operation(range.operation.as_str()).map(|o| o.tolerance).filter(|t| t.is_finite() && *t > 0.0).unwrap_or(DEFAULT_TOLERANCE);
440 if tool.kind == brep_cam::ToolKind::LatheInsert {
441 use brep_cam::planar::{Region,Loop,Segment};
442 let operation=doc.operation(section.operation.as_str()).ok_or("turning operation missing")?;
443 if !matches!(operation.strategy,brep_cam::Strategy::TurnFace(_)|brep_cam::Strategy::TurnProfile(_)) {return Err("axial simulation supports face/profile turning only".into());}
444 let certified=state.cam.results[section.operation.as_str()].turning_profile.as_ref().ok_or("turning result has no certified meridian; regenerate")?;
445 let target=Region::from_unoriented(certified.loops.iter().map(|lp|Loop::new(lp.iter().zip(lp.iter().cycle().skip(1)).take(lp.len()).filter(|(a,b)|a!=b).map(|(a,b)|Segment::line(brep_cam::Point2::new(a.x,a.z),brep_cam::Point2::new(b.x,b.z))).collect())).collect(),&Tolerance::default()).map_err(|e|e.to_string())?;
446 if turning_target.as_ref().is_some_and(|p|p!=&target) {return Err("turning ranges have different protected profiles".into());}
447 turning_target=Some(target.clone());
448 let protected=target.offset(-operation.tolerance,&Tolerance::default()).map_err(|e|e.to_string())?;
449 let geometry=tool.turning_geometry.as_ref().ok_or_else(||format!("tool {} has no insert geometry",tool.id))?;
450 if tool.holder.is_some() {return Err("turning simulation requires an explicit axial holder envelope; mill holder stacks are unsupported".into());}
451 let envelope=brep_cam::turning::envelope(tool,geometry,operation.tolerance*0.1).map_err(|e|e.to_string())?;
452 let stick=setup.turning_stick_out.ok_or("setup has no stick-out")?;
453 let stock=match setup.stock {brep_cam::Stock::Cylinder{base,axis,radius,height}=>brep_cam::StockCylinder{base:setup.model_to_machine.apply_point(base),axis:setup.model_to_machine.apply_vector(axis),radius,height},_=>return Err("turning simulation requires cylindrical stock".into())};
454 let chuck_z=brep_cam::turning::profile::bar_section(&stock,stick).map_err(|e|e.to_string())?.chuck_z;
455 let turning=brep_cam::multiaxis::sim::player::TurningRange{tool:tool.id.clone(),envelope,holders:Vec::new(),protected,chuck_z};
456 ranges.push(RangeTool{operation:range.operation.to_string(),first:range.first,count:range.count,cutter:None,profile:None,turning:Some(turning),tolerance});
457 } else {
458 let cutter = Cutter::from_tool(tool).map_err(|e| format!("tool `{}`: {e}", tool.id))?;
459 let profile = ToolProfile::new(&cutter, tool.holder.as_ref(), 0.01);
460 ranges.push(RangeTool { operation: range.operation.to_string(), first: range.first, count: range.count, cutter:Some(cutter), profile:Some(profile), turning:None, tolerance });
461 }
462 }
463 let mut target_names: Vec<String> = setup.targets.iter().map(|t| t.solid.clone()).collect();
465 for op in &ops {
466 for s in &op.targets.target_solids {
467 if !target_names.contains(s) {
468 target_names.push(s.clone());
469 }
470 }
471 }
472 let start = ops.iter().find_map(|op| state.cam.results.get(op.id.as_str()).and_then(|r| r.setup_start.as_ref()));
477 let target_bounds = start.and_then(|s| s.target_bounds).unwrap_or_else(|| bounds_of_solids(state, &target_names));
478 let tolerance = Tolerance::default();
479 let from_solid = match &setup.stock {
480 Stock::FromSolid { solid } => snapshot_of_solids(state, std::slice::from_ref(solid), tolerance),
481 _ => None,
482 };
483 let resolved = resolve_stock(setup, &target_bounds, from_solid.as_ref()).map_err(|e| e.to_string())?;
484 let part = snapshot_of_solids(state, &target_names, tolerance).map(|m| brep_cam::mesh::to_machine(setup, &m));
485 let carried = match &resolved {
489 brep_cam::ResolvedStock::Rest(_) if start.is_some_and(|s| s.carried.is_some()) => start.and_then(|s| s.carried.clone()),
490 brep_cam::ResolvedStock::Rest(_) => {
491 let mesh = part.as_ref().ok_or("the carried stock needs the target bodies on screen")?;
492 let previous = crate::panels::cam_generate::setup_history(state, doc, setup);
493 let own_machine = doc.setup_machine(setup.id.as_str());
494 let job = brep_cam::JobStock { setup, machine: own_machine.as_ref(), tools: &doc.tools, mesh,
495 fallback_targets: &target_names, earlier: &[], previous: &previous, tolerance };
496 let mut sink = NullSink;
497 let result = brep_cam::job_stock(&mut brep_cam::PreOpStockCache::new(previous.len() + 1), &job, &mut Runner::detached(&mut sink))
498 .map_err(|e| format!("carried stock from the previous setup: {e}"))?;
499 Some(result.stock.grid)
500 }
501 _ => None,
502 };
503 let chain = brep_cam::multiaxis::sim::sim_chain(machine);
506 if brep_cam::multiaxis::sim::uses_rotary(&timeline.motions) {
507 multi_axis = false;
508 }
509 let stock = if let Some(target)=turning_target {
510 let first_milling=ranges.iter().filter(|r|r.turning.is_none()).map(|r|r.first).min();
513 if first_milling.is_some_and(|m|ranges.iter().any(|r|r.turning.is_some()&&r.first>m)) {return Err("turning after milling in one setup is not simulated: split the setup after the first milling operation".into());}
514 let brep_cam::ResolvedStock::Cylinder(cylinder)=&resolved else {return Err("turning simulation requires cylinder stock".into());};
515 let section=brep_cam::planar::Region::new(vec![brep_cam::planar::Loop::rectangle(brep_cam::Point2::new(0.,cylinder.base.z),brep_cam::Point2::new(cylinder.radius,cylinder.base.z+cylinder.height))],&tolerance).map_err(|e|e.to_string())?;
516 let view=DexelGrid::from_stock(&resolved,&DexelOptions{cell,..Default::default()},None,None).map_err(|e|e.to_string())?;
517 let mut axial=brep_cam::multiaxis::sim::player::TurningStock{section,target,view};axial.refresh_view();
518 StockModel::Turning(axial)
519 } else if let Some(grid) = carried {
520 if multi_axis {
521 StockModel::TriDexel(TriDexel::from_dexel(&grid, &TriDexelOptions { cell, ..Default::default() }).map_err(|e| format!("{e:?}"))?)
522 } else {
523 StockModel::Dexel(grid)
524 }
525 } else if multi_axis {
526 StockModel::TriDexel(TriDexel::from_stock(&resolved, &TriDexelOptions { cell, ..Default::default() }, None, None).map_err(|e| e.to_string())?)
527 } else {
528 StockModel::Dexel(DexelGrid::from_stock(&resolved, &DexelOptions { cell, ..Default::default() }, None, None).map_err(|e| format!("{e:?}"))?)
529 };
530 let mut marks = Vec::new();
533 let mut collision_motions = BTreeSet::new();
534 let mut collisions: Vec<(usize, String, String)> = Vec::new();
535 if let StockModel::Dexel(grid) = &stock {
536 let mut scratch = grid.clone();
537 for r in &ranges {
538 let motions: Vec<brep_cam::model::Motion> = timeline.motions[r.first..r.first + r.count].to_vec();
539 let mut sink = NullSink;
540 let options = CollisionOptions { stop_at_first: false, ..Default::default() };
541 let mut runner = Runner::detached(&mut sink);
542 let checked = match &chain {
543 Some(chain) => simulate_checked_on_machine(&mut scratch, None, r.cutter.as_ref().unwrap(), r.profile.as_ref().unwrap(), &motions, &tolerance, &RemovalOptions::default(), &options, chain, &mut runner),
544 None => simulate_checked(&mut scratch, None, r.cutter.as_ref().unwrap(), r.profile.as_ref().unwrap(), &motions, &tolerance, &RemovalOptions::default(), &options, &mut runner),
545 }
546 .map_err(|_| "collision check cancelled".to_string())?;
547 for c in checked.collisions {
548 let gouge = matches!(c.part, brep_cam::CollisionPart::CuttingOnRapid);
551 marks.push(Marker { at: c.contact, gouge });
552 collision_motions.insert(r.first + c.motion_index);
553 collisions.push((r.first + c.motion_index, format!("{:?}", c.part), format!("{:?}", c.against)));
554 }
555 }
556 }
557 let mut machine_notes = Vec::new();
561 if let Some(machine) = machine.filter(|m| !m.collision.is_empty()) {
562 match Chain::from_machine(machine, Point3::ZERO) {
564 Ok(chain) => match resolve_program(&program, &chain, &ResolveOptions::default()) {
565 Ok(resolved) => {
566 let tri = match &stock {
567 StockModel::TriDexel(t) => Some(t.clone()),
568 StockModel::Dexel(g) => TriDexel::from_dexel(g, &TriDexelOptions { cell, ..Default::default() }).ok(),
569 StockModel::Turning(t) => TriDexel::from_dexel(&t.view, &TriDexelOptions { cell, ..Default::default() }).ok(),
570 };
571 let mut sink = NullSink;
572 match check_machine(&chain, &machine.collision, &resolved.motions, tri.as_ref(), &MachineCheckOptions::default(), &mut Runner::detached(&mut sink)) {
573 Ok(report) => {
574 if let Some(hit) = report.first {
575 let motion = resolved.motions[hit.block..].iter().find_map(|b| b.source).unwrap_or(hit.block);
576 let on_part = chain.world_to_part(&hit.angles_deg, hit.point);
577 marks.push(Marker { at: on_part, gouge: false });
578 collision_motions.insert(motion);
579 collisions.push((motion, hit.body.clone(), hit.against.clone()));
580 }
581 }
582 Err(e) => machine_notes.push(format!("machine check: {e}")),
583 }
584 }
585 Err(e) => machine_notes.push(format!("machine kinematics: {e}")),
586 },
587 Err(e) => machine_notes.push(format!("machine: {e}")),
588 }
589 }
590 let range_cutters: Vec<RangeCutter> = ranges.iter().map(|r| RangeCutter { first: r.first, count: r.count, cutter: r.cutter, profile: r.profile.clone(), turning:r.turning.clone() }).collect();
591 if matches!(&stock,StockModel::Turning(_)) {
592 let mut checked=SimDriver::new(stock.clone(),DriverOptions::default()).map_err(|e|e.to_string())?;
593 let turned=range_cutters.iter().filter(|r|r.turning.is_none()).map(|r|r.first).min().unwrap_or(timeline.len());
595 checked.advance_ranges(&timeline,&range_cutters,turned,usize::MAX,&tolerance,&mut |_|true).map_err(|e|format!("turning checked replay: {e}"))?;
596 }
597 let load_stock = stock.clone();
598 let mut driver = SimDriver::new(stock, DriverOptions::default()).map_err(|e| e.to_string())?.with_chain(chain);
599 for note in machine_notes {
600 driver.warnings.push((0, brep_cam::model::Diagnostic::new("machine-check", note)));
601 }
602 Ok(SimSession {
603 setup: setup_id,
604 player: Player::new(timeline),
605 driver,
606 ranges,
607 to_model: setup.machine_to_model(),
608 marks,
609 collision_motions,
610 collisions,
611 range_cutters,
612 load_stock,
613 sim_machine: machine.cloned(),
614 remote: None,
615 multi_axis,
616 part,
617 deviation: None,
618 legend: None,
619 mesh: StockMesh::default(),
620 mesh_version: None,
621 part_index: None,
622 remaining: None,
623 remaining_key: None,
624 epoch: NEXT_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
625 })
626}
627
628const RUNNER_CHUNK_GROWTH: usize = 8;
630
631pub struct CamSimPanel {
633 hits: HashMap<String, egui::Rect>,
634 pub session: Option<SimSession>,
635 pub speed: f64,
636 pub stop_at_collision: bool,
637 pub cell: f64,
638 pub budget_per_frame: usize,
639 pub budget_pinned: bool,
642 pub show_deviation: bool,
643 pub show_remaining: bool,
645 remaining_age: f64,
647 pub notice: Option<String>,
648 overlay: [String; 3],
650 overlay_stock_key: Option<String>,
654 overlay_stock_next: Option<web_time::Instant>,
661 overlay_stock_builds: u64,
663 pub pending_save: Option<SaveRequest>,
665 pub error: Option<String>,
667 pub machine: Option<Machine>,
670 pub toolpaths: BTreeMap<String, brep_cam::model::Toolpath>,
673 pub force_inline: bool,
675}
676
677impl Default for CamSimPanel {
678 fn default() -> Self {
679 Self::new()
680 }
681}
682
683impl CamSimPanel {
684 pub fn new() -> Self {
685 Self {
686 hits: HashMap::new(),
687 session: None,
688 speed: 1.0,
689 stop_at_collision: true,
690 cell: 0.5,
691 budget_per_frame: 64,
692 budget_pinned: false,
693 show_deviation: false,
694 show_remaining: false,
695 remaining_age: 0.0,
696 notice: None,
697 overlay: Default::default(),
698 overlay_stock_key: None,
699 overlay_stock_next: None,
700 overlay_stock_builds: 0,
701 pending_save: None,
702 error: None,
703 machine: None,
704 toolpaths: BTreeMap::new(),
705 force_inline: false,
706 }
707 }
708
709 pub fn load(&mut self, state: &mut EngineState, doc: &CamDocument, setup_id: Option<&str>) -> Result<(), String> {
711 if let Some(old) = self.session.as_mut() {
712 old.cancel(state);
713 if let Some(r) = &old.remote {
714 let _ = state.cam_sim_submit(CamSimJob { session: r.session, seq: u64::MAX, request: CamSimRequest::Drop });
715 }
716 }
717 match build_session(state, doc, setup_id, self.cell, self.machine.as_ref(), &self.toolpaths) {
718 Ok(mut s) => {
719 if !self.force_inline {
720 let session = NEXT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
723 let mut remote = RemoteSim { session, ..Default::default() };
724 remote.seq = 1;
725 let load = CamSimRequest::Load {
726 timeline: s.player.timeline.clone(),
727 stock: s.load_stock.clone(),
728 ranges: s.range_cutters.clone(),
729 options: s.driver.options.clone(),
730 machine: s.sim_machine.clone(),
731 };
732 if state.cam_sim_submit(CamSimJob { session, seq: 1, request: load }) {
733 remote.in_flight = Some((1, 0));
734 remote.submitted = 1;
735 s.remote = Some(remote);
736 }
737 }
738 s.player.set_speed(self.speed).ok();
739 s.player.stop_at_collision = self.stop_at_collision;
740 self.notice = Some(format!(
741 "{} motions, {:.1} s, {}{}",
742 s.player.timeline.len(),
743 s.player.timeline.total_seconds,
744 if matches!(&s.driver.stock, StockModel::Turning(_)) { "axisymmetric stock" } else if s.multi_axis { "tri-dexel stock" } else { "Z-dexel stock" },
745 if s.marks.is_empty() { String::new() } else { format!(", {} collision marks", s.marks.len()) }
746 ));
747 self.session = Some(s);
748 self.error = None;
749 Ok(())
750 }
751 Err(e) => {
752 self.error = Some(e.clone());
753 self.notice = Some(e.clone());
754 Err(e)
755 }
756 }
757 }
758
759 pub fn unload(&mut self, state: &mut EngineState) {
760 if let Some(mut s) = self.session.take() {
761 s.cancel(state);
762 if let Some(r) = &s.remote {
763 let _ = state.cam_sim_submit(CamSimJob { session: r.session, seq: u64::MAX, request: CamSimRequest::Drop });
764 }
765 }
766 self.overlay = Default::default();
767 self.overlay_stock_key = None;
768 }
769
770 pub fn cancel(&mut self, state: &mut EngineState) -> bool {
772 let stopped = self.session.as_mut().map_or(false, |s| s.cancel(state));
773 if stopped {
774 self.notice = Some("simulation stopped: the stock shows the last finished motion".into());
775 }
776 stopped
777 }
778
779 pub fn pump(&mut self, state: &mut EngineState, dt: f64) -> bool {
784 let stop_at_collision = self.stop_at_collision;
785 let budget = self.budget_per_frame;
786 let chunk = if self.budget_pinned { budget } else { budget.saturating_mul(RUNNER_CHUNK_GROWTH) };
793 let Some(s) = self.session.as_mut() else { return false };
794 self.remaining_age += dt;
795 let before = s.player.position;
796 let moved = s.player.advance(dt);
797 if moved && stop_at_collision && s.player.state == PlayState::Playing {
798 let entered = (before.motion..=s.player.position.motion).find(|m| s.collision_motions.contains(m) && (*m > before.motion || before.fraction == 0.0));
800 if let Some(m) = entered {
801 s.player.pause();
802 s.player.position = brep_cam::multiaxis::sim::Position { motion: m, fraction: 0.0 };
803 self.notice = Some(format!("stopped at motion {m}: collision"));
804 }
805 }
806 match s.drive(state, budget, chunk) {
807 Ok(pending) => pending || s.player.state == PlayState::Playing,
808 Err(e) => {
809 self.notice = Some(e);
810 false
811 }
812 }
813 }
814
815 pub fn push_overlay(&mut self, state: &mut EngineState) {
817 if self.show_remaining {
820 if let Some(s) = self.session.as_mut() {
821 let playing = s.player.state == PlayState::Playing;
822 if !playing || self.remaining_age >= 0.5 {
823 match s.refresh_remaining() {
824 Ok(true) => self.remaining_age = 0.0,
825 Ok(false) => {}
826 Err(e) => {
827 self.notice = Some(e);
828 self.show_remaining = false;
829 }
830 }
831 }
832 }
833 }
834 let show_remaining = self.show_remaining;
835 let mut built = None;
836 let groups = match self.session.as_mut() {
837 None => {
838 self.overlay_stock_key = None;
839 [STOCK_GROUP, TOOL_GROUP, MARKS_GROUP].map(|name| Some(json!({ "name": name }).to_string()))
840 }
841 Some(s) => {
842 let to_model = s.to_model;
843 let remaining = show_remaining && s.remaining.is_some();
844 let legend = if self.show_deviation { s.legend.as_ref().map(|l| l.to_string()) } else { None };
845 let look = format!("{}:{}:{:?}:{:?}", s.epoch, remaining, s.remaining_key, legend);
846 let key = format!("{look}:{}", s.driver.version);
847 let settled = s.caught_up() && s.player.state != PlayState::Playing;
850 let held = !settled
851 && self.overlay_stock_key.as_deref().is_some_and(|k| k.rsplit_once(':').map(|(l, _)| l) == Some(look.as_str()))
852 && self.overlay_stock_next.is_some_and(|t| web_time::Instant::now() < t);
853 let stock = if self.overlay_stock_key.as_deref() == Some(key.as_str()) || held {
857 None
858 } else {
859 built = Some(web_time::Instant::now());
860 self.overlay_stock_builds += 1;
861 self.overlay_stock_key = Some(key);
862 match (&s.remaining, remaining) {
863 (Some(r), true) => remaining_group(r, &to_model),
864 _ => {
865 let deviation = if self.show_deviation { s.deviation.clone() } else { None };
866 let scale = s.legend.as_ref().and_then(|l| l["scale"].as_f64()).unwrap_or(1.0);
867 stock_group(s.mesh(), &to_model, deviation.as_ref().map(|d| (d, scale)))
868 }
869 }
870 .to_string()
871 .into()
872 };
873 let tool = match (s.player.pose(), s.range_of(s.player.position.motion)) {
874 (Some((tip, axis)), Some(r)) if r.profile.is_some() => tool_group(r.profile.as_ref().unwrap(), tip, axis, &to_model),
875 _ => json!({ "name": TOOL_GROUP }),
876 };
877 [stock, Some(tool.to_string()), Some(marks_group(&s.marks, &to_model).to_string())]
878 }
879 };
880 let changed: Vec<usize> = (0..3).filter(|&i| groups[i].as_ref().is_some_and(|g| *g != self.overlay[i])).collect();
882 if !changed.is_empty() {
883 let body: Vec<&str> = changed.iter().map(|&i| groups[i].as_deref().unwrap_or_default()).collect();
884 let _ = state.widgets.set_overlay_json(&format!("{{\"groups\":[{}]}}", body.join(",")));
885 state.dirty = true;
886 for (slot, group) in self.overlay.iter_mut().zip(groups) {
887 if let Some(group) = group {
888 *slot = group;
889 }
890 }
891 }
892 if let Some(start) = built {
893 let cost = start.elapsed();
896 self.overlay_stock_next = Some(web_time::Instant::now() + (cost * 2).max(web_time::Duration::from_millis(500)));
897 }
898 }
899
900 pub fn export_stl(&mut self) -> Option<String> {
902 let s = self.session.as_mut()?;
903 let to_model = s.to_model;
904 let name = format!("stock-{}", s.setup);
905 Some(stock_stl(s.mesh(), &to_model, &name))
906 }
907
908 pub fn state_json(&self) -> Value {
909 json!({
910 "loaded": self.session.is_some(),
911 "speed": self.speed,
912 "stopAtCollision": self.stop_at_collision,
913 "cell": self.cell,
914 "showDeviation": self.show_deviation,
915 "showRemaining": self.show_remaining,
916 "notice": self.notice,
917 "error": self.error,
918 "session": self.session.as_ref().map(|s| s.state_json()),
919 "overlayStockBuilds": self.overlay_stock_builds,
920 })
921 }
922
923 pub fn hits_json(&self) -> String {
924 crate::automation::hit_rects::hits_json(&self.hits)
925 }
926
927 pub fn hit_rects(&self) -> &HashMap<String, egui::Rect> {
929 &self.hits
930 }
931
932 pub fn clear_hits(&mut self) {
933 self.hits.clear();
934 }
935
936 pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState, doc: &CamDocument, setup_hint: Option<&str>) {
938 self.hits.clear();
939 let header = egui::CollapsingHeader::new("Simulation").default_open(true).show(ui, |ui| {
940 ui.horizontal_wrapped(|ui| {
941 let load = ui.button(if self.session.is_some() { "Reload" } else { "Load" }).on_hover_text("Simulate the generated operations of the selected setup");
942 self.hits.insert("cam:sim:load".into(), load.rect);
943 if load.clicked() {
944 let _ = self.load(state, doc, setup_hint);
945 }
946 let busy = self.session.as_ref().map_or(false, |s| !s.caught_up());
947 let stop = ui.add_enabled(busy, egui::Button::new("Stop")).on_hover_text("Stop simulating: keep the stock of the last finished motion");
948 self.hits.insert("cam:sim:cancel".into(), stop.rect);
949 if stop.clicked() {
950 self.cancel(state);
951 }
952 let has = self.session.is_some();
953 let playing = self.session.as_ref().map_or(false, |s| s.player.state == PlayState::Playing);
954 let play = ui.add_enabled(has, egui::Button::new(if playing { "Pause" } else { "Play" }));
955 self.hits.insert("cam:sim:play".into(), play.rect);
956 if play.clicked() {
957 if let Some(s) = self.session.as_mut() {
958 s.player.toggle();
959 }
960 }
961 let back = ui.add_enabled(has, egui::Button::new("|<"));
962 self.hits.insert("cam:sim:step:back".into(), back.rect);
963 if back.clicked() {
964 if let Some(s) = self.session.as_mut() {
965 s.player.step_back();
966 }
967 }
968 let fwd = ui.add_enabled(has, egui::Button::new(">|"));
969 self.hits.insert("cam:sim:step:forward".into(), fwd.rect);
970 if fwd.clicked() {
971 if let Some(s) = self.session.as_mut() {
972 s.player.step_forward();
973 }
974 }
975 });
976 if let Some(s) = self.session.as_mut() {
977 let mut progress = s.player.progress();
978 let slider = ui.add(egui::Slider::new(&mut progress, 0.0..=1.0).show_value(false).text("scrub"));
979 self.hits.insert("cam:sim:scrub".into(), slider.rect);
980 if slider.changed() {
981 s.player.pause();
982 s.player.scrub(progress);
983 }
984 ui.horizontal_wrapped(|ui| {
985 let mut speed = self.speed;
986 let drag = ui.add(egui::DragValue::new(&mut speed).range(0.1..=100.0).speed(0.1).prefix("speed ×"));
987 self.hits.insert("cam:sim:speed".into(), drag.rect);
988 if drag.changed() && s.player.set_speed(speed).is_ok() {
989 self.speed = speed;
990 }
991 let stop = ui.checkbox(&mut self.stop_at_collision, "stop at collision");
992 self.hits.insert("cam:sim:stop".into(), stop.rect);
993 s.player.stop_at_collision = self.stop_at_collision;
994 });
995 let op = s.player.timeline.operation_of(s.player.position.motion).map(|i| s.player.timeline.operations[i].operation.to_string()).unwrap_or_default();
996 let time = s.player.time();
997 let total = s.player.timeline.total_seconds;
998 let line = ui.label(format!(
999 "{op} {:.1} s / {:.1} s motion {}/{} applied {} removed {:.1} {}",
1000 time,
1001 total,
1002 s.player.position.motion + 1,
1003 s.player.timeline.len(),
1004 s.driver.applied,
1005 s.driver.removed_volume,
1006 if s.caught_up() { "" } else { "(simulating…)" }
1007 ));
1008 self.hits.insert("cam:sim:readout".into(), line.rect);
1009 if !s.marks.is_empty() {
1010 ui.colored_label(egui::Color32::from_rgb(230, 90, 60), format!("{} collision / gouge marks (red points)", s.marks.len()));
1011 }
1012 if !s.driver.unsupported.is_empty() {
1013 ui.colored_label(egui::Color32::from_rgb(230, 160, 60), format!("{} tilted motions not cut by the Z-dexel", s.driver.unsupported.len()));
1014 }
1015 ui.horizontal_wrapped(|ui| {
1016 let compare = ui.button("Compare to part").on_hover_text("Deviation of the simulated stock from the part: excess blue, gouge red");
1017 self.hits.insert("cam:sim:deviation".into(), compare.rect);
1018 if compare.clicked() {
1019 match s.compare(&DeviationOptions::default(), 1.0) {
1020 Ok(()) => self.show_deviation = true,
1021 Err(e) => self.notice = Some(e),
1022 }
1023 }
1024 let shown = ui.add_enabled(s.deviation.is_some(), egui::Checkbox::new(&mut self.show_deviation, "colour map"));
1025 self.hits.insert("cam:sim:deviation:show".into(), shown.rect);
1026 let remaining = ui
1027 .add_enabled(s.part.is_some(), egui::Checkbox::new(&mut self.show_remaining, "Remaining stock"))
1028 .on_hover_text("Colour the stock at the playback point by the material left over the part: red gouge, green within the operation tolerance, blue up to 10× it, yellow beyond");
1029 self.hits.insert("cam:sim:remaining".into(), remaining.rect);
1030 let export = ui.button("Export stock (STL)");
1031 self.hits.insert("cam:sim:export".into(), export.rect);
1032 if export.clicked() {
1033 let name = format!("stock-{}.stl", s.setup);
1034 let solid = format!("stock-{}", s.setup);
1035 let to_model = s.to_model;
1036 let stl = stock_stl(s.mesh(), &to_model, &solid);
1037 self.pending_save = Some(SaveRequest { title: "Export the simulated stock".into(), file_name: name, body: SaveBody::Text(stl) });
1038 }
1039 });
1040 if let (true, Some(r)) = (self.show_remaining, &s.remaining) {
1041 let tol = r.tolerance;
1042 let (_, _, gouge) = r.counts();
1043 let line = ui.label(format!(
1044 "remaining stock (mm): red < {:.3} green ±{:.3} blue ≤ {:.3} yellow > {:.3} max excess {:.3}{}",
1045 -tol,
1046 tol,
1047 10.0 * tol,
1048 10.0 * tol,
1049 r.max.max(0.0),
1050 if gouge > 0 { format!(" {gouge} gouged vertices") } else { String::new() }
1051 ));
1052 self.hits.insert("cam:sim:remaining:legend".into(), line.rect);
1053 }
1054 if let Some(legend) = &s.legend {
1055 let line = ui.label(format!(
1056 "deviation: min {:.3} max {:.3} tolerance ±{:.3} (green within, blue excess, red gouge)",
1057 legend["min"].as_f64().unwrap_or(0.0),
1058 legend["max"].as_f64().unwrap_or(0.0),
1059 legend["tolerance"].as_f64().unwrap_or(0.0)
1060 ));
1061 self.hits.insert("cam:sim:legend".into(), line.rect);
1062 }
1063 }
1064 if let Some(notice) = &self.notice {
1065 let label = ui.label(egui::RichText::new(notice).weak());
1066 self.hits.insert("cam:sim:notice".into(), label.rect);
1067 }
1068 });
1069 self.hits.insert("cam:sim:header".into(), header.header_response.rect);
1070 self.push_overlay(state);
1071 }
1072}
1073
1074pub static HIT_KEYS: &[HitKeyDoc] = &[
1075 HitKeyDoc { panel: "cam", prefix: "cam:sim:header", meaning: "the Simulation section's header", command: None },
1076 HitKeyDoc { panel: "cam", prefix: "cam:sim:load", meaning: "load (or reload) the simulation of the selected setup's generated operations", command: Some("cam_sim_load") },
1077 HitKeyDoc { panel: "cam", prefix: "cam:sim:cancel", meaning: "stop simulating: the stock keeps the last finished motion", command: Some("cam_sim_cancel") },
1078 HitKeyDoc { panel: "cam", prefix: "cam:sim:play", meaning: "play or pause the simulation", command: Some("cam_sim_play") },
1079 HitKeyDoc { panel: "cam", prefix: "cam:sim:step:back", meaning: "step to the start of the current (or previous) motion", command: Some("cam_sim_step") },
1080 HitKeyDoc { panel: "cam", prefix: "cam:sim:step:forward", meaning: "step to the end of the current (or next) motion", command: Some("cam_sim_step") },
1081 HitKeyDoc { panel: "cam", prefix: "cam:sim:scrub", meaning: "the scrub slider over the program's time", command: Some("cam_sim_scrub") },
1082 HitKeyDoc { panel: "cam", prefix: "cam:sim:speed", meaning: "playback speed relative to machining time", command: Some("cam_sim_set") },
1083 HitKeyDoc { panel: "cam", prefix: "cam:sim:stop", meaning: "pause when a collision motion is entered", command: Some("cam_sim_set") },
1084 HitKeyDoc { panel: "cam", prefix: "cam:sim:readout", meaning: "operation, time, motion, applied count and removed volume", command: Some("cam_sim_state") },
1085 HitKeyDoc { panel: "cam", prefix: "cam:sim:deviation", meaning: "compare the simulated stock with the part (deviation colour map)", command: Some("cam_sim_deviation") },
1086 HitKeyDoc { panel: "cam", prefix: "cam:sim:deviation:show", meaning: "show or hide the deviation colour map", command: Some("cam_sim_set") },
1087 HitKeyDoc { panel: "cam", prefix: "cam:sim:remaining", meaning: "colour the stock by remaining material at the playback point (bands from the operation tolerance)", command: Some("cam_sim_set") },
1088 HitKeyDoc { panel: "cam", prefix: "cam:sim:remaining:legend", meaning: "the remaining-stock legend: band edges in mm and the largest excess", command: Some("cam_sim_state") },
1089 HitKeyDoc { panel: "cam", prefix: "cam:sim:export", meaning: "export the simulated stock as STL through the save door", command: Some("cam_sim_export") },
1090 HitKeyDoc { panel: "cam", prefix: "cam:sim:legend", meaning: "the deviation legend", command: None },
1091 HitKeyDoc { panel: "cam", prefix: "cam:sim:notice", meaning: "what the last simulation action said", command: None },
1092];
1093