diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 1c6beb3d..ee4162fb 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -456,6 +456,7 @@ fn main() -> eframe::Result { solidcraft_engine::crash::phase("app setup"); solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx); let mut app = SolveApp::new(Session::default(), services(cc)); + app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE); app.custom_titlebar = CUSTOM_TITLEBAR; app.integrated_titlebar = cfg!(target_os = "macos"); app.cpu_meshing = safe_mode; diff --git a/assets/locales/de.json b/assets/locales/de.json index e9764a79..bf2eb8ea 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -10,6 +10,7 @@ "browser.precision": "Genauigkeit: {precision}", "browser.sketch_name": "Skizze {number}", "browser.units": "Einheiten: {units}", + "dialog.computing": "Wird berechnet… {seconds} s", "dialog.custom_axis": "↖ Eigene Achse", "dialog.edit_title": "{title} bearbeiten", "dialog.select": "↖ Auswählen", diff --git a/assets/locales/en.json b/assets/locales/en.json index af0e10c5..7729a6e1 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -286,6 +286,7 @@ "cmd.view_list": "List Named Views", "cmd.view_rename": "Rename Named View", "cmd.view_save": "New Named View", + "dialog.computing": "Computing… {seconds} s", "dialog.custom_axis": "↖ Custom Axis", "dialog.edit_title": "EDIT {title}", "dialog.select": "↖ Select", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 0f349f25..f8f17f7d 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -286,6 +286,7 @@ "cmd.view_list": "名前付きビューを一覧表示", "cmd.view_rename": "名前付きビューの名前を変更", "cmd.view_save": "新規名前付きビュー", + "dialog.computing": "計算中… {seconds} 秒", "dialog.custom_axis": "↖ カスタム軸", "dialog.edit_title": "{title}を編集", "dialog.select": "↖ 選択", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 3b6cf556..2607933e 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -10,6 +10,7 @@ "browser.precision": "정밀도: {precision}", "browser.sketch_name": "스케치{number}", "browser.units": "단위: {units}", + "dialog.computing": "계산 중… {seconds}초", "dialog.custom_axis": "↖ 사용자 정의 축", "dialog.edit_title": "{title} 편집", "dialog.select": "↖ 선택", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index c183f897..7b75f38a 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -286,6 +286,7 @@ "cmd.view_list": "列出命名视图", "cmd.view_rename": "重命名命名视图", "cmd.view_save": "新建命名视图", + "dialog.computing": "正在计算… {seconds} 秒", "dialog.custom_axis": "↖ 自定义轴", "dialog.edit_title": "编辑 {title}", "dialog.select": "↖ 选择", diff --git a/crates/ui-egui/src/apply_off_thread_tests.rs b/crates/ui-egui/src/apply_off_thread_tests.rs new file mode 100644 index 00000000..0cd4c134 --- /dev/null +++ b/crates/ui-egui/src/apply_off_thread_tests.rs @@ -0,0 +1,118 @@ +//! OK off the window thread: a feature that takes longer than the app's wait is computed beside +//! the window, the dialog saying so, and applied once it is in; Cancel drops it. + +use std::time::Duration; + +use serde_json::json; + +use crate::scenario::Harness; + +/// A 60 × 20 × 10 block and, just finished on its top, a 10 × 10 square: Extrude takes it. +fn square_on_a_block() -> Harness { + let mut h = Harness::new(); + h.app.home.open = false; + for (id, p) in [ + ("sketch.create", json!({"plane": "XY"})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 20]})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance": 10})), + ("sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})), + ("sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]})), + ] { + h.app.run(id, p).unwrap(); + } + h.app.finish_sketch(); + h.app.start("solid.extrude"); + h.frames(2); + h +} + +/// Frames until OK's evaluation is in and applied (a few seconds at most). +fn until_applied(h: &mut Harness) { + for _ in 0..500 { + if h.app.applying.is_none() { + break; + } + h.frames(1); + std::thread::sleep(Duration::from_millis(10)); + } + h.frames(1); +} + +#[test] +fn ok_computes_off_the_window_thread_saying_so_then_applies() { + let mut h = square_on_a_block(); + h.app.apply_off_thread = Some(Duration::ZERO); + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.applying.is_some() && h.app.dialog.is_some(), "computing, the dialog open"); + // The evaluation may be in by the next frame: it is held until the dialog has been seen + // saying so (the evaluation itself is in the cache all the same). + let (finish, done) = std::sync::mpsc::channel(); + if let Some(job) = h.app.applying.as_mut() { + let _ = job.done.recv(); + job.done = done; + } + h.frames(1); + assert!(h.widget("Computing… 0 s").is_some(), "the dialog says it computes"); + // Enter again does not start it twice. + h.step(&json!({"key": "Enter"})).unwrap(); + finish.send(()).unwrap(); + until_applied(&mut h); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone())); + h.step(&json!({"expect": {"bodies": 1, "volume": 13000}})).unwrap(); + assert_eq!(h.app.session.doc.features.len(), 4, "one extrude added"); +} + +#[test] +fn cancel_while_computing_applies_nothing() { + let mut h = square_on_a_block(); + h.app.apply_off_thread = Some(Duration::ZERO); + let features = h.app.session.doc.features.len(); + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.applying.is_some()); + h.step(&json!({"key": "Escape"})).unwrap(); + assert!(h.app.applying.is_none() && h.app.dialog.is_none()); + h.frames(5); + assert_eq!(h.app.session.doc.features.len(), features); +} + +#[test] +fn the_dialog_holds_its_inputs_while_ok_computes() { + let mut h = square_on_a_block(); + h.app.fit_view(); + h.frames(2); + let picked = h.app.dialog.as_ref().map(|d| d.inputs[0].items.clone()).unwrap(); + // OK computing, until the test lets it finish. + let (finish, done) = std::sync::mpsc::channel(); + let cmds = h.app.dialog.as_ref().and_then(|d| crate::dialogs::apply_commands(&h.app, d).ok()).unwrap(); + h.app.applying = Some(crate::dialogs::Applying { done, started: crate::now_ms(), cmds }); + // A click on the square picked would take it out of the profiles. + h.step(&json!({"click": {"world": [10, 10, 10]}})).unwrap(); + h.frames(2); + assert_eq!(h.app.dialog.as_ref().map(|d| d.inputs[0].items.clone()), Some(picked), "no pick while computing"); + finish.send(()).unwrap(); + until_applied(&mut h); + assert!(h.app.dialog.is_none()); + h.step(&json!({"expect": {"bodies": 1, "volume": 13000}})).unwrap(); +} + +/// Inputs that changed while OK computed (nothing should change them) are computed in turn, off +/// the window thread too, then applied: run as OK finished, they would hold the window for the +/// whole boolean. +#[test] +fn inputs_changed_while_ok_computes_are_computed_off_the_thread_too() { + let mut h = square_on_a_block(); + h.app.apply_off_thread = Some(Duration::ZERO); + let features = h.app.session.doc.features.len(); + // OK computing other commands than the dialog's, until the test lets it finish. + let (finish, done) = std::sync::mpsc::channel(); + h.app.applying = Some(crate::dialogs::Applying { done, started: crate::now_ms(), cmds: Vec::new() }); + finish.send(()).unwrap(); + h.frames(1); + assert!(h.app.applying.is_some(), "the dialog's own commands are computing"); + assert_eq!(h.app.session.doc.features.len(), features, "nothing applied on the window thread"); + until_applied(&mut h); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.doc.features.len(), features + 1); + h.step(&json!({"expect": {"bodies": 1, "volume": 13000}})).unwrap(); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8bf25ed4..043534ac 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -199,6 +199,10 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { /// Draw the manipulator and the value box (called by the viewport after the model is drawn). pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) { + // While OK computes, the inputs hold what it computes: no arrow to drag, no box to type in. + if app.applying.is_some() { + return; + } let Some(mut d) = app.dialog.take() else { return }; if matches!(d.kind, Kind::Assembly(_)) { crate::dialogs_assembly::overlay(app, painter, proj, &mut d); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d0fdcc1b..9346aceb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -316,6 +316,61 @@ pub struct Dialog { pub then_start: Option<&'static str>, } +/// OK computing off the window thread: the dialog's commands evaluated on a scratch copy of the +/// session, which leaves the feature in the evaluation cache the session shares. Once that is +/// in, OK runs them for real and finds the feature there; meanwhile the window stays live and +/// the dialog says it is computing. +pub struct Applying { + pub(crate) done: std::sync::mpsc::Receiver<()>, + /// When OK was pressed (ms). + pub(crate) started: f64, + /// The commands being computed. + pub(crate) cmds: Vec<(String, Value)>, +} + +impl Applying { + /// The evaluation is in (or its worker ended). + fn finished(&self) -> bool { + !matches!(self.done.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)) + } +} + +/// How long the desktop app's OK waits for its evaluation before it lets the window go on and +/// shows that it is computing ([`SolveApp::apply_off_thread`]): a feature quicker than this +/// applies at once, as it always did. +pub const APPLY_AT_ONCE: std::time::Duration = std::time::Duration::from_millis(150); + +/// Start OK's evaluation off the window thread, for a feature dialog. `None` when OK should +/// just run: not a feature, not asked for ([`SolveApp::apply_off_thread`]), no threads (the +/// web), or done within the wait. +#[cfg(not(target_arch = "wasm32"))] +fn begin_applying(app: &SolveApp, d: &Dialog) -> Option { + let wait = app.apply_off_thread?; + if !d.previews() { + return None; + } + let cmds = apply_commands(app, d).ok()?; + let scratch = app.session.scratch(); + let (tx, rx) = std::sync::mpsc::channel(); + let computed = cmds.clone(); + std::thread::Builder::new() + .name("apply".into()) + .spawn(move || { + let _ = scratch.preview(&computed); + let _ = tx.send(()); + }) + .ok()?; + match rx.recv_timeout(wait) { + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Some(Applying { done: rx, started: crate::now_ms(), cmds }), + _ => None, + } +} + +#[cfg(target_arch = "wasm32")] +fn begin_applying(_app: &SolveApp, _d: &Dialog) -> Option { + None +} + /// The commands a dialog's inputs make (or why they make none), its picks, and the design's /// revision (a parameter the commands name can change without changing them). #[derive(Clone, Debug, PartialEq)] @@ -1881,6 +1936,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let footer_height = ui.spacing().interact_size.y.max(24.0) + 6.0 + ui.spacing().item_spacing.y; let fields_height = (vp.bottom() - anchor.y - 8.0 - vertical_frame - ui.min_rect().height() - footer_height).max(1.0); egui::ScrollArea::vertical().id_salt("feature_fields").min_scrolled_height(fields_height).max_height(fields_height).show(ui, |ui| { + // While OK computes, its inputs hold: what is applied is what was asked. + if app.applying.is_some() { + ui.disable(); + } // Configuration tables own their column widths and scrolling; the two-column form // grid would squeeze their row names and action buttons into a label-sized cell. if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind { @@ -2616,7 +2675,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // the automatic ids of OK and Cancel, and a click pressed in one frame and // released in the next would be lost (an animating preview toggles it often). let (sr, sresp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); - if app.preview.busy { + if let Some(job) = &app.applying { + egui::Spinner::new().size(14.0).paint_at(ui, sr); + let seconds = ((crate::now_ms() - job.started) / 1000.0).floor().max(0.0); + ui.label(RichText::new(crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))])).color(t.text_dim)); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } else if app.preview.busy { egui::Spinner::new().size(14.0).paint_at(ui, sr); sresp.on_hover_text(tr("Updating the preview")); } @@ -2636,7 +2700,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .fill(if valid { t.accent } else { Color32::TRANSPARENT }) .wrap_mode(egui::TextWrapMode::Extend) .min_size(vec2(ok_width, 24.0)); - let r = ui.add_enabled(valid, b); + let r = ui.add_enabled(valid && app.applying.is_none(), b); crate::scenario::publish_handle(&format!("dialog:{raw_label}"), r.rect.center()); if r.clicked() { ok = true; @@ -2661,7 +2725,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. }) && !matches!(&d.kind, Kind::Motion(k) if k.wide()); let report_active = app.issue_report.draft.is_some(); - if !report_active && enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) { + if !report_active && enter_applies && app.applying.is_none() && (enter || canvas_enter || (enter_free && nothing_focused)) { ok = true; } if !report_active && esc && !app.esc_handled { @@ -2682,20 +2746,50 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { Err(error) => d.refuse(app, error), } } - if ok { + if ok && app.applying.is_none() { // Edit Text applies its own way: its live changes and OK are one undo step. let run = match &mut d.kind { - Kind::Text(k) => crate::sketch_text::apply(app, k), - _ => run_dialog(app, &d), + Kind::Text(k) => Some(crate::sketch_text::apply(app, k)), + _ => match begin_applying(app, &d) { + // Computing: the dialog stays, saying so, until it is in. + Some(job) => { + app.applying = Some(job); + None + } + None => Some(run_dialog(app, &d)), + }, }; match run { - Ok(()) => { + Some(Ok(())) => { cancel = true; applied = true; } - Err(e) => d.refuse(app, e), + Some(Err(e)) => d.refuse(app, e), + None => {} } } + // OK's evaluation is in: run it, the feature taken from the cache. Inputs changed meanwhile + // (nothing should change them) are computed in turn, off the window thread too: run here, + // they would hold the window for the whole boolean. + if app.applying.as_ref().is_some_and(Applying::finished) && !cancel { + let computed = app.applying.take().map(|job| job.cmds); + let current = apply_commands(app, &d).ok(); + let again = if current.is_some() && current != computed { begin_applying(app, &d) } else { None }; + match again { + Some(job) => app.applying = Some(job), + None => match run_dialog(app, &d) { + Ok(()) => { + cancel = true; + applied = true; + } + Err(e) => d.refuse(app, e), + }, + } + } + // Cancel while computing: the result goes (its worker runs on to its end). + if cancel && !applied { + app.applying = None; + } if !keep || cancel { crate::dialogs_motion::closed(app, &mut d, applied); if let Kind::Text(k) = &mut d.kind { @@ -2724,6 +2818,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { /// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back. pub fn cancel(app: &mut SolveApp) { + app.applying = None; if let Some(mut d) = app.dialog.take() { crate::dialogs_motion::closed(app, &mut d, false); if let Kind::Text(k) = &mut d.kind { @@ -3032,8 +3127,6 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] { pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { let commands = apply_commands(app, d)?; - // The live preview may be computing these very commands: OK takes its result. - crate::preview::settle(app, &commands); if let Some((_, marker)) = d.editing { // Record the actual pre-edit timeline state in the command's undo snapshot. roll_marker(app, marker)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95eeee85..5f6dee2b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,6 +78,8 @@ mod status_tests { assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last()); } } +#[cfg(test)] +mod apply_off_thread_tests; mod expression_complete; #[cfg(test)] mod extrude_edit_tests; @@ -279,6 +281,11 @@ pub struct SolveApp { pub preferences: prefs::Prefs, pub prefs_window: prefs::PrefsWindow, pub preview: preview::PreviewState, + /// OK pressed on a feature that takes a while: its evaluation runs off the window thread. + pub applying: Option, + /// How long OK waits for a feature before computing it off the window thread, saying so; + /// `None`: it waits as long as it takes (headless use, tests). The desktop app sets it. + pub apply_off_thread: Option, /// The last command started interactively (id, label), for Repeat. pub last_command: Option<(String, String)>, /// Esc was already acted on this frame (by the shortcuts), so later handlers skip it. @@ -373,6 +380,8 @@ impl SolveApp { preferences: prefs::Prefs::default(), prefs_window: prefs::PrefsWindow::default(), preview: preview::PreviewState::default(), + applying: None, + apply_off_thread: None, last_command: None, esc_handled: false, status: None, diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index b59de0db..56117241 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -4,6 +4,10 @@ //! tinted, removed material as a translucent ghost. The last good preview stays up while the //! next one computes or when an input makes the feature fail (the error shows in the dialog). //! Nothing here touches the document or the undo history. +//! +//! A feature that adds or removes a tool body (join, cut, intersect) shows only its tool. That +//! is all its preview builds: its boolean, which can take seconds (a text joined onto a part), +//! runs once, on OK, and an error it finds shows then, the dialog staying open. use std::hash::{Hash, Hasher}; use std::sync::mpsc::{Receiver, TryRecvError}; @@ -60,18 +64,13 @@ pub struct Built { type Job = Result; -/// What the preview worker sends: for a feature that adds or removes a tool body, the tool as -/// soon as it is built (the boolean still running), then the finished preview. -enum Msg { - Partial(Built), - Done(Job), -} - #[derive(Default)] pub struct PreviewState { /// Key of the commands whose result (or error) is shown. shown_key: Option, - running: Option<(u64, Receiver)>, + running: Option<(u64, Receiver)>, + /// The running job builds a tool alone (for a join, cut or intersect). + running_tool: bool, /// Inputs of an in-flight joint preview, excluding only its driven values. running_motion_key: Option, /// Bodies the preview stands in for (hidden in the model scene). @@ -102,6 +101,7 @@ pub struct PreviewState { impl PreviewState { fn clear(&mut self) { self.running = None; + self.running_tool = false; self.running_motion_key = None; self.shown_key = None; self.error = None; @@ -157,28 +157,23 @@ impl PreviewState { self.sketches.clear(); self.error = None; } - while let Some((k, rx)) = &self.running { + if let Some((k, rx)) = &self.running { let k = *k; let job = match rx.try_recv() { - // The tool, the boolean still running: shown, and the preview stays busy. - Ok(Msg::Partial(b)) => { - if k == key { - self.apply(k, Ok(b)); - } - continue; - } - Ok(Msg::Done(job)) => Some(job), + Ok(job) => Some(job), Err(TryRecvError::Empty) => None, Err(TryRecvError::Disconnected) => Some(Err("the preview failed".into())), }; - let Some(job) = job else { break }; - { + if let Some(job) = job { self.running = None; - let intermediate = motion_key.is_some() && self.running_motion_key == motion_key && matches!(&job, Ok(b) if b.error.is_none()); + let good = matches!(&job, Ok(b) if b.error.is_none()); + let intermediate = good && (self.running_tool || motion_key.is_some() && self.running_motion_key == motion_key); self.running_motion_key = None; - // A held slider or animation may advance faster than the worker. Show - // completed poses from the same joint setup, then compute the latest. - // Errors and results from different setups still require an exact key. + self.running_tool = false; + // A held slider, a dragged arrow or an animation may advance faster than the + // worker. Show completed poses from the same joint setup, and tools built for + // inputs since changed, then compute the latest. Errors still require an exact + // key. if k == key || intermediate { self.apply(k, job); } @@ -233,13 +228,14 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) { let colors = Colors::from_tokens(&Tokens::get()); let key = job_key(&cmds, app.session.revision, colors); let motion_key = motion_key(&cmds, app.session.revision, colors); + let tool = as_new_body(&cmds).is_some(); let pv = &mut app.preview; pv.poll(key, motion_key); if pv.running.is_none() && pv.shown_key != Some(key) { pv.started = crate::now_ms(); pv.running_motion_key = motion_key; let scratch = app.session.scratch(); - spawn(&mut app.preview, key, scratch, cmds, colors); + spawn(&mut app.preview, key, scratch, cmds, colors, tool); } app.preview.busy = app.preview.running.is_some(); if app.preview.busy { @@ -257,46 +253,26 @@ fn job_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> u64 { h.finish() } -/// Before OK runs `cmds`: when the preview is still computing those very commands, wait for it -/// rather than compute them a second time alongside it. What it evaluated stays in the model's -/// cache, which the session shares with the preview's scratch copy, so OK then takes it from -/// there (the feature, its result or its error) instead of evaluating it again. -pub fn settle(app: &mut SolveApp, cmds: &[(String, Value)]) { - let key = job_key(cmds, app.session.revision, Colors::from_tokens(&Tokens::get())); - let Some((k, rx)) = app.preview.running.take_if(|(k, _)| *k == key) else { return }; - loop { - match rx.recv() { - Ok(Msg::Partial(b)) => app.preview.apply(k, Ok(b)), - Ok(Msg::Done(job)) => { - app.preview.apply(k, job); - break; - } - Err(_) => break, - } - } - app.preview.running_motion_key = None; - app.preview.busy = false; -} - +/// Start computing the preview of `cmds`: for a tool feature (`tool`), only its tool. #[cfg(not(target_arch = "wasm32"))] -fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, Value)>, colors: Colors) { +fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, Value)>, colors: Colors, tool: bool) { let (tx, rx) = std::sync::mpsc::channel(); let spawned = std::thread::Builder::new().name("preview".into()).spawn(move || { - let job = compute_staged(&scratch, &cmds, colors, &mut |b| { - let _ = tx.send(Msg::Partial(b)); - }); - let _ = tx.send(Msg::Done(job)); + let _ = tx.send(if tool { compute_tool(&scratch, &cmds, colors) } else { compute(&scratch, &cmds, colors) }); }); match spawned { - Ok(_) => pv.running = Some((key, rx)), + Ok(_) => { + pv.running = Some((key, rx)); + pv.running_tool = tool; + } Err(e) => pv.apply(key, Err(e.to_string())), } } /// No threads on the web: compute right away. #[cfg(target_arch = "wasm32")] -fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, Value)>, colors: Colors) { - pv.apply(key, compute(&scratch, &cmds, colors)); +fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, Value)>, colors: Colors, tool: bool) { + pv.apply(key, if tool { compute_tool(&scratch, &cmds, colors) } else { compute(&scratch, &cmds, colors) }); } /// Evaluate the commands on the scratch session and build the preview scene. @@ -305,13 +281,6 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V /// show their result in place of the bodies they change. When the feature fails (a boolean the /// kernel can't do yet), the tool body is still shown, with the error. pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { - compute_staged(s, cmds, colors, &mut |_| {}) -} - -/// [`compute`], handing a tool body's scene to `partial` as soon as it is built: the feature -/// itself (the boolean, which the preview runs for its error and OK finds in the cache) can -/// take far longer. -fn compute_staged(s: &Session, cmds: &[(String, Value)], colors: Colors, partial: &mut dyn FnMut(Built)) -> Job { if cmds.iter().any(|(command, params)| { matches!(command.as_str(), "sketch.redefine" | "sketch.mirror" | "sketch.pattern.rectangular" | "sketch.pattern.circular" | "sketch.offset") || command == "construct.plane.along_path" @@ -329,30 +298,16 @@ fn compute_staged(s: &Session, cmds: &[(String, Value)], colors: Colors, partial if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) { return job; } - let guard = |f: &dyn Fn() -> Built| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| "the preview failed".to_string()); match as_new_body(cmds) { - Some((alt, op)) => { - let tool = match s.preview(&alt) { - Ok(t) => t, - Err(e) => return Err(s.preview(cmds).err().map_or_else(|| e.to_string(), |r| r.to_string())), - }; - let mut b = guard(&|| build_tool(&tool.before, &tool.after, colors, &op))?; - b.bounds = state_bounds(&tool.after); - partial(Built { - scene: b.scene.clone(), - sketches: Vec::new(), - replaced: b.replaced.clone(), - error: None, - bounds: b.bounds, - applied: None, - tapered: Vec::new(), - stopped: None, - }); + Some(_) => { + let mut b = compute_tool(s, cmds, colors)?; b.error = s.preview(cmds).err().map(|e| e.to_string()); Ok(b) } None => { let p = s.preview(cmds).map_err(|e| e.to_string())?; + let guard = + |f: &dyn Fn() -> Built| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| "the preview failed".to_string()); let mut b = guard(&|| build(&p.before, &p.after, colors))?; b.bounds = state_bounds(&p.after); // The radius a previewed fillet used when it reduced the one asked for (a new @@ -365,6 +320,21 @@ fn compute_staged(s: &Session, cmds: &[(String, Value)], colors: Colors, partial } } +/// The tool body of a feature that adds or removes one, built on its own (as a new body): what +/// its live preview shows, without the boolean. When even the tool fails, the feature's own +/// error. Other features: their whole preview ([`compute`]). +fn compute_tool(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { + let Some((alt, op)) = as_new_body(cmds) else { return compute(s, cmds, colors) }; + let tool = match s.preview(&alt) { + Ok(t) => t, + Err(e) => return Err(s.preview(cmds).err().map_or_else(|| e.to_string(), |r| r.to_string())), + }; + let mut b = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tool(&tool.before, &tool.after, colors, &op))) + .map_err(|_| "the preview failed".to_string())?; + b.bounds = state_bounds(&tool.after); + Ok(b) +} + fn state_bounds(st: &ModelState) -> Aabb3 { st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds())) } @@ -711,7 +681,7 @@ mod tests { assert!(pv.active); assert_eq!(pv.replaced, ["Turn"]); assert_eq!(pv.key, 9, "the last good geometry stays visible"); - tx.send(Msg::Done(Err("unknown_angle".into()))).unwrap(); + tx.send(Err("unknown_angle".into())).unwrap(); pv.poll(2, None); assert!(pv.error.is_none(), "a late obsolete result cannot disable Apply again"); assert!(pv.running.is_none()); @@ -720,7 +690,7 @@ mod tests { let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((2, rx)); - tx.send(Msg::Done(Err("current invalid expression".into()))).unwrap(); + tx.send(Err("current invalid expression".into())).unwrap(); pv.poll(2, None); assert_eq!(pv.error.as_deref(), Some("current invalid expression")); assert_eq!(pv.shown_key, Some(2)); @@ -753,7 +723,7 @@ mod tests { pv.running_motion_key = Some(42); pv.poll(wanted, Some(42)); assert!(pv.running.is_some()); - tx.send(Msg::Done(Ok(pose()))).unwrap(); + tx.send(Ok(pose())).unwrap(); pv.poll(wanted, Some(42)); assert!(pv.active, "motion should be visible before input stops"); assert_eq!(pv.replaced, ["Lid"]); @@ -764,7 +734,7 @@ mod tests { let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((4, rx)); pv.running_motion_key = Some(42); - tx.send(Msg::Done(Ok(pose()))).unwrap(); + tx.send(Ok(pose())).unwrap(); pv.poll(4, Some(42)); assert_eq!(pv.shown_key, Some(4), "release catches up to the exact final pose"); assert!(pv.running.is_none()); @@ -781,7 +751,7 @@ mod tests { let mut pv = PreviewState { running_motion_key: Some(42), ..PreviewState::default() }; let (tx, rx) = std::sync::mpsc::channel(); pv.running = Some((1, rx)); - tx.send(Msg::Done(job)).unwrap(); + tx.send(job).unwrap(); pv.poll(2, context); assert!(!pv.active); assert!(pv.error.is_none(), "obsolete errors must not block Apply"); @@ -966,10 +936,17 @@ mod tests { assert!(s.model.state().bodies.is_empty()); } + fn session_of(cmds: &[(&str, Value)]) -> Session { + let mut s = Session::default(); + for (id, p) in cmds { + s.execute(id, p).unwrap(); + } + s + } + /// A block and a two-letter text to cut into it. fn text_on_block() -> (Session, Vec<(String, Value)>) { - let mut s = Session::default(); - for (id, p) in [ + let s = session_of(&[ ("sketch.create", json!({"plane": "XY"})), ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 20]})), ("sketch.finish", json!({})), @@ -977,54 +954,46 @@ mod tests { ("sketch.create", json!({"plane": "XY"})), ("sketch.text", json!({"text": "AB", "at": [5, 5], "height": 10})), ("sketch.finish", json!({})), - ] { - s.execute(id, &p).unwrap(); - } + ]); (s, vec![("solid.extrude".to_string(), json!({"distance": 2, "operation": "cut"}))]) } - /// A cut's tool is handed over as soon as it is built, before the cut itself has run; the - /// finished preview is the same tool, with the cut's error if any. + /// A tool feature's live preview builds its tool without the boolean: a cut into nothing + /// shows its tool with no error (OK will report it), while the whole preview has the error. #[test] - fn a_cut_preview_shows_its_tool_before_the_boolean_runs() { - let (s, cmds) = text_on_block(); - let mut partials = Vec::new(); - let done = compute_staged(&s, &cmds, colors(), &mut |b| partials.push((b.scene.xray.len(), b.error.clone()))).unwrap(); - assert_eq!(partials.len(), 1, "one early tool scene"); - assert!(partials[0].0 > 0 && partials[0].1.is_none()); - assert_eq!(done.scene.xray.len(), partials[0].0, "the cut tool, see-through"); - assert!(done.error.is_none(), "{:?}", done.error); - // A feature with no tool body has nothing to show early. - let mut none = 0; - let fillet = vec![("solid.box".to_string(), json!({"length": 10, "width": 10, "height": 10}))]; - compute_staged(&s, &fillet, colors(), &mut |_| none += 1).unwrap(); - assert_eq!(none, 0); + fn a_tool_preview_builds_the_tool_without_the_boolean() { + let s = session_of(&[ + ("sketch.create", json!({"plane": "XY"})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})), + ("sketch.finish", json!({})), + ]); + let cmds = vec![("solid.extrude".to_string(), json!({"distance": 2, "operation": "cut"}))]; + let tool = compute_tool(&s, &cmds, colors()).unwrap(); + assert!(tool.error.is_none() && !tool.scene.xray.is_empty(), "{:?}", tool.error); + let whole = compute(&s, &cmds, colors()).unwrap(); + assert!(whole.error.as_deref().is_some_and(|e| e.contains("no body to cut")), "{:?}", whole.error); + assert_eq!(whole.scene.xray.len(), tool.scene.xray.len()); } - /// OK while the preview still computes the same commands waits for it instead of computing - /// them again beside it, and then takes the feature from the cache the preview filled. + /// While an arrow is dragged the inputs change faster than a tool is built: a tool built + /// for inputs since changed is shown anyway (then the latest computed), an error only for + /// the inputs it belongs to. #[test] - fn ok_takes_the_result_of_the_preview_still_computing() { + fn a_tool_for_inputs_since_changed_still_shows() { let (s, cmds) = text_on_block(); - let mut app = SolveApp::new(s, crate::Services::default()); - let colors = Colors::from_tokens(&Tokens::get()); - // A preview of other commands is left alone. - let other = vec![("solid.extrude".to_string(), json!({"distance": 3, "operation": "cut"}))]; - spawn(&mut app.preview, job_key(&other, app.session.revision, colors), app.session.scratch(), other.clone(), colors); - settle(&mut app, &cmds); - assert!(app.preview.running.is_some(), "not the commands OK runs"); - let key = job_key(&cmds, app.session.revision, colors); - spawn(&mut app.preview, key, app.session.scratch(), cmds.clone(), colors); - settle(&mut app, &cmds); - assert!(app.preview.running.is_none() && !app.preview.busy); - assert_eq!(app.preview.shown_key, Some(key)); - assert!(app.preview.active && app.preview.error.is_none(), "{:?}", app.preview.error); - let hits = |app: &SolveApp| app.session.model.cache.lock().map(|c| c.hits).unwrap_or(0); - let before = hits(&app); - let (id, params) = cmds[0].clone(); - app.run(&id, params).unwrap(); - let _ = app.session.world_state(); - assert!(hits(&app) > before, "the cut came from the preview's evaluation"); - assert_eq!(app.session.world_state().bodies.len(), 1); + let tool = compute_tool(&s, &cmds, colors()).unwrap(); + let mut pv = PreviewState::default(); + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((1, rx)); + pv.running_tool = true; + tx.send(Ok(tool)).unwrap(); + pv.poll(2, None); + assert!(pv.running.is_none() && pv.active && pv.shown_key == Some(1), "shown, and key 2 still to compute"); + let (tx, rx) = std::sync::mpsc::channel(); + pv.running = Some((3, rx)); + pv.running_tool = true; + tx.send(Err("too far".into())).unwrap(); + pv.poll(4, None); + assert!(pv.error.is_none() && pv.shown_key == Some(1), "an old error is dropped"); } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index d6557c7d..1db3dada 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1444,6 +1444,10 @@ fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> { /// A pick for the open dialog's active input (a click, or a Select Other row). pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) { + // While OK computes, the dialog's inputs hold. + if app.applying.is_some() { + return; + } let Some(mut d) = app.dialog.take() else { return }; d.pick(&app.session, sel); if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind { @@ -2730,6 +2734,10 @@ pub(crate) fn measurement_selection(s: &solidcraft_engine::Session, sel: &Sel) - /// active input takes (edges, faces or bodies), otherwise bodies. fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { use crate::selection::{BODIES, EDGES, FACES, PROFILES}; + // While OK computes, the dialog's inputs hold. + if app.applying.is_some() { + return; + } let st = app.session.world_state(); let mut out: Vec = Vec::new(); let to2 = |pts: &[Vec3]| -> Vec { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };