Skip to main content

brep_app/panels/
cam_sim.rs

1//! The CAM simulation player (lane C17, rows 2-3): a section of the CAM pane
2//! that loads a setup's generated operations into a program, plays it
3//! against the stock (C7's Z-dexel for 3-axis streams, the tri-dexel when a
4//! tool axis tilts), draws the stock, the tool at the scrub position and the
5//! collision / gouge markers in the viewport, compares the stock with the
6//! part (deviation colour map with a legend), reads the cycle time, stops at
7//! a collision when asked, and exports the simulated stock as STL.
8//!
9//! The simulation advances on the UI thread in bounded chunks per frame
10//! (`budget_per_frame` motions) so the pane stays responsive; the runner
11//! Command/Reply that moves it to the worker is requested from C1.
12
13use 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/// One operation's range in the program with its cutter.
43#[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    /// The operation's tolerance: the remaining-stock bands' edge.
52    pub tolerance: f64,
53}
54
55/// A loaded simulation.
56pub 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    /// Collision and gouge markers found by C7's checked simulation (3-axis
63    /// streams) at load, with the motions they belong to.
64    pub marks: Vec<Marker>,
65    pub collision_motions: BTreeSet<usize>,
66    /// Every collision found: (motion index in the program, part or body, against).
67    pub collisions: Vec<(usize, String, String)>,
68    /// The ranges with their cutters, as the stepping function takes them.
69    pub range_cutters: Vec<RangeCutter>,
70    /// The stock as loaded (the runner's Load and a reload after a lost session).
71    load_stock: StockModel,
72    /// The machine the session simulates on (sent with the runner's Load).
73    sim_machine: Option<Machine>,
74    /// `Some` when the background runner holds the stock (C17b); `None`
75    /// simulates inline on the UI thread in bounded chunks.
76    pub remote: Option<RemoteSim>,
77    pub multi_axis: bool,
78    /// The part in machine coordinates (deviation).
79    pub part: Option<MeshSnapshot>,
80    pub deviation: Option<DeviationMap>,
81    pub legend: Option<Value>,
82    mesh: StockMesh,
83    mesh_version: Option<u64>,
84    /// The part's index, built once for the remaining-stock overlay.
85    part_index: Option<TreeIndex>,
86    /// The stock coloured by remaining material (C7's remaining_stock_mesh),
87    /// for the stock version and tolerance in `remaining_key`.
88    pub remaining: Option<RemainingStockMesh>,
89    remaining_key: Option<(u64, u64)>,
90    /// Distinguishes sessions in the overlay's stock key (versions restart at 0).
91    epoch: u64,
92}
93
94static NEXT_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
95
96/// Remaining-stock band edge when an operation has no usable tolerance.
97pub const DEFAULT_TOLERANCE: f64 = 0.05;
98
99/// The runner-side session's bookkeeping on the main side.
100#[derive(Debug, Default)]
101pub struct RemoteSim {
102    pub session: u64,
103    seq: u64,
104    /// The chunk on the runner: (seq, target).
105    pub in_flight: Option<(u64, usize)>,
106    /// Sequence numbers whose replies are dropped (cancelled chunks).
107    dropped: BTreeSet<u64>,
108    /// The runner acknowledged the Load.
109    pub loaded: bool,
110    /// Chunks submitted and replies accepted (for the state blob).
111    pub submitted: u64,
112    pub accepted: u64,
113    pub cancelled: u64,
114    /// The runner refused a chunk: the player stops here with the reason and
115    /// submits nothing more until the simulation is loaded again. (It used to
116    /// resubmit the refused chunk every frame and never catch up.)
117    pub failed: Option<String>,
118}
119
120static NEXT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
121
122impl SimSession {
123    /// The stock mesh for the driver's current version (rebuilt on change).
124    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    /// The range (and its tool) a motion belongs to.
133    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    /// Whether the stock shows the player's completed count with nothing
138    /// in flight.
139    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    /// Bring the stock toward the player's completed count: inline, at most
144    /// `budget` motions now; on the runner, fold landed replies and submit
145    /// the next chunk of at most `chunk` motions. `true` while not caught up.
146    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                    // The runner lost the session (a worker restart): load again.
169                    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                    // The view restarts from the loaded stock.
208                    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    /// Stop simulating: the chunk in flight is cancelled (between two
228    /// motions on the native thread) and its reply dropped; the stock stays
229    /// the last accepted one and the transport pauses there.
230    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    /// Compare the stock with the part: the deviation map and its legend.
250    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    /// The tolerance of the operation at the playback position.
281    pub fn tolerance_here(&self) -> f64 {
282        self.range_of(self.player.position.motion).map_or(DEFAULT_TOLERANCE, |r| r.tolerance)
283    }
284
285    /// Colour the stock as it stands by remaining material against the part
286    /// (C7's remaining_stock_mesh, band edges from the operation tolerance at
287    /// the playback position). Recomputed only when the stock version or the
288    /// tolerance changed; `true` when it was.
289    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    /// JSON for `cam_sim_state`.
309    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
347/// A target body's display mesh as a CAM snapshot in MODEL coordinates.
348fn 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
368/// Model-space bounds of the named solids on screen.
369fn 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
381/// Build the session for `setup_id` (or the setup of the first generated
382/// operation) from the engine's generation results.
383///
384/// `toolpaths` replaces an operation's generated toolpath for the session
385/// (by operation id; an operation with one needs no generation result).
386pub 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    // The machine the session checks: the one given to the player, else the
429    // setup's (C17c); its feeds and accelerations time the program.
430    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    // Ranges with their cutters.
435    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    // Stock from the setup and the target bodies on screen.
464    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    // The stock generation planned against: the setup's start as a generated
473    // operation's job resolved it from the bodies' f64 tessellation. The
474    // display mesh is f32, an ULP off at the stock's faces, and a carry grows
475    // that skin into a full-height wall the shank then "hits".
476    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    // A setup continuing from another starts from that setup's simulated
486    // stock carried into this frame: the stock generation planned against
487    // (lane C7's job_stock, before this setup's first operation).
488    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    // An indexed or rotary stream cuts in the part frame through the
504    // machine's chain (C7's `*_on_machine`), which only the Z-dexel does.
505    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        // A mill-turn setup turns, then live-mills the turned stock (the driver
511        // switches to its Z-dexel view); turning after milling cannot follow.
512        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    // Collision and gouge pre-pass (3-axis streams): C7's checked simulation
531    // per operation on a scratch grid.
532    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                // The cutting part meeting material on a non-cutting move is a
549                // gouge; the shank or holder touching anything is a collision.
550                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    // Machine collision (C10c): the machine's link solids along the resolved
558    // motion, against each other and the stock as loaded. The first hit is
559    // marked (on the part, in setup coordinates) and joins the stop list.
560    let mut machine_notes = Vec::new();
561    if let Some(machine) = machine.filter(|m| !m.collision.is_empty()) {
562        // The same zero work offset generation and the removal use.
563        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        // The turning motions only: live milling after them is the player's.
594        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
628/// How many frames' budgets one runner chunk may hold (see `pump`).
629const RUNNER_CHUNK_GROWTH: usize = 8;
630
631/// The pane section's state.
632pub 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    /// `budget_per_frame` was set explicitly (`cam_sim_set budgetPerFrame`):
640    /// runner chunks are then exactly that size, never grown.
641    pub budget_pinned: bool,
642    pub show_deviation: bool,
643    /// Colour the stock by remaining material at the playback point.
644    pub show_remaining: bool,
645    /// Seconds since the remaining-stock overlay was last recomputed.
646    remaining_age: f64,
647    pub notice: Option<String>,
648    /// The stock, tool and marks groups last sent to the viewport.
649    overlay: [String; 3],
650    /// What the sent stock group was built from; the stock (the whole grid's
651    /// mesh) is rebuilt and resent only when it changes, never per frame for
652    /// a moving tool.
653    overlay_stock_key: Option<String>,
654    /// While the stock is catching up or playing, a newer stock version waits
655    /// until this time to be resent. A tri-dexel stock group is hundreds of
656    /// thousands of vertices and the viewport re-parses it, so resending it
657    /// for every 64-motion chunk made the redraw, not the simulation, set the
658    /// pace (about 0.85 s per chunk on the 4th-axis part). The stock is
659    /// always resent once the player settles.
660    overlay_stock_next: Option<web_time::Instant>,
661    /// Stock groups built (automation: `overlayStockBuilds`).
662    overlay_stock_builds: u64,
663    /// An export waiting for the shell's save door.
664    pub pending_save: Option<SaveRequest>,
665    /// `true` after a load so `cam_sim_state` can report the last error.
666    pub error: Option<String>,
667    /// The machine the next load checks collisions on (from `cam_sim_load`;
668    /// the setup dialog has no machine field yet).
669    pub machine: Option<Machine>,
670    /// Toolpaths that replace operations' generated ones (by operation id),
671    /// kept for later loads like `machine`.
672    pub toolpaths: BTreeMap<String, brep_cam::model::Toolpath>,
673    /// Simulate inline even when the runner can (tests, comparison).
674    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    /// Load (or reload) the simulation of a setup.
710    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                    // Probe the runner with the Load itself: a runner that
721                    // cannot simulate declines and the session stays inline.
722                    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    /// Stop simulating (see [`SimSession::cancel`]).
771    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    /// Advance the transport by `dt` seconds and the stock toward it; `true`
780    /// when another frame is needed. The app calls it once per frame, drawn
781    /// or not: called from the pane, a simulation loaded while the CAM pane
782    /// was not drawn never read the runner's reply and never caught up.
783    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        // The runner cuts a 64-motion chunk in tens of milliseconds and then
787        // waits for the next frame, so a long catch-up was paced by frames
788        // (about 0.4 s each on the thin wall), not by cutting. Off the UI
789        // thread a chunk may hold RUNNER_CHUNK_GROWTH frames' budgets; a Stop
790        // still cancels it between two motions natively, and the browser drops
791        // the chunk when it lands.
792        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            // Pause at the first collision motion entered since the last frame.
799            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    /// Push the viewport groups (stock, tool, marks) when they changed.
816    pub fn push_overlay(&mut self, state: &mut EngineState) {
817        // The remaining-stock colours follow the playback point: on every stock
818        // change while paused, at most twice a second while playing.
819        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                // Only the stock version moved: hold it back while the player
848                // is still busy and the last resend's interval has not passed.
849                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                // An unchanged stock is neither copied nor compared: it is the
854                // whole grid's mesh (80 MB of JSON on a thin-wall part), and
855                // cloning it every frame cost about 0.1 s per frame.
856                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        // Groups are upserted by name: send only the ones that changed.
881        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            // The next mid-run resend waits at least twice what this one cost,
894            // so redrawing takes at most a third of the time.
895            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    /// The simulated stock as STL text (model coordinates).
901    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    /// The section's widget rects, merged into the CAM pane's blob.
928    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    /// The pane section.
937    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