diff --git a/crates/mesh/src/relax.rs b/crates/mesh/src/relax.rs index 0ff4e9d..d2f2564 100644 --- a/crates/mesh/src/relax.rs +++ b/crates/mesh/src/relax.rs @@ -938,7 +938,12 @@ pub fn apply_field( Ok(out) } +mod moving; mod strong; +pub use moving::{ + DEFAULT_MIN_QUALITY, ElementChange, GUARD_STEPS, Guard, Influence, MIN_RELATIVE_RADIUS, Moved, Propagate, Unconstrained, centroid, move_nodes, + move_weights, nearest_corner, +}; pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph}; #[cfg(test)] diff --git a/crates/mesh/src/relax/moving.rs b/crates/mesh/src/relax/moving.rs new file mode 100644 index 0000000..09fa41d --- /dev/null +++ b/crates/mesh/src/relax/moving.rs @@ -0,0 +1,384 @@ +//! Moving nodes by hand (`mesh.node.move`): grabbed nodes are displaced by a vector, and the +//! nodes round them follow, so an analyst can drag a node of a finished mesh and see what the +//! move does to the elements around it. +//! +//! **Who follows.** Every node gets a share `w` in `[0, 1]` of the displacement ([`move_weights`]): +//! +//! - *Falloff*: the grabbed nodes move by all of it, a node within the radius of them by +//! `strength × falloff(t)` (`t` its distance to the nearest grabbed node over the radius, +//! [`Falloff`]); with a sphere and no grabbed nodes, every node inside it by +//! `strength × falloff(t)` of its distance to the centre (a soft selection). +//! - *Harmonic*: the share is the harmonic function on the graph of element edges that is 1 at +//! the grabbed nodes and 0 beyond the radius and at the nodes that may not move (Gauss–Seidel +//! on the graph Laplacian, as the strong relaxation's morph: [`super::strong`]). The +//! displacement then spreads through the mesh as a membrane would carry it, following the +//! mesh's connectivity rather than straight-line distance (M. Botsch, O. Sorkine, *On linear +//! variational surface deformation methods*, IEEE TVCG 14, 2008, §3: harmonic deformation +//! fields with fixed and handle regions). +//! +//! **Where a node may go** is the relaxation's [`Constraints`]: a node on a geometry face slides +//! in the face's parameters, a node on a geometry edge along it, a node on a vertex stays; with +//! the geometry off ([`Unconstrained`]) every node moves in space. Mid-side nodes follow their +//! edge's corners, as in a relaxation. +//! +//! **Guard.** With [`Guard::NoInversion`] the displacement is scaled back (by bisection, at +//! most [`GUARD_STEPS`] halvings) to the largest share of it with which no valid element becomes +//! invalid or drops below the guard's smallest scaled Jacobian (default +//! [`DEFAULT_MIN_QUALITY`]; or its own, when that was lower); the scale is reported. + +use std::collections::{BTreeMap, BTreeSet}; + +use fieldcraft_model::Model; + +use super::{Constraints, Falloff, Freedom, P, RegionQuality, State, add, corner_nodes, dist, dot, measure, region_quality, scale, sub}; +use crate::{Error, Result}; + +/// How the displacement reaches the nodes round the grabbed ones. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Propagate { + /// `strength × falloff` of the distance. + #[default] + Falloff, + /// The harmonic function of the element-edge graph. + Harmonic, +} + +impl Propagate { + pub fn from_name(name: &str) -> Option { + match name { + "falloff" => Some(Propagate::Falloff), + "harmonic" => Some(Propagate::Harmonic), + _ => None, + } + } +} + +/// What keeps a move from breaking elements. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Guard { + /// The move as asked, whatever it does to the elements. + None, + /// Scaled back until no valid element becomes invalid or falls below `min_quality` (scaled + /// Jacobian; an element already below keeps at least its own). + NoInversion { min_quality: f64 }, +} + +impl Default for Guard { + fn default() -> Self { + Guard::NoInversion { min_quality: DEFAULT_MIN_QUALITY } + } +} + +/// Halvings of the guard's bisection: the scale is found to within 2⁻²⁰. +pub const GUARD_STEPS: usize = 20; + +/// The default smallest scaled Jacobian the guard lets a move make: with 0 the bisection stops at +/// the very edge of inversion, with an element of quality 0. +pub const DEFAULT_MIN_QUALITY: f64 = 0.05; + +/// The smallest radius of influence, relative to the mesh's size (its bounding box diagonal): +/// below it no node but the grabbed ones is within reach, and the search grid would overflow. +pub const MIN_RELATIVE_RADIUS: f64 = 1e-6; + +/// Every node moves in space, on the skin too: a move with the geometry off. +#[derive(Debug, Clone, Copy, Default)] +pub struct Unconstrained; + +impl Constraints for Unconstrained { + fn freedom(&self, _: u32) -> Option { + Some(Freedom::Free) + } + fn slide_face(&self, _: u32, _: P, _: P) -> Option

