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}