BREP_render 0.4.0

BREP Rust rendering engine: kernel-fed scene store + wgpu renderer (headless artifact, desktop window, and wasm canvas shells).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
//! The history-run seam — the async RUN machine behind a trait, mirroring the
//! platform-seam shape of `brep-app/src/store.rs`'s `ModelStore` (a trait with
//! impls behind it, async surfaced via a poll idiom).
//!
//! A history run is split SUBMIT → POLL/APPLY: [`HistoryRunner::submit_run`]
//! kicks off a run tagged with a monotonic generation, and the completed
//! [`RunReply`] is drained later via [`HistoryRunner::poll_run`]. The runner OWNS
//! the [`SceneRunner`](crate::pipeline::SceneRunner) — so a future thread/worker
//! impl owns the resident registry that `execute_history` populates — and holds
//! the delta baseline across reruns.
//!
//! This slice ships the DEFAULT [`InlineRunner`]: it runs on `submit_run` and
//! stashes the reply for an immediate `poll_run`, so the run stays synchronous
//! and byte-identical to the pre-seam in-process run. A native-thread impl (M2b)
//! and a wasm-worker impl (M3) slot in behind the SAME trait — `submit_run`
//! defers the work and `poll_run` surfaces it a frame (or many) later, so the
//! `EngineState::pump` caller never changes.

/// A completed history run, tagged with the [`generation`](Self::generation) it
/// was submitted under so the applier can drop stale replies (a newer run that
/// finished first). The [`output`](Self::output) is the [`SceneRunner`] delta to
/// apply to the display scene.
///
/// [`SceneRunner`]: crate::pipeline::SceneRunner
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RunReply {
    /// The monotonic generation this run was submitted under.
    pub generation: u64,
    /// The delta snapshot + report the run produced.
    pub output: crate::pipeline::RunOutput,
}

/// A feature about to EXECUTE inside an in-flight run (cached replays are
/// instant and are not reported): which one, of how many, under which
/// generation. Posted by the runner before the kernel starts the feature, so
/// the UI can name what it is waiting on — and, after a cancel, what it was
/// waiting on.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RunProgress {
    /// The generation of the run this belongs to (see [`RunReply::generation`]).
    pub generation: u64,
    /// Zero-based position of the feature in the request.
    pub index: usize,
    /// The request's feature count.
    pub total: usize,
    pub feature_id: String,
    pub feature_type: String,
}

/// A STEP text to PROBE for product structure on the runner — the parse that
/// `brep_kernel::read_step_assembly` performs, which builds every product's
/// bodies and takes seconds on a real assembly (8.3 s natively for a 5 MB,
/// 140-product file), so it must not run on the browser's main thread.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StepProbeRequest {
    pub id: u64,
    pub text: String,
}

/// The probe's answer: the parsed assembly (`Some`), no structure (`None`),
/// or a parse failure. The assembly crosses back to the main side whole — its
/// JSON is large (74 MB for the file above) but the trip costs a third of a
/// second against the seconds the parse took.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StepProbeReply {
    pub id: u64,
    pub result: Result<Option<brep_kernel::StepAssembly>, String>,
}

/// Which exact measurement a [`MeasureQuery`] wants, mirroring the object-info
/// kinds ([`crate::metadata`]): a whole solid, one named face, or one named edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum MeasureKind {
    Solid,
    Face,
    Edge,
}

/// A per-object MEASUREMENT request routed to the runner (which owns the warm
/// registry). The runner resolves `owner` to its resident handle and measures by
/// [`kind`](Self::kind); the reply is the object-info JSON fragment MINUS the
/// main-injected `name`/`creatingFeature` fields. Tagged with a monotonic
/// [`id`](Self::id) so the main side can pair the reply with its pending request.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MeasureQuery {
    /// Monotonic request id (main pairs the reply back by it).
    pub id: u64,
    /// The measurement to run.
    pub kind: MeasureKind,
    /// The OWNING solid name (a solid is its own owner; a face/edge names its solid).
    pub owner: String,
    /// The face/edge NAME to measure (ignored for a whole-solid query).
    pub entity: String,
    /// The density (mass per mm³) to scale a solid's weight (ignored for face/edge).
    pub density: f64,
}

