From 1dd2afe525de89883aedc4fcb596f50b5777b557 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] 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- Cargo.lock | 1 + crates/mesh/Cargo.toml | 1 + crates/mesh/src/relax.rs | 15 +++++-- crates/mesh/src/relax/moving.rs | 79 ++++++++++++++++++++++++++------- 4 files changed, 76 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb8d0b5..429cdb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1433,6 +1433,7 @@ name = "fieldcraft-mesh" version = "0.6.0" dependencies = [ "fieldcraft-model", + "fieldcraft-parallel", "libm", "num-bigint", "num-rational", diff --git a/crates/mesh/Cargo.toml b/crates/mesh/Cargo.toml index 806c14f..395cb4d 100644 --- a/crates/mesh/Cargo.toml +++ b/crates/mesh/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] 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 # 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. diff --git a/crates/mesh/src/relax.rs b/crates/mesh/src/relax.rs index d2f2564..f3a7fdb 100644 --- a/crates/mesh/src/relax.rs +++ b/crates/mesh/src/relax.rs @@ -275,7 +275,8 @@ struct Elem { nodes: Vec, /// A shell's normal as it was: its side, which a move must keep. normal: Option

, - /// 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, } @@ -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) } @@ -941,8 +948,8 @@ pub fn apply_field( 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, + DEFAULT_MIN_QUALITY, ElementChange, GUARD_STEPS, Guard, Influence, MIN_RELATIVE_RADIUS, MoveRun, Moved, Propagate, Unconstrained, centroid, + move_nodes, move_nodes_with, move_weights, nearest_corner, }; pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph}; diff --git a/crates/mesh/src/relax/moving.rs b/crates/mesh/src/relax/moving.rs index 09fa41d..228ce67 100644 --- a/crates/mesh/src/relax/moving.rs +++ b/crates/mesh/src/relax/moving.rs @@ -30,7 +30,7 @@ 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 super::{Constraints, Falloff, Freedom, P, RegionQuality, State, add, corner_nodes, dist, dot, measure, scale, sub}; use crate::{Error, Result}; /// 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 /// 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 { + 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>, + /// 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) -> Vec<(f64, bool)> { + fieldcraft_parallel::map(elems.len(), |i| { + let Some(e) = elems.get(i) else { return (-1.0, false) }; + let x: Vec

= 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, d: P, guard: Guard, geometry: &dyn Constraints, run: &MoveRun) -> 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())); } @@ -287,11 +332,15 @@ pub fn move_nodes(model: &Model, weights: &BTreeMap, d: P, guard: Guar 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 steps = if run.guard_steps == 0 { GUARD_STEPS } else { run.guard_steps.min(GUARD_STEPS) }; 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 start = match &run.baseline { + Some(b) if b.len() == state.elems.len() => b.clone(), + _ => measure_all(&state.elems, &state.pos), + }; + let before = summary(&state, &start); 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| { @@ -308,34 +357,33 @@ pub fn move_nodes(model: &Model, weights: &BTreeMap, d: P, guard: Guar }; // 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)) - }) - }; + let sound = |now: &[(f64, bool)]| now.iter().zip(&start).all(|(&(q, valid), &(q0, was))| !was || (valid && q >= floor.min(q0))); apply(&mut state, 1.0); + let mut now = measure_all(&state.elems, &state.pos); 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); - for _ in 0..GUARD_STEPS { + let mut at_lo = None; + for _ in 0..steps { fieldcraft_model::progress::check()?; let mid = 0.5 * (lo + hi); apply(&mut state, mid); - if sound(&state) { + let m = measure_all(&state.elems, &state.pos); + if sound(&m) { lo = mid; + at_lo = Some(m); } else { hi = mid; } } t = lo; 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 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)); + for (e, (&(q0, v0), &(q1, v1))) in state.elems.iter().zip(start.iter().zip(&now)) { sum_before += q0; sum_after += q1; 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, d: P, guard: Guar 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);