Skip to main content

brep_render/
runner.rs

1//! The history-run seam — the async RUN machine behind a trait, mirroring the
2//! platform-seam shape of `brep-app/src/store.rs`'s `ModelStore` (a trait with
3//! impls behind it, async surfaced via a poll idiom).
4//!
5//! A history run is split SUBMIT → POLL/APPLY: [`HistoryRunner::submit_run`]
6//! kicks off a run tagged with a monotonic generation, and the completed
7//! [`RunReply`] is drained later via [`HistoryRunner::poll_run`]. The runner OWNS
8//! the [`SceneRunner`](crate::pipeline::SceneRunner) — so a future thread/worker
9//! impl owns the resident registry that `execute_history` populates — and holds
10//! the delta baseline across reruns.
11//!
12//! This slice ships the DEFAULT [`InlineRunner`]: it runs on `submit_run` and
13//! stashes the reply for an immediate `poll_run`, so the run stays synchronous
14//! and byte-identical to the pre-seam in-process run. A native-thread impl (M2b)
15//! and a wasm-worker impl (M3) slot in behind the SAME trait — `submit_run`
16//! defers the work and `poll_run` surfaces it a frame (or many) later, so the
17//! `EngineState::pump` caller never changes.
18
19/// A completed history run, tagged with the [`generation`](Self::generation) it
20/// was submitted under so the applier can drop stale replies (a newer run that
21/// finished first). The [`output`](Self::output) is the [`SceneRunner`] delta to
22/// apply to the display scene.
23///
24/// [`SceneRunner`]: crate::pipeline::SceneRunner
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct RunReply {
27    /// The monotonic generation this run was submitted under.
28    pub generation: u64,
29    /// The delta snapshot + report the run produced.
30    pub output: crate::pipeline::RunOutput,
31}
32
33/// Which exact measurement a [`MeasureQuery`] wants, mirroring the object-info
34/// kinds ([`crate::metadata`]): a whole solid, one named face, or one named edge.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub enum MeasureKind {
37    Solid,
38    Face,
39    Edge,
40}
41
42/// A per-object MEASUREMENT request routed to the runner (which owns the warm
43/// registry). The runner resolves `owner` to its resident handle and measures by
44/// [`kind`](Self::kind); the reply is the object-info JSON fragment MINUS the
45/// main-injected `name`/`creatingFeature` fields. Tagged with a monotonic
46/// [`id`](Self::id) so the main side can pair the reply with its pending request.
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct MeasureQuery {
49    /// Monotonic request id (main pairs the reply back by it).
50    pub id: u64,
51    /// The measurement to run.
52    pub kind: MeasureKind,
53    /// The OWNING solid name (a solid is its own owner; a face/edge names its solid).
54    pub owner: String,
55    /// The face/edge NAME to measure (ignored for a whole-solid query).
56    pub entity: String,
57    /// The density (mass per mm³) to scale a solid's weight (ignored for face/edge).
58    pub density: f64,
59}
60
61/// A completed [`MeasureQuery`]: the object-info measurement fields as a JSON
62/// fragment (WITHOUT `name`/`creatingFeature`, which the main thread injects from
63/// its eager provenance), tagged with the request [`id`](Self::id).
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct MeasureReply {
66    /// The request id this answers.
67    pub id: u64,
68    /// The measurement JSON fragment (see [`measure_json`]).
69    pub result: String,
70}
71
72/// The history-run seam: SUBMIT a run, POLL for its completed reply, RESET the
73/// delta baseline, plus a QUERY channel for per-object measurements (routed to the
74/// runner so the warm registry answers them, never the cold main-side one). The
75/// Inline impl runs everything synchronously; a later thread/worker impl defers
76/// the work and surfaces the replies through the same poll idiom.
77pub trait HistoryRunner {
78    /// Submit a history run tagged with a monotonic generation. The runner executes
79    /// it (immediately for Inline; on a background thread later) and makes the reply
80    /// available via [`poll_run`](Self::poll_run).
81    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64);
82    /// Non-blocking: the next completed run reply, if any (drained each frame).
83    fn poll_run(&mut self) -> Option<RunReply>;
84    /// Submit a per-object measurement query (answered against the runner's warm
85    /// registry). Inline computes it immediately; a thread impl computes it on the
86    /// runner thread and surfaces it via [`poll_query`](Self::poll_query).
87    fn submit_query(&mut self, query: MeasureQuery);
88    /// Non-blocking: the next completed measurement reply, if any.
89    fn poll_query(&mut self) -> Option<MeasureReply>;
90    /// Drop the delta baseline (document switch → full rebuild).
91    fn reset(&mut self);
92}
93
94/// Measure `query` against `runner`'s resident geometry and emit the object-info
95/// JSON FRAGMENT — EXACTLY the fields [`crate::metadata::EngineState::object_info_json`]
96/// emits for that kind EXCEPT `name` and `creatingFeature` (the main thread injects
97/// those from its eager provenance, so the merged output is byte-identical to the
98/// pre-seam in-process result). Shared verbatim by the Inline and thread runners so
99/// both produce the identical fragment. A missing handle / kernel error yields
100/// `{ "ok": false, "message": .. }` (main injects `name`).
101fn measure_json(runner: &crate::pipeline::SceneRunner, query: &MeasureQuery) -> String {
102    let Some(handle) = runner.handle_of(&query.owner) else {
103        return serde_json::json!({
104            "ok": false,
105            "message": format!("solid '{}' has no resident geometry", query.owner),
106        })
107        .to_string();
108    };
109    match query.kind {
110        MeasureKind::Solid => {
111            match brep_kernel::mass_properties_handle_native(handle, query.density) {
112                Ok(properties) => {
113                    let edge_total =
114                        brep_kernel::solid_edge_length_total_native(handle).unwrap_or(0.0);
115                    serde_json::json!({
116                        "ok": true,
117                        "kind": "solid",
118                        "volume": properties.volume,
119                        "surfaceArea": properties.surface_area,
120                        "edgeLengthTotal": edge_total,
121                        "density": properties.density,
122                        "weight": properties.mass,
123                    })
124                    .to_string()
125                }
126                Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
127            }
128        }
129        MeasureKind::Face => match brep_kernel::face_measurements_native(handle, &query.entity) {
130            Ok((area, edge_total)) => serde_json::json!({
131                "ok": true,
132                "kind": "face",
133                "solid": query.owner,
134                "area": area,
135                "edgeLengthTotal": edge_total,
136            })
137            .to_string(),
138            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
139        },
140        MeasureKind::Edge => match brep_kernel::edge_length_native(handle, &query.entity) {
141            Ok(length) => serde_json::json!({
142                "ok": true,
143                "kind": "edge",
144                "solid": query.owner,
145                "length": length,
146            })
147            .to_string(),
148            Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
149        },
150    }
151}
152
153/// The default, SYNCHRONOUS runner: runs on submit, stashes the reply for an
154/// immediate poll. Behavior-identical to the pre-seam in-process run.
155pub struct InlineRunner {
156    /// The scene-free history runner it owns (delta baseline lives here).
157    runner: crate::pipeline::SceneRunner,
158    /// Completed replies awaiting a poll. For Inline this holds exactly one entry
159    /// between a `submit_run` and the immediately-following `poll_run`.
160    pending: std::collections::VecDeque<RunReply>,
161    /// Completed measurement replies awaiting a poll (computed synchronously on
162    /// `submit_query`, popped on `poll_query`) — the synchronous mirror of the
163    /// thread runner's query buffer, so the object-info path resolves same-call.
164    query_pending: std::collections::VecDeque<MeasureReply>,
165}
166
167impl InlineRunner {
168    pub fn new() -> Self {
169        Self {
170            runner: crate::pipeline::SceneRunner::new(),
171            pending: std::collections::VecDeque::new(),
172            query_pending: std::collections::VecDeque::new(),
173        }
174    }
175}
176
177impl HistoryRunner for InlineRunner {
178    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
179        let output = self.runner.run(&request, None);
180        self.pending.push_back(RunReply { generation, output });
181    }
182
183    fn poll_run(&mut self) -> Option<RunReply> {
184        self.pending.pop_front()
185    }
186
187    fn submit_query(&mut self, query: MeasureQuery) {
188        let result = measure_json(&self.runner, &query);
189        self.query_pending.push_back(MeasureReply { id: query.id, result });
190    }
191
192    fn poll_query(&mut self) -> Option<MeasureReply> {
193        self.query_pending.pop_front()
194    }
195
196    fn reset(&mut self) {
197        self.runner.reset();
198    }
199}
200
201impl Default for InlineRunner {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207// ===========================================================================
208// Shared run/query PROTOCOL (M3b): the `Command`/`Reply` message pair and the
209// `process_command` step. Both async runners speak it — the native `ThreadRunner`
210// ships the enums over `mpsc` (no serialization), and the wasm `WorkerRunner`
211// serializes them to JSON for `postMessage`. Hence the serde derives (the enums
212// carry serde types: `HistoryRequest`/`MeasureQuery`/`RunReply`/`MeasureReply`)
213// and `process_command` being un-gated + shared so both drivers do the SAME work.
214// ===========================================================================
215
216/// A command sent main → runner (thread channel OR worker `postMessage`) over one
217/// ordered stream (so a `Reset` before a `Run` stays before it). `Run` carries the
218/// whole request; the driver coalesces consecutive `Run`s to shed a slider drag's
219/// backlog (the thread in `thread_main`, the worker's main side in `WorkerRunner`).
220#[derive(serde::Serialize, serde::Deserialize)]
221pub enum Command {
222    Run {
223        request: brep_kernel::HistoryRequest,
224        generation: u64,
225    },
226    Query(MeasureQuery),
227    Reset,
228}
229
230/// A reply sent runner → main; the main side demuxes it into per-kind buffers so
231/// `poll_run`/`poll_query` each pop their own stream.
232#[derive(serde::Serialize, serde::Deserialize)]
233pub enum Reply {
234    Run(RunReply),
235    Query(MeasureReply),
236}
237
238/// Execute ONE [`Command`] against `runner` and return the [`Reply`] it produces,
239/// if any. The shared step both async drivers run: the native [`thread_main`]
240/// calls it for each (post-coalescing) command on the runner thread; the wasm
241/// `WorkerRunner`'s `worker_entry` calls it per `postMessage` on the worker.
242/// `Run` → a [`Reply::Run`]; `Query` → a [`Reply::Query`]; `Reset` drops the delta
243/// baseline AND the runner's OWN kernel history cache (the resident registry lives
244/// with the runner — thread or worker — not on main) and yields no reply.
245pub fn process_command(
246    runner: &mut crate::pipeline::SceneRunner,
247    command: Command,
248) -> Option<Reply> {
249    match command {
250        Command::Run { request, generation } => {
251            let output = runner.run(&request, None);
252            Some(Reply::Run(RunReply { generation, output }))
253        }
254        Command::Query(query) => {
255            let result = measure_json(runner, &query);
256            Some(Reply::Query(MeasureReply { id: query.id, result }))
257        }
258        Command::Reset => {
259            // A document switch: drop the delta baseline AND this runner's OWN kernel
260            // history cache (the resident registry lives here, not on main), mirroring
261            // `set_history_json`'s main-thread clear so a new model rebuilds fully and
262            // the old model's handles are freed.
263            runner.reset();
264            brep_kernel::clear_history_cache();
265            None
266        }
267    }
268}
269
270// ===========================================================================
271// ThreadRunner (M2b): a persistent std::thread that OWNS the SceneRunner, so a
272// history run — and per-object measurement queries — execute OFF the main thread
273// and the native UI stays responsive during a run AND during selection. Native
274// only: `std::thread` + `std::sync::mpsc` do not exist on wasm32 (M3b lands a
275// worker impl behind this same trait), so the whole thing is cfg-gated out there.
276// ===========================================================================
277
278/// The persistent-thread runner. `submit_*`/`reset` push [`Command`]s down the
279/// channel; `poll_*` first DRAIN every ready [`Reply`] into the two demux buffers,
280/// then pop the matching one. The `SceneRunner` (and thus the kernel's resident
281/// registry it warms) lives ENTIRELY on the thread — it is never shared — so the
282/// only cross-thread traffic is the `Send` command/reply payloads.
283#[cfg(not(target_arch = "wasm32"))]
284pub struct ThreadRunner {
285    /// Main → thread. `Option` so [`Drop`] can take + drop it, ending the thread's
286    /// blocking `recv` (the run loop exits, the join completes).
287    tx: Option<std::sync::mpsc::Sender<Command>>,
288    /// Thread → main.
289    rx: std::sync::mpsc::Receiver<Reply>,
290    /// The runner thread's join handle (joined on drop for a clean shutdown).
291    handle: Option<std::thread::JoinHandle<()>>,
292    /// Demuxed completed run replies awaiting `poll_run`.
293    run_buf: std::collections::VecDeque<RunReply>,
294    /// Demuxed completed measurement replies awaiting `poll_query`.
295    query_buf: std::collections::VecDeque<MeasureReply>,
296}
297
298#[cfg(not(target_arch = "wasm32"))]
299impl ThreadRunner {
300    pub fn new() -> Self {
301        let (tx, cmd_rx) = std::sync::mpsc::channel::<Command>();
302        let (reply_tx, rx) = std::sync::mpsc::channel::<Reply>();
303        let handle = std::thread::Builder::new()
304            .name("brep-history-runner".to_string())
305            .spawn(move || thread_main(cmd_rx, reply_tx))
306            .expect("spawn brep-history-runner thread");
307        Self {
308            tx: Some(tx),
309            rx,
310            handle: Some(handle),
311            run_buf: std::collections::VecDeque::new(),
312            query_buf: std::collections::VecDeque::new(),
313        }
314    }
315
316    /// Pull every ready reply off the channel and demux it into the run/query
317    /// buffers (so a `poll_run` never swallows a query reply and vice versa).
318    fn drain(&mut self) {
319        while let Ok(reply) = self.rx.try_recv() {
320            match reply {
321                Reply::Run(run) => self.run_buf.push_back(run),
322                Reply::Query(query) => self.query_buf.push_back(query),
323            }
324        }
325    }
326}
327
328#[cfg(not(target_arch = "wasm32"))]
329impl Default for ThreadRunner {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335/// The runner thread's whole life: block on the next command, batch it with every
336/// other command already queued, COALESCE consecutive `Run`s (a `Run` immediately
337/// followed by another `Run` — with no `Query`/`Reset` between — is dropped; only
338/// the last of each consecutive group runs), then process the batch IN ORDER so a
339/// `Reset` or a `Query` interleaved between two runs keeps its place. Exits when
340/// the command sender is dropped (`recv` errors) or the reply receiver is gone (a
341/// `send` errors — the main side went away).
342#[cfg(not(target_arch = "wasm32"))]
343fn thread_main(
344    cmd_rx: std::sync::mpsc::Receiver<Command>,
345    reply_tx: std::sync::mpsc::Sender<Reply>,
346) {
347    let mut runner = crate::pipeline::SceneRunner::new();
348    while let Ok(first) = cmd_rx.recv() {
349        // Gather this command plus everything else already waiting.
350        let mut batch = vec![first];
351        loop {
352            match cmd_rx.try_recv() {
353                Ok(command) => batch.push(command),
354                Err(_) => break, // Empty or Disconnected — process what we have.
355            }
356        }
357        // A Run immediately followed by another Run is coalesced away (its result
358        // would be overwritten before anything observed it); a Run followed by a
359        // Query/Reset/end still runs, so interleaved work sees the right geometry.
360        let mut run_here: Vec<bool> = vec![true; batch.len()];
361        for i in 0..batch.len() {
362            if matches!(batch[i], Command::Run { .. })
363                && matches!(batch.get(i + 1), Some(Command::Run { .. }))
364            {
365                run_here[i] = false;
366            }
367        }
368        // Process the KEPT commands in order through the SHARED `process_command`
369        // (byte-identical work to the wasm worker's per-message step), sending each
370        // reply it produces; a coalesced-away Run is skipped without touching the
371        // runner, so interleaved Query/Reset still see the right geometry.
372        for (i, command) in batch.into_iter().enumerate() {
373            if !run_here[i] {
374                continue;
375            }
376            if let Some(reply) = process_command(&mut runner, command) {
377                if reply_tx.send(reply).is_err() {
378                    return; // The reply receiver is gone — the main side went away.
379                }
380            }
381        }
382    }
383}
384
385#[cfg(not(target_arch = "wasm32"))]
386impl HistoryRunner for ThreadRunner {
387    fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
388        if let Some(tx) = &self.tx {
389            let _ = tx.send(Command::Run { request, generation });
390        }
391    }
392
393    fn poll_run(&mut self) -> Option<RunReply> {
394        self.drain();
395        self.run_buf.pop_front()
396    }
397
398    fn submit_query(&mut self, query: MeasureQuery) {
399        if let Some(tx) = &self.tx {
400            let _ = tx.send(Command::Query(query));
401        }
402    }
403
404    fn poll_query(&mut self) -> Option<MeasureReply> {
405        self.drain();
406        self.query_buf.pop_front()
407    }
408
409    fn reset(&mut self) {
410        if let Some(tx) = &self.tx {
411            let _ = tx.send(Command::Reset);
412        }
413        // A reset is a wholesale document switch: any replies still buffered from
414        // the old model are stale — drop them (a fresh run/query supersedes).
415        self.run_buf.clear();
416        self.query_buf.clear();
417    }
418}
419
420#[cfg(not(target_arch = "wasm32"))]
421impl Drop for ThreadRunner {
422    fn drop(&mut self) {
423        // Dropping the sender ends the thread's blocking `recv`; then join so the
424        // thread (and its resident registry) is fully torn down before we return.
425        self.tx.take();
426        if let Some(handle) = self.handle.take() {
427            let _ = handle.join();
428        }
429    }
430}
431
432// ===========================================================================
433// ThreadRunner integration tests (native only — the async run/query machine the
434// wasm WorkerRunner (M3) reuses). Driven end-to-end: submit → spin poll → assert.
435// ===========================================================================
436#[cfg(all(test, not(target_arch = "wasm32")))]
437mod thread_tests {
438    use super::*;
439
440    /// A one-feature history: a P.CU cube `Box` of side 20 (volume 8000).
441    fn box_request() -> brep_kernel::HistoryRequest {
442        serde_json::from_str(
443            r#"{
444                "expressions": "", "configurator": {},
445                "features": [{
446                    "type": "P.CU",
447                    "inputParams": {
448                        "id": "Box", "sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.0,
449                        "transform": { "position": [0,0,0], "rotationEuler": [0,0,0], "scale": [1,1,1] },
450                        "boolean": { "targets": [], "operation": "NONE" }
451                    },
452                    "persistentData": {}
453                }]
454            }"#,
455        )
456        .unwrap()
457    }
458
459    fn spin_run(runner: &mut ThreadRunner) -> RunReply {
460        for _ in 0..3000 {
461            if let Some(reply) = runner.poll_run() {
462                return reply;
463            }
464            std::thread::sleep(std::time::Duration::from_millis(1));
465        }
466        panic!("thread run did not complete within the spin budget");
467    }
468
469    fn spin_query(runner: &mut ThreadRunner) -> MeasureReply {
470        for _ in 0..3000 {
471            if let Some(reply) = runner.poll_query() {
472                return reply;
473            }
474            std::thread::sleep(std::time::Duration::from_millis(1));
475        }
476        panic!("thread query did not complete within the spin budget");
477    }
478
479    /// A submitted run executes ON THE THREAD and its reply is drained back with
480    /// the right generation + a freshly-tessellated snapshot entry for the box.
481    #[test]
482    fn thread_runner_runs_and_replies() {
483        let mut runner = ThreadRunner::new();
484        runner.submit_run(box_request(), 1);
485        let reply = spin_run(&mut runner);
486        assert_eq!(reply.generation, 1, "reply carries its submit generation");
487        assert_eq!(reply.output.snapshot.len(), 1, "one solid (the box)");
488        let (name, _handle, display) = &reply.output.snapshot[0];
489        assert_eq!(name, "Box");
490        assert!(display.is_some(), "first emit is a fresh tessellation, not a reuse");
491        assert_eq!(reply.output.provenance, vec![("Box".to_string(), "Box".to_string())]);
492    }
493
494    /// A measurement query routes to the thread's WARM registry (populated by the
495    /// preceding run) and returns the exact object-info fragment.
496    #[test]
497    fn thread_runner_measures_after_run() {
498        let mut runner = ThreadRunner::new();
499        runner.submit_run(box_request(), 1);
500        let _ = spin_run(&mut runner);
501        runner.submit_query(MeasureQuery {
502            id: 7,
503            kind: MeasureKind::Solid,
504            owner: "Box".to_string(),
505            entity: String::new(),
506            density: 1.0,
507        });
508        let reply = spin_query(&mut runner);
509        assert_eq!(reply.id, 7);
510        let info: serde_json::Value = serde_json::from_str(&reply.result).unwrap();
511        assert_eq!(info["ok"], true, "measured on the thread: {}", reply.result);
512        assert!(
513            (info["volume"].as_f64().unwrap() - 8000.0).abs() < 1.0,
514            "box volume {} != 8000",
515            info["volume"]
516        );
517    }
518
519    /// A re-submitted IDENTICAL run replays the thread's incremental cache: the
520    /// second reply is a REUSE (`None` in the snapshot), proving the runner's
521    /// baseline + the kernel cache both persist across submits on the thread.
522    #[test]
523    fn thread_runner_reuses_on_identical_resubmit() {
524        let mut runner = ThreadRunner::new();
525        runner.submit_run(box_request(), 1);
526        let first = spin_run(&mut runner);
527        assert!(first.output.snapshot[0].2.is_some(), "first is fresh");
528
529        runner.submit_run(box_request(), 2);
530        let second = spin_run(&mut runner);
531        assert_eq!(second.generation, 2);
532        assert!(
533            second.output.snapshot[0].2.is_none(),
534            "identical resubmit replays as a REUSE (unchanged handle)"
535        );
536    }
537
538    /// A `reset` clears the thread's delta baseline AND its kernel cache, so the
539    /// next run rebuilds fresh (a new handle ⇒ a `Some` snapshot, not a reuse).
540    #[test]
541    fn thread_runner_reset_forces_full_rebuild() {
542        let mut runner = ThreadRunner::new();
543        runner.submit_run(box_request(), 1);
544        let _ = spin_run(&mut runner);
545        runner.reset();
546        runner.submit_run(box_request(), 2);
547        let reply = spin_run(&mut runner);
548        assert!(
549            reply.output.snapshot[0].2.is_some(),
550            "after reset the box re-tessellates (baseline dropped)"
551        );
552    }
553
554    /// The wasm worker WIRE FORMAT: a `Command::Run` — carrying a `HistoryRequest`
555    /// whose brand-new `Serialize` must honor its field renames (`type`,
556    /// `inputParams`, …) — survives the serde-JSON round trip the `WorkerRunner`'s
557    /// `postMessage` uses, and the round-tripped request still executes. (The
558    /// `RunOutput`/`RunReply` reply half already has its own round-trip test.)
559    #[test]
560    fn run_command_round_trips_through_serde() {
561        let command = Command::Run { request: box_request(), generation: 42 };
562        let json = serde_json::to_string(&command).expect("Command serializes");
563        let back: Command = serde_json::from_str(&json).expect("Command deserializes");
564        match back {
565            Command::Run { request, generation } => {
566                assert_eq!(generation, 42, "generation round-trips");
567                assert_eq!(request.features.len(), 1);
568                // The `type` rename survived serialize → deserialize.
569                assert_eq!(request.features[0].feature_type, "P.CU");
570                // The deserialized request is still executable on a fresh runner.
571                let output = crate::pipeline::SceneRunner::new().run(&request, None);
572                assert_eq!(output.snapshot.len(), 1);
573                assert_eq!(output.snapshot[0].0, "Box");
574            }
575            _ => panic!("round-tripped to the wrong Command variant"),
576        }
577    }
578}