/// A completed [`MeasureQuery`]: the object-info measurement fields as a JSON
/// fragment (WITHOUT `name`/`creatingFeature`, which the main thread injects from
/// its eager provenance), tagged with the request [`id`](Self::id).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MeasureReply {
    /// The request id this answers.
    pub id: u64,
    /// The measurement JSON fragment (see [`measure_json`]).
    pub result: String,
}

pub use brep_reconstruction::stl_conversion::{
    ConversionPolicy, StlConversionOptions, StlConversionOutput,
};

/// Mesh encoding accepted by the off-thread reconstruction channel.
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub enum MeshImportFormat {
    Stl,
    Obj,
}

/// A mesh reconstruction request. The byte payload moves to the native thread
/// or is serialized to the browser worker; no parsing or fitting happens on UI.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct MeshImportRequest {
    pub id: u64,
    pub format: MeshImportFormat,
    pub bytes: Vec<u8>,
    pub options: StlConversionOptions,
}

/// Completed off-thread reconstruction. Success carries validated STEP and
/// diagnostics for a preview or an ordinary IMPORT3D history insertion.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct MeshImportReply {
    pub id: u64,
    pub result: Result<StlConversionOutput, String>,
}

/// The history-run seam: SUBMIT a run, POLL for its completed reply, RESET the
/// delta baseline, plus a QUERY channel for per-object measurements (routed to the
/// runner so the warm registry answers them, never the cold main-side one). The
/// Inline impl runs everything synchronously; a later thread/worker impl defers
/// the work and surfaces the replies through the same poll idiom.
pub trait HistoryRunner {
    /// Submit a history run tagged with a monotonic generation. The runner executes
    /// it (immediately for Inline; on a background thread later) and makes the reply
    /// available via [`poll_run`](Self::poll_run).
    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64);
    /// Non-blocking: the next completed run reply, if any (drained each frame).
    fn poll_run(&mut self) -> Option<RunReply>;
    /// Submit a per-object measurement query (answered against the runner's warm
    /// registry). Inline computes it immediately; a thread impl computes it on the
    /// runner thread and surfaces it via [`poll_query`](Self::poll_query).
    fn submit_query(&mut self, query: MeasureQuery);
    /// Non-blocking: the next completed measurement reply, if any.
    fn poll_query(&mut self) -> Option<MeasureReply>;
    /// Submit RANSAC mesh reconstruction to the runner's thread/worker.
    fn submit_mesh_import(&mut self, request: MeshImportRequest);
    /// Non-blocking: the next completed mesh reconstruction, if any.
    fn poll_mesh_import(&mut self) -> Option<MeshImportReply>;
    /// Submit a STEP product-structure probe (see [`StepProbeRequest`]).
    fn submit_step_probe(&mut self, request: StepProbeRequest);
    /// Non-blocking: the next completed STEP probe, if any.
    fn poll_step_probe(&mut self) -> Option<StepProbeReply>;
    /// Drop the delta baseline (document switch → full rebuild).
    fn reset(&mut self);

    /// Bring the runner's resident PARTS LIBRARY up to `revision`, calling
    /// `fetch` ONLY when it is not already there. Called immediately before
    /// every [`submit_run`](Self::submit_run).
    ///
    /// This is the whole point of the library channel: the library is sent
    /// when it CHANGES (insert, document load, refresh), not on every run.
    /// Cheap to call — the revision comparison is an integer, and `fetch`
    /// (which clones the store) never runs in the steady state. The default is
    /// a no-op for [`InlineRunner`], which shares the caller's kernel store.
    fn sync_parts_library(
        &mut self,
        _revision: u64,
        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
    ) {
    }

    /// True when the runner REFUSED a run because its resident parts library
    /// could not serve it (see [`Reply::NeedPartsLibrary`]). It has already
    /// forgotten its copy, so the next `sync_parts_library` reinstalls; the
    /// caller must re-submit the run. Never true for a runner that shares the
    /// caller's store.
    fn poll_library_request(&mut self) -> bool {
        false
    }

    /// Non-blocking: the MOST RECENT progress report of the in-flight run, with
    /// any older ones discarded (only the latest feature matters). `None` for
    /// a synchronous runner — Inline has finished before anyone could ask.
    fn poll_progress(&mut self) -> Option<RunProgress> {
        None
    }

    /// ABANDON the in-flight work and start over with an EMPTY resident
    /// registry. Returns `false` when there is nothing this runner can abandon
    /// (Inline: the run already completed on the caller's thread). A `true`
    /// means every handle the caller holds is now invalid, no reply is coming
    /// for anything submitted so far, and the parts library must be re-sent —
    /// the caller reconciles its generations and pending sets accordingly
    /// (`EngineState::cancel_run`). Nothing inside a feature is interruptible:
    /// the thread runner lets its old thread finish the feature it is on and
    /// stop at the next boundary; the browser worker is terminated outright.
    fn cancel(&mut self) -> bool {
        false
    }
}

