From 8a178879eee9fb3af0c6bc4f1c5d33091de7a0c6 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] engine: mesh.node.move's live preview, capped before its costly part prepare_capped counts the nodes that would follow and stops before the design's faces and the harmonic solve when they are more than a cap; preview_live reuses the region's baseline and guards more coarsely. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/mod.rs | 5 ++- crates/engine/src/cmd/node_move.rs | 43 ++++++++++++++++++++++-- crates/engine/src/cmd/node_move/tests.rs | 18 ++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 39e720f..8bbdebb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -20,7 +20,10 @@ pub use datum::edit_params as datum_edit_params; pub use datum::preview as datum_preview; pub use fieldcraft_mesh::relax::{ElementChange, Moved as NodeMoved}; pub use mesh::analytic_surfaces; -pub use node_move::{Prepared as NodeMovePrepared, nearest_node as node_move_nearest, prepare as node_move_prepare}; +pub use node_move::{ + Capped as NodeMoveCapped, Prepared as NodeMovePrepared, nearest_node as node_move_nearest, prepare as node_move_prepare, + prepare_capped as node_move_prepare_capped, +}; pub use nodes::merge_preview as node_merge_preview; pub use plan::untagged_faces; pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere}; diff --git a/crates/engine/src/cmd/node_move.rs b/crates/engine/src/cmd/node_move.rs index 20b2f0b..90fdd49 100644 --- a/crates/engine/src/cmd/node_move.rs +++ b/crates/engine/src/cmd/node_move.rs @@ -55,8 +55,14 @@ pub struct Prepared { pub sphere: Option<([f64; 3], f64)>, guard: Guard, geometry: Geometry, + /// The region's elements' quality before the move, measured by the first live preview. + baseline: std::sync::OnceLock>, } +/// Halvings of the guard's bisection in a live preview (the scale to within 2⁻⁶; the command +/// itself finds it to 2⁻²⁰). +const PREVIEW_GUARD_STEPS: usize = 6; + impl Prepared { /// Nodes that take a share of the displacement. pub fn nodes(&self) -> usize { @@ -85,6 +91,17 @@ impl Prepared { pub fn preview(&self, s: &Session, d: [f64; 3]) -> Result { Ok(relax::move_nodes(&s.model, &self.weights, d, self.guard, self.geometry.constraints())?) } + + /// [`Prepared::preview`] for a drag frame: the region's quality before the move is measured + /// once and reused, and the guard's scale is found more coarsely. + pub fn preview_live(&self, s: &Session, d: [f64; 3]) -> Result { + let run = relax::MoveRun { baseline: self.baseline.get().cloned(), guard_steps: PREVIEW_GUARD_STEPS }; + let m = relax::move_nodes_with(&s.model, &self.weights, d, self.guard, self.geometry.constraints(), &run)?; + if self.baseline.get().is_none() { + let _ = self.baseline.set(m.elements.iter().map(|c| (c.before, c.valid_before)).collect()); + } + Ok(m) + } } /// The corner node nearest `p`: the node a drag in the view grabs. @@ -105,6 +122,21 @@ fn positive(p: &Value, key: &str) -> Result> { /// Everything of `mesh.node.move` but its displacement. pub fn prepare(s: &Session, p: &Value) -> Result { + match prepare_capped(s, p, None)? { + Capped::Ready(prep) => Ok(*prep), + Capped::TooLarge(n) => Err(EngineError::Params(format!("{n} nodes to move"))), + } +} + +/// [`prepare`], or the count of the nodes that would follow when it is more than `cap`: the +/// costly part (the design's faces, the harmonic solve) is then not done. +pub enum Capped { + Ready(Box), + TooLarge(usize), +} + +/// [`prepare`] that stops before the costly part when more than `cap` nodes would follow. +pub fn prepare_capped(s: &Session, p: &Value, cap: Option) -> Result { let model = &s.model; let propagate = match super::opt::(p, "propagate")? { None => Propagate::Falloff, @@ -166,9 +198,14 @@ pub fn prepare(s: &Session, p: &Value) -> Result { let influence = Influence { centre: if propagate == Propagate::Falloff { centre } else { None }, radius, propagate, strength, falloff }; // The constraints of every node that might take a share: the grabbed ones and those within // the radius (all of the mesh's corner nodes when the region is not known yet). + let candidates = + relax::move_weights(model, &handles, &Influence { propagate: Propagate::Falloff, falloff: Falloff::None, ..influence }, &Unconstrained)?; + if let Some(cap) = cap + && candidates.len() > cap + { + return Ok(Capped::TooLarge(candidates.len())); + } let geometry = if on_geometry { - let candidates = - relax::move_weights(model, &handles, &Influence { propagate: Propagate::Falloff, falloff: Falloff::None, ..influence }, &Unconstrained)?; let mut nodes: BTreeSet = candidates.keys().copied().collect(); // Their elements' nodes too: the mid-side nodes that follow. for e in model.elements().values() { @@ -185,7 +222,7 @@ pub fn prepare(s: &Session, p: &Value) -> Result { return Err(EngineError::Params("no mesh nodes to move: the nodes given are no element's corners, or none lies in the sphere".into())); } let sphere = radius.and_then(|r| centre.or(origin).map(|c| (c, r))); - Ok(Prepared { weights, handles, origin, sphere, guard, geometry }) + Ok(Capped::Ready(Box::new(Prepared { weights, handles, origin, sphere, guard, geometry, baseline: std::sync::OnceLock::new() }))) } /// The quality of the region as JSON, with its mean scaled Jacobian. diff --git a/crates/engine/src/cmd/node_move/tests.rs b/crates/engine/src/cmd/node_move/tests.rs index 0936c2f..e684e18 100644 --- a/crates/engine/src/cmd/node_move/tests.rs +++ b/crates/engine/src/cmd/node_move/tests.rs @@ -254,3 +254,21 @@ fn a_move_of_nothing_records_no_undo_step_and_min_quality_is_adjustable() { let (loose, strict) = (scale(&mut s, 0.0), scale(&mut s, 0.5)); assert!(strict.0 < loose.0 && strict.1 >= 0.5 - 1e-9, "{loose:?} {strict:?}"); } + +/// The live preview's cap: more followers than it allows are counted, not prepared; the live +/// preview is the command's move. +#[test] +fn a_capped_prepare_counts_a_large_region_and_the_live_preview_matches() { + let s = plate(); + let n = interior(&s.model); + let p = json!({"nodes": [n], "by": [0.2, 0.1, 0.0], "radius": 4.0, "propagate": "harmonic"}); + assert!(matches!(prepare_capped(&s, &p, Some(3)).unwrap(), Capped::TooLarge(k) if k > 3)); + let Capped::Ready(prep) = prepare_capped(&s, &p, None).unwrap() else { panic!("not prepared") }; + let (exact, first, again) = ( + prep.preview(&s, [0.2, 0.1, 0.0]).unwrap(), + prep.preview_live(&s, [0.2, 0.1, 0.0]).unwrap(), + prep.preview_live(&s, [0.2, 0.1, 0.0]).unwrap(), + ); + assert_eq!(exact.positions, first.positions); + assert_eq!(first, again, "the reused baseline gives the same move"); +}