{ + None + } + fn slide_curve(&self, _: u32, _: P, _: P) -> Option

{ + None + } +} + +/// Which nodes follow the grabbed ones, and how. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Influence { + /// The sphere's centre: shares by the distance to it rather than to the grabbed nodes. + pub centre: Option

, + /// Nodes this close to the grabbed nodes (or the centre) follow; `None`: only the grabbed + /// nodes move. + pub radius: Option, + pub propagate: Propagate, + /// Falloff only: the largest share of a node that is not grabbed, in `(0, 1]`. + pub strength: f64, + pub falloff: Falloff, +} + +impl Default for Influence { + fn default() -> Self { + Influence { centre: None, radius: None, propagate: Propagate::Falloff, strength: 1.0, falloff: Falloff::Smooth } + } +} + +/// For each candidate, its distance to the nearest of `from` when that is at most `r` (a uniform +/// grid of cell `r` over `from`; deterministic). +pub(super) fn nearest_within(from: &[P], candidates: &BTreeMap, r: f64) -> BTreeMap { + // Saturating casts and additions: no coordinate over radius overflows the index. + let cell = |p: P| p.map(|x| (x / r).floor() as i64); + let mut grid: BTreeMap<[i64; 3], Vec

> = BTreeMap::new(); + for &p in from { + grid.entry(cell(p)).or_default().push(p); + } + let mut out = BTreeMap::new(); + for (&n, &p) in candidates { + let [i, j, k] = cell(p); + let mut best = f64::INFINITY; + for di in -1..=1 { + for dj in -1..=1 { + for dk in -1..=1 { + for &q in grid.get(&[i.saturating_add(di), j.saturating_add(dj), k.saturating_add(dk)]).map(Vec::as_slice).unwrap_or(&[]) { + best = best.min(dist(p, q)); + } + } + } + } + if best <= r { + out.insert(n, best); + } + } + out +} + +/// The share of the displacement each node takes (node → share in `(0, 1]`): the grabbed +/// corner nodes `handles` all of it, the others as `influence` says. Nodes that may not move +/// under `geometry` (a vertex, a skin node of unknown geometry) are anchors of the harmonic +/// function and get no share. Only corner nodes of measured elements count (mid-side nodes +/// follow them). +pub fn move_weights(model: &Model, handles: &BTreeSet, influence: &Influence, geometry: &dyn Constraints) -> Result> { + let Influence { centre, radius, propagate, strength, falloff } = *influence; + if !(strength > 0.0 && strength <= 1.0) { + return Err(Error::Invalid(format!("strength must be in (0, 1], got {strength}"))); + } + if radius.is_some_and(|r| !(r > 0.0 && r.is_finite())) { + return Err(Error::Invalid("the radius must be positive".into())); + } + if centre.is_some_and(|c| !c.iter().all(|x| x.is_finite())) { + return Err(Error::Invalid("the centre must be finite".into())); + } + let corners = corner_nodes(model); + let handles: BTreeSet = handles.iter().copied().filter(|n| corners.contains(n)).collect(); + if handles.len() > super::MAX_NODES { + return Err(Error::Limit(format!("more than {} nodes to move", super::MAX_NODES))); + } + let positions: BTreeMap = corners.iter().filter_map(|&n| model.node(n).map(|p| (n, p.xyz))).collect(); + if let Some(r) = radius { + let (lo, hi) = positions.values().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(lo, hi), p| { + ([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])]) + }); + let size = if positions.is_empty() { 0.0 } else { dist(lo, hi) }; + let min = MIN_RELATIVE_RADIUS * size; + if r < min { + return Err(Error::Invalid(format!("the radius must be at least {min:.3e} (a millionth of the mesh's size), got {r:.3e}"))); + } + } + // The nodes within the radius, with their distance to the nearest handle (or the centre). + let near = match (radius, centre) { + (None, _) => BTreeMap::new(), + (Some(r), Some(c)) => nearest_within(&[c], &positions, r), + (Some(r), None) => { + let from: Vec

