BREP_app 0.1.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
//! `WorkerRunner` (M3b) — the wasm history runner that executes a history OFF the
//! browser main thread in a dedicated WEB WORKER, so the single-threaded wasm UI
//! stays responsive during a run (and during per-object measurement queries). It
//! is the wasm sibling of `brep-render`'s native `ThreadRunner`, behind the SAME
//! [`HistoryRunner`] trait: `submit_*` / `reset` serialize a
//! [`Command`](brep_render::runner::Command) to JSON and `postMessage` it to the
//! worker; `poll_*` drain the JSON [`Reply`](brep_render::runner::Reply)s the
//! worker's `onmessage` pushes into a shared inbox.
//!
//! Everything here runs on the MAIN thread (single-threaded wasm) — the worker is a
//! separate agent reached only through `postMessage` — so `Rc<RefCell<..>>` is the
//! right shared-state primitive (no `Send`; the `HistoryRunner` trait imposes none).
//! The worker's CPU-only side is [`worker_entry`] at the bottom of this file.

use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;

use brep_render::brep_kernel::HistoryRequest;
use brep_render::runner::{Command, HistoryRunner, MeasureQuery, MeasureReply, Reply, RunReply};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::MessageEvent;

/// A [`HistoryRunner`] that offloads runs + measurement queries to a dedicated
/// MODULE web worker (`worker.js` → [`worker_entry`]). Holds the worker, a shared
/// `inbox` its `onmessage` fills, the two demux buffers, the kept `onmessage`
/// closure, and MAIN-SIDE run coalescing (`in_flight` + `pending_run`).
pub struct WorkerRunner {
    /// The dedicated worker running [`worker_entry`] (it owns the resident registry).
    worker: web_sys::Worker,
    /// Replies the worker's `onmessage` has pushed, awaiting [`Self::drain`]. Shared
    /// with the kept closure (both on the main thread → `Rc<RefCell<..>>`, no `Send`).
    inbox: Rc<RefCell<VecDeque<Reply>>>,
    /// The kept `onmessage` closure — dropping it detaches the handler, so it lives
    /// as long as the runner.
    _onmessage: Closure<dyn FnMut(MessageEvent)>,
    /// Demuxed completed run replies awaiting `poll_run`.
    run_buf: VecDeque<RunReply>,
    /// Demuxed completed measurement replies awaiting `poll_query`.
    query_buf: VecDeque<MeasureReply>,
    /// MAIN-SIDE run coalescing: a run is on the worker (posted, reply not yet drained).
    in_flight: bool,
    /// The latest run submitted WHILE one was in flight — REPLACES any earlier parked
    /// run (latest wins), so a slider drag never queues stale intermediates; posted
    /// when the in-flight run's reply drains (see [`Self::drain`]).
    pending_run: Option<(HistoryRequest, u64)>,
}

impl WorkerRunner {
    /// Spawn the module worker and wire its reply pump. Panics with a clear message
    /// if the worker cannot be created (the browser could not load `./worker.js`).
    pub fn new() -> Self {
        let options = web_sys::WorkerOptions::new();
        // 0.3.104 deprecates the builder `type_()`; `set_type` is the current setter.
        options.set_type(web_sys::WorkerType::Module);
        let worker = web_sys::Worker::new_with_options("./worker.js", &options)
            .unwrap_or_else(|e| panic!("failed to spawn history worker (./worker.js): {e:?}"));

        // The worker posts a JSON `Reply` string per completed command; parse it back
        // and queue it for the next `poll_*` drain.
        let inbox: Rc<RefCell<VecDeque<Reply>>> = Rc::new(RefCell::new(VecDeque::new()));
        let inbox_cb = inbox.clone();
        let onmessage = Closure::wrap(Box::new(move |event: MessageEvent| {
            if let Some(text) = event.data().as_string() {
                match serde_json::from_str::<Reply>(&text) {
                    Ok(reply) => inbox_cb.borrow_mut().push_back(reply),
                    Err(error) => log::error!("worker reply parse failed: {error}"),
                }
            }
        }) as Box<dyn FnMut(MessageEvent)>);
        worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));

        Self {
            worker,
            inbox,
            _onmessage: onmessage,
            run_buf: VecDeque::new(),
            query_buf: VecDeque::new(),
            in_flight: false,
            pending_run: None,
        }
    }

    /// Serialize `command` and `postMessage` it to the worker as a JSON string.
    fn post(&self, command: &Command) -> Result<(), JsValue> {
        let json = serde_json::to_string(command).expect("serialize worker command");
        self.worker.post_message(&JsValue::from_str(&json))
    }

    /// Post a `Run` and mark a run in flight. A failed post is FATAL: leaving
    /// `in_flight = true` after a swallowed error would wedge coalescing forever (the
    /// parked `pending_run` would never post), so panic loudly instead.
    fn post_run(&mut self, request: HistoryRequest, generation: u64) {
        self.post(&Command::Run { request, generation })
            .unwrap_or_else(|e| panic!("post run to history worker failed: {e:?}"));
        self.in_flight = true;
    }

    /// Move every queued inbox `Reply` into the per-kind buffers. On a `Run` reply the
    /// in-flight run is done: clear the flag and, if a newer run is parked in
    /// `pending_run`, post it now — this is the one-in-flight coalescing.
    fn drain(&mut self) {
        loop {
            // Bind the pop result before matching so the inbox borrow is released
            // before `post_run` (which does not touch the inbox, but keep it tight).
            let next = self.inbox.borrow_mut().pop_front();
            let Some(reply) = next else { break };
            match reply {
                Reply::Run(run) => {
                    self.run_buf.push_back(run);
                    self.in_flight = false;
                    if let Some((request, generation)) = self.pending_run.take() {
                        self.post_run(request, generation);
                    }
                }
                Reply::Query(query) => self.query_buf.push_back(query),
            }
        }
    }
}

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

