diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 6b6a2cf..76e01cb 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -48,6 +48,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.element.add.svg, assets/icons/mesh.element.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.element.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.decompose.svg, assets/icons/mesh.hex.decompose.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.decompose.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.dominant.svg, assets/icons/mesh.hex.dominant.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.dominant.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+| assets/icons/mesh.hex.blocks.svg, assets/icons/mesh.hex.blocks.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.blocks.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.mapped.svg, assets/icons/mesh.hex.mapped.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.mapped.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.octree.svg, assets/icons/mesh.hex.octree.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.octree.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.polycube.svg, assets/icons/mesh.hex.polycube.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.polycube.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
diff --git a/assets/icons/mesh.hex.blocks.svg b/assets/icons/mesh.hex.blocks.svg
new file mode 100644
index 0000000..0627062
--- /dev/null
+++ b/assets/icons/mesh.hex.blocks.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/assets/icons/mesh.hex.blocks.vectorcraft b/assets/icons/mesh.hex.blocks.vectorcraft
new file mode 100644
index 0000000..248fba5
Binary files /dev/null and b/assets/icons/mesh.hex.blocks.vectorcraft differ
diff --git a/assets/icons/src/mesh.hex.blocks.json b/assets/icons/src/mesh.hex.blocks.json
new file mode 100644
index 0000000..d0c273d
--- /dev/null
+++ b/assets/icons/src/mesh.hex.blocks.json
@@ -0,0 +1 @@
+{"shapes": [{"d": "M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z", "fill": "soft", "stroke": "line"}, {"d": "M4 7 L12 11 L20 7 M12 11 L12 20", "stroke": "line"}, {"d": "M6 8 L6 17 M10 10 L10 19 M4 9.25 L12 13.25 M4 13.75 L12 17.75 M14 10 L14 19 M18 8 L18 17 M12 13.25 L20 9.25 M12 17.75 L20 13.75 M6 6 L14 10 M10 4 L18 8 M14 4 L6 8 M18 6 L10 10", "stroke": "accent", "width": 1}, {"d": "M8 5 L16 9 M16 5 L8 9 M8 9 L8 18 M4 11.5 L12 15.5 M16 9 L16 18 M12 15.5 L20 11.5", "stroke": "line", "width": 1.5}]}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 16a2a87..d970866 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -27,3 +27,5 @@ log = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
+# Multi-body designs from SolidCraft scripts in tests.
+fieldcraft-geometry = { workspace = true, features = ["scripts"] }
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index 420346a..8c2b5c1 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -272,6 +272,13 @@ pub static CATALOGUE: &[Capability] = &[
"Hex mesh swept along extrusions (several levels), around revolutions, or from a source face to a target",
&["mesh.hex.generate"],
),
+ ready(
+ "mesh.hex.blocks",
+ "Block Hex",
+ Mesh,
+ "Structured Hex",
+ "One conforming hex mesh of a part split into six-sided blocks: shared faces meshed once",
+ ),
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
soon("mesh.hex.octree", "All-Hex", Mesh, "Automatic Hex", "M6", "Octree dual all-hex mesh of any part"),
diff --git a/crates/engine/src/cmd/hex.rs b/crates/engine/src/cmd/hex.rs
index 49cf3c8..3fc6cbe 100644
--- a/crates/engine/src/cmd/hex.rs
+++ b/crates/engine/src/cmd/hex.rs
@@ -3,7 +3,7 @@
use std::borrow::Cow;
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
-use fieldcraft_mesh::hex::{HexMesh, HexMethod, HexOptions, Surface, hex_mesh};
+use fieldcraft_mesh::hex::{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};
@@ -129,6 +129,134 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
run: hex_mapped,
};
+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 mesh 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"#,
+ kind: CommandKind::Model,
+ run: hex_blocks,
+};
+
+fn hex_blocks(s: &mut Session, p: &Value) -> Result {
+ let size = number(p, "size")?;
+ let quadratic = order(p)?;
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ let shapes: Vec<&Shape> = match p.get("bodies") {
+ None => design.bodies.iter().collect(),
+ Some(v) => {
+ let names = v.as_array().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
+ // Bounded before the duplicate check, which is quadratic in the list.
+ if names.len() > MAX_CELLS {
+ return Err(EngineError::Params(format!("`bodies` lists {} bodies; at most {MAX_CELLS} are meshed together", names.len())));
+ }
+ let mut out: Vec<&Shape> = Vec::with_capacity(names.len());
+ for n in names {
+ let name = n.as_str().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
+ let shape = design.body(name).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?;
+ if out.iter().any(|b| b.name == shape.name) {
+ return Err(EngineError::Params(format!("body `{name}` is listed twice")));
+ }
+ out.push(shape);
+ }
+ out
+ }
+ };
+ if shapes.is_empty() || shapes.len() > MAX_CELLS {
+ return Err(EngineError::Params(format!("{} bodies; mesh.hex.blocks meshes 1 to {MAX_CELLS}", shapes.len())));
+ }
+ // One size and one tessellation tolerance for all cells, from their joint extent, so shared
+ // curves are divided alike.
+ let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
+ for b in &shapes {
+ let m = b.measure()?;
+ for k in 0..3 {
+ lo[k] = lo[k].min(m.bbox_min[k]);
+ hi[k] = hi[k].max(m.bbox_max[k]);
+ }
+ }
+ let diag = diagonal(lo, hi);
+ if !(diag > 0.0 && diag.is_finite()) {
+ return Err(EngineError::Params("the bodies have no extent".into()));
+ }
+ let tolerance = number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * diag);
+ if tolerance < MIN_RELATIVE_TOLERANCE * diag {
+ return Err(EngineError::Params(format!("`tolerance` must be at least {} mm", MIN_RELATIVE_TOLERANCE * diag)));
+ }
+ let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * diag);
+ if h < MIN_RELATIVE_SIZE * diag {
+ return Err(EngineError::Params(format!("`size` must be at least {} mm", MIN_RELATIVE_SIZE * diag)));
+ }
+ let inputs: Vec = shapes.iter().map(|b| shape_input(b, Some(tolerance))).collect::>()?;
+ let mut surfaces = Vec::with_capacity(inputs.len());
+ for b in &inputs {
+ let options = super::mesh::surface_options(p, &b.mesh)?;
+ surfaces.push(
+ BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &options)
+ .map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?,
+ );
+ }
+ let cells: Vec| = surfaces.iter().zip(&inputs).map(|(surface, b)| Cell { name: &b.name, surface, faces: &b.surfaces }).collect();
+ let options = HexOptions {
+ method: HexMethod::Mapped,
+ quadratic,
+ chordal_tolerance: tolerance,
+ max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
+ ..HexOptions::default()
+ };
+ let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?;
+ let m = &cm.mesh;
+ // Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
+ let mut model = s.model.without_mesh();
+ for (i, xyz) in m.nodes.iter().enumerate() {
+ model.set_node(label(i)?, *xyz)?;
+ }
+ let mut body_tags = Vec::with_capacity(inputs.len());
+ for b in &inputs {
+ body_tags.push(model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?);
+ }
+ for e in 0..m.element_count() {
+ let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize);
+ let nodes: Vec = m.element(e).unwrap_or(&[]).iter().map(|&i| i + 1).collect();
+ model.set_element(label(e)?, m.kind, nodes)?;
+ model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
+ }
+ tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), m, 1)?;
+ s.model = model;
+ let blocks: Vec =
+ inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::()})).collect();
+ Ok(json!({
+ "cells": inputs.len(),
+ "interfaces": cm.interfaces,
+ "description": m.report.description,
+ "kind": m.kind.name(),
+ "elements": m.element_count(),
+ "nodes": m.nodes.len(),
+ "boundary_faces": m.boundary.len(),
+ "quality": {"min_scaled_jacobian": m.report.min_scaled_jacobian, "mean_scaled_jacobian": m.report.mean_scaled_jacobian},
+ "volume": m.report.volume,
+ "surface_volume": m.report.surface_volume,
+ "size": h,
+ "blocks": blocks,
+ }))
+}
+
+/// A 1-based model label for 0-based index `i`.
+fn label(i: usize) -> Result {
+ u32::try_from(i).ok().and_then(|x| x.checked_add(1)).ok_or_else(|| EngineError::Params("too many nodes or elements".into()))
+}
+
+/// The `order` parameter: false for hex8, true for hex20.
+fn order(p: &Value) -> Result {
+ match p.get("order") {
+ None => Ok(false),
+ Some(v) => match v.as_u64() {
+ Some(1) => Ok(false),
+ Some(2) => Ok(true),
+ _ => Err(EngineError::Params("`order` must be 1 (hex8) or 2 (hex20)".into())),
+ },
+ }
+}
+
fn hex_generate(s: &mut Session, p: &Value) -> Result {
let all = [HexMethod::Auto, HexMethod::Swept, HexMethod::Extrude, HexMethod::Sweep, HexMethod::Revolve, HexMethod::Mapped];
generate(s, p, HexMethod::Auto, &all)
@@ -145,14 +273,7 @@ fn hex_mapped(s: &mut Session, p: &Value) -> Result {
/// Mesh with `default` or the `method` parameter, which must be one of `allowed`.
fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod]) -> Result {
let size = number(p, "size")?;
- let quadratic = match p.get("order") {
- None => false,
- Some(v) => match v.as_u64() {
- Some(1) => false,
- Some(2) => true,
- _ => return Err(EngineError::Params("`order` must be 1 (hex8) or 2 (hex20)".into())),
- },
- };
+ let quadratic = order(p)?;
let method = match p.get("method") {
None => default,
Some(v) => v.as_str().and_then(HexMethod::from_name).filter(|m| allowed.contains(m)).ok_or_else(|| {
@@ -197,7 +318,7 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
model.tag_element(element_label, body_tag)?;
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
}
- tag_faces(&mut model, b, m, first_element)?;
+ tag_faces(&mut model, std::slice::from_ref(b), |_| 0, m, first_element)?;
report.push(json!({
"body": b.name,
"method": m.report.method.name(),
@@ -215,22 +336,20 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
}
-/// Tag every boundary element face with its B-rep face.
-fn tag_faces(model: &mut Model, b: &Input<'_>, m: &HexMesh, first_element: u32) -> Result<()> {
- let mut tags: Vec |