mesh: a node move measures its region in parallel and can reuse its baseline

move_nodes_with takes the region's quality before the move from an earlier run
and a coarser guard bisection, for a live preview; each pass measures the
elements in parallel (fixed blocks). The relaxation's state sizes its elements
(a certified check each) only for the strong mode, which alone uses the size:
on the crank arm's hex20 mesh that was 114 ms of every 130 ms preview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 53a370ed01
commit 1dd2afe525
4 files changed
+76 -20

No files matched your search

Generated
+1
View File
@@ -1433,6 +1433,7 @@ name = "fieldcraft-mesh"
version = "0.6.0" version = "0.6.0"
dependencies = [ dependencies = [
"fieldcraft-model", "fieldcraft-model",
"fieldcraft-parallel",
"libm", "libm",
"num-bigint", "num-bigint",
"num-rational", "num-rational",
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies] [dependencies]
fieldcraft-model = { workspace = true } fieldcraft-model = { workspace = true }
fieldcraft-parallel = { workspace = true }
# Pure-Rust maths (a port of musl's libm) for sin, cos, log, exp, pow, hypot, acos: the same # Pure-Rust maths (a port of musl's libm) for sin, cos, log, exp, pow, hypot, acos: the same
# bits on every platform, where the system library's are not correctly rounded and differ (the # bits on every platform, where the system library's are not correctly rounded and differ (the
# plate's hex mesh differed between macOS and Linux, #438). atan2 and cbrt are crate::vec3's. # plate's hex mesh differed between macOS and Linux, #438). atan2 and cbrt are crate::vec3's.
+11 -4
View File
@@ -275,7 +275,8 @@ struct Elem {
nodes: Vec<u32>, nodes: Vec<u32>,
/// A shell's normal as it was: its side, which a move must keep. /// A shell's normal as it was: its side, which a move must keep.
normal: Option<P>, normal: Option<P>,
/// Its size as it was ([`strong::size`]): the strong mode keeps sizes near it. /// Its size as it was ([`strong::size`]): the strong mode keeps sizes near it (only the strong
/// mode measures it, a certified check per element; 0 otherwise).
size0: f64, size0: f64,
} }
@@ -570,7 +571,13 @@ impl<'a> State<'a> {
} }
} }
} }
state.elems.push(Elem { label, kind: e.kind, normal: shell_normal(e.kind, &x), size0: strong::size(e.kind, &x), nodes: e.nodes.clone() }); state.elems.push(Elem {
label,
kind: e.kind,
normal: shell_normal(e.kind, &x),
size0: if pin_curves { strong::size(e.kind, &x) } else { 0.0 },
nodes: e.nodes.clone(),
});
} }
Ok(state) Ok(state)
} }
@@ -941,8 +948,8 @@ pub fn apply_field(
mod moving; mod moving;
mod strong; mod strong;
pub use moving::{ pub use moving::{
DEFAULT_MIN_QUALITY, ElementChange, GUARD_STEPS, Guard, Influence, MIN_RELATIVE_RADIUS, Moved, Propagate, Unconstrained, centroid, move_nodes, DEFAULT_MIN_QUALITY, ElementChange, GUARD_STEPS, Guard, Influence, MIN_RELATIVE_RADIUS, MoveRun, Moved, Propagate, Unconstrained, centroid,
move_weights, nearest_corner, move_nodes, move_nodes_with, move_weights, nearest_corner,
}; };
pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph}; pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
+63 -16
View File
@@ -30,7 +30,7 @@ use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_model::Model; use fieldcraft_model::Model;
use super::{Constraints, Falloff, Freedom, P, RegionQuality, State, add, corner_nodes, dist, dot, measure, region_quality, scale, sub}; use super::{Constraints, Falloff, Freedom, P, RegionQuality, State, add, corner_nodes, dist, dot, measure, scale, sub};
use crate::{Error, Result}; use crate::{Error, Result};
/// How the displacement reaches the nodes round the grabbed ones. /// How the displacement reaches the nodes round the grabbed ones.
@@ -273,6 +273,51 @@ const SAME: f64 = 1e-9;
/// Move each node of `weights` by its share of `d`, as `geometry` lets it, the displacement /// 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. /// 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> { pub fn move_nodes(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guard, geometry: &dyn Constraints) -> Result<Moved> {
move_nodes_with(model, weights, d, guard, geometry, &MoveRun::default())
}
/// How [`move_nodes_with`] runs: what a live preview reuses and how finely it guards.
#[derive(Debug, Clone, Default)]
pub struct MoveRun {
/// The region's elements' quality and validity before any move, in label order (from an
/// earlier run's [`Moved::elements`] with the same weights); measured again when absent or
/// of another length.
pub baseline: Option<Vec<(f64, bool)>>,
/// Halvings of the guard's bisection; 0 is [`GUARD_STEPS`].
pub guard_steps: usize,
}
/// Each element's quality and validity at the state's positions, measured in parallel (fixed
/// blocks: the same result on any thread count).
fn measure_all(elems: &[super::Elem], pos: &BTreeMap<u32, P>) -> Vec<(f64, bool)> {
fieldcraft_parallel::map(elems.len(), |i| {
let Some(e) = elems.get(i) else { return (-1.0, false) };
let x: Vec<P> = e.nodes.iter().map(|n| pos.get(n).copied().unwrap_or([0.0; 3])).collect();
measure(e, &x)
})
}
/// The region's quality from its elements' measures.
fn summary(state: &State<'_>, values: &[(f64, bool)]) -> RegionQuality {
let mut r = RegionQuality { elements: state.elems.len(), min_quality: f64::INFINITY, max_edge_ratio: 0.0, invalid: 0, worst: Vec::new() };
let mut all = Vec::with_capacity(values.len());
for (e, &(q, valid)) in state.elems.iter().zip(values) {
r.min_quality = r.min_quality.min(q);
r.max_edge_ratio = r.max_edge_ratio.max(super::edge_ratio(e.kind, &state.coords(e)));
r.invalid += usize::from(!valid);
all.push((e.label, q));
}
all.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
all.truncate(super::WORST_LISTED);
r.worst = all;
if r.elements == 0 {
r.min_quality = 0.0;
}
r
}
/// [`move_nodes`], reusing what `run` carries.
pub fn move_nodes_with(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guard, geometry: &dyn Constraints, run: &MoveRun) -> Result<Moved> {
if !d.iter().all(|x| x.is_finite()) || dot(d, d).sqrt() > fieldcraft_model::MAX_COORD { 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())); return Err(Error::Invalid("the displacement must be finite".into()));
} }
@@ -287,11 +332,15 @@ pub fn move_nodes(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guar
if let Some((n, w)) = weights.iter().find(|(_, w)| !(**w > 0.0 && **w <= 1.0)) { 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]"))); return Err(Error::Invalid(format!("node {n} has share {w}; shares are in (0, 1]")));
} }
let steps = if run.guard_steps == 0 { GUARD_STEPS } else { run.guard_steps.min(GUARD_STEPS) };
let movers: BTreeSet<u32> = weights.keys().copied().collect(); let movers: BTreeSet<u32> = weights.keys().copied().collect();
let mut state = State::new(model, &movers, geometry, false)?; let mut state = State::new(model, &movers, geometry, false)?;
let original = state.pos.clone(); let original = state.pos.clone();
let start: Vec<(f64, bool)> = state.elems.iter().map(|e| measure(e, &state.coords(e))).collect(); let start = match &run.baseline {
let before = region_quality(&state); Some(b) if b.len() == state.elems.len() => b.clone(),
_ => measure_all(&state.elems, &state.pos),
};
let before = summary(&state, &start);
let freedom: BTreeMap<u32, Freedom> = movers.iter().map(|&n| (n, state.freedom(n))).collect(); let freedom: BTreeMap<u32, Freedom> = movers.iter().map(|&n| (n, state.freedom(n))).collect();
// The state with the displacement applied at scale `t`. // The state with the displacement applied at scale `t`.
let apply = |state: &mut State<'_>, t: f64| { let apply = |state: &mut State<'_>, t: f64| {
@@ -308,34 +357,33 @@ pub fn move_nodes(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guar
}; };
// Whether every element valid before is still valid, and no worse than the guard's floor // Whether every element valid before is still valid, and no worse than the guard's floor
// (or its own quality, when that was lower). // (or its own quality, when that was lower).
let sound = |state: &State<'_>| { let sound = |now: &[(f64, bool)]| now.iter().zip(&start).all(|(&(q, valid), &(q0, was))| !was || (valid && q >= floor.min(q0)));
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); apply(&mut state, 1.0);
let mut now = measure_all(&state.elems, &state.pos);
let mut t = 1.0; let mut t = 1.0;
if guard != Guard::None && !sound(&state) { if guard != Guard::None && !sound(&now) {
let (mut lo, mut hi) = (0.0, 1.0); let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..GUARD_STEPS { let mut at_lo = None;
for _ in 0..steps {
fieldcraft_model::progress::check()?; fieldcraft_model::progress::check()?;
let mid = 0.5 * (lo + hi); let mid = 0.5 * (lo + hi);
apply(&mut state, mid); apply(&mut state, mid);
if sound(&state) { let m = measure_all(&state.elems, &state.pos);
if sound(&m) {
lo = mid; lo = mid;
at_lo = Some(m);
} else { } else {
hi = mid; hi = mid;
} }
} }
t = lo; t = lo;
apply(&mut state, t); apply(&mut state, t);
now = at_lo.unwrap_or_else(|| start.clone());
} }
let after = region_quality(&state); let after = summary(&state, &now);
let mut out = Moved { before, after, scale: t, ..Moved::default() }; let mut out = Moved { before, after, scale: t, ..Moved::default() };
let (mut sum_before, mut sum_after) = (0.0, 0.0); let (mut sum_before, mut sum_after) = (0.0, 0.0);
for (e, &(q0, v0)) in state.elems.iter().zip(&start) { for (e, (&(q0, v0), &(q1, v1))) in state.elems.iter().zip(start.iter().zip(&now)) {
let (q1, v1) = measure(e, &state.coords(e));
sum_before += q0; sum_before += q0;
sum_after += q1; sum_after += q1;
let change = ElementChange { label: e.label, before: q0, after: q1, valid_before: v0, valid_after: v1 }; let change = ElementChange { label: e.label, before: q0, after: q1, valid_before: v0, valid_after: v1 };
@@ -347,7 +395,6 @@ pub fn move_nodes(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guar
out.mean_before = sum_before / state.elems.len() as f64; out.mean_before = sum_before / state.elems.len() as f64;
out.mean_after = sum_after / 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 { for (n, p) in &state.pos {
let Some(o) = original.get(n) else { continue }; let Some(o) = original.get(n) else { continue };
let moved = dist(*o, *p); let moved = dist(*o, *p);