mesh: node tools commands: merge, duplicate check, renumber (#234)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 11:11:14 -04:00
1 parent 57f7bd3d7e
commit 198ead4015
7 files changed
+424 -5

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | | M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started | | M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: one datum, the half extruded in automatic blocks and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); automatic blocks of extruded cross-sections (done: `hex::decompose` — holes separated, O-grids, cuts from concave corners and fillets chosen by block quality, kept only when no worse than the free mesh; nothing added to the model); pyramid transitions; automatic decomposition of 3D parts into sweepable and mappable regions | started | | M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: one datum, the half extruded in automatic blocks and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); automatic blocks of extruded cross-sections (done: `hex::decompose` — holes separated, O-grids, cuts from concave corners and fillets chosen by block quality, kept only when no worse than the free mesh; nothing added to the model); pyramid transitions; automatic decomposition of 3D parts into sweepable and mappable regions | started |
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started | | M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing (node tools done: `mesh.node.merge` equivalences coincident nodes with sets and tags following the survivors and element collapses left out, previewed in its form; `mesh.check.duplicates` lists unmerged nodes, repeated elements, cracks and free edges and selects them; `mesh.renumber` compact, by body or in reverse Cuthill–McKee order, every reference following) and orphan-mesh import | started |
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started | | M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started | | M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned | | M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
+8 -1
View File
@@ -357,7 +357,14 @@ pub static CATALOGUE: &[Capability] = &[
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]), ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove"]), ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove"]),
ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"), ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"),
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"), ready_more(
"mesh.node.merge",
"Merge Nodes",
Mesh,
"Edit",
"Merge coincident nodes, keeping sets, loads and boundary conditions on the survivors; check for duplicates and cracks, renumber",
&["mesh.check.duplicates", "mesh.renumber"],
),
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"), 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"), 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"), ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
+22
View File
@@ -1249,6 +1249,28 @@ pub const ELEMENT_ADD: &[Field] = &[
labels("nodes", "Nodes", LabelOf::Node).help("The element's node labels in order.").req().hint("labels in order: 1, 2, 3, 4"), labels("nodes", "Nodes", LabelOf::Node).help("The element's node labels in order.").req().hint("labels in order: 1, 2, 3, 4"),
]; ];
const MERGE_TOLERANCE: Field =
positive("tolerance", "Tolerance", D::Length).help("Nodes closer than this are one node.").hint("a millionth of the mesh's size");
pub const NODE_MERGE: &[Field] = &[
MERGE_TOLERANCE,
reference("set", "Set", C::Set).help("Merge only the nodes of this set (and of its elements and faces).").hint("every node"),
choice("keep", "Keep", &[("lowest", "Lowest label"), ("highest", "Highest label")]).help("Which node of each group survives.").def("\"lowest\""),
];
pub const CHECK_DUPLICATES: &[Field] = &[MERGE_TOLERANCE];
pub const RENUMBER: &[Field] = &[
choice("what", "Renumber", &[("both", "Nodes and elements"), ("nodes", "Nodes"), ("elements", "Elements")])
.help("Which labels change.")
.def("\"both\""),
choice("by", "Order", &[("compact", "Compact (label order)"), ("bandwidth", "Bandwidth (reverse Cuthill–McKee)"), ("body", "Body by body")])
.help("The order of the new labels.")
.def("\"compact\""),
int("start", "Start", 1, 99_999_999).help("The first new label.").def("1"),
int("offset", "Offset", -99_999_999, 99_999_999).help("Instead of an order and a start: add this to every label.").hint("none"),
];
pub const SEED_EDGE: &[Field] = &[ pub const SEED_EDGE: &[Field] = &[
json("edges", "Edges") json("edges", "Edges")
.help("The edges to seed, as JSON; easiest with Use selection.") .help("The edges to seed, as JSON; easiest with Use selection.")
+5
View File
@@ -17,8 +17,10 @@ pub(crate) mod mesh;
pub use datum::edit_params as datum_edit_params; pub use datum::edit_params as datum_edit_params;
pub use datum::preview as datum_preview; pub use datum::preview as datum_preview;
pub use mesh::analytic_surfaces; pub use mesh::analytic_surfaces;
pub use nodes::merge_preview as node_merge_preview;
mod mirror; mod mirror;
mod model; mod model;
pub(crate) mod nodes;
pub(crate) mod octree; pub(crate) mod octree;
pub mod replace; pub mod replace;
mod results; mod results;
@@ -80,6 +82,9 @@ pub static ALL: &[CommandSpec] = &[
seeds::SEED_CLEAR, seeds::SEED_CLEAR,
seeds::SEED_LIST, seeds::SEED_LIST,
mirror::MIRROR, mirror::MIRROR,
nodes::MERGE,
nodes::DUPLICATES,
nodes::RENUMBER,
tags::TAG, tags::TAG,
tags::TAG_CLEAR, tags::TAG_CLEAR,
model::UNITS, model::UNITS,
+385
View File
@@ -0,0 +1,385 @@
//! Node tools (#234): `mesh.node.merge` equivalences coincident nodes, `mesh.check.duplicates`
//! finds what is not merged or does not join (coincident nodes, repeated elements, free faces and
//! edges, cracks), and `mesh.renumber` gives nodes and elements new labels. Merging and
//! renumbering move every reference with the labels (connectivity, sets, geometry tags, a step's
//! displacement limit), so boundary conditions and loads, which act on sets, stay on the same
//! entities; deck writers write the labels as they are. Both are one undo step; results solved
//! before go stale, as after any mesh edit.
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::nodes::{Keep, bandwidth, duplicates, merge_plan, reverse_cuthill_mckee};
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{MAX_LABEL, Model};
use serde_json::{Value, json};
use crate::requirement::Requirement;
use crate::schema::ProblemKind;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const MERGE: CommandSpec = CommandSpec {
id: "mesh.node.merge",
label: "Merge Nodes",
params: r#"{"tolerance"?: mm (default a millionth of the mesh's bounding-box diagonal), "set"?: "Name" (only the set's nodes and those of its elements and faces), "keep"?: "lowest" (default) | "highest"} — equivalences coincident nodes: every node within the distance of another becomes that one (the lowest or highest label of each group survives, keeping its position) in every element, set and geometry tag, and is removed. A merge that would put two nodes of one element together (collapsing it) is left out and listed under `refused`; the rest are made. Reports `merged`, the `pairs` [node, survivor] (the first 1000) and `refused`"#,
form: Some(super::forms::NODE_MERGE),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: merge,
};
pub const DUPLICATES: CommandSpec = CommandSpec {
id: "mesh.check.duplicates",
label: "Check Duplicates",
params: r#"{"tolerance"?: mm (default a millionth of the mesh's bounding-box diagonal)} — checks the mesh for what was not merged or does not join, changing nothing: `coincident` nodes within the distance of another ([node, other, distance]), `duplicate_elements` (groups on the same nodes), `free_faces` (faces of solid elements no other element shares: the skin), `cracks` (free faces lying on another free face: parts that touch without sharing nodes) and `free_edges` (edges of the skin and of 2D elements on one facet only: open borders). Lists hold the first 1000; `counts` has the totals"#,
form: Some(super::forms::CHECK_DUPLICATES),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: check_duplicates,
};
pub const RENUMBER: CommandSpec = CommandSpec {
id: "mesh.renumber",
label: "Renumber",
params: r#"{"what"?: "both" (default) | "nodes" | "elements", "by"?: "compact" (default: in label order) | "bandwidth" (nodes in reverse Cuthill–McKee order, elements by their lowest new node label) | "body" (body by body, by name; within a body in label order), "start"?: 1, "offset"?: n (instead of `by` and `start`: add n to every label)} — gives nodes and elements new labels from `start` up (or shifted by `offset`), every set, geometry tag and step following, so boundary conditions and loads stay on the same entities and decks write the new labels. Refused, changing nothing, when a label would leave 1..=99999999 or a node would take a reference point's label. Reports the counts renumbered, the label ranges and the bandwidth before and after"#,
form: Some(super::forms::RENUMBER),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: renumber,
};
/// Most items a reply lists.
const MAX_LISTED: usize = 1000;
/// The merge distance: `tolerance`, or a millionth of the mesh's bounding-box diagonal.
pub(crate) fn tolerance(model: &Model, p: &Value) -> Result<f64> {
match p.get("tolerance") {
Some(v) => v.as_f64().filter(|t| *t > 0.0 && t.is_finite()).ok_or_else(|| EngineError::param("tolerance", ProblemKind::NotPositive)),
None => {
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for n in model.nodes().values() {
for k in 0..3 {
lo[k] = lo[k].min(n.xyz[k]);
hi[k] = hi[k].max(n.xyz[k]);
}
}
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt();
Ok(if diagonal > 0.0 && diagonal.is_finite() { 1e-6 * diagonal } else { 1e-9 })
}
}
}
/// The merge's candidates: the nodes of `set` (its own, and those of its elements and faces).
fn candidates(model: &Model, p: &Value) -> Result<Option<BTreeSet<u32>>> {
let Some(name) = super::opt::<String>(p, "set")? else { return Ok(None) };
let set = model.resolve_set(&name)?;
let mut nodes: BTreeSet<u32> = set.nodes.into_iter().collect();
for e in set.elements.iter().chain(set.faces.iter().map(|f| &f.element)) {
if let Some(el) = model.element(*e) {
nodes.extend(el.nodes.iter().copied());
}
}
Ok(Some(nodes))
}
fn keep(p: &Value) -> Result<Keep> {
match super::opt::<String>(p, "keep")?.as_deref() {
None | Some("lowest") => Ok(Keep::Lowest),
Some("highest") => Ok(Keep::Highest),
Some(_) => Err(EngineError::param("keep", ProblemKind::NotAChoice)),
}
}
/// The merges `mesh.node.merge` with `params` would make, as [node, survivor] pairs: what its
/// form previews. `None` when the parameters are not (yet) valid.
pub fn merge_preview(s: &Session, params: &Value) -> Option<Vec<[u32; 2]>> {
let tol = tolerance(&s.model, params).ok()?;
let only = candidates(&s.model, params).ok()?;
let plan = merge_plan(&s.model, only.as_ref(), tol, keep(params).ok()?).ok()?;
Some(plan.map.into_iter().map(|(a, b)| [a, b]).collect())
}
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let tol = tolerance(&s.model, p)?;
let only = candidates(&s.model, p)?;
let plan = merge_plan(&s.model, only.as_ref(), tol, keep(p)?)?;
if !plan.map.is_empty() {
s.model.merge_nodes(&plan.map)?;
}
let pairs: Vec<[u32; 2]> = plan.map.iter().take(MAX_LISTED).map(|(&a, &b)| [a, b]).collect();
let refused: Vec<Value> =
plan.refused.iter().take(MAX_LISTED).map(|r| json!({"node": r.node, "survivor": r.survivor, "element": r.element})).collect();
let mut summary = format!("merged {} node{} within {tol:.3e}", plan.map.len(), if plan.map.len() == 1 { "" } else { "s" });
if !plan.refused.is_empty() {
summary.push_str(&format!("; {} left apart (they would collapse an element)", plan.refused.len()));
}
Ok(json!({
"merged": plan.map.len(),
"pairs": pairs,
"refused": refused,
"tolerance": tol,
"nodes": s.model.nodes().len(),
"summary": summary,
}))
}
fn check_duplicates(s: &mut Session, p: &Value) -> Result<Value> {
let tol = tolerance(&s.model, p)?;
let d = duplicates(&s.model, tol)?;
let coincident: Vec<Value> = d.coincident.iter().take(MAX_LISTED).map(|&(a, b, dist)| json!([a, b, dist])).collect();
let summary = format!(
"{} coincident node{}, {} duplicate element group{}, {} crack face{}, {} free edge{} ({} free faces)",
d.coincident.len(),
plural(d.coincident.len()),
d.elements.len(),
plural(d.elements.len()),
d.cracks.len(),
plural(d.cracks.len()),
d.free_edges.len(),
plural(d.free_edges.len()),
d.free_faces,
);
Ok(json!({
"tolerance": tol,
"coincident": coincident,
"duplicate_elements": d.elements.iter().take(MAX_LISTED).collect::<Vec<_>>(),
"free_faces": d.free_faces,
"cracks": d.cracks.iter().take(MAX_LISTED).collect::<Vec<_>>(),
"free_edges": d.free_edges.iter().take(MAX_LISTED).collect::<Vec<_>>(),
"counts": {
"coincident": d.coincident.len(),
"duplicate_elements": d.elements.len(),
"cracks": d.cracks.len(),
"free_edges": d.free_edges.len(),
},
"summary": summary,
}))
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
/// The body name of each element tagged with one.
fn element_bodies(model: &Model) -> BTreeMap<u32, String> {
let c = model.classification();
c.elements()
.iter()
.filter_map(|(&e, t)| {
let r = c.entity(t.region?)?;
(r.entity == Entity::Body).then(|| (e, r.body.clone()))
})
.collect()
}
/// New labels for `order` from `start` up; `None` when they would leave the label range.
fn from_start(order: &[u32], start: u32) -> Option<BTreeMap<u32, u32>> {
order
.iter()
.enumerate()
.map(|(i, &l)| Some((l, start.checked_add(u32::try_from(i).ok()?).filter(|x| *x <= MAX_LABEL)?)))
.filter(|x| x.as_ref().is_none_or(|(a, b)| a != b))
.collect()
}
fn renumber(s: &mut Session, p: &Value) -> Result<Value> {
let (nodes_too, elements_too) = match super::opt::<String>(p, "what")?.as_deref() {
None | Some("both") => (true, true),
Some("nodes") => (true, false),
Some("elements") => (false, true),
Some(_) => return Err(EngineError::param("what", ProblemKind::NotAChoice)),
};
let model = &s.model;
let out_of_range = || EngineError::Params(format!("the new labels would leave 1..={MAX_LABEL}"));
let (node_map, element_map) = if let Some(v) = p.get("offset") {
if p.get("by").is_some() || p.get("start").is_some() {
return Err(EngineError::Params("give `offset`, or `by` and `start`, not both".into()));
}
let offset = v
.as_i64()
.ok_or_else(|| EngineError::param("offset", ProblemKind::NotAWholeNumber { min: -i64::from(MAX_LABEL), max: i64::from(MAX_LABEL) }))?;
let shift = |labels: &mut dyn Iterator<Item = u32>| -> Result<BTreeMap<u32, u32>> {
labels
.filter(|_| offset != 0)
.map(|l| u32::try_from(i64::from(l) + offset).ok().filter(|x| (1..=MAX_LABEL).contains(x)).map(|n| (l, n)).ok_or_else(out_of_range))
.collect()
};
let n = if nodes_too { shift(&mut model.nodes().keys().copied())? } else { BTreeMap::new() };
let e = if elements_too { shift(&mut model.elements().keys().copied())? } else { BTreeMap::new() };
(n, e)
} else {
let start = match p.get("start") {
None => 1,
Some(v) => v
.as_u64()
.and_then(|x| u32::try_from(x).ok())
.filter(|x| (1..=MAX_LABEL).contains(x))
.ok_or_else(|| EngineError::param("start", ProblemKind::NotAWholeNumber { min: 1, max: i64::from(MAX_LABEL) }))?,
};
let (node_order, element_order): (Vec<u32>, Vec<u32>) = match super::opt::<String>(p, "by")?.as_deref() {
None | Some("compact") => (model.nodes().keys().copied().collect(), model.elements().keys().copied().collect()),
Some("bandwidth") => {
let nodes = reverse_cuthill_mckee(model);
let rank: BTreeMap<u32, usize> = nodes.iter().enumerate().map(|(i, &l)| (l, i)).collect();
let mut elements: Vec<(usize, u32)> = model
.elements()
.iter()
.map(|(&l, e)| (e.nodes.iter().filter_map(|n| rank.get(n).copied()).min().unwrap_or(usize::MAX), l))
.collect();
elements.sort_unstable();
(nodes, elements.into_iter().map(|(_, l)| l).collect())
}
Some("body") => {
let bodies = element_bodies(model);
// Elements by body name (untagged last), then label; nodes as their first
// element in that order meets them, then the nodes no element uses.
let mut elements: Vec<(Option<&String>, u32)> = model.elements().keys().map(|&l| (bodies.get(&l), l)).collect();
elements.sort_by(|a, b| (a.0.is_none(), a.0, a.1).cmp(&(b.0.is_none(), b.0, b.1)));
let elements: Vec<u32> = elements.into_iter().map(|(_, l)| l).collect();
let mut seen = BTreeSet::new();
let mut nodes: Vec<u32> = Vec::with_capacity(model.nodes().len());
for e in &elements {
for &n in model.element(*e).map(|x| x.nodes.as_slice()).unwrap_or(&[]) {
if seen.insert(n) {
nodes.push(n);
}
}
}
let mut free: Vec<u32> = model.nodes().keys().copied().filter(|n| !seen.contains(n)).collect();
free.sort_unstable();
nodes.extend(free);
(nodes, elements)
}
Some(_) => return Err(EngineError::param("by", ProblemKind::NotAChoice)),
};
let n = if nodes_too { from_start(&node_order, start).ok_or_else(out_of_range)? } else { BTreeMap::new() };
let e = if elements_too { from_start(&element_order, start).ok_or_else(out_of_range)? } else { BTreeMap::new() };
(n, e)
};
let before = bandwidth(model, &|l| l);
s.model.renumber(&node_map, &element_map)?;
let after = bandwidth(&s.model, &|l| l);
let range = |labels: &mut dyn Iterator<Item = u32>| -> Value {
let (lo, hi) = labels.fold((u32::MAX, 0), |(lo, hi), l| (lo.min(l), hi.max(l)));
if hi == 0 { Value::Null } else { json!([lo, hi]) }
};
Ok(json!({
"nodes": node_map.len(),
"elements": element_map.len(),
"node_labels": range(&mut s.model.nodes().keys().copied()),
"element_labels": range(&mut s.model.elements().keys().copied()),
"bandwidth": [before, after],
"summary": format!("renumbered {} nodes and {} elements; bandwidth {before} → {after}", node_map.len(), element_map.len()),
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// A 2 × 1 × 1 bar of hex8 blocks: whole, or as two halves that do not share their nodes
/// on x = 1.
fn bar(halves: bool) -> Session {
let mut s = Session::default();
let block = |body: &str, x0: f64, x1: f64, n: u32| {
let c = [[x0, 0., 0.], [x1, 0., 0.], [x1, 1., 0.], [x0, 1., 0.], [x0, 0., 1.], [x1, 0., 1.], [x1, 1., 1.], [x0, 1., 1.]];
json!({"body": body, "kind": "hex8", "corners": c, "divisions": [n, 2, 2], "merge": 0})
};
if halves {
s.execute("mesh.block", &block("A", 0.0, 1.0, 2)).unwrap();
s.execute("mesh.block", &block("B", 1.0, 2.0, 2)).unwrap();
} else {
s.execute("mesh.block", &block("A", 0.0, 2.0, 4)).unwrap();
}
s
}
fn positions(s: &Session) -> Vec<[u64; 3]> {
let mut p: Vec<[u64; 3]> = s.model.nodes().values().map(|n| n.xyz.map(|x| (x + 0.0).to_bits())).collect();
p.sort_unstable();
p
}
/// Two halves meshed apart are found coincident and cracked, and merging them gives the
/// whole bar's nodes: no crack left, and undo brings the halves back.
#[test]
fn merging_two_halves_gives_the_whole() {
let whole = bar(false);
let mut s = bar(true);
s.execute(
"model.set.create",
&json!({"name": "seam", "nodes": s.model.nodes().iter().filter(|(_, n)| n.xyz[0] == 1.0).map(|(l, _)| *l).collect::<Vec<_>>()}),
)
.unwrap();
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
assert_eq!(d["counts"]["coincident"], 9, "{d}");
assert_eq!(d["counts"]["cracks"], 8, "{d}");
assert_eq!(d["counts"]["free_edges"], 0, "{d}");
assert_eq!(s.model.nodes().len(), whole.model.nodes().len() + 9);
let r = s.execute("mesh.node.merge", &json!({})).unwrap();
assert_eq!(r["merged"], 9, "{r}");
assert_eq!(positions(&s), positions(&whole));
// The seam set keeps its nine positions, on the survivors.
assert_eq!(s.model.set("seam").unwrap().nodes.len(), 9);
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
assert_eq!((d["counts"]["coincident"].as_u64(), d["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{d}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.nodes().len(), whole.model.nodes().len() + 9);
// Keeping the highest labels; only a set's nodes; hostile parameters change nothing.
let r = s.execute("mesh.node.merge", &json!({"keep": "highest", "set": "seam"})).unwrap();
assert!(r["pairs"].as_array().unwrap().iter().all(|p| p[0].as_u64() < p[1].as_u64()), "{r}");
s.execute("edit.undo", &json!({})).unwrap();
for bad in [json!({"tolerance": -1}), json!({"tolerance": "x"}), json!({"keep": "middle"}), json!({"set": "nope"})] {
assert!(s.execute("mesh.node.merge", &bad).is_err(), "{bad}");
}
assert!(merge_preview(&s, &json!({})).is_some_and(|p| p.len() == 9));
}
/// Renumbering keeps the analysis: the cantilever solves to the same displacements, point
/// for point, after its nodes take the bandwidth order from 1000 and its elements are
/// shifted; the deck writes the new labels.
#[test]
fn renumbering_keeps_the_solution() {
let mut s = Session::default();
s.run_script(&crate::sample::cantilever()).unwrap();
let at = json!([[100.0, 10.0, 10.0], [50.0, 0.0, 20.0], [25.0, 20.0, 0.0]]);
let probe = |s: &mut Session| {
s.execute("solve.static", &json!({})).unwrap();
s.execute("solve.probe", &json!({"step": "Load", "at": at})).unwrap()["nodes"].clone()
};
let before = probe(&mut s);
let bw = bandwidth(&s.model, &|l| l);
let r = s.execute("mesh.renumber", &json!({"what": "nodes", "by": "bandwidth", "start": 1000})).unwrap();
assert_eq!(r["node_labels"][0], 1000, "{r}");
assert!(r["bandwidth"][1].as_u64().unwrap() <= u64::from(bw), "{r}");
s.execute("mesh.renumber", &json!({"what": "elements", "offset": 500})).unwrap();
assert_eq!(s.model.elements().keys().next().copied(), Some(501));
let after = probe(&mut s);
for (a, b) in before.as_array().unwrap().iter().zip(after.as_array().unwrap()) {
for k in 0..3 {
let (x, y) = (a["displacement"][k].as_f64().unwrap(), b["displacement"][k].as_f64().unwrap());
// Equal to roundoff of the tip deflection (0.24 mm).
assert!((x - y).abs() <= 1e-9, "{a} vs {b}");
}
}
let deck = fieldcraft_decks::abaqus::write(&s.model).unwrap();
assert!(deck.lines().any(|l| l.starts_with("1000,")) && deck.lines().any(|l| l.starts_with("501,")), "{deck}");
// Body by body and compact, then refusals that change nothing.
s.execute("mesh.renumber", &json!({"by": "body"})).unwrap();
s.execute("mesh.renumber", &json!({})).unwrap();
assert_eq!(s.model.nodes().keys().next().copied(), Some(1));
let model = s.model.clone();
for bad in [
json!({"offset": -1}),
json!({"start": 99_999_999}),
json!({"start": 0}),
json!({"by": "random"}),
json!({"what": "faces"}),
json!({"offset": 1, "start": 3}),
] {
assert!(s.execute("mesh.renumber", &bad).is_err(), "{bad}");
}
assert_eq!(s.model, model);
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands. the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
69 ready, 40 coming soon. 70 ready, 39 coming soon.
## Geometry ## Geometry
@@ -49,7 +49,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Edit | Node — Add a node | ready | `mesh.node.add`, `mesh.node.remove` | | Edit | Node — Add a node | ready | `mesh.node.add`, `mesh.node.remove` |
| Edit | Element — Add an element on existing nodes | ready | `mesh.element.add`, `mesh.element.remove` | | Edit | Element — Add an element on existing nodes | ready | `mesh.element.add`, `mesh.element.remove` |
| Edit | Mirror Mesh — Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part) | ready | `mesh.mirror` | | Edit | Mirror Mesh — Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part) | ready | `mesh.mirror` |
| Edit | Merge Nodes — Merge coincident nodes | coming soon (M6) | | | Edit | Merge Nodes — Merge coincident nodes, keeping sets, loads and boundary conditions on the survivors; check for duplicates and cracks, renumber | ready | `mesh.node.merge`, `mesh.check.duplicates`, `mesh.renumber` |
| Edit | Remesh Region — Remesh a region in place; split and collapse elements | coming soon (M6) | | | Edit | Remesh Region — Remesh a region in place; split and collapse elements | coming soon (M6) | |
| Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | | | Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | |
| Verify | Quality — Quality metrics and the certified validity check, failing elements listed | ready | `mesh.quality` | | Verify | Quality — Quality metrics and the certified validity check, failing elements listed | ready | `mesh.quality` |
+1 -1
View File
@@ -41,7 +41,7 @@ products are run, no documentation text is copied). Status: **done**, **started*
| Mesh | Octree/grid-dual all-hex for any part | M6 | yes | planned | | Mesh | Octree/grid-dual all-hex for any part | M6 | yes | planned |
| Mesh | Hex-dominant by recombination; polycube | M6 | yes | planned | | Mesh | Hex-dominant by recombination; polycube | M6 | yes | planned |
| Mesh | Smoothing, untangling, boundary padding, layers through thin walls | M6 | yes | planned | | Mesh | Smoothing, untangling, boundary padding, layers through thin walls | M6 | yes | planned |
| Mesh | Mesh editing (move, merge, split, collapse, remesh region), imported orphan meshes | M6 | yes | started (node/element add/remove) | | Mesh | Mesh editing (move, merge, split, collapse, remesh region), imported orphan meshes | M6 | yes | started (node/element add/remove, merge coincident nodes, duplicate and crack check, renumber) |
| Model | Sets and surfaces bound to geometry names | M3 | yes | done | | Model | Sets and surfaces bound to geometry names | M3 | yes | done |
| Model | Materials: elastic (isotropic, orthotropic), density, unit-system check | M3 | | done | | Model | Materials: elastic (isotropic, orthotropic), density, unit-system check | M3 | | done |
| Model | Materials: plasticity, hyperelasticity, damage, thermal | M8 | | started (von Mises plasticity: bilinear, table, Johnson–Cook; Johnson–Cook damage, erosion; plasticity in explicit and nonlinear static steps; written to Abaqus `*PLASTIC`, Nastran `MATS1`, LS-DYNA plastic materials where expressible; compressible neo-Hookean, Mooney–Rivlin and Yeoh for nonlinear steps, Abaqus `*HYPERELASTIC, NEO HOOKE`, `MOONEY-RIVLIN`, `YEOH`; thermal strain in nonlinear steps) | | Model | Materials: plasticity, hyperelasticity, damage, thermal | M8 | | started (von Mises plasticity: bilinear, table, Johnson–Cook; Johnson–Cook damage, erosion; plasticity in explicit and nonlinear static steps; written to Abaqus `*PLASTIC`, Nastran `MATS1`, LS-DYNA plastic materials where expressible; compressible neo-Hookean, Mooney–Rivlin and Yeoh for nonlinear steps, Abaqus `*HYPERELASTIC, NEO HOOKE`, `MOONEY-RIVLIN`, `YEOH`; thermal strain in nonlinear steps) |