diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index 31d03f3..53c88d8 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1198,6 +1198,9 @@ pub const HEX_PROJECT: &[Field] = &[
.def("\"elliptic\""),
int("iterations", "Iterations", 1, 100_000).help("Smoothing iterations.").def("100"),
range("min_quality", "Minimum quality", D::None, 0.0, 1.0).help("Smallest scaled Jacobian accepted.").def("0.2"),
+ range("max_arc_angle", "Max arc angle", D::Degree, 0.0, 90.0)
+ .help("The most a curved face may turn across one element; the divisions grow to keep it. 0 for no limit.")
+ .def("30"),
range("merge", "Merge distance", D::Millimetre, 0.0, 1.0e9)
.help("Join this mesh's nodes with other bodies' meshes within this distance; 0 never.")
.hint("1e-6 of the diagonal"),
@@ -1266,6 +1269,7 @@ pub const HEX_EXTRUDE: &[Field] = &[
.hint("pick the target surface"),
face_rows("sides", "Side faces").help("The faces the section's sides follow (they need not be straight); straight paths when empty.").hint("straight sides"),
xyz("direction", "Direction", D::None).help("The direction every node moves along; along the section's normal toward the target when empty.").hint("the section's normal"),
+ flag("normal", "Along the normal").help("Each node along the section's normal toward the target (the default when no direction is given)."),
int("layers", "Layers", 1, 10_000).help("The number of element layers; from Size when empty.").hint("from the size"),
positive("size", "Size", D::Length).help("Layer thickness, and the element size of a section meshed here.").hint("1/25 of the body"),
range("bias", "Bias", D::None, 0.001, 1000.0).help("The last layer's thickness over the first's: below 1 grades toward the target, above 1 toward the source.").def("1"),
@@ -1404,7 +1408,11 @@ pub const NODE_RELAX: &[Field] = &[
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"),
+ face_rows("faces", "Faces")
+ .help("Instead of a sphere: every node on these CAD faces and the elements touching them, nodes sliding on the faces. Pick faces in the view, then Use selection.")
+ .hint("pick faces"),
labels("nodes", "Nodes", LabelOf::Node).help("Instead of a sphere: these nodes.").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"),
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"),
@@ -1711,6 +1719,7 @@ pub const MESH_PLAN_APPLY: &[Field] = &[
flag("symmetry", "Use symmetry")
.help("Mesh one cell of a body that is its own mirror image across planes through its middle, and mirror it.")
.def("true"),
+ text("name", "Save as").help("The name a plan given as an object is saved under.").hint("Plan-
"),
];
pub const FEATURES: &[Field] = &[reference("body", "Body", C::Body).help("Only this body's features.").hint("every body")];
pub const GEOMETRY_CHECK: &[Field] = &[
diff --git a/crates/engine/src/cmd/relax.rs b/crates/engine/src/cmd/relax.rs
index ee2b453..ccf0ccb 100644
--- a/crates/engine/src/cmd/relax.rs
+++ b/crates/engine/src/cmd/relax.rs
@@ -1,5 +1,5 @@
//! 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
+//! the nodes on listed CAD faces, or listed nodes or elements) 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.
@@ -17,7 +17,7 @@ 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::geometry::{Entity, GeometryRef};
use fieldcraft_model::{Model, Relaxation};
use serde_json::{Value, json};
@@ -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) | "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"#,
+ 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), "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],
@@ -303,6 +303,61 @@ fn point(p: &Value, key: &str) -> Result