mesh: strong relaxation morphs round the bad elements (harmonic interpolation within a reach)

The owner's direction: bad nodes move to better places, the nodes within a reach follow by
the graph Laplacian's harmonic extension, edges held, nothing outside the reach moves.
mesh.node.relax strong mode takes below and reach and reports each round.

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-16 14:55:59 -04:00
1 parent 6f94346253
commit 4908f5679c
8 files changed
+430 -253

No files matched your search

+4 -4
View File
@@ -1419,11 +1419,11 @@ pub const NODE_RELAX: &[Field] = &[
labels("elements", "Elements", LabelOf::Element).help("Instead of a sphere: the nodes of these elements.").hint("none"),
references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("every body"),
choice("mode", "Mode", &[("guarded", "Guarded (local)"), ("strong", "Strong (optimise the region)")])
.help("Guarded: each move keeps the worst element around the node from getting worse. Strong: optimise every node of the region together toward scaled Jacobian 1, nodes on CAD edges held.")
.help("Guarded: each move keeps the worst element around the node from getting worse. Strong: move the nodes of the bad elements and morph the mesh round them, nodes on CAD edges held.")
.def("\"guarded\""),
range("below", "Below", D::None, 0.0, 1.0).help("Strong: relax the elements below this scaled Jacobian (and the layers around them).").hint("every element of the region"),
int("layers", "Layers", 0, 20).help("Strong: rings of elements added around the elements below the threshold.").def("2"),
int("iterations", "Iterations", 1, 2000).help("Sweeps over the nodes (guarded: 1 to 100, default 5; strong: default 200).").hint("5 (strong: 200)"),
range("below", "Below", D::None, 0.0, 1.0).help("Strong: the elements below this scaled Jacobian are moved.").hint("0.7"),
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("three element lengths"),
int("iterations", "Iterations", 1, 200).help("Guarded: sweeps over the nodes (1 to 100). Strong: morph rounds.").hint("5 (strong: 20)"),
range("strength", "Strength", D::None, 0.0, 1.0).help("How far each node moves toward its relaxed position, 0 to 1.").def("1"),
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
.help("How the strength falls from the centre to the radius, so the edit blends into the mesh.")
+44 -82
View File
@@ -28,7 +28,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const RELAX: CommandSpec = CommandSpec {
id: "mesh.node.relax",
label: "Relax Nodes",
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian), "layers"?: 2 (strong: rings of elements added around them, 0..=20)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` optimises the whole region instead (`bodies` alone is the whole body; `iterations` are sweeps, default 200, 1..=2000): nodes on B-rep edges and vertices are held, nodes on faces slide on them, interior nodes are free, and the node positions minimise Σ (1/SJ − 1)² over the elements (a barrier: nothing inverts), so single elements may get worse on the way; the best state passed through is kept, and nothing moves unless it is valid and its smallest scaled Jacobian is not below the start. Its reply adds the mean scaled Jacobian and the objective, min and mean per sweep (`history`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default three times the bad elements' mean edge length)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A round is kept (at full or a halved scale) only when nothing inverts, no element gets longer than the longest was, and the worst element it touches improves; the result never has a lower smallest scaled Jacobian than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
form: Some(super::forms::NODE_RELAX),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
@@ -486,6 +486,10 @@ pub struct Preview {
}
pub fn preview(s: &Session, params: &Value) -> Option<Preview> {
// The strong mode morphs a whole region: too heavy for a live preview.
if params.get("mode").and_then(Value::as_str) == Some("strong") {
return None;
}
let (w, sphere) = weights(&s.model, params).ok()?;
let g = design_geometry(s, &touched(&s.model, &w)).ok()?;
let relaxed = relax::relax(&s.model, &w, iterations(params).ok()?, &g).ok()?;
@@ -500,124 +504,83 @@ pub fn sphere(s: &Session, params: &Value) -> Option<([f64; 3], f64)> {
Some((c, r))
}
/// Elements below this scaled Jacobian, with `layers` rings of elements around them: their
/// nodes (only of `only` when given).
fn below_nodes(model: &Model, below: f64, layers: usize, only: Option<&BTreeSet<u32>>) -> Result<BTreeSet<u32>> {
let mut nodes = BTreeSet::new();
for e in model.elements().values() {
if !fieldcraft_mesh::quality::is_measured(e.kind) || only.is_some_and(|o| !e.nodes.iter().all(|n| o.contains(n))) {
continue;
}
let x: Vec<[f64; 3]> = e.nodes.iter().filter_map(|n| model.node(*n).map(|p| p.xyz)).collect();
let q = fieldcraft_mesh::quality::element_quality(e.kind, &x).ok().and_then(|q| {
q.get(fieldcraft_mesh::quality::Metric::ScaledJacobian).or_else(|| q.get(fieldcraft_mesh::quality::Metric::MinAngle).map(|a| a / 60.0))
});
if q.is_none_or(|q| q < below) {
nodes.extend(e.nodes.iter().copied());
}
}
for _ in 0..layers {
let mut grown = nodes.clone();
for e in model.elements().values() {
if e.nodes.iter().any(|n| nodes.contains(n)) {
grown.extend(e.nodes.iter().copied());
}
}
nodes = grown;
}
if let Some(only) = only {
nodes.retain(|n| only.contains(n));
}
Ok(nodes)
}
/// Default rounds of the strong mode.
const STRONG_ROUNDS: usize = 20;
/// The most layers `layers` may add.
const MAX_LAYERS: usize = 20;
/// Default sweeps of the strong mode.
const STRONG_SWEEPS: usize = 200;
/// `mesh.node.relax` with `"mode": "strong"`: optimise the region globally ([`relax::optimize`]).
/// `mesh.node.relax` with `"mode": "strong"`: morph the region round its bad elements
/// ([`relax::morph`]).
fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
let only = body_nodes(&s.model, p)?;
let below = match p.get("below") {
let below = super::optional_number(p, "below", relax::MorphOptions::default().below)?;
if !(below > 0.0 && below <= 1.0) {
return Err(EngineError::param("below", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
}
let reach = match p.get("reach") {
None => None,
Some(_) => {
let b = super::optional_number(p, "below", 0.0)?;
if !(b > 0.0 && b <= 1.0) {
return Err(EngineError::param("below", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
}
Some(b)
}
Some(_) => Some(super::optional_number(p, "reach", 0.0)?)
.filter(|r| *r > 0.0 && r.is_finite())
.ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive))
.map(Some)?,
};
let layers = match p.get("layers") {
None => 2,
let rounds = match p.get("iterations") {
None => STRONG_ROUNDS,
Some(v) => v
.as_u64()
.and_then(|l| usize::try_from(l).ok())
.filter(|l| *l <= MAX_LAYERS)
.ok_or_else(|| EngineError::param("layers", ProblemKind::NotInRange { min: 0.0, max: MAX_LAYERS as f64 }))?,
.and_then(|i| usize::try_from(i).ok())
.filter(|i| (1..=relax::MAX_ROUNDS).contains(i))
.ok_or_else(|| EngineError::param("iterations", ProblemKind::NotInRange { min: 1.0, max: relax::MAX_ROUNDS as f64 }))?,
};
let listed = ["center", "radius", "around_element", "faces", "nodes", "elements"].iter().any(|k| p.get(*k).is_some());
let mut nodes: BTreeSet<u32> = if listed {
let nodes: BTreeSet<u32> = if listed {
weights(&s.model, p)?.0.into_keys().collect()
} else if let Some((_, n)) = &only {
n.clone()
} else if below.is_none() {
return Err(EngineError::Params(
"say which nodes to relax: give `bodies`, `below`, `center` and `radius`, `around_element`, `faces`, `nodes` or `elements`".into(),
));
} else {
s.model.nodes().keys().copied().collect()
};
if let Some(b) = below {
let low = below_nodes(&s.model, b, layers, only.as_ref().map(|(_, n)| n))?;
nodes.retain(|n| low.contains(n));
}
let nodes: BTreeSet<u32> = relax::uniform_weights(&s.model, &nodes, 1.0)?.into_keys().collect();
if nodes.is_empty() {
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
}
let sweeps = match p.get("iterations") {
None => STRONG_SWEEPS,
Some(v) => v
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| (1..=relax::MAX_SWEEPS).contains(i))
.ok_or_else(|| EngineError::param("iterations", ProblemKind::NotInRange { min: 1.0, max: relax::MAX_SWEEPS as f64 }))?,
};
let g = design_geometry(s, &touched(&s.model, &nodes.iter().map(|&n| (n, 1.0)).collect()))?;
let r = relax::optimize(&s.model, &nodes, sweeps, &g)?;
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds }, &g)?;
let field_name = match super::opt::<String>(p, "field")? {
Some(n) => n,
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
};
let bodies = only.map(|(b, _)| b).unwrap_or_default();
let reapply = super::opt::<bool>(p, "reapply")?.unwrap_or(false);
let as_relaxed = Relaxed { positions: r.positions.clone(), ..Relaxed::default() };
if !r.positions.is_empty() {
let cells = relax::field_cells(&s.model, &as_relaxed);
let cells = relax::field_cells(&s.model, &Relaxed { positions: r.positions.clone(), ..Relaxed::default() });
s.model.set_relaxation(&field_name, Relaxation { bodies, cells, reapply })?;
}
for (n, x) in &r.positions {
s.model.set_node(*n, *x)?;
}
let summary = format!(
"strong: moved {} node{} (largest {:.3e}) over {} sweep{}; scaled Jacobian min {:.3} → {:.3}, mean {:.3} → {:.3}{}",
"strong: moved {} node{} (largest {:.3e}) in {} round{}; scaled Jacobian min {:.3} → {:.3}, mean {:.3} → {:.3}{}",
r.moved,
if r.moved == 1 { "" } else { "s" },
r.max_displacement,
r.history.len().saturating_sub(1),
if r.history.len() == 2 { "" } else { "s" },
r.rounds.len(),
if r.rounds.len() == 1 { "" } else { "s" },
r.before.min_quality,
r.after.min_quality,
r.mean_before,
r.mean_after,
if r.positions.is_empty() { "; nothing better found, nothing moved".to_string() } else { format!("; kept as field {field_name}") }
if r.positions.is_empty() {
format!("; nothing below {below} could be improved, nothing moved")
} else {
format!("; kept as field {field_name}")
}
);
let history: Vec<Value> = r
.history
let rounds: Vec<Value> = r
.rounds
.iter()
.map(|h| json!({"phase": match h.phase { relax::Phase::Smooth => "smooth", relax::Phase::MinMax => "min_max" }, "objective": h.objective, "min": h.min_quality, "mean": h.mean_quality, "invalid": h.invalid}))
.map(|k| {
json!({"bad_elements": k.bad_elements, "bad_nodes": k.bad_nodes, "region_nodes": k.region_nodes, "reach": k.reach,
"clusters": k.clusters, "kept": k.kept, "min": k.min_quality, "mean": k.mean_quality})
})
.collect();
let mut before = quality_json(&r.before);
let mut after = quality_json(&r.after);
@@ -630,9 +593,8 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
"moved": r.moved,
"nodes_changed": r.positions.len(),
"max_displacement": r.max_displacement,
"iterations": r.history.len().saturating_sub(1),
"kept_sweep": r.kept,
"history": history,
"iterations": r.rounds.len(),
"rounds": rounds,
"before": before,
"after": after,
"field": (!r.positions.is_empty()).then_some(field_name),
@@ -647,8 +609,8 @@ fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
Some("strong") => return relax_strong(s, p),
Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
}
if p.get("below").is_some() || p.get("layers").is_some() {
return Err(EngineError::Params("`below` and `layers` go with \"mode\": \"strong\"".into()));
if p.get("below").is_some() || p.get("reach").is_some() {
return Err(EngineError::Params("`below` and `reach` go with \"mode\": \"strong\"".into()));
}
let (w, sphere) = weights(&s.model, p)?;
if w.is_empty() {
+21 -21
View File
@@ -267,22 +267,23 @@ fn on_box_edge(p: [f64; 3]) -> bool {
p.iter().filter(|c| c.abs() < 1e-6 || (**c - 20.0).abs() < 1e-6).count() >= 2
}
/// Strong mode on the whole filleted box: the smallest and the mean scaled Jacobian do not drop,
/// Strong mode on the whole filleted box: the smallest scaled Jacobian does not drop, the mean rises,
/// nothing inverts, the fillet's nodes stay on its cylinder, the plane faces' nodes on their
/// planes, and the nodes on the box's edges and the fillet's tangent lines do not move. The
/// reply carries the objective, min and mean per sweep.
/// reply carries each round's region and quality.
#[test]
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
let (_d, mut s, _) = filleted_box();
let before = s.model.clone();
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "iterations": 40})).unwrap();
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.3})).unwrap();
let (b, a) = (&r["before"], &r["after"]);
assert!(a["min_scaled_jacobian"].as_f64().unwrap() > b["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
// The worst tets may not improve: their corners can all be held on the box's edges.
assert!(a["min_scaled_jacobian"].as_f64().unwrap() >= b["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
assert!(a["mean_scaled_jacobian"].as_f64().unwrap() > b["mean_scaled_jacobian"].as_f64().unwrap(), "{r}");
assert_eq!(a["invalid"], 0, "{r}");
assert_eq!(invalid(&mut s), 0);
let history = r["history"].as_array().unwrap();
assert!(history.len() > 2 && history.iter().all(|h| h["objective"].is_number() && h["min"].is_number() && h["mean"].is_number()), "{r}");
let rounds = r["rounds"].as_array().unwrap();
assert!(!rounds.is_empty() && rounds.iter().all(|k| k["region_nodes"].as_u64() >= k["bad_nodes"].as_u64() && k["min"].is_number()), "{r}");
for (n, p) in s.model.nodes() {
let (old, new) = (before.node(*n).unwrap().xyz, p.xyz);
if on_box_edge(old) {
@@ -302,9 +303,8 @@ fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
}
/// Strong mode on a distorted hex plate: over the guarded mode's sphere it lifts the smallest
/// scaled Jacobian as far as the guarded mode (to the elements on the sphere's rim, which
/// neither moves) (with nodes on edges held, where the guarded mode slides
/// them); by quality threshold (the elements below 0.5 and two layers round them) it untangles
/// scaled Jacobian past the threshold, close to the guarded mode (which also slides the edge nodes) (with nodes on edges held, where the guarded mode slides
/// them); by quality threshold (the elements below 0.5, morphed 4 mm round) it untangles
/// every element. Nothing inverts, nodes on the hole stay on its cylinder and the caps' on their
/// planes, nodes on the hole's edges do not move.
#[test]
@@ -316,13 +316,12 @@ fn strong_relaxation_untangles_a_distorted_plate() {
let sphere = json!({"center": ON_HOLE, "radius": 7.0, "iterations": 20});
let guarded = s.execute("mesh.node.relax", &sphere).unwrap();
let min = |r: &Value, at: &str| r[at]["min_scaled_jacobian"].as_f64().unwrap();
for strong in [json!({"mode": "strong", "center": ON_HOLE, "radius": 7.0}), json!({"mode": "strong", "below": 0.5, "layers": 2})] {
for strong in [json!({"mode": "strong", "center": ON_HOLE, "radius": 7.0}), json!({"mode": "strong", "below": 0.5, "reach": 4.0})] {
s.model = perturbed.clone();
let r = s.execute("mesh.node.relax", &strong).unwrap();
assert!(min(&r, "after") > 0.5 && min(&r, "before") < 0.0, "{strong}: {r}");
if strong.get("center").is_some() {
assert!(min(&r, "after") >= min(&guarded, "after") - 1e-9, "strong {r}\nguarded {guarded}");
}
assert!(min(&r, "after") > strong["below"].as_f64().unwrap_or(0.7) && min(&r, "before") < 0.0, "{strong}: {r}");
// Close to the guarded mode, which also slides the hole's edge nodes.
assert!(strong.get("below").is_some() || min(&r, "after") > min(&guarded, "after") - 0.05, "strong {r}\nguarded {guarded}");
assert_eq!(r["after"]["invalid"], 0, "{r}");
assert_eq!(invalid(&mut s), 0);
check_on_geometry(&perturbed, &s.model);
@@ -340,20 +339,21 @@ fn strong_mode_parameters_are_checked() {
let mut s = plate();
s.execute("mesh.hex.generate", &json!({"size": 4.0})).unwrap();
for p in [
json!({"mode": "strong"}),
json!({"mode": "wild", "nodes": [1]}),
json!({"mode": "strong", "below": 0}),
json!({"mode": "strong", "below": 1.5}),
json!({"mode": "strong", "below": 0.5, "layers": 21}),
json!({"mode": "strong", "below": 0.5, "layers": -1}),
json!({"mode": "strong", "below": 0.5, "reach": 0}),
json!({"mode": "strong", "below": 0.5, "reach": -1}),
json!({"mode": "strong", "bodies": ["nope"]}),
json!({"mode": "strong", "nodes": [1], "iterations": 0}),
json!({"mode": "strong", "nodes": [1], "iterations": 2001}),
json!({"mode": "strong", "nodes": [1], "iterations": 201}),
json!({"below": 0.5}),
json!({"nodes": [1], "layers": 1}),
json!({"nodes": [1], "reach": 1}),
json!({"nodes": [1], "mode": "strong", "layers": 1}),
] {
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
}
// An undistorted mesh above the threshold: nothing to relax.
assert!(s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.01})).is_err());
// Nothing below the threshold: nothing moves.
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.01})).unwrap();
assert_eq!(r["moved"], 0, "{r}");
}
+4 -1
View File
@@ -608,6 +608,9 @@ pub fn relax(model: &Model, weights: &BTreeMap<u32, f64>, iterations: usize, geo
let before = region_quality(&state);
let mut done = 0;
for _ in 0..iterations {
fieldcraft_model::progress::stage("relax sweep", Some(iterations as f64), "sweeps");
fieldcraft_model::progress::count(done as f64);
fieldcraft_model::progress::check()?;
done += 1;
let mut any = false;
for (&n, &w) in weights {
@@ -926,7 +929,7 @@ pub fn apply_field(
}
mod strong;
pub use strong::{MAX_SWEEPS, Optimized, Phase, Sweep, optimize};
pub use strong::{MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
#[cfg(test)]
mod tests;
+298 -110
View File
@@ -43,41 +43,8 @@ use fieldcraft_model::Model;
use super::{Constraints, Elem, Freedom, P, RegionQuality, State, add, dist, dot, measure, region_quality, scale, sub};
use crate::{Error, Result};
/// Most sweeps of a strong relaxation.
pub const MAX_SWEEPS: usize = 2000;
/// Weight of an invalid element in the objective.
const INVALID: f64 = 1e8;
/// A phase stops when a sweep lowers its objective by less than this fraction.
const CONVERGED: f64 = 1e-5;
/// One sweep's numbers.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Sweep {
pub phase: Phase,
/// The phase's objective.
pub objective: f64,
pub min_quality: f64,
pub mean_quality: f64,
pub invalid: usize,
}
/// What [`optimize`] did.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Optimized {
/// The new position of every node that moved.
pub positions: BTreeMap<u32, P>,
/// Corner nodes that moved.
pub moved: usize,
pub max_displacement: f64,
pub before: RegionQuality,
pub after: RegionQuality,
pub mean_before: f64,
pub mean_after: f64,
/// Before the first sweep, then after each.
pub history: Vec<Sweep>,
/// The sweep whose state was kept (0: the start, nothing moved).
pub kept: usize,
}
/// Weight of the size term.
const SIZE_WEIGHT: f64 = 0.5;
@@ -87,7 +54,7 @@ const MIN_MAX_POWER: i32 = 16;
/// What a phase minimises.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Phase {
enum Phase {
/// `(1/q − 1)²`: every element toward 1, the worst weighted most.
#[default]
Smooth,
@@ -264,38 +231,261 @@ impl State<'_> {
*step = (t * 2.0).max(1e-6);
moved
}
}
fn sweep_numbers(&self, phase: Phase) -> Sweep {
let mut s = Sweep { phase, min_quality: f64::INFINITY, ..Sweep::default() };
for e in &self.elems {
let (f, q, valid) = term_of(phase, e, self, true);
s.objective += f;
s.min_quality = s.min_quality.min(q);
s.mean_quality += q;
s.invalid += usize::from(!valid);
}
if self.elems.is_empty() {
s.min_quality = 0.0;
} else {
s.mean_quality /= self.elems.len() as f64;
}
s
/// What [`morph`] does.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MorphOptions {
/// Elements below this quality are the bad ones.
pub below: f64,
/// The radius round the bad nodes whose nodes follow them; `None`: three times the bad
/// elements' mean edge length.
pub reach: Option<f64>,
/// Most rounds (bad nodes moved, the region morphed after them).
pub rounds: usize,
}
impl Default for MorphOptions {
fn default() -> Self {
MorphOptions { below: 0.7, reach: None, rounds: 20 }
}
}
/// Whether `a` is a better state than `b`: fewer invalid elements, then a higher smallest
/// quality, then a higher mean.
fn better(a: &Sweep, b: &Sweep) -> bool {
a.invalid < b.invalid
|| (a.invalid == b.invalid && (a.min_quality > b.min_quality || (a.min_quality == b.min_quality && a.mean_quality > b.mean_quality)))
/// One round of [`morph`].
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Round {
/// Elements below the threshold, and their movable corner nodes (the ones moved first).
pub bad_elements: usize,
pub bad_nodes: usize,
/// Nodes within the reach of the clusters (the ones that follow), summed.
pub region_nodes: usize,
pub reach: f64,
/// Clusters of bad nodes (within the reach of each other), and those whose morph was kept.
pub clusters: usize,
pub kept: usize,
/// Over all the elements, after the round.
pub min_quality: f64,
pub mean_quality: f64,
}
/// Optimise the corner nodes `nodes` (and the mid-side nodes following them) for up to `sweeps`
/// sweeps, faces' nodes sliding on them and edges' and vertices' nodes held, as `geometry` says.
/// The model is not changed: the result has the new positions.
pub fn optimize(model: &Model, nodes: &BTreeSet<u32>, sweeps: usize, geometry: &dyn Constraints) -> Result<Optimized> {
if sweeps == 0 || sweeps > MAX_SWEEPS {
return Err(Error::Invalid(format!("sweeps must be 1 to {MAX_SWEEPS}, got {sweeps}")));
/// What [`morph`] did.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Morphed {
/// The new position of every node that moved.
pub positions: BTreeMap<u32, P>,
/// Corner nodes that moved.
pub moved: usize,
pub max_displacement: f64,
pub before: RegionQuality,
pub after: RegionQuality,
pub mean_before: f64,
pub mean_after: f64,
pub rounds: Vec<Round>,
}
/// A certified look at some elements ([`State::certified`]).
struct Checked {
invalid: usize,
worst: f64,
objective: f64,
ratio: f64,
}
impl Checked {
/// Whether `self` (after a move) is better than `was`: fewer invalid elements, or as many
/// with the worst valid one no worse and the objective lower.
fn improves(&self, was: &Checked) -> bool {
self.invalid < was.invalid || (self.invalid == was.invalid && self.worst >= was.worst && self.objective < was.objective)
}
}
/// Sweeps of the bad nodes' own optimisation (their neighbours held).
const TARGET_SWEEPS: usize = 5;
/// Most Gauss–Seidel sweeps of the harmonic solve, and its tolerance (of the largest prescribed
/// displacement).
const HARMONIC_SWEEPS: usize = 2000;
const HARMONIC_TOL: f64 = 1e-6;
/// Most rounds of [`morph`].
pub const MAX_ROUNDS: usize = 200;
/// Nodes within `r` of `centres`, from a uniform grid of cell `r` over `points` (a k-d tree's
/// fixed-radius query, deterministic: the result is a sorted set).
fn within(points: &BTreeMap<u32, P>, centres: &[P], r: f64) -> BTreeSet<u32> {
let cell = |p: P| p.map(|x| (x / r).floor() as i64);
let mut grid: BTreeMap<[i64; 3], Vec<(u32, P)>> = BTreeMap::new();
for (&n, &p) in points {
grid.entry(cell(p)).or_default().push((n, p));
}
let mut out = BTreeSet::new();
for &c in centres {
let [i, j, k] = cell(c);
for di in -1..=1 {
for dj in -1..=1 {
for dk in -1..=1 {
for &(n, p) in grid.get(&[i + di, j + dj, k + dk]).map(Vec::as_slice).unwrap_or(&[]) {
if dist(p, c) <= r {
out.insert(n);
}
}
}
}
}
}
out
}
/// `bad` split into clusters: nodes within `reach` of each other (transitively) are one.
fn clusters(s: &State<'_>, bad: &BTreeSet<u32>, reach: f64) -> Vec<BTreeSet<u32>> {
let points: BTreeMap<u32, P> = bad.iter().map(|&n| (n, s.at(n))).collect();
let mut left = bad.clone();
let mut out = Vec::new();
while let Some(first) = left.pop_first() {
let mut cluster = BTreeSet::from([first]);
let mut front = vec![first];
while let Some(n) = front.pop() {
for m in within(&points, &[s.at(n)], reach) {
if left.remove(&m) {
cluster.insert(m);
front.push(m);
}
}
}
out.push(cluster);
}
out
}
impl State<'_> {
/// Morph round one cluster of bad nodes: their own optimisation (the rest held) gives their
/// displacements, the harmonic extension carries them to the nodes within `reach`, applied at
/// full or a halved scale when that improves the elements it touches. The region's size and
/// whether it was kept.
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
let start: BTreeMap<u32, P> = bad.iter().map(|&n| (n, self.at(n))).collect();
let mut undo = Vec::new();
let mut steps: BTreeMap<u32, f64> = bad.iter().map(|&n| (n, 0.1)).collect();
for _ in 0..TARGET_SWEEPS {
for &n in bad {
let before = self.at(n);
let mut step = steps.get(&n).copied().unwrap_or(0.1);
if self.optimize_node(Phase::MinMax, n, &mut step) {
undo.push((n, before));
}
steps.insert(n, step);
}
}
let targets: BTreeMap<u32, P> = start.iter().map(|(&n, p0)| (n, sub(self.at(n), *p0))).collect();
for (n, p) in undo.into_iter().rev() {
self.place(n, p);
}
let points: BTreeMap<u32, P> = self.around.keys().map(|&n| (n, self.at(n))).collect();
let centres: Vec<P> = start.values().copied().collect();
let region = within(&points, &centres, reach);
let free: Vec<u32> = region.iter().copied().filter(|&n| !bad.contains(&n) && movable(self, n)).collect();
let u = self.harmonic(&free, &targets)?;
let touched: BTreeSet<usize> = u.keys().flat_map(|n| self.around.get(n).into_iter().flatten().copied()).collect();
let was = self.certified(&touched);
for t in [1.0, 0.5, 0.25, 0.125] {
let mut undo = Vec::new();
for (&n, d) in &u {
if dot(*d, *d) == 0.0 {
continue;
}
let p0 = self.at(n);
if let Some(p) = self.moved(self.freedom(n), p0, scale(*d, t)) {
undo.extend(self.place(n, p));
}
}
let now = self.certified(&touched);
if now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio) {
return Ok((region.len(), true));
}
self.restore(undo);
}
Ok((region.len(), false))
}
/// Every element's fast quality, its smallest and mean.
fn qualities(&self) -> (Vec<f64>, f64, f64) {
let q: Vec<f64> = self.elems.iter().map(|e| term_of(Phase::Smooth, e, self, false).1).collect();
if q.is_empty() {
return (q, 0.0, 0.0);
}
let min = q.iter().copied().fold(f64::INFINITY, f64::min);
let mean = q.iter().sum::<f64>() / q.len() as f64;
(q, min, mean)
}
/// Over elements `elems`, certified: how many are invalid, the worst quality of the valid
/// ones, the smooth objective's sum and the largest edge ratio.
fn certified(&self, elems: &BTreeSet<usize>) -> Checked {
let mut c = Checked { invalid: 0, worst: f64::INFINITY, objective: 0.0, ratio: 0.0 };
for &i in elems {
let Some(e) = self.elems.get(i) else { continue };
let x = self.coords(e);
let (q, valid) = measure(e, &x);
if valid {
c.worst = c.worst.min(q);
} else {
c.invalid += 1;
}
c.objective += phi(Phase::Smooth, q, valid, size(e.kind, &x), e.size0);
c.ratio = c.ratio.max(super::edge_ratio(e.kind, &x));
}
c
}
/// 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>> {
let mut u: BTreeMap<u32, P> = fixed.clone();
for &n in free {
u.insert(n, [0.0; 3]);
}
let scale_of = fixed.values().map(|d| dot(*d, *d).sqrt()).fold(0.0, f64::max);
for sweep in 0..HARMONIC_SWEEPS {
if sweep % 64 == 0 {
fieldcraft_model::progress::check()?;
}
let mut change = 0.0f64;
for &n in free {
let Some(nb) = self.neighbours.get(&n) else { continue };
if nb.is_empty() {
continue;
}
let sum = nb.iter().fold([0.0; 3], |s, m| add(s, u.get(m).copied().unwrap_or([0.0; 3])));
let v = scale(sum, 1.0 / nb.len() as f64);
let old = u.insert(n, v).unwrap_or([0.0; 3]);
change = change.max(dist(old, v));
}
if change <= HARMONIC_TOL * scale_of {
break;
}
}
Ok(u)
}
}
/// Morph the mesh round its bad elements: each round finds the elements of quality below
/// `options.below` and, cluster by cluster (bad nodes within the reach of each other), moves
/// their movable corner nodes to better places (a few sweeps of their own
/// optimisation, the rest of the mesh held), and carries that displacement to the nodes within
/// the reach by harmonic interpolation (the graph Laplacian solved with the bad nodes'
/// displacements and zero beyond the reach and at held nodes). Face nodes are put back on their
/// face, edge and vertex nodes do not move, nodes outside the reach stay exactly as they are. A
/// cluster's morph is kept, at full or a halved scale, only if no more elements are invalid, no element
/// gets longer than the longest one was, the worst element it touches is no worse and the smooth
/// objective there drops. Only nodes of `nodes`
/// move. The model is not changed: the result has the new positions.
pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geometry: &dyn Constraints) -> Result<Morphed> {
if !(options.below > 0.0 && options.below <= 1.0) {
return Err(Error::Invalid(format!("below must be in (0, 1], got {}", options.below)));
}
if options.reach.is_some_and(|r| !(r > 0.0 && r.is_finite())) {
return Err(Error::Invalid("the reach must be positive".into()));
}
if options.rounds == 0 || options.rounds > MAX_ROUNDS {
return Err(Error::Invalid(format!("rounds must be 1 to {MAX_ROUNDS}, got {}", options.rounds)));
}
if nodes.len() > super::MAX_NODES {
return Err(Error::Limit(format!("more than {} nodes to relax", super::MAX_NODES)));
@@ -303,63 +493,61 @@ pub fn optimize(model: &Model, nodes: &BTreeSet<u32>, sweeps: usize, geometry: &
let mut state = State::new(model, nodes, geometry, true)?;
let original = state.pos.clone();
let before = region_quality(&state);
// No element may end up more stretched than the most stretched one was.
state.ratio_cap = before.max_edge_ratio.max(1.0);
let start = state.sweep_numbers(Phase::Smooth);
let mut history = vec![start];
let (mut best, mut best_pos, mut kept) = (start, None::<BTreeMap<u32, P>>, 0);
let movers: Vec<u32> = nodes.iter().copied().filter(|n| state.around.contains_key(n) && state.freedom(*n) != Freedom::Fixed).collect();
let mut steps: BTreeMap<u32, f64> = movers.iter().map(|&n| (n, 0.1)).collect();
// Phase 1 has up to half the sweeps (the rest when it stops early); phase 2 the rest. Each
// sweep visits the nodes whose neighbourhood changed in the previous one.
let mut k = 0;
for (phase, budget) in [(Phase::Smooth, sweeps.div_ceil(2)), (Phase::MinMax, sweeps)] {
let mut active: BTreeSet<u32> = movers.iter().copied().collect();
let mut last = state.sweep_numbers(phase).objective;
while k < budget && !active.is_empty() {
k += 1;
let mut next = BTreeSet::new();
for &n in &movers {
if !active.contains(&n) {
continue;
let (_, _, mean_before) = state.qualities();
let movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed;
let mut rounds = Vec::new();
for round in 0..options.rounds {
fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds");
fieldcraft_model::progress::count(round as f64);
fieldcraft_model::progress::check()?;
let (q, _, _) = state.qualities();
let bad_elems: Vec<usize> = (0..q.len()).filter(|&i| q.get(i).is_some_and(|v| *v < options.below)).collect();
let mut bad: BTreeSet<u32> = BTreeSet::new();
let mut edge_sum = (0.0, 0usize);
for &i in &bad_elems {
let Some(e) = state.elems.get(i) else { continue };
bad.extend(e.nodes.iter().take(e.kind.corner_count()).copied().filter(|&n| movable(&state, n)));
let x = state.coords(e);
for edge in e.kind.edges() {
if let (Some(a), Some(b)) = (edge.first().and_then(|&k| x.get(k)), edge.get(1).and_then(|&k| x.get(k))) {
edge_sum = (edge_sum.0 + dist(*a, *b), edge_sum.1 + 1);
}
let mut step = steps.get(&n).copied().unwrap_or(0.1);
if state.optimize_node(phase, n, &mut step) {
next.insert(n);
next.extend(state.neighbours.get(&n).into_iter().flatten().copied());
}
steps.insert(n, step);
}
active = next;
let now = state.sweep_numbers(phase);
history.push(now);
if better(&now, &best) {
best = now;
best_pos = Some(state.pos.clone());
kept = k;
}
// Converged: the objective no longer drops.
if last - now.objective <= CONVERGED * last.max(f64::MIN_POSITIVE) {
break;
}
last = now.objective;
}
// Phase 2 starts from the best state.
if let Some(p) = &best_pos {
state.pos = p.clone();
if bad.is_empty() {
break;
}
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { 3.0 * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
if !(reach > 0.0) {
break;
}
// Bad nodes within the reach of each other are one cluster, morphed and checked together.
let clusters = clusters(&state, &bad, reach);
let mut round_out = Round { bad_elements: bad_elems.len(), bad_nodes: bad.len(), reach, clusters: clusters.len(), ..Round::default() };
for cluster in &clusters {
fieldcraft_model::progress::check()?;
let (region, kept) = state.morph_cluster(cluster, reach, &|s: &State<'_>, n| movable(s, n))?;
round_out.region_nodes += region;
round_out.kept += usize::from(kept);
}
let (_, min, mean) = state.qualities();
(round_out.min_quality, round_out.mean_quality) = (min, mean);
let any = round_out.kept > 0;
rounds.push(round_out);
if !any {
break;
}
}
let accept = best.invalid == 0 && best.min_quality >= start.min_quality;
match best_pos {
Some(p) if accept => state.pos = p,
_ => {
state.pos = original.clone();
kept = 0;
}
let mut after = region_quality(&state);
let (_, _, mut mean_after) = state.qualities();
// Never worse than the start.
if after.invalid > before.invalid || after.min_quality < before.min_quality {
state.pos = original.clone();
after = before.clone();
mean_after = mean_before;
}
let after = region_quality(&state);
let end = state.sweep_numbers(Phase::Smooth);
let mut out = Optimized { before, after, mean_before: start.mean_quality, mean_after: end.mean_quality, history, kept, ..Default::default() };
let mut out = Morphed { before, after, mean_before, mean_after, rounds, ..Default::default() };
for (n, p) in &state.pos {
let Some(o) = original.get(n) else { continue };
let d = dist(*o, *p);
+42 -20
View File
@@ -351,23 +351,21 @@ fn distorted_annulus(n: usize, amount: f64) -> (Model, Model) {
(original, m)
}
/// Strong mode on a distorted block: the region optimised as a whole lifts the smallest and the
/// mean scaled Jacobian well above where they were; nothing inverts; nodes on faces stay on them
/// (the cylinders exactly); nodes on edges and vertices do not move; the same input gives the
/// same output.
/// Strong mode on a distorted block: rounds of bad nodes moved and the mesh morphed round them
/// lift the smallest scaled Jacobian well above where it was and the mean with it; nothing
/// inverts; nodes on faces stay on them (the cylinders exactly); nodes on edges and vertices do
/// not move; the same input gives the same output.
#[test]
fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
let (original, m) = distorted_annulus(6, 0.06);
let geo = Geo { model: original.clone() };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let r = optimize(&m, &nodes, 200, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}", r.before.min_quality, r.after.min_quality);
let options = MorphOptions { below: 0.9, reach: None, rounds: 40 };
let r = morph(&m, &nodes, options, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}: {:?}", r.before.min_quality, r.after.min_quality, r.rounds);
assert!(r.mean_after > r.mean_before, "mean {} → {}", r.mean_before, r.mean_after);
assert_eq!(r.after.invalid, 0);
assert!(r.kept > 0 && r.history.len() > 2);
// Within a phase the objective falls sweep by sweep (the descent keeps only lowering moves).
assert!(r.history.windows(2).filter(|w| w[0].phase == w[1].phase).all(|w| w[1].objective <= w[0].objective), "{:?}", r.history);
assert!(r.history.iter().any(|h| h.phase == Phase::MinMax), "{:?}", r.history);
assert!(!r.rounds.is_empty() && r.rounds.iter().all(|k| k.region_nodes >= k.bad_nodes && k.reach > 0.0), "{:?}", r.rounds);
let out = applied(&m, &Relaxed { positions: r.positions.clone(), ..Relaxed::default() });
let elements: Vec<u32> = m.elements().keys().copied().collect();
let (after, invalid) = region_min(&out, &elements);
@@ -379,11 +377,27 @@ fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
assert_eq!(p.xyz, m.node(*n).unwrap().xyz, "node {n} on an edge moved");
}
}
assert_eq!(optimize(&m, &nodes, 200, &geo).unwrap(), r, "not deterministic");
assert_eq!(morph(&m, &nodes, options, &geo).unwrap(), r, "not deterministic");
}
/// The morph is local: one bad spot moves only the nodes within the reach.
#[test]
fn strong_relaxation_moves_only_nodes_within_the_reach() {
let original = hex_annulus(8);
let mut m = original.clone();
perturb(&mut m, CENTRE, 0.2, 0.06);
let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let reach = 0.4;
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20 }, &geo).unwrap();
assert!(r.after.min_quality > r.before.min_quality, "{:?}", r.rounds);
for n in r.positions.keys() {
assert!(dist(m.node(*n).unwrap().xyz, CENTRE) < 0.2 + 2.0 * reach, "node {n} far from the bad spot moved");
}
}
/// Strong mode untangles: an interior node pushed through its neighbours inverts elements; the
/// optimisation brings every element back to valid.
/// morph brings every element back to valid.
#[test]
fn strong_relaxation_untangles_inverted_elements() {
let (original, mut m) = distorted_annulus(4, 0.0);
@@ -393,21 +407,29 @@ fn strong_relaxation_untangles_inverted_elements() {
m.set_node(inside, [p[0] + 0.4, p[1] + 0.4, p[2] + 0.3]).unwrap();
let geo = Geo { model: original };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let r = optimize(&m, &nodes, 100, &geo).unwrap();
let r = morph(&m, &nodes, MorphOptions::default(), &geo).unwrap();
assert!(r.before.invalid > 0, "{:?}", r.before);
assert_eq!(r.after.invalid, 0, "{:?}", r.after);
assert!(r.after.min_quality > 0.5, "{:?}", r.after);
}
/// Nothing better found leaves the mesh as it is; bad parameters are errors.
/// A good mesh is left as it is; bad options are errors.
#[test]
fn strong_relaxation_keeps_a_perfect_mesh_and_refuses_bad_sweeps() {
fn strong_relaxation_keeps_a_good_mesh_and_refuses_bad_options() {
let m = hex_annulus(3);
let geo = Geo { model: m.clone() };
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
let r = optimize(&m, &nodes, 20, &geo).unwrap();
assert!(r.after.min_quality >= r.before.min_quality && r.after.invalid == 0);
assert!(optimize(&m, &nodes, 0, &geo).is_err());
assert!(optimize(&m, &nodes, MAX_SWEEPS + 1, &geo).is_err());
assert!(optimize(&m, &BTreeSet::new(), 5, &geo).unwrap().positions.is_empty());
let r = morph(&m, &nodes, MorphOptions { below: 0.5, ..MorphOptions::default() }, &geo).unwrap();
assert!(r.positions.is_empty() && r.rounds.is_empty());
for bad in [
MorphOptions { below: 0.0, ..MorphOptions::default() },
MorphOptions { below: 1.5, ..MorphOptions::default() },
MorphOptions { reach: Some(-1.0), ..MorphOptions::default() },
MorphOptions { reach: Some(f64::NAN), ..MorphOptions::default() },
MorphOptions { rounds: 0, ..MorphOptions::default() },
MorphOptions { rounds: MAX_ROUNDS + 1, ..MorphOptions::default() },
] {
assert!(morph(&m, &nodes, bad, &geo).is_err(), "{bad:?}");
}
assert!(morph(&m, &BTreeSet::new(), MorphOptions::default(), &geo).unwrap().positions.is_empty());
}
+6 -5
View File
@@ -210,11 +210,12 @@ When `mesh.quality` shows a handful of spikes in an otherwise good mesh:
vertex nodes stay; nodes on a partition cut face move freely (both cells share them). The
reply gives the nodes moved, the largest displacement, and the region's smallest scaled
Jacobian, largest edge ratio and worst elements before and after. One undo step.
For a whole region at once, `mesh.node.relax {"mode": "strong", "bodies": ["Base"]}` (or
`"below": 0.7, "layers": 2` for the elements below 0.7 and two rings round them) optimises
every node together toward scaled Jacobian 1, edge and vertex nodes held, single elements
allowed to get worse on the way; it keeps the best state, never one with a lower smallest
scaled Jacobian, and replies the objective, min and mean per sweep (`history`).
For a whole region at once, `mesh.node.relax {"mode": "strong", "bodies": ["Base"], "below": 0.7}`
morphs the mesh round every element below 0.7: their nodes move to better places and the
nodes within `reach` (default three element lengths) follow by harmonic interpolation; edge
and vertex nodes are held, face nodes stay on their faces, nothing outside the reach moves,
and the smallest scaled Jacobian never drops. The reply lists each round's bad elements,
clusters and region size (`rounds`). It runs as a job (`job.status`, `job.cancel`).
2. The relaxation is kept as a field of the model (`field`, default `Relax1`, …; saved with it).
After a remesh, `mesh.node.relax.apply {"field": "Relax1"}` puts the relaxed shape on the new
mesh (each node takes the displacement interpolated in the old cell it lies in, on its face,
+11 -10
View File
@@ -970,16 +970,17 @@ input points), Steiner points kept half the local spacing from the boundary.
mesh takes the displacement at each node's parametric coordinates in the old cell holding it (barycentric or
trilinear shape functions), slides it onto its face or edge, undoes any move that would invert an element, and
smooths once (`mesh.node.relax.apply`, or `reapply` after every remesh).
- **Strong mode** (`"mode": "strong"`, `fieldcraft-mesh::relax::optimize`). An optimisation of every corner node
of the region at once: interior nodes in space, face nodes in their face's parameters, edge and vertex nodes held.
It minimises Σ φ(SJ) over the elements by nonlinear Gauss–Seidel (per node: the neighbours' centroid, then a
central-difference gradient step with backtracking, each kept when it lowers the node's part of the sum), first
with φ = (1/SJ − 1)², then with φ = SJ⁻¹⁶ (a smooth stand-in for the smallest SJ); an invalid element costs
far more than any valid one (a barrier, and an untangling objective while elements are inverted; P. Knupp,
*Hexahedral and tetrahedral mesh untangling*, Eng. Comput. 17, 2001; L. Freitag, P. Plassmann, IJNME 49, 2000).
A term ½ ln²(V/V₀) keeps element sizes, and no element may exceed the region's largest edge ratio before: the
scaled Jacobian alone lets elements flatten against the boundary. The best state passed through is kept, and
only if it is valid and its smallest SJ is not below the start.
- **Strong mode** (`"mode": "strong"`, `fieldcraft-mesh::relax::morph`). A mesh morph round the bad elements
(scaled Jacobian below `below`): their movable corner nodes are grouped into clusters (nodes within the reach of
each other, from a fixed-radius grid query); per cluster, the bad nodes first move to better places by a few
sweeps of their own optimisation with the rest held (a central-difference gradient step with backtracking on
Σ SJ⁻¹⁶ + ½ ln²(V/V₀) over their elements, an invalid element costing far more than any valid one), then that
displacement is carried to every node within the reach by harmonic interpolation: the graph Laplacian of the
element edges solved (Gauss–Seidel) with the bad nodes' displacements and zero at held nodes and beyond the reach.
Face nodes go back on their face (UV), edge and vertex nodes are held, nodes outside the reach do not move. A
cluster's morph is kept, at full or a halved scale, only when no more elements are invalid, the worst element it
touches is no worse, the smooth objective Σ (1/SJ − 1)² there drops, and no element gets longer than the longest
was; rounds repeat while any cluster is kept. The result never has a lower smallest SJ than the start.
---