From c1941ad3496d24fdae240f86efaf5101f2da53ee Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:52:40 -0400 Subject: [PATCH] ui: Move Nodes prepares once the form settles and skips large regions A changed form (Alt + scroll on the radius) is prepared 150 ms after it last changed, the sphere drawn meanwhile; a region of more than 1000 nodes is not previewed and moves on release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/lib.rs | 8 ++- crates/ui-egui/src/node_move.rs | 119 +++++++++++++++++++++++++++----- crates/ui-egui/src/viewport.rs | 8 +++ 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 520c8c0..8441a7a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1614,7 +1614,7 @@ impl ModelApp { let cut = self.partition_preview(); let merge = self.merge_preview(); let relax_key = self.dialog.as_ref().filter(|d| d.command == "mesh.node.relax").map(|d| d.text.clone()); - let move_key = self.dialog.as_ref().filter(|d| d.command == node_move::COMMAND).map(|d| d.text.clone()); + let move_key = self.dialog.as_ref().filter(|d| d.command == node_move::COMMAND).map(|d| (d.text.clone(), self.node_move_pending())); let selected = hash(&|h| (scene, self.pick.generation, &set, &cut, &*merge, &relax_key, &move_key).hash(h)); let key = hash(&|h| (selected, self.pick.hover_generation).hash(h)); if self.pick.overlay.0 == Some(key) { @@ -2606,6 +2606,12 @@ mod tests { !overlay.fills.is_empty() && !overlay.lines.is_empty() && !overlay.on_top.is_empty(), "sphere, ghost and moved edges, the grabbed node" ); + // A changed radius waits out the debounce (the sphere drawn meanwhile), then prepares. + assert!(app.node_move_scale_radius(1.5)); + let waiting = app.node_move_preview().unwrap(); + assert!(app.node_move_pending() && waiting.moved.is_none() && waiting.sphere.is_some()); + app.now += node_move::DEBOUNCE * 2.0; + assert!(app.node_move_preview().unwrap().moved.is_some() && !app.node_move_pending()); // Esc during a drag: nothing moves, the form stays. let start = app.session.model.clone(); app.node_drag = Some(node_move::Drag { start: Vec3::new(2.0, 2.0, 2.0), normal: Vec3::Z }); diff --git a/crates/ui-egui/src/node_move.rs b/crates/ui-egui/src/node_move.rs index 0065643..6b3439b 100644 --- a/crates/ui-egui/src/node_move.rs +++ b/crates/ui-egui/src/node_move.rs @@ -15,7 +15,7 @@ use std::sync::Arc; -use fieldcraft_engine::cmd::{NodeMovePrepared, NodeMoved}; +use fieldcraft_engine::cmd::{NodeMoveCapped, NodeMovePrepared, NodeMoved}; use fieldcraft_render::{Scene, SceneLine, Vec3}; use serde_json::{Value, json}; @@ -25,8 +25,25 @@ use crate::{ModelApp, scene}; /// The command the tool runs. pub const COMMAND: &str = "mesh.node.move"; -/// Most nodes a live preview moves: a larger region is moved on release only. -pub const PREVIEW_NODES: usize = 20_000; +/// Most nodes a live preview moves: a larger region is moved on release only. On the crank arm's +/// hex20 mesh (8264 elements) a region of 492 nodes prepares in 20 ms (falloff) to 27 ms +/// (harmonic) and draws a frame in 12 to 52 ms; one of 4051 nodes takes 0.8 s to prepare +/// harmonically and 0.3 s a frame, too slow to follow the pointer. +pub const PREVIEW_NODES: usize = 1_000; + +/// How long the form must stay the same (seconds) before a changed move is prepared again: Alt + +/// scroll changes the radius every frame, and preparing (the design's faces, the harmonic +/// solve) takes longer than a frame on a big region. +pub const DEBOUNCE: f64 = 0.15; + +/// The prepared part of the move. +#[derive(Clone)] +enum Prep { + Ready(Arc), + /// More than [`PREVIEW_NODES`] would follow: not prepared, applied on release only. + TooLarge(usize), + Failed(String), +} /// A drag of the Move Nodes tool in progress. #[derive(Debug, Clone, Copy, PartialEq)] @@ -41,7 +58,10 @@ pub struct Drag { #[derive(Default)] pub struct Cache { /// The move up to its displacement, by model revision and form text without `by` and `to`. - prepared: Option<((u64, String), Result, String>)>, + prepared: Option<((u64, String), Prep)>, + /// The form (without the displacement) last seen and when (app time): a change waits + /// [`DEBOUNCE`] before it is prepared again. + seen: Option<((u64, String), f64)>, /// The move itself, by model revision and form text. preview: Option<((u64, String), Option>)>, } @@ -98,18 +118,70 @@ impl ModelApp { } } - /// The open form's move up to its displacement (cached by form and revision). - fn move_prepared(&self, params: &Value) -> Result, String> { + /// The open form's move up to its displacement, cached by form and revision; `None` while a + /// changed form waits out [`DEBOUNCE`] (the last one is kept meanwhile, never prepared on + /// every frame of an Alt + scroll). + fn move_prepared(&self, params: &Value) -> Option { let key = (self.revision, without_displacement(params)); let mut cache = self.node_move.borrow_mut(); if let Some((k, p)) = &cache.prepared && *k == key { - return p.clone(); + return Some(p.clone()); } - let p = fieldcraft_engine::cmd::node_move_prepare(&self.session, params).map(Arc::new).map_err(|e| e.to_string()); + if cache.prepared.is_some() { + match &cache.seen { + Some((k, at)) if *k == key => { + if self.now - at < DEBOUNCE { + return None; + } + } + _ => { + cache.seen = Some((key, self.now)); + return None; + } + } + } + let p = match fieldcraft_engine::cmd::node_move_prepare_capped(&self.session, params, Some(PREVIEW_NODES)) { + Ok(NodeMoveCapped::Ready(p)) => Prep::Ready(Arc::from(p)), + Ok(NodeMoveCapped::TooLarge(n)) => Prep::TooLarge(n), + Err(e) => Prep::Failed(e.to_string()), + }; cache.prepared = Some((key, p.clone())); - p + cache.seen = None; + Some(p) + } + + /// Whether a changed Move Nodes form waits to be prepared (the view draws again after + /// [`DEBOUNCE`]). + pub fn node_move_pending(&self) -> bool { + self.node_move.borrow().seen.is_some() + } + + /// The sphere the form describes, without preparing the move: its centre, or the listed + /// nodes' centroid, and its radius. + fn quick_sphere(&self, params: &Value) -> Option<([f64; 3], f64)> { + let r = params.get("radius").and_then(Value::as_f64).filter(|r| *r > 0.0 && r.is_finite())?; + let xyz = |v: &Value| -> Option<[f64; 3]> { + let a = v.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]) + }; + if let Some(c) = params.get("center").and_then(xyz) { + return Some((c, r)); + } + let pts: Vec<[f64; 3]> = params + .get("nodes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|n| n.as_u64().and_then(|n| u32::try_from(n).ok())) + .filter_map(|n| self.session.model.node(n).map(|p| p.xyz)) + .collect(); + if pts.is_empty() { + return None; + } + let k = 1.0 / pts.len() as f64; + Some((pts.iter().fold([0.0; 3], |a, p| [a[0] + p[0] * k, a[1] + p[1] * k, a[2] + p[2] * k]), r)) } /// What the open Move Nodes form would do, worked out once per form text and model revision. @@ -121,20 +193,29 @@ impl ModelApp { { return p.clone(); } - let params = serde_json::from_str::(&d.text).ok(); - let preview = params.and_then(|params| { - let prep = self.move_prepared(¶ms).ok()?; - let mut out = Preview { sphere: prep.sphere, nodes: prep.nodes(), moved: None, note: None }; - if prep.nodes() > PREVIEW_NODES { - out.note = Some(format!("{} nodes follow: too many for a live preview; the move applies on release", prep.nodes())); - } else { - match prep.displacement(¶ms).and_then(|d| prep.preview(&self.session, d)) { + let params = serde_json::from_str::(&d.text).ok()?; + let Some(prep) = self.move_prepared(¶ms) else { + // Waiting out the debounce: the sphere as the form has it, nothing cached. + return Some(Arc::new(Preview { sphere: self.quick_sphere(¶ms), nodes: 0, moved: None, note: Some("Preview: updating…".into()) })); + }; + let preview = match prep { + Prep::Failed(e) => Some(Preview { sphere: self.quick_sphere(¶ms), nodes: 0, moved: None, note: Some(e) }), + Prep::TooLarge(n) => Some(Preview { + sphere: self.quick_sphere(¶ms), + nodes: n, + moved: None, + note: Some(format!("{n} nodes follow: too many for a live preview (over {PREVIEW_NODES}); the move applies on release")), + }), + Prep::Ready(prep) => { + let mut out = Preview { sphere: prep.sphere, nodes: prep.nodes(), moved: None, note: None }; + match prep.displacement(¶ms).and_then(|d| prep.preview_live(&self.session, d)) { Ok(m) => out.moved = Some(m), Err(e) => out.note = Some(e.to_string()), } + Some(out) } - Some(Arc::new(out)) - }); + } + .map(Arc::new); self.node_move.borrow_mut().preview = Some((key, preview.clone())); preview } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index d37ed7a..9800080 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -184,6 +184,14 @@ fn node_drag(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, boxin { app.node_move_grab(at); } + // A changed form is prepared once it settles (here, before the overlay is drawn): draw + // again then. + if app.moving_nodes() { + app.node_move_preview(); + } + if app.node_move_pending() { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::node_move::DEBOUNCE)); + } if app.node_drag.is_none() { return; }