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
+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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user