engine, ui: mesh.node.relax and relaxation fields, Relax Nodes tool with sphere and preview (#398)

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-15 14:40:00 -04:00
1 parent 75f9e5ae6e
commit 94fdbf0fca
19 files changed
+1038 -9

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
use super::*;
/// The plate (40 × 30 × 10) with its Ø10 hole round (15, 15): a point on the hole's wall.
const ON_HOLE: [f64; 3] = [20.0, 15.0, 5.0];
const HOLE: ([f64; 2], f64) = ([15.0, 15.0], 5.0);
fn plate() -> Session {
super::super::datum::tests::plate().0
}
fn hole_radius(p: [f64; 3]) -> f64 {
((p[0] - HOLE.0[0]).powi(2) + (p[1] - HOLE.0[1]).powi(2)).sqrt()
}
/// Which analytic face a node of the plate lies on, if any.
fn on_faces(p: [f64; 3]) -> Vec<&'static str> {
let mut f = Vec::new();
if (hole_radius(p) - HOLE.1).abs() < 1e-6 {
f.push("hole");
}
if p[2].abs() < 1e-6 || (p[2] - 10.0).abs() < 1e-6 {
f.push("cap");
}
f
}
/// Spikes near the hole: interior nodes pushed about; nodes on the hole turned round it and
/// moved along its axis (still on the cylinder), nodes on the caps moved in their plane.
fn perturb(s: &mut Session, centre: [f64; 3], radius: f64, amount: f64) {
let nodes: Vec<(u32, [f64; 3])> = s.model.nodes().iter().map(|(n, p)| (*n, p.xyz)).collect();
for (k, (n, p)) in nodes.into_iter().enumerate() {
let d2: f64 = (0..3).map(|i| (p[i] - centre[i]).powi(2)).sum();
if d2 > radius * radius {
continue;
}
let wiggle = |m: usize| amount * ((k * m % 7) as f64 - 3.0) / 3.0;
let faces = on_faces(p);
let q = match faces.as_slice() {
[] => [p[0] + wiggle(3), p[1] + wiggle(5), p[2] + wiggle(11)],
["hole"] => {
let a = wiggle(3) / HOLE.1;
let (x, y) = (p[0] - HOLE.0[0], p[1] - HOLE.0[1]);
[HOLE.0[0] + x * a.cos() - y * a.sin(), HOLE.0[1] + x * a.sin() + y * a.cos(), p[2] + wiggle(5)]
}
["cap"] if hole_radius(p) > HOLE.1 + 1e-6 => [p[0] + wiggle(3), p[1] + wiggle(5), p[2]],
_ => continue,
};
s.model.set_node(n, q).unwrap();
}
}
fn check_on_geometry(before: &Model, after: &Model) {
for (n, p) in after.nodes() {
let old = before.node(*n).unwrap().xyz;
for f in on_faces(old) {
match f {
"hole" => assert!((hole_radius(p.xyz) - HOLE.1).abs() < 1e-6, "node {n} left the hole: r = {}", hole_radius(p.xyz)),
_ => assert!((p.xyz[2] - old[2]).abs() < 1e-6, "node {n} left its cap"),
}
}
}
}
fn invalid(s: &mut Session) -> u64 {
s.execute("mesh.quality", &json!({})).unwrap()["invalid"]["count"].as_u64().unwrap_or(0)
}
/// The issue's hex test on a design: spikes near the hole relax, every surface node stays on its
/// face (the hole's cylinder exactly), quality rises, nothing inverts, nothing outside the sphere
/// moves; one undo step; the field is kept and saved with the model.
#[test]
fn hex_spikes_near_a_hole_relax_on_the_geometry() {
let mut s = plate();
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
perturb(&mut s, ON_HOLE, 3.0, 0.35);
let perturbed = s.model.clone();
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 4.0, "iterations": 10})).unwrap();
let (before, after) = (r["before"]["min_scaled_jacobian"].as_f64().unwrap(), r["after"]["min_scaled_jacobian"].as_f64().unwrap());
assert!(after > before + 0.05, "{r}");
assert_eq!(r["after"]["invalid"], 0, "{r}");
assert!(r["moved"].as_u64().unwrap() > 0 && r["field"] == "Relax1", "{r}");
check_on_geometry(&perturbed, &s.model);
for (n, p) in s.model.nodes() {
let old = perturbed.node(*n).unwrap().xyz;
if old != p.xyz {
let d2: f64 = (0..3).map(|i| (old[i] - ON_HOLE[i]).powi(2)).sum();
assert!(d2 < 16.0, "node {n} outside the sphere moved");
}
}
assert_eq!(invalid(&mut s), 0);
// The field is saved with the model.
let text = serde_json::to_string(&s.model).unwrap();
let back: Model = serde_json::from_str(&text).unwrap();
assert_eq!(back.relaxation("Relax1"), s.model.relaxation("Relax1"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model, perturbed);
}
/// The same on tet10 with curved mid-side nodes on the hole: they stay on the cylinder.
#[test]
fn tet10_relaxes_with_curved_mid_side_nodes_on_the_hole() {
let mut s = plate();
s.execute("mesh.tet.generate", &json!({"size": 2.5, "order": "quadratic"})).unwrap();
perturb(&mut s, ON_HOLE, 3.0, 0.2);
// Put the mid-side nodes back on their edges' middles, on the hole where the edge is.
let mids: Vec<(u32, u32, u32)> = s
.model
.elements()
.values()
.flat_map(|e| e.kind.edges().iter().map(|ed| (e.nodes[ed[0]], e.nodes[ed[1]], e.nodes[ed[2]])).collect::<Vec<_>>())
.collect();
let original = s.model.clone();
for (a, b, m) in mids {
let (pa, pb, pm) = (original.node(a).unwrap().xyz, original.node(b).unwrap().xyz, original.node(m).unwrap().xyz);
let mut q = [(pa[0] + pb[0]) / 2.0, (pa[1] + pb[1]) / 2.0, (pa[2] + pb[2]) / 2.0];
if on_faces(pm).contains(&"hole") {
let r = hole_radius(q);
q = [HOLE.0[0] + (q[0] - HOLE.0[0]) * HOLE.1 / r, HOLE.0[1] + (q[1] - HOLE.0[1]) * HOLE.1 / r, q[2]];
}
s.model.set_node(m, q).unwrap();
}
let perturbed = s.model.clone();
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 4.0, "iterations": 8})).unwrap();
assert!(r["after"]["min_scaled_jacobian"].as_f64().unwrap() >= r["before"]["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
assert_eq!(r["after"]["invalid"], 0, "{r}");
check_on_geometry(&perturbed, &s.model);
}
/// A partition cut is not geometry: nodes on it move freely, shared by the cells on both sides.
#[test]
fn nodes_on_a_partition_cut_move_freely() {
let mut s = plate();
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 27}})).unwrap();
s.execute("geometry.partition", &json!({"name": "P", "datum": "Cut"})).unwrap();
s.execute("mesh.cells", &json!({"size": 2.0, "default": "hex"})).unwrap();
let centre = [27.0, 15.0, 5.0];
perturb(&mut s, centre, 2.5, 0.3);
let before = s.model.clone();
s.execute("mesh.node.relax", &json!({"center": centre, "radius": 3.5, "iterations": 10})).unwrap();
let moved_off = s
.model
.nodes()
.iter()
.filter(|(n, p)| {
let old = before.node(**n).unwrap().xyz;
(old[0] - 27.0).abs() < 1e-6 && on_faces(old).is_empty() && (p.xyz[0] - 27.0).abs() > 1e-6
})
.count();
assert!(moved_off > 0, "nodes on the cut are free");
assert_eq!(invalid(&mut s), 0);
}
/// A field marked `reapply` is applied again after a remesh; `mesh.node.relax.apply` applies it
/// on demand; around the worst element works.
#[test]
fn a_kept_field_is_applied_again_after_a_remesh() {
let mut s = plate();
s.execute("mesh.hex.generate", &json!({"size": 3.0})).unwrap();
perturb(&mut s, ON_HOLE, 4.0, 0.3);
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 5.0, "field": "Spike", "reapply": true})).unwrap();
assert_eq!(r["field"], "Spike");
let r = s.execute("mesh.hex.generate", &json!({"size": 1.5})).unwrap();
assert_eq!(r["relaxations_applied"][0]["field"], "Spike", "{r}");
assert_eq!(invalid(&mut s), 0);
let r = s.execute("mesh.node.relax.apply", &json!({"field": "Spike"})).unwrap();
assert!(r["summary"].as_str().unwrap().contains("Spike"));
let worst = s.execute("mesh.quality", &json!({})).unwrap()["metrics"]["scaled_jacobian"]["worst"].as_u64().unwrap();
let r = s.execute("mesh.node.relax", &json!({"around_element": worst})).unwrap();
assert!(r["after"]["min_scaled_jacobian"].as_f64() >= r["before"]["min_scaled_jacobian"].as_f64(), "{r}");
}
#[test]
fn hostile_parameters_never_panic() {
let mut s = plate();
s.execute("mesh.hex.generate", &json!({"size": 4.0})).unwrap();
for p in [
json!({}),
json!({"center": [0, 0, 0]}),
json!({"center": [1e300, 0, 0], "radius": 1}),
json!({"center": [0, 0, 0], "radius": -1}),
json!({"center": [0, 0, 0], "radius": 1, "nodes": [1]}),
json!({"nodes": [1], "strength": 2}),
json!({"nodes": [1], "iterations": 0}),
json!({"nodes": [1], "iterations": 1000}),
json!({"nodes": [1], "falloff": "cubic"}),
json!({"around_element": 99999999}),
json!({"set": "nope"}),
json!({"nodes": [1], "bodies": ["nope"]}),
] {
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
}
assert!(s.execute("mesh.node.relax.apply", &json!({"field": "nope"})).is_err());
assert!(s.execute("mesh.node.relax.apply", &json!({})).is_err());
}