/// Measure `query` against `runner`'s resident geometry and emit the object-info
/// JSON FRAGMENT — EXACTLY the fields [`crate::metadata::EngineState::object_info_json`]
/// emits for that kind EXCEPT `name` and `creatingFeature` (the main thread injects
/// those from its eager provenance, so the merged output is byte-identical to the
/// pre-seam in-process result). Shared verbatim by the Inline and thread runners so
/// both produce the identical fragment. A missing handle / kernel error yields
/// `{ "ok": false, "message": .. }` (main injects `name`).
fn measure_json(runner: &crate::pipeline::SceneRunner, query: &MeasureQuery) -> String {
    let Some(handle) = runner.handle_of(&query.owner) else {
        return serde_json::json!({
            "ok": false,
            "message": format!("solid '{}' has no resident geometry", query.owner),
        })
        .to_string();
    };
    match query.kind {
        MeasureKind::Solid => {
            match brep_kernel::mass_properties_handle_native(handle, query.density) {
                Ok(properties) => {
                    let edge_total =
                        brep_kernel::solid_edge_length_total_native(handle).unwrap_or(0.0);
                    serde_json::json!({
                        "ok": true,
                        "kind": "solid",
                        "volume": properties.volume,
                        "surfaceArea": properties.surface_area,
                        "edgeLengthTotal": edge_total,
                        "density": properties.density,
                        "weight": properties.mass,
                    })
                    .to_string()
                }
                Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
            }
        }
        MeasureKind::Face => match brep_kernel::face_measurements_native(handle, &query.entity) {
            Ok((area, edge_total, surface_type)) => serde_json::json!({
                "ok": true,
                "kind": "face",
                "solid": query.owner,
                "surfaceType": surface_type,
                "area": area,
                "edgeLengthTotal": edge_total,
            })
            .to_string(),
            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
        },
        MeasureKind::Edge => match brep_kernel::edge_length_native(handle, &query.entity) {
            Ok(length) => serde_json::json!({
                "ok": true,
                "kind": "edge",
                "solid": query.owner,
                "length": length,
            })
            .to_string(),
            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
        },
    }
}

/// The default, SYNCHRONOUS runner: runs on submit, stashes the reply for an
/// immediate poll. Behavior-identical to the pre-seam in-process run.
pub struct InlineRunner {
    /// The scene-free history runner it owns (delta baseline lives here).
    runner: crate::pipeline::SceneRunner,
    /// Completed replies awaiting a poll. For Inline this holds exactly one entry
    /// between a `submit_run` and the immediately-following `poll_run`.
    pending: std::collections::VecDeque<RunReply>,
    /// Completed measurement replies awaiting a poll (computed synchronously on
    /// `submit_query`, popped on `poll_query`) — the synchronous mirror of the
    /// thread runner's query buffer, so the object-info path resolves same-call.
    query_pending: std::collections::VecDeque<MeasureReply>,
    mesh_import_pending: std::collections::VecDeque<MeshImportReply>,
    step_probe_pending: std::collections::VecDeque<StepProbeReply>,
}

impl InlineRunner {
    pub fn new() -> Self {
        Self {
            runner: crate::pipeline::SceneRunner::new(),
            pending: std::collections::VecDeque::new(),
            query_pending: std::collections::VecDeque::new(),
            mesh_import_pending: std::collections::VecDeque::new(),
            step_probe_pending: std::collections::VecDeque::new(),
        }
    }
}

