Skip to main content

brep_render/
runner.rs

1//! The history-run seam — the async RUN machine behind a trait, mirroring the
2//! platform-seam shape of `brep-app/src/store.rs`'s `ModelStore` (a trait with
3//! impls behind it, async surfaced via a poll idiom).
4//!
5//! A history run is split SUBMIT → POLL/APPLY: [`HistoryRunner::submit_run`]
6//! kicks off a run tagged with a monotonic generation, and the completed
7//! [`RunReply`] is drained later via [`HistoryRunner::poll_run`]. The runner OWNS
8//! the [`SceneRunner`](crate::pipeline::SceneRunner) — so a future thread/worker
9//! impl owns the resident registry that `execute_history` populates — and holds
10//! the delta baseline across reruns.
11//!
12//! This slice ships the DEFAULT [`InlineRunner`]: it runs on `submit_run` and
13//! stashes the reply for an immediate `poll_run`, so the run stays synchronous
14//! and byte-identical to the pre-seam in-process run. A native-thread impl (M2b)
15//! and a wasm-worker impl (M3) slot in behind the SAME trait — `submit_run`
16//! defers the work and `poll_run` surfaces it a frame (or many) later, so the
17//! `EngineState::pump` caller never changes.
18
19/// A completed history run, tagged with the [`generation`](Self::generation) it
20/// was submitted under so the applier can drop stale replies (a newer run that
21/// finished first). The [`output`](Self::output) is the [`SceneRunner`] delta to
22/// apply to the display scene.
23///
24/// [`SceneRunner`]: crate::pipeline::SceneRunner
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct RunReply {
27    /// The monotonic generation this run was submitted under.
28    pub generation: u64,
29    /// The delta snapshot + report the run produced.
30    pub output: crate::pipeline::RunOutput,
31}
32
33/// A feature about to EXECUTE inside an in-flight run (cached replays are
34/// instant and are not reported): which one, of how many, under which
35/// generation. Posted by the runner before the kernel starts the feature, so
36/// the UI can name what it is waiting on — and, after a cancel, what it was
37/// waiting on.
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39pub struct RunProgress {
40    /// The generation of the run this belongs to (see [`RunReply::generation`]).
41    pub generation: u64,
42    /// Zero-based position of the feature in the request.
43    pub index: usize,
44    /// The request's feature count.
45    pub total: usize,
46    pub feature_id: String,
47    pub feature_type: String,
48}
49
50/// A STEP text to PROBE for product structure on the runner — the parse that
51/// `brep_kernel::read_step_assembly` performs, which builds every product's
52/// bodies and takes seconds on a real assembly (8.3 s natively for a 5 MB,
53/// 140-product file), so it must not run on the browser's main thread.
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct StepProbeRequest {
56    pub id: u64,
57    pub text: String,
58}
59
60/// The probe's answer: the parsed assembly (`Some`), no structure (`None`),
61/// or a parse failure. The assembly crosses back to the main side whole — its
62/// JSON is large (74 MB for the file above) but the trip costs a third of a
63/// second against the seconds the parse took.
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct StepProbeReply {
66    pub id: u64,
67    pub result: Result<Option<brep_kernel::StepAssembly>, String>,
68}
69
70/// Which exact measurement a [`MeasureQuery`] wants, mirroring the object-info
71/// kinds ([`crate::metadata`]): a whole solid, one named face, or one named edge.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73pub enum MeasureKind {
74    Solid,
75    Face,
76    Edge,
77}
78
79/// A per-object MEASUREMENT request routed to the runner (which owns the warm
80/// registry). The runner resolves `owner` to its resident handle and measures by
81/// [`kind`](Self::kind); the reply is the object-info JSON fragment MINUS the
82/// main-injected `name`/`creatingFeature` fields. Tagged with a monotonic
83/// [`id`](Self::id) so the main side can pair the reply with its pending request.
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct MeasureQuery {
86    /// Monotonic request id (main pairs the reply back by it).
87    pub id: u64,
88    /// The measurement to run.
89    pub kind: MeasureKind,
90    /// The OWNING solid name (a solid is its own owner; a face/edge names its solid).
91    pub owner: String,
92    /// The face/edge NAME to measure (ignored for a whole-solid query).
93    pub entity: String,
94    /// The density (mass per mm³) to scale a solid's weight (ignored for face/edge).
95    pub density: f64,
96}
97
98/// A completed [`MeasureQuery`]: the object-info measurement fields as a JSON
99/// fragment (WITHOUT `name`/`creatingFeature`, which the main thread injects from
100/// its eager provenance), tagged with the request [`id`](Self::id).
101#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
102pub struct MeasureReply {
103    /// The request id this answers.
104    pub id: u64,
105    /// The measurement JSON fragment (see [`measure_json`]).
106    pub result: String,
107}
108
109/// A request for the B-rep TOPOLOGY of resident solids, by the handle each
110/// display was tessellated from. The drawing sheet's exact hidden-line pass
111/// reads faces and edges, and the resident registry that holds them is
112/// thread-local to the runner, so the main side asks for exactly the solids a
113/// placement draws and does not already hold. Nothing else ever asks, so a
114/// document with no sheet never pays for the topology crossing.
115#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
116pub struct TopologyRequest {
117    /// Monotonic request id (main pairs the reply back by it).
118    pub id: u64,
119    /// `(solid name, resident handle)` per solid wanted.
120    pub solids: Vec<(String, u32)>,
121}
122
123/// A completed [`TopologyRequest`]: a clone of every requested solid the
124/// registry still holds, `(name, handle, solid)`. A handle the registry no
125/// longer holds (a later run freed it) is left out.
126#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
127pub struct TopologyReply {
128    /// The request id this answers.
129    pub id: u64,
130    pub solids: Vec<(String, u32, brep_kernel::BrepSolid)>,
131}
132
133/// Answer a topology request against the resident registry of the thread this
134/// runs on. Shared by every runner.
135fn topology_reply(request: TopologyRequest) -> TopologyReply {
136    let solids = request
137        .solids
138        .into_iter()
139        .filter_map(|(name, handle)| {
140            brep_kernel::registered_solid_clone(handle)
141                .ok()
142                .map(|solid| (name, handle, solid))
143        })
144        .collect();
145    TopologyReply { id: request.id, solids }
146}
147
148/// A drawing sheet's exact hidden-line pass for ONE placement, handed to the
149/// runner so the UI thread never runs it (see
150/// [`crate::sheets::project::LinesJob`]). The job names the runner's own
151/// resident solids by handle and content fingerprint, so nothing crosses the
152/// seam but the question and the lines.
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
154pub struct SheetLinesRequest {
155    /// Monotonic request id (main pairs the reply back by it).
156    pub id: u64,
157    pub job: crate::sheets::project::LinesJob,
158}
159
160/// A completed [`SheetLinesRequest`]: the placement's lines relative to its
161/// position, or why the runner could not draw them (a handle its registry no
162/// longer holds, or holds different geometry under).
163#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
164pub struct SheetLinesReply {
165    /// The request id this answers.
166    pub id: u64,
167    pub key: String,
168    pub lines: Result<crate::sheets::project::ExactLines, String>,
169}
170
171/// Answer a sheet-lines request against the resident registry of the thread
172/// this runs on. Shared by every runner.
173fn sheet_lines_reply(request: SheetLinesRequest) -> SheetLinesReply {
174    SheetLinesReply {
175        id: request.id,
176        lines: crate::sheets::project::run_lines_job(&request.job),
177        key: request.job.key,
178    }
179}
180
181pub use brep_reconstruction::stl_conversion::{
182    ConversionPolicy, StlConversionOptions, StlConversionOutput,
183};
184
185/// Mesh encoding accepted by the off-thread reconstruction channel.
186#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
187pub enum MeshImportFormat {
188    Stl,
189    Obj,
190    /// 3MF (core specification): a package whose build instances are flattened
191    /// into one millimetre triangle soup before this common chain runs.
192    ThreeMf,
193}
194
195/// A mesh reconstruction request. The byte payload moves to the native thread
196/// or is serialized to the browser worker; no parsing or fitting happens on UI.
197#[derive(Debug, serde::Serialize, serde::Deserialize)]
198pub struct MeshImportRequest {
199    pub id: u64,
200    pub format: MeshImportFormat,
201    pub bytes: Vec<u8>,
202    pub options: StlConversionOptions,
203}
204
205/// Completed off-thread reconstruction. Success carries validated STEP and
206/// diagnostics for a preview or an ordinary IMPORT3D history insertion.
207#[derive(Debug, serde::Serialize, serde::Deserialize)]
208pub struct MeshImportReply {
209    pub id: u64,
210    pub result: Result<StlConversionOutput, String>,
211}
212
213/// The history-run seam: SUBMIT a run, POLL for its completed reply, RESET the
214/// delta baseline, plus a QUERY channel for per-object measurements (routed to the
215/// runner so the warm registry answers them, never the cold main-side one). The
216/// Inline impl runs everything synchronously; a later thread/worker impl defers
217/// the work and surfaces the replies through the same poll idiom.
218pub trait HistoryRunner {
219    /// Submit a history run tagged with a monotonic generation. The runner executes
220    /// it (immediately for Inline; on a background thread later) and makes the reply
221    /// available via [`poll_run`](Self::poll_run).
222    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64);
223    /// Non-blocking: the next completed run reply, if any (drained each frame).
224    fn poll_run(&mut self) -> Option<RunReply>;
225    /// Submit a per-object measurement query (answered against the runner's warm
226    /// registry). Inline computes it immediately; a thread impl computes it on the
227    /// runner thread and surfaces it via [`poll_query`](Self::poll_query).
228    fn submit_query(&mut self, query: MeasureQuery);
229    /// Non-blocking: the next completed measurement reply, if any.
230    fn poll_query(&mut self) -> Option<MeasureReply>;
231    /// Submit RANSAC mesh reconstruction to the runner's thread/worker.
232    fn submit_mesh_import(&mut self, request: MeshImportRequest);
233    /// Non-blocking: the next completed mesh reconstruction, if any.
234    fn poll_mesh_import(&mut self) -> Option<MeshImportReply>;
235    /// Submit a STEP product-structure probe (see [`StepProbeRequest`]).
236    fn submit_step_probe(&mut self, request: StepProbeRequest);
237    /// Non-blocking: the next completed STEP probe, if any.
238    fn poll_step_probe(&mut self) -> Option<StepProbeReply>;
239    /// Submit a topology request (see [`TopologyRequest`]). The default drops
240    /// it: a runner that never answers leaves a sheet on its mesh
241    /// approximation, which the drawing reports.
242    fn submit_topology(&mut self, _request: TopologyRequest) {}
243    /// Non-blocking: the next completed topology reply, if any.
244    fn poll_topology(&mut self) -> Option<TopologyReply> {
245        None
246    }
247    /// Submit a sheet placement's exact pass (see [`SheetLinesRequest`]).
248    /// `false` when this runner cannot run one — the engine then runs it on
249    /// its own thread, as every projection did before the pass moved off it.
250    fn submit_sheet_lines(&mut self, _request: SheetLinesRequest) -> bool {
251        false
252    }
253    /// Non-blocking: the next completed sheet-lines reply, if any.
254    fn poll_sheet_lines(&mut self) -> Option<SheetLinesReply> {
255        None
256    }
257    /// Drop the delta baseline (document switch → full rebuild).
258    fn reset(&mut self);
259
260    /// Bring the runner's resident PARTS LIBRARY up to `revision`, calling
261    /// `fetch` ONLY when it is not already there. Called immediately before
262    /// every [`submit_run`](Self::submit_run).
263    ///
264    /// This is the whole point of the library channel: the library is sent
265    /// when it CHANGES (insert, document load, refresh), not on every run.
266    /// Cheap to call — the revision comparison is an integer, and `fetch`
267    /// (which clones the store) never runs in the steady state. The default is
268    /// a no-op for [`InlineRunner`], which shares the caller's kernel store.
269    fn sync_parts_library(
270        &mut self,
271        _revision: u64,
272        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
273    ) {
274    }
275
276    /// True when the runner REFUSED a run because its resident parts library
277    /// could not serve it (see [`Reply::NeedPartsLibrary`]). It has already
278    /// forgotten its copy, so the next `sync_parts_library` reinstalls; the
279    /// caller must re-submit the run. Never true for a runner that shares the
280    /// caller's store.
281    fn poll_library_request(&mut self) -> bool {
282        false
283    }
284
285    /// Non-blocking: the MOST RECENT progress report of the in-flight run, with
286    /// any older ones discarded (only the latest feature matters). `None` for
287    /// a synchronous runner — Inline has finished before anyone could ask.
288    fn poll_progress(&mut self) -> Option<RunProgress> {
289        None
290    }
291
292    /// ABANDON the in-flight work and start over with an EMPTY resident
293    /// registry. Returns `false` when there is nothing this runner can abandon
294    /// (Inline: the run already completed on the caller's thread). A `true`
295    /// means every handle the caller holds is now invalid, no reply is coming
296    /// for anything submitted so far, and the parts library must be re-sent —
297    /// the caller reconciles its generations and pending sets accordingly
298    /// (`EngineState::cancel_run`). Nothing inside a feature is interruptible:
299    /// the thread runner lets its old thread finish the feature it is on and
300    /// stop at the next boundary; the browser worker is terminated outright.
301    fn cancel(&mut self) -> bool {
302        false
303    }
304}
305
306/// Measure `query` against `runner`'s resident geometry and emit the object-info
307/// JSON FRAGMENT — EXACTLY the fields [`crate::metadata::EngineState::object_info_json`]
308/// emits for that kind EXCEPT `name` and `creatingFeature` (the main thread injects
309/// those from its eager provenance, so the merged output is byte-identical to the
310/// pre-seam in-process result). Shared verbatim by the Inline and thread runners so
311/// both produce the identical fragment. A missing handle / kernel error yields
312/// `{ "ok": false, "message": .. }` (main injects `name`).
313fn measure_json(runner: &crate::pipeline::SceneRunner, query: &MeasureQuery) -> String {
314    let Some(handle) = runner.handle_of(&query.owner) else {
315        return serde_json::json!({
316            "ok": false,
317            "message": format!("solid '{}' has no resident geometry", query.owner),
318        })
319        .to_string();
320    };
321    match query.kind {
322        MeasureKind::Solid => {
323            match brep_kernel::mass_properties_handle_native(handle, query.density) {
324                Ok(properties) => {
325                    let edge_total =
326                        brep_kernel::solid_edge_length_total_native(handle).unwrap_or(0.0);
327                    serde_json::json!({
328                        "ok": true,
329                        "kind": "solid",
330                        "volume": properties.volume,
331                        "surfaceArea": properties.surface_area,
332                        "edgeLengthTotal": edge_total,
333                        "density": properties.density,
334                        "weight": properties.mass,
335                    })
336                    .to_string()
337                }
338                Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
339            }
340        }
341        MeasureKind::Face => match brep_kernel::face_measurements_native(handle, &query.entity) {
342            Ok((area, edge_total, surface_type)) => serde_json::json!({
343                "ok": true,
344                "kind": "face",
345                "solid": query.owner,
346                "surfaceType": surface_type,
347                "area": area,
348                "edgeLengthTotal": edge_total,
349            })
350            .to_string(),
351            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
352        },
353        MeasureKind::Edge => match brep_kernel::edge_length_native(handle, &query.entity) {
354            Ok(length) => serde_json::json!({
355                "ok": true,
356                "kind": "edge",
357                "solid": query.owner,
358                "length": length,
359            })
360            .to_string(),
361            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
362        },
363    }
364}
365
366/// The default, SYNCHRONOUS runner: runs on submit, stashes the reply for an
367/// immediate poll. Behavior-identical to the pre-seam in-process run.
368pub struct InlineRunner {
369    /// The scene-free history runner it owns (delta baseline lives here).
370    runner: crate::pipeline::SceneRunner,
371    /// Completed replies awaiting a poll. For Inline this holds exactly one entry
372    /// between a `submit_run` and the immediately-following `poll_run`.
373    pending: std::collections::VecDeque<RunReply>,
374    /// Completed measurement replies awaiting a poll (computed synchronously on
375    /// `submit_query`, popped on `poll_query`) — the synchronous mirror of the
376    /// thread runner's query buffer, so the object-info path resolves same-call.
377    query_pending: std::collections::VecDeque<MeasureReply>,
378    mesh_import_pending: std::collections::VecDeque<MeshImportReply>,
379    step_probe_pending: std::collections::VecDeque<StepProbeReply>,
380    topology_pending: std::collections::VecDeque<TopologyReply>,
381    sheet_lines_pending: std::collections::VecDeque<SheetLinesReply>,
382}
383
384impl InlineRunner {
385    pub fn new() -> Self {
386        Self {
387            runner: crate::pipeline::SceneRunner::new(),
388            pending: std::collections::VecDeque::new(),
389            query_pending: std::collections::VecDeque::new(),
390            mesh_import_pending: std::collections::VecDeque::new(),
391            step_probe_pending: std::collections::VecDeque::new(),
392            topology_pending: std::collections::VecDeque::new(),
393            sheet_lines_pending: std::collections::VecDeque::new(),
394        }
395    }
396}
397
398impl HistoryRunner for InlineRunner {
399    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
400        let output = self.runner.run(&request);
401        self.pending.push_back(RunReply { generation, output });
402    }
403
404    /// Nothing to send: Inline runs on the CALLER's thread against the CALLER's
405    /// kernel store, so the library the caller would hand over is already the
406    /// one this run resolves against. (Which is also why Inline never needs the
407    /// preflight — there is only one store, and the caller seeds it directly.)
408    fn sync_parts_library(
409        &mut self,
410        _revision: u64,
411        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
412    ) {
413    }
414
415    fn poll_run(&mut self) -> Option<RunReply> {
416        self.pending.pop_front()
417    }
418
419    fn submit_query(&mut self, query: MeasureQuery) {
420        let result = measure_json(&self.runner, &query);
421        self.query_pending.push_back(MeasureReply { id: query.id, result });
422    }
423
424    fn poll_query(&mut self) -> Option<MeasureReply> {
425        self.query_pending.pop_front()
426    }
427
428    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
429        self.mesh_import_pending
430            .push_back(reconstruct_mesh(request));
431    }
432
433    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
434        self.mesh_import_pending.pop_front()
435    }
436
437    fn submit_step_probe(&mut self, request: StepProbeRequest) {
438        self.step_probe_pending.push_back(probe_step(request));
439    }
440
441    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
442        self.step_probe_pending.pop_front()
443    }
444
445    fn submit_topology(&mut self, request: TopologyRequest) {
446        self.topology_pending.push_back(topology_reply(request));
447    }
448
449    fn poll_topology(&mut self) -> Option<TopologyReply> {
450        self.topology_pending.pop_front()
451    }
452
453    /// Runs the pass at once, on the caller's thread: an inline runner's
454    /// registry IS the caller's, so the answer is ready for the next poll.
455    fn submit_sheet_lines(&mut self, request: SheetLinesRequest) -> bool {
456        self.sheet_lines_pending.push_back(sheet_lines_reply(request));
457        true
458    }
459
460    fn poll_sheet_lines(&mut self) -> Option<SheetLinesReply> {
461        self.sheet_lines_pending.pop_front()
462    }
463
464    fn reset(&mut self) {
465        self.runner.reset();
466    }
467}
468
469impl Default for InlineRunner {
470    fn default() -> Self {
471        Self::new()
472    }
473}
474
475// ===========================================================================
476// Shared run/query PROTOCOL (M3b): the `Command`/`Reply` message pair and the
477// `process_command` step. Both async runners speak it — the native `ThreadRunner`
478// ships the enums over `mpsc` (no serialization), and the wasm `WorkerRunner`
479// serializes them to JSON for `postMessage`. Hence the serde derives (the enums
480// carry serde types: `HistoryRequest`/`MeasureQuery`/`RunReply`/`MeasureReply`)
481// and `process_command` being un-gated + shared so both drivers do the SAME work.
482// ===========================================================================
483
484/// A command sent main → runner (thread channel OR worker `postMessage`) over one
485/// ordered stream (so a `Reset` before a `Run` stays before it). `Run` carries the
486/// whole request; the driver coalesces consecutive `Run`s to shed a slider drag's
487/// backlog (the thread in `thread_main`, the worker's main side in `WorkerRunner`).
488#[derive(serde::Serialize, serde::Deserialize)]
489pub enum Command {
490    Run {
491        request: brep_kernel::HistoryRequest,
492        generation: u64,
493        /// The [`brep_kernel::parts_library_revision`] this run was built
494        /// against. The runner refuses to execute a run stamped for a library
495        /// it does not hold — see [`process_command`].
496        #[serde(default)]
497        parts_library_revision: u64,
498    },
499    /// Install the parts library on the runner's OWN kernel store. Sent only
500    /// when the library actually changes (insert, document load, refresh),
501    /// never per run: it carries the whole embedded-part payload, which for an
502    /// imported STEP assembly is megabytes, and stringifying that on the
503    /// browser's main thread once per edit is what froze the UI.
504    ///
505    /// Deliberately its OWN command rather than an optional field on `Run`:
506    /// both drivers COALESCE consecutive runs (see [`thread_main`] and
507    /// `WorkerRunner::submit_run`), so a library riding on a run could be
508    /// dropped with it. A `SetPartsLibrary` is never coalesced away, and the
509    /// stream is ordered, so it always lands before the run that needs it.
510    SetPartsLibrary {
511        library: brep_kernel::PartsLibraryMap,
512        revision: u64,
513    },
514    Query(MeasureQuery),
515    MeshImport(MeshImportRequest),
516    StepProbe(StepProbeRequest),
517    Topology(TopologyRequest),
518    SheetLines(SheetLinesRequest),
519    Reset,
520}
521
522/// A reply sent runner → main; the main side demuxes it into per-kind buffers.
523#[derive(serde::Serialize, serde::Deserialize)]
524pub enum Reply {
525    Run(RunReply),
526    Query(MeasureReply),
527    MeshImport(MeshImportReply),
528    StepProbe(StepProbeReply),
529    Topology(TopologyReply),
530    SheetLines(SheetLinesReply),
531    /// The runner REFUSED a run because its resident parts library could not
532    /// serve it (a part the request references is missing, or the run was
533    /// stamped for a different library revision). No geometry was touched; the
534    /// main side re-sends the library and re-submits. Refusing is the whole
535    /// point — running with the wrong library would be silently wrong
536    /// geometry, which is far worse than one extra round trip.
537    NeedPartsLibrary,
538    /// A feature of the in-flight run is about to execute (see
539    /// [`RunProgress`]). Posted from INSIDE `process_command`, before its
540    /// `Reply::Run`; the main side keeps only the latest.
541    Progress(RunProgress),
542}
543
544/// The probe itself — the one parse of a structured STEP import (see
545/// `EngineState::submit_step_probe`), run wherever the runner runs.
546fn probe_step(request: StepProbeRequest) -> StepProbeReply {
547    StepProbeReply {
548        id: request.id,
549        result: brep_kernel::read_step_assembly(&request.text),
550    }
551}
552
553fn reconstruct_mesh(request: MeshImportRequest) -> MeshImportReply {
554    let result = (|| {
555        use brep_reconstruction::stl_conversion::{
556            binary_stl_coordinate_precision_tolerance, convert_stl_mesh_to_step,
557        };
558        use brep_reconstruction::{Mesh, Vec3};
559
560        // A 3MF build's placements, kept only to name each body afterwards.
561        let mut instances = Vec::new();
562        let (mesh, positions, indices, coordinate_precision_tolerance) = match request.format {
563            MeshImportFormat::Stl => {
564                use brep_reconstruction::stl::{parse_stl_bytes, StlFormat, StlReadOptions};
565                let read_options = StlReadOptions {
566                    weld_tolerance: (request.options.weld_tolerance >= 0.0)
567                        .then_some(request.options.weld_tolerance),
568                };
569                let imported = parse_stl_bytes(&request.bytes, &read_options)
570                    .map_err(|error| format!("STL import failed: {error}"))?;
571                let positions = imported
572                    .mesh
573                    .vertices
574                    .iter()
575                    .flat_map(|point| [point.x, point.y, point.z])
576                    .collect::<Vec<_>>();
577                let indices = imported
578                    .mesh
579                    .triangles
580                    .iter()
581                    .flatten()
582                    .copied()
583                    .collect::<Vec<_>>();
584                let precision = if imported.format == StlFormat::Binary {
585                    binary_stl_coordinate_precision_tolerance(&imported.mesh)
586                } else {
587                    0.0
588                };
589                (imported.mesh, positions, indices, precision)
590            }
591            MeshImportFormat::Obj => {
592                let text = std::str::from_utf8(&request.bytes)
593                    .map_err(|_| "OBJ import failed: file is not UTF-8 text".to_string())?;
594                let obj = brep_kernel::read_obj(text)
595                    .map_err(|error| format!("OBJ import failed: {error}"))?;
596                let vertices = obj
597                    .positions
598                    .chunks_exact(3)
599                    .map(|point| Vec3::new(point[0], point[1], point[2]))
600                    .collect::<Vec<_>>();
601                let triangles = obj
602                    .indices
603                    .chunks_exact(3)
604                    .map(|triangle| [triangle[0], triangle[1], triangle[2]])
605                    .collect::<Vec<_>>();
606                (
607                    Mesh::new(vertices, triangles),
608                    obj.positions,
609                    obj.indices,
610                    0.0,
611                )
612            }
613            // The 3MF reader has already applied the file's unit and every
614            // build/component transform, so what arrives here is the same kind
615            // of millimetre triangle soup an STL carries — including a build
616            // that places SEVERAL instances, which is several bodies in that
617            // one buffer. The chain splits a soup into its vertex-connected
618            // components and reconstructs each as its own body, so the whole
619            // build goes through in ONE call and the poses are already in the
620            // coordinates.
621            MeshImportFormat::ThreeMf => {
622                let model = brep_kernel::read_3mf(&request.bytes)
623                    .map_err(|error| format!("3MF import failed: {error}"))?;
624                instances = model.instances;
625                let vertices = model
626                    .positions
627                    .chunks_exact(3)
628                    .map(|point| Vec3::new(point[0], point[1], point[2]))
629                    .collect::<Vec<_>>();
630                let triangles = model
631                    .indices
632                    .chunks_exact(3)
633                    .map(|triangle| [triangle[0], triangle[1], triangle[2]])
634                    .collect::<Vec<_>>();
635                (
636                    Mesh::new(vertices, triangles),
637                    model.positions,
638                    model.indices,
639                    0.0,
640                )
641            }
642        };
643        let mut options = request.options;
644        options.coordinate_precision_tolerance = options.coordinate_precision_tolerance
645            .max(coordinate_precision_tolerance);
646        convert_stl_mesh_to_step(
647            &mesh,
648            &positions,
649            Some(&indices),
650            &options,
651            "Imported mesh",
652            "MM",
653            "",
654        )
655        .map(|mut output| {
656            name_bodies(&mut output.report, &instances);
657            output
658        })
659        .map_err(|error| format!("RANSAC reconstruction failed: {error}"))
660    })();
661    MeshImportReply {
662        id: request.id,
663        result,
664    }
665}
666
667/// Give each body the name of the 3MF object its first source triangle came
668/// from, so a refusal can say WHICH object is missing. Bodies are found in the
669/// flattened soup the reader laid out instance by instance.
670fn name_bodies(
671    report: &mut brep_reconstruction::stl_conversion::StlConversionReport,
672    instances: &[brep_kernel::ThreeMfInstance],
673) {
674    for component in &mut report.components {
675        component.source_name = instances
676            .iter()
677            .find(|instance| {
678                (instance.first_triangle..instance.first_triangle + instance.triangle_count)
679                    .contains(&component.first_triangle)
680            })
681            .and_then(|instance| instance.name.clone());
682    }
683}
684
685/// The notice for bodies a multi-body import left out, one clause per body,
686/// named by the source's own name when it has one; `None` when every body was
687/// built.
688pub(crate) fn refused_bodies_notice(
689    report: &brep_reconstruction::stl_conversion::StlConversionReport,
690) -> Option<String> {
691    let refused = report
692        .components
693        .iter()
694        .filter_map(|component| {
695            component.refusal.as_ref().map(|reason| {
696                let body = match &component.source_name {
697                    Some(name) => format!("body {} \"{name}\"", component.index + 1),
698                    None => format!(
699                        "body {} (from source triangle {})",
700                        component.index + 1,
701                        component.first_triangle
702                    ),
703                };
704                format!("{body}: {reason}")
705            })
706        })
707        .collect::<Vec<_>>();
708    (!refused.is_empty()).then(|| {
709        format!(
710            "mesh import: {} of {} bodies could not be reconstructed and are NOT in the model — {}",
711            refused.len(),
712            report.components.len(),
713            refused.join("; ")
714        )
715    })
716}
717
718/// Execute ONE [`Command`] against `runner` and return the [`Reply`] it produces,
719/// if any. The shared step both async drivers run: the native [`thread_main`]
720/// calls it for each (post-coalescing) command on the runner thread; the wasm
721/// `WorkerRunner`'s `worker_entry` calls it per `postMessage` on the worker.
722/// `Run` → a [`Reply::Run`]; `Query` → a [`Reply::Query`]; `Reset` drops the delta
723/// baseline AND the runner's OWN kernel history cache (the resident registry lives
724/// with the runner — thread or worker — not on main) and yields no reply.
725///
726/// `progress` is called before every feature a `Run` actually executes, with
727/// the report the driver should ship as [`Reply::Progress`]; returning `false`
728/// stops the run at that boundary (the native thread's cooperative cancel —
729/// the worker is terminated instead and always returns `true`).
730pub fn process_command(
731    runner: &mut crate::pipeline::SceneRunner,
732    command: Command,
733    progress: &mut dyn FnMut(RunProgress) -> bool,
734) -> Option<Reply> {
735    match command {
736        Command::Run {
737            request,
738            generation,
739            parts_library_revision,
740        } => {
741            // PREFLIGHT (see `Reply::NeedPartsLibrary`). Two independent
742            // checks, because neither alone is enough:
743            //
744            // * the revision stamp catches CHANGED content the sender knows
745            //   about but this store has not received;
746            // * `missing_library_parts` catches content that is simply GONE
747            //   here — the orphan GC at the end of every run drops entries no
748            //   ACOMP in THAT run referenced, so an undo to zero components
749            //   empties this store while the sender's (which never ran) keeps
750            //   everything and its revision never moves. Only looking at the
751            //   actual content sees that.
752            let stale = match runner.parts_library_revision {
753                Some(installed) => installed != parts_library_revision,
754                // Nothing installed yet. Accept a run stamped 0 — that is
755                // either an empty library or a caller driving `submit_run`
756                // directly (the runner tests); the content preflight below is
757                // what actually protects the run. A non-zero stamp with nothing
758                // installed IS a gap: refuse it.
759                None => parts_library_revision != 0,
760            };
761            // Only a part the sender DID send and this store has since lost is
762            // worth asking for again. One it never sent is a dangling reference
763            // in the document itself: run it, so the ACOMP feature reports it
764            // the way it always has. (An install inserts every incoming name,
765            // so right after one this set can only be empty — which is what
766            // makes the ask-and-retry terminate.)
767            let recoverable = brep_kernel::missing_library_parts(&request)
768                .iter()
769                .any(|name| runner.parts_library_names.contains(name));
770            if stale || recoverable {
771                runner.parts_library_revision = None;
772                return Some(Reply::NeedPartsLibrary);
773            }
774            let output = runner.run_observed(&request, &mut |event| {
775                progress(RunProgress {
776                    generation,
777                    index: event.index,
778                    total: event.total,
779                    feature_id: event.id.to_string(),
780                    feature_type: event.feature_type.to_string(),
781                })
782            });
783            Some(Reply::Run(RunReply { generation, output }))
784        }
785        Command::SetPartsLibrary { library, revision } => {
786            runner.parts_library_names = library.keys().cloned().collect();
787            brep_kernel::install_parts_library(&library);
788            runner.parts_library_revision = Some(revision);
789            None
790        }
791        Command::Query(query) => {
792            let result = measure_json(runner, &query);
793            Some(Reply::Query(MeasureReply { id: query.id, result }))
794        }
795        Command::MeshImport(request) => Some(Reply::MeshImport(reconstruct_mesh(request))),
796        Command::StepProbe(request) => Some(Reply::StepProbe(probe_step(request))),
797        Command::Topology(request) => Some(Reply::Topology(topology_reply(request))),
798        Command::SheetLines(request) => Some(Reply::SheetLines(sheet_lines_reply(request))),
799        Command::Reset => {
800            // A document switch: drop the delta baseline AND this runner's OWN kernel
801            // history cache (the resident registry lives here, not on main), mirroring
802            // `set_history_json`'s main-thread clear so a new model rebuilds fully and
803            // the old model's handles are freed.
804            runner.reset();
805            brep_kernel::clear_history_cache();
806            None
807        }
808    }
809}
810
811// ===========================================================================
812// ThreadRunner (M2b): a persistent std::thread that OWNS the SceneRunner, so a
813// history run — and per-object measurement queries — execute OFF the main thread
814// and the native UI stays responsive during a run AND during selection. Native
815// only: `std::thread` + `std::sync::mpsc` do not exist on wasm32 (M3b lands a
816// worker impl behind this same trait), so the whole thing is cfg-gated out there.
817// ===========================================================================
818
819/// The persistent-thread runner. `submit_*`/`reset` push [`Command`]s down the
820/// channel; `poll_*` first DRAIN every ready [`Reply`] into the two demux buffers,
821/// then pop the matching one. The `SceneRunner` (and thus the kernel's resident
822/// registry it warms) lives ENTIRELY on the thread — it is never shared — so the
823/// only cross-thread traffic is the `Send` command/reply payloads.
824#[cfg(not(target_arch = "wasm32"))]
825pub struct ThreadRunner {
826    /// Main → thread. `Option` so [`Drop`] can take + drop it, ending the thread's
827    /// blocking `recv` after already-submitted work finishes.
828    tx: Option<std::sync::mpsc::Sender<Command>>,
829    /// Thread → main.
830    rx: std::sync::mpsc::Receiver<Reply>,
831    /// Dropped without joining so closing a busy preview never stalls the UI.
832    handle: Option<std::thread::JoinHandle<()>>,
833    /// Demuxed completed run replies awaiting `poll_run`.
834    run_buf: std::collections::VecDeque<RunReply>,
835    /// Demuxed completed measurement replies awaiting `poll_query`.
836    query_buf: std::collections::VecDeque<MeasureReply>,
837    mesh_import_buf: std::collections::VecDeque<MeshImportReply>,
838    step_probe_buf: std::collections::VecDeque<StepProbeReply>,
839    topology_buf: std::collections::VecDeque<TopologyReply>,
840    sheet_lines_buf: std::collections::VecDeque<SheetLinesReply>,
841    /// Progress reports of the in-flight run, oldest first (`poll_progress`
842    /// keeps the newest).
843    progress_buf: std::collections::VecDeque<RunProgress>,
844    /// The parts-library revision last SENT down the channel (`None` = never,
845    /// or the thread dropped it). Lives here rather than on the caller so
846    /// "reset forgets the library" is a local property of this object.
847    sent_library_revision: Option<u64>,
848    /// The thread refused a run for want of its library (drained by
849    /// [`HistoryRunner::poll_library_request`]).
850    library_requested: bool,
851    /// The cooperative stop flag THIS thread checks between features. Each
852    /// spawn gets its own, so a cancelled thread keeps its raised flag while
853    /// the replacement starts clean.
854    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
855}
856
857#[cfg(not(target_arch = "wasm32"))]
858impl ThreadRunner {
859    pub fn new() -> Self {
860        let (tx, cmd_rx) = std::sync::mpsc::channel::<Command>();
861        let (reply_tx, rx) = std::sync::mpsc::channel::<Reply>();
862        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
863        let thread_stop = stop.clone();
864        let handle = std::thread::Builder::new()
865            .name("brep-history-runner".to_string())
866            .spawn(move || thread_main(cmd_rx, reply_tx, thread_stop))
867            .expect("spawn brep-history-runner thread");
868        Self {
869            tx: Some(tx),
870            rx,
871            handle: Some(handle),
872            run_buf: std::collections::VecDeque::new(),
873            query_buf: std::collections::VecDeque::new(),
874            mesh_import_buf: std::collections::VecDeque::new(),
875            step_probe_buf: std::collections::VecDeque::new(),
876            topology_buf: std::collections::VecDeque::new(),
877            sheet_lines_buf: std::collections::VecDeque::new(),
878            progress_buf: std::collections::VecDeque::new(),
879            sent_library_revision: None,
880            library_requested: false,
881            stop,
882        }
883    }
884
885    /// Pull every ready reply off the channel and demux it into the run/query
886    /// buffers (so a `poll_run` never swallows a query reply and vice versa).
887    fn drain(&mut self) {
888        while let Ok(reply) = self.rx.try_recv() {
889            match reply {
890                Reply::Run(run) => self.run_buf.push_back(run),
891                Reply::Query(query) => self.query_buf.push_back(query),
892                Reply::MeshImport(reply) => self.mesh_import_buf.push_back(reply),
893                Reply::StepProbe(reply) => self.step_probe_buf.push_back(reply),
894                Reply::Topology(reply) => self.topology_buf.push_back(reply),
895                Reply::SheetLines(reply) => self.sheet_lines_buf.push_back(reply),
896                Reply::Progress(progress) => self.progress_buf.push_back(progress),
897                Reply::NeedPartsLibrary => {
898                    // The thread dropped (or never had) the library this run
899                    // needs. Forget what we believe it holds so the next
900                    // `sync_parts_library` re-installs, and flag the refused
901                    // run for the caller to re-submit.
902                    self.sent_library_revision = None;
903                    self.library_requested = true;
904                }
905            }
906        }
907    }
908}
909
910#[cfg(not(target_arch = "wasm32"))]
911impl Default for ThreadRunner {
912    fn default() -> Self {
913        Self::new()
914    }
915}
916
917/// The runner thread's whole life: block on the next command, batch it with every
918/// other command already queued, COALESCE consecutive `Run`s (a `Run` immediately
919/// followed by another `Run` — with no `Query`/`Reset` between — is dropped; only
920/// the last of each consecutive group runs), then process the batch IN ORDER so a
921/// `Reset` or a `Query` interleaved between two runs keeps its place. Exits when
922/// the command sender is dropped (`recv` errors) or the reply receiver is gone (a
923/// `send` errors — the main side went away).
924///
925/// `stop` is the cooperative cancel: raised by [`ThreadRunner::cancel`] on a
926/// thread that has already been abandoned, it is checked before every feature
927/// a run executes, so the abandoned thread finishes the feature it is on and
928/// exits at the next boundary instead of running the rest of the history for
929/// a receiver that is gone.
930#[cfg(not(target_arch = "wasm32"))]
931fn thread_main(
932    cmd_rx: std::sync::mpsc::Receiver<Command>,
933    reply_tx: std::sync::mpsc::Sender<Reply>,
934    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
935) {
936    let mut runner = crate::pipeline::SceneRunner::new();
937    while let Ok(first) = cmd_rx.recv() {
938        if stop.load(std::sync::atomic::Ordering::Relaxed) {
939            return;
940        }
941        // Gather this command plus everything else already waiting.
942        let mut batch = vec![first];
943        loop {
944            match cmd_rx.try_recv() {
945                Ok(command) => batch.push(command),
946                Err(_) => break, // Empty or Disconnected — process what we have.
947            }
948        }
949        // A Run immediately followed by another Run is coalesced away (its result
950        // would be overwritten before anything observed it); a Run followed by a
951        // Query/Reset/end still runs, so interleaved work sees the right geometry.
952        let mut run_here: Vec<bool> = vec![true; batch.len()];
953        for i in 0..batch.len() {
954            if matches!(batch[i], Command::Run { .. })
955                && matches!(batch.get(i + 1), Some(Command::Run { .. }))
956            {
957                run_here[i] = false;
958            }
959        }
960        // Process the KEPT commands in order through the SHARED `process_command`
961        // (byte-identical work to the wasm worker's per-message step), sending each
962        // reply it produces; a coalesced-away Run is skipped without touching the
963        // runner, so interleaved Query/Reset still see the right geometry.
964        for (i, command) in batch.into_iter().enumerate() {
965            if !run_here[i] {
966                continue;
967            }
968            let mut progress = |report: RunProgress| {
969                // A lost receiver means the main side abandoned this thread:
970                // stop at this boundary rather than finishing for nobody.
971                reply_tx.send(Reply::Progress(report)).is_ok()
972                    && !stop.load(std::sync::atomic::Ordering::Relaxed)
973            };
974            if let Some(reply) = process_command(&mut runner, command, &mut progress) {
975                if reply_tx.send(reply).is_err() {
976                    return; // The reply receiver is gone — the main side went away.
977                }
978            }
979        }
980    }
981}
982
983#[cfg(not(target_arch = "wasm32"))]
984impl HistoryRunner for ThreadRunner {
985    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
986        if let Some(tx) = &self.tx {
987            let _ = tx.send(Command::Run {
988                request,
989                generation,
990                parts_library_revision: self.sent_library_revision.unwrap_or(0),
991            });
992        }
993    }
994
995    fn sync_parts_library(
996        &mut self,
997        revision: u64,
998        fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
999    ) {
1000        if self.sent_library_revision == Some(revision) {
1001            return;
1002        }
1003        if let Some(tx) = &self.tx {
1004            let _ = tx.send(Command::SetPartsLibrary {
1005                library: fetch(),
1006                revision,
1007            });
1008            self.sent_library_revision = Some(revision);
1009        }
1010    }
1011
1012    fn poll_library_request(&mut self) -> bool {
1013        self.drain();
1014        std::mem::take(&mut self.library_requested)
1015    }
1016
1017    fn poll_run(&mut self) -> Option<RunReply> {
1018        self.drain();
1019        self.run_buf.pop_front()
1020    }
1021
1022    fn submit_query(&mut self, query: MeasureQuery) {
1023        if let Some(tx) = &self.tx {
1024            let _ = tx.send(Command::Query(query));
1025        }
1026    }
1027
1028    fn poll_query(&mut self) -> Option<MeasureReply> {
1029        self.drain();
1030        self.query_buf.pop_front()
1031    }
1032
1033    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
1034        if let Some(tx) = &self.tx {
1035            let _ = tx.send(Command::MeshImport(request));
1036        }
1037    }
1038
1039    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
1040        self.drain();
1041        self.mesh_import_buf.pop_front()
1042    }
1043
1044    fn submit_step_probe(&mut self, request: StepProbeRequest) {
1045        if let Some(tx) = &self.tx {
1046            let _ = tx.send(Command::StepProbe(request));
1047        }
1048    }
1049
1050    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
1051        self.drain();
1052        self.step_probe_buf.pop_front()
1053    }
1054
1055    fn submit_topology(&mut self, request: TopologyRequest) {
1056        if let Some(tx) = &self.tx {
1057            let _ = tx.send(Command::Topology(request));
1058        }
1059    }
1060
1061    fn poll_topology(&mut self) -> Option<TopologyReply> {
1062        self.drain();
1063        self.topology_buf.pop_front()
1064    }
1065
1066    fn submit_sheet_lines(&mut self, request: SheetLinesRequest) -> bool {
1067        match &self.tx {
1068            Some(tx) => tx.send(Command::SheetLines(request)).is_ok(),
1069            None => false,
1070        }
1071    }
1072
1073    fn poll_sheet_lines(&mut self) -> Option<SheetLinesReply> {
1074        self.drain();
1075        self.sheet_lines_buf.pop_front()
1076    }
1077
1078    fn poll_progress(&mut self) -> Option<RunProgress> {
1079        self.drain();
1080        let latest = self.progress_buf.pop_back();
1081        self.progress_buf.clear();
1082        latest
1083    }
1084
1085    /// Abandon the thread: raise its stop flag, close its channels, and spawn a
1086    /// fresh thread with a fresh registry. The old thread cannot be interrupted
1087    /// inside a feature — it finishes the one it is on (burning CPU until
1088    /// then), sees the flag, and exits. Always `true`: there is always a
1089    /// thread to replace.
1090    fn cancel(&mut self) -> bool {
1091        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
1092        self.tx.take();
1093        self.handle.take();
1094        *self = Self::new();
1095        true
1096    }
1097
1098    fn reset(&mut self) {
1099        if let Some(tx) = &self.tx {
1100            let _ = tx.send(Command::Reset);
1101        }
1102        // A reset is a wholesale document switch: any replies still buffered from
1103        // the old model are stale — drop them (a fresh run/query supersedes).
1104        self.run_buf.clear();
1105        self.query_buf.clear();
1106        self.mesh_import_buf.clear();
1107        self.step_probe_buf.clear();
1108        self.topology_buf.clear();
1109        self.sheet_lines_buf.clear();
1110        self.progress_buf.clear();
1111        // `Command::Reset` clears the thread's kernel store, library included,
1112        // so what we believe it holds is void.
1113        self.sent_library_revision = None;
1114        self.library_requested = false;
1115    }
1116}
1117
1118#[cfg(not(target_arch = "wasm32"))]
1119impl Drop for ThreadRunner {
1120    fn drop(&mut self) {
1121        // Closing a document or cancelling an import preview must not block
1122        // the UI on reconstruction. Closing the channel lets the worker exit
1123        // and release its registry once already-submitted work finishes.
1124        self.tx.take();
1125        self.handle.take();
1126    }
1127}
1128