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
109pub use brep_reconstruction::stl_conversion::{
110    ConversionPolicy, StlConversionOptions, StlConversionOutput,
111};
112
113/// Mesh encoding accepted by the off-thread reconstruction channel.
114#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
115pub enum MeshImportFormat {
116    Stl,
117    Obj,
118}
119
120/// A mesh reconstruction request. The byte payload moves to the native thread
121/// or is serialized to the browser worker; no parsing or fitting happens on UI.
122#[derive(Debug, serde::Serialize, serde::Deserialize)]
123pub struct MeshImportRequest {
124    pub id: u64,
125    pub format: MeshImportFormat,
126    pub bytes: Vec<u8>,
127    pub options: StlConversionOptions,
128}
129
130/// Completed off-thread reconstruction. Success carries validated STEP and
131/// diagnostics for a preview or an ordinary IMPORT3D history insertion.
132#[derive(Debug, serde::Serialize, serde::Deserialize)]
133pub struct MeshImportReply {
134    pub id: u64,
135    pub result: Result<StlConversionOutput, String>,
136}
137
138/// The history-run seam: SUBMIT a run, POLL for its completed reply, RESET the
139/// delta baseline, plus a QUERY channel for per-object measurements (routed to the
140/// runner so the warm registry answers them, never the cold main-side one). The
141/// Inline impl runs everything synchronously; a later thread/worker impl defers
142/// the work and surfaces the replies through the same poll idiom.
143pub trait HistoryRunner {
144    /// Submit a history run tagged with a monotonic generation. The runner executes
145    /// it (immediately for Inline; on a background thread later) and makes the reply
146    /// available via [`poll_run`](Self::poll_run).
147    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64);
148    /// Non-blocking: the next completed run reply, if any (drained each frame).
149    fn poll_run(&mut self) -> Option<RunReply>;
150    /// Submit a per-object measurement query (answered against the runner's warm
151    /// registry). Inline computes it immediately; a thread impl computes it on the
152    /// runner thread and surfaces it via [`poll_query`](Self::poll_query).
153    fn submit_query(&mut self, query: MeasureQuery);
154    /// Non-blocking: the next completed measurement reply, if any.
155    fn poll_query(&mut self) -> Option<MeasureReply>;
156    /// Submit RANSAC mesh reconstruction to the runner's thread/worker.
157    fn submit_mesh_import(&mut self, request: MeshImportRequest);
158    /// Non-blocking: the next completed mesh reconstruction, if any.
159    fn poll_mesh_import(&mut self) -> Option<MeshImportReply>;
160    /// Submit a STEP product-structure probe (see [`StepProbeRequest`]).
161    fn submit_step_probe(&mut self, request: StepProbeRequest);
162    /// Non-blocking: the next completed STEP probe, if any.
163    fn poll_step_probe(&mut self) -> Option<StepProbeReply>;
164    /// Drop the delta baseline (document switch → full rebuild).
165    fn reset(&mut self);
166
167    /// Bring the runner's resident PARTS LIBRARY up to `revision`, calling
168    /// `fetch` ONLY when it is not already there. Called immediately before
169    /// every [`submit_run`](Self::submit_run).
170    ///
171    /// This is the whole point of the library channel: the library is sent
172    /// when it CHANGES (insert, document load, refresh), not on every run.
173    /// Cheap to call — the revision comparison is an integer, and `fetch`
174    /// (which clones the store) never runs in the steady state. The default is
175    /// a no-op for [`InlineRunner`], which shares the caller's kernel store.
176    fn sync_parts_library(
177        &mut self,
178        _revision: u64,
179        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
180    ) {
181    }
182
183    /// True when the runner REFUSED a run because its resident parts library
184    /// could not serve it (see [`Reply::NeedPartsLibrary`]). It has already
185    /// forgotten its copy, so the next `sync_parts_library` reinstalls; the
186    /// caller must re-submit the run. Never true for a runner that shares the
187    /// caller's store.
188    fn poll_library_request(&mut self) -> bool {
189        false
190    }
191
192    /// Non-blocking: the MOST RECENT progress report of the in-flight run, with
193    /// any older ones discarded (only the latest feature matters). `None` for
194    /// a synchronous runner — Inline has finished before anyone could ask.
195    fn poll_progress(&mut self) -> Option<RunProgress> {
196        None
197    }
198
199    /// ABANDON the in-flight work and start over with an EMPTY resident
200    /// registry. Returns `false` when there is nothing this runner can abandon
201    /// (Inline: the run already completed on the caller's thread). A `true`
202    /// means every handle the caller holds is now invalid, no reply is coming
203    /// for anything submitted so far, and the parts library must be re-sent —
204    /// the caller reconciles its generations and pending sets accordingly
205    /// (`EngineState::cancel_run`). Nothing inside a feature is interruptible:
206    /// the thread runner lets its old thread finish the feature it is on and
207    /// stop at the next boundary; the browser worker is terminated outright.
208    fn cancel(&mut self) -> bool {
209        false
210    }
211}
212
213/// Measure `query` against `runner`'s resident geometry and emit the object-info
214/// JSON FRAGMENT — EXACTLY the fields [`crate::metadata::EngineState::object_info_json`]
215/// emits for that kind EXCEPT `name` and `creatingFeature` (the main thread injects
216/// those from its eager provenance, so the merged output is byte-identical to the
217/// pre-seam in-process result). Shared verbatim by the Inline and thread runners so
218/// both produce the identical fragment. A missing handle / kernel error yields
219/// `{ "ok": false, "message": .. }` (main injects `name`).
220fn measure_json(runner: &crate::pipeline::SceneRunner, query: &MeasureQuery) -> String {
221    let Some(handle) = runner.handle_of(&query.owner) else {
222        return serde_json::json!({
223            "ok": false,
224            "message": format!("solid '{}' has no resident geometry", query.owner),
225        })
226        .to_string();
227    };
228    match query.kind {
229        MeasureKind::Solid => {
230            match brep_kernel::mass_properties_handle_native(handle, query.density) {
231                Ok(properties) => {
232                    let edge_total =
233                        brep_kernel::solid_edge_length_total_native(handle).unwrap_or(0.0);
234                    serde_json::json!({
235                        "ok": true,
236                        "kind": "solid",
237                        "volume": properties.volume,
238                        "surfaceArea": properties.surface_area,
239                        "edgeLengthTotal": edge_total,
240                        "density": properties.density,
241                        "weight": properties.mass,
242                    })
243                    .to_string()
244                }
245                Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
246            }
247        }
248        MeasureKind::Face => match brep_kernel::face_measurements_native(handle, &query.entity) {
249            Ok((area, edge_total, surface_type)) => serde_json::json!({
250                "ok": true,
251                "kind": "face",
252                "solid": query.owner,
253                "surfaceType": surface_type,
254                "area": area,
255                "edgeLengthTotal": edge_total,
256            })
257            .to_string(),
258            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
259        },
260        MeasureKind::Edge => match brep_kernel::edge_length_native(handle, &query.entity) {
261            Ok(length) => serde_json::json!({
262                "ok": true,
263                "kind": "edge",
264                "solid": query.owner,
265                "length": length,
266            })
267            .to_string(),
268            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
269        },
270    }
271}
272
273/// The default, SYNCHRONOUS runner: runs on submit, stashes the reply for an
274/// immediate poll. Behavior-identical to the pre-seam in-process run.
275pub struct InlineRunner {
276    /// The scene-free history runner it owns (delta baseline lives here).
277    runner: crate::pipeline::SceneRunner,
278    /// Completed replies awaiting a poll. For Inline this holds exactly one entry
279    /// between a `submit_run` and the immediately-following `poll_run`.
280    pending: std::collections::VecDeque<RunReply>,
281    /// Completed measurement replies awaiting a poll (computed synchronously on
282    /// `submit_query`, popped on `poll_query`) — the synchronous mirror of the
283    /// thread runner's query buffer, so the object-info path resolves same-call.
284    query_pending: std::collections::VecDeque<MeasureReply>,
285    mesh_import_pending: std::collections::VecDeque<MeshImportReply>,
286    step_probe_pending: std::collections::VecDeque<StepProbeReply>,
287}
288
289impl InlineRunner {
290    pub fn new() -> Self {
291        Self {
292            runner: crate::pipeline::SceneRunner::new(),
293            pending: std::collections::VecDeque::new(),
294            query_pending: std::collections::VecDeque::new(),
295            mesh_import_pending: std::collections::VecDeque::new(),
296            step_probe_pending: std::collections::VecDeque::new(),
297        }
298    }
299}
300
301impl HistoryRunner for InlineRunner {
302    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
303        let output = self.runner.run(&request);
304        self.pending.push_back(RunReply { generation, output });
305    }
306
307    /// Nothing to send: Inline runs on the CALLER's thread against the CALLER's
308    /// kernel store, so the library the caller would hand over is already the
309    /// one this run resolves against. (Which is also why Inline never needs the
310    /// preflight — there is only one store, and the caller seeds it directly.)
311    fn sync_parts_library(
312        &mut self,
313        _revision: u64,
314        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
315    ) {
316    }
317
318    fn poll_run(&mut self) -> Option<RunReply> {
319        self.pending.pop_front()
320    }
321
322    fn submit_query(&mut self, query: MeasureQuery) {
323        let result = measure_json(&self.runner, &query);
324        self.query_pending.push_back(MeasureReply { id: query.id, result });
325    }
326
327    fn poll_query(&mut self) -> Option<MeasureReply> {
328        self.query_pending.pop_front()
329    }
330
331    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
332        self.mesh_import_pending
333            .push_back(reconstruct_mesh(request));
334    }
335
336    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
337        self.mesh_import_pending.pop_front()
338    }
339
340    fn submit_step_probe(&mut self, request: StepProbeRequest) {
341        self.step_probe_pending.push_back(probe_step(request));
342    }
343
344    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
345        self.step_probe_pending.pop_front()
346    }
347
348    fn reset(&mut self) {
349        self.runner.reset();
350    }
351}
352
353impl Default for InlineRunner {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359// ===========================================================================
360// Shared run/query PROTOCOL (M3b): the `Command`/`Reply` message pair and the
361// `process_command` step. Both async runners speak it — the native `ThreadRunner`
362// ships the enums over `mpsc` (no serialization), and the wasm `WorkerRunner`
363// serializes them to JSON for `postMessage`. Hence the serde derives (the enums
364// carry serde types: `HistoryRequest`/`MeasureQuery`/`RunReply`/`MeasureReply`)
365// and `process_command` being un-gated + shared so both drivers do the SAME work.
366// ===========================================================================
367
368/// A command sent main → runner (thread channel OR worker `postMessage`) over one
369/// ordered stream (so a `Reset` before a `Run` stays before it). `Run` carries the
370/// whole request; the driver coalesces consecutive `Run`s to shed a slider drag's
371/// backlog (the thread in `thread_main`, the worker's main side in `WorkerRunner`).
372#[derive(serde::Serialize, serde::Deserialize)]
373pub enum Command {
374    Run {
375        request: brep_kernel::HistoryRequest,
376        generation: u64,
377        /// The [`brep_kernel::parts_library_revision`] this run was built
378        /// against. The runner refuses to execute a run stamped for a library
379        /// it does not hold — see [`process_command`].
380        #[serde(default)]
381        parts_library_revision: u64,
382    },
383    /// Install the parts library on the runner's OWN kernel store. Sent only
384    /// when the library actually changes (insert, document load, refresh),
385    /// never per run: it carries the whole embedded-part payload, which for an
386    /// imported STEP assembly is megabytes, and stringifying that on the
387    /// browser's main thread once per edit is what froze the UI.
388    ///
389    /// Deliberately its OWN command rather than an optional field on `Run`:
390    /// both drivers COALESCE consecutive runs (see [`thread_main`] and
391    /// `WorkerRunner::submit_run`), so a library riding on a run could be
392    /// dropped with it. A `SetPartsLibrary` is never coalesced away, and the
393    /// stream is ordered, so it always lands before the run that needs it.
394    SetPartsLibrary {
395        library: brep_kernel::PartsLibraryMap,
396        revision: u64,
397    },
398    Query(MeasureQuery),
399    MeshImport(MeshImportRequest),
400    StepProbe(StepProbeRequest),
401    Reset,
402}
403
404/// A reply sent runner → main; the main side demuxes it into per-kind buffers.
405#[derive(serde::Serialize, serde::Deserialize)]
406pub enum Reply {
407    Run(RunReply),
408    Query(MeasureReply),
409    MeshImport(MeshImportReply),
410    StepProbe(StepProbeReply),
411    /// The runner REFUSED a run because its resident parts library could not
412    /// serve it (a part the request references is missing, or the run was
413    /// stamped for a different library revision). No geometry was touched; the
414    /// main side re-sends the library and re-submits. Refusing is the whole
415    /// point — running with the wrong library would be silently wrong
416    /// geometry, which is far worse than one extra round trip.
417    NeedPartsLibrary,
418    /// A feature of the in-flight run is about to execute (see
419    /// [`RunProgress`]). Posted from INSIDE `process_command`, before its
420    /// `Reply::Run`; the main side keeps only the latest.
421    Progress(RunProgress),
422}
423
424/// The probe itself — the one parse of a structured STEP import (see
425/// `EngineState::submit_step_probe`), run wherever the runner runs.
426fn probe_step(request: StepProbeRequest) -> StepProbeReply {
427    StepProbeReply {
428        id: request.id,
429        result: brep_kernel::read_step_assembly(&request.text),
430    }
431}
432
433fn reconstruct_mesh(request: MeshImportRequest) -> MeshImportReply {
434    let result = (|| {
435        use brep_reconstruction::stl_conversion::{
436            binary_stl_coordinate_precision_tolerance, convert_stl_mesh_to_step,
437        };
438        use brep_reconstruction::{Mesh, Vec3};
439
440        let (mesh, positions, indices, coordinate_precision_tolerance) = match request.format {
441            MeshImportFormat::Stl => {
442                use brep_reconstruction::stl::{parse_stl_bytes, StlFormat, StlReadOptions};
443                let read_options = StlReadOptions {
444                    weld_tolerance: (request.options.weld_tolerance >= 0.0)
445                        .then_some(request.options.weld_tolerance),
446                };
447                let imported = parse_stl_bytes(&request.bytes, &read_options)
448                    .map_err(|error| format!("STL import failed: {error}"))?;
449                let positions = imported
450                    .mesh
451                    .vertices
452                    .iter()
453                    .flat_map(|point| [point.x, point.y, point.z])
454                    .collect::<Vec<_>>();
455                let indices = imported
456                    .mesh
457                    .triangles
458                    .iter()
459                    .flatten()
460                    .copied()
461                    .collect::<Vec<_>>();
462                let precision = if imported.format == StlFormat::Binary {
463                    binary_stl_coordinate_precision_tolerance(&imported.mesh)
464                } else {
465                    0.0
466                };
467                (imported.mesh, positions, indices, precision)
468            }
469            MeshImportFormat::Obj => {
470                let text = std::str::from_utf8(&request.bytes)
471                    .map_err(|_| "OBJ import failed: file is not UTF-8 text".to_string())?;
472                let obj = brep_kernel::read_obj(text)
473                    .map_err(|error| format!("OBJ import failed: {error}"))?;
474                let vertices = obj
475                    .positions
476                    .chunks_exact(3)
477                    .map(|point| Vec3::new(point[0], point[1], point[2]))
478                    .collect::<Vec<_>>();
479                let triangles = obj
480                    .indices
481                    .chunks_exact(3)
482                    .map(|triangle| [triangle[0], triangle[1], triangle[2]])
483                    .collect::<Vec<_>>();
484                (
485                    Mesh::new(vertices, triangles),
486                    obj.positions,
487                    obj.indices,
488                    0.0,
489                )
490            }
491        };
492        let mut options = request.options;
493        options.coordinate_precision_tolerance = options.coordinate_precision_tolerance
494            .max(coordinate_precision_tolerance);
495        convert_stl_mesh_to_step(
496            &mesh,
497            &positions,
498            Some(&indices),
499            &options,
500            "Imported mesh",
501            "MM",
502            "",
503        )
504        .map_err(|error| format!("RANSAC reconstruction failed: {error}"))
505    })();
506    MeshImportReply {
507        id: request.id,
508        result,
509    }
510}
511
512/// Execute ONE [`Command`] against `runner` and return the [`Reply`] it produces,
513/// if any. The shared step both async drivers run: the native [`thread_main`]
514/// calls it for each (post-coalescing) command on the runner thread; the wasm
515/// `WorkerRunner`'s `worker_entry` calls it per `postMessage` on the worker.
516/// `Run` → a [`Reply::Run`]; `Query` → a [`Reply::Query`]; `Reset` drops the delta
517/// baseline AND the runner's OWN kernel history cache (the resident registry lives
518/// with the runner — thread or worker — not on main) and yields no reply.
519///
520/// `progress` is called before every feature a `Run` actually executes, with
521/// the report the driver should ship as [`Reply::Progress`]; returning `false`
522/// stops the run at that boundary (the native thread's cooperative cancel —
523/// the worker is terminated instead and always returns `true`).
524pub fn process_command(
525    runner: &mut crate::pipeline::SceneRunner,
526    command: Command,
527    progress: &mut dyn FnMut(RunProgress) -> bool,
528) -> Option<Reply> {
529    match command {
530        Command::Run {
531            request,
532            generation,
533            parts_library_revision,
534        } => {
535            // PREFLIGHT (see `Reply::NeedPartsLibrary`). Two independent
536            // checks, because neither alone is enough:
537            //
538            // * the revision stamp catches CHANGED content the sender knows
539            //   about but this store has not received;
540            // * `missing_library_parts` catches content that is simply GONE
541            //   here — the orphan GC at the end of every run drops entries no
542            //   ACOMP in THAT run referenced, so an undo to zero components
543            //   empties this store while the sender's (which never ran) keeps
544            //   everything and its revision never moves. Only looking at the
545            //   actual content sees that.
546            let stale = match runner.parts_library_revision {
547                Some(installed) => installed != parts_library_revision,
548                // Nothing installed yet. Accept a run stamped 0 — that is
549                // either an empty library or a caller driving `submit_run`
550                // directly (the runner tests); the content preflight below is
551                // what actually protects the run. A non-zero stamp with nothing
552                // installed IS a gap: refuse it.
553                None => parts_library_revision != 0,
554            };
555            // Only a part the sender DID send and this store has since lost is
556            // worth asking for again. One it never sent is a dangling reference
557            // in the document itself: run it, so the ACOMP feature reports it
558            // the way it always has. (An install inserts every incoming name,
559            // so right after one this set can only be empty — which is what
560            // makes the ask-and-retry terminate.)
561            let recoverable = brep_kernel::missing_library_parts(&request)
562                .iter()
563                .any(|name| runner.parts_library_names.contains(name));
564            if stale || recoverable {
565                runner.parts_library_revision = None;
566                return Some(Reply::NeedPartsLibrary);
567            }
568            let output = runner.run_observed(&request, &mut |event| {
569                progress(RunProgress {
570                    generation,
571                    index: event.index,
572                    total: event.total,
573                    feature_id: event.id.to_string(),
574                    feature_type: event.feature_type.to_string(),
575                })
576            });
577            Some(Reply::Run(RunReply { generation, output }))
578        }
579        Command::SetPartsLibrary { library, revision } => {
580            runner.parts_library_names = library.keys().cloned().collect();
581            brep_kernel::install_parts_library(&library);
582            runner.parts_library_revision = Some(revision);
583            None
584        }
585        Command::Query(query) => {
586            let result = measure_json(runner, &query);
587            Some(Reply::Query(MeasureReply { id: query.id, result }))
588        }
589        Command::MeshImport(request) => Some(Reply::MeshImport(reconstruct_mesh(request))),
590        Command::StepProbe(request) => Some(Reply::StepProbe(probe_step(request))),
591        Command::Reset => {
592            // A document switch: drop the delta baseline AND this runner's OWN kernel
593            // history cache (the resident registry lives here, not on main), mirroring
594            // `set_history_json`'s main-thread clear so a new model rebuilds fully and
595            // the old model's handles are freed.
596            runner.reset();
597            brep_kernel::clear_history_cache();
598            None
599        }
600    }
601}
602
603// ===========================================================================
604// ThreadRunner (M2b): a persistent std::thread that OWNS the SceneRunner, so a
605// history run — and per-object measurement queries — execute OFF the main thread
606// and the native UI stays responsive during a run AND during selection. Native
607// only: `std::thread` + `std::sync::mpsc` do not exist on wasm32 (M3b lands a
608// worker impl behind this same trait), so the whole thing is cfg-gated out there.
609// ===========================================================================
610
611/// The persistent-thread runner. `submit_*`/`reset` push [`Command`]s down the
612/// channel; `poll_*` first DRAIN every ready [`Reply`] into the two demux buffers,
613/// then pop the matching one. The `SceneRunner` (and thus the kernel's resident
614/// registry it warms) lives ENTIRELY on the thread — it is never shared — so the
615/// only cross-thread traffic is the `Send` command/reply payloads.
616#[cfg(not(target_arch = "wasm32"))]
617pub struct ThreadRunner {
618    /// Main → thread. `Option` so [`Drop`] can take + drop it, ending the thread's
619    /// blocking `recv` after already-submitted work finishes.
620    tx: Option<std::sync::mpsc::Sender<Command>>,
621    /// Thread → main.
622    rx: std::sync::mpsc::Receiver<Reply>,
623    /// Dropped without joining so closing a busy preview never stalls the UI.
624    handle: Option<std::thread::JoinHandle<()>>,
625    /// Demuxed completed run replies awaiting `poll_run`.
626    run_buf: std::collections::VecDeque<RunReply>,
627    /// Demuxed completed measurement replies awaiting `poll_query`.
628    query_buf: std::collections::VecDeque<MeasureReply>,
629    mesh_import_buf: std::collections::VecDeque<MeshImportReply>,
630    step_probe_buf: std::collections::VecDeque<StepProbeReply>,
631    /// Progress reports of the in-flight run, oldest first (`poll_progress`
632    /// keeps the newest).
633    progress_buf: std::collections::VecDeque<RunProgress>,
634    /// The parts-library revision last SENT down the channel (`None` = never,
635    /// or the thread dropped it). Lives here rather than on the caller so
636    /// "reset forgets the library" is a local property of this object.
637    sent_library_revision: Option<u64>,
638    /// The thread refused a run for want of its library (drained by
639    /// [`HistoryRunner::poll_library_request`]).
640    library_requested: bool,
641    /// The cooperative stop flag THIS thread checks between features. Each
642    /// spawn gets its own, so a cancelled thread keeps its raised flag while
643    /// the replacement starts clean.
644    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
645}
646
647#[cfg(not(target_arch = "wasm32"))]
648impl ThreadRunner {
649    pub fn new() -> Self {
650        let (tx, cmd_rx) = std::sync::mpsc::channel::<Command>();
651        let (reply_tx, rx) = std::sync::mpsc::channel::<Reply>();
652        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
653        let thread_stop = stop.clone();
654        let handle = std::thread::Builder::new()
655            .name("brep-history-runner".to_string())
656            .spawn(move || thread_main(cmd_rx, reply_tx, thread_stop))
657            .expect("spawn brep-history-runner thread");
658        Self {
659            tx: Some(tx),
660            rx,
661            handle: Some(handle),
662            run_buf: std::collections::VecDeque::new(),
663            query_buf: std::collections::VecDeque::new(),
664            mesh_import_buf: std::collections::VecDeque::new(),
665            step_probe_buf: std::collections::VecDeque::new(),
666            progress_buf: std::collections::VecDeque::new(),
667            sent_library_revision: None,
668            library_requested: false,
669            stop,
670        }
671    }
672
673    /// Pull every ready reply off the channel and demux it into the run/query
674    /// buffers (so a `poll_run` never swallows a query reply and vice versa).
675    fn drain(&mut self) {
676        while let Ok(reply) = self.rx.try_recv() {
677            match reply {
678                Reply::Run(run) => self.run_buf.push_back(run),
679                Reply::Query(query) => self.query_buf.push_back(query),
680                Reply::MeshImport(reply) => self.mesh_import_buf.push_back(reply),
681                Reply::StepProbe(reply) => self.step_probe_buf.push_back(reply),
682                Reply::Progress(progress) => self.progress_buf.push_back(progress),
683                Reply::NeedPartsLibrary => {
684                    // The thread dropped (or never had) the library this run
685                    // needs. Forget what we believe it holds so the next
686                    // `sync_parts_library` re-installs, and flag the refused
687                    // run for the caller to re-submit.
688                    self.sent_library_revision = None;
689                    self.library_requested = true;
690                }
691            }
692        }
693    }
694}
695
696#[cfg(not(target_arch = "wasm32"))]
697impl Default for ThreadRunner {
698    fn default() -> Self {
699        Self::new()
700    }
701}
702
703/// The runner thread's whole life: block on the next command, batch it with every
704/// other command already queued, COALESCE consecutive `Run`s (a `Run` immediately
705/// followed by another `Run` — with no `Query`/`Reset` between — is dropped; only
706/// the last of each consecutive group runs), then process the batch IN ORDER so a
707/// `Reset` or a `Query` interleaved between two runs keeps its place. Exits when
708/// the command sender is dropped (`recv` errors) or the reply receiver is gone (a
709/// `send` errors — the main side went away).
710///
711/// `stop` is the cooperative cancel: raised by [`ThreadRunner::cancel`] on a
712/// thread that has already been abandoned, it is checked before every feature
713/// a run executes, so the abandoned thread finishes the feature it is on and
714/// exits at the next boundary instead of running the rest of the history for
715/// a receiver that is gone.
716#[cfg(not(target_arch = "wasm32"))]
717fn thread_main(
718    cmd_rx: std::sync::mpsc::Receiver<Command>,
719    reply_tx: std::sync::mpsc::Sender<Reply>,
720    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
721) {
722    let mut runner = crate::pipeline::SceneRunner::new();
723    while let Ok(first) = cmd_rx.recv() {
724        if stop.load(std::sync::atomic::Ordering::Relaxed) {
725            return;
726        }
727        // Gather this command plus everything else already waiting.
728        let mut batch = vec![first];
729        loop {
730            match cmd_rx.try_recv() {
731                Ok(command) => batch.push(command),
732                Err(_) => break, // Empty or Disconnected — process what we have.
733            }
734        }
735        // A Run immediately followed by another Run is coalesced away (its result
736        // would be overwritten before anything observed it); a Run followed by a
737        // Query/Reset/end still runs, so interleaved work sees the right geometry.
738        let mut run_here: Vec<bool> = vec![true; batch.len()];
739        for i in 0..batch.len() {
740            if matches!(batch[i], Command::Run { .. })
741                && matches!(batch.get(i + 1), Some(Command::Run { .. }))
742            {
743                run_here[i] = false;
744            }
745        }
746        // Process the KEPT commands in order through the SHARED `process_command`
747        // (byte-identical work to the wasm worker's per-message step), sending each
748        // reply it produces; a coalesced-away Run is skipped without touching the
749        // runner, so interleaved Query/Reset still see the right geometry.
750        for (i, command) in batch.into_iter().enumerate() {
751            if !run_here[i] {
752                continue;
753            }
754            let mut progress = |report: RunProgress| {
755                // A lost receiver means the main side abandoned this thread:
756                // stop at this boundary rather than finishing for nobody.
757                reply_tx.send(Reply::Progress(report)).is_ok()
758                    && !stop.load(std::sync::atomic::Ordering::Relaxed)
759            };
760            if let Some(reply) = process_command(&mut runner, command, &mut progress) {
761                if reply_tx.send(reply).is_err() {
762                    return; // The reply receiver is gone — the main side went away.
763                }
764            }
765        }
766    }
767}
768
769#[cfg(not(target_arch = "wasm32"))]
770impl HistoryRunner for ThreadRunner {
771    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
772        if let Some(tx) = &self.tx {
773            let _ = tx.send(Command::Run {
774                request,
775                generation,
776                parts_library_revision: self.sent_library_revision.unwrap_or(0),
777            });
778        }
779    }
780
781    fn sync_parts_library(
782        &mut self,
783        revision: u64,
784        fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
785    ) {
786        if self.sent_library_revision == Some(revision) {
787            return;
788        }
789        if let Some(tx) = &self.tx {
790            let _ = tx.send(Command::SetPartsLibrary {
791                library: fetch(),
792                revision,
793            });
794            self.sent_library_revision = Some(revision);
795        }
796    }
797
798    fn poll_library_request(&mut self) -> bool {
799        self.drain();
800        std::mem::take(&mut self.library_requested)
801    }
802
803    fn poll_run(&mut self) -> Option<RunReply> {
804        self.drain();
805        self.run_buf.pop_front()
806    }
807
808    fn submit_query(&mut self, query: MeasureQuery) {
809        if let Some(tx) = &self.tx {
810            let _ = tx.send(Command::Query(query));
811        }
812    }
813
814    fn poll_query(&mut self) -> Option<MeasureReply> {
815        self.drain();
816        self.query_buf.pop_front()
817    }
818
819    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
820        if let Some(tx) = &self.tx {
821            let _ = tx.send(Command::MeshImport(request));
822        }
823    }
824
825    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
826        self.drain();
827        self.mesh_import_buf.pop_front()
828    }
829
830    fn submit_step_probe(&mut self, request: StepProbeRequest) {
831        if let Some(tx) = &self.tx {
832            let _ = tx.send(Command::StepProbe(request));
833        }
834    }
835
836    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
837        self.drain();
838        self.step_probe_buf.pop_front()
839    }
840
841    fn poll_progress(&mut self) -> Option<RunProgress> {
842        self.drain();
843        let latest = self.progress_buf.pop_back();
844        self.progress_buf.clear();
845        latest
846    }
847
848    /// Abandon the thread: raise its stop flag, close its channels, and spawn a
849    /// fresh thread with a fresh registry. The old thread cannot be interrupted
850    /// inside a feature — it finishes the one it is on (burning CPU until
851    /// then), sees the flag, and exits. Always `true`: there is always a
852    /// thread to replace.
853    fn cancel(&mut self) -> bool {
854        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
855        self.tx.take();
856        self.handle.take();
857        *self = Self::new();
858        true
859    }
860
861    fn reset(&mut self) {
862        if let Some(tx) = &self.tx {
863            let _ = tx.send(Command::Reset);
864        }
865        // A reset is a wholesale document switch: any replies still buffered from
866        // the old model are stale — drop them (a fresh run/query supersedes).
867        self.run_buf.clear();
868        self.query_buf.clear();
869        self.mesh_import_buf.clear();
870        self.step_probe_buf.clear();
871        self.progress_buf.clear();
872        // `Command::Reset` clears the thread's kernel store, library included,
873        // so what we believe it holds is void.
874        self.sent_library_revision = None;
875        self.library_requested = false;
876    }
877}
878
879#[cfg(not(target_arch = "wasm32"))]
880impl Drop for ThreadRunner {
881    fn drop(&mut self) {
882        // Closing a document or cancelling an import preview must not block
883        // the UI on reconstruction. Closing the channel lets the worker exit
884        // and release its registry once already-submitted work finishes.
885        self.tx.take();
886        self.handle.take();
887    }
888}
889
890// ===========================================================================
891// ThreadRunner integration tests (native only — the async run/query machine the
892// wasm WorkerRunner (M3) reuses). Driven end-to-end: submit → spin poll → assert.
893// ===========================================================================
894#[cfg(all(test, not(target_arch = "wasm32")))]
895mod thread_tests {
896    use super::*;
897
898    /// A one-feature history: a P.CU cube `Box` of side 20 (volume 8000).
899    fn box_request() -> brep_kernel::HistoryRequest {
900        serde_json::from_str(
901            r#"{
902                "expressions": "", "configurator": {},
903                "features": [{
904                    "type": "P.CU",
905                    "inputParams": {
906                        "id": "Box", "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
907                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
908                        "boolean": { "targets": [], "operation": "NONE" }
909                    },
910                    "persistentData": {}
911                }]
912            }"#,
913        )
914        .unwrap()
915    }
916
917    fn spin_run(runner: &mut ThreadRunner) -> RunReply {
918        for _ in 0..3000 {
919            if let Some(reply) = runner.poll_run() {
920                return reply;
921            }
922            std::thread::sleep(std::time::Duration::from_millis(1));
923        }
924        panic!("thread run did not complete within the spin budget");
925    }
926
927    fn spin_query(runner: &mut ThreadRunner) -> MeasureReply {
928        for _ in 0..3000 {
929            if let Some(reply) = runner.poll_query() {
930                return reply;
931            }
932            std::thread::sleep(std::time::Duration::from_millis(1));
933        }
934        panic!("thread query did not complete within the spin budget");
935    }
936
937    /// Progress rides the reply channel ahead of the run's own reply: after a
938    /// run lands, `poll_progress` names the feature that executed. Cancel
939    /// abandons the thread — a run submitted to it never replies — and the
940    /// replacement thread serves the next submit under its own generation.
941    #[test]
942    fn thread_runner_reports_progress_and_cancel_starts_over() {
943        let mut runner = ThreadRunner::new();
944        runner.submit_run(box_request(), 1);
945        let reply = spin_run(&mut runner);
946        assert_eq!(reply.generation, 1);
947        let progress = runner.poll_progress().expect("the executed feature was reported");
948        assert_eq!(
949            progress,
950            RunProgress {
951                generation: 1,
952                index: 0,
953                total: 1,
954                feature_id: "Box".into(),
955                feature_type: "P.CU".into(),
956            }
957        );
958        assert!(runner.poll_progress().is_none(), "consumed");
959
960        // A warm re-run replays the box: nothing executes, nothing is reported.
961        runner.submit_run(box_request(), 2);
962        assert_eq!(spin_run(&mut runner).generation, 2);
963        assert!(runner.poll_progress().is_none());
964
965        // Cancel: whatever generation 3 would have replied is gone with the old
966        // thread; generation 4 runs on the fresh one (cold, so it reports).
967        runner.submit_run(box_request(), 3);
968        assert!(runner.cancel());
969        runner.submit_run(box_request(), 4);
970        let reply = spin_run(&mut runner);
971        assert_eq!(reply.generation, 4, "the replacement thread serves the new submit");
972        assert_eq!(
973            runner.poll_progress().map(|p| p.generation),
974            Some(4),
975            "the fresh registry executed the box again"
976        );
977        std::thread::sleep(std::time::Duration::from_millis(50));
978        assert!(runner.poll_run().is_none(), "no reply ever arrives for the abandoned run");
979    }
980
981    /// A STEP probe runs on the thread and its parsed assembly comes back
982    /// whole; the same request/reply pair survives the worker's JSON trip.
983    #[test]
984    fn thread_runner_probes_step_structure_and_the_reply_round_trips() {
985        let text = std::fs::read_to_string(concat!(
986            env!("CARGO_MANIFEST_DIR"),
987            "/../BREP_kernel/tests/fixtures/step-import/as1-ug-214.stp"
988        ))
989        .expect("the as1 fixture");
990        let mut runner = ThreadRunner::new();
991        runner.submit_step_probe(StepProbeRequest { id: 9, text: text.clone() });
992        let reply = (|| {
993            for _ in 0..30_000 {
994                if let Some(reply) = runner.poll_step_probe() {
995                    return reply;
996                }
997                std::thread::sleep(std::time::Duration::from_millis(1));
998            }
999            panic!("thread probe did not complete within the spin budget");
1000        })();
1001        assert_eq!(reply.id, 9);
1002        let assembly = reply.result.clone().expect("parses").expect("has structure");
1003        assert_eq!(assembly.products.len(), 9);
1004        assert_eq!(assembly.occurrences.len(), 13);
1005
1006        let json = serde_json::to_string(&Reply::StepProbe(reply)).unwrap();
1007        let back: Reply = serde_json::from_str(&json).unwrap();
1008        match back {
1009            Reply::StepProbe(reply) => {
1010                assert_eq!(reply.result.unwrap().unwrap().products.len(), 9)
1011            }
1012            _ => panic!("round-tripped to the wrong Reply variant"),
1013        }
1014        let command = serde_json::to_string(&Command::StepProbe(StepProbeRequest { id: 1, text }))
1015            .unwrap();
1016        assert!(matches!(
1017            serde_json::from_str::<Command>(&command).unwrap(),
1018            Command::StepProbe(StepProbeRequest { id: 1, .. })
1019        ));
1020
1021        // No structure: the flat-lane answer, and a broken file is an error.
1022        runner.submit_step_probe(StepProbeRequest { id: 10, text: "ISO-10303-21; garbage".into() });
1023        let reply = (|| {
1024            for _ in 0..30_000 {
1025                if let Some(reply) = runner.poll_step_probe() {
1026                    return reply;
1027                }
1028                std::thread::sleep(std::time::Duration::from_millis(1));
1029            }
1030            panic!("thread probe did not complete within the spin budget");
1031        })();
1032        assert_eq!(reply.id, 10);
1033        assert!(reply.result.is_err() || reply.result.unwrap().is_none());
1034    }
1035
1036    /// A submitted run executes ON THE THREAD and its reply is drained back with
1037    /// the right generation + a freshly-tessellated snapshot entry for the box.
1038    #[test]
1039    fn thread_runner_runs_and_replies() {
1040        let mut runner = ThreadRunner::new();
1041        runner.submit_run(box_request(), 1);
1042        let reply = spin_run(&mut runner);
1043        assert_eq!(reply.generation, 1, "reply carries its submit generation");
1044        assert_eq!(reply.output.snapshot.len(), 1, "one solid (the box)");
1045        let (name, _handle, display) = &reply.output.snapshot[0];
1046        assert_eq!(name, "Box");
1047        assert!(display.is_some(), "first emit is a fresh tessellation, not a reuse");
1048        assert_eq!(reply.output.provenance, vec![("Box".to_string(), "Box".to_string())]);
1049    }
1050
1051    /// A measurement query routes to the thread's WARM registry (populated by the
1052    /// preceding run) and returns the exact object-info fragment.
1053    #[test]
1054    fn thread_runner_measures_after_run() {
1055        let mut runner = ThreadRunner::new();
1056        runner.submit_run(box_request(), 1);
1057        let _ = spin_run(&mut runner);
1058        runner.submit_query(MeasureQuery {
1059            id: 7,
1060            kind: MeasureKind::Solid,
1061            owner: "Box".to_string(),
1062            entity: String::new(),
1063            density: 1.0,
1064        });
1065        let reply = spin_query(&mut runner);
1066        assert_eq!(reply.id, 7);
1067        let info: serde_json::Value = serde_json::from_str(&reply.result).unwrap();
1068        assert_eq!(info["ok"], true, "measured on the thread: {}", reply.result);
1069        assert!(
1070            (info["volume"].as_f64().unwrap() - 8000.0).abs() < 1.0,
1071            "box volume {} != 8000",
1072            info["volume"]
1073        );
1074    }
1075
1076    /// A re-submitted IDENTICAL run replays the thread's incremental cache: the
1077    /// second reply is a REUSE (`None` in the snapshot), proving the runner's
1078    /// baseline + the kernel cache both persist across submits on the thread.
1079    #[test]
1080    fn thread_runner_reuses_on_identical_resubmit() {
1081        let mut runner = ThreadRunner::new();
1082        runner.submit_run(box_request(), 1);
1083        let first = spin_run(&mut runner);
1084        assert!(first.output.snapshot[0].2.is_some(), "first is fresh");
1085
1086        runner.submit_run(box_request(), 2);
1087        let second = spin_run(&mut runner);
1088        assert_eq!(second.generation, 2);
1089        assert!(
1090            second.output.snapshot[0].2.is_none(),
1091            "identical resubmit replays as a REUSE (unchanged handle)"
1092        );
1093    }
1094
1095    // --- the PARTS-LIBRARY channel ---------------------------------------
1096    //
1097    // The library is sent when it CHANGES, not with every run (sending it per
1098    // run stringified megabytes of embedded part payload on the browser's main
1099    // thread for every edit). These cover the two ways that can go wrong.
1100
1101    /// A one-instance assembly: an ACOMP referencing part `widget`, plus the
1102    /// library entry that defines it.
1103    fn widget_part() -> (brep_kernel::PartsLibraryMap, brep_kernel::HistoryRequest) {
1104        let document = serde_json::json!({
1105            "expressions": "", "configurator": {},
1106            "features": [{
1107                "type": "P.CU",
1108                "inputParams": {
1109                    "id": "Part", "sizeX": 4.0, "sizeY": 4.0, "sizeZ": 4.0,
1110                    "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
1111                    "boolean": { "targets": [], "operation": "NONE" }
1112                },
1113                "persistentData": {}
1114            }]
1115        });
1116        let mut library = brep_kernel::PartsLibraryMap::new();
1117        library.insert(
1118            "widget".to_string(),
1119            brep_kernel::PartsLibraryEntry {
1120                document,
1121                ..Default::default()
1122            },
1123        );
1124        let request = serde_json::from_str(
1125            r#"{
1126                "expressions": "", "configurator": {},
1127                "features": [{
1128                    "type": "ACOMP",
1129                    "inputParams": {
1130                        "id": "ACOMP1", "partName": "widget",
1131                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] }
1132                    },
1133                    "persistentData": {}
1134                }]
1135            }"#,
1136        )
1137        .unwrap();
1138        (library, request)
1139    }
1140
1141    /// Spin until the thread either completes the run or REFUSES it for want of
1142    /// its parts library (`None`).
1143    fn spin_reply(runner: &mut ThreadRunner) -> Option<RunReply> {
1144        for _ in 0..3000 {
1145            if runner.poll_library_request() {
1146                return None;
1147            }
1148            if let Some(reply) = runner.poll_run() {
1149                return Some(reply);
1150            }
1151            std::thread::sleep(std::time::Duration::from_millis(1));
1152        }
1153        panic!("thread run neither completed nor asked for the library");
1154    }
1155
1156    /// The steady state: the library is installed ONCE and every later run
1157    /// resolves its ACOMP against the thread's resident copy — no run carries
1158    /// the payload, and the fetch closure is called exactly once.
1159    #[test]
1160    fn thread_runner_installs_the_library_once_and_reuses_it() {
1161        let (library, request) = widget_part();
1162        let mut runner = ThreadRunner::new();
1163        let mut fetches = 0;
1164        for generation in 1..=3 {
1165            runner.sync_parts_library(1, &mut || {
1166                fetches += 1;
1167                library.clone()
1168            });
1169            runner.submit_run(request.clone(), generation);
1170            let reply = spin_reply(&mut runner).expect("the run executed");
1171            assert_eq!(reply.generation, generation);
1172            assert_eq!(
1173                reply.output.snapshot.len(),
1174                1,
1175                "the ACOMP resolved against the resident library"
1176            );
1177        }
1178        assert_eq!(fetches, 1, "the library is sent once, not once per run");
1179    }
1180
1181    /// THE DRIFT CASE, and why the runner preflights CONTENT and not just a
1182    /// revision. The kernel's orphan GC drops entries no ACOMP in the run
1183    /// referenced, so a componentless run empties the THREAD's library while the
1184    /// sender's store — and therefore its revision — never moves. The next run
1185    /// that does reference the part must be refused, not executed against the
1186    /// emptied store, and must recover.
1187    #[test]
1188    fn thread_runner_recovers_after_its_library_is_gc_d() {
1189        let (library, request) = widget_part();
1190        let empty: brep_kernel::HistoryRequest =
1191            serde_json::from_str(r#"{"expressions":"","configurator":{},"features":[]}"#).unwrap();
1192        let mut runner = ThreadRunner::new();
1193
1194        runner.sync_parts_library(1, &mut || library.clone());
1195        runner.submit_run(request.clone(), 1);
1196        assert!(spin_reply(&mut runner).is_some(), "first run executes");
1197
1198        // A componentless run: the thread's GC drops `widget`. Nothing about the
1199        // revision changed — only the thread's CONTENT did.
1200        runner.sync_parts_library(1, &mut || library.clone());
1201        runner.submit_run(empty, 2);
1202        assert!(spin_reply(&mut runner).is_some(), "the empty run executes");
1203
1204        // Back to the assembly. The content preflight must catch the gap.
1205        runner.sync_parts_library(1, &mut || library.clone());
1206        runner.submit_run(request.clone(), 3);
1207        assert!(
1208            spin_reply(&mut runner).is_none(),
1209            "a run whose part the thread GC'd must be REFUSED, never run against \
1210             an empty library"
1211        );
1212
1213        // Recovery: the refusal forgot the sent revision, so this re-installs.
1214        let mut refetched = 0;
1215        runner.sync_parts_library(1, &mut || {
1216            refetched += 1;
1217            library.clone()
1218        });
1219        assert_eq!(refetched, 1, "the refusal forces a re-send");
1220        runner.submit_run(request, 4);
1221        let reply = spin_reply(&mut runner).expect("the re-driven run executes");
1222        assert_eq!(reply.generation, 4);
1223        assert_eq!(reply.output.snapshot.len(), 1, "the component is back");
1224    }
1225
1226    /// The revision half of the preflight: a run stamped for a library the
1227    /// runner never received is refused rather than executed.
1228    #[test]
1229    fn a_run_stamped_for_an_unknown_library_is_refused() {
1230        let (_library, request) = widget_part();
1231        let mut scene = crate::pipeline::SceneRunner::new();
1232        let refusal = process_command(
1233            &mut scene,
1234            Command::Run {
1235                request,
1236                generation: 1,
1237                parts_library_revision: 7,
1238            },
1239            &mut |_| true,
1240        );
1241        assert!(
1242            matches!(refusal, Some(Reply::NeedPartsLibrary)),
1243            "an unknown library stamp must refuse the run"
1244        );
1245        assert_eq!(
1246            scene.parts_library_revision, None,
1247            "the refusal drops what the runner believed it held"
1248        );
1249    }
1250
1251    /// The ask-and-retry must TERMINATE. A document referencing a part its own
1252    /// library never had is a dangling reference, not a lost cache entry: the
1253    /// run goes ahead so the ACOMP feature reports it, exactly as before the
1254    /// library moved off the request.
1255    #[test]
1256    fn a_part_the_library_never_had_errors_instead_of_looping() {
1257        let (_library, request) = widget_part();
1258        let mut runner = ThreadRunner::new();
1259        // Install an EMPTY library at revision 1 — `widget` was never sent.
1260        runner.sync_parts_library(1, &mut brep_kernel::PartsLibraryMap::new);
1261        runner.submit_run(request, 1);
1262        let reply = spin_reply(&mut runner)
1263            .expect("a dangling part name must not refuse the run forever");
1264        assert!(
1265            !reply.output.report.feature_errors.is_empty(),
1266            "the ACOMP reports the missing part: {:?}",
1267            reply.output.report
1268        );
1269    }
1270
1271    /// A `reset` clears the thread's delta baseline AND its kernel cache, so the
1272    /// next run rebuilds fresh (a new handle ⇒ a `Some` snapshot, not a reuse).
1273    #[test]
1274    fn thread_runner_reset_forces_full_rebuild() {
1275        let mut runner = ThreadRunner::new();
1276        runner.submit_run(box_request(), 1);
1277        let _ = spin_run(&mut runner);
1278        runner.reset();
1279        runner.submit_run(box_request(), 2);
1280        let reply = spin_run(&mut runner);
1281        assert!(
1282            reply.output.snapshot[0].2.is_some(),
1283            "after reset the box re-tessellates (baseline dropped)"
1284        );
1285    }
1286
1287    /// The wasm worker WIRE FORMAT: a `Command::Run` — carrying a `HistoryRequest`
1288    /// whose brand-new `Serialize` must honor its field renames (`type`,
1289    /// `inputParams`, …) — survives the serde-JSON round trip the `WorkerRunner`'s
1290    /// `postMessage` uses, and the round-tripped request still executes. (The
1291    /// `RunOutput`/`RunReply` reply half already has its own round-trip test.)
1292    #[test]
1293    fn run_command_round_trips_through_serde() {
1294        let command = Command::Run {
1295            request: box_request(),
1296            generation: 42,
1297            parts_library_revision: 9,
1298        };
1299        let json = serde_json::to_string(&command).expect("Command serializes");
1300        let back: Command = serde_json::from_str(&json).expect("Command deserializes");
1301        match back {
1302            Command::Run {
1303                request,
1304                generation,
1305                parts_library_revision,
1306            } => {
1307                assert_eq!(generation, 42, "generation round-trips");
1308                assert_eq!(parts_library_revision, 9, "library stamp round-trips");
1309                assert_eq!(request.features.len(), 1);
1310                // The `type` rename survived serialize → deserialize.
1311                assert_eq!(request.features[0].feature_type, "P.CU");
1312                // The deserialized request is still executable on a fresh runner.
1313                let output = crate::pipeline::SceneRunner::new().run(&request);
1314                assert_eq!(output.snapshot.len(), 1);
1315                assert_eq!(output.snapshot[0].0, "Box");
1316            }
1317            _ => panic!("round-tripped to the wrong Command variant"),
1318        }
1319    }
1320
1321    /// Browser workers receive the same mesh command as JSON. Preserve every
1322    /// source byte and the request id/format across that boundary.
1323    #[test]
1324    fn mesh_import_command_round_trips_through_serde() {
1325        let command = Command::MeshImport(MeshImportRequest {
1326            id: 17,
1327            format: MeshImportFormat::Stl,
1328            bytes: vec![0, 1, 127, 128, 255],
1329            options: StlConversionOptions {
1330                kernel_fit_tolerance: 2e-5,
1331                ..Default::default()
1332            },
1333        });
1334        let json = serde_json::to_string(&command).expect("mesh command serializes");
1335        let back: Command = serde_json::from_str(&json).expect("mesh command deserializes");
1336        match back {
1337            Command::MeshImport(request) => {
1338                assert_eq!(request.id, 17);
1339                assert!(matches!(request.format, MeshImportFormat::Stl));
1340                assert_eq!(request.bytes, vec![0, 1, 127, 128, 255]);
1341                assert_eq!(request.options.kernel_fit_tolerance, 2e-5);
1342            }
1343            _ => panic!("round-tripped to the wrong Command variant"),
1344        }
1345    }
1346}