impl HistoryRunner for InlineRunner {
    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
        let output = self.runner.run(&request);
        self.pending.push_back(RunReply { generation, output });
    }

    /// Nothing to send: Inline runs on the CALLER's thread against the CALLER's
    /// kernel store, so the library the caller would hand over is already the
    /// one this run resolves against. (Which is also why Inline never needs the
    /// preflight — there is only one store, and the caller seeds it directly.)
    fn sync_parts_library(
        &mut self,
        _revision: u64,
        _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
    ) {
    }

    fn poll_run(&mut self) -> Option<RunReply> {
        self.pending.pop_front()
    }

    fn submit_query(&mut self, query: MeasureQuery) {
        let result = measure_json(&self.runner, &query);
        self.query_pending.push_back(MeasureReply { id: query.id, result });
    }

    fn poll_query(&mut self) -> Option<MeasureReply> {
        self.query_pending.pop_front()
    }

    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
        self.mesh_import_pending
            .push_back(reconstruct_mesh(request));
    }

    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
        self.mesh_import_pending.pop_front()
    }

    fn submit_step_probe(&mut self, request: StepProbeRequest) {
        self.step_probe_pending.push_back(probe_step(request));
    }

    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
        self.step_probe_pending.pop_front()
    }

    fn reset(&mut self) {
        self.runner.reset();
    }
}

impl Default for InlineRunner {
    fn default() -> Self {
        Self::new()
    }
}

// ===========================================================================
// Shared run/query PROTOCOL (M3b): the `Command`/`Reply` message pair and the
// `process_command` step. Both async runners speak it — the native `ThreadRunner`
// ships the enums over `mpsc` (no serialization), and the wasm `WorkerRunner`
// serializes them to JSON for `postMessage`. Hence the serde derives (the enums
// carry serde types: `HistoryRequest`/`MeasureQuery`/`RunReply`/`MeasureReply`)
// and `process_command` being un-gated + shared so both drivers do the SAME work.
// ===========================================================================

/// A command sent main → runner (thread channel OR worker `postMessage`) over one
/// ordered stream (so a `Reset` before a `Run` stays before it). `Run` carries the
/// whole request; the driver coalesces consecutive `Run`s to shed a slider drag's
/// backlog (the thread in `thread_main`, the worker's main side in `WorkerRunner`).
#[derive(serde::Serialize, serde::Deserialize)]
pub enum Command {
    Run {
        request: brep_kernel::HistoryRequest,
        generation: u64,
        /// The [`brep_kernel::parts_library_revision`] this run was built
        /// against. The runner refuses to execute a run stamped for a library
        /// it does not hold — see [`process_command`].
        #[serde(default)]
        parts_library_revision: u64,
    },
    /// Install the parts library on the runner's OWN kernel store. Sent only
    /// when the library actually changes (insert, document load, refresh),
    /// never per run: it carries the whole embedded-part payload, which for an
    /// imported STEP assembly is megabytes, and stringifying that on the
    /// browser's main thread once per edit is what froze the UI.
    ///
    /// Deliberately its OWN command rather than an optional field on `Run`:
    /// both drivers COALESCE consecutive runs (see [`thread_main`] and
    /// `WorkerRunner::submit_run`), so a library riding on a run could be
    /// dropped with it. A `SetPartsLibrary` is never coalesced away, and the
    /// stream is ordered, so it always lands before the run that needs it.
    SetPartsLibrary {
        library: brep_kernel::PartsLibraryMap,
        revision: u64,
    },
    Query(MeasureQuery),
    MeshImport(MeshImportRequest),
    StepProbe(StepProbeRequest),
    Reset,
}

/// A reply sent runner → main; the main side demuxes it into per-kind buffers.
#[derive(serde::Serialize, serde::Deserialize)]
pub enum Reply {
    Run(RunReply),
    Query(MeasureReply),
    MeshImport(MeshImportReply),
    StepProbe(StepProbeReply),
    /// The runner REFUSED a run because its resident parts library could not
    /// serve it (a part the request references is missing, or the run was
    /// stamped for a different library revision). No geometry was touched; the
    /// main side re-sends the library and re-submits. Refusing is the whole
    /// point — running with the wrong library would be silently wrong
    /// geometry, which is far worse than one extra round trip.
    NeedPartsLibrary,
    /// A feature of the in-flight run is about to execute (see
    /// [`RunProgress`]). Posted from INSIDE `process_command`, before its
    /// `Reply::Run`; the main side keeps only the latest.
    Progress(RunProgress),
}

/// The probe itself — the one parse of a structured STEP import (see
/// `EngineState::submit_step_probe`), run wherever the runner runs.
fn probe_step(request: StepProbeRequest) -> StepProbeReply {
    StepProbeReply {
        id: request.id,
        result: brep_kernel::read_step_assembly(&request.text),
    }
}

