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 <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-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}");
}