forked from 1b-agent/fieldcraft-upstream
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:
1 parent
6f94346253
commit
4908f5679c
8 files changed
+430
-253
No files matched your search
@@ -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"),
|
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"),
|
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)")])
|
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\""),
|
.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"),
|
range("below", "Below", D::None, 0.0, 1.0).help("Strong: the elements below this scaled Jacobian are moved.").hint("0.7"),
|
||||||
int("layers", "Layers", 0, 20).help("Strong: rings of elements added around the elements below the threshold.").def("2"),
|
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, 2000).help("Sweeps over the nodes (guarded: 1 to 100, default 5; strong: default 200).").hint("5 (strong: 200)"),
|
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"),
|
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")])
|
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.")
|
.help("How the strength falls from the centre to the radius, so the edit blends into the mesh.")
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
|||||||
pub const RELAX: CommandSpec = CommandSpec {
|
pub const RELAX: CommandSpec = CommandSpec {
|
||||||
id: "mesh.node.relax",
|
id: "mesh.node.relax",
|
||||||
label: "Relax Nodes",
|
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),
|
form: Some(super::forms::NODE_RELAX),
|
||||||
kind: CommandKind::Model,
|
kind: CommandKind::Model,
|
||||||
requires: &[Requirement::Mesh],
|
requires: &[Requirement::Mesh],
|
||||||
@@ -486,6 +486,10 @@ pub struct Preview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn preview(s: &Session, params: &Value) -> Option<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 (w, sphere) = weights(&s.model, params).ok()?;
|
||||||
let g = design_geometry(s, &touched(&s.model, &w)).ok()?;
|
let g = design_geometry(s, &touched(&s.model, &w)).ok()?;
|
||||||
let relaxed = relax::relax(&s.model, &w, iterations(params).ok()?, &g).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))
|
Some((c, r))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Elements below this scaled Jacobian, with `layers` rings of elements around them: their
|
/// Default rounds of the strong mode.
|
||||||
/// nodes (only of `only` when given).
|
const STRONG_ROUNDS: usize = 20;
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The most layers `layers` may add.
|
/// `mesh.node.relax` with `"mode": "strong"`: morph the region round its bad elements
|
||||||
const MAX_LAYERS: usize = 20;
|
/// ([`relax::morph`]).
|
||||||
/// Default sweeps of the strong mode.
|
|
||||||
const STRONG_SWEEPS: usize = 200;
|
|
||||||
|
|
||||||
/// `mesh.node.relax` with `"mode": "strong"`: optimise the region globally ([`relax::optimize`]).
|
|
||||||
fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
|
fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let only = body_nodes(&s.model, p)?;
|
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,
|
None => None,
|
||||||
Some(_) => {
|
Some(_) => Some(super::optional_number(p, "reach", 0.0)?)
|
||||||
let b = super::optional_number(p, "below", 0.0)?;
|
.filter(|r| *r > 0.0 && r.is_finite())
|
||||||
if !(b > 0.0 && b <= 1.0) {
|
.ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive))
|
||||||
return Err(EngineError::param("below", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
|
.map(Some)?,
|
||||||
}
|
|
||||||
Some(b)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let layers = match p.get("layers") {
|
let rounds = match p.get("iterations") {
|
||||||
None => 2,
|
None => STRONG_ROUNDS,
|
||||||
Some(v) => v
|
Some(v) => v
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.and_then(|l| usize::try_from(l).ok())
|
.and_then(|i| usize::try_from(i).ok())
|
||||||
.filter(|l| *l <= MAX_LAYERS)
|
.filter(|i| (1..=relax::MAX_ROUNDS).contains(i))
|
||||||
.ok_or_else(|| EngineError::param("layers", ProblemKind::NotInRange { min: 0.0, max: MAX_LAYERS as f64 }))?,
|
.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 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()
|
weights(&s.model, p)?.0.into_keys().collect()
|
||||||
} else if let Some((_, n)) = &only {
|
} else if let Some((_, n)) = &only {
|
||||||
n.clone()
|
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 {
|
} else {
|
||||||
s.model.nodes().keys().copied().collect()
|
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();
|
let nodes: BTreeSet<u32> = relax::uniform_weights(&s.model, &nodes, 1.0)?.into_keys().collect();
|
||||||
if nodes.is_empty() {
|
if nodes.is_empty() {
|
||||||
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
|
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 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")? {
|
let field_name = match super::opt::<String>(p, "field")? {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
|
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 bodies = only.map(|(b, _)| b).unwrap_or_default();
|
||||||
let reapply = super::opt::<bool>(p, "reapply")?.unwrap_or(false);
|
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() {
|
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 })?;
|
s.model.set_relaxation(&field_name, Relaxation { bodies, cells, reapply })?;
|
||||||
}
|
}
|
||||||
for (n, x) in &r.positions {
|
for (n, x) in &r.positions {
|
||||||
s.model.set_node(*n, *x)?;
|
s.model.set_node(*n, *x)?;
|
||||||
}
|
}
|
||||||
let summary = format!(
|
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,
|
r.moved,
|
||||||
if r.moved == 1 { "" } else { "s" },
|
if r.moved == 1 { "" } else { "s" },
|
||||||
r.max_displacement,
|
r.max_displacement,
|
||||||
r.history.len().saturating_sub(1),
|
r.rounds.len(),
|
||||||
if r.history.len() == 2 { "" } else { "s" },
|
if r.rounds.len() == 1 { "" } else { "s" },
|
||||||
r.before.min_quality,
|
r.before.min_quality,
|
||||||
r.after.min_quality,
|
r.after.min_quality,
|
||||||
r.mean_before,
|
r.mean_before,
|
||||||
r.mean_after,
|
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
|
let rounds: Vec<Value> = r
|
||||||
.history
|
.rounds
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let mut before = quality_json(&r.before);
|
let mut before = quality_json(&r.before);
|
||||||
let mut after = quality_json(&r.after);
|
let mut after = quality_json(&r.after);
|
||||||
@@ -630,9 +593,8 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
"moved": r.moved,
|
"moved": r.moved,
|
||||||
"nodes_changed": r.positions.len(),
|
"nodes_changed": r.positions.len(),
|
||||||
"max_displacement": r.max_displacement,
|
"max_displacement": r.max_displacement,
|
||||||
"iterations": r.history.len().saturating_sub(1),
|
"iterations": r.rounds.len(),
|
||||||
"kept_sweep": r.kept,
|
"rounds": rounds,
|
||||||
"history": history,
|
|
||||||
"before": before,
|
"before": before,
|
||||||
"after": after,
|
"after": after,
|
||||||
"field": (!r.positions.is_empty()).then_some(field_name),
|
"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("strong") => return relax_strong(s, p),
|
||||||
Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
|
Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
|
||||||
}
|
}
|
||||||
if p.get("below").is_some() || p.get("layers").is_some() {
|
if p.get("below").is_some() || p.get("reach").is_some() {
|
||||||
return Err(EngineError::Params("`below` and `layers` go with \"mode\": \"strong\"".into()));
|
return Err(EngineError::Params("`below` and `reach` go with \"mode\": \"strong\"".into()));
|
||||||
}
|
}
|
||||||
let (w, sphere) = weights(&s.model, p)?;
|
let (w, sphere) = weights(&s.model, p)?;
|
||||||
if w.is_empty() {
|
if w.is_empty() {
|
||||||
|
|||||||
@@ -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
|
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
|
/// 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
|
/// 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]
|
#[test]
|
||||||
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
|
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
|
||||||
let (_d, mut s, _) = filleted_box();
|
let (_d, mut s, _) = filleted_box();
|
||||||
let before = s.model.clone();
|
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"]);
|
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!(a["mean_scaled_jacobian"].as_f64().unwrap() > b["mean_scaled_jacobian"].as_f64().unwrap(), "{r}");
|
||||||
assert_eq!(a["invalid"], 0, "{r}");
|
assert_eq!(a["invalid"], 0, "{r}");
|
||||||
assert_eq!(invalid(&mut s), 0);
|
assert_eq!(invalid(&mut s), 0);
|
||||||
let history = r["history"].as_array().unwrap();
|
let rounds = r["rounds"].as_array().unwrap();
|
||||||
assert!(history.len() > 2 && history.iter().all(|h| h["objective"].is_number() && h["min"].is_number() && h["mean"].is_number()), "{r}");
|
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() {
|
for (n, p) in s.model.nodes() {
|
||||||
let (old, new) = (before.node(*n).unwrap().xyz, p.xyz);
|
let (old, new) = (before.node(*n).unwrap().xyz, p.xyz);
|
||||||
if on_box_edge(old) {
|
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
|
/// 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
|
/// 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
|
||||||
/// neither moves) (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
|
||||||
/// them); by quality threshold (the elements below 0.5 and two layers round them) it untangles
|
|
||||||
/// every element. Nothing inverts, nodes on the hole stay on its cylinder and the caps' on their
|
/// 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.
|
/// planes, nodes on the hole's edges do not move.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -316,13 +316,12 @@ fn strong_relaxation_untangles_a_distorted_plate() {
|
|||||||
let sphere = json!({"center": ON_HOLE, "radius": 7.0, "iterations": 20});
|
let sphere = json!({"center": ON_HOLE, "radius": 7.0, "iterations": 20});
|
||||||
let guarded = s.execute("mesh.node.relax", &sphere).unwrap();
|
let guarded = s.execute("mesh.node.relax", &sphere).unwrap();
|
||||||
let min = |r: &Value, at: &str| r[at]["min_scaled_jacobian"].as_f64().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();
|
s.model = perturbed.clone();
|
||||||
let r = s.execute("mesh.node.relax", &strong).unwrap();
|
let r = s.execute("mesh.node.relax", &strong).unwrap();
|
||||||
assert!(min(&r, "after") > 0.5 && min(&r, "before") < 0.0, "{strong}: {r}");
|
assert!(min(&r, "after") > strong["below"].as_f64().unwrap_or(0.7) && min(&r, "before") < 0.0, "{strong}: {r}");
|
||||||
if strong.get("center").is_some() {
|
// Close to the guarded mode, which also slides the hole's edge nodes.
|
||||||
assert!(min(&r, "after") >= min(&guarded, "after") - 1e-9, "strong {r}\nguarded {guarded}");
|
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!(r["after"]["invalid"], 0, "{r}");
|
||||||
assert_eq!(invalid(&mut s), 0);
|
assert_eq!(invalid(&mut s), 0);
|
||||||
check_on_geometry(&perturbed, &s.model);
|
check_on_geometry(&perturbed, &s.model);
|
||||||
@@ -340,20 +339,21 @@ fn strong_mode_parameters_are_checked() {
|
|||||||
let mut s = plate();
|
let mut s = plate();
|
||||||
s.execute("mesh.hex.generate", &json!({"size": 4.0})).unwrap();
|
s.execute("mesh.hex.generate", &json!({"size": 4.0})).unwrap();
|
||||||
for p in [
|
for p in [
|
||||||
json!({"mode": "strong"}),
|
|
||||||
json!({"mode": "wild", "nodes": [1]}),
|
json!({"mode": "wild", "nodes": [1]}),
|
||||||
json!({"mode": "strong", "below": 0}),
|
json!({"mode": "strong", "below": 0}),
|
||||||
json!({"mode": "strong", "below": 1.5}),
|
json!({"mode": "strong", "below": 1.5}),
|
||||||
json!({"mode": "strong", "below": 0.5, "layers": 21}),
|
json!({"mode": "strong", "below": 0.5, "reach": 0}),
|
||||||
json!({"mode": "strong", "below": 0.5, "layers": -1}),
|
json!({"mode": "strong", "below": 0.5, "reach": -1}),
|
||||||
json!({"mode": "strong", "bodies": ["nope"]}),
|
json!({"mode": "strong", "bodies": ["nope"]}),
|
||||||
json!({"mode": "strong", "nodes": [1], "iterations": 0}),
|
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!({"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}");
|
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
|
||||||
}
|
}
|
||||||
// An undistorted mesh above the threshold: nothing to relax.
|
// Nothing below the threshold: nothing moves.
|
||||||
assert!(s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.01})).is_err());
|
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.01})).unwrap();
|
||||||
|
assert_eq!(r["moved"], 0, "{r}");
|
||||||
}
|
}
|
||||||
@@ -608,6 +608,9 @@ pub fn relax(model: &Model, weights: &BTreeMap<u32, f64>, iterations: usize, geo
|
|||||||
let before = region_quality(&state);
|
let before = region_quality(&state);
|
||||||
let mut done = 0;
|
let mut done = 0;
|
||||||
for _ in 0..iterations {
|
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;
|
done += 1;
|
||||||
let mut any = false;
|
let mut any = false;
|
||||||
for (&n, &w) in weights {
|
for (&n, &w) in weights {
|
||||||
@@ -926,7 +929,7 @@ pub fn apply_field(
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod strong;
|
mod strong;
|
||||||
pub use strong::{MAX_SWEEPS, Optimized, Phase, Sweep, optimize};
|
pub use strong::{MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
+298
-110
@@ -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 super::{Constraints, Elem, Freedom, P, RegionQuality, State, add, dist, dot, measure, region_quality, scale, sub};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
/// Most sweeps of a strong relaxation.
|
|
||||||
pub const MAX_SWEEPS: usize = 2000;
|
|
||||||
/// Weight of an invalid element in the objective.
|
/// Weight of an invalid element in the objective.
|
||||||
const INVALID: f64 = 1e8;
|
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.
|
/// Weight of the size term.
|
||||||
const SIZE_WEIGHT: f64 = 0.5;
|
const SIZE_WEIGHT: f64 = 0.5;
|
||||||
@@ -87,7 +54,7 @@ const MIN_MAX_POWER: i32 = 16;
|
|||||||
|
|
||||||
/// What a phase minimises.
|
/// What a phase minimises.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum Phase {
|
enum Phase {
|
||||||
/// `(1/q − 1)²`: every element toward 1, the worst weighted most.
|
/// `(1/q − 1)²`: every element toward 1, the worst weighted most.
|
||||||
#[default]
|
#[default]
|
||||||
Smooth,
|
Smooth,
|
||||||
@@ -264,38 +231,261 @@ impl State<'_> {
|
|||||||
*step = (t * 2.0).max(1e-6);
|
*step = (t * 2.0).max(1e-6);
|
||||||
moved
|
moved
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sweep_numbers(&self, phase: Phase) -> Sweep {
|
/// What [`morph`] does.
|
||||||
let mut s = Sweep { phase, min_quality: f64::INFINITY, ..Sweep::default() };
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
for e in &self.elems {
|
pub struct MorphOptions {
|
||||||
let (f, q, valid) = term_of(phase, e, self, true);
|
/// Elements below this quality are the bad ones.
|
||||||
s.objective += f;
|
pub below: f64,
|
||||||
s.min_quality = s.min_quality.min(q);
|
/// The radius round the bad nodes whose nodes follow them; `None`: three times the bad
|
||||||
s.mean_quality += q;
|
/// elements' mean edge length.
|
||||||
s.invalid += usize::from(!valid);
|
pub reach: Option<f64>,
|
||||||
}
|
/// Most rounds (bad nodes moved, the region morphed after them).
|
||||||
if self.elems.is_empty() {
|
pub rounds: usize,
|
||||||
s.min_quality = 0.0;
|
}
|
||||||
} else {
|
|
||||||
s.mean_quality /= self.elems.len() as f64;
|
impl Default for MorphOptions {
|
||||||
}
|
fn default() -> Self {
|
||||||
s
|
MorphOptions { below: 0.7, reach: None, rounds: 20 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `a` is a better state than `b`: fewer invalid elements, then a higher smallest
|
/// One round of [`morph`].
|
||||||
/// quality, then a higher mean.
|
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||||
fn better(a: &Sweep, b: &Sweep) -> bool {
|
pub struct Round {
|
||||||
a.invalid < b.invalid
|
/// Elements below the threshold, and their movable corner nodes (the ones moved first).
|
||||||
|| (a.invalid == b.invalid && (a.min_quality > b.min_quality || (a.min_quality == b.min_quality && a.mean_quality > b.mean_quality)))
|
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`
|
/// What [`morph`] did.
|
||||||
/// sweeps, faces' nodes sliding on them and edges' and vertices' nodes held, as `geometry` says.
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
/// The model is not changed: the result has the new positions.
|
pub struct Morphed {
|
||||||
pub fn optimize(model: &Model, nodes: &BTreeSet<u32>, sweeps: usize, geometry: &dyn Constraints) -> Result<Optimized> {
|
/// The new position of every node that moved.
|
||||||
if sweeps == 0 || sweeps > MAX_SWEEPS {
|
pub positions: BTreeMap<u32, P>,
|
||||||
return Err(Error::Invalid(format!("sweeps must be 1 to {MAX_SWEEPS}, got {sweeps}")));
|
/// 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, ¢res, 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 {
|
if nodes.len() > super::MAX_NODES {
|
||||||
return Err(Error::Limit(format!("more than {} nodes to relax", 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 mut state = State::new(model, nodes, geometry, true)?;
|
||||||
let original = state.pos.clone();
|
let original = state.pos.clone();
|
||||||
let before = region_quality(&state);
|
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);
|
state.ratio_cap = before.max_edge_ratio.max(1.0);
|
||||||
let start = state.sweep_numbers(Phase::Smooth);
|
let (_, _, mean_before) = state.qualities();
|
||||||
let mut history = vec![start];
|
let movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed;
|
||||||
let (mut best, mut best_pos, mut kept) = (start, None::<BTreeMap<u32, P>>, 0);
|
let mut rounds = Vec::new();
|
||||||
let movers: Vec<u32> = nodes.iter().copied().filter(|n| state.around.contains_key(n) && state.freedom(*n) != Freedom::Fixed).collect();
|
for round in 0..options.rounds {
|
||||||
let mut steps: BTreeMap<u32, f64> = movers.iter().map(|&n| (n, 0.1)).collect();
|
fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds");
|
||||||
// Phase 1 has up to half the sweeps (the rest when it stops early); phase 2 the rest. Each
|
fieldcraft_model::progress::count(round as f64);
|
||||||
// sweep visits the nodes whose neighbourhood changed in the previous one.
|
fieldcraft_model::progress::check()?;
|
||||||
let mut k = 0;
|
let (q, _, _) = state.qualities();
|
||||||
for (phase, budget) in [(Phase::Smooth, sweeps.div_ceil(2)), (Phase::MinMax, sweeps)] {
|
let bad_elems: Vec<usize> = (0..q.len()).filter(|&i| q.get(i).is_some_and(|v| *v < options.below)).collect();
|
||||||
let mut active: BTreeSet<u32> = movers.iter().copied().collect();
|
let mut bad: BTreeSet<u32> = BTreeSet::new();
|
||||||
let mut last = state.sweep_numbers(phase).objective;
|
let mut edge_sum = (0.0, 0usize);
|
||||||
while k < budget && !active.is_empty() {
|
for &i in &bad_elems {
|
||||||
k += 1;
|
let Some(e) = state.elems.get(i) else { continue };
|
||||||
let mut next = BTreeSet::new();
|
bad.extend(e.nodes.iter().take(e.kind.corner_count()).copied().filter(|&n| movable(&state, n)));
|
||||||
for &n in &movers {
|
let x = state.coords(e);
|
||||||
if !active.contains(&n) {
|
for edge in e.kind.edges() {
|
||||||
continue;
|
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 bad.is_empty() {
|
||||||
if let Some(p) = &best_pos {
|
break;
|
||||||
state.pos = p.clone();
|
}
|
||||||
|
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;
|
let mut after = region_quality(&state);
|
||||||
match best_pos {
|
let (_, _, mut mean_after) = state.qualities();
|
||||||
Some(p) if accept => state.pos = p,
|
// Never worse than the start.
|
||||||
_ => {
|
if after.invalid > before.invalid || after.min_quality < before.min_quality {
|
||||||
state.pos = original.clone();
|
state.pos = original.clone();
|
||||||
kept = 0;
|
after = before.clone();
|
||||||
}
|
mean_after = mean_before;
|
||||||
}
|
}
|
||||||
let after = region_quality(&state);
|
let mut out = Morphed { before, after, mean_before, mean_after, rounds, ..Default::default() };
|
||||||
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() };
|
|
||||||
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 d = dist(*o, *p);
|
let d = dist(*o, *p);
|
||||||
|
|||||||
@@ -351,23 +351,21 @@ fn distorted_annulus(n: usize, amount: f64) -> (Model, Model) {
|
|||||||
(original, m)
|
(original, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strong mode on a distorted block: the region optimised as a whole lifts the smallest and the
|
/// Strong mode on a distorted block: rounds of bad nodes moved and the mesh morphed round them
|
||||||
/// mean scaled Jacobian well above where they were; nothing inverts; nodes on faces stay on them
|
/// lift the smallest scaled Jacobian well above where it was and the mean with it; nothing
|
||||||
/// (the cylinders exactly); nodes on edges and vertices do not move; the same input gives the
|
/// inverts; nodes on faces stay on them (the cylinders exactly); nodes on edges and vertices do
|
||||||
/// same output.
|
/// not move; the same input gives the same output.
|
||||||
#[test]
|
#[test]
|
||||||
fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
|
fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
|
||||||
let (original, m) = distorted_annulus(6, 0.06);
|
let (original, m) = distorted_annulus(6, 0.06);
|
||||||
let geo = Geo { model: original.clone() };
|
let geo = Geo { model: original.clone() };
|
||||||
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
||||||
let r = optimize(&m, &nodes, 200, &geo).unwrap();
|
let options = MorphOptions { below: 0.9, reach: None, rounds: 40 };
|
||||||
assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}", r.before.min_quality, r.after.min_quality);
|
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!(r.mean_after > r.mean_before, "mean {} → {}", r.mean_before, r.mean_after);
|
||||||
assert_eq!(r.after.invalid, 0);
|
assert_eq!(r.after.invalid, 0);
|
||||||
assert!(r.kept > 0 && r.history.len() > 2);
|
assert!(!r.rounds.is_empty() && r.rounds.iter().all(|k| k.region_nodes >= k.bad_nodes && k.reach > 0.0), "{:?}", r.rounds);
|
||||||
// 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);
|
|
||||||
let out = applied(&m, &Relaxed { positions: r.positions.clone(), ..Relaxed::default() });
|
let out = applied(&m, &Relaxed { positions: r.positions.clone(), ..Relaxed::default() });
|
||||||
let elements: Vec<u32> = m.elements().keys().copied().collect();
|
let elements: Vec<u32> = m.elements().keys().copied().collect();
|
||||||
let (after, invalid) = region_min(&out, &elements);
|
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!(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
|
/// 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]
|
#[test]
|
||||||
fn strong_relaxation_untangles_inverted_elements() {
|
fn strong_relaxation_untangles_inverted_elements() {
|
||||||
let (original, mut m) = distorted_annulus(4, 0.0);
|
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();
|
m.set_node(inside, [p[0] + 0.4, p[1] + 0.4, p[2] + 0.3]).unwrap();
|
||||||
let geo = Geo { model: original };
|
let geo = Geo { model: original };
|
||||||
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
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!(r.before.invalid > 0, "{:?}", r.before);
|
||||||
assert_eq!(r.after.invalid, 0, "{:?}", r.after);
|
assert_eq!(r.after.invalid, 0, "{:?}", r.after);
|
||||||
assert!(r.after.min_quality > 0.5, "{:?}", 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]
|
#[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 m = hex_annulus(3);
|
||||||
let geo = Geo { model: m.clone() };
|
let geo = Geo { model: m.clone() };
|
||||||
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
||||||
let r = optimize(&m, &nodes, 20, &geo).unwrap();
|
let r = morph(&m, &nodes, MorphOptions { below: 0.5, ..MorphOptions::default() }, &geo).unwrap();
|
||||||
assert!(r.after.min_quality >= r.before.min_quality && r.after.invalid == 0);
|
assert!(r.positions.is_empty() && r.rounds.is_empty());
|
||||||
assert!(optimize(&m, &nodes, 0, &geo).is_err());
|
for bad in [
|
||||||
assert!(optimize(&m, &nodes, MAX_SWEEPS + 1, &geo).is_err());
|
MorphOptions { below: 0.0, ..MorphOptions::default() },
|
||||||
assert!(optimize(&m, &BTreeSet::new(), 5, &geo).unwrap().positions.is_empty());
|
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
@@ -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
|
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
|
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.
|
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
|
For a whole region at once, `mesh.node.relax {"mode": "strong", "bodies": ["Base"], "below": 0.7}`
|
||||||
`"below": 0.7, "layers": 2` for the elements below 0.7 and two rings round them) optimises
|
morphs the mesh round every element below 0.7: their nodes move to better places and the
|
||||||
every node together toward scaled Jacobian 1, edge and vertex nodes held, single elements
|
nodes within `reach` (default three element lengths) follow by harmonic interpolation; edge
|
||||||
allowed to get worse on the way; it keeps the best state, never one with a lower smallest
|
and vertex nodes are held, face nodes stay on their faces, nothing outside the reach moves,
|
||||||
scaled Jacobian, and replies the objective, min and mean per sweep (`history`).
|
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).
|
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
|
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,
|
mesh (each node takes the displacement interpolated in the old cell it lies in, on its face,
|
||||||
|
|||||||
+11
-10
@@ -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
|
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
|
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).
|
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
|
- **Strong mode** (`"mode": "strong"`, `fieldcraft-mesh::relax::morph`). A mesh morph round the bad elements
|
||||||
of the region at once: interior nodes in space, face nodes in their face's parameters, edge and vertex nodes held.
|
(scaled Jacobian below `below`): their movable corner nodes are grouped into clusters (nodes within the reach of
|
||||||
It minimises Σ φ(SJ) over the elements by nonlinear Gauss–Seidel (per node: the neighbours' centroid, then a
|
each other, from a fixed-radius grid query); per cluster, the bad nodes first move to better places by a few
|
||||||
central-difference gradient step with backtracking, each kept when it lowers the node's part of the sum), first
|
sweeps of their own optimisation with the rest held (a central-difference gradient step with backtracking on
|
||||||
with φ = (1/SJ − 1)², then with φ = SJ⁻¹⁶ (a smooth stand-in for the smallest SJ); an invalid element costs
|
Σ SJ⁻¹⁶ + ½ ln²(V/V₀) over their elements, an invalid element costing far more than any valid one), then that
|
||||||
far more than any valid one (a barrier, and an untangling objective while elements are inverted; P. Knupp,
|
displacement is carried to every node within the reach by harmonic interpolation: the graph Laplacian of the
|
||||||
*Hexahedral and tetrahedral mesh untangling*, Eng. Comput. 17, 2001; L. Freitag, P. Plassmann, IJNME 49, 2000).
|
element edges solved (Gauss–Seidel) with the bad nodes' displacements and zero at held nodes and beyond the reach.
|
||||||
A term ½ ln²(V/V₀) keeps element sizes, and no element may exceed the region's largest edge ratio before: the
|
Face nodes go back on their face (UV), edge and vertex nodes are held, nodes outside the reach do not move. A
|
||||||
scaled Jacobian alone lets elements flatten against the boundary. The best state passed through is kept, and
|
cluster's morph is kept, at full or a halved scale, only when no more elements are invalid, the worst element it
|
||||||
only if it is valid and its smallest SJ is not below the start.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user