fn reconstruct_mesh(request: MeshImportRequest) -> MeshImportReply {
    let result = (|| {
        use brep_reconstruction::stl_conversion::{
            binary_stl_coordinate_precision_tolerance, convert_stl_mesh_to_step,
        };
        use brep_reconstruction::{Mesh, Vec3};

        let (mesh, positions, indices, coordinate_precision_tolerance) = match request.format {
            MeshImportFormat::Stl => {
                use brep_reconstruction::stl::{parse_stl_bytes, StlFormat, StlReadOptions};
                let read_options = StlReadOptions {
                    weld_tolerance: (request.options.weld_tolerance >= 0.0)
                        .then_some(request.options.weld_tolerance),
                };
                let imported = parse_stl_bytes(&request.bytes, &read_options)
                    .map_err(|error| format!("STL import failed: {error}"))?;
                let positions = imported
                    .mesh
                    .vertices
                    .iter()
                    .flat_map(|point| [point.x, point.y, point.z])
                    .collect::<Vec<_>>();
                let indices = imported
                    .mesh
                    .triangles
                    .iter()
                    .flatten()
                    .copied()
                    .collect::<Vec<_>>();
                let precision = if imported.format == StlFormat::Binary {
                    binary_stl_coordinate_precision_tolerance(&imported.mesh)
                } else {
                    0.0
                };
                (imported.mesh, positions, indices, precision)
            }
            MeshImportFormat::Obj => {
                let text = std::str::from_utf8(&request.bytes)
                    .map_err(|_| "OBJ import failed: file is not UTF-8 text".to_string())?;
                let obj = brep_kernel::read_obj(text)
                    .map_err(|error| format!("OBJ import failed: {error}"))?;
                let vertices = obj
                    .positions
                    .chunks_exact(3)
                    .map(|point| Vec3::new(point[0], point[1], point[2]))
                    .collect::<Vec<_>>();
                let triangles = obj
                    .indices
                    .chunks_exact(3)
                    .map(|triangle| [triangle[0], triangle[1], triangle[2]])
                    .collect::<Vec<_>>();
                (
                    Mesh::new(vertices, triangles),
                    obj.positions,
                    obj.indices,
                    0.0,
                )
            }
        };
        let mut options = request.options;
        options.coordinate_precision_tolerance = options.coordinate_precision_tolerance
            .max(coordinate_precision_tolerance);
        convert_stl_mesh_to_step(
            &mesh,
            &positions,
            Some(&indices),
            &options,
            "Imported mesh",
            "MM",
            "",
        )
        .map_err(|error| format!("RANSAC reconstruction failed: {error}"))
    })();
    MeshImportReply {
        id: request.id,
        result,
    }
}

