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/// Which exact measurement a [`MeasureQuery`] wants, mirroring the object-info
34/// kinds ([`crate::metadata`]): a whole solid, one named face, or one named edge.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub enum MeasureKind {
37    Solid,
38    Face,
39    Edge,
40}
41
42/// A per-object MEASUREMENT request routed to the runner (which owns the warm
43/// registry). The runner resolves `owner` to its resident handle and measures by
44/// [`kind`](Self::kind); the reply is the object-info JSON fragment MINUS the
45/// main-injected `name`/`creatingFeature` fields. Tagged with a monotonic
46/// [`id`](Self::id) so the main side can pair the reply with its pending request.
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct MeasureQuery {
49    /// Monotonic request id (main pairs the reply back by it).
50    pub id: u64,
51    /// The measurement to run.
52    pub kind: MeasureKind,
53    /// The OWNING solid name (a solid is its own owner; a face/edge names its solid).
54    pub owner: String,
55    /// The face/edge NAME to measure (ignored for a whole-solid query).
56    pub entity: String,
57    /// The density (mass per mm³) to scale a solid's weight (ignored for face/edge).
58    pub density: f64,
59}
60
61/// A completed [`MeasureQuery`]: the object-info measurement fields as a JSON
62/// fragment (WITHOUT `name`/`creatingFeature`, which the main thread injects from
63/// its eager provenance), tagged with the request [`id`](Self::id).
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct MeasureReply {
66    /// The request id this answers.
67    pub id: u64,
68    /// The measurement JSON fragment (see [`measure_json`]).
69    pub result: String,
70}
71
72/// Mesh encoding accepted by the off-thread reconstruction channel.
73#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
74pub enum MeshImportFormat {
75    Stl,
76    Obj,
77}
78
79/// A mesh reconstruction request. The byte payload moves to the native thread
80/// or is serialized to the browser worker; no parsing or fitting happens on UI.
81#[derive(Debug, serde::Serialize, serde::Deserialize)]
82pub struct MeshImportRequest {
83    pub id: u64,
84    pub format: MeshImportFormat,
85    pub bytes: Vec<u8>,
86}
87
88/// Completed off-thread reconstruction. Success carries validated STEP text,
89/// which the main side appends through the ordinary IMPORT3D history lane.
90#[derive(Debug, serde::Serialize, serde::Deserialize)]
91pub struct MeshImportReply {
92    pub id: u64,
93    pub result: Result<String, String>,
94}
95
96/// The history-run seam: SUBMIT a run, POLL for its completed reply, RESET the
97/// delta baseline, plus a QUERY channel for per-object measurements (routed to the
98/// runner so the warm registry answers them, never the cold main-side one). The
99/// Inline impl runs everything synchronously; a later thread/worker impl defers
100/// the work and surfaces the replies through the same poll idiom.
101pub trait HistoryRunner {
102    /// Submit a history run tagged with a monotonic generation. The runner executes
103    /// it (immediately for Inline; on a background thread later) and makes the reply
104    /// available via [`poll_run`](Self::poll_run).
105    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64);
106    /// Non-blocking: the next completed run reply, if any (drained each frame).
107    fn poll_run(&mut self) -> Option<RunReply>;
108    /// Submit a per-object measurement query (answered against the runner's warm
109    /// registry). Inline computes it immediately; a thread impl computes it on the
110    /// runner thread and surfaces it via [`poll_query`](Self::poll_query).
111    fn submit_query(&mut self, query: MeasureQuery);
112    /// Non-blocking: the next completed measurement reply, if any.
113    fn poll_query(&mut self) -> Option<MeasureReply>;
114    /// Submit RANSAC mesh reconstruction to the runner's thread/worker.
115    fn submit_mesh_import(&mut self, request: MeshImportRequest);
116    /// Non-blocking: the next completed mesh reconstruction, if any.
117    fn poll_mesh_import(&mut self) -> Option<MeshImportReply>;
118    /// Drop the delta baseline (document switch → full rebuild).
119    fn reset(&mut self);
120
121    /// Bring the runner's resident PARTS LIBRARY up to `revision`, calling
122    /// `fetch` ONLY when it is not already there. Called immediately before
123    /// every [`submit_run`](Self::submit_run).
124    ///
125    /// This is the whole point of the library channel: the library is sent
126    /// when it CHANGES (insert, document load, refresh), not on every run.
127    /// Cheap to call — the revision comparison is an integer, and `fetch`
128    /// (which clones the store) never runs in the steady state. The default is
129    /// a no-op for [`InlineRunner`], which shares the caller's kernel store.
130    fn sync_parts_library(
131        &mut self,
132        _revision: u64,
133        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
134    ) {
135    }
136
137    /// True when the runner REFUSED a run because its resident parts library
138    /// could not serve it (see [`Reply::NeedPartsLibrary`]). It has already
139    /// forgotten its copy, so the next `sync_parts_library` reinstalls; the
140    /// caller must re-submit the run. Never true for a runner that shares the
141    /// caller's store.
142    fn poll_library_request(&mut self) -> bool {
143        false
144    }
145}
146
147/// Measure `query` against `runner`'s resident geometry and emit the object-info
148/// JSON FRAGMENT — EXACTLY the fields [`crate::metadata::EngineState::object_info_json`]
149/// emits for that kind EXCEPT `name` and `creatingFeature` (the main thread injects
150/// those from its eager provenance, so the merged output is byte-identical to the
151/// pre-seam in-process result). Shared verbatim by the Inline and thread runners so
152/// both produce the identical fragment. A missing handle / kernel error yields
153/// `{ "ok": false, "message": .. }` (main injects `name`).
154fn measure_json(runner: &crate::pipeline::SceneRunner, query: &MeasureQuery) -> String {
155    let Some(handle) = runner.handle_of(&query.owner) else {
156        return serde_json::json!({
157            "ok": false,
158            "message": format!("solid '{}' has no resident geometry", query.owner),
159        })
160        .to_string();
161    };
162    match query.kind {
163        MeasureKind::Solid => {
164            match brep_kernel::mass_properties_handle_native(handle, query.density) {
165                Ok(properties) => {
166                    let edge_total =
167                        brep_kernel::solid_edge_length_total_native(handle).unwrap_or(0.0);
168                    serde_json::json!({
169                        "ok": true,
170                        "kind": "solid",
171                        "volume": properties.volume,
172                        "surfaceArea": properties.surface_area,
173                        "edgeLengthTotal": edge_total,
174                        "density": properties.density,
175                        "weight": properties.mass,
176                    })
177                    .to_string()
178                }
179                Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
180            }
181        }
182        MeasureKind::Face => match brep_kernel::face_measurements_native(handle, &query.entity) {
183            Ok((area, edge_total, surface_type)) => serde_json::json!({
184                "ok": true,
185                "kind": "face",
186                "solid": query.owner,
187                "surfaceType": surface_type,
188                "area": area,
189                "edgeLengthTotal": edge_total,
190            })
191            .to_string(),
192            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
193        },
194        MeasureKind::Edge => match brep_kernel::edge_length_native(handle, &query.entity) {
195            Ok(length) => serde_json::json!({
196                "ok": true,
197                "kind": "edge",
198                "solid": query.owner,
199                "length": length,
200            })
201            .to_string(),
202            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
203        },
204    }
205}
206
207/// The default, SYNCHRONOUS runner: runs on submit, stashes the reply for an
208/// immediate poll. Behavior-identical to the pre-seam in-process run.
209pub struct InlineRunner {
210    /// The scene-free history runner it owns (delta baseline lives here).
211    runner: crate::pipeline::SceneRunner,
212    /// Completed replies awaiting a poll. For Inline this holds exactly one entry
213    /// between a `submit_run` and the immediately-following `poll_run`.
214    pending: std::collections::VecDeque<RunReply>,
215    /// Completed measurement replies awaiting a poll (computed synchronously on
216    /// `submit_query`, popped on `poll_query`) — the synchronous mirror of the
217    /// thread runner's query buffer, so the object-info path resolves same-call.
218    query_pending: std::collections::VecDeque<MeasureReply>,
219    mesh_import_pending: std::collections::VecDeque<MeshImportReply>,
220}
221
222impl InlineRunner {
223    pub fn new() -> Self {
224        Self {
225            runner: crate::pipeline::SceneRunner::new(),
226            pending: std::collections::VecDeque::new(),
227            query_pending: std::collections::VecDeque::new(),
228            mesh_import_pending: std::collections::VecDeque::new(),
229        }
230    }
231}
232
233impl HistoryRunner for InlineRunner {
234    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
235        let output = self.runner.run(&request, None);
236        self.pending.push_back(RunReply { generation, output });
237    }
238
239    /// Nothing to send: Inline runs on the CALLER's thread against the CALLER's
240    /// kernel store, so the library the caller would hand over is already the
241    /// one this run resolves against. (Which is also why Inline never needs the
242    /// preflight — there is only one store, and the caller seeds it directly.)
243    fn sync_parts_library(
244        &mut self,
245        _revision: u64,
246        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
247    ) {
248    }
249
250    fn poll_run(&mut self) -> Option<RunReply> {
251        self.pending.pop_front()
252    }
253
254    fn submit_query(&mut self, query: MeasureQuery) {
255        let result = measure_json(&self.runner, &query);
256        self.query_pending.push_back(MeasureReply { id: query.id, result });
257    }
258
259    fn poll_query(&mut self) -> Option<MeasureReply> {
260        self.query_pending.pop_front()
261    }
262
263    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
264        self.mesh_import_pending
265            .push_back(reconstruct_mesh(request));
266    }
267
268    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
269        self.mesh_import_pending.pop_front()
270    }
271
272    fn reset(&mut self) {
273        self.runner.reset();
274    }
275}
276
277impl Default for InlineRunner {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283// ===========================================================================
284// Shared run/query PROTOCOL (M3b): the `Command`/`Reply` message pair and the
285// `process_command` step. Both async runners speak it — the native `ThreadRunner`
286// ships the enums over `mpsc` (no serialization), and the wasm `WorkerRunner`
287// serializes them to JSON for `postMessage`. Hence the serde derives (the enums
288// carry serde types: `HistoryRequest`/`MeasureQuery`/`RunReply`/`MeasureReply`)
289// and `process_command` being un-gated + shared so both drivers do the SAME work.
290// ===========================================================================
291
292/// A command sent main → runner (thread channel OR worker `postMessage`) over one
293/// ordered stream (so a `Reset` before a `Run` stays before it). `Run` carries the
294/// whole request; the driver coalesces consecutive `Run`s to shed a slider drag's
295/// backlog (the thread in `thread_main`, the worker's main side in `WorkerRunner`).
296#[derive(serde::Serialize, serde::Deserialize)]
297pub enum Command {
298    Run {
299        request: brep_kernel::HistoryRequest,
300        generation: u64,
301        /// The [`brep_kernel::parts_library_revision`] this run was built
302        /// against. The runner refuses to execute a run stamped for a library
303        /// it does not hold — see [`process_command`].
304        #[serde(default)]
305        parts_library_revision: u64,
306    },
307    /// Install the parts library on the runner's OWN kernel store. Sent only
308    /// when the library actually changes (insert, document load, refresh),
309    /// never per run: it carries the whole embedded-part payload, which for an
310    /// imported STEP assembly is megabytes, and stringifying that on the
311    /// browser's main thread once per edit is what froze the UI.
312    ///
313    /// Deliberately its OWN command rather than an optional field on `Run`:
314    /// both drivers COALESCE consecutive runs (see [`thread_main`] and
315    /// `WorkerRunner::submit_run`), so a library riding on a run could be
316    /// dropped with it. A `SetPartsLibrary` is never coalesced away, and the
317    /// stream is ordered, so it always lands before the run that needs it.
318    SetPartsLibrary {
319        library: brep_kernel::PartsLibraryMap,
320        revision: u64,
321    },
322    Query(MeasureQuery),
323    MeshImport(MeshImportRequest),
324    Reset,
325}
326
327/// A reply sent runner → main; the main side demuxes it into per-kind buffers.
328#[derive(serde::Serialize, serde::Deserialize)]
329pub enum Reply {
330    Run(RunReply),
331    Query(MeasureReply),
332    MeshImport(MeshImportReply),
333    /// The runner REFUSED a run because its resident parts library could not
334    /// serve it (a part the request references is missing, or the run was
335    /// stamped for a different library revision). No geometry was touched; the
336    /// main side re-sends the library and re-submits. Refusing is the whole
337    /// point — running with the wrong library would be silently wrong
338    /// geometry, which is far worse than one extra round trip.
339    NeedPartsLibrary,
340}
341
342fn reconstruct_mesh(request: MeshImportRequest) -> MeshImportReply {
343    let result = (|| {
344        use brep_reconstruction::stl_conversion::{
345            binary_stl_coordinate_precision_tolerance, convert_stl_mesh_to_step,
346            StlConversionOptions,
347        };
348        use brep_reconstruction::{Mesh, Vec3};
349
350        let (mesh, positions, indices, coordinate_precision_tolerance) = match request.format {
351            MeshImportFormat::Stl => {
352                use brep_reconstruction::stl::{parse_stl_bytes, StlFormat, StlReadOptions};
353                let imported = parse_stl_bytes(&request.bytes, &StlReadOptions::default())
354                    .map_err(|error| format!("STL import failed: {error}"))?;
355                let positions = imported
356                    .mesh
357                    .vertices
358                    .iter()
359                    .flat_map(|point| [point.x, point.y, point.z])
360                    .collect::<Vec<_>>();
361                let indices = imported
362                    .mesh
363                    .triangles
364                    .iter()
365                    .flatten()
366                    .copied()
367                    .collect::<Vec<_>>();
368                let precision = if imported.format == StlFormat::Binary {
369                    binary_stl_coordinate_precision_tolerance(&imported.mesh)
370                } else {
371                    0.0
372                };
373                (imported.mesh, positions, indices, precision)
374            }
375            MeshImportFormat::Obj => {
376                let text = std::str::from_utf8(&request.bytes)
377                    .map_err(|_| "OBJ import failed: file is not UTF-8 text".to_string())?;
378                let obj = brep_kernel::read_obj(text)
379                    .map_err(|error| format!("OBJ import failed: {error}"))?;
380                let vertices = obj
381                    .positions
382                    .chunks_exact(3)
383                    .map(|point| Vec3::new(point[0], point[1], point[2]))
384                    .collect::<Vec<_>>();
385                let triangles = obj
386                    .indices
387                    .chunks_exact(3)
388                    .map(|triangle| [triangle[0], triangle[1], triangle[2]])
389                    .collect::<Vec<_>>();
390                (
391                    Mesh::new(vertices, triangles),
392                    obj.positions,
393                    obj.indices,
394                    0.0,
395                )
396            }
397        };
398        let mut options = StlConversionOptions::default();
399        options.coordinate_precision_tolerance = coordinate_precision_tolerance;
400        convert_stl_mesh_to_step(
401            &mesh,
402            &positions,
403            Some(&indices),
404            &options,
405            "Imported mesh",
406            "MM",
407            "",
408        )
409        .map(|output| output.step_text)
410        .map_err(|error| format!("RANSAC reconstruction failed: {error}"))
411    })();
412    MeshImportReply {
413        id: request.id,
414        result,
415    }
416}
417
418/// Execute ONE [`Command`] against `runner` and return the [`Reply`] it produces,
419/// if any. The shared step both async drivers run: the native [`thread_main`]
420/// calls it for each (post-coalescing) command on the runner thread; the wasm
421/// `WorkerRunner`'s `worker_entry` calls it per `postMessage` on the worker.
422/// `Run` → a [`Reply::Run`]; `Query` → a [`Reply::Query`]; `Reset` drops the delta
423/// baseline AND the runner's OWN kernel history cache (the resident registry lives
424/// with the runner — thread or worker — not on main) and yields no reply.
425pub fn process_command(
426    runner: &mut crate::pipeline::SceneRunner,
427    command: Command,
428) -> Option<Reply> {
429    match command {
430        Command::Run {
431            request,
432            generation,
433            parts_library_revision,
434        } => {
435            // PREFLIGHT (see `Reply::NeedPartsLibrary`). Two independent
436            // checks, because neither alone is enough:
437            //
438            // * the revision stamp catches CHANGED content the sender knows
439            //   about but this store has not received;
440            // * `missing_library_parts` catches content that is simply GONE
441            //   here — the orphan GC at the end of every run drops entries no
442            //   ACOMP in THAT run referenced, so an undo to zero components
443            //   empties this store while the sender's (which never ran) keeps
444            //   everything and its revision never moves. Only looking at the
445            //   actual content sees that.
446            let stale = match runner.parts_library_revision {
447                Some(installed) => installed != parts_library_revision,
448                // Nothing installed yet. Accept a run stamped 0 — that is
449                // either an empty library or a caller driving `submit_run`
450                // directly (the runner tests); the content preflight below is
451                // what actually protects the run. A non-zero stamp with nothing
452                // installed IS a gap: refuse it.
453                None => parts_library_revision != 0,
454            };
455            // Only a part the sender DID send and this store has since lost is
456            // worth asking for again. One it never sent is a dangling reference
457            // in the document itself: run it, so the ACOMP feature reports it
458            // the way it always has. (An install inserts every incoming name,
459            // so right after one this set can only be empty — which is what
460            // makes the ask-and-retry terminate.)
461            let recoverable = brep_kernel::missing_library_parts(&request)
462                .iter()
463                .any(|name| runner.parts_library_names.contains(name));
464            if stale || recoverable {
465                runner.parts_library_revision = None;
466                return Some(Reply::NeedPartsLibrary);
467            }
468            let output = runner.run(&request, None);
469            Some(Reply::Run(RunReply { generation, output }))
470        }
471        Command::SetPartsLibrary { library, revision } => {
472            runner.parts_library_names = library.keys().cloned().collect();
473            brep_kernel::install_parts_library(&library);
474            runner.parts_library_revision = Some(revision);
475            None
476        }
477        Command::Query(query) => {
478            let result = measure_json(runner, &query);
479            Some(Reply::Query(MeasureReply { id: query.id, result }))
480        }
481        Command::MeshImport(request) => Some(Reply::MeshImport(reconstruct_mesh(request))),
482        Command::Reset => {
483            // A document switch: drop the delta baseline AND this runner's OWN kernel
484            // history cache (the resident registry lives here, not on main), mirroring
485            // `set_history_json`'s main-thread clear so a new model rebuilds fully and
486            // the old model's handles are freed.
487            runner.reset();
488            brep_kernel::clear_history_cache();
489            None
490        }
491    }
492}
493
494// ===========================================================================
495// ThreadRunner (M2b): a persistent std::thread that OWNS the SceneRunner, so a
496// history run — and per-object measurement queries — execute OFF the main thread
497// and the native UI stays responsive during a run AND during selection. Native
498// only: `std::thread` + `std::sync::mpsc` do not exist on wasm32 (M3b lands a
499// worker impl behind this same trait), so the whole thing is cfg-gated out there.
500// ===========================================================================
501
502/// The persistent-thread runner. `submit_*`/`reset` push [`Command`]s down the
503/// channel; `poll_*` first DRAIN every ready [`Reply`] into the two demux buffers,
504/// then pop the matching one. The `SceneRunner` (and thus the kernel's resident
505/// registry it warms) lives ENTIRELY on the thread — it is never shared — so the
506/// only cross-thread traffic is the `Send` command/reply payloads.
507#[cfg(not(target_arch = "wasm32"))]
508pub struct ThreadRunner {
509    /// Main → thread. `Option` so [`Drop`] can take + drop it, ending the thread's
510    /// blocking `recv` (the run loop exits, the join completes).
511    tx: Option<std::sync::mpsc::Sender<Command>>,
512    /// Thread → main.
513    rx: std::sync::mpsc::Receiver<Reply>,
514    /// The runner thread's join handle (joined on drop for a clean shutdown).
515    handle: Option<std::thread::JoinHandle<()>>,
516    /// Demuxed completed run replies awaiting `poll_run`.
517    run_buf: std::collections::VecDeque<RunReply>,
518    /// Demuxed completed measurement replies awaiting `poll_query`.
519    query_buf: std::collections::VecDeque<MeasureReply>,
520    mesh_import_buf: std::collections::VecDeque<MeshImportReply>,
521    /// The parts-library revision last SENT down the channel (`None` = never,
522    /// or the thread dropped it). Lives here rather than on the caller so
523    /// "reset forgets the library" is a local property of this object.
524    sent_library_revision: Option<u64>,
525    /// The thread refused a run for want of its library (drained by
526    /// [`HistoryRunner::poll_library_request`]).
527    library_requested: bool,
528}
529
530#[cfg(not(target_arch = "wasm32"))]
531impl ThreadRunner {
532    pub fn new() -> Self {
533        let (tx, cmd_rx) = std::sync::mpsc::channel::<Command>();
534        let (reply_tx, rx) = std::sync::mpsc::channel::<Reply>();
535        let handle = std::thread::Builder::new()
536            .name("brep-history-runner".to_string())
537            .spawn(move || thread_main(cmd_rx, reply_tx))
538            .expect("spawn brep-history-runner thread");
539        Self {
540            tx: Some(tx),
541            rx,
542            handle: Some(handle),
543            run_buf: std::collections::VecDeque::new(),
544            query_buf: std::collections::VecDeque::new(),
545            mesh_import_buf: std::collections::VecDeque::new(),
546            sent_library_revision: None,
547            library_requested: false,
548        }
549    }
550
551    /// Pull every ready reply off the channel and demux it into the run/query
552    /// buffers (so a `poll_run` never swallows a query reply and vice versa).
553    fn drain(&mut self) {
554        while let Ok(reply) = self.rx.try_recv() {
555            match reply {
556                Reply::Run(run) => self.run_buf.push_back(run),
557                Reply::Query(query) => self.query_buf.push_back(query),
558                Reply::MeshImport(reply) => self.mesh_import_buf.push_back(reply),
559                Reply::NeedPartsLibrary => {
560                    // The thread dropped (or never had) the library this run
561                    // needs. Forget what we believe it holds so the next
562                    // `sync_parts_library` re-installs, and flag the refused
563                    // run for the caller to re-submit.
564                    self.sent_library_revision = None;
565                    self.library_requested = true;
566                }
567            }
568        }
569    }
570}
571
572#[cfg(not(target_arch = "wasm32"))]
573impl Default for ThreadRunner {
574    fn default() -> Self {
575        Self::new()
576    }
577}
578
579/// The runner thread's whole life: block on the next command, batch it with every
580/// other command already queued, COALESCE consecutive `Run`s (a `Run` immediately
581/// followed by another `Run` — with no `Query`/`Reset` between — is dropped; only
582/// the last of each consecutive group runs), then process the batch IN ORDER so a
583/// `Reset` or a `Query` interleaved between two runs keeps its place. Exits when
584/// the command sender is dropped (`recv` errors) or the reply receiver is gone (a
585/// `send` errors — the main side went away).
586#[cfg(not(target_arch = "wasm32"))]
587fn thread_main(
588    cmd_rx: std::sync::mpsc::Receiver<Command>,
589    reply_tx: std::sync::mpsc::Sender<Reply>,
590) {
591    let mut runner = crate::pipeline::SceneRunner::new();
592    while let Ok(first) = cmd_rx.recv() {
593        // Gather this command plus everything else already waiting.
594        let mut batch = vec![first];
595        loop {
596            match cmd_rx.try_recv() {
597                Ok(command) => batch.push(command),
598                Err(_) => break, // Empty or Disconnected — process what we have.
599            }
600        }
601        // A Run immediately followed by another Run is coalesced away (its result
602        // would be overwritten before anything observed it); a Run followed by a
603        // Query/Reset/end still runs, so interleaved work sees the right geometry.
604        let mut run_here: Vec<bool> = vec![true; batch.len()];
605        for i in 0..batch.len() {
606            if matches!(batch[i], Command::Run { .. })
607                && matches!(batch.get(i + 1), Some(Command::Run { .. }))
608            {
609                run_here[i] = false;
610            }
611        }
612        // Process the KEPT commands in order through the SHARED `process_command`
613        // (byte-identical work to the wasm worker's per-message step), sending each
614        // reply it produces; a coalesced-away Run is skipped without touching the
615        // runner, so interleaved Query/Reset still see the right geometry.
616        for (i, command) in batch.into_iter().enumerate() {
617            if !run_here[i] {
618                continue;
619            }
620            if let Some(reply) = process_command(&mut runner, command) {
621                if reply_tx.send(reply).is_err() {
622                    return; // The reply receiver is gone — the main side went away.
623                }
624            }
625        }
626    }
627}
628
629#[cfg(not(target_arch = "wasm32"))]
630impl HistoryRunner for ThreadRunner {
631    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
632        if let Some(tx) = &self.tx {
633            let _ = tx.send(Command::Run {
634                request,
635                generation,
636                parts_library_revision: self.sent_library_revision.unwrap_or(0),
637            });
638        }
639    }
640
641    fn sync_parts_library(
642        &mut self,
643        revision: u64,
644        fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
645    ) {
646        if self.sent_library_revision == Some(revision) {
647            return;
648        }
649        if let Some(tx) = &self.tx {
650            let _ = tx.send(Command::SetPartsLibrary {
651                library: fetch(),
652                revision,
653            });
654            self.sent_library_revision = Some(revision);
655        }
656    }
657
658    fn poll_library_request(&mut self) -> bool {
659        self.drain();
660        std::mem::take(&mut self.library_requested)
661    }
662
663    fn poll_run(&mut self) -> Option<RunReply> {
664        self.drain();
665        self.run_buf.pop_front()
666    }
667
668    fn submit_query(&mut self, query: MeasureQuery) {
669        if let Some(tx) = &self.tx {
670            let _ = tx.send(Command::Query(query));
671        }
672    }
673
674    fn poll_query(&mut self) -> Option<MeasureReply> {
675        self.drain();
676        self.query_buf.pop_front()
677    }
678
679    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
680        if let Some(tx) = &self.tx {
681            let _ = tx.send(Command::MeshImport(request));
682        }
683    }
684
685    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
686        self.drain();
687        self.mesh_import_buf.pop_front()
688    }
689
690    fn reset(&mut self) {
691        if let Some(tx) = &self.tx {
692            let _ = tx.send(Command::Reset);
693        }
694        // A reset is a wholesale document switch: any replies still buffered from
695        // the old model are stale — drop them (a fresh run/query supersedes).
696        self.run_buf.clear();
697        self.query_buf.clear();
698        self.mesh_import_buf.clear();
699        // `Command::Reset` clears the thread's kernel store, library included,
700        // so what we believe it holds is void.
701        self.sent_library_revision = None;
702        self.library_requested = false;
703    }
704}
705
706#[cfg(not(target_arch = "wasm32"))]
707impl Drop for ThreadRunner {
708    fn drop(&mut self) {
709        // Dropping the sender ends the thread's blocking `recv`; then join so the
710        // thread (and its resident registry) is fully torn down before we return.
711        self.tx.take();
712        if let Some(handle) = self.handle.take() {
713            let _ = handle.join();
714        }
715    }
716}
717
718// ===========================================================================
719// ThreadRunner integration tests (native only — the async run/query machine the
720// wasm WorkerRunner (M3) reuses). Driven end-to-end: submit → spin poll → assert.
721// ===========================================================================
722#[cfg(all(test, not(target_arch = "wasm32")))]
723mod thread_tests {
724    use super::*;
725
726    /// A one-feature history: a P.CU cube `Box` of side 20 (volume 8000).
727    fn box_request() -> brep_kernel::HistoryRequest {
728        serde_json::from_str(
729            r#"{
730                "expressions": "", "configurator": {},
731                "features": [{
732                    "type": "P.CU",
733                    "inputParams": {
734                        "id": "Box", "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
735                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
736                        "boolean": { "targets": [], "operation": "NONE" }
737                    },
738                    "persistentData": {}
739                }]
740            }"#,
741        )
742        .unwrap()
743    }
744
745    fn spin_run(runner: &mut ThreadRunner) -> RunReply {
746        for _ in 0..3000 {
747            if let Some(reply) = runner.poll_run() {
748                return reply;
749            }
750            std::thread::sleep(std::time::Duration::from_millis(1));
751        }
752        panic!("thread run did not complete within the spin budget");
753    }
754
755    fn spin_query(runner: &mut ThreadRunner) -> MeasureReply {
756        for _ in 0..3000 {
757            if let Some(reply) = runner.poll_query() {
758                return reply;
759            }
760            std::thread::sleep(std::time::Duration::from_millis(1));
761        }
762        panic!("thread query did not complete within the spin budget");
763    }
764
765    /// A submitted run executes ON THE THREAD and its reply is drained back with
766    /// the right generation + a freshly-tessellated snapshot entry for the box.
767    #[test]
768    fn thread_runner_runs_and_replies() {
769        let mut runner = ThreadRunner::new();
770        runner.submit_run(box_request(), 1);
771        let reply = spin_run(&mut runner);
772        assert_eq!(reply.generation, 1, "reply carries its submit generation");
773        assert_eq!(reply.output.snapshot.len(), 1, "one solid (the box)");
774        let (name, _handle, display) = &reply.output.snapshot[0];
775        assert_eq!(name, "Box");
776        assert!(display.is_some(), "first emit is a fresh tessellation, not a reuse");
777        assert_eq!(reply.output.provenance, vec![("Box".to_string(), "Box".to_string())]);
778    }
779
780    /// A measurement query routes to the thread's WARM registry (populated by the
781    /// preceding run) and returns the exact object-info fragment.
782    #[test]
783    fn thread_runner_measures_after_run() {
784        let mut runner = ThreadRunner::new();
785        runner.submit_run(box_request(), 1);
786        let _ = spin_run(&mut runner);
787        runner.submit_query(MeasureQuery {
788            id: 7,
789            kind: MeasureKind::Solid,
790            owner: "Box".to_string(),
791            entity: String::new(),
792            density: 1.0,
793        });
794        let reply = spin_query(&mut runner);
795        assert_eq!(reply.id, 7);
796        let info: serde_json::Value = serde_json::from_str(&reply.result).unwrap();
797        assert_eq!(info["ok"], true, "measured on the thread: {}", reply.result);
798        assert!(
799            (info["volume"].as_f64().unwrap() - 8000.0).abs() < 1.0,
800            "box volume {} != 8000",
801            info["volume"]
802        );
803    }
804
805    /// A re-submitted IDENTICAL run replays the thread's incremental cache: the
806    /// second reply is a REUSE (`None` in the snapshot), proving the runner's
807    /// baseline + the kernel cache both persist across submits on the thread.
808    #[test]
809    fn thread_runner_reuses_on_identical_resubmit() {
810        let mut runner = ThreadRunner::new();
811        runner.submit_run(box_request(), 1);
812        let first = spin_run(&mut runner);
813        assert!(first.output.snapshot[0].2.is_some(), "first is fresh");
814
815        runner.submit_run(box_request(), 2);
816        let second = spin_run(&mut runner);
817        assert_eq!(second.generation, 2);
818        assert!(
819            second.output.snapshot[0].2.is_none(),
820            "identical resubmit replays as a REUSE (unchanged handle)"
821        );
822    }
823
824    // --- the PARTS-LIBRARY channel ---------------------------------------
825    //
826    // The library is sent when it CHANGES, not with every run (sending it per
827    // run stringified megabytes of embedded part payload on the browser's main
828    // thread for every edit). These cover the two ways that can go wrong.
829
830    /// A one-instance assembly: an ACOMP referencing part `widget`, plus the
831    /// library entry that defines it.
832    fn widget_part() -> (brep_kernel::PartsLibraryMap, brep_kernel::HistoryRequest) {
833        let document = serde_json::json!({
834            "expressions": "", "configurator": {},
835            "features": [{
836                "type": "P.CU",
837                "inputParams": {
838                    "id": "Part", "sizeX": 4.0, "sizeY": 4.0, "sizeZ": 4.0,
839                    "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
840                    "boolean": { "targets": [], "operation": "NONE" }
841                },
842                "persistentData": {}
843            }]
844        });
845        let mut library = brep_kernel::PartsLibraryMap::new();
846        library.insert(
847            "widget".to_string(),
848            brep_kernel::PartsLibraryEntry {
849                document,
850                ..Default::default()
851            },
852        );
853        let request = serde_json::from_str(
854            r#"{
855                "expressions": "", "configurator": {},
856                "features": [{
857                    "type": "ACOMP",
858                    "inputParams": {
859                        "id": "ACOMP1", "partName": "widget",
860                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] }
861                    },
862                    "persistentData": {}
863                }]
864            }"#,
865        )
866        .unwrap();
867        (library, request)
868    }
869
870    /// Spin until the thread either completes the run or REFUSES it for want of
871    /// its parts library (`None`).
872    fn spin_reply(runner: &mut ThreadRunner) -> Option<RunReply> {
873        for _ in 0..3000 {
874            if runner.poll_library_request() {
875                return None;
876            }
877            if let Some(reply) = runner.poll_run() {
878                return Some(reply);
879            }
880            std::thread::sleep(std::time::Duration::from_millis(1));
881        }
882        panic!("thread run neither completed nor asked for the library");
883    }
884
885    /// The steady state: the library is installed ONCE and every later run
886    /// resolves its ACOMP against the thread's resident copy — no run carries
887    /// the payload, and the fetch closure is called exactly once.
888    #[test]
889    fn thread_runner_installs_the_library_once_and_reuses_it() {
890        let (library, request) = widget_part();
891        let mut runner = ThreadRunner::new();
892        let mut fetches = 0;
893        for generation in 1..=3 {
894            runner.sync_parts_library(1, &mut || {
895                fetches += 1;
896                library.clone()
897            });
898            runner.submit_run(request.clone(), generation);
899            let reply = spin_reply(&mut runner).expect("the run executed");
900            assert_eq!(reply.generation, generation);
901            assert_eq!(
902                reply.output.snapshot.len(),
903                1,
904                "the ACOMP resolved against the resident library"
905            );
906        }
907        assert_eq!(fetches, 1, "the library is sent once, not once per run");
908    }
909
910    /// THE DRIFT CASE, and why the runner preflights CONTENT and not just a
911    /// revision. The kernel's orphan GC drops entries no ACOMP in the run
912    /// referenced, so a componentless run empties the THREAD's library while the
913    /// sender's store — and therefore its revision — never moves. The next run
914    /// that does reference the part must be refused, not executed against the
915    /// emptied store, and must recover.
916    #[test]
917    fn thread_runner_recovers_after_its_library_is_gc_d() {
918        let (library, request) = widget_part();
919        let empty: brep_kernel::HistoryRequest =
920            serde_json::from_str(r#"{"expressions":"","configurator":{},"features":[]}"#).unwrap();
921        let mut runner = ThreadRunner::new();
922
923        runner.sync_parts_library(1, &mut || library.clone());
924        runner.submit_run(request.clone(), 1);
925        assert!(spin_reply(&mut runner).is_some(), "first run executes");
926
927        // A componentless run: the thread's GC drops `widget`. Nothing about the
928        // revision changed — only the thread's CONTENT did.
929        runner.sync_parts_library(1, &mut || library.clone());
930        runner.submit_run(empty, 2);
931        assert!(spin_reply(&mut runner).is_some(), "the empty run executes");
932
933        // Back to the assembly. The content preflight must catch the gap.
934        runner.sync_parts_library(1, &mut || library.clone());
935        runner.submit_run(request.clone(), 3);
936        assert!(
937            spin_reply(&mut runner).is_none(),
938            "a run whose part the thread GC'd must be REFUSED, never run against \
939             an empty library"
940        );
941
942        // Recovery: the refusal forgot the sent revision, so this re-installs.
943        let mut refetched = 0;
944        runner.sync_parts_library(1, &mut || {
945            refetched += 1;
946            library.clone()
947        });
948        assert_eq!(refetched, 1, "the refusal forces a re-send");
949        runner.submit_run(request, 4);
950        let reply = spin_reply(&mut runner).expect("the re-driven run executes");
951        assert_eq!(reply.generation, 4);
952        assert_eq!(reply.output.snapshot.len(), 1, "the component is back");
953    }
954
955    /// The revision half of the preflight: a run stamped for a library the
956    /// runner never received is refused rather than executed.
957    #[test]
958    fn a_run_stamped_for_an_unknown_library_is_refused() {
959        let (_library, request) = widget_part();
960        let mut scene = crate::pipeline::SceneRunner::new();
961        let refusal = process_command(
962            &mut scene,
963            Command::Run {
964                request,
965                generation: 1,
966                parts_library_revision: 7,
967            },
968        );
969        assert!(
970            matches!(refusal, Some(Reply::NeedPartsLibrary)),
971            "an unknown library stamp must refuse the run"
972        );
973        assert_eq!(
974            scene.parts_library_revision, None,
975            "the refusal drops what the runner believed it held"
976        );
977    }
978
979    /// The ask-and-retry must TERMINATE. A document referencing a part its own
980    /// library never had is a dangling reference, not a lost cache entry: the
981    /// run goes ahead so the ACOMP feature reports it, exactly as before the
982    /// library moved off the request.
983    #[test]
984    fn a_part_the_library_never_had_errors_instead_of_looping() {
985        let (_library, request) = widget_part();
986        let mut runner = ThreadRunner::new();
987        // Install an EMPTY library at revision 1 — `widget` was never sent.
988        runner.sync_parts_library(1, &mut brep_kernel::PartsLibraryMap::new);
989        runner.submit_run(request, 1);
990        let reply = spin_reply(&mut runner)
991            .expect("a dangling part name must not refuse the run forever");
992        assert!(
993            !reply.output.report.feature_errors.is_empty(),
994            "the ACOMP reports the missing part: {:?}",
995            reply.output.report
996        );
997    }
998
999    /// A `reset` clears the thread's delta baseline AND its kernel cache, so the
1000    /// next run rebuilds fresh (a new handle ⇒ a `Some` snapshot, not a reuse).
1001    #[test]
1002    fn thread_runner_reset_forces_full_rebuild() {
1003        let mut runner = ThreadRunner::new();
1004        runner.submit_run(box_request(), 1);
1005        let _ = spin_run(&mut runner);
1006        runner.reset();
1007        runner.submit_run(box_request(), 2);
1008        let reply = spin_run(&mut runner);
1009        assert!(
1010            reply.output.snapshot[0].2.is_some(),
1011            "after reset the box re-tessellates (baseline dropped)"
1012        );
1013    }
1014
1015    /// The wasm worker WIRE FORMAT: a `Command::Run` — carrying a `HistoryRequest`
1016    /// whose brand-new `Serialize` must honor its field renames (`type`,
1017    /// `inputParams`, …) — survives the serde-JSON round trip the `WorkerRunner`'s
1018    /// `postMessage` uses, and the round-tripped request still executes. (The
1019    /// `RunOutput`/`RunReply` reply half already has its own round-trip test.)
1020    #[test]
1021    fn run_command_round_trips_through_serde() {
1022        let command = Command::Run {
1023            request: box_request(),
1024            generation: 42,
1025            parts_library_revision: 9,
1026        };
1027        let json = serde_json::to_string(&command).expect("Command serializes");
1028        let back: Command = serde_json::from_str(&json).expect("Command deserializes");
1029        match back {
1030            Command::Run {
1031                request,
1032                generation,
1033                parts_library_revision,
1034            } => {
1035                assert_eq!(generation, 42, "generation round-trips");
1036                assert_eq!(parts_library_revision, 9, "library stamp round-trips");
1037                assert_eq!(request.features.len(), 1);
1038                // The `type` rename survived serialize → deserialize.
1039                assert_eq!(request.features[0].feature_type, "P.CU");
1040                // The deserialized request is still executable on a fresh runner.
1041                let output = crate::pipeline::SceneRunner::new().run(&request, None);
1042                assert_eq!(output.snapshot.len(), 1);
1043                assert_eq!(output.snapshot[0].0, "Box");
1044            }
1045            _ => panic!("round-tripped to the wrong Command variant"),
1046        }
1047    }
1048
1049    /// Browser workers receive the same mesh command as JSON. Preserve every
1050    /// source byte and the request id/format across that boundary.
1051    #[test]
1052    fn mesh_import_command_round_trips_through_serde() {
1053        let command = Command::MeshImport(MeshImportRequest {
1054            id: 17,
1055            format: MeshImportFormat::Stl,
1056            bytes: vec![0, 1, 127, 128, 255],
1057        });
1058        let json = serde_json::to_string(&command).expect("mesh command serializes");
1059        let back: Command = serde_json::from_str(&json).expect("mesh command deserializes");
1060        match back {
1061            Command::MeshImport(request) => {
1062                assert_eq!(request.id, 17);
1063                assert!(matches!(request.format, MeshImportFormat::Stl));
1064                assert_eq!(request.bytes, vec![0, 1, 127, 128, 255]);
1065            }
1066            _ => panic!("round-tripped to the wrong Command variant"),
1067        }
1068    }
1069}