impl HistoryRunner for WorkerRunner {
    fn submit_run(&mut self, request: HistoryRequest, generation: u64) {
        if self.in_flight {
            // A run is already on the worker — PARK this one (latest wins) so the
            // worker never runs a stale intermediate from a drag backlog. `in_flight`
            // false always implies `pending_run` is `None` (drain / reset keep this).
            self.pending_run = Some((request, generation));
        } else {
            self.post_run(request, generation);
        }
    }

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

    fn submit_query(&mut self, query: MeasureQuery) {
        // Queries are not coalesced; post immediately. (A query posted while a run is
        // parked in `pending_run` answers against the worker's CURRENT geometry; the
        // next applied run clears any pending query main-side, so this is benign.)
        let _ = self.post(&Command::Query(query));
    }

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

    fn reset(&mut self) {
        let _ = self.post(&Command::Reset);
        // A wholesale document switch: drop any buffered/parked work from the old
        // model (the generation gate in `EngineState::pump` also protects) and clear
        // the in-flight flag so the next run posts immediately.
        self.run_buf.clear();
        self.query_buf.clear();
        self.pending_run = None;
        self.in_flight = false;
    }
}

// ===========================================================================
// worker_entry — the WORKER side (CPU only; never touches wgpu/canvas). Owns a
// PERSISTENT SceneRunner so its delta baseline + this worker's thread-local kernel
// registry survive across messages, and answers each posted `Command` through the
// SHARED `process_command`, posting back the JSON `Reply` it produces.
// ===========================================================================

/// The web worker's entry point — invoked once from `worker.js` after `init()`.
/// Installs an `onmessage` loop over the worker's global scope: each posted
/// [`Command`] (a JSON string) runs through
/// [`brep_render::runner::process_command`] against a persistent
/// [`SceneRunner`](brep_render::pipeline::SceneRunner), and any [`Reply`] is posted
/// back as JSON. The closure is `forget()`-ten so it lives for the worker's life.
#[wasm_bindgen]
pub fn worker_entry() {
    console_error_panic_hook::set_once();

    let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
    let scope_reply = scope.clone();

    // The resident runner: its `last_sent` delta baseline AND this worker's
    // thread-local kernel history cache persist across messages, so incremental
    // reruns replay exactly as the native thread runner does.
    let runner = Rc::new(RefCell::new(brep_render::pipeline::SceneRunner::new()));

    let onmessage = Closure::wrap(Box::new(move |event: MessageEvent| {
        let Some(text) = event.data().as_string() else {
            return;
        };
        let command = match serde_json::from_str::<Command>(&text) {
            Ok(command) => command,
            Err(error) => {
                log::error!("worker command parse failed: {error}");
                return;
            }
        };
        let reply = {
            let mut resident = runner.borrow_mut();
            brep_render::runner::process_command(&mut resident, command)
        };
        if let Some(reply) = reply {
            let json = serde_json::to_string(&reply).expect("serialize worker reply");
            let _ = scope_reply.post_message(&JsValue::from_str(&json));
        }
    }) as Box<dyn FnMut(MessageEvent)>);
    scope.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
    // Keep the handler alive for the worker's whole life (the worker owns it now).
    onmessage.forget();
}