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