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

+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");
}
}