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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-17 10:35:53 -04:00
1 parent 1dd2afe525
commit 8a178879ee
3 files changed
+62 -4

No files matched your search

+4 -1
View File
@@ -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};
+40 -3
View File
@@ -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<Vec<(f64, bool)>>,
}
/// 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<Moved> {
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<Moved> {
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<Option<f64>> {
/// Everything of `mesh.node.move` but its displacement.
pub fn prepare(s: &Session, p: &Value) -> Result<Prepared> {
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<Prepared>),
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<usize>) -> Result<Capped> {
let model = &s.model;
let propagate = match super::opt::<String>(p, "propagate")? {
None => Propagate::Falloff,
@@ -166,9 +198,14 @@ pub fn prepare(s: &Session, p: &Value) -> Result<Prepared> {
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<u32> = 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<Prepared> {
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.
+18
View File
@@ -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");
}