Live preview: the tool alone, the boolean only on OK, computed off the window thread (#1201)

The preview builds only the tool; the boolean runs once on OK, off the window thread, with a Computing label while it takes a while. Follow-up to #1020.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-14 19:31:29 -04:00
1 parent a261aaa841
commit 9935133b56
12 files changed
+343 -136

No files matched your search

+1
View File
@@ -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;
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "↖ 選択",
+1
View File
@@ -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": "↖ 선택",
+1
View File
@@ -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": "↖ 选择",
@@ -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();
}
+4
View File
@@ -199,6 +199,10 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
/// 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);
+103 -10
View File
@@ -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<Applying> {
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<Applying> {
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)?;
+9
View File
@@ -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<dialogs::Applying>,
/// 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<std::time::Duration>,
/// 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,
+95 -126
View File
@@ -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<Built, String>;
/// 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<u64>,
running: Option<(u64, Receiver<Msg>)>,
running: Option<(u64, Receiver<Job>)>,
/// 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<u64>,
/// 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");
}
}
+8
View File
@@ -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<Sel> = Vec::new();
let to2 = |pts: &[Vec3]| -> Vec<Pos2> { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() };