From 7b46ddee45aac6a53a99b3a25335b39df5428d0d Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:36:57 -0500 Subject: [PATCH 1/9] feat(viewer): the loading card shows the decoder's own progress, and a model too large for the tab says so (#1195) The wasm open binding takes an optional progress function that the decoder's WalkerControl reports to, throttled to one call per whole percent of a stage. The worker posts each report while the wasm call runs, the card names the stage and fills its bar with the stage's count, and the size-derived estimate stays as the first thing it says. A file whose size says it may need more than a tab's 4 GiB is warned about before the decode; a wasm instance that runs out of memory (a bare unreachable with no panic message, or a capacity overflow) is reported as too large for the browser, with how far it got and the CLIs that open it. Playwright covers the stages, the part-way bar on Core Interior, the warning, and a real stop under a V8-capped wasm memory. --- Cargo.lock | 1 + .../added/1195-viewer-progress-too-large.md | 12 + rvt-wasm/Cargo.toml | 1 + rvt-wasm/src/lib.rs | 138 ++++++++- src/ifc/mod.rs | 22 +- viewer/index.html | 24 +- viewer/src/main.ts | 280 ++++++++++++++++-- viewer/src/styles.css | 24 +- viewer/src/worker.ts | 75 ++++- viewer/tests/load-progress.spec.ts | 184 ++++++++++++ viewer/tests/memory-ceiling.spec.ts | 118 ++++++++ viewer/tests/saved-meshes-measure.spec.ts | 66 ++++- viewer/tests/viewer-regression.spec.ts | 10 +- 13 files changed, 903 insertions(+), 52 deletions(-) create mode 100644 changelog.d/added/1195-viewer-progress-too-large.md create mode 100644 viewer/tests/load-progress.spec.ts create mode 100644 viewer/tests/memory-ceiling.spec.ts diff --git a/Cargo.lock b/Cargo.lock index ec816d9e0..673ebdd63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,6 +867,7 @@ dependencies = [ name = "rvt-wasm" version = "0.12.2" dependencies = [ + "js-sys", "rvt", "serde", "serde-wasm-bindgen", diff --git a/changelog.d/added/1195-viewer-progress-too-large.md b/changelog.d/added/1195-viewer-progress-too-large.md new file mode 100644 index 000000000..fd99589d1 --- /dev/null +++ b/changelog.d/added/1195-viewer-progress-too-large.md @@ -0,0 +1,12 @@ +- **The browser viewer shows the decoder's own progress, and says when a + model is too large for a tab.** The loading card names the stage the + decoder is in (the stages `rvt-gltf --progress` prints) and fills its bar + with that stage's count, where it used to sweep and guess "about N s" from + the file's size. `openRvtBytesWithDiagnosticsModeAndOpenLimits` takes an + optional fourth argument, a function called with the stage, the count done + and the count it is out of. A file whose size says it may need more than a + tab's 4 GiB (about ten times its size, the figure of the largest reference + model) is warned about before the decode and still tried. When the wasm + instance runs out of memory, the viewer says the model is too large for the + browser, how far the decode got, and that `rvt-ifc` and `rvt-gltf` read + models of any size, instead of `RuntimeError: unreachable` (#1195). diff --git a/rvt-wasm/Cargo.toml b/rvt-wasm/Cargo.toml index 66c557270..fc415a646 100644 --- a/rvt-wasm/Cargo.toml +++ b/rvt-wasm/Cargo.toml @@ -22,6 +22,7 @@ workspace = true [dependencies] rvt = { path = ".." } wasm-bindgen = "0.2" +js-sys = "0.3" serde = { version = "1", features = ["derive"] } serde-wasm-bindgen = "0.6" diff --git a/rvt-wasm/src/lib.rs b/rvt-wasm/src/lib.rs index fd4c77c15..0cec0bcaf 100644 --- a/rvt-wasm/src/lib.rs +++ b/rvt-wasm/src/lib.rs @@ -19,10 +19,12 @@ //! compiled `.wasm` for those imports and fails the build if any //! appear. +use std::cell::RefCell; + use wasm_bindgen::prelude::*; use rvt::RevitFile; -use rvt::control::WalkerControl; +use rvt::control::{ProgressEvent, Stage, WalkerControl}; use rvt::ifc::{ ExportQualityMode, ExportResult, IfcModel, RvtDocExporter, gltf::model_to_glb, @@ -78,6 +80,7 @@ pub fn open_rvt_bytes_with_diagnostics_mode(bytes: &[u8], mode: &str) -> Result< OpenLimits::default(), rvt::walker::WalkerLimits::default(), true, + None, ) } @@ -106,15 +109,105 @@ pub fn open_rvt_bytes_with_diagnostics_mode(bytes: &[u8], mode: &str) -> Result< /// skipped item and a `Not read whole:` warning per stream, #1075), and the /// model is missing what it holds. Unknown fields and fractional, negative /// or non-finite byte limits throw. +/// +/// `progress`, when given, is a JS function `(stage, done, total)` called as +/// the decoder works: `stage` is a [`Stage`] name as `rvt-gltf --progress` +/// prints it (`schema-parse`, `candidate-scan`, `element-decode`, +/// `partition-scan`, `saved-graphics`, `saved-meshes`), `done` a count and +/// `total` the count it is out of, or `null` where the stage has no +/// denominator yet (#1195). It is called when a stage begins, when it ends, +/// and each time a stage with a total passes another whole percent, so a +/// stage costs at most about a hundred calls. An exception it throws is +/// ignored. #[wasm_bindgen(js_name = openRvtBytesWithDiagnosticsModeAndOpenLimits)] pub fn open_rvt_bytes_with_diagnostics_mode_and_open_limits( bytes: &[u8], mode: &str, limits: JsValue, + progress: Option, ) -> Result { install_panic_hook(); let (open_limits, walker_limits, saved_meshes) = limits_from_js(limits)?; - export_with_limits(bytes, mode, open_limits, walker_limits, saved_meshes) + export_with_limits( + bytes, + mode, + open_limits, + walker_limits, + saved_meshes, + progress, + ) +} + +/// The JS function a decode reports its progress to, and the last report it +/// forwarded. A `js_sys::Function` is not `Send + Sync`, which +/// [`WalkerControl::with_progress`] requires, so it sits here, where one +/// single-threaded wasm instance reads it, and the closure handed to the +/// control captures nothing. +struct ProgressSink { + callback: js_sys::Function, + last: Option<(Stage, Option)>, +} + +thread_local! { + static PROGRESS: RefCell> = const { RefCell::new(None) }; +} + +/// The control of one decode: reports to `progress` when a function is given +/// and does nothing otherwise. +fn progress_control(progress: Option) -> WalkerControl { + match progress { + Some(callback) => { + PROGRESS.with(|slot| { + *slot.borrow_mut() = Some(ProgressSink { + callback, + last: None, + }); + }); + WalkerControl::new().with_progress(forward_progress) + } + None => WalkerControl::default(), + } +} + +fn clear_progress() { + PROGRESS.with(|slot| { + slot.borrow_mut().take(); + }); +} + +/// Hands `event` to the JS function unless it is a report within the same +/// whole percent of the same stage as the last one handed over. The decoder +/// reports each megabyte scanned and every 256th candidate, which the page +/// has no use for; a stage with no total reports once, when it begins. +fn forward_progress(event: ProgressEvent) { + PROGRESS.with(|slot| { + let Ok(mut slot) = slot.try_borrow_mut() else { + return; + }; + let Some(sink) = slot.as_mut() else { + return; + }; + let percent = event + .total + .filter(|total| *total > 0) + .map(|total| event.done.saturating_mul(100) / total); + let finished = event.total.is_some_and(|total| event.done >= total); + let same_stage = sink.last.is_some_and(|(stage, _)| stage == event.stage); + let same_percent = sink.last.is_some_and(|(_, last)| last == percent); + if same_stage && same_percent && !finished { + return; + } + sink.last = Some((event.stage, percent)); + let total = event + .total + .map_or(JsValue::NULL, |total| JsValue::from_f64(total as f64)); + let _ = sink.callback.call3( + &JsValue::NULL, + &JsValue::from_str(event.stage.as_str()), + &JsValue::from_f64(event.done as f64), + &total, + ); + }); } fn export_with_limits( @@ -123,14 +216,41 @@ fn export_with_limits( open_limits: OpenLimits, walker_limits: rvt::walker::WalkerLimits, saved_meshes: bool, + progress: Option, +) -> Result { + let control = progress_control(progress); + let result = export_with_control( + bytes, + mode, + open_limits, + walker_limits, + saved_meshes, + &control, + ); + clear_progress(); + result +} + +fn export_with_control( + bytes: &[u8], + mode: &str, + open_limits: OpenLimits, + walker_limits: rvt::walker::WalkerLimits, + saved_meshes: bool, + control: &WalkerControl, ) -> Result { let quality_mode = ExportQualityMode::parse(mode).map_err(err_str)?; let mut rf = RevitFile::open_bytes_with_limits(bytes.to_vec(), open_limits).map_err(err_str)?; let mut result = RvtDocExporter - .export_with_diagnostics_mode_and_limits(&mut rf, quality_mode, walker_limits) + .export_with_diagnostics_mode_limits_and_control( + &mut rf, + quality_mode, + walker_limits, + control, + ) .map_err(err_str)?; if saved_meshes { - attach_saved_meshes(&mut rf, &mut result); + attach_saved_meshes(&mut rf, &mut result, control); } serde_wasm_bindgen::to_value(&result).map_err(err_str) } @@ -140,16 +260,12 @@ fn export_with_limits( /// 2027 only), and recount its diagnostics. Saved graphics only improve /// bodies: when they cannot be read the model keeps its bounding boxes and the /// diagnostics carry a warning saying so. -fn attach_saved_meshes(rf: &mut RevitFile, result: &mut ExportResult) { +fn attach_saved_meshes(rf: &mut RevitFile, result: &mut ExportResult, control: &WalkerControl) { let Some(scope) = rvt::ifc::saved_meshes::default_scope(rf) else { return; }; - match rvt::ifc::saved_meshes::attach_scoped_with_control( - rf, - &mut result.model, - scope, - &WalkerControl::default(), - ) { + match rvt::ifc::saved_meshes::attach_scoped_with_control(rf, &mut result.model, scope, control) + { Ok(_) => rvt::ifc::recount_exported(&mut result.diagnostics, &result.model), Err(e) => result.diagnostics.warnings.push(format!( "Saved graphics not read, door and window bodies unchanged: {e}" diff --git a/src/ifc/mod.rs b/src/ifc/mod.rs index 5a48f126f..94e379044 100644 --- a/src/ifc/mod.rs +++ b/src/ifc/mod.rs @@ -1016,7 +1016,27 @@ impl RvtDocExporter { quality_mode: ExportQualityMode, limits: crate::walker::WalkerLimits, ) -> Result { - let model = self.export_with_mode_and_limits(rf, quality_mode, limits)?; + self.export_with_diagnostics_mode_limits_and_control( + rf, + quality_mode, + limits, + &crate::control::WalkerControl::default(), + ) + } + + /// Same as [`Self::export_with_diagnostics_mode_and_limits`], with the + /// walk's progress reported to `control` and a cancelled token stopping + /// the export with [`crate::Error::Cancelled`], as + /// [`Self::export_with_mode_limits_and_control`] does for the model alone. + /// The diagnostics are built after the walk and report nothing. + pub fn export_with_diagnostics_mode_limits_and_control( + &self, + rf: &mut crate::RevitFile, + quality_mode: ExportQualityMode, + limits: crate::walker::WalkerLimits, + control: &crate::control::WalkerControl, + ) -> Result { + let model = self.export_with_mode_limits_and_control(rf, quality_mode, limits, control)?; let diagnostics = build_export_diagnostics_with_limits( rf, &model, diff --git a/viewer/index.html b/viewer/index.html index 291d48d97..2bacbb011 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -119,26 +119,36 @@

Demo gallery