/// Execute ONE [`Command`] against `runner` and return the [`Reply`] it produces,
/// if any. The shared step both async drivers run: the native [`thread_main`]
/// calls it for each (post-coalescing) command on the runner thread; the wasm
/// `WorkerRunner`'s `worker_entry` calls it per `postMessage` on the worker.
/// `Run` → a [`Reply::Run`]; `Query` → a [`Reply::Query`]; `Reset` drops the delta
/// baseline AND the runner's OWN kernel history cache (the resident registry lives
/// with the runner — thread or worker — not on main) and yields no reply.
///
/// `progress` is called before every feature a `Run` actually executes, with
/// the report the driver should ship as [`Reply::Progress`]; returning `false`
/// stops the run at that boundary (the native thread's cooperative cancel —
/// the worker is terminated instead and always returns `true`).
pub fn process_command(
    runner: &mut crate::pipeline::SceneRunner,
    command: Command,
    progress: &mut dyn FnMut(RunProgress) -> bool,
) -> Option<Reply> {
    match command {
        Command::Run {
            request,
            generation,
            parts_library_revision,
        } => {
            // PREFLIGHT (see `Reply::NeedPartsLibrary`). Two independent
            // checks, because neither alone is enough:
            //
            // * the revision stamp catches CHANGED content the sender knows
            //   about but this store has not received;
            // * `missing_library_parts` catches content that is simply GONE
            //   here — the orphan GC at the end of every run drops entries no
            //   ACOMP in THAT run referenced, so an undo to zero components
            //   empties this store while the sender's (which never ran) keeps
            //   everything and its revision never moves. Only looking at the
            //   actual content sees that.
            let stale = match runner.parts_library_revision {
                Some(installed) => installed != parts_library_revision,
                // Nothing installed yet. Accept a run stamped 0 — that is
                // either an empty library or a caller driving `submit_run`
                // directly (the runner tests); the content preflight below is
                // what actually protects the run. A non-zero stamp with nothing
                // installed IS a gap: refuse it.
                None => parts_library_revision != 0,
            };
            // Only a part the sender DID send and this store has since lost is
            // worth asking for again. One it never sent is a dangling reference
            // in the document itself: run it, so the ACOMP feature reports it
            // the way it always has. (An install inserts every incoming name,
            // so right after one this set can only be empty — which is what
            // makes the ask-and-retry terminate.)
            let recoverable = brep_kernel::missing_library_parts(&request)
                .iter()
                .any(|name| runner.parts_library_names.contains(name));
            if stale || recoverable {
                runner.parts_library_revision = None;
                return Some(Reply::NeedPartsLibrary);
            }
            let output = runner.run_observed(&request, &mut |event| {
                progress(RunProgress {
                    generation,
                    index: event.index,
                    total: event.total,
                    feature_id: event.id.to_string(),
                    feature_type: event.feature_type.to_string(),
                })
            });
            Some(Reply::Run(RunReply { generation, output }))
        }
        Command::SetPartsLibrary { library, revision } => {
            runner.parts_library_names = library.keys().cloned().collect();
            brep_kernel::install_parts_library(&library);
            runner.parts_library_revision = Some(revision);
            None
        }
        Command::Query(query) => {
            let result = measure_json(runner, &query);
            Some(Reply::Query(MeasureReply { id: query.id, result }))
        }
        Command::MeshImport(request) => Some(Reply::MeshImport(reconstruct_mesh(request))),
        Command::StepProbe(request) => Some(Reply::StepProbe(probe_step(request))),
        Command::Reset => {
            // A document switch: drop the delta baseline AND this runner's OWN kernel
            // history cache (the resident registry lives here, not on main), mirroring
            // `set_history_json`'s main-thread clear so a new model rebuilds fully and
            // the old model's handles are freed.
            runner.reset();
            brep_kernel::clear_history_cache();
            None
        }
    }
}

// ===========================================================================
// ThreadRunner (M2b): a persistent std::thread that OWNS the SceneRunner, so a
// history run — and per-object measurement queries — execute OFF the main thread
// and the native UI stays responsive during a run AND during selection. Native
// only: `std::thread` + `std::sync::mpsc` do not exist on wasm32 (M3b lands a
// worker impl behind this same trait), so the whole thing is cfg-gated out there.
// ===========================================================================

/// The persistent-thread runner. `submit_*`/`reset` push [`Command`]s down the
/// channel; `poll_*` first DRAIN every ready [`Reply`] into the two demux buffers,
/// then pop the matching one. The `SceneRunner` (and thus the kernel's resident
/// registry it warms) lives ENTIRELY on the thread — it is never shared — so the
/// only cross-thread traffic is the `Send` command/reply payloads.
#[cfg(not(target_arch = "wasm32"))]
pub struct ThreadRunner {
    /// Main → thread. `Option` so [`Drop`] can take + drop it, ending the thread's
    /// blocking `recv` after already-submitted work finishes.
    tx: Option<std::sync::mpsc::Sender<Command>>,
    /// Thread → main.
    rx: std::sync::mpsc::Receiver<Reply>,
    /// Dropped without joining so closing a busy preview never stalls the UI.
    handle: Option<std::thread::JoinHandle<()>>,
    /// Demuxed completed run replies awaiting `poll_run`.
    run_buf: std::collections::VecDeque<RunReply>,
    /// Demuxed completed measurement replies awaiting `poll_query`.
    query_buf: std::collections::VecDeque<MeasureReply>,
    mesh_import_buf: std::collections::VecDeque<MeshImportReply>,
    step_probe_buf: std::collections::VecDeque<StepProbeReply>,
    /// Progress reports of the in-flight run, oldest first (`poll_progress`
    /// keeps the newest).
    progress_buf: std::collections::VecDeque<RunProgress>,
    /// The parts-library revision last SENT down the channel (`None` = never,
    /// or the thread dropped it). Lives here rather than on the caller so
    /// "reset forgets the library" is a local property of this object.
    sent_library_revision: Option<u64>,
    /// The thread refused a run for want of its library (drained by
    /// [`HistoryRunner::poll_library_request`]).
    library_requested: bool,
    /// The cooperative stop flag THIS thread checks between features. Each
    /// spawn gets its own, so a cancelled thread keeps its raised flag while
    /// the replacement starts clean.
    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
}

