Skip to main content

brep_render/
runner.rs

1//! The history-run seam — the async RUN machine behind a trait, mirroring the
2//! platform-seam shape of `brep-app/src/store.rs`'s `ModelStore` (a trait with
3//! impls behind it, async surfaced via a poll idiom).
4//!
5//! A history run is split SUBMIT → POLL/APPLY: [`HistoryRunner::submit_run`]
6//! kicks off a run tagged with a monotonic generation, and the completed
7//! [`RunReply`] is drained later via [`HistoryRunner::poll_run`]. The runner OWNS
8//! the [`SceneRunner`](crate::pipeline::SceneRunner) — so a future thread/worker
9//! impl owns the resident registry that `execute_history` populates — and holds
10//! the delta baseline across reruns.
11//!
12//! This slice ships the DEFAULT [`InlineRunner`]: it runs on `submit_run` and
13//! stashes the reply for an immediate `poll_run`, so the run stays synchronous
14//! and byte-identical to the pre-seam in-process run. A native-thread impl (M2b)
15//! and a wasm-worker impl (M3) slot in behind the SAME trait — `submit_run`
16//! defers the work and `poll_run` surfaces it a frame (or many) later, so the
17//! `EngineState::pump` caller never changes.
18
19/// A completed history run, tagged with the [`generation`](Self::generation) it
20/// was submitted under so the applier can drop stale replies (a newer run that
21/// finished first). The [`output`](Self::output) is the [`SceneRunner`] delta to
22/// apply to the display scene.
23///
24/// [`SceneRunner`]: crate::pipeline::SceneRunner
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct RunReply {
27    /// The monotonic generation this run was submitted under.
28    pub generation: u64,
29    /// The delta snapshot + report the run produced.
30    pub output: crate::pipeline::RunOutput,
31}
32
33/// A feature about to EXECUTE inside an in-flight run (cached replays are
34/// instant and are not reported): which one, of how many, under which
35/// generation. Posted by the runner before the kernel starts the feature, so
36/// the UI can name what it is waiting on — and, after a cancel, what it was
37/// waiting on.
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39pub struct RunProgress {
40    /// The generation of the run this belongs to (see [`RunReply::generation`]).
41    pub generation: u64,
42    /// Zero-based position of the feature in the request.
43    pub index: usize,
44    /// The request's feature count.
45    pub total: usize,
46    pub feature_id: String,
47    pub feature_type: String,
48}
49
50/// A STEP text to PROBE for product structure on the runner — the parse that
51/// `brep_kernel::read_step_assembly` performs, which builds every product's
52/// bodies and takes seconds on a real assembly (8.3 s natively for a 5 MB,
53/// 140-product file), so it must not run on the browser's main thread.
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct StepProbeRequest {
56    pub id: u64,
57    pub text: String,
58}
59
60/// The probe's answer: the parsed assembly (`Some`), no structure (`None`),
61/// or a parse failure. The assembly crosses back to the main side whole — its
62/// JSON is large (74 MB for the file above) but the trip costs a third of a
63/// second against the seconds the parse took.
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct StepProbeReply {
66    pub id: u64,
67    pub result: Result<Option<brep_kernel::StepAssembly>, String>,
68}
69
70/// Which exact measurement a [`MeasureQuery`] wants, mirroring the object-info
71/// kinds ([`crate::metadata`]): a whole solid, one named face, or one named edge.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73pub enum MeasureKind {
74    Solid,
75    Face,
76    Edge,
77}
78
79/// A per-object MEASUREMENT request routed to the runner (which owns the warm
80/// registry). The runner resolves `owner` to its resident handle and measures by
81/// [`kind`](Self::kind); the reply is the object-info JSON fragment MINUS the
82/// main-injected `name`/`creatingFeature` fields. Tagged with a monotonic
83/// [`id`](Self::id) so the main side can pair the reply with its pending request.
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct MeasureQuery {
86    /// Monotonic request id (main pairs the reply back by it).
87    pub id: u64,
88    /// The measurement to run.
89    pub kind: MeasureKind,
90    /// The OWNING solid name (a solid is its own owner; a face/edge names its solid).
91    pub owner: String,
92    /// The face/edge NAME to measure (ignored for a whole-solid query).
93    pub entity: String,
94    /// The density (mass per mm³) to scale a solid's weight (ignored for face/edge).
95    pub density: f64,
96}
97
98/// A completed [`MeasureQuery`]: the object-info measurement fields as a JSON
99/// fragment (WITHOUT `name`/`creatingFeature`, which the main thread injects from
100/// its eager provenance), tagged with the request [`id`](Self::id).
101#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
102pub struct MeasureReply {
103    /// The request id this answers.
104    pub id: u64,
105    /// The measurement JSON fragment (see `measure_json`).
106    pub result: String,
107}
108
109/// A request for the B-rep TOPOLOGY of resident solids, by the handle each
110/// display was tessellated from. The drawing sheet's exact hidden-line pass
111/// reads faces and edges, and the resident registry that holds them is
112/// thread-local to the runner, so the main side asks for exactly the solids a
113/// placement draws and does not already hold. Nothing else ever asks, so a
114/// document with no sheet never pays for the topology crossing.
115#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
116pub struct TopologyRequest {
117    /// Monotonic request id (main pairs the reply back by it).
118    pub id: u64,
119    /// `(solid name, resident handle)` per solid wanted.
120    pub solids: Vec<(String, u32)>,
121}
122
123/// A completed [`TopologyRequest`]: a clone of every requested solid the
124/// registry still holds, `(name, handle, solid)`. A handle the registry no
125/// longer holds (a later run freed it) is left out.
126#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
127pub struct TopologyReply {
128    /// The request id this answers.
129    pub id: u64,
130    pub solids: Vec<(String, u32, brep_kernel::BrepSolid)>,
131}
132
133/// Answer a topology request against the resident registry of the thread this
134/// runs on. Shared by every runner.
135fn topology_reply(request: TopologyRequest) -> TopologyReply {
136    let solids = request
137        .solids
138        .into_iter()
139        .filter_map(|(name, handle)| {
140            brep_kernel::registered_solid_clone(handle)
141                .ok()
142                .map(|solid| (name, handle, solid))
143        })
144        .collect();
145    TopologyReply { id: request.id, solids }
146}
147
148/// A drawing sheet's exact hidden-line pass for ONE placement, handed to the
149/// runner so the UI thread never runs it (see
150/// [`crate::sheets::project::LinesJob`]). The job names the runner's own
151/// resident solids by handle and content fingerprint, so nothing crosses the
152/// seam but the question and the lines.
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
154pub struct SheetLinesRequest {
155    /// Monotonic request id (main pairs the reply back by it).
156    pub id: u64,
157    pub job: crate::sheets::project::LinesJob,
158}
159
160/// A completed [`SheetLinesRequest`]: the placement's lines relative to its
161/// position, or why the runner could not draw them (a handle its registry no
162/// longer holds, or holds different geometry under).
163#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
164pub struct SheetLinesReply {
165    /// The request id this answers.
166    pub id: u64,
167    pub key: String,
168    pub lines: Result<crate::sheets::project::ExactLines, String>,
169}
170
171/// Answer a sheet-lines request against the resident registry of the thread
172/// this runs on. Shared by every runner.
173fn sheet_lines_reply(request: SheetLinesRequest) -> SheetLinesReply {
174    SheetLinesReply {
175        id: request.id,
176        lines: crate::sheets::project::run_lines_job(&request.job),
177        key: request.job.key,
178    }
179}
180
181/// ONE CAM operation's generation, off the UI thread (the CAM workbench's
182/// Generate). The job carries everything the generator reads as plain data:
183/// the operation, its setup and the document's tools (the shared model, by
184/// value), and the resident solids it machines by `(name, handle)`, which
185/// the runner clones out of its registry and tessellates through the CAM
186/// kernel adapter. The main side submits ONE operation per job and the next
187/// when its reply lands, so a cancel between operations is immediate on
188/// every platform; inside a job the native thread's cancel flag ends it at
189/// the generator's next work chunk, and the browser worker — which cannot
190/// read a message while it is inside a job — finishes the operation, whose
191/// reply the main side then drops as cancelled. A document `Reset` drops
192/// nothing here: the job holds no parked state across commands.
193#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
194pub struct CamJob {
195    /// Monotonic job id (main pairs the reply and the progress back by it).
196    pub id: u64,
197    pub operation: brep_cam::model::Operation,
198    pub setup: brep_cam::model::Setup,
199    pub tools: Vec<brep_cam::model::Tool>,
200    /// The bodies the setup machines or protects: `(solid name, resident handle)`.
201    pub solids: Vec<(String, u32)>,
202    /// The chordal tolerance the bodies are tessellated at, in model units.
203    pub chord_tolerance: f64,
204    /// Work units per progress / cancel check (the generator's chunk).
205    pub budget_chunk: u64,
206    /// The sketch curves the operation's chains name, `(sketch id, geometry
207    /// id, curve)` in model space, from the last history run (the runner's
208    /// registry holds solids, not sketches).
209    #[serde(default)]
210    pub chain_curves: Vec<(String, String, brep_kernel::NurbsCurve)>,
211    /// The sketch profiles the operation's chains name, `(sketch id, profile)`.
212    #[serde(default)]
213    pub chain_profiles: Vec<(String, brep_kernel::SketchProfile)>,
214    /// The setup's machine, resolved (its id is `setup.machine`): the
215    /// generator's kinematics context for rotary-capable strategies.
216    #[serde(default)]
217    pub machine: Option<brep_cam::model::Machine>,
218    /// Exact automatically programmed geometry and associative feature identity.
219    #[serde(default)]
220    pub autoprogram: Option<brep_cam::autoprogram::binding::Binding>,
221    /// The setup's operations BEFORE this one, in program order, each with
222    /// its current toolpath (`None`: not generated, failed or stale): the
223    /// runner replays them into the stock this operation starts from (lane C7).
224    #[serde(default)]
225    pub earlier: Vec<(brep_cam::model::Operation, Option<brep_cam::model::Toolpath>)>,
226    /// The setups this one continues from (`Stock::FromRest`), oldest first.
227    #[serde(default)]
228    pub previous_setups: Vec<brep_cam::SetupHistory>,
229    /// A multi-axis finish's designated three-axis source, generated and
230    /// current (lane C17): the generator tilts this path.
231    #[serde(default)]
232    pub source: Option<brep_cam::program::OperationInput>,
233    /// The designated source's definition when it has no current result: the
234    /// worker generates it on this job's bodies first.
235    #[serde(default)]
236    pub source_operation: Option<brep_cam::model::Operation>,
237}
238
239/// A completed [`CamJob`]: the toolpath, or why the operation could not be
240/// generated (the generator's own words), and how much work it took.
241#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
242pub struct CamReply {
243    /// Certified setup-Z meridian, carried with the generated result for axial replay.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub turning_profile: Option<brep_cam::turning::profile::TurningProfile>,
246    /// The stock the operation's setup starts from, as this job resolved it
247    /// (the simulation loads it; absent when the job built no pre-operation stock).
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub setup_start: Option<brep_cam::SetupStart>,
250    pub id: u64,
251    pub operation: String,
252    pub toolpath: Result<brep_cam::model::Toolpath, String>,
253    pub work_units: u64,
254    /// Whether the job ended on the cancel flag rather than on its own.
255    pub cancelled: bool,
256}
257
258/// A generation's progress, posted from inside the job at its stage and
259/// chunk boundaries.
260#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
261pub struct CamProgress {
262    pub id: u64,
263    pub operation: String,
264    pub phase: String,
265    pub message: String,
266    pub current: u64,
267    pub total: u64,
268}
269
270/// A CAM SIMULATION request (lane C17b). The runner keeps ONE simulation
271/// session resident (its stock and checkpoints live runner-side; a thread
272/// local, so the native thread and the wasm worker hold it the same way);
273/// the main side loads it once and then asks it to advance in bounded
274/// chunks, so a cancel between chunks is immediate on every platform and a
275/// chunk stops between two motions on the native thread.
276#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
277pub enum CamSimRequest {
278    /// Make `stock` the session's stock over `timeline`, each range cut by its cutter.
279    Load {
280        timeline: brep_cam::multiaxis::sim::Timeline,
281        stock: brep_cam::multiaxis::sim::StockModel,
282        ranges: Vec<brep_cam::multiaxis::sim::RangeCutter>,
283        options: brep_cam::multiaxis::sim::DriverOptions,
284        /// The machine whose chain applies rotary positioning (C7's
285        /// `*_on_machine`); none refuses any nonzero rotary word.
286        #[serde(default)]
287        machine: Option<brep_cam::model::Machine>,
288    },
289    /// Advance (or rewind and replay) toward `target` applied motions, at
290    /// most `budget` motions. `stock: true` returns the stock whenever its
291    /// version differs from `known_version` (the one the caller holds), so
292    /// a chunk whose reply the caller dropped is still made good.
293    Advance { target: usize, budget: usize, stock: bool, known_version: u64 },
294    /// Forget the session.
295    Drop,
296}
297
298/// One simulation command, paired back by `(session, seq)`.
299#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
300pub struct CamSimJob {
301    pub session: u64,
302    pub seq: u64,
303    pub request: CamSimRequest,
304}
305
306/// The session's state after a [`CamSimJob`].
307#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
308pub struct CamSimReply {
309    pub session: u64,
310    pub seq: u64,
311    pub applied: usize,
312    pub version: u64,
313    pub removed_volume: f64,
314    pub stock_volume: f64,
315    pub unsupported: Vec<usize>,
316    pub warnings: Vec<(usize, brep_cam::model::Diagnostic)>,
317    /// The stock, when asked and it changed since the job started.
318    pub stock: Option<brep_cam::multiaxis::sim::StockModel>,
319    /// The target was reached (`false`: the budget ran out or it stopped).
320    pub reached: bool,
321    /// The chunk stopped on the cancel flag (between two motions).
322    pub cancelled: bool,
323    pub error: Option<String>,
324}
325
326/// A simulation chunk's progress, posted between motions.
327#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
328pub struct CamSimProgress {
329    pub session: u64,
330    pub seq: u64,
331    pub applied: usize,
332    pub target: usize,
333}
334
335/// The runner-side simulation session.
336struct CamSimSession {
337    session: u64,
338    timeline: brep_cam::multiaxis::sim::Timeline,
339    ranges: Vec<brep_cam::multiaxis::sim::RangeCutter>,
340    driver: brep_cam::multiaxis::sim::SimDriver,
341}
342
343thread_local! {
344    static CAM_SIM: std::cell::RefCell<Option<CamSimSession>> = const { std::cell::RefCell::new(None) };
345}
346
347/// Forget the resident simulation (a document switch).
348fn cam_sim_clear() {
349    CAM_SIM.with(|c| *c.borrow_mut() = None);
350}
351
352thread_local! {
353    /// Pre-operation stock states of this document session (lane C7): a
354    /// regenerated operation replays only from the longest unchanged prefix.
355    static PRE_OP_STOCK: std::cell::RefCell<brep_cam::PreOpStockCache> = std::cell::RefCell::new(brep_cam::PreOpStockCache::new(16));
356}
357
358/// Forget the pre-operation stock states (a document switch).
359fn pre_op_stock_clear() {
360    PRE_OP_STOCK.with(|c| c.borrow_mut().clear());
361}
362
363fn cam_sim_reply(job: CamSimJob, post: &mut dyn FnMut(Reply) -> bool) -> CamSimReply {
364    let (session, seq) = (job.session, job.seq);
365    let empty = |error: Option<String>| CamSimReply {
366        session,
367        seq,
368        applied: 0,
369        version: 0,
370        removed_volume: 0.0,
371        stock_volume: 0.0,
372        unsupported: Vec::new(),
373        warnings: Vec::new(),
374        stock: None,
375        reached: false,
376        cancelled: false,
377        error,
378    };
379    match job.request {
380        CamSimRequest::Drop => {
381            cam_sim_clear();
382            empty(None)
383        }
384        CamSimRequest::Load { timeline, stock, ranges, options, machine } => match brep_cam::multiaxis::sim::SimDriver::new(stock, options) {
385            Ok(driver) => {
386                let driver = driver.with_chain(brep_cam::multiaxis::sim::sim_chain(machine.as_ref()));
387                let reply = CamSimReply {
388                    applied: driver.applied,
389                    version: driver.version,
390                    stock_volume: driver.stock.volume(),
391                    reached: true,
392                    ..empty(None)
393                };
394                CAM_SIM.with(|c| *c.borrow_mut() = Some(CamSimSession { session, timeline, ranges, driver }));
395                reply
396            }
397            Err(e) => empty(Some(e.to_string())),
398        },
399        CamSimRequest::Advance { target, budget, stock, known_version } => CAM_SIM.with(|c| {
400            let mut guard = c.borrow_mut();
401            let Some(sim) = guard.as_mut().filter(|s| s.session == session) else {
402                return empty(Some("no simulation session is loaded on the runner".into()));
403            };
404            let mut cancelled = false;
405            let tolerance = brep_cam::Tolerance::default();
406            let outcome = sim.driver.advance_ranges(&sim.timeline, &sim.ranges, target, budget, &tolerance, &mut |applied| {
407                let keep = post(Reply::CamSimProgress(CamSimProgress { session, seq, applied, target }));
408                if !keep {
409                    cancelled = true;
410                }
411                keep
412            });
413            let d = &sim.driver;
414            let (reached, error) = match outcome {
415                Ok(o) => (o == brep_cam::multiaxis::sim::AdvanceOutcome::Reached, None),
416                Err(e) => (false, Some(e.to_string())),
417            };
418            CamSimReply {
419                session,
420                seq,
421                applied: d.applied,
422                version: d.version,
423                removed_volume: d.removed_volume,
424                stock_volume: d.stock.volume(),
425                unsupported: d.unsupported.clone(),
426                warnings: d.warnings.clone(),
427                stock: (stock && d.version != known_version).then(|| d.stock.clone()),
428                reached,
429                cancelled,
430                error,
431            }
432        }),
433    }
434}
435
436/// A progress sink that posts through the driver's reply channel and turns
437/// a driver that says "stop" into the generator's cancel.
438struct CamSink<'a> {
439    id: u64,
440    operation: String,
441    token: brep_cam::CancelToken,
442    post: &'a mut dyn FnMut(Reply) -> bool,
443}
444
445impl brep_cam::ProgressSink for CamSink<'_> {
446    fn progress(&mut self, progress: &brep_cam::Progress) {
447        if std::env::var_os("BREP_CAM_DEBUG").is_some() {
448            eprintln!("cam job {} progress: {} {}/{}", self.operation, progress.phase, progress.current, progress.total);
449        }
450        let keep_going = (self.post)(Reply::CamProgress(CamProgress {
451            id: self.id,
452            operation: self.operation.clone(),
453            phase: progress.phase.clone(),
454            message: progress.message.clone(),
455            current: progress.current,
456            total: progress.total,
457        }));
458        if !keep_going {
459            if std::env::var_os("BREP_CAM_DEBUG").is_some() {
460                eprintln!("cam job {}: the driver said stop; cancelling", self.operation);
461            }
462            self.token.cancel();
463        }
464    }
465}
466
467/// The most triangles a CAM job's part mesh may have (the triangle index and
468/// the drop-cutter queries scale with it).
469pub const CAM_TRIANGLE_BUDGET: usize = 4_000_000;
470
471/// Run one CAM job against the resident registry of this thread.
472fn cam_generate_reply(job: CamJob, post: &mut dyn FnMut(Reply) -> bool) -> CamReply {
473    use brep_cam::kernel_adapter::{machine_snapshot, revolved_faces, BodyInput};
474    use brep_cam::{Runner, Tolerance, TreeIndex, TreeOptions, WorkBudget};
475    let operation = job.operation.id.as_str().to_string();
476    let token = brep_cam::CancelToken::new();
477    let mut sink = CamSink { id: job.id, operation: operation.clone(), token: token.clone(), post };
478    let mut certified_turning_profile = None;
479    let mut setup_start = None;
480    let mut outcome = || -> Result<(brep_cam::model::Toolpath, u64), String> {
481        // The resident bodies, cloned out of the registry by handle.
482        let mut bodies: Vec<(String, brep_kernel::BrepSolid)> = Vec::new();
483        for (name, handle) in &job.solids {
484            let solid = brep_kernel::registered_solid_clone(*handle)
485                .map_err(|e| format!("body `{name}` is not resident on the runner ({e}); run the history again"))?;
486            bodies.push((name.clone(), solid));
487        }
488        let diagonal = bodies
489            .iter()
490            .filter_map(|(_, s)| brep_cam::kernel_adapter::body_bounds(s, job.chord_tolerance).ok())
491            .fold(brep_cam::model::Bounds3::empty(), |acc, b| acc.union(&b))
492            .diagonal();
493        let tolerance = if diagonal.is_finite() && diagonal > 0.0 { Tolerance::scaled_for_diagonal(diagonal) } else { Tolerance::default() };
494        // The snapshot holds the SETUP's bodies and the operation's own: an
495        // operation's target or protected body outside the setup's list is
496        // still machined or avoided, so it joins the snapshot here.
497        let mut snapshot_setup = job.setup.clone();
498        for name in &job.operation.targets.target_solids {
499            if !snapshot_setup.targets.iter().any(|t| &t.solid == name) {
500                snapshot_setup.targets.push(brep_cam::model::TargetRef { solid: name.clone(), face: None });
501            }
502        }
503        for name in &job.operation.targets.protected_solids {
504            let listed = snapshot_setup.targets.iter().chain(snapshot_setup.protected.iter()).any(|t| &t.solid == name);
505            if !listed {
506                snapshot_setup.protected.push(brep_cam::model::TargetRef { solid: name.clone(), face: None });
507            }
508        }
509        let (mesh, unresolved) = machine_snapshot(&snapshot_setup, job.chord_tolerance, tolerance, |name| bodies.iter().find(|(n, _)| n == name).map(|(_, s)| s))
510            .map_err(|e| format!("{e}"))?;
511        if !unresolved.is_empty() {
512            return Err(format!("the setup names bodies the model does not have: {}", unresolved.join(", ")));
513        }
514        if std::env::var_os("BREP_CAM_DEBUG").is_some() {
515            eprintln!("cam job {}: {} bodies, snapshot {} faces / {} triangles, bounds {:?}", operation, bodies.len(), mesh.faces.len(), mesh.triangles.len(), mesh.bounds);
516        }
517        // The mesh is tessellated at the operation's own tolerance (a quarter
518        // of it); a part that needs more triangles than the generators can
519        // carry is refused by name, never coarsened behind the user's back.
520        if mesh.triangles.len() > CAM_TRIANGLE_BUDGET {
521            return Err(format!(
522                "the part mesh needs {} triangles at a chord of {}, over the budget of {CAM_TRIANGLE_BUDGET}; raise the operation's tolerance or machine fewer bodies at once",
523                mesh.triangles.len(),
524                job.chord_tolerance
525            ));
526        }
527        let index = TreeIndex::build(&mesh, TreeOptions::default());
528        let mut runner = Runner::new(token.clone(), &mut sink, WorkBudget::new(job.budget_chunk.max(1))).with_operation(operation.clone());
529        // Drilling reads the recognised holes of the target bodies.
530        let holes = if matches!(job.operation.strategy, brep_cam::model::Strategy::Drill(_)) {
531            let mut carriers = Vec::new();
532            for (name, solid) in &bodies {
533                if job.setup.targets.iter().any(|t| t.solid == *name) || job.operation.targets.target_solids.contains(name) {
534                    let body = BodyInput { name, solid, protected_only: false };
535                    carriers.extend(revolved_faces(&body, &job.setup));
536                }
537            }
538            brep_cam::holes::recognize(&carriers, &mesh, tolerance, &mut runner).map_err(|e| e.to_string())?.holes
539        } else {
540            Vec::new()
541        };
542        // Frozen geometry is valid only for the exact bodies it was planned
543        // against. A body edit re-recognises by face identity and XY dimensions,
544        // preserving depth associativity without choosing a different feature.
545        let mut effective_operation = job.operation.clone();
546        let mut automatic_geometry = None;
547        let mut rebound = false;
548        if let Some(binding) = &job.autoprogram {
549            let tool = job.tools.iter().find(|t| t.id == job.operation.tool).ok_or("automatic programming tool is missing")?;
550            binding.validate_inputs(&job.operation, &job.setup, tool)?;
551            let fingerprints: std::collections::BTreeMap<_, _> = bodies.iter().map(|(name, solid)| (name.clone(), crate::scene::brep_fingerprint(solid))).collect();
552            if fingerprints == binding.body_fingerprints {
553                automatic_geometry = Some(binding.geometry.clone());
554            } else {
555                let recognized = brep_cam::autoprogram::binding::recognize_bodies(&bodies, &job.setup, &mesh, job.chord_tolerance, tolerance, &mut runner).map_err(|e| e.to_string())?;
556                let model_bounds = bodies.iter().filter(|(name, _)| job.setup.targets.iter().any(|t| &t.solid == name))
557                    .filter_map(|(_, s)| brep_cam::kernel_adapter::body_bounds(s, job.chord_tolerance).ok())
558                    .fold(brep_cam::model::Bounds3::empty(), |a, b| a.union(&b));
559                let stock_bounds = match &job.setup.stock {
560                    brep_cam::model::Stock::FromSolid { solid } => bodies.iter().find(|(name, _)| name == solid).and_then(|(_, body)| brep_cam::kernel_adapter::body_bounds(body, job.chord_tolerance).ok()),
561                    _ => job.setup.stock.model_bounds(&model_bounds),
562                };
563                let stock_top = stock_bounds.map(|b| job.setup.model_to_machine.apply_bounds(&b).max.z);
564                let (op, geometry) = binding.rebind_with_mesh(&recognized, &mesh, &job.operation, &job.setup, &job.tools, job.machine.as_ref(), stock_top, tolerance)?;
565                effective_operation = op;
566                automatic_geometry = Some(geometry);
567                rebound = true;
568            }
569        }
570        let resolved_planar = match &automatic_geometry { Some(brep_cam::autoprogram::OperationGeometry::Planar(p)) => Some((&effective_operation.id, p)), _ => None };
571        let holes = match &automatic_geometry { Some(brep_cam::autoprogram::OperationGeometry::Holes(h)) => h.as_slice(), _ => holes.as_slice() };
572        // Lane C7: the stock as it stands before this operation (the setup's
573        // earlier operations replayed; a FromRest setup carried from the one it
574        // continues from). Unavailable: a named warning on the operation, and the
575        // linkers keep above the conservative material top. Never the raw stock.
576        let mut pre_op_warning = None;
577        // Automatic axial live contouring uses conservative planar links and
578        // does not consume the milling stock cache. Replaying a preceding
579        // turning operation into that cache is both unused and inappropriate.
580        let live_contour = matches!(job.operation.strategy, brep_cam::Strategy::Contour2D(_) | brep_cam::Strategy::Trace(_))
581            && job.operation.live_tool.unwrap_or(true)
582            && job.machine.as_ref().is_some_and(|m| matches!(m.kind, brep_cam::MachineKind::MillTurn { .. }));
583        let pre_op = if !live_contour && brep_cam::uses_pre_op_stock(&job.operation.strategy, job.machine.as_ref()) {
584            let fallback = job.operation.targets.target_solids.clone();
585            let request = brep_cam::JobStock {
586                setup: &job.setup,
587                machine: job.machine.as_ref(),
588                tools: &job.tools,
589                mesh: &mesh,
590                fallback_targets: &fallback,
591                earlier: &job.earlier,
592                previous: &job.previous_setups,
593                tolerance,
594            };
595            match PRE_OP_STOCK.with(|c| brep_cam::job_stock(&mut c.borrow_mut(), &request, &mut runner)) {
596                Ok(result) => Some(result),
597                Err(brep_cam::PreOpStockError::Cancelled) => return Err("cancelled".to_string()),
598                Err(error) => {
599                    pre_op_warning = Some(brep_cam::unavailable_warning(&error));
600                    None
601                }
602            }
603        } else {
604            None
605        };
606        let input = brep_cam::dispatch::GenerateInput {
607            operation: &effective_operation,
608            setup: &job.setup,
609            tools: &job.tools,
610            mesh: &mesh,
611            index: Some(&index),
612            tolerance,
613            holes,
614        };
615        // Picked chains resolve by identity against the bodies, sketch curves
616        // and profiles the job carries (C20's KernelChainGeometry).
617        let solid_refs: Vec<(&str, &brep_kernel::BrepSolid)> = bodies.iter().map(|(n, s)| (n.as_str(), s)).collect();
618        let curve_refs: Vec<(&str, &str, &brep_kernel::NurbsCurve)> = job.chain_curves.iter().map(|(s, g, c)| (s.as_str(), g.as_str(), c)).collect();
619        let profile_refs: Vec<(&str, &brep_kernel::SketchProfile)> = job.chain_profiles.iter().map(|(s, p)| (s.as_str(), p)).collect();
620        let chain_geometry = brep_cam::dispatch::chains::KernelChainGeometry { solids: &solid_refs, curves: &curve_refs, profiles: &profile_refs };
621        let is_turning = matches!(job.operation.strategy, brep_cam::Strategy::TurnFace(_) | brep_cam::Strategy::TurnProfile(_));
622        let mut turning_carriers = Vec::new();
623        let turning_tool = if is_turning {
624            let tool = job.tools.iter().find(|t| t.id == job.operation.tool).ok_or("turning tool is missing")?;
625            if tool.turning_geometry.is_none() { return Err(format!("tool {} has no insert geometry", tool.id)); }
626            if job.setup.turning_stick_out.is_none() { return Err("setup has no stick-out".into()); }
627            let targets: std::collections::BTreeSet<_> = job.setup.targets.iter().map(|t| t.solid.as_str()).collect();
628            if targets.len() != 1 || job.operation.targets.target_solids.iter().any(|n| !targets.contains(n.as_str())) {
629                return Err("turning requires one exact setup solid and matching operation targets".into());
630            }
631            let name = *targets.iter().next().unwrap();
632            let solid = bodies.iter().find(|(n, _)| n == name).map(|(_, s)| s).ok_or("turning setup solid is missing")?;
633            if !job.machine.as_ref().is_some_and(|m| matches!(m.kind, brep_cam::MachineKind::Lathe | brep_cam::MachineKind::MillTurn { .. })) {
634                return Err("turning envelope requires a defined lathe spindle axis".into());
635            }
636            certified_turning_profile = Some(match brep_cam::kernel_adapter::turning_faces(&BodyInput { name, solid, protected_only: false }, &job.setup, tolerance) {
637                Ok(carriers) => {
638                    turning_carriers = carriers;
639                    brep_cam::turning::profile::extract(&turning_carriers, &mesh, job.operation.tolerance * 0.125, &mut runner)
640                }
641                Err(_) => brep_cam::turning::envelope::extract(&mesh, job.chord_tolerance, &mut runner),
642            }.map_err(|e| e.to_string())?);
643            Some(tool)
644        } else { None };
645        let turning = turning_tool.map(|tool| brep_cam::dispatch::TurningInput {
646            tool_id: &tool.id, tool_geometry: tool.turning_geometry.as_ref().unwrap(),
647            carriers: &turning_carriers, profile: certified_turning_profile.as_ref(), contour: None,
648            stick_out: job.setup.turning_stick_out.unwrap(), groove_center_z: None, thread_radius: None,
649        });
650        let context = brep_cam::dispatch::GenerateContext {
651            turning: turning.as_ref(),
652            chain_geometry: Some(&chain_geometry),
653            machine: job.machine.as_ref(),
654            resolved_planar,
655            rest_stock: pre_op.as_ref().map(|r| &r.stock.grid),
656            pre_op_stock: pre_op.as_ref().map(|r| &r.stock.envelope),
657            source: job.source.as_ref(),
658            source_operation: job.source_operation.as_ref(),
659            ..Default::default()
660        };
661        let mut path = brep_cam::dispatch::generate_with_context(&input, &context, &mut runner).map_err(|e| e.to_string())?;
662        if rebound { path.report.warn(brep_cam::model::Diagnostic::new("autoprogram-re-recognised", "re-recognised after model change")); }
663        if let Some(warning) = pre_op_warning {
664            // First, so a reader finds it at warnings[0] whenever it is there.
665            path.report.warn(warning);
666            path.report.warnings.rotate_right(1);
667        }
668        if let Some(result) = pre_op {
669            path.report.approximations.extend(result.limits);
670            setup_start = Some(result.start);
671        }
672        Ok((path, runner.budget().used()))
673    };
674    match outcome() {
675        Ok((toolpath, work_units)) => CamReply { turning_profile: certified_turning_profile, setup_start, id: job.id, operation, toolpath: Ok(toolpath), work_units, cancelled: false },
676        Err(message) => {
677            let cancelled = token.is_cancelled();
678            CamReply { turning_profile: None, setup_start: None, id: job.id, operation, toolpath: Err(if cancelled { "cancelled".to_string() } else { message }), work_units: 0, cancelled }
679        }
680    }
681}
682
683pub use brep_reconstruction::stl_conversion::{
684    ConversionPolicy, StlConversionOptions, StlConversionOutput,
685};
686
687/// Mesh encoding accepted by the off-thread reconstruction channel.
688#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
689pub enum MeshImportFormat {
690    Stl,
691    Obj,
692    /// 3MF (core specification): a package whose build instances are flattened
693    /// into one millimetre triangle soup before this common chain runs.
694    ThreeMf,
695}
696
697/// A mesh reconstruction request. The byte payload moves to the native thread
698/// or is serialized to the browser worker; no parsing or fitting happens on UI.
699#[derive(Debug, serde::Serialize, serde::Deserialize)]
700pub struct MeshImportRequest {
701    pub id: u64,
702    pub format: MeshImportFormat,
703    pub bytes: Vec<u8>,
704    pub options: StlConversionOptions,
705}
706
707/// Completed off-thread reconstruction. Success carries validated STEP and
708/// diagnostics for a preview or an ordinary IMPORT3D history insertion.
709#[derive(Debug, serde::Serialize, serde::Deserialize)]
710pub struct MeshImportReply {
711    pub id: u64,
712    pub result: Result<StlConversionOutput, String>,
713}
714
715/// Validate explicitly installed packages away from the UI thread.
716#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
717pub struct PluginInstallRequest {
718    pub id: u64,
719    pub packages: serde_json::Value,
720}
721#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
722pub struct PluginInstallReply {
723    pub id: u64,
724    pub result: Result<brep_plugins::Registry, String>,
725}
726fn validate_plugins(request: PluginInstallRequest) -> PluginInstallReply {
727    let result = serde_json::from_value(request.packages)
728        .map_err(|e| format!("plugin packages: {e}"))
729        .and_then(brep_plugins::Runtime::new)
730        .map(|runtime| runtime.registry().clone());
731    PluginInstallReply { id: request.id, result }
732}
733
734/// A callback request against an immutable document/selection snapshot.
735#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
736pub struct PluginActionRequest {
737    pub id: u64,
738    pub pins: Vec<brep_plugins::PluginPin>,
739    pub action: String,
740    pub input: serde_json::Value,
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub script: Option<String>,
743}
744
745#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
746pub struct PluginActionReply {
747    pub id: u64,
748    pub result: Result<brep_plugins::ActionPlan, String>,
749}
750
751/// The history-run seam: SUBMIT a run, POLL for its completed reply, RESET the
752/// delta baseline, plus a QUERY channel for per-object measurements (routed to the
753/// runner so the warm registry answers them, never the cold main-side one). The
754/// Inline impl runs everything synchronously; a later thread/worker impl defers
755/// the work and surfaces the replies through the same poll idiom.
756/// Where the main thread gets the assembly session of the run it applied
757/// ([`HistoryRunner::session_for_main`]).
758pub enum SessionOnMain {
759    /// The run executed on the main thread and its session is still the
760    /// thread's: nothing to do.
761    Resident,
762    /// An owned copy from the runner's thread, to import.
763    Copied(brep_kernel::SessionTransfer),
764    /// Not available (busy, replaced, or a runner that cannot copy): replay.
765    Unavailable,
766}
767
768pub trait HistoryRunner {
769    /// The assembly session the run of `generation` left, for the main
770    /// thread's constraint actions. Blocking, but bounded. The default cannot
771    /// provide one, and the caller replays the history instead.
772    fn session_for_main(&mut self, _generation: u64) -> SessionOnMain {
773        SessionOnMain::Unavailable
774    }
775    fn submit_plugin_install(&mut self, _request: PluginInstallRequest) -> bool { false }
776    fn poll_plugin_install(&mut self) -> Option<PluginInstallReply> { None }
777    /// Trusted installed content, separate from saved document dependency pins.
778    fn sync_plugins(&mut self, _revision: u64, _fetch: &mut dyn FnMut() -> serde_json::Value) {}
779    /// False explicitly reports a runner without action support.
780    fn submit_plugin_action(&mut self, _request: PluginActionRequest) -> bool { false }
781    fn poll_plugin_action(&mut self) -> Option<PluginActionReply> { None }
782
783    /// Submit a history run tagged with a monotonic generation. The runner executes
784    /// it (immediately for Inline; on a background thread later) and makes the reply
785    /// available via [`poll_run`](Self::poll_run).
786    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64);
787    /// Non-blocking: the next completed run reply, if any (drained each frame).
788    fn poll_run(&mut self) -> Option<RunReply>;
789    /// Submit a per-object measurement query (answered against the runner's warm
790    /// registry). Inline computes it immediately; a thread impl computes it on the
791    /// runner thread and surfaces it via [`poll_query`](Self::poll_query).
792    fn submit_query(&mut self, query: MeasureQuery);
793    /// Non-blocking: the next completed measurement reply, if any.
794    fn poll_query(&mut self) -> Option<MeasureReply>;
795    /// Submit RANSAC mesh reconstruction to the runner's thread/worker.
796    fn submit_mesh_import(&mut self, request: MeshImportRequest);
797    /// Non-blocking: the next completed mesh reconstruction, if any.
798    fn poll_mesh_import(&mut self) -> Option<MeshImportReply>;
799    /// Submit a STEP product-structure probe (see [`StepProbeRequest`]).
800    fn submit_step_probe(&mut self, request: StepProbeRequest);
801    /// Non-blocking: the next completed STEP probe, if any.
802    fn poll_step_probe(&mut self) -> Option<StepProbeReply>;
803    /// Submit a topology request (see [`TopologyRequest`]). The default drops
804    /// it: a runner that never answers leaves a sheet on its mesh
805    /// approximation, which the drawing reports.
806    fn submit_topology(&mut self, _request: TopologyRequest) {}
807    /// Non-blocking: the next completed topology reply, if any.
808    fn poll_topology(&mut self) -> Option<TopologyReply> {
809        None
810    }
811    /// BLOCK up to `timeout` until a topology reply is ready to poll: `true`
812    /// when one is. An export that needs the exact solids waits this way on a
813    /// runner with a thread of its own; the default cannot wait (a worker
814    /// answers through the page's event loop, which a blocked page never
815    /// turns), so the export draws the mesh approximation and the file says
816    /// so.
817    fn wait_topology(&mut self, _timeout: std::time::Duration) -> bool {
818        false
819    }
820    /// Submit a sheet placement's exact pass (see [`SheetLinesRequest`]).
821    /// `false` when this runner cannot run one — the engine then runs it on
822    /// its own thread, as every projection did before the pass moved off it.
823    fn submit_sheet_lines(&mut self, _request: SheetLinesRequest) -> bool {
824        false
825    }
826    /// Non-blocking: the next completed sheet-lines reply, if any.
827    fn poll_sheet_lines(&mut self) -> Option<SheetLinesReply> {
828        None
829    }
830    /// Submit ONE CAM operation's generation (see [`CamJob`]); `false` when
831    /// this runner cannot run it.
832    fn submit_cam(&mut self, _job: CamJob) -> bool {
833        false
834    }
835    fn poll_cam(&mut self) -> Option<CamReply> {
836        None
837    }
838    fn poll_cam_progress(&mut self) -> Option<CamProgress> {
839        None
840    }
841    /// Cancel the CAM job in flight at its next work chunk, where the driver
842    /// can reach it (the native thread); a driver that cannot lets the job
843    /// finish and the main side drops its reply.
844    fn cancel_cam(&mut self) {}
845    /// TEST HOLD (automation only): while `hold`, finished CAM replies are
846    /// kept back from `poll_cam`, so a job stays in flight on the main side
847    /// however fast it ran — the one way a script can meet it with a cancel
848    /// every time. Releasing delivers what was kept, in order. A driver
849    /// without the hold ignores it.
850    fn hold_cam_replies(&mut self, _hold: bool) {}
851    /// Submit ONE CAM simulation command (see [`CamSimJob`]); `false` when
852    /// this runner cannot simulate (the caller then simulates inline).
853    fn submit_cam_sim(&mut self, _job: CamSimJob) -> bool {
854        false
855    }
856    fn poll_cam_sim(&mut self) -> Option<CamSimReply> {
857        None
858    }
859    fn poll_cam_sim_progress(&mut self) -> Option<CamSimProgress> {
860        None
861    }
862    /// Stop the simulation chunk in flight between two motions where the
863    /// driver can reach it (the native thread); elsewhere the chunk finishes
864    /// and the caller drops its reply.
865    fn cancel_cam_sim(&mut self) {}
866    /// Drop the delta baseline (document switch → full rebuild).
867    fn reset(&mut self);
868
869    /// Bring the runner's resident PARTS LIBRARY up to `revision`, calling
870    /// `fetch` ONLY when it is not already there. Called immediately before
871    /// every [`submit_run`](Self::submit_run).
872    ///
873    /// This is the whole point of the library channel: the library is sent
874    /// when it CHANGES (insert, document load, refresh), not on every run.
875    /// Cheap to call — the revision comparison is an integer, and `fetch`
876    /// (which clones the store) never runs in the steady state. The default is
877    /// a no-op for [`InlineRunner`], which shares the caller's kernel store.
878    fn sync_parts_library(
879        &mut self,
880        _revision: u64,
881        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
882    ) {
883    }
884
885    /// True when the runner REFUSED a run because its resident parts library
886    /// could not serve it (see [`Reply::NeedPartsLibrary`]). It has already
887    /// forgotten its copy, so the next `sync_parts_library` reinstalls; the
888    /// caller must re-submit the run. Never true for a runner that shares the
889    /// caller's store.
890    fn poll_library_request(&mut self) -> bool {
891        false
892    }
893
894    /// Whether a run reply (or a refusal asking for the parts library) is
895    /// waiting for [`poll_run`](Self::poll_run): a caller may skip what only a
896    /// landing needs while this is false. `true` when the runner cannot tell.
897    fn run_reply_waiting(&mut self) -> bool {
898        true
899    }
900
901    /// Non-blocking: the MOST RECENT progress report of the in-flight run, with
902    /// any older ones discarded (only the latest feature matters). `None` for
903    /// a synchronous runner — Inline has finished before anyone could ask.
904    fn poll_progress(&mut self) -> Option<RunProgress> {
905        None
906    }
907
908    /// ABANDON the in-flight work and start over with an EMPTY resident
909    /// registry. Returns `false` when there is nothing this runner can abandon
910    /// (Inline: the run already completed on the caller's thread). A `true`
911    /// means every handle the caller holds is now invalid, no reply is coming
912    /// for anything submitted so far, and the parts library must be re-sent —
913    /// the caller reconciles its generations and pending sets accordingly
914    /// (`EngineState::cancel_run`). Nothing inside a feature is interruptible:
915    /// the thread runner lets its old thread finish the feature it is on and
916    /// stop at the next boundary; the browser worker is terminated outright.
917    fn cancel(&mut self) -> bool {
918        false
919    }
920}
921
922/// Measure `query` against `runner`'s resident geometry and emit the object-info
923/// JSON FRAGMENT — EXACTLY the fields [`crate::metadata::EngineState::object_info_json`]
924/// emits for that kind EXCEPT `name` and `creatingFeature` (the main thread injects
925/// those from its eager provenance, so the merged output is byte-identical to the
926/// pre-seam in-process result). Shared verbatim by the Inline and thread runners so
927/// both produce the identical fragment. A missing handle / kernel error yields
928/// `{ "ok": false, "message": .. }` (main injects `name`).
929fn measure_json(runner: &crate::pipeline::SceneRunner, query: &MeasureQuery) -> String {
930    let Some(handle) = runner.handle_of(&query.owner) else {
931        return serde_json::json!({
932            "ok": false,
933            "message": format!("solid '{}' has no resident geometry", query.owner),
934        })
935        .to_string();
936    };
937    match query.kind {
938        MeasureKind::Solid => {
939            match brep_kernel::mass_properties_handle_native(handle, query.density) {
940                Ok(properties) => {
941                    let edge_total =
942                        brep_kernel::solid_edge_length_total_native(handle).unwrap_or(0.0);
943                    serde_json::json!({
944                        "ok": true,
945                        "kind": "solid",
946                        "volume": properties.volume,
947                        "surfaceArea": properties.surface_area,
948                        "edgeLengthTotal": edge_total,
949                        "density": properties.density,
950                        "weight": properties.mass,
951                    })
952                    .to_string()
953                }
954                Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
955            }
956        }
957        MeasureKind::Face => match brep_kernel::face_measurements_native(handle, &query.entity) {
958            Ok((area, edge_total, surface_type)) => {
959                let dimensions =
960                    brep_kernel::face_carrier_dimensions_native(handle, &query.entity)
961                        .ok()
962                        .flatten();
963                // A partial revolution of a straight line is a "Surface of
964                // revolution" to the kernel and a cylinder to the user.
965                let surface_type = dimensions.as_ref().map_or(surface_type, |d| d.label);
966                let mut fragment = serde_json::json!({
967                    "ok": true,
968                    "kind": "face",
969                    "solid": query.owner,
970                    "surfaceType": surface_type,
971                    "area": area,
972                    "edgeLengthTotal": edge_total,
973                });
974                insert_dimensions(&mut fragment, dimensions.as_ref());
975                fragment.to_string()
976            }
977            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
978        },
979        MeasureKind::Edge => match brep_kernel::edge_length_native(handle, &query.entity) {
980            Ok(length) => {
981                let dimensions =
982                    brep_kernel::edge_carrier_dimensions_native(handle, &query.entity)
983                        .ok()
984                        .flatten();
985                let mut fragment = serde_json::json!({
986                    "ok": true,
987                    "kind": "edge",
988                    "solid": query.owner,
989                    "length": length,
990                });
991                if let Some(dimensions) = &dimensions {
992                    fragment["curveType"] = serde_json::Value::from(dimensions.label);
993                }
994                insert_dimensions(&mut fragment, dimensions.as_ref());
995                fragment.to_string()
996            }
997            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
998        },
999    }
1000}
1001
1002/// Add a carrier's named dimensions (`radius`, `diameter`, `halfAngleDeg`,
1003/// `majorRadius`, `minorRadius`, `sweepDeg`) to an object-info fragment.
1004fn insert_dimensions(
1005    fragment: &mut serde_json::Value,
1006    dimensions: Option<&brep_kernel::CarrierDimensions>,
1007) {
1008    let (Some(dimensions), Some(object)) = (dimensions, fragment.as_object_mut()) else {
1009        return;
1010    };
1011    for (key, value) in &dimensions.values {
1012        object.insert((*key).to_string(), serde_json::Value::from(*value));
1013    }
1014}
1015
1016/// The default, SYNCHRONOUS runner: runs on submit, stashes the reply for an
1017/// immediate poll. Behavior-identical to the pre-seam in-process run.
1018pub struct InlineRunner {
1019    /// The scene-free history runner it owns (delta baseline lives here).
1020    runner: crate::pipeline::SceneRunner,
1021    /// Completed replies awaiting a poll. For Inline this holds exactly one entry
1022    /// between a `submit_run` and the immediately-following `poll_run`.
1023    pending: std::collections::VecDeque<RunReply>,
1024    /// Completed measurement replies awaiting a poll (computed synchronously on
1025    /// `submit_query`, popped on `poll_query`) — the synchronous mirror of the
1026    /// thread runner's query buffer, so the object-info path resolves same-call.
1027    query_pending: std::collections::VecDeque<MeasureReply>,
1028    mesh_import_pending: std::collections::VecDeque<MeshImportReply>,
1029    step_probe_pending: std::collections::VecDeque<StepProbeReply>,
1030    topology_pending: std::collections::VecDeque<TopologyReply>,
1031    sheet_lines_pending: std::collections::VecDeque<SheetLinesReply>,
1032    plugin_action_pending: std::collections::VecDeque<PluginActionReply>,
1033    plugin_install_pending: std::collections::VecDeque<PluginInstallReply>,
1034}
1035
1036impl InlineRunner {
1037    pub fn new() -> Self {
1038        Self {
1039            runner: crate::pipeline::SceneRunner::new(),
1040            pending: std::collections::VecDeque::new(),
1041            query_pending: std::collections::VecDeque::new(),
1042            mesh_import_pending: std::collections::VecDeque::new(),
1043            step_probe_pending: std::collections::VecDeque::new(),
1044            topology_pending: std::collections::VecDeque::new(),
1045            sheet_lines_pending: std::collections::VecDeque::new(),
1046            plugin_action_pending: std::collections::VecDeque::new(),
1047            plugin_install_pending: std::collections::VecDeque::new(),
1048        }
1049    }
1050}
1051
1052impl HistoryRunner for InlineRunner {
1053    fn submit_plugin_install(&mut self, request: PluginInstallRequest) -> bool {
1054        self.plugin_install_pending.push_back(validate_plugins(request));
1055        true
1056    }
1057    fn poll_plugin_install(&mut self) -> Option<PluginInstallReply> {
1058        self.plugin_install_pending.pop_front()
1059    }
1060    fn sync_plugins(&mut self, revision: u64, fetch: &mut dyn FnMut() -> serde_json::Value) {
1061        if self.runner.plugin_revision != Some(revision) {
1062            self.runner.set_plugins(revision, fetch());
1063        }
1064    }
1065    fn submit_plugin_action(&mut self, request: PluginActionRequest) -> bool {
1066        self.plugin_action_pending.push_back(self.runner.plugin_action(request));
1067        true
1068    }
1069    fn poll_plugin_action(&mut self) -> Option<PluginActionReply> {
1070        self.plugin_action_pending.pop_front()
1071    }
1072
1073    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
1074        let serial = brep_kernel::assembly_session_serial();
1075        let output = self.runner.run(&request);
1076        let after = brep_kernel::assembly_session_serial();
1077        self.runner.session_of = (after != serial).then_some((generation, after));
1078        self.pending.push_back(RunReply { generation, output });
1079    }
1080
1081    /// The run executed on this (the caller's) thread: its session is already
1082    /// here unless something has replaced it since.
1083    fn session_for_main(&mut self, generation: u64) -> SessionOnMain {
1084        match self.runner.session_of {
1085            Some((ran, serial)) if ran == generation && serial == brep_kernel::assembly_session_serial() => {
1086                SessionOnMain::Resident
1087            }
1088            _ => SessionOnMain::Unavailable,
1089        }
1090    }
1091
1092    /// Nothing to send: Inline runs on the CALLER's thread against the CALLER's
1093    /// kernel store, so the library the caller would hand over is already the
1094    /// one this run resolves against. (Which is also why Inline never needs the
1095    /// preflight — there is only one store, and the caller seeds it directly.)
1096    fn sync_parts_library(
1097        &mut self,
1098        _revision: u64,
1099        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
1100    ) {
1101    }
1102
1103    fn poll_run(&mut self) -> Option<RunReply> {
1104        self.pending.pop_front()
1105    }
1106
1107    fn submit_query(&mut self, query: MeasureQuery) {
1108        let result = measure_json(&self.runner, &query);
1109        self.query_pending.push_back(MeasureReply { id: query.id, result });
1110    }
1111
1112    fn poll_query(&mut self) -> Option<MeasureReply> {
1113        self.query_pending.pop_front()
1114    }
1115
1116    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
1117        self.mesh_import_pending
1118            .push_back(reconstruct_mesh(request));
1119    }
1120
1121    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
1122        self.mesh_import_pending.pop_front()
1123    }
1124
1125    fn submit_step_probe(&mut self, request: StepProbeRequest) {
1126        self.step_probe_pending.push_back(probe_step(request));
1127    }
1128
1129    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
1130        self.step_probe_pending.pop_front()
1131    }
1132
1133    fn submit_topology(&mut self, request: TopologyRequest) {
1134        self.topology_pending.push_back(topology_reply(request));
1135    }
1136
1137    fn poll_topology(&mut self) -> Option<TopologyReply> {
1138        self.topology_pending.pop_front()
1139    }
1140
1141    /// Runs the pass at once, on the caller's thread: an inline runner's
1142    /// registry IS the caller's, so the answer is ready for the next poll.
1143    fn submit_sheet_lines(&mut self, request: SheetLinesRequest) -> bool {
1144        self.sheet_lines_pending.push_back(sheet_lines_reply(request));
1145        true
1146    }
1147
1148    fn poll_sheet_lines(&mut self) -> Option<SheetLinesReply> {
1149        self.sheet_lines_pending.pop_front()
1150    }
1151
1152    fn reset(&mut self) {
1153        self.runner.reset();
1154    }
1155}
1156
1157impl Default for InlineRunner {
1158    fn default() -> Self {
1159        Self::new()
1160    }
1161}
1162
1163// ===========================================================================
1164// Shared run/query PROTOCOL (M3b): the `Command`/`Reply` message pair and the
1165// `process_command` step. Both async runners speak it — the native `ThreadRunner`
1166// ships the enums over `mpsc` (no serialization), and the wasm `WorkerRunner`
1167// serializes them to JSON for `postMessage`. Hence the serde derives (the enums
1168// carry serde types: `HistoryRequest`/`MeasureQuery`/`RunReply`/`MeasureReply`)
1169// and `process_command` being un-gated + shared so both drivers do the SAME work.
1170// ===========================================================================
1171
1172/// A command sent main → runner (thread channel OR worker `postMessage`) over one
1173/// ordered stream (so a `Reset` before a `Run` stays before it). `Run` carries the
1174/// whole request; the driver coalesces consecutive `Run`s to shed a slider drag's
1175/// backlog (the thread in `thread_main`, the worker's main side in `WorkerRunner`).
1176#[derive(serde::Serialize, serde::Deserialize)]
1177pub enum Command {
1178    ValidatePlugins(PluginInstallRequest),
1179    SetPlugins { revision: u64, packages: serde_json::Value },
1180    PluginAction(PluginActionRequest),
1181    Run {
1182        request: brep_kernel::HistoryRequest,
1183        generation: u64,
1184        /// The [`brep_kernel::parts_library_revision`] this run was built
1185        /// against. The runner refuses to execute a run stamped for a library
1186        /// it does not hold — see [`process_command`].
1187        #[serde(default)]
1188        parts_library_revision: u64,
1189    },
1190    /// Install the parts library on the runner's OWN kernel store. Sent only
1191    /// when the library actually changes (insert, document load, refresh),
1192    /// never per run: it carries the whole embedded-part payload, which for an
1193    /// imported STEP assembly is megabytes, and stringifying that on the
1194    /// browser's main thread once per edit is what froze the UI.
1195    ///
1196    /// Deliberately its OWN command rather than an optional field on `Run`:
1197    /// both drivers COALESCE consecutive runs (see `thread_main` and
1198    /// `WorkerRunner::submit_run`), so a library riding on a run could be
1199    /// dropped with it. A `SetPartsLibrary` is never coalesced away, and the
1200    /// stream is ordered, so it always lands before the run that needs it.
1201    SetPartsLibrary {
1202        library: brep_kernel::PartsLibraryMap,
1203        revision: u64,
1204    },
1205    Query(MeasureQuery),
1206    MeshImport(MeshImportRequest),
1207    StepProbe(StepProbeRequest),
1208    Topology(TopologyRequest),
1209    SheetLines(SheetLinesRequest),
1210    /// Generate ONE CAM operation (never coalesced: every job answers).
1211    CamGenerate(CamJob),
1212    /// One CAM simulation command (never coalesced: every job answers).
1213    CamSimulate(CamSimJob),
1214    Reset,
1215    /// Copy the assembly session the run of `generation` left here (native
1216    /// thread only: the copy holds kernel solids and never crosses a worker).
1217    #[serde(skip)]
1218    ExportSession { generation: u64 },
1219}
1220
1221/// A reply sent runner → main; the main side demuxes it into per-kind buffers.
1222#[derive(serde::Serialize, serde::Deserialize)]
1223pub enum Reply {
1224    ValidatedPlugins(PluginInstallReply),
1225    PluginAction(PluginActionReply),
1226    Run(RunReply),
1227    Query(MeasureReply),
1228    MeshImport(MeshImportReply),
1229    StepProbe(StepProbeReply),
1230    Topology(TopologyReply),
1231    SheetLines(SheetLinesReply),
1232    CamGenerate(CamReply),
1233    /// A CAM job's progress, posted from inside the job.
1234    CamProgress(CamProgress),
1235    CamSimulate(CamSimReply),
1236    /// A simulation chunk's progress, posted between motions.
1237    CamSimProgress(CamSimProgress),
1238    /// The runner REFUSED a run because its resident parts library could not
1239    /// serve it (a part the request references is missing, or the run was
1240    /// stamped for a different library revision). No geometry was touched; the
1241    /// main side re-sends the library and re-submits. Refusing is the whole
1242    /// point — running with the wrong library would be silently wrong
1243    /// geometry, which is far worse than one extra round trip.
1244    NeedPartsLibrary,
1245    /// A feature of the in-flight run is about to execute (see
1246    /// [`RunProgress`]). Posted from INSIDE `process_command`, before its
1247    /// `Reply::Run`; the main side keeps only the latest.
1248    Progress(RunProgress),
1249    /// The answer to `Command::ExportSession` (native thread only).
1250    #[serde(skip)]
1251    SessionExport { generation: u64, transfer: Option<brep_kernel::SessionTransfer> },
1252}
1253
1254/// The probe itself — the one parse of a structured STEP import (see
1255/// `EngineState::submit_step_probe`), run wherever the runner runs.
1256fn probe_step(request: StepProbeRequest) -> StepProbeReply {
1257    StepProbeReply {
1258        id: request.id,
1259        result: brep_kernel::read_step_assembly(&request.text),
1260    }
1261}
1262
1263fn reconstruct_mesh(request: MeshImportRequest) -> MeshImportReply {
1264    let result = (|| {
1265        use brep_reconstruction::stl_conversion::{
1266            binary_stl_coordinate_precision_tolerance, convert_stl_mesh_to_step_with_colors,
1267        };
1268        use brep_reconstruction::{Mesh, Vec3};
1269
1270        // A 3MF build's placements, kept only to name each body afterwards.
1271        let mut instances = Vec::new();
1272        let mut reader_messages = Vec::new();
1273        // A 3MF's per-triangle colours (its base materials), parallel to the
1274        // soup's triangles; empty for STL and OBJ, which carry none.
1275        let mut triangle_colors: Vec<Option<[u8; 3]>> = Vec::new();
1276        let (mesh, positions, indices, coordinate_precision_tolerance) = match request.format {
1277            MeshImportFormat::Stl => {
1278                use brep_reconstruction::stl::{parse_stl_bytes, StlFormat, StlReadOptions};
1279                let read_options = StlReadOptions {
1280                    weld_tolerance: (request.options.weld_tolerance >= 0.0)
1281                        .then_some(request.options.weld_tolerance),
1282                };
1283                let imported = parse_stl_bytes(&request.bytes, &read_options)
1284                    .map_err(|error| format!("STL import failed: {error}"))?;
1285                let positions = imported
1286                    .mesh
1287                    .vertices
1288                    .iter()
1289                    .flat_map(|point| [point.x, point.y, point.z])
1290                    .collect::<Vec<_>>();
1291                let indices = imported
1292                    .mesh
1293                    .triangles
1294                    .iter()
1295                    .flatten()
1296                    .copied()
1297                    .collect::<Vec<_>>();
1298                let precision = if imported.format == StlFormat::Binary {
1299                    binary_stl_coordinate_precision_tolerance(&imported.mesh)
1300                } else {
1301                    0.0
1302                };
1303                if !imported.dropped_facets.is_empty() {
1304                    reader_messages.push(format!(
1305                        "the STL reader dropped {} of {} facet(s) with no area: {}",
1306                        imported.dropped_facets.len(),
1307                        imported.source_triangle_count,
1308                        imported.dropped_facets.iter().take(8).map(ToString::to_string).collect::<Vec<_>>().join("; ")
1309                    ));
1310                }
1311                (imported.mesh, positions, indices, precision)
1312            }
1313            MeshImportFormat::Obj => {
1314                let text = std::str::from_utf8(&request.bytes)
1315                    .map_err(|_| "OBJ import failed: file is not UTF-8 text".to_string())?;
1316                let obj = brep_kernel::read_obj(text)
1317                    .map_err(|error| format!("OBJ import failed: {error}"))?;
1318                let vertices = obj
1319                    .positions
1320                    .chunks_exact(3)
1321                    .map(|point| Vec3::new(point[0], point[1], point[2]))
1322                    .collect::<Vec<_>>();
1323                let triangles = obj
1324                    .indices
1325                    .chunks_exact(3)
1326                    .map(|triangle| [triangle[0], triangle[1], triangle[2]])
1327                    .collect::<Vec<_>>();
1328                (
1329                    Mesh::new(vertices, triangles),
1330                    obj.positions,
1331                    obj.indices,
1332                    0.0,
1333                )
1334            }
1335            // The 3MF reader has already applied the file's unit and every
1336            // build/component transform, so what arrives here is the same kind
1337            // of millimetre triangle soup an STL carries — including a build
1338            // that places SEVERAL instances, which is several bodies in that
1339            // one buffer. The chain splits a soup into its vertex-connected
1340            // components and reconstructs each as its own body, so the whole
1341            // build goes through in ONE call and the poses are already in the
1342            // coordinates.
1343            MeshImportFormat::ThreeMf => {
1344                let model = brep_kernel::read_3mf(&request.bytes)
1345                    .map_err(|error| format!("3MF import failed: {error}"))?;
1346                instances = model.instances;
1347                triangle_colors = model.triangle_colors;
1348                let vertices = model
1349                    .positions
1350                    .chunks_exact(3)
1351                    .map(|point| Vec3::new(point[0], point[1], point[2]))
1352                    .collect::<Vec<_>>();
1353                let triangles = model
1354                    .indices
1355                    .chunks_exact(3)
1356                    .map(|triangle| [triangle[0], triangle[1], triangle[2]])
1357                    .collect::<Vec<_>>();
1358                (
1359                    Mesh::new(vertices, triangles),
1360                    model.positions,
1361                    model.indices,
1362                    0.0,
1363                )
1364            }
1365        };
1366        let mut options = request.options;
1367        options.coordinate_precision_tolerance = options.coordinate_precision_tolerance
1368            .max(coordinate_precision_tolerance);
1369        convert_stl_mesh_to_step_with_colors(
1370            &mesh,
1371            &positions,
1372            Some(&indices),
1373            (!triangle_colors.is_empty()).then_some(triangle_colors.as_slice()),
1374            &options,
1375            "Imported mesh",
1376            "MM",
1377            "",
1378        )
1379        .map(|mut output| {
1380            name_bodies(&mut output.report, &instances);
1381            output.report.messages.splice(0..0, reader_messages);
1382            output
1383        })
1384        .map_err(|error| format!("RANSAC reconstruction failed: {error}"))
1385    })();
1386    MeshImportReply {
1387        id: request.id,
1388        result,
1389    }
1390}
1391
1392/// Give each body the name of the 3MF object its first source triangle came
1393/// from, so a refusal can say WHICH object is missing. Bodies are found in the
1394/// flattened soup the reader laid out instance by instance.
1395fn name_bodies(
1396    report: &mut brep_reconstruction::stl_conversion::StlConversionReport,
1397    instances: &[brep_kernel::ThreeMfInstance],
1398) {
1399    for component in &mut report.components {
1400        component.source_name = instances
1401            .iter()
1402            .find(|instance| {
1403                (instance.first_triangle..instance.first_triangle + instance.triangle_count)
1404                    .contains(&component.first_triangle)
1405            })
1406            .and_then(|instance| instance.name.clone());
1407    }
1408}
1409
1410/// The notice for bodies a multi-body import left out, one clause per body,
1411/// named by the source's own name when it has one; `None` when every body was
1412/// built.
1413pub(crate) fn refused_bodies_notice(
1414    report: &brep_reconstruction::stl_conversion::StlConversionReport,
1415) -> Option<String> {
1416    let refused = report
1417        .components
1418        .iter()
1419        .filter_map(|component| {
1420            component.refusal.as_ref().map(|reason| {
1421                let body = match &component.source_name {
1422                    Some(name) => format!("body {} \"{name}\"", component.index + 1),
1423                    None => format!(
1424                        "body {} (from source triangle {})",
1425                        component.index + 1,
1426                        component.first_triangle
1427                    ),
1428                };
1429                format!("{body}: {reason}")
1430            })
1431        })
1432        .collect::<Vec<_>>();
1433    (!refused.is_empty()).then(|| {
1434        format!(
1435            "mesh import: {} of {} bodies could not be reconstructed and are NOT in the model — {}",
1436            refused.len(),
1437            report.components.len(),
1438            refused.join("; ")
1439        )
1440    })
1441}
1442
1443/// Execute ONE [`Command`] against `runner` and return the [`Reply`] it produces,
1444/// if any. The shared step both async drivers run: the native `thread_main`
1445/// calls it for each (post-coalescing) command on the runner thread; the wasm
1446/// `WorkerRunner`'s `worker_entry` calls it per `postMessage` on the worker.
1447/// `Run` → a [`Reply::Run`]; `Query` → a [`Reply::Query`]; `Reset` drops the delta
1448/// baseline AND the runner's OWN kernel history cache (the resident registry lives
1449/// with the runner — thread or worker — not on main) and yields no reply.
1450///
1451/// `progress` is called before every feature a `Run` actually executes, with
1452/// the report the driver should ship as [`Reply::Progress`]; returning `false`
1453/// stops the run at that boundary (the native thread's cooperative cancel —
1454/// the worker is terminated instead and always returns `true`).
1455pub fn process_command(
1456    runner: &mut crate::pipeline::SceneRunner,
1457    command: Command,
1458    progress: &mut dyn FnMut(Reply) -> bool,
1459) -> Option<Reply> {
1460    match command {
1461        Command::ValidatePlugins(request) => Some(Reply::ValidatedPlugins(validate_plugins(request))),
1462        Command::SetPlugins { revision, packages } => {
1463            runner.set_plugins(revision, packages);
1464            None
1465        }
1466        Command::PluginAction(request) => Some(Reply::PluginAction(runner.plugin_action(request))),
1467        Command::Run {
1468            request,
1469            generation,
1470            parts_library_revision,
1471        } => {
1472            // PREFLIGHT (see `Reply::NeedPartsLibrary`). Two independent
1473            // checks, because neither alone is enough:
1474            //
1475            // * the revision stamp catches CHANGED content the sender knows
1476            //   about but this store has not received;
1477            // * `missing_library_parts` catches content that is simply GONE
1478            //   here — the orphan GC at the end of every run drops entries no
1479            //   ACOMP in THAT run referenced, so an undo to zero components
1480            //   empties this store while the sender's (which never ran) keeps
1481            //   everything and its revision never moves. Only looking at the
1482            //   actual content sees that.
1483            let stale = match runner.parts_library_revision {
1484                Some(installed) => installed != parts_library_revision,
1485                // Nothing installed yet. Accept a run stamped 0 — that is
1486                // either an empty library or a caller driving `submit_run`
1487                // directly (the runner tests); the content preflight below is
1488                // what actually protects the run. A non-zero stamp with nothing
1489                // installed IS a gap: refuse it.
1490                None => parts_library_revision != 0,
1491            };
1492            // Only a part the sender DID send and this store has since lost is
1493            // worth asking for again. One it never sent is a dangling reference
1494            // in the document itself: run it, so the ACOMP feature reports it
1495            // the way it always has. (An install inserts every incoming name,
1496            // so right after one this set can only be empty — which is what
1497            // makes the ask-and-retry terminate.)
1498            let recoverable = brep_kernel::missing_library_parts(&request)
1499                .iter()
1500                .any(|name| runner.parts_library_names.contains(name));
1501            if stale || recoverable {
1502                runner.parts_library_revision = None;
1503                return Some(Reply::NeedPartsLibrary);
1504            }
1505            let serial = brep_kernel::assembly_session_serial();
1506            let output = runner.run_observed(&request, &mut |event| {
1507                progress(Reply::Progress(RunProgress {
1508                    generation,
1509                    index: event.index,
1510                    total: event.total,
1511                    feature_id: event.id.to_string(),
1512                    feature_type: event.feature_type.to_string(),
1513                }))
1514            });
1515            let after = brep_kernel::assembly_session_serial();
1516            runner.session_of = (after != serial).then_some((generation, after));
1517            Some(Reply::Run(RunReply { generation, output }))
1518        }
1519        Command::SetPartsLibrary { library, revision } => {
1520            runner.parts_library_names = library.keys().cloned().collect();
1521            brep_kernel::install_parts_library(&library);
1522            runner.parts_library_revision = Some(revision);
1523            None
1524        }
1525        Command::Query(query) => {
1526            let result = measure_json(runner, &query);
1527            Some(Reply::Query(MeasureReply { id: query.id, result }))
1528        }
1529        Command::MeshImport(request) => Some(Reply::MeshImport(reconstruct_mesh(request))),
1530        Command::StepProbe(request) => Some(Reply::StepProbe(probe_step(request))),
1531        Command::Topology(request) => Some(Reply::Topology(topology_reply(request))),
1532        Command::SheetLines(request) => Some(Reply::SheetLines(sheet_lines_reply(request))),
1533        Command::CamGenerate(job) => Some(Reply::CamGenerate(cam_generate_reply(job, progress))),
1534        Command::CamSimulate(job) => Some(Reply::CamSimulate(cam_sim_reply(job, progress))),
1535        Command::Reset => {
1536            // A document switch: drop the delta baseline AND this runner's OWN kernel
1537            // history cache (the resident registry lives here, not on main), mirroring
1538            // `set_history_json`'s main-thread clear so a new model rebuilds fully and
1539            // the old model's handles are freed.
1540            runner.reset();
1541            brep_kernel::clear_history_cache();
1542            cam_sim_clear();
1543            pre_op_stock_clear();
1544            None
1545        }
1546        Command::ExportSession { generation } => {
1547            Some(Reply::SessionExport { generation, transfer: runner.export_session(generation) })
1548        }
1549    }
1550}
1551
1552// ===========================================================================
1553// ThreadRunner (M2b): a persistent std::thread that OWNS the SceneRunner, so a
1554// history run — and per-object measurement queries — execute OFF the main thread
1555// and the native UI stays responsive during a run AND during selection. Native
1556// only: `std::thread` + `std::sync::mpsc` do not exist on wasm32 (M3b lands a
1557// worker impl behind this same trait), so the whole thing is cfg-gated out there.
1558// ===========================================================================
1559
1560/// The persistent-thread runner. `submit_*`/`reset` push [`Command`]s down the
1561/// channel; `poll_*` first DRAIN every ready [`Reply`] into the two demux buffers,
1562/// then pop the matching one. The `SceneRunner` (and thus the kernel's resident
1563/// registry it warms) lives ENTIRELY on the thread — it is never shared — so the
1564/// only cross-thread traffic is the `Send` command/reply payloads.
1565#[cfg(not(target_arch = "wasm32"))]
1566pub struct ThreadRunner {
1567    /// Main → thread. `Option` so [`Drop`] can take + drop it, ending the thread's
1568    /// blocking `recv` after already-submitted work finishes.
1569    tx: Option<std::sync::mpsc::Sender<Command>>,
1570    /// Thread → main.
1571    rx: std::sync::mpsc::Receiver<Reply>,
1572    /// Dropped without joining so closing a busy preview never stalls the UI.
1573    handle: Option<std::thread::JoinHandle<()>>,
1574    /// Demuxed completed run replies awaiting `poll_run`.
1575    run_buf: std::collections::VecDeque<RunReply>,
1576    /// Demuxed completed measurement replies awaiting `poll_query`.
1577    query_buf: std::collections::VecDeque<MeasureReply>,
1578    mesh_import_buf: std::collections::VecDeque<MeshImportReply>,
1579    step_probe_buf: std::collections::VecDeque<StepProbeReply>,
1580    topology_buf: std::collections::VecDeque<TopologyReply>,
1581    sheet_lines_buf: std::collections::VecDeque<SheetLinesReply>,
1582    cam_buf: std::collections::VecDeque<CamReply>,
1583    /// [`HistoryRunner::hold_cam_replies`]: `poll_cam` answers `None` while set.
1584    cam_hold: bool,
1585    cam_progress_buf: std::collections::VecDeque<CamProgress>,
1586    /// Set by [`HistoryRunner::cancel_cam`]; the thread reads it on every CAM
1587    /// progress post and the running job cancels at its next chunk.
1588    cam_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1589    cam_sim_buf: std::collections::VecDeque<CamSimReply>,
1590    cam_sim_progress_buf: std::collections::VecDeque<CamSimProgress>,
1591    /// Set by [`HistoryRunner::cancel_cam_sim`]; read between two motions.
1592    cam_sim_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1593    plugin_action_buf: std::collections::VecDeque<PluginActionReply>,
1594    plugin_install_buf: std::collections::VecDeque<PluginInstallReply>,
1595    sent_plugin_revision: Option<u64>,
1596    /// Progress reports of the in-flight run, oldest first (`poll_progress`
1597    /// keeps the newest).
1598    progress_buf: std::collections::VecDeque<RunProgress>,
1599    /// The parts-library revision last SENT down the channel (`None` = never,
1600    /// or the thread dropped it). Lives here rather than on the caller so
1601    /// "reset forgets the library" is a local property of this object.
1602    sent_library_revision: Option<u64>,
1603    /// The thread refused a run for want of its library (drained by
1604    /// [`HistoryRunner::poll_library_request`]).
1605    library_requested: bool,
1606    /// The cooperative stop flag THIS thread checks between features. Each
1607    /// spawn gets its own, so a cancelled thread keeps its raised flag while
1608    /// the replacement starts clean.
1609    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
1610}
1611
1612#[cfg(not(target_arch = "wasm32"))]
1613impl ThreadRunner {
1614    pub fn new() -> Self {
1615        let (tx, cmd_rx) = std::sync::mpsc::channel::<Command>();
1616        let (reply_tx, rx) = std::sync::mpsc::channel::<Reply>();
1617        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1618        let thread_stop = stop.clone();
1619        let cam_cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1620        let thread_cam_cancel = cam_cancel.clone();
1621        let cam_sim_cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1622        let thread_cam_sim_cancel = cam_sim_cancel.clone();
1623        let handle = std::thread::Builder::new()
1624            .name("brep-history-runner".to_string())
1625            .spawn(move || thread_main(cmd_rx, reply_tx, thread_stop, thread_cam_cancel, thread_cam_sim_cancel))
1626            .expect("spawn brep-history-runner thread");
1627        Self {
1628            tx: Some(tx),
1629            rx,
1630            handle: Some(handle),
1631            run_buf: std::collections::VecDeque::new(),
1632            query_buf: std::collections::VecDeque::new(),
1633            mesh_import_buf: std::collections::VecDeque::new(),
1634            step_probe_buf: std::collections::VecDeque::new(),
1635            topology_buf: std::collections::VecDeque::new(),
1636            sheet_lines_buf: std::collections::VecDeque::new(),
1637            cam_buf: std::collections::VecDeque::new(),
1638            cam_hold: false,
1639            cam_progress_buf: std::collections::VecDeque::new(),
1640            cam_cancel,
1641            cam_sim_buf: std::collections::VecDeque::new(),
1642            cam_sim_progress_buf: std::collections::VecDeque::new(),
1643            cam_sim_cancel,
1644            plugin_action_buf: std::collections::VecDeque::new(),
1645            plugin_install_buf: std::collections::VecDeque::new(),
1646            sent_plugin_revision: None,
1647            progress_buf: std::collections::VecDeque::new(),
1648            sent_library_revision: None,
1649            library_requested: false,
1650            stop,
1651        }
1652    }
1653
1654    /// Pull every ready reply off the channel and demux it into the run/query
1655    /// buffers (so a `poll_run` never swallows a query reply and vice versa).
1656    fn drain(&mut self) {
1657        while let Ok(reply) = self.rx.try_recv() {
1658            self.file(reply);
1659        }
1660    }
1661
1662    /// Demux one reply into its buffer. A session export is handed back (only
1663    /// [`HistoryRunner::session_for_main`] waits for one; a late one is dropped).
1664    fn file(&mut self, reply: Reply) -> Option<(u64, Option<brep_kernel::SessionTransfer>)> {
1665        match reply {
1666            Reply::SessionExport { generation, transfer } => return Some((generation, transfer)),
1667            Reply::ValidatedPlugins(reply) => self.plugin_install_buf.push_back(reply),
1668            Reply::PluginAction(reply) => self.plugin_action_buf.push_back(reply),
1669            Reply::Run(run) => self.run_buf.push_back(run),
1670            Reply::Query(query) => self.query_buf.push_back(query),
1671            Reply::MeshImport(reply) => self.mesh_import_buf.push_back(reply),
1672            Reply::StepProbe(reply) => self.step_probe_buf.push_back(reply),
1673            Reply::Topology(reply) => self.topology_buf.push_back(reply),
1674            Reply::SheetLines(reply) => self.sheet_lines_buf.push_back(reply),
1675            Reply::CamGenerate(reply) => self.cam_buf.push_back(reply),
1676            Reply::CamProgress(progress) => self.cam_progress_buf.push_back(progress),
1677            Reply::CamSimulate(reply) => self.cam_sim_buf.push_back(reply),
1678            Reply::CamSimProgress(progress) => self.cam_sim_progress_buf.push_back(progress),
1679            Reply::Progress(progress) => self.progress_buf.push_back(progress),
1680            Reply::NeedPartsLibrary => {
1681                // The thread dropped (or never had) the library this run
1682                // needs. Forget what we believe it holds so the next
1683                // `sync_parts_library` re-installs, and flag the refused
1684                // run for the caller to re-submit.
1685                self.sent_library_revision = None;
1686                self.library_requested = true;
1687            }
1688        }
1689        None
1690    }
1691}
1692
1693/// How long the main thread waits for the runner's session copy before it
1694/// replays the history instead (the copy of l500 takes ~8 ms).
1695#[cfg(not(target_arch = "wasm32"))]
1696const SESSION_EXPORT_WAIT: std::time::Duration = std::time::Duration::from_millis(250);
1697
1698#[cfg(not(target_arch = "wasm32"))]
1699impl Default for ThreadRunner {
1700    fn default() -> Self {
1701        Self::new()
1702    }
1703}
1704
1705/// The runner thread's whole life: block on the next command, batch it with every
1706/// other command already queued, COALESCE consecutive `Run`s (a `Run` immediately
1707/// followed by another `Run` — with no `Query`/`Reset` between — is dropped; only
1708/// the last of each consecutive group runs), then process the batch IN ORDER so a
1709/// `Reset` or a `Query` interleaved between two runs keeps its place. Exits when
1710/// the command sender is dropped (`recv` errors) or the reply receiver is gone (a
1711/// `send` errors — the main side went away).
1712///
1713/// `stop` is the cooperative cancel: raised by [`ThreadRunner::cancel`] on a
1714/// thread that has already been abandoned, it is checked before every feature
1715/// a run executes, so the abandoned thread finishes the feature it is on and
1716/// exits at the next boundary instead of running the rest of the history for
1717/// a receiver that is gone.
1718#[cfg(not(target_arch = "wasm32"))]
1719fn thread_main(
1720    cmd_rx: std::sync::mpsc::Receiver<Command>,
1721    reply_tx: std::sync::mpsc::Sender<Reply>,
1722    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
1723    cam_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1724    cam_sim_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1725) {
1726    let mut runner = crate::pipeline::SceneRunner::new();
1727    while let Ok(first) = cmd_rx.recv() {
1728        if stop.load(std::sync::atomic::Ordering::Relaxed) {
1729            return;
1730        }
1731        // Gather this command plus everything else already waiting.
1732        let mut batch = vec![first];
1733        loop {
1734            match cmd_rx.try_recv() {
1735                Ok(command) => batch.push(command),
1736                Err(_) => break, // Empty or Disconnected — process what we have.
1737            }
1738        }
1739        // A Run immediately followed by another Run is coalesced away (its result
1740        // would be overwritten before anything observed it); a Run followed by a
1741        // Query/Reset/end still runs, so interleaved work sees the right geometry.
1742        let mut run_here: Vec<bool> = vec![true; batch.len()];
1743        for i in 0..batch.len() {
1744            if matches!(batch[i], Command::Run { .. })
1745                && matches!(batch.get(i + 1), Some(Command::Run { .. }))
1746            {
1747                run_here[i] = false;
1748            }
1749        }
1750        // Process the KEPT commands in order through the SHARED `process_command`
1751        // (byte-identical work to the wasm worker's per-message step), sending each
1752        // reply it produces; a coalesced-away Run is skipped without touching the
1753        // runner, so interleaved Query/Reset still see the right geometry.
1754        for (i, command) in batch.into_iter().enumerate() {
1755            if !run_here[i] {
1756                continue;
1757            }
1758            // A CAM job's progress post also reads the cancel flag: `false`
1759            // back to the job is its cancel (see `CamSink`).
1760            let is_cam = matches!(command, Command::CamGenerate(_));
1761            if is_cam {
1762                cam_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
1763            }
1764            let is_sim = matches!(command, Command::CamSimulate(_));
1765            let mut progress = |reply: Reply| {
1766                // A lost receiver means the main side abandoned this thread:
1767                // stop at this boundary rather than finishing for nobody.
1768                reply_tx.send(reply).is_ok()
1769                    && !stop.load(std::sync::atomic::Ordering::Relaxed)
1770                    && !(is_cam && cam_cancel.load(std::sync::atomic::Ordering::SeqCst))
1771                    && !(is_sim && cam_sim_cancel.load(std::sync::atomic::Ordering::SeqCst))
1772            };
1773            if let Some(reply) = process_command(&mut runner, command, &mut progress) {
1774                if reply_tx.send(reply).is_err() {
1775                    return; // The reply receiver is gone — the main side went away.
1776                }
1777            }
1778        }
1779    }
1780}
1781
1782#[cfg(not(target_arch = "wasm32"))]
1783impl HistoryRunner for ThreadRunner {
1784    fn session_for_main(&mut self, generation: u64) -> SessionOnMain {
1785        let Some(tx) = &self.tx else { return SessionOnMain::Unavailable };
1786        if tx.send(Command::ExportSession { generation }).is_err() {
1787            return SessionOnMain::Unavailable;
1788        }
1789        let deadline = std::time::Instant::now() + SESSION_EXPORT_WAIT;
1790        loop {
1791            let left = deadline.saturating_duration_since(std::time::Instant::now());
1792            let Ok(reply) = self.rx.recv_timeout(left) else { return SessionOnMain::Unavailable };
1793            if let Some((answered, transfer)) = self.file(reply) {
1794                if answered != generation {
1795                    continue; // a late answer to an earlier ask
1796                }
1797                return transfer.map_or(SessionOnMain::Unavailable, SessionOnMain::Copied);
1798            }
1799        }
1800    }
1801    fn submit_plugin_install(&mut self, request: PluginInstallRequest) -> bool {
1802        self.tx.as_ref().is_some_and(|tx| tx.send(Command::ValidatePlugins(request)).is_ok())
1803    }
1804    fn poll_plugin_install(&mut self) -> Option<PluginInstallReply> {
1805        self.drain();
1806        self.plugin_install_buf.pop_front()
1807    }
1808    fn sync_plugins(&mut self, revision: u64, fetch: &mut dyn FnMut() -> serde_json::Value) {
1809        if self.sent_plugin_revision != Some(revision) {
1810            if let Some(tx) = &self.tx {
1811                if tx.send(Command::SetPlugins { revision, packages: fetch() }).is_ok() {
1812                    self.sent_plugin_revision = Some(revision);
1813                }
1814            }
1815        }
1816    }
1817    fn submit_plugin_action(&mut self, request: PluginActionRequest) -> bool {
1818        self.tx.as_ref().is_some_and(|tx| tx.send(Command::PluginAction(request)).is_ok())
1819    }
1820    fn poll_plugin_action(&mut self) -> Option<PluginActionReply> {
1821        self.drain();
1822        self.plugin_action_buf.pop_front()
1823    }
1824
1825    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
1826        if let Some(tx) = &self.tx {
1827            let _ = tx.send(Command::Run {
1828                request,
1829                generation,
1830                parts_library_revision: self.sent_library_revision.unwrap_or(0),
1831            });
1832        }
1833    }
1834
1835    fn sync_parts_library(
1836        &mut self,
1837        revision: u64,
1838        fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
1839    ) {
1840        if self.sent_library_revision == Some(revision) {
1841            return;
1842        }
1843        if let Some(tx) = &self.tx {
1844            let _ = tx.send(Command::SetPartsLibrary {
1845                library: fetch(),
1846                revision,
1847            });
1848            self.sent_library_revision = Some(revision);
1849        }
1850    }
1851
1852    fn poll_library_request(&mut self) -> bool {
1853        self.drain();
1854        std::mem::take(&mut self.library_requested)
1855    }
1856
1857    fn poll_run(&mut self) -> Option<RunReply> {
1858        self.drain();
1859        self.run_buf.pop_front()
1860    }
1861
1862    fn run_reply_waiting(&mut self) -> bool {
1863        self.drain();
1864        !self.run_buf.is_empty() || self.library_requested
1865    }
1866
1867    fn submit_query(&mut self, query: MeasureQuery) {
1868        if let Some(tx) = &self.tx {
1869            let _ = tx.send(Command::Query(query));
1870        }
1871    }
1872
1873    fn poll_query(&mut self) -> Option<MeasureReply> {
1874        self.drain();
1875        self.query_buf.pop_front()
1876    }
1877
1878    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
1879        if let Some(tx) = &self.tx {
1880            let _ = tx.send(Command::MeshImport(request));
1881        }
1882    }
1883
1884    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
1885        self.drain();
1886        self.mesh_import_buf.pop_front()
1887    }
1888
1889    fn submit_step_probe(&mut self, request: StepProbeRequest) {
1890        if let Some(tx) = &self.tx {
1891            let _ = tx.send(Command::StepProbe(request));
1892        }
1893    }
1894
1895    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
1896        self.drain();
1897        self.step_probe_buf.pop_front()
1898    }
1899
1900    fn submit_topology(&mut self, request: TopologyRequest) {
1901        if let Some(tx) = &self.tx {
1902            let _ = tx.send(Command::Topology(request));
1903        }
1904    }
1905
1906    fn poll_topology(&mut self) -> Option<TopologyReply> {
1907        self.drain();
1908        self.topology_buf.pop_front()
1909    }
1910
1911    fn wait_topology(&mut self, timeout: std::time::Duration) -> bool {
1912        self.drain();
1913        let deadline = std::time::Instant::now() + timeout;
1914        while self.topology_buf.is_empty() {
1915            let now = std::time::Instant::now();
1916            if now >= deadline {
1917                return false;
1918            }
1919            match self.rx.recv_timeout(deadline - now) {
1920                Ok(reply) => {
1921                    self.file(reply);
1922                }
1923                Err(_) => return false,
1924            }
1925        }
1926        true
1927    }
1928
1929    fn submit_sheet_lines(&mut self, request: SheetLinesRequest) -> bool {
1930        match &self.tx {
1931            Some(tx) => tx.send(Command::SheetLines(request)).is_ok(),
1932            None => false,
1933        }
1934    }
1935
1936    fn poll_sheet_lines(&mut self) -> Option<SheetLinesReply> {
1937        self.drain();
1938        self.sheet_lines_buf.pop_front()
1939    }
1940
1941    fn submit_cam(&mut self, job: CamJob) -> bool {
1942        self.cam_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
1943        match &self.tx {
1944            Some(tx) => tx.send(Command::CamGenerate(job)).is_ok(),
1945            None => false,
1946        }
1947    }
1948
1949    fn poll_cam(&mut self) -> Option<CamReply> {
1950        self.drain();
1951        if self.cam_hold {
1952            return None;
1953        }
1954        self.cam_buf.pop_front()
1955    }
1956
1957    fn hold_cam_replies(&mut self, hold: bool) {
1958        self.cam_hold = hold;
1959    }
1960
1961    fn poll_cam_progress(&mut self) -> Option<CamProgress> {
1962        self.drain();
1963        let latest = self.cam_progress_buf.pop_back();
1964        self.cam_progress_buf.clear();
1965        latest
1966    }
1967
1968    fn cancel_cam(&mut self) {
1969        self.cam_cancel.store(true, std::sync::atomic::Ordering::SeqCst);
1970    }
1971
1972    fn submit_cam_sim(&mut self, job: CamSimJob) -> bool {
1973        // A cancel raised for an earlier chunk must not stop this one.
1974        self.cam_sim_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
1975        match &self.tx {
1976            Some(tx) => tx.send(Command::CamSimulate(job)).is_ok(),
1977            None => false,
1978        }
1979    }
1980
1981    fn poll_cam_sim(&mut self) -> Option<CamSimReply> {
1982        self.drain();
1983        self.cam_sim_buf.pop_front()
1984    }
1985
1986    fn poll_cam_sim_progress(&mut self) -> Option<CamSimProgress> {
1987        self.drain();
1988        let latest = self.cam_sim_progress_buf.pop_back();
1989        self.cam_sim_progress_buf.clear();
1990        latest
1991    }
1992
1993    fn cancel_cam_sim(&mut self) {
1994        self.cam_sim_cancel.store(true, std::sync::atomic::Ordering::SeqCst);
1995    }
1996
1997    fn poll_progress(&mut self) -> Option<RunProgress> {
1998        self.drain();
1999        let latest = self.progress_buf.pop_back();
2000        self.progress_buf.clear();
2001        latest
2002    }
2003
2004    /// Abandon the thread: raise its stop flag, close its channels, and spawn a
2005    /// fresh thread with a fresh registry. The old thread cannot be interrupted
2006    /// inside a feature — it finishes the one it is on (burning CPU until
2007    /// then), sees the flag, and exits. Always `true`: there is always a
2008    /// thread to replace.
2009    fn cancel(&mut self) -> bool {
2010        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
2011        self.tx.take();
2012        self.handle.take();
2013        *self = Self::new();
2014        true
2015    }
2016
2017    fn reset(&mut self) {
2018        self.sent_plugin_revision = None;
2019        self.plugin_action_buf.clear();
2020        if let Some(tx) = &self.tx {
2021            let _ = tx.send(Command::Reset);
2022        }
2023        // A reset is a wholesale document switch: any replies still buffered from
2024        // the old model are stale — drop them (a fresh run/query supersedes).
2025        self.run_buf.clear();
2026        self.query_buf.clear();
2027        self.mesh_import_buf.clear();
2028        self.step_probe_buf.clear();
2029        self.topology_buf.clear();
2030        self.sheet_lines_buf.clear();
2031        self.progress_buf.clear();
2032        // `Command::Reset` clears the thread's kernel store, library included,
2033        // so what we believe it holds is void.
2034        self.sent_library_revision = None;
2035        self.library_requested = false;
2036    }
2037}
2038
2039#[cfg(not(target_arch = "wasm32"))]
2040impl Drop for ThreadRunner {
2041    fn drop(&mut self) {
2042        // Closing a document or cancelling an import preview must not block
2043        // the UI on reconstruction. Closing the channel lets the worker exit
2044        // and release its registry once already-submitted work finishes.
2045        self.tx.take();
2046        self.handle.take();
2047    }
2048}
2049