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