#[cfg(not(target_arch = "wasm32"))]
impl ThreadRunner {
    pub fn new() -> Self {
        let (tx, cmd_rx) = std::sync::mpsc::channel::<Command>();
        let (reply_tx, rx) = std::sync::mpsc::channel::<Reply>();
        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        let thread_stop = stop.clone();
        let handle = std::thread::Builder::new()
            .name("brep-history-runner".to_string())
            .spawn(move || thread_main(cmd_rx, reply_tx, thread_stop))
            .expect("spawn brep-history-runner thread");
        Self {
            tx: Some(tx),
            rx,
            handle: Some(handle),
            run_buf: std::collections::VecDeque::new(),
            query_buf: std::collections::VecDeque::new(),
            mesh_import_buf: std::collections::VecDeque::new(),
            step_probe_buf: std::collections::VecDeque::new(),
            progress_buf: std::collections::VecDeque::new(),
            sent_library_revision: None,
            library_requested: false,
            stop,
        }
    }

    /// Pull every ready reply off the channel and demux it into the run/query
    /// buffers (so a `poll_run` never swallows a query reply and vice versa).
    fn drain(&mut self) {
        while let Ok(reply) = self.rx.try_recv() {
            match reply {
                Reply::Run(run) => self.run_buf.push_back(run),
                Reply::Query(query) => self.query_buf.push_back(query),
                Reply::MeshImport(reply) => self.mesh_import_buf.push_back(reply),
                Reply::StepProbe(reply) => self.step_probe_buf.push_back(reply),
                Reply::Progress(progress) => self.progress_buf.push_back(progress),
                Reply::NeedPartsLibrary => {
                    // The thread dropped (or never had) the library this run
                    // needs. Forget what we believe it holds so the next
                    // `sync_parts_library` re-installs, and flag the refused
                    // run for the caller to re-submit.
                    self.sent_library_revision = None;
                    self.library_requested = true;
                }
            }
        }
    }
}

#[cfg(not(target_arch = "wasm32"))]
impl Default for ThreadRunner {
    fn default() -> Self {
        Self::new()
    }
}

/// The runner thread's whole life: block on the next command, batch it with every
/// other command already queued, COALESCE consecutive `Run`s (a `Run` immediately
/// followed by another `Run` — with no `Query`/`Reset` between — is dropped; only
/// the last of each consecutive group runs), then process the batch IN ORDER so a
/// `Reset` or a `Query` interleaved between two runs keeps its place. Exits when
/// the command sender is dropped (`recv` errors) or the reply receiver is gone (a
/// `send` errors — the main side went away).
///
/// `stop` is the cooperative cancel: raised by [`ThreadRunner::cancel`] on a
/// thread that has already been abandoned, it is checked before every feature
/// a run executes, so the abandoned thread finishes the feature it is on and
/// exits at the next boundary instead of running the rest of the history for
/// a receiver that is gone.
#[cfg(not(target_arch = "wasm32"))]
fn thread_main(
    cmd_rx: std::sync::mpsc::Receiver<Command>,
    reply_tx: std::sync::mpsc::Sender<Reply>,
    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
    let mut runner = crate::pipeline::SceneRunner::new();
    while let Ok(first) = cmd_rx.recv() {
        if stop.load(std::sync::atomic::Ordering::Relaxed) {
            return;
        }
        // Gather this command plus everything else already waiting.
        let mut batch = vec![first];
        loop {
            match cmd_rx.try_recv() {
                Ok(command) => batch.push(command),
                Err(_) => break, // Empty or Disconnected — process what we have.
            }
        }
        // A Run immediately followed by another Run is coalesced away (its result
        // would be overwritten before anything observed it); a Run followed by a
        // Query/Reset/end still runs, so interleaved work sees the right geometry.
        let mut run_here: Vec<bool> = vec![true; batch.len()];
        for i in 0..batch.len() {
            if matches!(batch[i], Command::Run { .. })
                && matches!(batch.get(i + 1), Some(Command::Run { .. }))
            {
                run_here[i] = false;
            }
        }
        // Process the KEPT commands in order through the SHARED `process_command`
        // (byte-identical work to the wasm worker's per-message step), sending each
        // reply it produces; a coalesced-away Run is skipped without touching the
        // runner, so interleaved Query/Reset still see the right geometry.
        for (i, command) in batch.into_iter().enumerate() {
            if !run_here[i] {
                continue;
            }
            let mut progress = |report: RunProgress| {
                // A lost receiver means the main side abandoned this thread:
                // stop at this boundary rather than finishing for nobody.
                reply_tx.send(Reply::Progress(report)).is_ok()
                    && !stop.load(std::sync::atomic::Ordering::Relaxed)
            };
            if let Some(reply) = process_command(&mut runner, command, &mut progress) {
                if reply_tx.send(reply).is_err() {
                    return; // The reply receiver is gone — the main side went away.
                }
            }
        }
    }
}

