From 3ee622f62aa74c2e4cfc6029130b9835084202c2 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:31:06 -0400 Subject: [PATCH] mesh: relax nodes by CAD face; params docs match their forms (#471) mesh.node.relax takes faces: [{body, face}] (every node on those B-rep faces and the nodes of the elements touching them, sliding on the faces) and elements in its form; the undocumented-by-form set is dropped. A schema test checks every top-level key a params doc names is accepted, which also found mesh.hex.extrude normal, mesh.hex.project max_arc_angle and mesh.plan.apply name missing from their forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/forms.rs | 9 ++++ crates/engine/src/cmd/relax.rs | 74 +++++++++++++++++++++++----- crates/engine/src/cmd/relax/tests.rs | 62 +++++++++++++++++++++++ crates/engine/src/schema.rs | 50 +++++++++++++++++++ 4 files changed, 184 insertions(+), 11 deletions(-) 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> { } } +/// The most faces `faces` may list. +const MAX_FACES: usize = 10_000; + +/// The `faces` parameter: a list of `{body, face}` B-rep face references. +fn face_refs(p: &Value) -> Result> { + let refs: Vec = super::req(p, "faces")?; + if refs.is_empty() || refs.len() > MAX_FACES { + return Err(EngineError::invalid("faces", format!("1 to {MAX_FACES} faces"))); + } + if let Some(r) = refs.iter().find(|r| !matches!(r.entity, Entity::Face(_))) { + return Err(EngineError::invalid("faces", format!("`{}`: give {{\"body\", \"face\"}}", r.body.chars().take(80).collect::()))); + } + Ok(refs) +} + +/// The nodes on the B-rep faces `refs` (from the mesh's geometry tags: element faces on them and +/// shell elements meshing them) and the nodes of every element touching those. +fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result> { + let c = model.classification(); + let mut tags = BTreeSet::new(); + for r in refs { + let found = c.tags_selected_by(r); + if found.is_empty() { + let face = r.entity.name().unwrap_or_default(); + return Err(EngineError::Params(format!( + "face `{}` of body `{}` has no tagged mesh (mesh it, or mesh.tag)", + face.chars().take(80).collect::(), + r.body.chars().take(80).collect::() + ))); + } + tags.extend(found); + } + let mut on = BTreeSet::new(); + for (&label, t) in c.elements() { + let Some(e) = model.element(label) else { continue }; + if e.kind.dimension() == 2 && t.region.is_some_and(|r| tags.contains(&r)) { + on.extend(e.nodes.iter().copied()); + } + for (&face, tag) in &t.faces { + if !tags.contains(tag) { + continue; + } + let Some(local) = e.kind.faces().get(usize::from(face).wrapping_sub(1)) else { continue }; + on.extend(local.iter().filter_map(|&i| e.nodes.get(i).copied())); + } + } + let mut out = on.clone(); + for e in model.elements().values() { + if e.nodes.iter().any(|n| on.contains(n)) { + out.extend(e.nodes.iter().copied()); + } + } + Ok(out) +} + /// The sphere around element `label`: its centroid, and twice its longest corner edge. fn around_element(model: &Model, label: u32) -> Result<([f64; 3], f64)> { let e = model.element(label).ok_or_else(|| EngineError::Params(format!("no element {label}")))?; @@ -344,11 +399,11 @@ pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap, O .map(Some)?, None => around.map(|a| a.1), }; - let listed = ["nodes", "elements", "set"].iter().filter(|k| p.get(**k).is_some()).count(); + let listed = ["faces", "nodes", "elements"].iter().filter(|k| p.get(**k).is_some()).count(); if let (Some(c), Some(r)) = (center, radius) { if listed > 0 { return Err(EngineError::Params( - "give a sphere (`center` and `radius`, or `around_element`) or `nodes`, `elements` or `set`, not both".into(), + "give a sphere (`center` and `radius`, or `around_element`) or `faces`, `nodes` or `elements`, not both".into(), )); } return Ok((relax::sphere_weights(model, c, r, strength, falloff, only.as_ref())?, Some((c, r)))); @@ -364,11 +419,8 @@ pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap, O if p.get("elements").is_some() { elements.extend(super::labels(p, "elements", fieldcraft_model::MAX_ELEMENTS)?); } - if let Some(name) = super::opt::(p, "set")? { - let set = model.resolve_set(&name)?; - nodes.extend(set.nodes.iter().copied()); - elements.extend(set.elements.iter().copied()); - elements.extend(set.faces.iter().map(|f| f.element)); + if p.get("faces").is_some() { + nodes.extend(face_nodes(model, &face_refs(p)?)?); } for e in elements { let el = model.element(e).ok_or_else(|| EngineError::Params(format!("no element {e}")))?; @@ -376,7 +428,7 @@ pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap, O } if listed == 0 { return Err(EngineError::Params( - "say which nodes to relax: give one of `center` and `radius`, `around_element`, `nodes`, `elements` or `set`".into(), + "say which nodes to relax: give one of `center` and `radius`, `around_element`, `faces`, `nodes` or `elements`".into(), )); } if let Some(only) = &only { diff --git a/crates/engine/src/cmd/relax/tests.rs b/crates/engine/src/cmd/relax/tests.rs index 46dd1cc..3d93245 100644 --- a/crates/engine/src/cmd/relax/tests.rs +++ b/crates/engine/src/cmd/relax/tests.rs @@ -192,3 +192,65 @@ fn hostile_parameters_never_panic() { assert!(s.execute("mesh.node.relax.apply", &json!({"field": "nope"})).is_err()); assert!(s.execute("mesh.node.relax.apply", &json!({})).is_err()); } + +/// A 20 mm box with an R2 fillet along its edge at x = z = 20 (axis x = z = 18, along y), tet +/// meshed; its fillet faces' persistent names. +fn filleted_box() -> (crate::tests::TempDir, Session, Vec) { + let script = json!({"commands": [ + {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "body_name": "B"}}, + {"command": "solid.fillet", "params": {"edges": [[20, 10, 20]], "radius": 2}}, + ]}); + let bytes = fieldcraft_geometry::script_design(&script.to_string()).unwrap(); + let (dir, path) = crate::tests::design_file(&bytes); + let mut s = Session::default(); + s.execute("geometry.open", &json!({"path": path})).unwrap(); + let features = s.execute("geometry.features", &json!({"body": "B"})).unwrap(); + let fillet = features["bodies"][0]["features"].as_array().unwrap().iter().find(|f| f["type"] == "fillet").unwrap().clone(); + let faces: Vec = fillet["faces"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert!(!faces.is_empty(), "{fillet}"); + s.execute("mesh.tet.generate", &json!({"size": 1.5})).unwrap(); + (dir, s, faces) +} + +fn fillet_radius(p: [f64; 3]) -> f64 { + ((p[0] - 18.0).powi(2) + (p[2] - 18.0).powi(2)).sqrt() +} + +/// #471: relaxing by CAD face. Relaxing the fillet faces of the tet mesh raises the worst +/// element there, inverts none, keeps the fillet's nodes on its cylinder +/// and moves nothing away from the fillet's elements. Faces that are not meshed are refused. +#[test] +fn relaxing_fillet_faces_improves_them() { + let (_d, mut s, faces) = filleted_box(); + let perturbed = s.model.clone(); + let refs: Vec = faces.iter().map(|f| json!({"body": "B", "face": f})).collect(); + let r = s.execute("mesh.node.relax", &json!({"faces": refs, "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, "{r}"); + assert_eq!(r["after"]["invalid"], 0, "{r}"); + assert_eq!(invalid(&mut s), 0); + for (n, p) in s.model.nodes() { + let old = perturbed.node(*n).unwrap().xyz; + if old[0] > 18.0 && old[2] > 18.0 && (fillet_radius(old) - 2.0).abs() < 1e-6 { + assert!((fillet_radius(p.xyz) - 2.0).abs() < 1e-6, "node {n} left the fillet"); + } + if old != p.xyz { + assert!(fillet_radius(old) < 6.0, "node {n} far from the fillet moved"); + } + } + // Faces: required objects, known and meshed; not with a sphere. + for bad in [ + json!({"faces": []}), + json!({"faces": "F1"}), + json!({"faces": [{"body": "B", "face": "Nope"}]}), + json!({"faces": [{"body": "Nope", "face": faces[0]}]}), + json!({"faces": [{"body": "B"}]}), + json!({"faces": refs, "center": [0, 0, 0], "radius": 1}), + ] { + assert!(s.execute("mesh.node.relax", &bad).is_err(), "{bad}"); + } + // Elements by label are accepted too. + let e = *s.model.elements().keys().next().unwrap(); + s.execute("mesh.node.relax", &json!({"elements": [e]})).unwrap(); + assert!(s.execute("mesh.node.relax", &json!({"set": "All"})).is_err()); +} diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index 93d9f0b..d621e7a 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -1560,4 +1560,54 @@ mod tests { let e = s.execute("results.contour", &json!({"field": "displacement", "deformed": true})).unwrap_err().to_string(); assert!(e.contains("`deformed`") && e.contains("field"), "{e}"); } + + /// The top-level keys a params doc names (`"key":` and `"key"?:` directly inside its + /// leading `{…}`; keys of nested objects and of the reply described after it are not). + fn doc_keys(doc: &str) -> Vec<&str> { + let mut keys = Vec::new(); + let mut depth = 0usize; + let mut chars = doc.char_indices(); + while let Some((i, c)) = chars.next() { + match c { + '{' | '[' => depth += 1, + '}' | ']' => { + depth = depth.saturating_sub(1); + if depth == 0 { + break; + } + } + '"' => { + let rest = &doc[i + 1..]; + let Some(end) = rest.find('"') else { break }; + let word = &rest[..end]; + let tail = &rest[end + 1..]; + if depth == 1 && (tail.starts_with(':') || tail.starts_with("?:")) && !word.is_empty() { + keys.push(word); + } + for _ in 0..word.chars().count() + 1 { + chars.next(); + } + } + _ => {} + } + } + keys + } + + /// A command's params doc names only parameters its form describes, so a documented + /// parameter is never refused as unknown (#471: `mesh.node.relax` listed `set`). + #[test] + fn every_documented_parameter_is_accepted() { + let mut wrong = Vec::new(); + for spec in crate::commands() { + let Some(form) = spec.form else { continue }; + let keys = accepted_keys(form, &Value::Null); + for key in doc_keys(spec.params) { + if !keys.contains(&key) { + wrong.push(format!("{}: `{key}`", spec.id)); + } + } + } + assert!(wrong.is_empty(), "documented but not in the form:\n{}", wrong.join("\n")); + } }