= handles.iter().filter_map(|n| positions.get(n).copied()).collect(); + nearest_within(&from, &positions, r) + } + }; + if near.len() > super::MAX_NODES { + return Err(Error::Limit(format!("more than {} nodes within the radius; give a smaller one", super::MAX_NODES))); + } + let mut out: BTreeMap = handles.iter().map(|&n| (n, 1.0)).collect(); + match propagate { + Propagate::Falloff => { + let r = radius.unwrap_or(1.0); + for (&n, &d) in &near { + let w = strength * falloff.weight(d / r); + if w > 0.0 && !handles.contains(&n) { + out.insert(n, w); + } + } + } + Propagate::Harmonic => { + if radius.is_none() { + return Err(Error::Invalid("a harmonic move needs a radius".into())); + } + if handles.is_empty() { + return Err(Error::Invalid("a harmonic move needs nodes to grab".into())); + } + let movers: BTreeSet = near.keys().chain(&handles).copied().collect(); + let state = State::new(model, &movers, geometry, false)?; + let free: Vec = near.keys().copied().filter(|n| !handles.contains(n) && state.freedom(*n) != Freedom::Fixed).collect(); + let fixed: BTreeMap = handles.iter().map(|&n| (n, [1.0, 0.0, 0.0])).collect(); + let u = state.harmonic(&free, &fixed)?; + for n in free { + let w = u.get(&n).map_or(0.0, |v| v[0]).clamp(0.0, 1.0); + if w > 1e-12 { + out.insert(n, w); + } + } + } + } + Ok(out) +} + +/// One element's quality before and after a move. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ElementChange { + pub label: u32, + /// Scaled Jacobian (a triangle's smallest angle over 60°). + pub before: f64, + pub after: f64, + pub valid_before: bool, + pub valid_after: bool, +} + +impl ElementChange { + /// Its quality dropped, or it became invalid. + pub fn worse(&self) -> bool { + self.after < self.before - SAME || (self.valid_before && !self.valid_after) + } + /// Its quality rose. + pub fn better(&self) -> bool { + self.after > self.before + SAME && !self.worse() + } +} + +/// What [`move_nodes`] did. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Moved { + /// The new position of every node that moved (corners and the mid-side nodes following them). + pub positions: BTreeMap, + /// Corner nodes that moved. + pub moved: usize, + pub max_displacement: f64, + /// The quality of the elements with a corner that may move, before and after. + pub before: RegionQuality, + pub after: RegionQuality, + pub mean_before: f64, + pub mean_after: f64, + /// Elements whose quality dropped, and rose. + pub worse: usize, + pub better: usize, + /// The share of the asked displacement applied (below 1 when the guard scaled it back). + pub scale: f64, + /// Every element of the region, by label. + pub elements: Vec, +} + +/// A change in quality smaller than this is no change. +const SAME: f64 = 1e-9; + +/// Move each node of `weights` by its share of `d`, as `geometry` lets it, the displacement +/// scaled back under `guard`. The model is not changed: the result has the new positions. +pub fn move_nodes(model: &Model, weights: &BTreeMap, d: P, guard: Guard, geometry: &dyn Constraints) -> Result { + if !d.iter().all(|x| x.is_finite()) || dot(d, d).sqrt() > fieldcraft_model::MAX_COORD { + return Err(Error::Invalid("the displacement must be finite".into())); + } + if weights.len() > super::MAX_NODES { + return Err(Error::Limit(format!("more than {} nodes to move", super::MAX_NODES))); + } + let floor = match guard { + Guard::None => 0.0, + Guard::NoInversion { min_quality } if (0.0..=1.0).contains(&min_quality) => min_quality, + Guard::NoInversion { min_quality } => return Err(Error::Invalid(format!("the smallest quality must be in [0, 1], got {min_quality}"))), + }; + if let Some((n, w)) = weights.iter().find(|(_, w)| !(**w > 0.0 && **w <= 1.0)) { + return Err(Error::Invalid(format!("node {n} has share {w}; shares are in (0, 1]"))); + } + let movers: BTreeSet = weights.keys().copied().collect(); + let mut state = State::new(model, &movers, geometry, false)?; + let original = state.pos.clone(); + let start: Vec<(f64, bool)> = state.elems.iter().map(|e| measure(e, &state.coords(e))).collect(); + let before = region_quality(&state); + let freedom: BTreeMap = movers.iter().map(|&n| (n, state.freedom(n))).collect(); + // The state with the displacement applied at scale `t`. + let apply = |state: &mut State<'_>, t: f64| { + state.pos.clone_from(&original); + for (&n, &w) in weights { + let (Some(&f), Some(&p0)) = (freedom.get(&n), original.get(&n)) else { continue }; + if !state.around.contains_key(&n) { + continue; + } + if let Some(q) = state.moved(f, p0, scale(d, w * t)) { + state.place(n, q); + } + } + }; + // Whether every element valid before is still valid, and no worse than the guard's floor + // (or its own quality, when that was lower). + let sound = |state: &State<'_>| { + state.elems.iter().zip(&start).all(|(e, &(q0, was))| { + let (q, valid) = measure(e, &state.coords(e)); + !was || (valid && q >= floor.min(q0)) + }) + }; + apply(&mut state, 1.0); + let mut t = 1.0; + if guard != Guard::None && !sound(&state) { + let (mut lo, mut hi) = (0.0, 1.0); + for _ in 0..GUARD_STEPS { + fieldcraft_model::progress::check()?; + let mid = 0.5 * (lo + hi); + apply(&mut state, mid); + if sound(&state) { + lo = mid; + } else { + hi = mid; + } + } + t = lo; + apply(&mut state, t); + } + let after = region_quality(&state); + let mut out = Moved { before, after, scale: t, ..Moved::default() }; + let (mut sum_before, mut sum_after) = (0.0, 0.0); + for (e, &(q0, v0)) in state.elems.iter().zip(&start) { + let (q1, v1) = measure(e, &state.coords(e)); + sum_before += q0; + sum_after += q1; + let change = ElementChange { label: e.label, before: q0, after: q1, valid_before: v0, valid_after: v1 }; + out.worse += usize::from(change.worse()); + out.better += usize::from(change.better()); + out.elements.push(change); + } + if !state.elems.is_empty() { + out.mean_before = sum_before / state.elems.len() as f64; + out.mean_after = sum_after / state.elems.len() as f64; + } + out.elements.sort_by_key(|c| c.label); + for (n, p) in &state.pos { + let Some(o) = original.get(n) else { continue }; + let moved = dist(*o, *p); + if moved > 0.0 { + out.max_displacement = out.max_displacement.max(moved); + out.moved += usize::from(movers.contains(n)); + out.positions.insert(*n, *p); + } + } + Ok(out) +} + +/// The centroid of `nodes`' positions (`None` when none has one): where a move `to` a point +/// starts from. +pub fn centroid(model: &Model, nodes: &BTreeSet) -> Option

