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:
1 parent
29e72806f6
commit
a18bedddf6
6 files changed
+294
-23
No files matched your search
@@ -66,6 +66,13 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
|
|||||||
retain their saved two-finger scroll preference; camera motion is available through
|
retain their saved two-finger scroll preference; camera motion is available through
|
||||||
`view.navigate` and modifier-aware `ui.move` automation.
|
`view.navigate` and modifier-aware `ui.move` automation.
|
||||||
|
|
||||||
|
## Interactive modelling
|
||||||
|
|
||||||
|
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||||
|
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||||
|
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||||
|
follow the current frame's input.
|
||||||
|
|
||||||
## Physical material data
|
## Physical material data
|
||||||
|
|
||||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||||
|
|||||||
@@ -527,17 +527,12 @@ fn drag_arrow(
|
|||||||
) -> Option<Pos2> {
|
) -> Option<Pos2> {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
|
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 tip = |length: f64| {
|
||||||
let ts = proj.to_screen(base + n * shown)?;
|
let shown = if length.abs() < min { min * if length < 0.0 { -1.0 } else { 1.0 } } else { length };
|
||||||
crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts);
|
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 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()
|
if r.dragged()
|
||||||
&& let Some(p) = r.interact_pointer_pos()
|
&& let Some(p) = r.interact_pointer_pos()
|
||||||
&& let Some(v) = along(proj, p, base, n)
|
&& let Some(v) = along(proj, p, base, n)
|
||||||
@@ -549,6 +544,17 @@ fn drag_arrow(
|
|||||||
v = v.max(if free { 1e-3 } else { step });
|
v = v.max(if free { 1e-3 } else { step });
|
||||||
}
|
}
|
||||||
*value = format_len(v);
|
*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)
|
Some(ts)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
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);
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,8 +71,8 @@ pub struct PreviewState {
|
|||||||
running: Option<(u64, Receiver<Job>)>,
|
running: Option<(u64, Receiver<Job>)>,
|
||||||
/// The running job builds a tool alone (for a join, cut or intersect).
|
/// The running job builds a tool alone (for a join, cut or intersect).
|
||||||
running_tool: bool,
|
running_tool: bool,
|
||||||
/// Inputs of an in-flight joint preview, excluding only its driven values.
|
/// Inputs of a joint or distance preview, excluding only its driven values.
|
||||||
running_motion_key: Option<u64>,
|
running_stream_key: Option<u64>,
|
||||||
/// Bodies the preview stands in for (hidden in the model scene).
|
/// Bodies the preview stands in for (hidden in the model scene).
|
||||||
pub replaced: Vec<String>,
|
pub replaced: Vec<String>,
|
||||||
/// The preview scene for the GPU and its key (changes whenever the scene does).
|
/// The preview scene for the GPU and its key (changes whenever the scene does).
|
||||||
@@ -102,7 +102,7 @@ impl PreviewState {
|
|||||||
fn clear(&mut self) {
|
fn clear(&mut self) {
|
||||||
self.running = None;
|
self.running = None;
|
||||||
self.running_tool = false;
|
self.running_tool = false;
|
||||||
self.running_motion_key = None;
|
self.running_stream_key = None;
|
||||||
self.shown_key = None;
|
self.shown_key = None;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.applied = None;
|
self.applied = None;
|
||||||
@@ -152,7 +152,7 @@ impl PreviewState {
|
|||||||
|
|
||||||
/// Errors validate only the current inputs. Keep the last good geometry while
|
/// Errors validate only the current inputs. Keep the last good geometry while
|
||||||
/// computing new inputs, but never let an obsolete result block their Apply.
|
/// 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) {
|
if self.shown_key != Some(key) {
|
||||||
self.sketches.clear();
|
self.sketches.clear();
|
||||||
self.error = None;
|
self.error = None;
|
||||||
@@ -167,11 +167,11 @@ impl PreviewState {
|
|||||||
if let Some(job) = job {
|
if let Some(job) = job {
|
||||||
self.running = None;
|
self.running = None;
|
||||||
let good = 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);
|
let intermediate = good && (self.running_tool || stream_key.is_some() && self.running_stream_key == stream_key);
|
||||||
self.running_motion_key = None;
|
self.running_stream_key = None;
|
||||||
self.running_tool = false;
|
self.running_tool = false;
|
||||||
// A held slider, a dragged arrow or an animation may advance faster than the
|
// 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
|
// inputs since changed, then compute the latest. Errors still require an exact
|
||||||
// key.
|
// key.
|
||||||
if k == key || intermediate {
|
if k == key || intermediate {
|
||||||
@@ -209,6 +209,41 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option
|
|||||||
Some(h.finish())
|
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,
|
/// Keep the preview in step with the open dialog: start a computation when its inputs changed,
|
||||||
/// take finished results. Called once per frame.
|
/// take finished results. Called once per frame.
|
||||||
pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
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 colors = Colors::from_tokens(&Tokens::get());
|
||||||
let key = job_key(&cmds, app.session.revision, colors);
|
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 tool = as_new_body(&cmds).is_some();
|
||||||
let pv = &mut app.preview;
|
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) {
|
if pv.running.is_none() && pv.shown_key != Some(key) {
|
||||||
pv.started = crate::now_ms();
|
pv.started = crate::now_ms();
|
||||||
pv.running_motion_key = motion_key;
|
pv.running_stream_key = stream_key;
|
||||||
let scratch = app.session.scratch();
|
let scratch = app.session.scratch();
|
||||||
spawn(&mut app.preview, key, scratch, cmds, colors, tool);
|
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)] {
|
for (running, wanted) in [(1, 2), (2, 3), (3, 4)] {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
pv.running = Some((running, rx));
|
pv.running = Some((running, rx));
|
||||||
pv.running_motion_key = Some(42);
|
pv.running_stream_key = Some(42);
|
||||||
pv.poll(wanted, Some(42));
|
pv.poll(wanted, Some(42));
|
||||||
assert!(pv.running.is_some());
|
assert!(pv.running.is_some());
|
||||||
tx.send(Ok(pose())).unwrap();
|
tx.send(Ok(pose())).unwrap();
|
||||||
@@ -733,7 +768,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
pv.running = Some((4, rx));
|
pv.running = Some((4, rx));
|
||||||
pv.running_motion_key = Some(42);
|
pv.running_stream_key = Some(42);
|
||||||
tx.send(Ok(pose())).unwrap();
|
tx.send(Ok(pose())).unwrap();
|
||||||
pv.poll(4, Some(42));
|
pv.poll(4, Some(42));
|
||||||
assert_eq!(pv.shown_key, Some(4), "release catches up to the exact final pose");
|
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), Err("old invalid value".into())),
|
||||||
(Some(42), Ok(Built { error: Some("old tool preview error".into()), ..pose() })),
|
(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();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
pv.running = Some((1, rx));
|
pv.running = Some((1, rx));
|
||||||
tx.send(job).unwrap();
|
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");
|
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;
|
||||||
@@ -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(¤t, revision, colors()), stream_key(¤t, 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");
|
||||||
|
}
|
||||||
@@ -816,3 +816,40 @@ observed 47.6 ms check; no local PNG was created and the active document remaine
|
|||||||
empty. The screenshot shows the transferred file's picture while built-in sample
|
empty. The screenshot shows the transferred file's picture while built-in sample
|
||||||
pictures are still loading. This is one functional visual check, not a first-frame
|
pictures are still loading. This is one functional visual check, not a first-frame
|
||||||
benchmark. The separate counting-reader regression proves source-blob deferral.
|
benchmark. The separate counting-reader regression proves source-blob deferral.
|
||||||
|
|
||||||
|
## Extrude drag preview delivery
|
||||||
|
|
||||||
|
On macOS with Metal, editing a 15 mm radius cylinder's 10 mm extrusion and dragging its
|
||||||
|
arrow upward by 150 screen points (120 input steps, Ctrl held) exposed preview starvation.
|
||||||
|
A completed worker result was discarded whenever the pointer had already changed the
|
||||||
|
requested distance. The arrow also drew the previous frame's distance.
|
||||||
|
|
||||||
|
Main's tool-only preview already delivers intermediate Join/Cut/Intersect geometry. New Body
|
||||||
|
extrusions (including feature edits) and Offset Face use full-result previews and still discard
|
||||||
|
completed distances during a moving drag. Those paths now accept successful intermediate
|
||||||
|
distances from the same setup. Selections, operation, extent mode, feature identity, revision
|
||||||
|
and colours remain part of the setup identity. Errors still apply only to the exact requested
|
||||||
|
inputs, and the final distance is computed normally. Tool previews and background Apply retain
|
||||||
|
their existing paths. The arrow and value-box anchor use the current frame's pointer value.
|
||||||
|
|
||||||
|
Release-build control-channel checks against upstream `4dc63810` with the arrow fix alone,
|
||||||
|
then with the distance fallback, used the same 150-point, 120-step drags. The cylinder used
|
||||||
|
the setup above; planar-face Press Pull (Join) and Offset Face used a 30 × 30 × 10 mm box.
|
||||||
|
The samples captured 25–27 distinct requested distances per drag:
|
||||||
|
|
||||||
|
| Preview updates before reaching the final distance | Main + arrow fix | With distance fallback |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Edited New Body extrusion | 0 | 24 |
|
||||||
|
| Planar-face Press Pull (Join) | 22 | 24 |
|
||||||
|
| Offset Face | 0 | 23 |
|
||||||
|
|
||||||
|
Join already streams on main; the small count difference reflects sampling, not a change to
|
||||||
|
that path. Applying the fixed New Body extrusion produced the requested 19.585 mm height;
|
||||||
|
applying the 11.672 mm face offset produced a 21.672 mm tall box.
|
||||||
|
|
||||||
|
These are sampled preview deliveries (`ui.inspect.preview.ms` changes), not every rendered
|
||||||
|
frame or a geometry-kernel speed benchmark. With main's preview code unchanged, the New Body,
|
||||||
|
edited New Body and Offset Face drag regressions fail while Join passes. With the fallback,
|
||||||
|
all six distance-preview regressions pass, covering intermediate geometry, exact final
|
||||||
|
convergence, unchanged document/undo, obsolete errors and setup changes. A separate regression
|
||||||
|
checks the arrow's position on each held input frame.
|
||||||
Reference in new issue
Block a user