mesh: move nodes by hand, with falloff or harmonic followers and an inversion guard

relax::move_weights gives each node its share of a displacement (the grabbed
nodes all of it, the nodes within the radius strength x falloff, or the
harmonic function of the element-edge graph, zero beyond the radius and at
held nodes); relax::move_nodes applies it as the relaxation's constraints let
each node move (faces in UV, edges along, vertices held), scaled back by
bisection until no valid element inverts, and reports each element's scaled
Jacobian before and after.

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 99b0b0cfa4
commit e52eda728f
4 files changed
+526 -1

No files matched your search

+5
View File
@@ -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)]
+384
View File
@@ -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<Propagate> {
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<Freedom> {
Some(Freedom::Free)
}
fn slide_face(&self, _: u32, _: P, _: P) -> Option<P> {
None
}
fn slide_curve(&self, _: u32, _: P, _: P) -> Option<P> {
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<P>,
/// Nodes this close to the grabbed nodes (or the centre) follow; `None`: only the grabbed
/// nodes move.
pub radius: Option<f64>,
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<u32, P>, r: f64) -> BTreeMap<u32, f64> {
// 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<P>> = 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<u32>, influence: &Influence, geometry: &dyn Constraints) -> Result<BTreeMap<u32, f64>> {
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<u32> = 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<u32, P> = 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<P> = 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<u32, f64> = 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<u32> = near.keys().chain(&handles).copied().collect();
let state = State::new(model, &movers, geometry, false)?;
let free: Vec<u32> = near.keys().copied().filter(|n| !handles.contains(n) && state.freedom(*n) != Freedom::Fixed).collect();
let fixed: BTreeMap<u32, P> = 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<u32, P>,
/// 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<ElementChange>,
}
/// 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<u32, f64>, d: P, guard: Guard, geometry: &dyn Constraints) -> Result<Moved> {
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<u32> = 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<u32, Freedom> = 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<u32>) -> Option<P> {
let pts: Vec<P> = 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<u32> {
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)
}
+1 -1
View File
@@ -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<u32, P>) -> Result<BTreeMap<u32, P>> {
pub(super) fn harmonic(&self, free: &[u32], fixed: &BTreeMap<u32, P>) -> Result<BTreeMap<u32, P>> {
let mut u: BTreeMap<u32, P> = fixed.clone();
for &n in free {
u.insert(n, [0.0; 3]);
+136
View File
@@ -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<u32> {
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());
}