diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 24c62a8..f44c2d3 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -88,6 +88,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.layers.svg, assets/icons/mesh.layers.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.layers.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.merge.svg, assets/icons/mesh.node.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+| assets/icons/mesh.node.relax.svg, assets/icons/mesh.node.relax.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.relax.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
diff --git a/assets/icons/mesh.node.relax.svg b/assets/icons/mesh.node.relax.svg
new file mode 100644
index 0000000..f95c15f
--- /dev/null
+++ b/assets/icons/mesh.node.relax.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/assets/icons/mesh.node.relax.vectorcraft b/assets/icons/mesh.node.relax.vectorcraft
new file mode 100644
index 0000000..7261516
Binary files /dev/null and b/assets/icons/mesh.node.relax.vectorcraft differ
diff --git a/assets/icons/src/mesh.node.relax.json b/assets/icons/src/mesh.node.relax.json
new file mode 100644
index 0000000..4fdd68e
--- /dev/null
+++ b/assets/icons/src/mesh.node.relax.json
@@ -0,0 +1 @@
+{"shapes": [{"circle": [12, 12, 9], "stroke": "line", "width": 1.25, "dash": [2, 2]}, {"d": "M5 12 L19 12 M12 5 L12 19", "stroke": "line", "width": 1.0}, {"d": "M7.5 8 L16.5 8 M7.5 16 L16.5 16 M8 7.5 L8 16.5 M16 7.5 L16 16.5", "stroke": "line", "width": 1.0}, {"circle": [12, 12, 2.4], "fill": "accent", "stroke": "accent"}]}
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index 5ca0c50..de65303 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -365,6 +365,14 @@ pub static CATALOGUE: &[Capability] = &[
"Merge coincident nodes, keeping sets, loads and boundary conditions on the survivors; check for duplicates and cracks, renumber",
&["mesh.check.duplicates", "mesh.renumber"],
),
+ ready_more(
+ "mesh.node.relax",
+ "Relax Nodes",
+ Mesh,
+ "Improve",
+ "Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh",
+ &["mesh.node.relax.apply"],
+ ),
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index 3098d7a..beb3c57 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1264,6 +1264,29 @@ pub const NODE_MERGE: &[Field] = &[
pub const CHECK_DUPLICATES: &[Field] = &[MERGE_TOLERANCE];
+pub const NODE_RELAX: &[Field] = &[
+ xyz("center", "Centre", D::Length).help("The centre of the sphere of influence; click the mesh to place it.").hint("click the mesh"),
+ positive("radius", "Radius", D::Length).help("Nodes closer to the centre than this move; Alt + scroll over the view changes it."),
+ int("around_element", "Around element", 1, 99_999_999)
+ .help("Instead of a centre: a sphere round this element (radius default twice its longest edge).")
+ .hint("none"),
+ labels("nodes", "Nodes", LabelOf::Node).help("Instead of a sphere: these nodes.").hint("none"),
+ references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("every body"),
+ int("iterations", "Iterations", 1, 100).help("Sweeps over the nodes.").def("5"),
+ 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.")
+ .def("\"smooth\""),
+ text("field", "Keep as").help("The relaxation field to keep, to apply again after a remesh.").hint("Relax1, Relax2, …"),
+ flag("reapply", "Reapply after remesh").help("Apply the field again by itself after each remesh.").def("false"),
+];
+
+pub const NODE_RELAX_APPLY: &[Field] = &[
+ text("field", "Field").req().help("The relaxation field kept by Relax Nodes."),
+ references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("the field's bodies"),
+ range("polish", "Polish", D::None, 0.0, 1.0).help("Strength of one guarded smoothing sweep after the field is applied; 0 for none.").def("0.3"),
+];
+
pub const RENUMBER: &[Field] = &[
choice("what", "Renumber", &[("both", "Nodes and elements"), ("nodes", "Nodes"), ("elements", "Elements")])
.help("Which labels change.")
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 64d3c02..c73d471 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -18,10 +18,12 @@ pub use datum::edit_params as datum_edit_params;
pub use datum::preview as datum_preview;
pub use mesh::analytic_surfaces;
pub use nodes::merge_preview as node_merge_preview;
+pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere};
mod mirror;
mod model;
pub(crate) mod nodes;
pub(crate) mod octree;
+pub(crate) mod relax;
pub mod replace;
mod results;
pub(crate) mod seeds;
@@ -86,6 +88,8 @@ pub static ALL: &[CommandSpec] = &[
nodes::MERGE,
nodes::DUPLICATES,
nodes::RENUMBER,
+ relax::RELAX,
+ relax::APPLY,
tags::TAG,
tags::TAG_CLEAR,
model::UNITS,
diff --git a/crates/engine/src/cmd/relax.rs b/crates/engine/src/cmd/relax.rs
new file mode 100644
index 0000000..cc65039
--- /dev/null
+++ b/crates/engine/src/cmd/relax.rs
@@ -0,0 +1,540 @@
+//! Node relaxation (#398): `mesh.node.relax` moves the nodes inside a sphere of influence (or
+//! listed nodes, elements or a set) to fix the worst elements there, nodes on the geometry
+//! staying on it ([`fieldcraft_mesh::relax`]), and keeps what it did as a relaxation field of the
+//! model; `mesh.node.relax.apply` does a field again on the current mesh (after a remesh), and a
+//! field marked `reapply` is applied by itself after every remesh of its bodies.
+//!
+//! **Where a node may go** comes from the mesh's geometry tags: a node on one B-rep face slides
+//! in that face's parameters, a node where two faces meet slides along their edge, a node on a
+//! geometry vertex or on three or more faces stays. Faces of the same surface (the two halves a
+//! kernel splits a cylinder into, or one face as cut into cells) count once. Partition cut faces
+//! (`partition::a` and `:b`) are not geometry: a node on one moves freely, and the cells on
+//! both sides share it, so the mesh stays conforming across the cut. A node on the skin whose
+//! geometry is unknown (an imported mesh) stays.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use fieldcraft_mesh::relax::{self, Constraints, Falloff, Freedom, RegionQuality, Relaxed};
+use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
+use fieldcraft_mesh::tet::geometry::{AnalyticSurface, Tessellation};
+use fieldcraft_model::geometry::Entity;
+use fieldcraft_model::{Model, Relaxation};
+use serde_json::{Value, json};
+
+use crate::requirement::Requirement;
+use crate::schema::ProblemKind;
+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) | "nodes": [labels] | "elements": [labels] | "set": "Name", "bodies"?: ["Body1"] (only their nodes), "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)} — 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). 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, and the field kept. One undo step"#,
+ form: Some(super::forms::NODE_RELAX),
+ kind: CommandKind::Model,
+ requires: &[Requirement::Mesh],
+ run: relax_nodes,
+};
+
+pub const APPLY: CommandSpec = CommandSpec {
+ id: "mesh.node.relax.apply",
+ label: "Apply Relaxation",
+ params: r#"{"field": "Name", "bodies"?: ["Body1"] (default the field's), "polish"?: 0.3 (strength of one guarded smoothing sweep after; 0 for none)} — applies a relaxation field kept by mesh.node.relax to the current mesh (typically after a remesh): each node inside the relaxed region takes the displacement interpolated from the field at its parametric coordinates in the old cells, on its face or edge as mesh.node.relax moves it; a move that would invert an element is undone; then one guarded smoothing sweep. Reports the nodes moved and the largest displacement"#,
+ form: Some(super::forms::NODE_RELAX_APPLY),
+ kind: CommandKind::Model,
+ requires: &[Requirement::Mesh],
+ run: apply,
+};
+
+/// The commands after which `reapply` fields are applied again.
+const REMESH: &[&str] = &[
+ "mesh.tet.generate",
+ "mesh.hex.generate",
+ "mesh.hex.sweep",
+ "mesh.hex.mapped",
+ "mesh.hex.blocks",
+ "mesh.hex.octree",
+ "mesh.cells",
+ "mesh.block",
+];
+
+const DEFAULT_ITERATIONS: usize = 5;
+
+/// The region's quality as JSON.
+fn quality_json(q: &RegionQuality) -> Value {
+ json!({
+ "elements": q.elements,
+ "min_scaled_jacobian": q.min_quality,
+ "max_edge_ratio": if q.max_edge_ratio.is_finite() { json!(q.max_edge_ratio) } else { Value::Null },
+ "invalid": q.invalid,
+ "worst": q.worst.iter().map(|(e, v)| json!({"element": e, "quality": v})).collect::>(),
+ })
+}
+
+/// Faces a node lies on (geometry references), from the element faces, shells and vertex tags.
+struct NodeTags {
+ faces: BTreeMap>,
+ vertex: BTreeSet,
+ /// Nodes on a partition cut face.
+ cut: BTreeSet,
+}
+
+fn node_tags(model: &Model, nodes: &BTreeSet) -> NodeTags {
+ let c = model.classification();
+ let mut t = NodeTags { faces: BTreeMap::new(), vertex: BTreeSet::new(), cut: BTreeSet::new() };
+ let note = |n: u32, tag: u32, t: &mut NodeTags| {
+ let Some(g) = c.entity(tag) else { return };
+ if let Entity::Face(name) = &g.entity {
+ if name.starts_with("partition:") {
+ t.cut.insert(n);
+ } else {
+ t.faces.entry(n).or_default().insert((g.body.clone(), name.clone()));
+ }
+ }
+ };
+ for (&label, tags) in c.elements() {
+ let Some(e) = model.element(label) else { continue };
+ if !e.nodes.iter().any(|n| nodes.contains(n)) {
+ continue;
+ }
+ if e.kind.dimension() == 2
+ && let Some(r) = tags.region
+ {
+ for &n in &e.nodes {
+ note(n, r, &mut t);
+ }
+ }
+ for (&face, &tag) in &tags.faces {
+ let Some(local) = e.kind.faces().get(usize::from(face).wrapping_sub(1)) else { continue };
+ for &i in local.iter() {
+ if let Some(&n) = e.nodes.get(i) {
+ note(n, tag, &mut t);
+ }
+ }
+ }
+ }
+ for (&n, &tag) in c.nodes() {
+ if nodes.contains(&n) && c.entity(tag).is_some_and(|g| matches!(g.entity, Entity::Vertex(_))) {
+ t.vertex.insert(n);
+ }
+ }
+ t
+}
+
+/// Whether two exact surfaces are the same surface.
+fn same_surface(a: &AnalyticSurface, b: &AnalyticSurface, tol: f64) -> bool {
+ use AnalyticSurface as S;
+ let sub = |x: [f64; 3], y: [f64; 3]| [x[0] - y[0], x[1] - y[1], x[2] - y[2]];
+ let dot = |x: [f64; 3], y: [f64; 3]| x[0] * y[0] + x[1] * y[1] + x[2] * y[2];
+ let len = |x: [f64; 3]| dot(x, x).sqrt();
+ let unit = |x: [f64; 3]| {
+ let l = len(x);
+ if l > 0.0 { [x[0] / l, x[1] / l, x[2] / l] } else { x }
+ };
+ let parallel = |x: [f64; 3], y: [f64; 3]| (dot(unit(x), unit(y)).abs() - 1.0).abs() < 1e-9;
+ let on_axis = |p: [f64; 3], o: [f64; 3], a: [f64; 3]| {
+ let d = sub(p, o);
+ let a = unit(a);
+ let along = dot(d, a);
+ len([d[0] - a[0] * along, d[1] - a[1] * along, d[2] - a[2] * along]) <= tol
+ };
+ match (*a, *b) {
+ (S::Plane { normal: n1, point: p1 }, S::Plane { normal: n2, point: p2 }) => parallel(n1, n2) && dot(sub(p2, p1), unit(n1)).abs() <= tol,
+ (S::Cylinder { origin: o1, axis: a1, radius: r1 }, S::Cylinder { origin: o2, axis: a2, radius: r2 }) => {
+ (r1 - r2).abs() <= tol && parallel(a1, a2) && on_axis(o2, o1, a1)
+ }
+ (S::Sphere { center: c1, radius: r1 }, S::Sphere { center: c2, radius: r2 }) => len(sub(c1, c2)) <= tol && (r1 - r2).abs() <= tol,
+ (S::Cone { apex: p1, axis: a1, half_angle: h1 }, S::Cone { apex: p2, axis: a2, half_angle: h2 }) => {
+ len(sub(p1, p2)) <= tol && dot(unit(a1), unit(a2)) > 1.0 - 1e-9 && (h1 - h2).abs() <= 1e-9
+ }
+ (S::Torus { center: c1, axis: a1, major: m1, minor: n1 }, S::Torus { center: c2, axis: a2, major: m2, minor: n2 }) => {
+ len(sub(c1, c2)) <= tol && parallel(a1, a2) && (m1 - m2).abs() <= tol && (n1 - n2).abs() <= tol
+ }
+ _ => false,
+ }
+}
+
+/// The design's geometry as relaxation constraints.
+struct DesignGeometry {
+ /// Per body: its tessellation with exact surfaces.
+ bodies: Vec,
+ /// Face ids: (body, B-rep face).
+ faces: Vec<(usize, u32)>,
+ /// Curve ids: (body, the two B-rep faces).
+ curves: Vec<(usize, [u32; 2])>,
+ freedom: BTreeMap,
+}
+
+impl Constraints for DesignGeometry {
+ fn freedom(&self, node: u32) -> Option {
+ self.freedom.get(&node).copied()
+ }
+ fn slide_face(&self, face: u32, p: [f64; 3], d: [f64; 3]) -> Option<[f64; 3]> {
+ let &(b, f) = self.faces.get(face as usize)?;
+ self.bodies.get(b)?.slide_face(f, p, d)
+ }
+ fn slide_curve(&self, curve: u32, p: [f64; 3], d: [f64; 3]) -> Option<[f64; 3]> {
+ let &(b, f) = self.curves.get(curve as usize)?;
+ self.bodies.get(b)?.slide_curve(f, p, d)
+ }
+}
+
+/// The constraints of `nodes` from the mesh's tags and the open design.
+fn design_geometry(s: &Session, nodes: &BTreeSet) -> Result {
+ let tags = node_tags(&s.model, nodes);
+ let mut g = DesignGeometry { bodies: Vec::new(), faces: Vec::new(), curves: Vec::new(), freedom: BTreeMap::new() };
+ // Body name → (index into `g.bodies`, its exact surfaces), built when first needed.
+ let mut loaded: BTreeMap>)>> = BTreeMap::new();
+ let mut load = |name: &str, g: &mut DesignGeometry| -> Result