engine: mesh.size.* commands, honoured by every mesher (#435)

mesh.size.set adds a size control on faces, bodies, a box or a sphere;
mesh.size.growth sets the growth rate; mesh.size.clear and
mesh.size.list. The tet mesher bakes the controls into its graded field,
the hex meshers, cells and the octree take the graded RegionSize, and
the block projection and mesh plans divide their edges by it.

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-16 17:08:27 -04:00
1 parent c611080182
commit b62be1ada6
20 files changed
+591 -25

No files matched your search

+1
View File
@@ -104,6 +104,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.set.svg, assets/icons/mesh.size.set.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.set.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.seed.edge.svg, assets/icons/mesh.seed.edge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.seed.edge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.smooth.svg, assets/icons/mesh.smooth.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.smooth.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+1 -1
View File
@@ -28,7 +28,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|---|---|---|
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in; size controls done (#435): `mesh.size.set` on faces, bodies, a box or a sphere with a growth rate (`mesh.size.growth`), saved with the model, listed by `mesh.size.list`, drawn in the viewport, honoured by the tet mesher's graded field, the structured hex meshers' and the block projection's divisions and the octree's depth); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | 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 |
| 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 |
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.size.set</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 3 L21 3 L21 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 21 M3 12 L21 12" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 12 L21 12 L21 21 L12 21 Z" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.5 12 L16.5 21 M12 16.5 L21 16.5" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [3, 3, 18, 18], "fill": "soft", "stroke": "line"}, {"d": "M12 3 L12 21 M3 12 L21 12", "stroke": "line", "width": 1}, {"rect": [12, 12, 9, 9], "fill": null, "stroke": "accent", "width": 1.5}, {"d": "M16.5 12 L16.5 21 M12 16.5 L21 16.5", "stroke": "accent", "width": 1}]}
+8
View File
@@ -336,6 +336,14 @@ pub static CATALOGUE: &[Capability] = &[
"Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them",
&["mesh.seed.clear", "mesh.seed.list"],
),
ready_more(
"mesh.size.set",
"Local Size",
Mesh,
"Size",
"Element size on faces or bodies, in a box or in a sphere, growing away at a set rate; every mesher honours it",
&["mesh.size.growth", "mesh.size.clear", "mesh.size.list"],
),
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
ready(
"mesh.tet.generate",
+8 -4
View File
@@ -28,7 +28,6 @@ use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_mesh::cdt::CdtOptions;
use fieldcraft_mesh::hex::{Cell, CellsMesh, HexMesh, HexMethod, HexOptions, hex_mesh_swept_cells};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{ElementFace, GeometryRef, Model, Set, Tie};
@@ -173,7 +172,10 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = hex_params.as_object_mut() {
o.remove("order");
}
// The cells are meshed at one size, so the size controls apply to all of them alike.
let sizing = super::sizes::sizing(s)?.joint(&names.iter().map(String::as_str).collect::<Vec<_>>());
let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?;
let tet_options = sizing.tet_options("", &tet_params, &tet_options);
let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets };
let seeds = super::seeds::edge_seeds(s)?;
@@ -204,7 +206,7 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|&i| shapes.get(i))
.map(|shape| Ok(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }))
.collect::<Result<_>>()?;
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds)? {
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds, &sizing)? {
Ok(cm) => {
for &i in &members {
if let Some(slot) = joint.get_mut(i) {
@@ -229,7 +231,7 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? });
let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> {
let b = input()?;
let mesh = hex_body(&hex_params, &b, m, quadratic, size)?;
let mesh = hex_body(&hex_params, &b, m, quadratic, size, &sizing)?;
Ok((b, mesh))
};
let (cell, mut info) = match method {
@@ -363,8 +365,10 @@ fn swept_together(
size: Option<f64>,
tolerance: f64,
seeds: &[fieldcraft_mesh::seeds::EdgeSeed],
sizing: &super::sizes::Sizing,
) -> Result<std::result::Result<CellsMesh, String>> {
let h = size.ok_or_else(|| EngineError::Params("no element size".into()))?;
let field = sizing.field("", h)?;
let mut surfaces = Vec::with_capacity(inputs.len());
for b in inputs {
let options = super::mesh::surface_options(p, &b.mesh)?;
@@ -382,7 +386,7 @@ fn swept_together(
seeds: seeds.to_vec(),
..HexOptions::default()
};
match hex_mesh_swept_cells(&cells, &UniformSize(h), &options) {
match hex_mesh_swept_cells(&cells, &field, &options) {
Ok(cm) => Ok(Ok(cm)),
Err(fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled)) => Err(EngineError::Model(fieldcraft_model::Error::Cancelled)),
Err(e @ fieldcraft_mesh::Error::Limit(_)) => Err(EngineError::Params(e.to_string())),
+31
View File
@@ -1480,6 +1480,37 @@ pub const SEED_EDGE: &[Field] = &[
pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").help("The edges to clear, as JSON; every seed when empty.").hint("every seed")];
pub const SIZE_SET: &[Field] = &[
NAME,
positive("size", "Element size", D::Length).help("The element size in the region.").req(),
face_rows("faces", "Faces").help("Faces to refine. Pick them, then Use as faces.").hint("or bodies, a box or a sphere"),
references("bodies", "Bodies", C::Body).help("Whole bodies meshed at this size.").hint("or faces, a box or a sphere"),
group(
"box",
"Box",
&[
xyz("min", "Box min", D::Length).help("The box corner with the smallest coordinates.").req(),
xyz("max", "Box max", D::Length).help("The box corner with the largest coordinates.").req(),
],
)
.help("An axis-aligned box to refine."),
group(
"sphere",
"Sphere",
&[
xyz("center", "Centre", D::Length).help("The sphere's centre; click the model to place it.").req(),
positive("radius", "Radius", D::Length).help("The sphere's radius.").req(),
],
)
.help("A sphere to refine."),
];
pub const SIZE_GROWTH: &[Field] =
&[range("rate", "Growth rate", D::None, 0.05, 2.0).help("The largest change of element size per unit length away from a local size.").def("0.3")];
pub const SIZE_CLEAR: &[Field] =
&[references("names", "Local sizes", C::SizeControl).help("The size controls to remove; every one when empty.").hint("every one")];
pub const DELAUNAY_POINTS: &[Field] = &[points("points", "Points", D::Length, 20_000).help("The points to triangulate.").req()];
pub const TAG: &[Field] = &[
+14 -10
View File
@@ -5,18 +5,18 @@ use std::borrow::Cow;
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
use fieldcraft_mesh::hex::{BoundaryFace, Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{GeometryRef, Model};
use serde_json::{Value, json};
use super::sizes::Sizing;
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_GENERATE: CommandSpec = CommandSpec {
id: "mesh.hex.generate",
label: "Hex Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Size controls (mesh.size.set) set the division counts and spacing of the other edges through the graded size field (a structured mesh carries a count across its block, so a control refines the whole layer or row it lies in). Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
form: Some(super::forms::HEX_GENERATE),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
@@ -158,7 +158,7 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
pub const HEX_BLOCKS: CommandSpec = CommandSpec {
id: "mesh.hex.blocks",
label: "Block Hex Mesh",
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets, size controls (mesh.size.set; one on a whole cell refines its surface) setting the counts and spacing through the graded size field. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
form: Some(super::forms::HEX_BLOCKS),
kind: CommandKind::Model,
requires: &[Requirement::Design],
@@ -232,7 +232,9 @@ pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
seeds: super::seeds::edge_seeds(s)?,
..HexOptions::default()
};
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| match e {
let names: Vec<&str> = inputs.iter().map(|b| b.name.as_str()).collect();
let field = super::sizes::sizing(s)?.joint(&names).field("", h)?;
let cm = hex_mesh_cells(&cells, &field, &options).map_err(|e| match e {
fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled) => EngineError::Model(fieldcraft_model::Error::Cancelled),
e => EngineError::Params(e.to_string()),
})?;
@@ -297,9 +299,10 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
})?,
};
let bodies = inputs(s, p)?;
let sizing = super::sizes::sizing(s)?;
let mut meshes: Vec<(Input, HexMesh)> = Vec::with_capacity(bodies.len());
for b in bodies {
let mesh = hex_body(p, &b, method, quadratic, size)?;
let mesh = hex_body(p, &b, method, quadratic, size, &sizing)?;
meshes.push((b, mesh));
}
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
@@ -327,13 +330,13 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
}
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent);
/// the model is not touched. Errors name the body.
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>) -> Result<HexMesh> {
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent) and
/// the size controls of `sizing`; the model is not touched. Errors name the body.
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>, sizing: &Sizing) -> Result<HexMesh> {
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
}
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
let h = sizing.body_size(&b.name, size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal));
if h < MIN_RELATIVE_SIZE * b.diagonal {
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
}
@@ -349,7 +352,8 @@ pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool,
seeds: b.seeds.clone(),
..HexOptions::default()
};
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::of_body(&b.name, e))
let field = sizing.field(&b.name, h)?;
hex_mesh(&surface, &b.surfaces, &field, &options).map_err(|e| EngineError::of_body(&b.name, e))
}
/// Add a mesh of several cells (`inputs[c]` is cell `c`) after the model's largest labels, its
+3 -1
View File
@@ -127,7 +127,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
pub const TET_GENERATE: CommandSpec = CommandSpec {
id: "mesh.tet.generate",
label: "Generate Tet Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2; default the model's growth rate, mesh.size.growth), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them; size controls (mesh.size.set) refine it, graded at the model's growth rate (mesh.size.growth) unless `grading` is given. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
form: Some(super::forms::TET),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
@@ -315,9 +315,11 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets };
// Every body is meshed before the model changes; with several, an error names its body.
let inputs = super::hex::inputs(s, p)?;
let sizing = super::sizes::sizing(s)?;
let several = inputs.len() > 1;
let mut meshed = Vec::with_capacity(inputs.len());
for input in inputs {
let options = sizing.tet_options(&input.name, p, &options);
meshed.push(mesh_body(s.design.as_deref(), p, input, &cdt_options, &options, quality, several)?);
}
+5
View File
@@ -31,6 +31,7 @@ pub(crate) mod relax;
pub mod replace;
mod results;
pub(crate) mod seeds;
pub(crate) mod sizes;
pub(crate) mod solve;
pub(crate) mod stitch;
mod symmetry;
@@ -106,6 +107,10 @@ pub static ALL: &[CommandSpec] = &[
seeds::SEED_EDGE,
seeds::SEED_CLEAR,
seeds::SEED_LIST,
sizes::SIZE_SET,
sizes::SIZE_GROWTH,
sizes::SIZE_CLEAR,
sizes::SIZE_LIST,
mirror::MIRROR,
nodes::MERGE,
nodes::DUPLICATES,
+5 -4
View File
@@ -2,7 +2,6 @@
//! imported surfaces.
use fieldcraft_mesh::octree::{OctreeHexMesh, OctreeOptions, octree_hex_mesh};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
@@ -15,7 +14,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_OCTREE: CommandSpec = CommandSpec {
id: "mesh.hex.octree",
label: "Octree Hex Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 4 (the most refinement levels below the size, each a third, 0..=5; the levels the part's thinnest wall and narrowest face need are found before meshing, and a part that needs more levels or more than max_elements cells fails at once, naming them), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics (root cell size, levels, the thinnest wall and narrowest face, cells per level after refinement and after closure)"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 4 (the most refinement levels below the size, each a third, 0..=5; the levels the part's thinnest wall, narrowest face and smallest local size (mesh.size.set) need are found before meshing, and a part that needs more levels or more than max_elements cells fails at once, naming them), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid (root cells at `size`, refined where the size controls ask for smaller elements) with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics (root cell size, levels, the thinnest wall and narrowest face, cells per level after refinement and after closure)"#,
form: Some(super::forms::HEX_OCTREE),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
@@ -46,13 +45,14 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
..defaults
};
let bodies = inputs(s, p)?;
let sizing = super::sizes::sizing(s)?;
let mut meshes: Vec<(Input, OctreeHexMesh)> = Vec::with_capacity(bodies.len());
let mut total = 0usize;
for b in bodies {
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
}
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
let h = sizing.body_size(&b.name, size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal));
if h < MIN_RELATIVE_SIZE * b.diagonal {
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
}
@@ -60,7 +60,8 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::of_body(&b.name, e))?;
let field = sizing.field(&b.name, h)?;
let mesh = octree_hex_mesh(&surface, &field, &left).map_err(|e| EngineError::of_body(&b.name, e))?;
total += mesh.hexes.len();
meshes.push((b, mesh));
}
+4 -2
View File
@@ -1567,7 +1567,8 @@ fn corner_meshes(
c: f64,
tolerance: f64,
) -> Result<CornerCopy> {
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, ..Default::default() };
let field = super::sizes::sizing(s)?.local_field(plan.size)?;
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, field, ..Default::default() };
let whole_body = super::project::open_body(s, cell, Some(tolerance), &json!({}))?;
let merge = super::project::merge_distance(None, whole_body.diagonal())?;
// What the cell meshed whole gets (no corner may be worse), and per candidate cut the whole
@@ -1783,7 +1784,8 @@ fn apply_projection(s: &mut Session, name: &str, plan: &MeshPlan, tolerance: f64
// Cut faces another cell's mesh already covers are taken as given, so the cells meet node
// to node there.
let (fixed, fixed_faces) = cut_face_patches(s, &body, &plan.body, plan.size)?;
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, ..Default::default() };
let field = super::sizes::sizing(s)?.local_field(plan.size)?;
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, field, ..Default::default() };
let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge)
.map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")))?;
if let Some(m) = mesh.as_object_mut() {
+4 -1
View File
@@ -485,7 +485,9 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
bounded(&p.fixed, "fixed")?;
let b = open_body(s, &p.body, p.tolerance, raw)?;
let diag = b.input.diagonal;
let size = element_size(p.size, diag)?;
let sizing = super::sizes::sizing(s)?;
let size = element_size(sizing.bodies.get(&p.body).copied().or(p.size), diag)?;
let field = sizing.local_field(size)?;
let mut t = topology(&p, b.lo, b.hi)?;
operate(&mut t, &p.operations)?;
if p.preview {
@@ -545,6 +547,7 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
min_quality,
fixed,
planes: Vec::new(),
field,
};
let merge = merge_distance(p.merge, diag)?;
project_body(s, &p.body, b, &t, &opts, merge)
+461
View File
@@ -0,0 +1,461 @@
//! Size controls: `mesh.size.set` asks for elements of a size on faces or bodies, in a box or in
//! a sphere; `mesh.size.growth` sets how fast the size grows away from them; `mesh.size.clear`
//! removes controls and `mesh.size.list` lists them with what each covers in the open design.
//! Controls live in the model (`fieldcraft_model::sizes`), are saved with it, and every mesher
//! reads them through [`Sizing`]: the tetrahedral mesher bakes them into its graded size field,
//! the structured hex meshers and the octree take the exact graded field
//! ([`fieldcraft_mesh::size::RegionSize`]), and the block projection (`mesh.hex.project`) divides
//! its block edges by it.
use std::collections::BTreeMap;
use fieldcraft_geometry::{Design, Shape};
use fieldcraft_mesh::size::{LocalSize, Region, RegionSize};
use fieldcraft_mesh::tet::QualityOptions;
use fieldcraft_model::{Entity, GeometryRef, SizeControl, SizeRegion};
use serde::Deserialize;
use serde_json::{Value, json};
use super::opt;
use crate::requirement::Requirement;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
/// Most controls one `mesh.size.clear` names.
const MAX_NAMES: usize = 10_000;
/// The chordal tolerance of the tessellation a face control is resolved on, relative to the
/// body's diagonal (as the meshers' default).
const FACE_TOLERANCE: f64 = 1e-3;
pub const SIZE_SET: CommandSpec = CommandSpec {
id: "mesh.size.set",
label: "Local Size",
params: r#"{"name": "Hole", "size": 0.5, and one of "faces": [{"body": "Body1", "face": "F3"}, …] (persistent face names, geometry.bodies; a name without its `#k` number takes every piece), "bodies": ["Body1", …], "box": {"min": [x, y, z], "max": [x, y, z]}, "sphere": {"center": [x, y, z], "radius": r}} — adds the size control `name`, or replaces it: elements of about `size` on those faces, in those bodies, in the box or in the sphere. Every mesher honours it: the size at a point is the smallest of the mesher's size and, over the controls, a control's size plus the growth rate (mesh.size.growth) times the distance to its region, so elements grow away from it gradually. A control finer than the mesher's size refines; a coarser one changes nothing, except on whole bodies, where it is the body's size in place of the mesher's `size` (meshers that mesh several bodies together, mesh.hex.blocks and mesh.cells, take it as a control on the body's surface). Saved with the model; mesh.size.list shows it"#,
form: Some(super::forms::SIZE_SET),
kind: CommandKind::Model,
requires: &[],
run: size_set,
};
pub const SIZE_GROWTH: CommandSpec = CommandSpec {
id: "mesh.size.growth",
label: "Size Growth",
params: r#"{"rate": 0.3 (0.05..=2; omitted or null: back to the default, 0.3)} — the size field's growth rate: the largest change of element size per unit length away from a size control or an edge seed. mesh.tet.generate's `grading` overrides it for one run"#,
form: Some(super::forms::SIZE_GROWTH),
kind: CommandKind::Model,
requires: &[],
run: size_growth,
};
pub const SIZE_CLEAR: CommandSpec = CommandSpec {
id: "mesh.size.clear",
label: "Clear Local Sizes",
params: r#"{"names"?: ["Hole", …]} — removes these size controls, or every one"#,
form: Some(super::forms::SIZE_CLEAR),
kind: CommandKind::Model,
requires: &[Requirement::SizeControl],
run: size_clear,
};
pub const SIZE_LIST: CommandSpec = CommandSpec {
id: "mesh.size.list",
label: "Local Sizes",
params: "{} — the size controls by name, each with its region and size and, for faces and bodies, what it covers in the open design (`stale`: names the design no longer has), and the growth rate",
form: Some(&[]),
kind: CommandKind::Model,
requires: &[],
run: size_list,
};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BoxParams {
min: [f64; 3],
max: [f64; 3],
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SphereParams {
center: [f64; 3],
radius: f64,
}
fn size_set(s: &mut Session, p: &Value) -> Result<Value> {
let name: String = opt(p, "name")?.ok_or_else(|| EngineError::Params("`name` is required".into()))?;
let size: f64 = opt(p, "size")?.ok_or_else(|| EngineError::Params("`size` is required: the element size".into()))?;
let faces: Option<Vec<GeometryRef>> = opt(p, "faces")?;
let bodies: Option<Vec<String>> = opt(p, "bodies")?;
let boxed: Option<BoxParams> = opt(p, "box")?;
let sphere: Option<SphereParams> = opt(p, "sphere")?;
let given = [faces.is_some(), bodies.is_some(), boxed.is_some(), sphere.is_some()].iter().filter(|g| **g).count();
if given != 1 {
return Err(EngineError::Params("give one of `faces`, `bodies`, `box` or `sphere`".into()));
}
let region = if let Some(faces) = faces {
if let Some(r) = faces.iter().find(|r| !matches!(r.entity, Entity::Face(_))) {
return Err(EngineError::Params(format!("`faces` lists the {r}; name faces as {{body, face}}")));
}
SizeRegion::Geometry(faces)
} else if let Some(bodies) = bodies {
SizeRegion::Geometry(bodies.into_iter().map(|b| GeometryRef::new(b, Entity::Body)).collect::<fieldcraft_model::Result<_>>()?)
} else if let Some(b) = boxed {
SizeRegion::Box { min: b.min, max: b.max }
} else if let Some(sp) = sphere {
SizeRegion::Sphere { center: sp.center, radius: sp.radius }
} else {
return Err(EngineError::Params("give one of `faces`, `bodies`, `box` or `sphere`".into()));
};
let control = SizeControl { region, size };
// Faces and bodies must name the open design's, when there is one.
if let (Some(design), SizeRegion::Geometry(refs)) = (s.design.as_deref(), &control.region)
&& let Some(r) = refs.iter().find(|r| covered(design, r).is_ok_and(|c| c.is_empty()))
{
return Err(EngineError::Params(format!("the design has no {r} (geometry.bodies lists the faces)")));
}
s.model.set_size_control(&name, control.clone())?;
Ok(report(s, &name, &control))
}
fn size_growth(s: &mut Session, p: &Value) -> Result<Value> {
let rate: Option<f64> = opt(p, "rate")?;
s.model.set_size_growth(rate)?;
Ok(json!({"growth": s.model.size_growth(), "default": !s.model.size_growth_set()}))
}
fn size_clear(s: &mut Session, p: &Value) -> Result<Value> {
let names: Option<Vec<String>> = opt(p, "names")?;
let cleared = match names {
None => {
let all: Vec<String> = s.model.size_controls().keys().cloned().collect();
if all.is_empty() {
return Err(EngineError::Params("the model has no size controls".into()));
}
for n in &all {
s.model.remove_size_control(n)?;
}
all.len()
}
Some(names) => {
if names.is_empty() || names.len() > MAX_NAMES {
return Err(EngineError::Params(format!("`names` must list 1 to {MAX_NAMES} size controls")));
}
for n in &names {
s.model.remove_size_control(n)?;
}
names.len()
}
};
Ok(json!({"cleared": cleared, "left": s.model.size_controls().len()}))
}
fn size_list(s: &mut Session, _: &Value) -> Result<Value> {
let controls: serde_json::Map<String, Value> = s.model.size_controls().iter().map(|(n, c)| (n.clone(), report(s, n, c))).collect();
Ok(json!({"controls": controls, "growth": s.model.size_growth()}))
}
/// A control's report: its definition and, for faces and bodies, the names each covers in the
/// open design.
fn report(s: &Session, name: &str, c: &SizeControl) -> Value {
let mut v = json!({"name": name, "size": c.size, "region": c.region});
if let (SizeRegion::Geometry(refs), Some(design)) = (&c.region, s.design.as_deref()) {
let on: Vec<Value> = refs.iter().map(|r| json!({"target": r.to_string(), "covers": covered(design, r).unwrap_or_default()})).collect();
v["stale"] = json!(on.iter().any(|o| o["covers"].as_array().is_none_or(Vec::is_empty)));
v["on"] = json!(on);
}
v
}
/// The faces a face reference selects (its `#k` pieces too), or the body a body reference names.
fn covered(design: &Design, r: &GeometryRef) -> Result<Vec<String>> {
let Some(body) = design.body(&r.body) else { return Ok(Vec::new()) };
Ok(match r.entity {
Entity::Body => vec![body.name.clone()],
_ => body.faces().iter().filter(|f| selects(r, &body.name, &f.name)).map(|f| f.name.clone()).collect(),
})
}
fn selects(r: &GeometryRef, body: &str, face: &str) -> bool {
GeometryRef::new(body.to_string(), Entity::Face(face.to_string())).is_ok_and(|g| r.selects(&g))
}
/// The model's size controls resolved on the open design, as the meshers take them.
#[derive(Debug, Clone, Default)]
pub(crate) struct Sizing {
/// Local sizes on faces, in boxes and in spheres.
pub(crate) locals: Vec<LocalSize>,
/// Per body, the size its body controls set.
pub(crate) bodies: BTreeMap<String, f64>,
/// Per body, its boundary surface's triangles (for the meshers that take a body control as
/// a local size on its surface).
surfaces: BTreeMap<String, Vec<[[f64; 3]; 3]>>,
pub(crate) growth: f64,
}
/// The triangles of `shape`'s faces that `select` picks.
fn triangles(shape: &Shape, select: impl Fn(&str) -> bool) -> Result<Vec<[[f64; 3]; 3]>> {
let tolerance = FACE_TOLERANCE * shape.size()?;
let mesh = shape.tessellate(tolerance)?;
let names = shape.faces();
let point = |i: u32| mesh.positions.get(i as usize).copied();
Ok(mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| names.get(**f as usize).is_some_and(|n| select(&n.name)))
.filter_map(|(t, _)| Some([point(t[0])?, point(t[1])?, point(t[2])?]))
.collect())
}
/// Resolve the model's size controls on the open design. Face controls on faces the design does
/// not have, and body controls on bodies it does not have, are left out (mesh.size.list shows
/// them as stale).
pub(crate) fn sizing(s: &Session) -> Result<Sizing> {
let mut out = Sizing { growth: s.model.size_growth(), ..Sizing::default() };
for c in s.model.size_controls().values() {
let region = match &c.region {
SizeRegion::Box { min, max } => Region::Box { lo: *min, hi: *max },
SizeRegion::Sphere { center, radius } => Region::Sphere { center: *center, radius: *radius },
SizeRegion::Geometry(refs) => {
let Some(design) = s.design.as_deref() else { continue };
let mut tris = Vec::new();
for r in refs {
let Some(shape) = design.body(&r.body) else { continue };
if r.entity == Entity::Body {
let size = out.bodies.entry(shape.name.clone()).or_insert(c.size);
*size = size.min(c.size);
if !out.surfaces.contains_key(&shape.name) {
out.surfaces.insert(shape.name.clone(), triangles(shape, |_| true)?);
}
} else {
tris.extend(triangles(shape, |f| selects(r, &shape.name, f))?);
}
}
if tris.is_empty() {
continue;
}
Region::Triangles(tris)
}
};
out.locals.push(LocalSize { region, size: c.size });
}
Ok(out)
}
impl Sizing {
/// The size of `body` meshed alone: its body control's, else `size`.
pub(crate) fn body_size(&self, body: &str, size: f64) -> f64 {
self.bodies.get(body).copied().unwrap_or(size)
}
/// The sizing of several bodies meshed together at one size, so the curves and faces they
/// share are divided alike: the face, box and sphere controls, and each body control as a
/// local size on that body's surface.
pub(crate) fn joint(&self, bodies: &[&str]) -> Sizing {
let mut locals = self.locals.clone();
for b in bodies {
if let (Some(&size), Some(tris)) = (self.bodies.get(*b), self.surfaces.get(*b)) {
locals.push(LocalSize { region: Region::Triangles(tris.clone()), size });
}
}
Sizing { locals, growth: self.growth, ..Sizing::default() }
}
/// The graded field of `body` meshed at `size` (its body control's size in place of it).
pub(crate) fn field(&self, body: &str, size: f64) -> Result<RegionSize> {
Ok(RegionSize::new(self.body_size(body, size), self.growth, self.locals.clone())?)
}
/// The graded field of the face, box and sphere controls about a size already chosen (body
/// controls taken into it, or a mesh plan's size), for a mesher that takes a uniform size
/// and an optional field; `None` when no control refines it.
pub(crate) fn local_field(&self, size: f64) -> Result<Option<RegionSize>> {
Ok(Some(RegionSize::new(size, self.growth, self.locals.clone())?).filter(|f| !f.is_uniform()))
}
/// The tetrahedral mesher's options for `body`: its body control's size in place of the
/// global one, the local sizes, and the growth rate unless the command gives `grading`.
pub(crate) fn tet_options(&self, body: &str, p: &Value, o: &QualityOptions) -> QualityOptions {
QualityOptions {
size: self.bodies.get(body).copied().or(o.size),
locals: self.locals.clone(),
grading: if p.get("grading").is_some() { o.grading } else { self.growth },
..o.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plate() -> (Session, std::path::PathBuf) {
super::super::datum::tests::plate()
}
/// The name of the plate's face that lies in the plane x = `x`.
fn face_at_x(s: &Session, x: f64) -> String {
let body = s.design.as_ref().unwrap().body("Body1").unwrap();
let mesh = body.tessellate(0.1).unwrap();
let faces = body.faces();
(0..faces.len())
.find(|&f| {
let mut pts = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).flat_map(|(t, _)| t.iter());
pts.clone().next().is_some() && pts.all(|&v| (mesh.positions[v as usize][0] - x).abs() < 1e-6)
})
.map(|f| faces[f].name.clone())
.unwrap()
}
/// Every element edge of the model's tet4 or hex8 elements, once: its two end points.
fn edges(s: &Session) -> Vec<([f64; 3], [f64; 3])> {
const TET: &[[usize; 2]] = &[[0, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]];
const HEX: &[[usize; 2]] = &[[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for e in s.model.elements().values() {
let pairs = if e.nodes.len() == 4 { TET } else { HEX };
for [a, b] in pairs {
let (a, b) = (e.nodes[*a], e.nodes[*b]);
if seen.insert((a.min(b), a.max(b))) {
out.push((s.model.nodes()[&a].xyz, s.model.nodes()[&b].xyz));
}
}
}
out
}
fn length((a, b): &([f64; 3], [f64; 3])) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
fn mean(v: &[f64]) -> f64 {
assert!(!v.is_empty());
v.iter().sum::<f64>() / v.len() as f64
}
#[test]
fn controls_are_set_listed_cleared_saved_and_undone() {
let (mut s, dir) = plate();
let end = face_at_x(&s, 40.0);
let r = s.execute("mesh.size.set", &json!({"name": "End", "size": 1.5, "faces": [{"body": "Body1", "face": end}]})).unwrap();
assert_eq!(r["on"][0]["covers"], json!([end]), "{r}");
assert_eq!(r["stale"], false);
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 2, "box": {"min": [0, 0, 0], "max": [5, 5, 10]}})).unwrap();
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 2, "sphere": {"center": [0, 0, 0], "radius": 3}})).unwrap();
s.execute("mesh.size.set", &json!({"name": "Whole", "size": 4, "bodies": ["Body1"]})).unwrap();
s.execute("mesh.size.growth", &json!({"rate": 0.5})).unwrap();
let list = s.execute("mesh.size.list", &json!({})).unwrap();
assert_eq!(list["controls"].as_object().unwrap().len(), 3, "{list}");
assert_eq!(list["controls"]["Zone"]["region"]["sphere"]["radius"], 3.0);
assert_eq!(list["growth"], 0.5);
let file = dir.join("sized.fieldcraft");
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
let mut back = Session::default();
back.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert_eq!(back.model.size_controls(), s.model.size_controls());
assert_eq!(back.model.size_growth(), 0.5);
s.execute("mesh.size.clear", &json!({"names": ["Zone"]})).unwrap();
assert_eq!(s.model.size_controls().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.size_controls().len(), 3);
assert!(s.execute("mesh.size.clear", &json!({"names": ["Nope"]})).is_err());
assert_eq!(s.execute("mesh.size.clear", &json!({})).unwrap()["cleared"], 3);
assert!(s.execute("mesh.size.clear", &json!({})).is_err(), "nothing to clear");
s.execute("mesh.size.growth", &json!({})).unwrap();
assert_eq!(s.model.size_growth(), fieldcraft_model::sizes::DEFAULT_GROWTH);
}
#[test]
fn hostile_params_never_panic() {
let (mut s, _) = plate();
for p in [
json!({}),
json!({"name": "A"}),
json!({"name": "A", "size": 1}),
json!({"name": "A", "size": 0, "bodies": ["Body1"]}),
json!({"name": "A", "size": -1, "bodies": ["Body1"]}),
json!({"name": "A", "size": 1e300, "bodies": ["Body1"]}),
json!({"name": "A", "size": "big", "bodies": ["Body1"]}),
json!({"name": "A", "size": 1, "bodies": ["Nope"]}),
json!({"name": "A", "size": 1, "bodies": []}),
json!({"name": "A", "size": 1, "faces": [{"body": "Body1", "face": "Nope"}]}),
json!({"name": "A", "size": 1, "faces": [{"body": "Body1", "edge": "E"}]}),
json!({"name": "A", "size": 1, "bodies": ["Body1"], "box": {"min": [0, 0, 0], "max": [1, 1, 1]}}),
json!({"name": "A", "size": 1, "box": {"min": [1, 0, 0], "max": [0, 1, 1]}}),
json!({"name": "A", "size": 1, "box": {"min": [0, 0], "max": [1, 1, 1]}}),
json!({"name": "A", "size": 1, "sphere": {"center": [0, 0, 0], "radius": 0}}),
json!({"name": "A", "size": 1, "sphere": {"center": [0, 0, 0], "radius": 1, "x": 1}}),
json!({"name": "1 bad", "size": 1, "bodies": ["Body1"]}),
] {
assert!(s.execute("mesh.size.set", &p).is_err(), "{p}");
}
for p in [json!({"rate": 0}), json!({"rate": 5}), json!({"rate": "x"})] {
assert!(s.execute("mesh.size.growth", &p).is_err(), "{p}");
}
assert!(s.execute("mesh.size.clear", &json!({"names": []})).is_err());
assert!(s.model.size_controls().is_empty());
}
/// A size box halves the tetrahedra inside it; a face control gives its face the size it
/// asks for, and the elements grow away from it no faster than the growth rate allows.
#[test]
fn tet_meshes_follow_size_controls() {
let (mut s, _) = plate();
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
let inside = |e: &&([f64; 3], [f64; 3])| [e.0, e.1].iter().all(|p| p[0] >= 30.0 && p[1] >= 0.0 && p[1] <= 10.0);
let before = mean(&edges(&s).iter().filter(inside).map(length).collect::<Vec<_>>());
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 3, "box": {"min": [30, -1, -1], "max": [41, 10, 11]}})).unwrap();
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
let after = mean(&edges(&s).iter().filter(inside).map(length).collect::<Vec<_>>());
assert!((after / before - 0.5).abs() < 0.15, "box: {before} -> {after}");
s.execute("mesh.size.clear", &json!({})).unwrap();
let end = face_at_x(&s, 40.0);
s.execute("mesh.size.set", &json!({"name": "End", "size": 1.5, "faces": [{"body": "Body1", "face": end}]})).unwrap();
s.execute("mesh.size.growth", &json!({"rate": 0.5})).unwrap();
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
let all = edges(&s);
let on_face: Vec<f64> = all.iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-6 && (e.1[0] - 40.0).abs() < 1e-6).map(length).collect();
let m = mean(&on_face);
assert!((1.2..1.95).contains(&m), "on the face: {m}");
// Slabs away from the face: the mean edge stays near 1.5 + 0.5 d (and below 6).
for d in [3.0, 6.0, 9.0] {
let slab: Vec<f64> = all.iter().filter(|e| (40.0 - 0.5 * (e.0[0] + e.1[0]) - d).abs() < 1.0).map(length).collect();
let want = (1.5f64 + 0.5 * d).min(6.0);
let m = mean(&slab);
assert!(m < want * 1.3 && m > want * 0.5, "{d} mm from the face: mean edge {m}, field {want}");
}
}
/// A face control sets the hex divisions of the face's edges: the swept plate's end face gets
/// elements of the size asked for.
#[test]
fn hex_meshes_follow_size_controls() {
let (mut s, _) = plate();
let end = face_at_x(&s, 40.0);
s.execute("mesh.hex.generate", &json!({"body": "Body1", "size": 5})).unwrap();
let on_end = |s: &Session| -> Vec<f64> {
edges(s).iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-6 && (e.1[0] - 40.0).abs() < 1e-6).map(length).collect()
};
let coarse = on_end(&s).into_iter().fold(0.0, f64::max);
assert!(coarse > 3.0, "{coarse}");
s.execute("mesh.size.set", &json!({"name": "End", "size": 1.25, "faces": [{"body": "Body1", "face": end}]})).unwrap();
s.execute("mesh.hex.generate", &json!({"body": "Body1", "size": 5})).unwrap();
let fine = on_end(&s);
assert!(fine.iter().all(|&l| l < 1.25 * 1.3), "{fine:?}");
assert!(mean(&fine) > 1.25 * 0.7, "{fine:?}");
}
/// The octree's cells on a face with a size control reach the level its size needs.
#[test]
fn octree_meshes_follow_size_controls() {
let (mut s, _) = plate();
let end = face_at_x(&s, 40.0);
s.execute("mesh.size.set", &json!({"name": "End", "size": 2, "faces": [{"body": "Body1", "face": end}]})).unwrap();
s.execute("mesh.hex.octree", &json!({"body": "Body1", "size": 6})).unwrap();
let on_end: Vec<f64> = edges(&s).iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-3 && (e.1[0] - 40.0).abs() < 1e-3).map(length).collect();
assert!(!on_end.is_empty() && mean(&on_end) < 2.0 * 1.3, "{on_end:?}");
}
}
+6
View File
@@ -40,6 +40,8 @@ pub enum Requirement {
FaceSplit,
/// An edge seed.
Seed,
/// A size control.
SizeControl,
/// At least one mesh node.
Nodes,
/// At least one element.
@@ -89,6 +91,7 @@ impl Requirement {
Requirement::Stitch => !m.stitches().is_empty(),
Requirement::FaceSplit => !m.face_splits().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::SizeControl => !m.size_controls().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements,
Requirement::Analysable => {
@@ -132,6 +135,7 @@ impl Requirement {
Requirement::Stitch => "a stitch",
Requirement::FaceSplit => "a face split",
Requirement::Seed => "an edge seed",
Requirement::SizeControl => "a local size",
Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh",
Requirement::ElementMaterial | Requirement::Material => "a material",
@@ -165,6 +169,7 @@ impl Requirement {
Requirement::Stitch => "geometry.stitch",
Requirement::FaceSplit => "geometry.face.split",
Requirement::Seed => "mesh.seed.edge",
Requirement::SizeControl => "mesh.size.set",
Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
Requirement::ElementMaterial | Requirement::Material => "model.material.create",
@@ -224,6 +229,7 @@ impl crate::schema::Collection {
C::FaceMerge => Requirement::FaceMerge,
C::Stitch => Requirement::Stitch,
C::FaceSplit => Requirement::FaceSplit,
C::SizeControl => Requirement::SizeControl,
}
}
}
+4
View File
@@ -223,6 +223,8 @@ pub enum Collection {
Stitch,
/// A face split (`geometry.face.split`).
FaceSplit,
/// A size control (`mesh.size.set`).
SizeControl,
}
impl Collection {
@@ -251,6 +253,7 @@ impl Collection {
Collection::FaceMerge => "face merge",
Collection::Stitch => "stitch",
Collection::FaceSplit => "face split",
Collection::SizeControl => "size control",
}
}
}
@@ -283,6 +286,7 @@ impl Session {
Collection::FaceMerge => Box::new(m.face_merges().iter().map(|f| f.name.as_str())),
Collection::Stitch => Box::new(m.stitches().iter().map(|f| f.name.as_str())),
Collection::FaceSplit => Box::new(m.face_splits().iter().map(|f| f.name.as_str())),
Collection::SizeControl => Box::new(m.size_controls().keys().map(String::as_str)),
};
names.take(MAX_NAMES).map(str::to_string).collect()
}
+2 -1
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,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
82 ready, 37 coming soon.
83 ready, 37 coming soon.
## Geometry
@@ -34,6 +34,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|---|---|---|---|
| Size | Global Size — One element size for the whole part | coming soon (M2) | |
| Size | Seed Edges — Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them | ready | `mesh.seed.edge`, `mesh.seed.clear`, `mesh.seed.list` |
| Size | Local Size — Element size on faces or bodies, in a box or in a sphere, growing away at a set rate; every mesher honours it | ready | `mesh.size.set`, `mesh.size.growth`, `mesh.size.clear`, `mesh.size.list` |
| Size | Curvature — Refine by curvature and proximity | coming soon (M2) | |
| Tet | Tet Mesh — Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged | ready | `mesh.tet.generate` |
| Tet | Quadratic — Second-order elements with mid-side nodes on the geometry | coming soon (M2) | |
+21
View File
@@ -185,6 +185,27 @@ edges of partition cells matching counts.
4. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when the design has no such
edge); `mesh.seed.clear {edges?}` removes them. Seeds are saved with the model.
## Size controls
Ask for a local element size where the answer is decided (a fillet, a hole's wall, a contact
patch) instead of making the whole part fine.
1. `execute` `mesh.size.set {name, size, faces: [{body, face}] | bodies: [...] | box: {min, max} |
sphere: {center, radius}}` adds the control (or replaces the one of that name). Faces are
named as `inspect_model` lists them.
2. `mesh.size.growth {rate}` sets how fast the size grows away from a control (the largest
change of element size per unit length; 0.3 by default, 0.05 to 2).
3. Mesh: every mesher honours the controls. The size at a point is the smallest of the mesh
command's `size` and, over the controls, a control's size plus the growth rate times the
distance to it. The tet mesher refines to that field; the structured hex meshers, the block
projection and mesh plans divide their edges by it (a structured mesh carries a count across
its block, so the refinement spans the row or layer); the octree refines its cells to it (a
size that needs more levels than `max_depth` is refused before meshing). A control on whole
bodies is their size in place of the command's.
4. `mesh.size.list` lists the controls, what each covers in the open design (`stale` when the
design no longer has a face it names) and the growth rate; `mesh.size.clear {names?}` removes
them. Controls are saved with the model.
## Meshing from the feature graph
A design opened from `.solidcraft` is rebuilt from its timeline, so how each body was made is
+2 -1
View File
@@ -111,7 +111,8 @@ Shapes without a timeline (STEP, 3MF) skip `mesh.plan` and go to step 4.
pure conduction. Set `layers` in `mesh.plan`, or `mesh.seed.edge` across the wall.
- Matching counts on shared edges: `mesh.seed.edge`.
- Size from the physics: finer at stress gradients, fillets and contact; in explicit, from the
wave length.
wave length. Refine locally with `mesh.size.set` (faces, bodies, a box or a sphere) rather
than making the whole part fine; `mesh.size.growth` sets how fast it coarsens away.
## 6. Quality, mark, relax