#[cfg(not(target_arch = "wasm32"))]
impl HistoryRunner for ThreadRunner {
    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
        if let Some(tx) = &self.tx {
            let _ = tx.send(Command::Run {
                request,
                generation,
                parts_library_revision: self.sent_library_revision.unwrap_or(0),
            });
        }
    }

    fn sync_parts_library(
        &mut self,
        revision: u64,
        fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
    ) {
        if self.sent_library_revision == Some(revision) {
            return;
        }
        if let Some(tx) = &self.tx {
            let _ = tx.send(Command::SetPartsLibrary {
                library: fetch(),
                revision,
            });
            self.sent_library_revision = Some(revision);
        }
    }

    fn poll_library_request(&mut self) -> bool {
        self.drain();
        std::mem::take(&mut self.library_requested)
    }

    fn poll_run(&mut self) -> Option<RunReply> {
        self.drain();
        self.run_buf.pop_front()
    }

    fn submit_query(&mut self, query: MeasureQuery) {
        if let Some(tx) = &self.tx {
            let _ = tx.send(Command::Query(query));
        }
    }

    fn poll_query(&mut self) -> Option<MeasureReply> {
        self.drain();
        self.query_buf.pop_front()
    }

    fn submit_mesh_import(&mut self, request: MeshImportRequest) {
        if let Some(tx) = &self.tx {
            let _ = tx.send(Command::MeshImport(request));
        }
    }

    fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
        self.drain();
        self.mesh_import_buf.pop_front()
    }

    fn submit_step_probe(&mut self, request: StepProbeRequest) {
        if let Some(tx) = &self.tx {
            let _ = tx.send(Command::StepProbe(request));
        }
    }

    fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
        self.drain();
        self.step_probe_buf.pop_front()
    }

    fn poll_progress(&mut self) -> Option<RunProgress> {
        self.drain();
        let latest = self.progress_buf.pop_back();
        self.progress_buf.clear();
        latest
    }

    /// Abandon the thread: raise its stop flag, close its channels, and spawn a
    /// fresh thread with a fresh registry. The old thread cannot be interrupted
    /// inside a feature — it finishes the one it is on (burning CPU until
    /// then), sees the flag, and exits. Always `true`: there is always a
    /// thread to replace.
    fn cancel(&mut self) -> bool {
        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
        self.tx.take();
        self.handle.take();
        *self = Self::new();
        true
    }

    fn reset(&mut self) {
        if let Some(tx) = &self.tx {
            let _ = tx.send(Command::Reset);
        }
        // A reset is a wholesale document switch: any replies still buffered from
        // the old model are stale — drop them (a fresh run/query supersedes).
        self.run_buf.clear();
        self.query_buf.clear();
        self.mesh_import_buf.clear();
        self.step_probe_buf.clear();
        self.progress_buf.clear();
        // `Command::Reset` clears the thread's kernel store, library included,
        // so what we believe it holds is void.
        self.sent_library_revision = None;
        self.library_requested = false;
    }
}

#[cfg(not(target_arch = "wasm32"))]
impl Drop for ThreadRunner {
    fn drop(&mut self) {
        // Closing a document or cancelling an import preview must not block
        // the UI on reconstruction. Closing the channel lets the worker exit
        // and release its registry once already-submitted work finishes.
        self.tx.take();
        self.handle.take();
    }
}

// BREP private tests: e94b3e6301fb59ea