{ + let pts: Vec

= nodes.iter().filter_map(|n| model.node(*n).map(|p| p.xyz)).collect(); + if pts.is_empty() { + return None; + } + Some(scale(pts.iter().fold([0.0; 3], |s, p| add(s, *p)), 1.0 / pts.len() as f64)) +} + +/// The corner node of a measured element nearest `p` (the node a sphere's harmonic move grabs). +pub fn nearest_corner(model: &Model, p: P) -> Option { + let mut best: Option<(f64, u32)> = None; + for n in corner_nodes(model) { + let Some(x) = model.node(n) else { continue }; + let d = dot(sub(x.xyz, p), sub(x.xyz, p)); + if best.is_none_or(|(b, _)| d < b) { + best = Some((d, n)); + } + } + best.map(|(_, n)| n) +} diff --git a/crates/mesh/src/relax/strong.rs b/crates/mesh/src/relax/strong.rs index 06240c4..11aa5f0 100644 --- a/crates/mesh/src/relax/strong.rs +++ b/crates/mesh/src/relax/strong.rs @@ -479,7 +479,7 @@ impl State<'_> { /// The harmonic extension over `free` of the displacements `fixed` (zero at every other /// node): Gauss–Seidel on the graph Laplacian of the element edges. - fn harmonic(&self, free: &[u32], fixed: &BTreeMap) -> Result> { + pub(super) fn harmonic(&self, free: &[u32], fixed: &BTreeMap) -> Result> { let mut u: BTreeMap = fixed.clone(); for &n in free { u.insert(n, [0.0; 3]); diff --git a/crates/mesh/src/relax/tests.rs b/crates/mesh/src/relax/tests.rs index 204b3ab..68e15f9 100644 --- a/crates/mesh/src/relax/tests.rs +++ b/crates/mesh/src/relax/tests.rs @@ -465,3 +465,139 @@ fn guarded_relaxation_keeps_the_largest_edge_ratio() { assert!(r.after.invalid <= r.before.invalid); } } + +// ---- Moving nodes by hand (`mesh.node.move`) ---- + +/// The annulus node `(i, j, k)` of [`hex_annulus`] with `n` divisions. +fn node_of(n: usize, i: usize, j: usize, k: usize) -> u32 { + (1 + i + (n + 1) * (j + (n + 1) * k)) as u32 +} + +fn grab(n: u32) -> BTreeSet { + BTreeSet::from([n]) +} + +/// Falloff: the grabbed node moves by all of the displacement, the nodes round it by less the +/// farther they are, none beyond the radius; the three falloffs differ. +#[test] +fn a_falloff_move_carries_the_grabbed_node_and_fades_to_the_radius() { + let m = hex_annulus(6); + let h = node_of(6, 3, 3, 3); + let d = [0.02, -0.01, 0.015]; + let r = 0.6; + let mut shares = Vec::new(); + for falloff in [Falloff::Smooth, Falloff::Linear, Falloff::None] { + let inf = Influence { radius: Some(r), falloff, ..Influence::default() }; + let w = move_weights(&m, &grab(h), &inf, &Unconstrained).unwrap(); + assert_eq!(w.get(&h), Some(&1.0)); + let moved = move_nodes(&m, &w, d, Guard::None, &Unconstrained).unwrap(); + assert!(dist(moved.positions[&h], add(m.node(h).unwrap().xyz, d)) < 1e-12); + let p0 = m.node(h).unwrap().xyz; + for (n, p) in &moved.positions { + let old = m.node(*n).unwrap().xyz; + assert!(dist(old, p0) <= r + 1e-12, "node {n} beyond the radius moved"); + assert!(dist(old, *p) <= dist([0.0; 3], d) + 1e-12); + } + assert_eq!(moved.scale, 1.0); + assert!(moved.elements.len() >= 8 && moved.worse + moved.better > 0); + shares.push(w.get(&node_of(6, 4, 3, 3)).copied().unwrap_or(0.0)); + } + // Smooth (1 − t²)², linear 1 − t, none 1, at t = one element over the radius. + assert!(shares[0] > 0.0 && shares[0] < 1.0 && shares[1] > 0.0 && shares[1] < 1.0 && shares[2] == 1.0, "{shares:?}"); + assert_ne!(shares[0], shares[1]); + // Strength scales the followers, never the grabbed node. + let half = Influence { radius: Some(r), strength: 0.5, falloff: Falloff::None, ..Influence::default() }; + let w = move_weights(&m, &grab(h), &half, &Unconstrained).unwrap(); + assert_eq!((w.get(&h), w.get(&node_of(6, 4, 3, 3))), (Some(&1.0), Some(&0.5))); + // A sphere without grabbed nodes is a soft selection round its centre. + let c = m.node(h).unwrap().xyz; + let w = move_weights(&m, &BTreeSet::new(), &Influence { centre: Some(c), radius: Some(r), ..Influence::default() }, &Unconstrained).unwrap(); + assert!(w.get(&h).is_some_and(|v| (*v - 1.0).abs() < 1e-12) && w.len() > 1); +} + +/// Harmonic: shares between 0 and 1, 1 at the grabbed node, falling with the graph distance, +/// and zero beyond the radius (no node there moves). +#[test] +fn a_harmonic_move_is_zero_beyond_the_radius() { + let m = hex_annulus(8); + let h = node_of(8, 4, 4, 4); + let r = 0.45; + let inf = Influence { radius: Some(r), propagate: Propagate::Harmonic, ..Influence::default() }; + let w = move_weights(&m, &grab(h), &inf, &Unconstrained).unwrap(); + let p0 = m.node(h).unwrap().xyz; + assert_eq!(w.get(&h), Some(&1.0)); + assert!(w.len() > 10); + for (n, s) in &w { + assert!(*s > 0.0 && *s <= 1.0); + assert!(dist(m.node(*n).unwrap().xyz, p0) <= r + 1e-12, "node {n} beyond the radius has a share"); + } + let (near, far) = (w[&node_of(8, 5, 4, 4)], w.get(&node_of(8, 6, 4, 4)).copied().unwrap_or(0.0)); + assert!(near < 1.0 && far < near, "{near} {far}"); + let moved = move_nodes(&m, &w, [0.0, 0.0, 0.03], Guard::default(), &Unconstrained).unwrap(); + assert!(moved.positions.keys().all(|n| w.contains_key(n))); + assert_eq!(moved.after.invalid, 0); +} + +/// On the geometry: a node on the outer cylinder stays on it, a node on an edge of two faces +/// stays on both, a vertex is held; followers on the faces stay on theirs. +#[test] +fn a_move_on_the_geometry_slides_on_faces_and_edges_and_holds_vertices() { + let n = 6; + let m = hex_annulus(n); + let geo = Geo { model: m.clone() }; + let d = [0.05, 0.05, 0.05]; + let inf = Influence { radius: Some(0.5), ..Influence::default() }; + for h in [node_of(n, n, 3, 3), node_of(n, n, 3, 0), node_of(n, n, 0, 0)] { + let w = move_weights(&m, &grab(h), &inf, &geo).unwrap(); + let moved = move_nodes(&m, &w, d, Guard::default(), &geo).unwrap(); + for (k, p) in &moved.positions { + let faces = Annulus::faces(m.node(*k).unwrap().xyz); + assert!(faces.len() < 3, "vertex {k} moved"); + assert!(Annulus::on(*p, &faces), "node {k} left its faces {faces:?}"); + } + let moved_handle = moved.positions.contains_key(&h); + assert_eq!(moved_handle, Annulus::faces(m.node(h).unwrap().xyz).len() < 3, "node {h}"); + } +} + +/// The guard scales a move that would invert elements back to the largest that inverts none. +#[test] +fn the_guard_scales_a_move_back_until_nothing_inverts() { + let m = hex_annulus(6); + let h = node_of(6, 3, 3, 3); + let w = move_weights(&m, &grab(h), &Influence::default(), &Unconstrained).unwrap(); + assert_eq!(w.len(), 1); + let d = [0.6, 0.0, 0.0]; + let wild = move_nodes(&m, &w, d, Guard::None, &Unconstrained).unwrap(); + assert!(wild.after.invalid > 0 && wild.scale == 1.0); + let guarded = move_nodes(&m, &w, d, Guard::default(), &Unconstrained).unwrap(); + assert_eq!(guarded.after.invalid, 0); + assert!(guarded.scale > 0.05 && guarded.scale < 1.0, "{}", guarded.scale); + assert!(guarded.worse > 0 && guarded.after.min_quality < guarded.before.min_quality); + assert!(guarded.after.min_quality >= DEFAULT_MIN_QUALITY, "the guard stops short of a degenerate element"); + let p = guarded.positions[&h]; + assert!((p[0] - m.node(h).unwrap().xyz[0] - 0.6 * guarded.scale).abs() < 1e-12); +} + +#[test] +fn hostile_move_parameters_are_refused() { + let m = hex_annulus(3); + let h = grab(node_of(3, 1, 1, 1)); + let bad = |inf: Influence| move_weights(&m, &h, &inf, &Unconstrained).is_err(); + assert!(bad(Influence { strength: 0.0, ..Influence::default() })); + assert!(bad(Influence { strength: f64::NAN, ..Influence::default() })); + assert!(bad(Influence { radius: Some(-1.0), ..Influence::default() })); + assert!(bad(Influence { radius: Some(f64::INFINITY), ..Influence::default() })); + assert!(bad(Influence { centre: Some([f64::NAN, 0.0, 0.0]), radius: Some(1.0), ..Influence::default() })); + assert!(bad(Influence { propagate: Propagate::Harmonic, ..Influence::default() })); + let w = move_weights(&m, &h, &Influence::default(), &Unconstrained).unwrap(); + assert!(move_nodes(&m, &w, [f64::NAN, 0.0, 0.0], Guard::None, &Unconstrained).is_err()); + assert!(move_nodes(&m, &w, [1e300, 0.0, 0.0], Guard::None, &Unconstrained).is_err()); + assert!(move_nodes(&m, &BTreeMap::from([(1, 2.0)]), [0.0; 3], Guard::None, &Unconstrained).is_err()); + assert!(move_nodes(&m, &w, [0.1; 3], Guard::NoInversion { min_quality: 1.5 }, &Unconstrained).is_err()); + // A radius too small for the mesh is refused, never an overflow of the search grid. + assert!(bad(Influence { radius: Some(1e-300), ..Influence::default() })); + assert!(bad(Influence { centre: Some([-1e300, -1e300, 0.0]), radius: Some(1e-300), ..Influence::default() })); + let near = |r: f64| moving::nearest_within(&[[-1e300, 0.0, 0.0]], &BTreeMap::from([(1, [1e300, 0.0, 0.0])]), r); + assert!(near(1e-300).is_empty() && near(1.0).is_empty()); +}