brep_render/runner.rs
1//! The history-run seam — the async RUN machine behind a trait, mirroring the
2//! platform-seam shape of `brep-app/src/store.rs`'s `ModelStore` (a trait with
3//! impls behind it, async surfaced via a poll idiom).
4//!
5//! A history run is split SUBMIT → POLL/APPLY: [`HistoryRunner::submit_run`]
6//! kicks off a run tagged with a monotonic generation, and the completed
7//! [`RunReply`] is drained later via [`HistoryRunner::poll_run`]. The runner OWNS
8//! the [`SceneRunner`](crate::pipeline::SceneRunner) — so a future thread/worker
9//! impl owns the resident registry that `execute_history` populates — and holds
10//! the delta baseline across reruns.
11//!
12//! This slice ships the DEFAULT [`InlineRunner`]: it runs on `submit_run` and
13//! stashes the reply for an immediate `poll_run`, so the run stays synchronous
14//! and byte-identical to the pre-seam in-process run. A native-thread impl (M2b)
15//! and a wasm-worker impl (M3) slot in behind the SAME trait — `submit_run`
16//! defers the work and `poll_run` surfaces it a frame (or many) later, so the
17//! `EngineState::pump` caller never changes.
18
19/// A completed history run, tagged with the [`generation`](Self::generation) it
20/// was submitted under so the applier can drop stale replies (a newer run that
21/// finished first). The [`output`](Self::output) is the [`SceneRunner`] delta to
22/// apply to the display scene.
23///
24/// [`SceneRunner`]: crate::pipeline::SceneRunner
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct RunReply {
27 /// The monotonic generation this run was submitted under.
28 pub generation: u64,
29 /// The delta snapshot + report the run produced.
30 pub output: crate::pipeline::RunOutput,
31}
32
33/// A feature about to EXECUTE inside an in-flight run (cached replays are
34/// instant and are not reported): which one, of how many, under which
35/// generation. Posted by the runner before the kernel starts the feature, so
36/// the UI can name what it is waiting on — and, after a cancel, what it was
37/// waiting on.
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39pub struct RunProgress {
40 /// The generation of the run this belongs to (see [`RunReply::generation`]).
41 pub generation: u64,
42 /// Zero-based position of the feature in the request.
43 pub index: usize,
44 /// The request's feature count.
45 pub total: usize,
46 pub feature_id: String,
47 pub feature_type: String,
48}
49
50/// A STEP text to PROBE for product structure on the runner — the parse that
51/// `brep_kernel::read_step_assembly` performs, which builds every product's
52/// bodies and takes seconds on a real assembly (8.3 s natively for a 5 MB,
53/// 140-product file), so it must not run on the browser's main thread.
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct StepProbeRequest {
56 pub id: u64,
57 pub text: String,
58}
59
60/// The probe's answer: the parsed assembly (`Some`), no structure (`None`),
61/// or a parse failure. The assembly crosses back to the main side whole — its
62/// JSON is large (74 MB for the file above) but the trip costs a third of a
63/// second against the seconds the parse took.
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct StepProbeReply {
66 pub id: u64,
67 pub result: Result<Option<brep_kernel::StepAssembly>, String>,
68}
69
70/// Which exact measurement a [`MeasureQuery`] wants, mirroring the object-info
71/// kinds ([`crate::metadata`]): a whole solid, one named face, or one named edge.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73pub enum MeasureKind {
74 Solid,
75 Face,
76 Edge,
77}
78
79/// A per-object MEASUREMENT request routed to the runner (which owns the warm
80/// registry). The runner resolves `owner` to its resident handle and measures by
81/// [`kind`](Self::kind); the reply is the object-info JSON fragment MINUS the
82/// main-injected `name`/`creatingFeature` fields. Tagged with a monotonic
83/// [`id`](Self::id) so the main side can pair the reply with its pending request.
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct MeasureQuery {
86 /// Monotonic request id (main pairs the reply back by it).
87 pub id: u64,
88 /// The measurement to run.
89 pub kind: MeasureKind,
90 /// The OWNING solid name (a solid is its own owner; a face/edge names its solid).
91 pub owner: String,
92 /// The face/edge NAME to measure (ignored for a whole-solid query).
93 pub entity: String,
94 /// The density (mass per mm³) to scale a solid's weight (ignored for face/edge).
95 pub density: f64,
96}
97
98/// A completed [`MeasureQuery`]: the object-info measurement fields as a JSON
99/// fragment (WITHOUT `name`/`creatingFeature`, which the main thread injects from
100/// its eager provenance), tagged with the request [`id`](Self::id).
101#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
102pub struct MeasureReply {
103 /// The request id this answers.
104 pub id: u64,
105 /// The measurement JSON fragment (see [`measure_json`]).
106 pub result: String,
107}
108
109pub use brep_reconstruction::stl_conversion::{
110 ConversionPolicy, StlConversionOptions, StlConversionOutput,
111};
112
113/// Mesh encoding accepted by the off-thread reconstruction channel.
114#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
115pub enum MeshImportFormat {
116 Stl,
117 Obj,
118}
119
120/// A mesh reconstruction request. The byte payload moves to the native thread
121/// or is serialized to the browser worker; no parsing or fitting happens on UI.
122#[derive(Debug, serde::Serialize, serde::Deserialize)]
123pub struct MeshImportRequest {
124 pub id: u64,
125 pub format: MeshImportFormat,
126 pub bytes: Vec<u8>,
127 pub options: StlConversionOptions,
128}
129
130/// Completed off-thread reconstruction. Success carries validated STEP and
131/// diagnostics for a preview or an ordinary IMPORT3D history insertion.
132#[derive(Debug, serde::Serialize, serde::Deserialize)]
133pub struct MeshImportReply {
134 pub id: u64,
135 pub result: Result<StlConversionOutput, String>,
136}
137
138/// The history-run seam: SUBMIT a run, POLL for its completed reply, RESET the
139/// delta baseline, plus a QUERY channel for per-object measurements (routed to the
140/// runner so the warm registry answers them, never the cold main-side one). The
141/// Inline impl runs everything synchronously; a later thread/worker impl defers
142/// the work and surfaces the replies through the same poll idiom.
143pub trait HistoryRunner {
144 /// Submit a history run tagged with a monotonic generation. The runner executes
145 /// it (immediately for Inline; on a background thread later) and makes the reply
146 /// available via [`poll_run`](Self::poll_run).
147 fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64);
148 /// Non-blocking: the next completed run reply, if any (drained each frame).
149 fn poll_run(&mut self) -> Option<RunReply>;
150 /// Submit a per-object measurement query (answered against the runner's warm
151 /// registry). Inline computes it immediately; a thread impl computes it on the
152 /// runner thread and surfaces it via [`poll_query`](Self::poll_query).
153 fn submit_query(&mut self, query: MeasureQuery);
154 /// Non-blocking: the next completed measurement reply, if any.
155 fn poll_query(&mut self) -> Option<MeasureReply>;
156 /// Submit RANSAC mesh reconstruction to the runner's thread/worker.
157 fn submit_mesh_import(&mut self, request: MeshImportRequest);
158 /// Non-blocking: the next completed mesh reconstruction, if any.
159 fn poll_mesh_import(&mut self) -> Option<MeshImportReply>;
160 /// Submit a STEP product-structure probe (see [`StepProbeRequest`]).
161 fn submit_step_probe(&mut self, request: StepProbeRequest);
162 /// Non-blocking: the next completed STEP probe, if any.
163 fn poll_step_probe(&mut self) -> Option<StepProbeReply>;
164 /// Drop the delta baseline (document switch → full rebuild).
165 fn reset(&mut self);
166
167 /// Bring the runner's resident PARTS LIBRARY up to `revision`, calling
168 /// `fetch` ONLY when it is not already there. Called immediately before
169 /// every [`submit_run`](Self::submit_run).
170 ///
171 /// This is the whole point of the library channel: the library is sent
172 /// when it CHANGES (insert, document load, refresh), not on every run.
173 /// Cheap to call — the revision comparison is an integer, and `fetch`
174 /// (which clones the store) never runs in the steady state. The default is
175 /// a no-op for [`InlineRunner`], which shares the caller's kernel store.
176 fn sync_parts_library(
177 &mut self,
178 _revision: u64,
179 _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
180 ) {
181 }
182
183 /// True when the runner REFUSED a run because its resident parts library
184 /// could not serve it (see [`Reply::NeedPartsLibrary`]). It has already
185 /// forgotten its copy, so the next `sync_parts_library` reinstalls; the
186 /// caller must re-submit the run. Never true for a runner that shares the
187 /// caller's store.
188 fn poll_library_request(&mut self) -> bool {
189 false
190 }
191
192 /// Non-blocking: the MOST RECENT progress report of the in-flight run, with
193 /// any older ones discarded (only the latest feature matters). `None` for
194 /// a synchronous runner — Inline has finished before anyone could ask.
195 fn poll_progress(&mut self) -> Option<RunProgress> {
196 None
197 }
198
199 /// ABANDON the in-flight work and start over with an EMPTY resident
200 /// registry. Returns `false` when there is nothing this runner can abandon
201 /// (Inline: the run already completed on the caller's thread). A `true`
202 /// means every handle the caller holds is now invalid, no reply is coming
203 /// for anything submitted so far, and the parts library must be re-sent —
204 /// the caller reconciles its generations and pending sets accordingly
205 /// (`EngineState::cancel_run`). Nothing inside a feature is interruptible:
206 /// the thread runner lets its old thread finish the feature it is on and
207 /// stop at the next boundary; the browser worker is terminated outright.
208 fn cancel(&mut self) -> bool {
209 false
210 }
211}
212
213/// Measure `query` against `runner`'s resident geometry and emit the object-info
214/// JSON FRAGMENT — EXACTLY the fields [`crate::metadata::EngineState::object_info_json`]
215/// emits for that kind EXCEPT `name` and `creatingFeature` (the main thread injects
216/// those from its eager provenance, so the merged output is byte-identical to the
217/// pre-seam in-process result). Shared verbatim by the Inline and thread runners so
218/// both produce the identical fragment. A missing handle / kernel error yields
219/// `{ "ok": false, "message": .. }` (main injects `name`).
220fn measure_json(runner: &crate::pipeline::SceneRunner, query: &MeasureQuery) -> String {
221 let Some(handle) = runner.handle_of(&query.owner) else {
222 return serde_json::json!({
223 "ok": false,
224 "message": format!("solid '{}' has no resident geometry", query.owner),
225 })
226 .to_string();
227 };
228 match query.kind {
229 MeasureKind::Solid => {
230 match brep_kernel::mass_properties_handle_native(handle, query.density) {
231 Ok(properties) => {
232 let edge_total =
233 brep_kernel::solid_edge_length_total_native(handle).unwrap_or(0.0);
234 serde_json::json!({
235 "ok": true,
236 "kind": "solid",
237 "volume": properties.volume,
238 "surfaceArea": properties.surface_area,
239 "edgeLengthTotal": edge_total,
240 "density": properties.density,
241 "weight": properties.mass,
242 })
243 .to_string()
244 }
245 Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
246 }
247 }
248 MeasureKind::Face => match brep_kernel::face_measurements_native(handle, &query.entity) {
249 Ok((area, edge_total, surface_type)) => serde_json::json!({
250 "ok": true,
251 "kind": "face",
252 "solid": query.owner,
253 "surfaceType": surface_type,
254 "area": area,
255 "edgeLengthTotal": edge_total,
256 })
257 .to_string(),
258 Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
259 },
260 MeasureKind::Edge => match brep_kernel::edge_length_native(handle, &query.entity) {
261 Ok(length) => serde_json::json!({
262 "ok": true,
263 "kind": "edge",
264 "solid": query.owner,
265 "length": length,
266 })
267 .to_string(),
268 Err(error) => serde_json::json!({ "ok": false, "message": error }).to_string(),
269 },
270 }
271}
272
273/// The default, SYNCHRONOUS runner: runs on submit, stashes the reply for an
274/// immediate poll. Behavior-identical to the pre-seam in-process run.
275pub struct InlineRunner {
276 /// The scene-free history runner it owns (delta baseline lives here).
277 runner: crate::pipeline::SceneRunner,
278 /// Completed replies awaiting a poll. For Inline this holds exactly one entry
279 /// between a `submit_run` and the immediately-following `poll_run`.
280 pending: std::collections::VecDeque<RunReply>,
281 /// Completed measurement replies awaiting a poll (computed synchronously on
282 /// `submit_query`, popped on `poll_query`) — the synchronous mirror of the
283 /// thread runner's query buffer, so the object-info path resolves same-call.
284 query_pending: std::collections::VecDeque<MeasureReply>,
285 mesh_import_pending: std::collections::VecDeque<MeshImportReply>,
286 step_probe_pending: std::collections::VecDeque<StepProbeReply>,
287}
288
289impl InlineRunner {
290 pub fn new() -> Self {
291 Self {
292 runner: crate::pipeline::SceneRunner::new(),
293 pending: std::collections::VecDeque::new(),
294 query_pending: std::collections::VecDeque::new(),
295 mesh_import_pending: std::collections::VecDeque::new(),
296 step_probe_pending: std::collections::VecDeque::new(),
297 }
298 }
299}
300
301impl HistoryRunner for InlineRunner {
302 fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
303 let output = self.runner.run(&request);
304 self.pending.push_back(RunReply { generation, output });
305 }
306
307 /// Nothing to send: Inline runs on the CALLER's thread against the CALLER's
308 /// kernel store, so the library the caller would hand over is already the
309 /// one this run resolves against. (Which is also why Inline never needs the
310 /// preflight — there is only one store, and the caller seeds it directly.)
311 fn sync_parts_library(
312 &mut self,
313 _revision: u64,
314 _fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
315 ) {
316 }
317
318 fn poll_run(&mut self) -> Option<RunReply> {
319 self.pending.pop_front()
320 }
321
322 fn submit_query(&mut self, query: MeasureQuery) {
323 let result = measure_json(&self.runner, &query);
324 self.query_pending.push_back(MeasureReply { id: query.id, result });
325 }
326
327 fn poll_query(&mut self) -> Option<MeasureReply> {
328 self.query_pending.pop_front()
329 }
330
331 fn submit_mesh_import(&mut self, request: MeshImportRequest) {
332 self.mesh_import_pending
333 .push_back(reconstruct_mesh(request));
334 }
335
336 fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
337 self.mesh_import_pending.pop_front()
338 }
339
340 fn submit_step_probe(&mut self, request: StepProbeRequest) {
341 self.step_probe_pending.push_back(probe_step(request));
342 }
343
344 fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
345 self.step_probe_pending.pop_front()
346 }
347
348 fn reset(&mut self) {
349 self.runner.reset();
350 }
351}
352
353impl Default for InlineRunner {
354 fn default() -> Self {
355 Self::new()
356 }
357}
358
359// ===========================================================================
360// Shared run/query PROTOCOL (M3b): the `Command`/`Reply` message pair and the
361// `process_command` step. Both async runners speak it — the native `ThreadRunner`
362// ships the enums over `mpsc` (no serialization), and the wasm `WorkerRunner`
363// serializes them to JSON for `postMessage`. Hence the serde derives (the enums
364// carry serde types: `HistoryRequest`/`MeasureQuery`/`RunReply`/`MeasureReply`)
365// and `process_command` being un-gated + shared so both drivers do the SAME work.
366// ===========================================================================
367
368/// A command sent main → runner (thread channel OR worker `postMessage`) over one
369/// ordered stream (so a `Reset` before a `Run` stays before it). `Run` carries the
370/// whole request; the driver coalesces consecutive `Run`s to shed a slider drag's
371/// backlog (the thread in `thread_main`, the worker's main side in `WorkerRunner`).
372#[derive(serde::Serialize, serde::Deserialize)]
373pub enum Command {
374 Run {
375 request: brep_kernel::HistoryRequest,
376 generation: u64,
377 /// The [`brep_kernel::parts_library_revision`] this run was built
378 /// against. The runner refuses to execute a run stamped for a library
379 /// it does not hold — see [`process_command`].
380 #[serde(default)]
381 parts_library_revision: u64,
382 },
383 /// Install the parts library on the runner's OWN kernel store. Sent only
384 /// when the library actually changes (insert, document load, refresh),
385 /// never per run: it carries the whole embedded-part payload, which for an
386 /// imported STEP assembly is megabytes, and stringifying that on the
387 /// browser's main thread once per edit is what froze the UI.
388 ///
389 /// Deliberately its OWN command rather than an optional field on `Run`:
390 /// both drivers COALESCE consecutive runs (see [`thread_main`] and
391 /// `WorkerRunner::submit_run`), so a library riding on a run could be
392 /// dropped with it. A `SetPartsLibrary` is never coalesced away, and the
393 /// stream is ordered, so it always lands before the run that needs it.
394 SetPartsLibrary {
395 library: brep_kernel::PartsLibraryMap,
396 revision: u64,
397 },
398 Query(MeasureQuery),
399 MeshImport(MeshImportRequest),
400 StepProbe(StepProbeRequest),
401 Reset,
402}
403
404/// A reply sent runner → main; the main side demuxes it into per-kind buffers.
405#[derive(serde::Serialize, serde::Deserialize)]
406pub enum Reply {
407 Run(RunReply),
408 Query(MeasureReply),
409 MeshImport(MeshImportReply),
410 StepProbe(StepProbeReply),
411 /// The runner REFUSED a run because its resident parts library could not
412 /// serve it (a part the request references is missing, or the run was
413 /// stamped for a different library revision). No geometry was touched; the
414 /// main side re-sends the library and re-submits. Refusing is the whole
415 /// point — running with the wrong library would be silently wrong
416 /// geometry, which is far worse than one extra round trip.
417 NeedPartsLibrary,
418 /// A feature of the in-flight run is about to execute (see
419 /// [`RunProgress`]). Posted from INSIDE `process_command`, before its
420 /// `Reply::Run`; the main side keeps only the latest.
421 Progress(RunProgress),
422}
423
424/// The probe itself — the one parse of a structured STEP import (see
425/// `EngineState::submit_step_probe`), run wherever the runner runs.
426fn probe_step(request: StepProbeRequest) -> StepProbeReply {
427 StepProbeReply {
428 id: request.id,
429 result: brep_kernel::read_step_assembly(&request.text),
430 }
431}
432
433fn reconstruct_mesh(request: MeshImportRequest) -> MeshImportReply {
434 let result = (|| {
435 use brep_reconstruction::stl_conversion::{
436 binary_stl_coordinate_precision_tolerance, convert_stl_mesh_to_step,
437 };
438 use brep_reconstruction::{Mesh, Vec3};
439
440 let (mesh, positions, indices, coordinate_precision_tolerance) = match request.format {
441 MeshImportFormat::Stl => {
442 use brep_reconstruction::stl::{parse_stl_bytes, StlFormat, StlReadOptions};
443 let read_options = StlReadOptions {
444 weld_tolerance: (request.options.weld_tolerance >= 0.0)
445 .then_some(request.options.weld_tolerance),
446 };
447 let imported = parse_stl_bytes(&request.bytes, &read_options)
448 .map_err(|error| format!("STL import failed: {error}"))?;
449 let positions = imported
450 .mesh
451 .vertices
452 .iter()
453 .flat_map(|point| [point.x, point.y, point.z])
454 .collect::<Vec<_>>();
455 let indices = imported
456 .mesh
457 .triangles
458 .iter()
459 .flatten()
460 .copied()
461 .collect::<Vec<_>>();
462 let precision = if imported.format == StlFormat::Binary {
463 binary_stl_coordinate_precision_tolerance(&imported.mesh)
464 } else {
465 0.0
466 };
467 (imported.mesh, positions, indices, precision)
468 }
469 MeshImportFormat::Obj => {
470 let text = std::str::from_utf8(&request.bytes)
471 .map_err(|_| "OBJ import failed: file is not UTF-8 text".to_string())?;
472 let obj = brep_kernel::read_obj(text)
473 .map_err(|error| format!("OBJ import failed: {error}"))?;
474 let vertices = obj
475 .positions
476 .chunks_exact(3)
477 .map(|point| Vec3::new(point[0], point[1], point[2]))
478 .collect::<Vec<_>>();
479 let triangles = obj
480 .indices
481 .chunks_exact(3)
482 .map(|triangle| [triangle[0], triangle[1], triangle[2]])
483 .collect::<Vec<_>>();
484 (
485 Mesh::new(vertices, triangles),
486 obj.positions,
487 obj.indices,
488 0.0,
489 )
490 }
491 };
492 let mut options = request.options;
493 options.coordinate_precision_tolerance = options.coordinate_precision_tolerance
494 .max(coordinate_precision_tolerance);
495 convert_stl_mesh_to_step(
496 &mesh,
497 &positions,
498 Some(&indices),
499 &options,
500 "Imported mesh",
501 "MM",
502 "",
503 )
504 .map_err(|error| format!("RANSAC reconstruction failed: {error}"))
505 })();
506 MeshImportReply {
507 id: request.id,
508 result,
509 }
510}
511
512/// Execute ONE [`Command`] against `runner` and return the [`Reply`] it produces,
513/// if any. The shared step both async drivers run: the native [`thread_main`]
514/// calls it for each (post-coalescing) command on the runner thread; the wasm
515/// `WorkerRunner`'s `worker_entry` calls it per `postMessage` on the worker.
516/// `Run` → a [`Reply::Run`]; `Query` → a [`Reply::Query`]; `Reset` drops the delta
517/// baseline AND the runner's OWN kernel history cache (the resident registry lives
518/// with the runner — thread or worker — not on main) and yields no reply.
519///
520/// `progress` is called before every feature a `Run` actually executes, with
521/// the report the driver should ship as [`Reply::Progress`]; returning `false`
522/// stops the run at that boundary (the native thread's cooperative cancel —
523/// the worker is terminated instead and always returns `true`).
524pub fn process_command(
525 runner: &mut crate::pipeline::SceneRunner,
526 command: Command,
527 progress: &mut dyn FnMut(RunProgress) -> bool,
528) -> Option<Reply> {
529 match command {
530 Command::Run {
531 request,
532 generation,
533 parts_library_revision,
534 } => {
535 // PREFLIGHT (see `Reply::NeedPartsLibrary`). Two independent
536 // checks, because neither alone is enough:
537 //
538 // * the revision stamp catches CHANGED content the sender knows
539 // about but this store has not received;
540 // * `missing_library_parts` catches content that is simply GONE
541 // here — the orphan GC at the end of every run drops entries no
542 // ACOMP in THAT run referenced, so an undo to zero components
543 // empties this store while the sender's (which never ran) keeps
544 // everything and its revision never moves. Only looking at the
545 // actual content sees that.
546 let stale = match runner.parts_library_revision {
547 Some(installed) => installed != parts_library_revision,
548 // Nothing installed yet. Accept a run stamped 0 — that is
549 // either an empty library or a caller driving `submit_run`
550 // directly (the runner tests); the content preflight below is
551 // what actually protects the run. A non-zero stamp with nothing
552 // installed IS a gap: refuse it.
553 None => parts_library_revision != 0,
554 };
555 // Only a part the sender DID send and this store has since lost is
556 // worth asking for again. One it never sent is a dangling reference
557 // in the document itself: run it, so the ACOMP feature reports it
558 // the way it always has. (An install inserts every incoming name,
559 // so right after one this set can only be empty — which is what
560 // makes the ask-and-retry terminate.)
561 let recoverable = brep_kernel::missing_library_parts(&request)
562 .iter()
563 .any(|name| runner.parts_library_names.contains(name));
564 if stale || recoverable {
565 runner.parts_library_revision = None;
566 return Some(Reply::NeedPartsLibrary);
567 }
568 let output = runner.run_observed(&request, &mut |event| {
569 progress(RunProgress {
570 generation,
571 index: event.index,
572 total: event.total,
573 feature_id: event.id.to_string(),
574 feature_type: event.feature_type.to_string(),
575 })
576 });
577 Some(Reply::Run(RunReply { generation, output }))
578 }
579 Command::SetPartsLibrary { library, revision } => {
580 runner.parts_library_names = library.keys().cloned().collect();
581 brep_kernel::install_parts_library(&library);
582 runner.parts_library_revision = Some(revision);
583 None
584 }
585 Command::Query(query) => {
586 let result = measure_json(runner, &query);
587 Some(Reply::Query(MeasureReply { id: query.id, result }))
588 }
589 Command::MeshImport(request) => Some(Reply::MeshImport(reconstruct_mesh(request))),
590 Command::StepProbe(request) => Some(Reply::StepProbe(probe_step(request))),
591 Command::Reset => {
592 // A document switch: drop the delta baseline AND this runner's OWN kernel
593 // history cache (the resident registry lives here, not on main), mirroring
594 // `set_history_json`'s main-thread clear so a new model rebuilds fully and
595 // the old model's handles are freed.
596 runner.reset();
597 brep_kernel::clear_history_cache();
598 None
599 }
600 }
601}
602
603// ===========================================================================
604// ThreadRunner (M2b): a persistent std::thread that OWNS the SceneRunner, so a
605// history run — and per-object measurement queries — execute OFF the main thread
606// and the native UI stays responsive during a run AND during selection. Native
607// only: `std::thread` + `std::sync::mpsc` do not exist on wasm32 (M3b lands a
608// worker impl behind this same trait), so the whole thing is cfg-gated out there.
609// ===========================================================================
610
611/// The persistent-thread runner. `submit_*`/`reset` push [`Command`]s down the
612/// channel; `poll_*` first DRAIN every ready [`Reply`] into the two demux buffers,
613/// then pop the matching one. The `SceneRunner` (and thus the kernel's resident
614/// registry it warms) lives ENTIRELY on the thread — it is never shared — so the
615/// only cross-thread traffic is the `Send` command/reply payloads.
616#[cfg(not(target_arch = "wasm32"))]
617pub struct ThreadRunner {
618 /// Main → thread. `Option` so [`Drop`] can take + drop it, ending the thread's
619 /// blocking `recv` after already-submitted work finishes.
620 tx: Option<std::sync::mpsc::Sender<Command>>,
621 /// Thread → main.
622 rx: std::sync::mpsc::Receiver<Reply>,
623 /// Dropped without joining so closing a busy preview never stalls the UI.
624 handle: Option<std::thread::JoinHandle<()>>,
625 /// Demuxed completed run replies awaiting `poll_run`.
626 run_buf: std::collections::VecDeque<RunReply>,
627 /// Demuxed completed measurement replies awaiting `poll_query`.
628 query_buf: std::collections::VecDeque<MeasureReply>,
629 mesh_import_buf: std::collections::VecDeque<MeshImportReply>,
630 step_probe_buf: std::collections::VecDeque<StepProbeReply>,
631 /// Progress reports of the in-flight run, oldest first (`poll_progress`
632 /// keeps the newest).
633 progress_buf: std::collections::VecDeque<RunProgress>,
634 /// The parts-library revision last SENT down the channel (`None` = never,
635 /// or the thread dropped it). Lives here rather than on the caller so
636 /// "reset forgets the library" is a local property of this object.
637 sent_library_revision: Option<u64>,
638 /// The thread refused a run for want of its library (drained by
639 /// [`HistoryRunner::poll_library_request`]).
640 library_requested: bool,
641 /// The cooperative stop flag THIS thread checks between features. Each
642 /// spawn gets its own, so a cancelled thread keeps its raised flag while
643 /// the replacement starts clean.
644 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
645}
646
647#[cfg(not(target_arch = "wasm32"))]
648impl ThreadRunner {
649 pub fn new() -> Self {
650 let (tx, cmd_rx) = std::sync::mpsc::channel::<Command>();
651 let (reply_tx, rx) = std::sync::mpsc::channel::<Reply>();
652 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
653 let thread_stop = stop.clone();
654 let handle = std::thread::Builder::new()
655 .name("brep-history-runner".to_string())
656 .spawn(move || thread_main(cmd_rx, reply_tx, thread_stop))
657 .expect("spawn brep-history-runner thread");
658 Self {
659 tx: Some(tx),
660 rx,
661 handle: Some(handle),
662 run_buf: std::collections::VecDeque::new(),
663 query_buf: std::collections::VecDeque::new(),
664 mesh_import_buf: std::collections::VecDeque::new(),
665 step_probe_buf: std::collections::VecDeque::new(),
666 progress_buf: std::collections::VecDeque::new(),
667 sent_library_revision: None,
668 library_requested: false,
669 stop,
670 }
671 }
672
673 /// Pull every ready reply off the channel and demux it into the run/query
674 /// buffers (so a `poll_run` never swallows a query reply and vice versa).
675 fn drain(&mut self) {
676 while let Ok(reply) = self.rx.try_recv() {
677 match reply {
678 Reply::Run(run) => self.run_buf.push_back(run),
679 Reply::Query(query) => self.query_buf.push_back(query),
680 Reply::MeshImport(reply) => self.mesh_import_buf.push_back(reply),
681 Reply::StepProbe(reply) => self.step_probe_buf.push_back(reply),
682 Reply::Progress(progress) => self.progress_buf.push_back(progress),
683 Reply::NeedPartsLibrary => {
684 // The thread dropped (or never had) the library this run
685 // needs. Forget what we believe it holds so the next
686 // `sync_parts_library` re-installs, and flag the refused
687 // run for the caller to re-submit.
688 self.sent_library_revision = None;
689 self.library_requested = true;
690 }
691 }
692 }
693 }
694}
695
696#[cfg(not(target_arch = "wasm32"))]
697impl Default for ThreadRunner {
698 fn default() -> Self {
699 Self::new()
700 }
701}
702
703/// The runner thread's whole life: block on the next command, batch it with every
704/// other command already queued, COALESCE consecutive `Run`s (a `Run` immediately
705/// followed by another `Run` — with no `Query`/`Reset` between — is dropped; only
706/// the last of each consecutive group runs), then process the batch IN ORDER so a
707/// `Reset` or a `Query` interleaved between two runs keeps its place. Exits when
708/// the command sender is dropped (`recv` errors) or the reply receiver is gone (a
709/// `send` errors — the main side went away).
710///
711/// `stop` is the cooperative cancel: raised by [`ThreadRunner::cancel`] on a
712/// thread that has already been abandoned, it is checked before every feature
713/// a run executes, so the abandoned thread finishes the feature it is on and
714/// exits at the next boundary instead of running the rest of the history for
715/// a receiver that is gone.
716#[cfg(not(target_arch = "wasm32"))]
717fn thread_main(
718 cmd_rx: std::sync::mpsc::Receiver<Command>,
719 reply_tx: std::sync::mpsc::Sender<Reply>,
720 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
721) {
722 let mut runner = crate::pipeline::SceneRunner::new();
723 while let Ok(first) = cmd_rx.recv() {
724 if stop.load(std::sync::atomic::Ordering::Relaxed) {
725 return;
726 }
727 // Gather this command plus everything else already waiting.
728 let mut batch = vec![first];
729 loop {
730 match cmd_rx.try_recv() {
731 Ok(command) => batch.push(command),
732 Err(_) => break, // Empty or Disconnected — process what we have.
733 }
734 }
735 // A Run immediately followed by another Run is coalesced away (its result
736 // would be overwritten before anything observed it); a Run followed by a
737 // Query/Reset/end still runs, so interleaved work sees the right geometry.
738 let mut run_here: Vec<bool> = vec![true; batch.len()];
739 for i in 0..batch.len() {
740 if matches!(batch[i], Command::Run { .. })
741 && matches!(batch.get(i + 1), Some(Command::Run { .. }))
742 {
743 run_here[i] = false;
744 }
745 }
746 // Process the KEPT commands in order through the SHARED `process_command`
747 // (byte-identical work to the wasm worker's per-message step), sending each
748 // reply it produces; a coalesced-away Run is skipped without touching the
749 // runner, so interleaved Query/Reset still see the right geometry.
750 for (i, command) in batch.into_iter().enumerate() {
751 if !run_here[i] {
752 continue;
753 }
754 let mut progress = |report: RunProgress| {
755 // A lost receiver means the main side abandoned this thread:
756 // stop at this boundary rather than finishing for nobody.
757 reply_tx.send(Reply::Progress(report)).is_ok()
758 && !stop.load(std::sync::atomic::Ordering::Relaxed)
759 };
760 if let Some(reply) = process_command(&mut runner, command, &mut progress) {
761 if reply_tx.send(reply).is_err() {
762 return; // The reply receiver is gone — the main side went away.
763 }
764 }
765 }
766 }
767}
768
769#[cfg(not(target_arch = "wasm32"))]
770impl HistoryRunner for ThreadRunner {
771 fn submit_run(&mut self, request: brep_kernel::HistoryRequest, generation: u64) {
772 if let Some(tx) = &self.tx {
773 let _ = tx.send(Command::Run {
774 request,
775 generation,
776 parts_library_revision: self.sent_library_revision.unwrap_or(0),
777 });
778 }
779 }
780
781 fn sync_parts_library(
782 &mut self,
783 revision: u64,
784 fetch: &mut dyn FnMut() -> brep_kernel::PartsLibraryMap,
785 ) {
786 if self.sent_library_revision == Some(revision) {
787 return;
788 }
789 if let Some(tx) = &self.tx {
790 let _ = tx.send(Command::SetPartsLibrary {
791 library: fetch(),
792 revision,
793 });
794 self.sent_library_revision = Some(revision);
795 }
796 }
797
798 fn poll_library_request(&mut self) -> bool {
799 self.drain();
800 std::mem::take(&mut self.library_requested)
801 }
802
803 fn poll_run(&mut self) -> Option<RunReply> {
804 self.drain();
805 self.run_buf.pop_front()
806 }
807
808 fn submit_query(&mut self, query: MeasureQuery) {
809 if let Some(tx) = &self.tx {
810 let _ = tx.send(Command::Query(query));
811 }
812 }
813
814 fn poll_query(&mut self) -> Option<MeasureReply> {
815 self.drain();
816 self.query_buf.pop_front()
817 }
818
819 fn submit_mesh_import(&mut self, request: MeshImportRequest) {
820 if let Some(tx) = &self.tx {
821 let _ = tx.send(Command::MeshImport(request));
822 }
823 }
824
825 fn poll_mesh_import(&mut self) -> Option<MeshImportReply> {
826 self.drain();
827 self.mesh_import_buf.pop_front()
828 }
829
830 fn submit_step_probe(&mut self, request: StepProbeRequest) {
831 if let Some(tx) = &self.tx {
832 let _ = tx.send(Command::StepProbe(request));
833 }
834 }
835
836 fn poll_step_probe(&mut self) -> Option<StepProbeReply> {
837 self.drain();
838 self.step_probe_buf.pop_front()
839 }
840
841 fn poll_progress(&mut self) -> Option<RunProgress> {
842 self.drain();
843 let latest = self.progress_buf.pop_back();
844 self.progress_buf.clear();
845 latest
846 }
847
848 /// Abandon the thread: raise its stop flag, close its channels, and spawn a
849 /// fresh thread with a fresh registry. The old thread cannot be interrupted
850 /// inside a feature — it finishes the one it is on (burning CPU until
851 /// then), sees the flag, and exits. Always `true`: there is always a
852 /// thread to replace.
853 fn cancel(&mut self) -> bool {
854 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
855 self.tx.take();
856 self.handle.take();
857 *self = Self::new();
858 true
859 }
860
861 fn reset(&mut self) {
862 if let Some(tx) = &self.tx {
863 let _ = tx.send(Command::Reset);
864 }
865 // A reset is a wholesale document switch: any replies still buffered from
866 // the old model are stale — drop them (a fresh run/query supersedes).
867 self.run_buf.clear();
868 self.query_buf.clear();
869 self.mesh_import_buf.clear();
870 self.step_probe_buf.clear();
871 self.progress_buf.clear();
872 // `Command::Reset` clears the thread's kernel store, library included,
873 // so what we believe it holds is void.
874 self.sent_library_revision = None;
875 self.library_requested = false;
876 }
877}
878
879#[cfg(not(target_arch = "wasm32"))]
880impl Drop for ThreadRunner {
881 fn drop(&mut self) {
882 // Closing a document or cancelling an import preview must not block
883 // the UI on reconstruction. Closing the channel lets the worker exit
884 // and release its registry once already-submitted work finishes.
885 self.tx.take();
886 self.handle.take();
887 }
888}
889
890// BREP private tests: e94b3e6301fb59ea