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:
1 parent
a261aaa841
commit
9935133b56
12 files changed
+343
-136
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "↖ 選択",
|
||||
|
||||
@@ -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": "↖ 선택",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
|
||||
Reference in new issue
Block a user