diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index c2ab5221..25872cfa 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1219,6 +1219,8 @@ impl Model { } } state = Arc::new(next); + // A window opening the design can show what is built so far. + watched.offer(i + 1, doc, &state); None } Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()), diff --git a/crates/doc/src/progress.rs b/crates/doc/src/progress.rs index ae063976..22a76cd8 100644 --- a/crates/doc/src/progress.rs +++ b/crates/doc/src/progress.rs @@ -21,8 +21,23 @@ pub struct Progress { work_stage: std::sync::Mutex<&'static str>, work_done: AtomicUsize, work_total: AtomicUsize, + /// A window wants the model as far as it is built, to show while the rest is ([`Progress::want_partial`]). + partial_wanted: AtomicBool, + /// The latest such model and when it was offered. + partial: std::sync::Mutex<(Option, Option)>, } +/// The model as far as an evaluation has built it, placed in the world as the design places it. +#[derive(Clone, Debug)] +pub struct Partial { + /// Features evaluated. + pub features: usize, + pub world: Arc, +} + +/// How often an evaluation offers the model built so far. +const PARTIAL_EVERY: std::time::Duration = std::time::Duration::from_millis(1500); + impl Default for Progress { fn default() -> Self { Progress { @@ -33,6 +48,8 @@ impl Default for Progress { work_stage: std::sync::Mutex::new(""), work_done: AtomicUsize::new(0), work_total: AtomicUsize::new(0), + partial_wanted: AtomicBool::new(false), + partial: std::sync::Mutex::new((None, None)), } } } @@ -54,6 +71,14 @@ impl Progress { pub fn features(&self) -> (usize, usize) { (self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed)) } + /// Ask the watched evaluation to offer the model as far as it is built, now and then. + pub fn want_partial(&self) { + self.partial_wanted.store(true, Ordering::Relaxed); + } + /// The model as far as it was built when last offered, once (`None` until there is a newer one). + pub fn take_partial(&self) -> Option { + self.partial.lock().ok().and_then(|mut p| p.0.take()) + } /// Long work inside a feature, such as reading a large STEP file: (step, done, total); /// `None` while there is none to show. pub fn work(&self) -> Option<(&'static str, usize, usize)> { @@ -146,6 +171,37 @@ impl Evaluation { } } +impl Evaluation { + /// The outer evaluation has built the model up to feature `features`: a watcher that wants + /// partial models gets it, placed by `doc`, at most every [`PARTIAL_EVERY`] (the first one + /// after one interval, so a design that opens quickly offers none). + pub(crate) fn offer(&self, features: usize, doc: &crate::Document, state: &Arc) { + if !self.outer { + return; + } + let progress = CURRENT.with(|c| c.borrow().as_ref().map(|cur| cur.progress.clone())); + let Some(progress) = progress.filter(|p| p.partial_wanted.load(Ordering::Relaxed) && !p.is_cancelled()) else { return }; + let now = web_time::Instant::now(); + let due = match progress.partial.lock() { + Ok(mut p) => match p.1 { + None => { + p.1 = Some(now); + false + } + Some(last) => now.duration_since(last) >= PARTIAL_EVERY, + }, + Err(_) => false, + }; + if !due { + return; + } + let world = Arc::new(crate::world_state(doc, state)); + if let Ok(mut p) = progress.partial.lock() { + *p = (Some(Partial { features, world }), Some(web_time::Instant::now())); + } + } +} + impl Drop for Evaluation { fn drop(&mut self) { CURRENT.with(|c| { @@ -185,4 +241,44 @@ mod tests { assert_eq!(p.features(), (5, 5)); assert!(!begin(1).step(0), "the watch ends with its scope"); } + + /// A watcher that asks for partial models gets the model built so far, placed, now and then; + /// one that doesn't ask, or an unwatched evaluation, never pays for it. + #[test] + fn partial_models_go_only_to_a_watcher_that_wants_them() { + let mut doc = crate::Document::new("partial"); + let block = crate::FeatureKind::Box { + corner: crate::point_expr(solidcraft_geom::Vec3::ZERO), + length: "2 mm".into(), + width: "3 mm".into(), + height: "4 mm".into(), + operation: crate::Operation::NewBody, + }; + doc.add_feature(block, Some("Block")).unwrap(); + let mut model = crate::Model::default(); + model.evaluate(&doc); + let state = model.state(); + let offer = |p: &Arc| { + watch(p.clone(), || { + let e = begin(1); + e.offer(1, &doc, &state); + }) + }; + let p = Arc::new(Progress::default()); + offer(&p); + assert!(p.take_partial().is_none(), "not asked for"); + p.want_partial(); + offer(&p); + assert!(p.take_partial().is_none(), "nothing in the first interval: a quick open offers none"); + if let Ok(mut g) = p.partial.lock() { + g.1 = web_time::Instant::now().checked_sub(PARTIAL_EVERY * 2); + } + offer(&p); + let partial = p.take_partial().unwrap(); + assert_eq!(partial.features, 1); + assert_eq!(partial.world.bodies.len(), 1); + assert!(p.take_partial().is_none(), "taken once"); + offer(&p); + assert!(p.take_partial().is_none(), "not again within the interval"); + } } diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index ea941c33..aa5e7a38 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -82,6 +82,10 @@ struct Running { progress: Arc, preview_rx: Option>, preview: Option, + /// Pictures of the model as far as it is built (the picture, features built). + partial_rx: Option>, + /// The picture shown is of the first this many features (`None`: the saved preview). + built: Option, } impl Running { @@ -249,6 +253,36 @@ fn start(app: &mut SolveApp) { let (command, params) = request.command(); let scope = request.scope.clone(); let thumbnail = request.kind == Kind::Open; + // While a design rebuilds, a picture of what is built so far (a large design takes a while). + let finished = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let partial_rx = (command == "doc.open" || command == "doc.recover").then(|| { + progress.want_partial(); + let (partial_tx, partial_rx) = std::sync::mpsc::channel(); + let (watch, finished) = (progress.clone(), finished.clone()); + let _ = std::thread::Builder::new().name("file-load-picture".into()).spawn(move || { + let stop = || finished.load(Ordering::Relaxed) || watch.is_cancelled(); + while !stop() { + std::thread::sleep(std::time::Duration::from_millis(250)); + let Some(partial) = watch.take_partial() else { continue }; + let began = std::time::Instant::now(); + let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| partial_picture(&partial.world, &stop))).ok().flatten(); + if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() { + eprintln!( + "SolidCraft partial picture: features={} bodies={} drawn={} ms={:.1}", + partial.features, + partial.world.bodies.len(), + picture.is_some(), + began.elapsed().as_secs_f64() * 1000.0 + ); + } + if let Some(picture) = picture.filter(|_| !stop()) { + let _ = partial_tx.send((picture, partial.features)); + } + } + }); + partial_rx + }); + let worker_finished = finished; let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || { let preview_began = std::time::Instant::now(); if command == "doc.open" { @@ -270,12 +304,24 @@ fn start(app: &mut SolveApp) { compute(scratch, command, ¶ms, scope, thumbnail, &worker_phase, &worker_progress) })) .unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into())); + worker_finished.store(true, Ordering::Relaxed); let _ = tx.send(result); }); match spawned { Ok(_) => { - app.file_load.running = - Some(Running { request, base, base_revision, before, rx, phase, progress, preview_rx: Some(preview_rx), preview: None }) + app.file_load.running = Some(Running { + request, + base, + base_revision, + before, + rx, + phase, + progress, + preview_rx: Some(preview_rx), + preview: None, + partial_rx, + built: None, + }) } Err(e) => { let e = format!("could not start file operation: {e}"); @@ -285,6 +331,44 @@ fn start(app: &mut SolveApp) { } } +/// A small picture of the bodies built so far (their display meshes, made here and kept for +/// the finished model), or `None` when there is nothing to show yet, too much to draw, or the +/// load ended meanwhile (`stop`). +#[cfg(not(target_arch = "wasm32"))] +fn partial_picture(world: &solidcraft_engine::doc::ModelState, stop: &dyn Fn() -> bool) -> Option { + use solidcraft_engine::{ + geom::Aabb3, + render::{Camera, Rgb, Scene, SceneMesh, StandardView}, + }; + let mut scene = Scene::default(); + let mut bounds = Aabb3::EMPTY; + let mut triangles = 0usize; + for body in &world.bodies { + if stop() { + return None; + } + let mesh = body.mesh(); + triangles += mesh.triangles.len(); + if triangles > 4_000_000 { + return None; + } + bounds = bounds.union(&mesh.bounds()); + scene.meshes.push(SceneMesh { mesh, color: Rgb(176, 186, 199), face_colors: Vec::new() }); + } + if bounds.is_empty() { + return None; + } + let mut camera = Camera::default(); + camera.set_view(StandardView::Iso); + camera.fit(&bounds); + scene.radius = bounds.diagonal(); + if stop() || !preview_work_fits(&scene, &camera) { + return None; + } + let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200); + Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba)) +} + #[cfg(not(target_arch = "wasm32"))] fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option { use solidcraft_engine::{ @@ -429,6 +513,14 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { if !running.cancelled() && running.matches(&app.session) { if let Some(image) = running.preview_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR)); + running.built = None; + } + // The model as far as it is built, unless the file's saved picture (all of it) is up. + if let Some((image, built)) = running.partial_rx.as_ref().and_then(|rx| rx.try_iter().last()) + && (running.preview.is_none() || running.built.is_some()) + { + running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR)); + running.built = Some(built); } } else { running.preview = None; @@ -572,7 +664,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) { let w = ui.available_width().min(320.0); ui.image((preview.id(), egui::vec2(w, w * 200.0 / 320.0))); - ui.label(egui::RichText::new(crate::i18n::tr_label("Saved preview. Geometry is being checked before editing.")).small().weak()); + let note = match r.built { + Some(built) => format!("Built so far: {built} of {total} features."), + None => crate::i18n::tr_label("Saved preview. Geometry is being checked before editing.").to_string(), + }; + ui.label(egui::RichText::new(note).small().weak()); } ui.add_space(4.0); ui.horizontal(|ui| { @@ -636,6 +732,8 @@ mod tests { progress: Arc::new(Progress::default()), preview_rx: Some(preview_rx), preview: None, + partial_rx: None, + built: None, }); if change_document { app.session.execute("file.new", &serde_json::json!({"name":"Newer design"})).unwrap(); @@ -698,6 +796,8 @@ mod tests { progress: Arc::new(Progress::default()), preview_rx: None, preview: None, + partial_rx: None, + built: None, }); tick(&mut app, &egui::Context::default()); assert!(Arc::ptr_eq(&app.session.doc, &old)); @@ -721,6 +821,8 @@ mod tests { progress, preview_rx: None, preview: None, + partial_rx: None, + built: None, }); assert!(!app.file_load.busy()); assert!(app.file_load.running.is_some());