Keep New Body and Offset Face previews moving during distance drags (#1238)

New Body extrusions and Offset Face previews show successful intermediate distances from the same setup while a distance drag runs, then compute the latest one (tool previews keep streaming as before; errors still need exact inputs). The drag arrow and value box are painted from this frame's pointer, removing a frame of lag. Drag regression tests and a perf note added.
This commit is contained in:
Colin Faulkingham authored and GitHub committed 2026-10-15 12:47:37 -04:00
1 parent 29e72806f6
commit a18bedddf6
6 files changed
+294 -23

No files matched your search

+16 -10
View File
@@ -527,17 +527,12 @@ fn drag_arrow(
) -> Option<Pos2> {
let t = Tokens::get();
let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l };
let ts = proj.to_screen(base + n * shown)?;
crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts);
let tip = |length: f64| {
let shown = if length.abs() < min { min * if length < 0.0 { -1.0 } else { 1.0 } } else { length };
proj.to_screen(base + n * shown)
};
let mut ts = tip(l)?;
let r = ui.interact(egui::Rect::from_center_size(ts, vec2(20.0, 20.0)), id, egui::Sense::drag());
let hot = r.hovered() || r.dragged();
let col = if hot { t.accent } else { t.manipulator };
arrow(painter, bs, ts, col);
if hot {
painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col));
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if r.dragged()
&& let Some(p) = r.interact_pointer_pos()
&& let Some(v) = along(proj, p, base, n)
@@ -549,6 +544,17 @@ fn drag_arrow(
v = v.max(if free { 1e-3 } else { step });
}
*value = format_len(v);
ts = tip(v)?;
}
// Paint the value just read from this frame's pointer, including the value box anchor.
// Using the old distance here made both trail the drag by another frame.
crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts);
let hot = r.hovered() || r.dragged();
let col = if hot { t.accent } else { t.manipulator };
arrow(painter, bs, ts, col);
if hot {
painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col));
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
Some(ts)
}
+33
View File
@@ -361,3 +361,36 @@ fn symmetric_through_all_handle_drag_switches_to_total_distance() {
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
assert!((bounds.min.z + 60.0).abs() < 1e-7 && (bounds.max.z - 60.0).abs() < 1e-7);
}
/// Inspect each held frame, not just the settled end of the drag: the displayed arrow must
/// be at the distance the pointer just wrote, rather than the previous frame's distance.
#[test]
fn extrusion_arrow_tracks_the_current_drag_frame() {
let mut h = make(json!({"distance":10}));
open(&mut h);
let at = h.at(&json!({"handle":"arrow"})).unwrap();
let at = egui::pos2(at[0] as f32, at[1] as f32);
h.app.synthetic_mods.ctrl = true;
h.app.synthetic.push(egui::Event::PointerMoved(at));
h.frame();
h.app.synthetic.push(egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: h.app.synthetic_mods,
});
h.frame();
for dy in [-20.0, -40.0, -60.0] {
h.app.synthetic.push(egui::Event::PointerMoved(at + egui::vec2(0.0, dy)));
h.frame();
let d = h.app.dialog.as_ref().unwrap();
let crate::dialogs::Kind::Extrude { distance, .. } = &d.kind else { panic!("extrude") };
let distance = h.app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length).unwrap();
assert!(distance > 10.0);
let (base, normal) = crate::canvas::anchor(&h.app, d).unwrap();
let world = base + normal.unwrap() * distance;
let expected = h.at(&json!({"world":[world.x, world.y, world.z]})).unwrap();
let drawn = h.at(&json!({"handle":"arrow"})).unwrap();
assert!((drawn[0] - expected[0]).hypot(drawn[1] - expected[1]) < 0.1, "arrow {drawn:?} trails current value {expected:?}");
}
}
+52 -13
View File
@@ -71,8 +71,8 @@ pub struct PreviewState {
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>,
/// Inputs of a joint or distance preview, excluding only its driven values.
running_stream_key: Option<u64>,
/// Bodies the preview stands in for (hidden in the model scene).
pub replaced: Vec<String>,
/// The preview scene for the GPU and its key (changes whenever the scene does).
@@ -102,7 +102,7 @@ impl PreviewState {
fn clear(&mut self) {
self.running = None;
self.running_tool = false;
self.running_motion_key = None;
self.running_stream_key = None;
self.shown_key = None;
self.error = None;
self.applied = None;
@@ -152,7 +152,7 @@ impl PreviewState {
/// Errors validate only the current inputs. Keep the last good geometry while
/// computing new inputs, but never let an obsolete result block their Apply.
fn poll(&mut self, key: u64, motion_key: Option<u64>) {
fn poll(&mut self, key: u64, stream_key: Option<u64>) {
if self.shown_key != Some(key) {
self.sketches.clear();
self.error = None;
@@ -167,11 +167,11 @@ impl PreviewState {
if let Some(job) = job {
self.running = 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;
let intermediate = good && (self.running_tool || stream_key.is_some() && self.running_stream_key == stream_key);
self.running_stream_key = None;
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
// worker. Show completed joint/distance results from the same setup, and tools built for
// inputs since changed, then compute the latest. Errors still require an exact
// key.
if k == key || intermediate {
@@ -209,6 +209,41 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option
Some(h.finish())
}
/// Tool previews already stream in `poll`. New-body extrusions and face offsets instead
/// build their full result; allow successful intermediate distances only within the same
/// setup. Preserve selections, feature identity, extent mode, revision and colours. The
/// exact job key still ensures the latest distance is computed and errors never go stale.
fn stream_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option<u64> {
if let Some(key) = motion_key(cmds, revision, colors) {
return Some(key);
}
if cmds.is_empty() || as_new_body(cmds).is_some() {
return None;
}
let mut setup = cmds.to_vec();
for (command, params) in &mut setup {
let (command, params) = if command == "timeline.redefine" {
let command = params.get("command")?.as_str()?.to_string();
(command, params.get_mut("params")?)
} else {
(command.clone(), params)
};
let fields: &[&str] = match command.as_str() {
"solid.extrude" => &["distance", "distance2", "to_offset"],
"solid.offset_face" => &["distance"],
_ => return None,
};
let params = params.as_object_mut()?;
for field in fields {
// Keep presence: changing extent mode must not reuse the old result.
if let Some(value) = params.get_mut(*field) {
*value = Value::Null;
}
}
}
Some(job_key(&setup, revision, colors))
}
/// Keep the preview in step with the open dialog: start a computation when its inputs changed,
/// take finished results. Called once per frame.
pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
@@ -227,13 +262,13 @@ 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 stream_key = stream_key(&cmds, app.session.revision, colors);
let tool = as_new_body(&cmds).is_some();
let pv = &mut app.preview;
pv.poll(key, motion_key);
pv.poll(key, stream_key);
if pv.running.is_none() && pv.shown_key != Some(key) {
pv.started = crate::now_ms();
pv.running_motion_key = motion_key;
pv.running_stream_key = stream_key;
let scratch = app.session.scratch();
spawn(&mut app.preview, key, scratch, cmds, colors, tool);
}
@@ -720,7 +755,7 @@ mod tests {
for (running, wanted) in [(1, 2), (2, 3), (3, 4)] {
let (tx, rx) = std::sync::mpsc::channel();
pv.running = Some((running, rx));
pv.running_motion_key = Some(42);
pv.running_stream_key = Some(42);
pv.poll(wanted, Some(42));
assert!(pv.running.is_some());
tx.send(Ok(pose())).unwrap();
@@ -733,7 +768,7 @@ mod tests {
}
let (tx, rx) = std::sync::mpsc::channel();
pv.running = Some((4, rx));
pv.running_motion_key = Some(42);
pv.running_stream_key = Some(42);
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");
@@ -748,7 +783,7 @@ mod tests {
(Some(42), Err("old invalid value".into())),
(Some(42), Ok(Built { error: Some("old tool preview error".into()), ..pose() })),
] {
let mut pv = PreviewState { running_motion_key: Some(42), ..PreviewState::default() };
let mut pv = PreviewState { running_stream_key: Some(42), ..PreviewState::default() };
let (tx, rx) = std::sync::mpsc::channel();
pv.running = Some((1, rx));
tx.send(job).unwrap();
@@ -997,3 +1032,7 @@ mod tests {
assert!(pv.error.is_none() && pv.shown_key == Some(1), "an old error is dropped");
}
}
#[cfg(test)]
#[path = "preview_drag_tests.rs"]
mod drag_tests;
+149
View File
@@ -0,0 +1,149 @@
//! Completed distances must reach the viewport before a continuously moving pointer stops.
use super::*;
use serde_json::json;
fn colors() -> Colors {
Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }
}
fn fixture() -> Session {
let mut s = Session::new(solidcraft_engine::doc::Document::new("Preview drag"));
s.execute("sketch.create", &json!({"plane":"XY", "name":"Profile"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0":[0,0], "p1":[10,10]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s
}
fn extrude(distance: i32) -> Vec<(String, Value)> {
vec![("solid.extrude".into(), json!({"sketch":"Profile", "distance":distance, "operation":"new"}))]
}
fn check_drag(s: &Session, commands: impl Fn(i32) -> Vec<(String, Value)>, base_z: f64) {
let original = serde_json::to_value(&*s.doc).unwrap();
let undo = s.undo.len();
let mut pv = PreviewState::default();
for (finished, wanted) in [(10, 20), (20, 30), (30, 30)] {
let old = commands(finished);
let new = commands(wanted);
let old_key = job_key(&old, s.revision, colors());
let key = job_key(&new, s.revision, colors());
let (tx, rx) = std::sync::mpsc::channel();
pv.running = Some((old_key, rx));
pv.running_tool = as_new_body(&old).is_some();
pv.running_stream_key = stream_key(&old, s.revision, colors());
let built = compute_tool(s, &old, colors()).unwrap();
tx.send(Ok(built)).unwrap();
pv.poll(key, stream_key(&new, s.revision, colors()));
assert!(pv.active && pv.error.is_none(), "completed geometry must show during the drag: {:?}", pv.error);
assert!((pv.bounds.max.z - base_z - f64::from(finished)).abs() < 1e-8);
assert_eq!(pv.shown_key, Some(old_key));
assert_eq!(pv.shown_key == Some(key), finished == wanted, "only the final distance satisfies the latest request");
assert!(pv.running.is_none());
}
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), original);
assert_eq!(s.undo.len(), undo);
}
#[test]
fn new_body_extrude_keeps_moving_during_a_drag() {
check_drag(&fixture(), extrude, 0.0);
}
#[test]
fn edited_new_body_extrude_keeps_moving_during_a_drag() {
let mut s = fixture();
let feature = s.execute("solid.extrude", &extrude(5)[0].1).unwrap()["feature"].as_u64().unwrap();
check_drag(
&s,
|distance| vec![("timeline.redefine".into(), json!({"feature":feature,"command":"solid.extrude","params":extrude(distance)[0].1}))],
0.0,
);
}
#[test]
fn face_offset_keeps_moving_during_a_drag() {
let mut s = Session::new(solidcraft_engine::doc::Document::new("Face offset drag"));
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
check_drag(&s, |distance| vec![("solid.offset_face".into(), json!({"faces":[[5,5,10]],"distance":distance}))], 10.0);
}
#[test]
fn join_tool_keeps_moving_without_waiting_for_its_boolean() {
let mut s = fixture();
s.execute("solid.box", &json!({"length":10,"width":10,"height":5})).unwrap();
check_drag(&s, |distance| vec![("solid.extrude".into(), json!({"sketch":"Profile","distance":distance,"operation":"join"}))], 0.0);
}
#[test]
fn distance_stream_keeps_feature_selection_and_extent_context() {
let old = extrude(10);
let key = stream_key(&old, 7, colors());
assert!(key.is_some());
assert_eq!(key, stream_key(&extrude(20), 7, colors()));
assert_ne!(key, stream_key(&old, 8, colors()));
let mut recolored = colors();
recolored.added = [40, 50, 60, 255];
assert_ne!(key, stream_key(&old, 7, recolored));
for (field, value) in [
("sketch", json!("Other")),
("profiles", json!([1])),
("face", json!([0, 0, 5])),
("operation", json!("cut")),
("direction", json!("negative")),
("distance2", json!(5)),
("to", json!([0, 0, 50])),
("taper", json!(5)),
("start_offset", json!(3)),
("participants", json!(["Other"])),
] {
let mut changed = old.clone();
changed[0].1[field] = value;
assert_ne!(key, stream_key(&changed, 7, colors()), "changed {field} cannot reuse the old result");
}
for field in ["distance2", "to_offset"] {
let mut from = old.clone();
from[0].1[field] = json!(5);
let mut to = from.clone();
to[0].1[field] = json!(9);
assert_eq!(stream_key(&from, 7, colors()), stream_key(&to, 7, colors()));
}
let edit =
|feature, distance| vec![("timeline.redefine".into(), json!({"feature":feature,"command":"solid.extrude","params":extrude(distance)[0].1}))];
assert_eq!(stream_key(&edit(2, 10), 7, colors()), stream_key(&edit(2, 20), 7, colors()));
assert_ne!(stream_key(&edit(2, 10), 7, colors()), stream_key(&edit(3, 20), 7, colors()));
assert_ne!(key, stream_key(&edit(2, 10), 7, colors()));
let offset = |distance| vec![("solid.offset_face".into(), json!({"faces":[[5,5,10]],"distance":distance}))];
assert!(stream_key(&offset(1), 7, colors()).is_some());
assert_eq!(stream_key(&offset(1), 7, colors()), stream_key(&offset(5), 7, colors()));
assert!(stream_key(&[], 7, colors()).is_none());
assert!(stream_key(&[("solid.fillet".into(), json!({"radius":5}))], 7, colors()).is_none());
let mut mixed = old;
mixed.push(("select.clear".into(), json!({})));
assert!(stream_key(&mixed, 7, colors()).is_none());
}
#[test]
fn distance_results_reject_other_setups_and_obsolete_errors() {
let s = fixture();
let old = extrude(10);
let current = extrude(20);
for (revision, failed) in [(s.revision + 1, false), (s.revision, true)] {
let mut built = compute_tool(&s, &old, colors()).unwrap();
if failed {
built.error = Some("old distance failed".into());
}
let (tx, rx) = std::sync::mpsc::channel();
let mut pv = PreviewState {
running: Some((job_key(&old, s.revision, colors()), rx)),
running_stream_key: stream_key(&old, s.revision, colors()),
..Default::default()
};
tx.send(Ok(built)).unwrap();
pv.poll(job_key(&current, revision, colors()), stream_key(&current, revision, colors()));
assert!(!pv.active && pv.error.is_none() && pv.shown_key.is_none());
}
let mut join = current.clone();
join[0].1["operation"] = json!("join");
assert!(as_new_body(&join).is_some());
assert!(stream_key(&join, s.revision, colors()).is_none(), "tool previews retain their upstream streaming path");
}