diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index f44c2d3..0ba73b6 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -100,6 +100,7 @@ is committed and listed here with its exports.
| 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 |
| assets/icons/mesh.tag.svg, assets/icons/mesh.tag.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tag.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.tet.generate.svg, assets/icons/mesh.tet.generate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tet.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+| assets/icons/mesh.plan.svg, assets/icons/mesh.plan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.plan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.planar.svg, assets/icons/mesh.planar.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.planar.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.transition.svg, assets/icons/mesh.transition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.transition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.untangle.svg, assets/icons/mesh.untangle.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.untangle.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
diff --git a/assets/icons/mesh.plan.svg b/assets/icons/mesh.plan.svg
new file mode 100644
index 0000000..e458f25
--- /dev/null
+++ b/assets/icons/mesh.plan.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/assets/icons/mesh.plan.vectorcraft b/assets/icons/mesh.plan.vectorcraft
new file mode 100644
index 0000000..c52ac96
Binary files /dev/null and b/assets/icons/mesh.plan.vectorcraft differ
diff --git a/assets/icons/src/mesh.plan.json b/assets/icons/src/mesh.plan.json
new file mode 100644
index 0000000..5dfcbb9
--- /dev/null
+++ b/assets/icons/src/mesh.plan.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": "M4 11.5 L12 15.5 L20 11.5 M8 9 L8 18 M16 9 L16 18", "stroke": "accent", "width": 1}, {"d": "M12 3 L12 5.4 M20 7 L15.2 7 M12 11 L12 8.6 M4 7 L8.8 7", "stroke": "accent", "width": 1}, {"d": "M8.8 7 A3.2 1.6 0 1 0 15.2 7 A3.2 1.6 0 1 0 8.8 7 Z", "stroke": "warm", "width": 1.5}]}
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index 22b18b3..abe0245 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -244,7 +244,7 @@ pub static CATALOGUE: &[Capability] = &[
Geometry,
"Inspect",
"List the open design's bodies, volumes and named faces, edges and vertices",
- &["geometry.centre"],
+ &["geometry.centre", "geometry.features"],
),
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
ready(
@@ -332,6 +332,14 @@ pub static CATALOGUE: &[Capability] = &[
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions",
&["mesh.hex.blocks"],
),
+ ready_more(
+ "mesh.plan",
+ "Mesh Plan",
+ Mesh,
+ "Structured Hex",
+ "A hex-meshing plan from the features that built a body (sweeps, blocks, O-grids, layers, imprints, copies); saved, editable, applied with the structured meshers",
+ &["mesh.plan.apply"],
+ ),
ready_more(
"mesh.block",
"Mesh Block",
diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index 037b90a..27637c8 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1599,6 +1599,15 @@ pub const PARTITION: &[Field] = &[
references("bodies", "Bodies", C::Body).help("The bodies to divide; every body the plane cuts when empty.").hint("every body the plane divides"),
];
pub const PARTITION_DELETE: &[Field] = existing!("Partition", C::Partition);
+pub const MESH_PLAN: &[Field] = &[
+ reference("body", "Body", C::Body).help("The body of the design to plan (as opened, not a partition cell).").req(),
+ text("name", "Name").help("The name the plan is saved under.").hint("Plan-
"),
+ positive("size", "Element size", D::Millimetre).help("The largest element size; thin walls make it smaller.").hint("1/25 of the diagonal"),
+ int("layers", "Layers through walls", 1, 10).help("Elements through the thinnest wall the features give (a shell, a plate).").hint("2"),
+ int("max_elements", "Most elements", 1, 5_000_000).help("The size grows past the walls' when more elements would be needed.").hint("300000"),
+];
+pub const MESH_PLAN_APPLY: &[Field] = &[text("plan", "Plan").help("The name of a saved mesh plan (mesh.plan).").req()];
+pub const FEATURES: &[Field] = &[reference("body", "Body", C::Body).help("Only this body's features.").hint("every body")];
pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).help("The body to tessellate.").req(), TOLERANCE];
pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).help("A 3MF surface mesh with named faces.").req()];
pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).help("The FieldCraft model file to open.").req()];
diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs
index 89c7b8d..2cbbdfd 100644
--- a/crates/engine/src/cmd/geometry.rs
+++ b/crates/engine/src/cmd/geometry.rs
@@ -33,6 +33,16 @@ pub const BODIES: CommandSpec = CommandSpec {
run: bodies,
};
+pub const FEATURES: CommandSpec = CommandSpec {
+ id: "geometry.features",
+ label: "Feature Graph",
+ params: r#"{"body"?: "Base" (default: every body)} — the features of the SolidCraft timeline that built each body of the open design, in order (read-only): each feature's `id` (`F` in persistent names), `name`, `type` (extrude, box, cylinder, hole, shell, fillet, combine, pattern, mirror, split, …), `operation` (new_body, join, cut, intersect), `values` (its numeric inputs evaluated: mm, radians, counts), `placement` (a primitive's corner, base or centre), `direction` (an extrude's along the sketch normal, a cylinder's axis, a hole's drilling direction), `two_sided`, `sketch` (plane origin, axes and normal, and the profiles used: lines, arcs and circles in sketch coordinates, holes, curve ids), `transforms` (pattern and mirror copies, 4×4 row-major), `targets` and `tools`, the stored `definition`, the persistent `faces` and `edges` of the body it made, and `error` when it failed to rebuild. A partition cell is described by the body it was cut from"#,
+ form: Some(super::forms::FEATURES),
+ kind: CommandKind::Model,
+ requires: &[Requirement::Design],
+ run: features,
+};
+
pub const CENTRE: CommandSpec = CommandSpec {
id: "geometry.centre",
label: "Centre of Selection",
@@ -170,6 +180,16 @@ fn bodies(s: &mut Session, _: &Value) -> Result {
Ok(Value::Array(out))
}
+fn features(s: &mut Session, p: &Value) -> Result {
+ let body = match p.get("body") {
+ None | Some(Value::Null) => None,
+ Some(v) => Some(v.as_str().ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?),
+ };
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ let graph = design.features(body)?;
+ Ok(json!({"bodies": graph}))
+}
+
/// Most geometry items and nodes `geometry.centre` takes.
const MAX_CENTRE_ITEMS: usize = 10_000;
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 6734c34..c092c23 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -23,6 +23,7 @@ mod mirror;
mod model;
pub(crate) mod nodes;
pub(crate) mod octree;
+pub(crate) mod plan;
mod project;
pub(crate) mod relax;
pub mod replace;
@@ -47,6 +48,7 @@ pub static ALL: &[CommandSpec] = &[
model::INFO,
geometry::OPEN,
geometry::BODIES,
+ geometry::FEATURES,
geometry::CENTRE,
geometry::TESSELLATE,
geometry::IMPORT_MESH,
@@ -83,6 +85,8 @@ pub static ALL: &[CommandSpec] = &[
hex::HEX_BLOCKS,
project::HEX_PROJECT,
cells::CELLS,
+ plan::PLAN,
+ plan::APPLY,
seeds::SEED_EDGE,
seeds::SEED_CLEAR,
seeds::SEED_LIST,
diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs
new file mode 100644
index 0000000..a70edcd
--- /dev/null
+++ b/crates/engine/src/cmd/plan.rs
@@ -0,0 +1,1381 @@
+//! Provenance-driven mesh plans (#402): the features that built a body (`geometry.features`)
+//! become its hex-meshing plan, and the plan is carried out with today's meshers.
+//!
+//! `mesh.plan` reads the body's feature graph and maps each feature to a template:
+//! - an extrude of a sketch: the sweep, along its stored direction, of the profile's cross-section
+//! (circles butterflied, circular holes O-gridded);
+//! - a box: a block (or a part of the swept cross-section); a cylinder: a butterfly, an O-grid
+//! when it cuts;
+//! - a hole: an O-grid at its stored diameter and axis;
+//! - a shell: layers through its stored thickness (the element size takes the layers through
+//! the thinnest wall);
+//! - a join or cut (and a combine): the tool's cross-section imprinted on the swept one;
+//! - a pattern or mirror: the instance meshed and repeated;
+//! - a split body: its partition cuts.
+//!
+//! The sweep direction is the direction most mapped features are prismatic along (the first new
+//! body's stored direction on a tie); a feature that is not prismatic along it is reported as not
+//! mapped. Fillets, chamfers, revolves, sweeps and lofts are reported as not mapped (their blend,
+//! revolve and sweep templates are step 3 of #402); nothing is faked.
+//!
+//! The butterflies and O-grids need a frame round each circle: the plan cuts the body by planes
+//! square to the sweep on either side of every circle that is not the body's own outline
+//! ([`frame_cuts`]), placed so the frame is square (as far from the circle as the body's nearest
+//! side, when that is close) and never on or near a face of the body, through another circle, or
+//! next to another cut. `mesh.plan.apply` adds those cuts as partitions of the body (only those),
+//! sweeps its cells together (`mesh.cells`), or sweeps the whole body when the plan has no cuts
+//! (`mesh.hex.generate`), and reports the quality and whether the cells joined node to node.
+//!
+//! The plan is a definition of the model ([`MeshPlan`]): saved with it, inspectable, editable
+//! (apply an edited plan object) and applied again after the design changes. The projection
+//! blocking mesher (#399) consumes the same plan: its direction, templates and cuts.
+
+use fieldcraft_geometry::{Curve, Design, FaceSurface, FeatureNode, Shape};
+use fieldcraft_model::{CutRole, Datum, MeshPlan, Partition, PlanCut, PlanFeature, PlanMesher, PlanRing, PlanUnmapped, RingKind};
+use serde_json::{Value, json};
+
+use crate::requirement::Requirement;
+use crate::schema::ProblemKind;
+use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
+
+/// Elements a plan aims at most for one body unless told otherwise.
+pub const DEFAULT_MAX_ELEMENTS: usize = 300_000;
+/// The most a plan may aim at.
+const MAX_ELEMENTS: usize = 5_000_000;
+/// Element size by default, as a fraction of the body's bounding-box diagonal (as the meshers).
+const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
+/// The finest tessellation a plan asks for, as a fraction of the diagonal (as the meshers take).
+const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
+
+pub const PLAN: CommandSpec = CommandSpec {
+ id: "mesh.plan",
+ label: "Mesh Plan",
+ params: r#"{"body": "Base", "name"?: "Plan-Base", "size"?: mm (largest element size; default 1/25 of the body's diagonal), "layers"?: 2 (elements through the thinnest wall: a shell's thickness, a plate's; 1..=10), "max_elements"?: 300000 (the size grows past the walls' when more would be needed)} — the body's hex-meshing plan derived from the features that built it (geometry.features), saved in the model under `name` (replacing a plan of that name); nothing is meshed (mesh.plan.apply). The plan: the sweep `direction` (and every direction the mapped features are prismatic along), the element `size`, `tolerance` and `layers`; per mapped feature its `template` (sweep of an extrude's profile along its stored direction, block for a box, butterfly for a cylinder or a circular boss, o_grid for a hole or a circular cut, layers through a shell's thickness, imprint of a join, cut or combine tool's cross-section, copy for a pattern or mirror, partition for a split body) and the `faces` it made (the projection targets); the `rings` round the body's circles (o_grid or butterfly: centre, axis, radius, half-side of the square `frame`, the cylindrical `faces` they project onto); the partition `cuts` the frames and imprints need (each with its feature and reason, never on a face of the body); the `mesher` (cells when there are cuts, else sweep); the `copies` (the other bodies a pattern or mirror made from it); the features it does not map with the reason (`unmapped`: fillets, chamfers, revolves, sweeps and lofts are step 3; a feature not prismatic along the sweep needs projection blocking, #399) and `notes`"#,
+ form: Some(super::forms::MESH_PLAN),
+ kind: CommandKind::Model,
+ requires: &[Requirement::Design],
+ run: plan,
+};
+
+pub const APPLY: CommandSpec = CommandSpec {
+ id: "mesh.plan.apply",
+ label: "Apply Mesh Plan",
+ params: r#"{"plan": "Plan-Base" (a saved plan) | {plan object, as mesh.plan replies it; edited}, "name"?: "Plan-Base" (the name an object is saved under; default Plan-)} — carries the plan out with today's meshers: replaces the plan's earlier partitions (`-cut`) with its `cuts` (datum planes and partitions of the plan's body only; a cut the kernel cannot make, or that leaves a cell no mesher takes, is left out and reported in `skipped_cuts`), then meshes the body: its cells swept together with structured hex (mesh.cells, `default` hex) when there are cuts, else the body swept (mesh.hex.generate, swept), at the plan's size; each of its `copies` is meshed the same way by a plan of its own (`Plan-`, saved). No fallback: a cell that does not mesh all-hex is the error. Replies the cuts made and skipped, the cells, the element count, the smallest scaled Jacobian, `all_hex`, whether the cells joined node to node into one mesh (`conforming`), the mapped features, what today's meshers cannot build yet (`pending`: the O-grid and butterfly rings, which need the projection blocking mesher, #399), the `unmapped` features, the copies and the mesher's reply (`mesh`); `replaced` names the meshes it replaced. A plan object is checked and saved first"#,
+ form: Some(super::forms::MESH_PLAN_APPLY),
+ kind: CommandKind::Model,
+ requires: &[Requirement::Design],
+ run: apply,
+};
+
+type V3 = [f64; 3];
+
+fn dot(a: V3, b: V3) -> f64 {
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+fn cross(a: V3, b: V3) -> V3 {
+ [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
+}
+
+fn unit(v: V3) -> Option {
+ let n = dot(v, v).sqrt();
+ (n.is_finite() && n > 1e-12).then(|| v.map(|c| c / n))
+}
+
+/// Parallel or opposite, within 1e-6 of a radian.
+fn parallel(a: V3, b: V3) -> bool {
+ match (unit(a), unit(b)) {
+ (Some(a), Some(b)) => dot(a, b).abs() > 1.0 - 1e-6,
+ _ => false,
+ }
+}
+
+fn perpendicular(a: V3, b: V3) -> bool {
+ match (unit(a), unit(b)) {
+ (Some(a), Some(b)) => dot(a, b).abs() < 1e-6,
+ _ => false,
+ }
+}
+
+/// A direction up to its sign: the first non-zero component positive.
+fn canonical(d: V3) -> Option {
+ let d = unit(d)?;
+ let first = d.iter().copied().find(|c| c.abs() > 1e-9).unwrap_or(1.0);
+ Some(if first < 0.0 { d.map(|c| if c == 0.0 { 0.0 } else { -c }) } else { d })
+}
+
+const AXES: [V3; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
+
+/// Whether the faces `faces` (persistent names on `shape`) are all prismatic along `d`: planes
+/// along or across it, cylinders about an axis along it.
+fn faces_prismatic(shape: &Shape, surfaces: &[Option], faces: &[String], d: V3) -> bool {
+ faces.iter().all(|name| {
+ let Some(i) = shape.face_index(name) else { return true };
+ match surfaces.get(i as usize).copied().flatten() {
+ Some(FaceSurface::Plane { normal, .. }) => parallel(normal, d) || perpendicular(normal, d),
+ Some(FaceSurface::Cylinder { axis, .. }) => parallel(axis, d),
+ _ => false,
+ }
+ })
+}
+
+/// The kinds of feature the plan has a template for.
+fn template_of(f: &FeatureNode) -> Option<&'static str> {
+ let op = f.operation.as_deref().unwrap_or("new_body");
+ Some(match (f.kind.as_str(), op) {
+ ("extrude", "new_body") => "sweep",
+ ("extrude", "join" | "cut" | "intersect") => "imprint",
+ ("box", "new_body") => "block",
+ ("box", _) => "imprint",
+ ("cylinder", "cut") => "o_grid",
+ ("cylinder", _) => "butterfly",
+ ("hole", _) => "o_grid",
+ ("shell", _) => "layers",
+ ("combine", _) => "imprint",
+ ("pattern" | "mirror", _) => "copy",
+ ("split", _) => "partition",
+ _ => return None,
+ })
+}
+
+/// Why a feature with no template is not mapped.
+fn unmapped_reason(f: &FeatureNode) -> String {
+ match f.kind.as_str() {
+ "fillet" | "chamfer" => "a blend layer along its edges at its stored size is step 3 of the provenance plan (#402); no template yet".into(),
+ "revolve" => "revolving the profile mesh about the stored axis is step 3 of the provenance plan (#402); no template yet".into(),
+ "sweep" | "loft" => {
+ "sweeping the profile along the stored path or between the sections is step 3 of the provenance plan (#402); no template yet".into()
+ }
+ other => format!("no template for `{other}` features yet"),
+ }
+}
+
+/// The directions a feature builds along, or is prismatic along by its nature.
+fn feature_directions(f: &FeatureNode) -> Vec {
+ match f.kind.as_str() {
+ "box" => AXES.to_vec(),
+ _ => f.direction.into_iter().collect(),
+ }
+}
+
+/// Whether `f` keeps to a sweep along `d`.
+fn compatible(f: &FeatureNode, d: V3, shape: &Shape, surfaces: &[Option]) -> bool {
+ match f.kind.as_str() {
+ "extrude" | "cylinder" | "hole" if f.direction.is_some_and(|x| parallel(x, d)) => true,
+ "box" => AXES.iter().any(|a| parallel(*a, d)),
+ // Copies and combines bring the features they repeat or join, which are judged themselves.
+ "pattern" | "mirror" | "combine" | "split" => true,
+ _ => faces_prismatic(shape, surfaces, &f.faces, d),
+ }
+}
+
+/// A circle a butterfly or O-grid goes round: in the plane square to the sweep.
+#[derive(Debug, Clone, Copy)]
+struct Circle {
+ center: V3,
+ radius: f64,
+ feature: u64,
+ kind: RingKind,
+ /// Whether the circle needs a frame cut round it (not the body's own outline).
+ framed: bool,
+}
+
+/// The circles of `f` along `d`: a cylinder's or hole's, an extrude's profile circles (in world
+/// coordinates). A circle that is the body's whole outline (a lone cylinder) needs no frame.
+fn circles(f: &FeatureNode, d: V3) -> Vec {
+ let mut out = Vec::new();
+ let cut = f.operation.as_deref() == Some("cut");
+ match f.kind.as_str() {
+ "cylinder" | "hole" if f.direction.is_some_and(|x| parallel(x, d)) => {
+ let r = f.values.get("radius").copied().or_else(|| f.values.get("diameter").map(|v| v / 2.0));
+ if let (Some(c), Some(r)) = (f.placement, r) {
+ let kind = if cut { RingKind::OGrid } else { RingKind::Butterfly };
+ out.push(Circle { center: c, radius: r, feature: f.id, kind, framed: f.operation.as_deref() != Some("new_body") });
+ }
+ }
+ "extrude" if f.direction.is_some_and(|x| parallel(x, d)) => {
+ let Some(sk) = &f.sketch else { return out };
+ let world = |p: [f64; 2]| -> V3 { std::array::from_fn(|k| sk.origin[k] + p[0] * sk.x_dir[k] + p[1] * sk.y_dir[k]) };
+ for p in &sk.profiles {
+ // A profile whose outline is one circle is the body's own when the extrude makes
+ // a new body; a joined boss's or a cut's otherwise.
+ let outline_is_body = f.operation.as_deref() == Some("new_body");
+ // An outline is material inside (unless the extrude cuts), a hole material outside.
+ let loops = std::iter::once((&p.outer, outline_is_body, cut)).chain(p.holes.iter().map(|h| (h, false, !cut)));
+ for (lp, own, hollow) in loops {
+ if let [Curve::Circle { center, radius }] = lp.as_slice() {
+ let kind = if hollow { RingKind::OGrid } else { RingKind::Butterfly };
+ out.push(Circle { center: world(*center), radius: *radius, feature: f.id, kind, framed: !own });
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ out
+}
+
+/// The circles of `f` where the body has them: a feature's stored placement is the original's,
+/// and a pattern or mirror copy has its circles elsewhere. Each stored circle takes the centre of
+/// a cylindrical face of the feature on this body of its radius, about an axis along `d`, each
+/// face once; a stored circle with no such face keeps its place.
+fn placed(stored: Vec, f: &FeatureNode, d: V3, shape: &Shape, surfaces: &[Option]) -> Vec {
+ let mut axes: Vec<(V3, f64)> = Vec::new();
+ for name in &f.faces {
+ let Some(i) = shape.face_index(name) else { continue };
+ if let Some(FaceSurface::Cylinder { origin, axis, radius }) = surfaces.get(i as usize).copied().flatten()
+ && parallel(axis, d)
+ {
+ // The axis's point in the plane through the origin square to `d`.
+ let along = dot(origin, d);
+ let p: V3 = std::array::from_fn(|k| origin[k] - along * d[k]);
+ let same =
+ |(q, r): &(V3, f64)| (r - radius).abs() < 1e-6 * radius.max(1.0) && (0..3).all(|k| (q[k] - p[k]).abs() < 1e-6 * radius.max(1.0));
+ if !axes.iter().any(same) {
+ axes.push((p, radius));
+ }
+ }
+ }
+ let mut out = Vec::with_capacity(stored.len());
+ for mut c in stored {
+ let near = |(p, r): &(V3, f64)| {
+ let off: V3 = std::array::from_fn(|k| c.center[k] - p[k]);
+ let along = dot(off, d);
+ ((r - c.radius).abs() < 1e-6 * c.radius.max(1.0), dot(off, off) - along * along)
+ };
+ let best = axes.iter().enumerate().filter(|(_, a)| near(a).0).min_by(|a, b| near(a.1).1.total_cmp(&near(b.1).1)).map(|(k, _)| k);
+ if let Some(k) = best {
+ let (p, _) = axes.swap_remove(k);
+ // Keep the stored point's level along the sweep.
+ let along = dot(c.center, d);
+ c.center = std::array::from_fn(|i| p[i] + along * d[i]);
+ }
+ out.push(c);
+ }
+ out
+}
+
+/// What the cuts must keep clear of: the body's extent, its planar faces (normal, offset, name)
+/// and the points of its curved faces, and the smallest distance a cut keeps.
+struct Clearance<'a> {
+ corners: Vec,
+ planes: Vec<(V3, f64, &'a str)>,
+ curved: Vec>,
+ gap: f64,
+}
+
+impl Clearance<'_> {
+ /// The body's extent along `n`.
+ fn extent(&self, n: V3) -> (f64, f64) {
+ self.corners.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), c| (l.min(dot(*c, n)), h.max(dot(*c, n))))
+ }
+
+ /// Whether the plane `n·x = t` lies inside the body, `gap` from its extent.
+ fn inside(&self, n: V3, t: f64) -> bool {
+ let (lo, hi) = self.extent(n);
+ t > lo + self.gap && t < hi - self.gap
+ }
+
+ /// Whether the plane passes through a curved face (between its points' extremes along `n`,
+ /// by more than `gap`): the partitions' tessellation does not close along such a cut.
+ fn crosses_curved(&self, n: V3, t: f64) -> bool {
+ self.curved.iter().any(|pts| {
+ let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(dot(*p, n)), h.max(dot(*p, n))));
+ t > lo + 1e-6 && t < hi - 1e-6
+ })
+ }
+
+ /// The planar faces square to `n` within `gap` of the plane `n·x = t`, with their distance.
+ fn faces_near(&self, n: V3, t: f64) -> impl Iterator- + '_ {
+ self.planes
+ .iter()
+ .filter(move |(m, _, _)| parallel(*m, n))
+ .map(move |(m, off, name)| ((t - off * dot(*m, n).signum()).abs(), *name))
+ .filter(|(x, _)| *x < self.gap)
+ }
+}
+
+/// A joined or cutting tool's place along the sweep and, for a cut, its sides across it: the
+/// levels and the frame of its imprint.
+struct Imprint {
+ feature: u64,
+ cut: bool,
+ /// Where its cross-section starts and ends along the sweep direction.
+ extent: Option<(f64, f64)>,
+ /// A cut's side planes along the sweep (normal, offset): a rectangle's four.
+ sides: Vec<(V3, f64)>,
+}
+
+/// The imprint of `f` (a join or cut, `cut` saying which) along `d`.
+fn imprint_of(f: &FeatureNode, d: V3, cut: bool) -> Option {
+ let v = |k: &str| f.values.get(k).copied();
+ let mut sides = Vec::new();
+ let extent = match f.kind.as_str() {
+ "box" => {
+ let p = f.placement?;
+ let dims = [v("length")?, v("width")?, v("height")?];
+ let k = AXES.iter().position(|a| parallel(*a, d))?;
+ for j in (0..3).filter(|&j| j != k) {
+ sides.push((AXES[j], p[j]));
+ sides.push((AXES[j], p[j] + dims[j]));
+ }
+ Some((p[k], p[k] + dims[k]))
+ }
+ "cylinder" => {
+ let (p, a) = (f.placement?, f.direction?);
+ parallel(a, d).then(|| (dot(p, d), dot(p, d) + v("height").unwrap_or_default() * dot(a, d)))
+ }
+ "hole" => {
+ let (p, a) = (f.placement?, f.direction?);
+ v("depth").filter(|_| parallel(a, d)).map(|depth| (dot(p, d), dot(p, d) + depth * dot(a, d)))
+ }
+ "extrude" => {
+ let (sk, a) = (f.sketch.as_ref()?, f.direction?);
+ if !parallel(a, d) || f.two_sided {
+ return None;
+ }
+ // A rectangle's sides (four lines, each along a sketch axis).
+ for p in &sk.profiles {
+ let lines: Vec<([f64; 2], [f64; 2])> =
+ p.outer.iter().filter_map(|c| if let Curve::Line { a, b } = c { Some((*a, *b)) } else { None }).collect();
+ let square = lines.len() == 4
+ && p.outer.len() == 4
+ && p.holes.is_empty()
+ && lines.iter().all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 || (a[1] - b[1]).abs() < 1e-9);
+ if square {
+ for (a, b) in lines {
+ let world = |q: [f64; 2]| -> V3 { std::array::from_fn(|k| sk.origin[k] + q[0] * sk.x_dir[k] + q[1] * sk.y_dir[k]) };
+ let (wa, wb) = (world(a), world(b));
+ if let Some(n) = unit(cross(std::array::from_fn(|k| wb[k] - wa[k]), d)) {
+ sides.push((n, dot(n, wa)));
+ }
+ }
+ }
+ }
+ let t0 = dot(sk.origin, d);
+ Some((t0, t0 + v("distance")? * dot(a, d)))
+ }
+ _ => None,
+ };
+ Some(Imprint { feature: f.id, cut, extent, sides: if cut { sides } else { Vec::new() } })
+}
+
+/// The cuts that imprint the tools: a plane square to the sweep where each tool's cross-section
+/// starts or ends inside the body (a level), and for a cutting tool with a rectangular section,
+/// its four sides extended across the body (the frame of the opening). A plane goes in only
+/// inside the body (`gap` from its extent), not through a curved face, not near but off a face, not on a face a feature
+/// other than the tools made (exactly on a tool's own face is that face carried on: no sliver), and not
+/// next to another cut.
+fn imprint_cuts(tools: &[Imprint], d: V3, body: &Clearance, cuts: &mut Vec) {
+ let tool_ids: Vec = tools.iter().map(|t| t.feature).collect();
+ let mut add = |n: V3, t: f64, reason: String, feature: u64| {
+ if !body.inside(n, t) || body.crosses_curved(n, t) {
+ return;
+ }
+ // Near a face but off it, or on a face some other feature made (a floor a boss stands
+ // on): a sliver, or a cut along a face that the kernel splits badly.
+ let near_face =
+ body.faces_near(n, t).any(|(x, name)| x > 1e-6 || !fieldcraft_geometry::features::feature_ids(name).any(|id| tool_ids.contains(&id)));
+ if near_face || cuts.iter().any(|k| parallel(k.normal, n) && (dot(k.point, n) - t).abs() < body.gap) {
+ return;
+ }
+ let role = if parallel(n, d) { CutRole::Level } else { CutRole::Frame };
+ cuts.push(PlanCut { point: n.map(|x| x * t), normal: n, role, feature: Some(feature), reason });
+ };
+ for tool in tools {
+ let what = if tool.cut { "cut" } else { "joined" };
+ if let Some((a, b)) = tool.extent {
+ for (t, end) in [(a.min(b), "starts"), (a.max(b), "ends")] {
+ add(d, t, format!("level: the {what} section of F{} {end} here", tool.feature), tool.feature);
+ }
+ }
+ for (n, off) in &tool.sides {
+ add(*n, *off, format!("frame: a side of the opening F{} cuts, carried across the body", tool.feature), tool.feature);
+ }
+ }
+}
+
+/// Most profile segments [`profile_wall`] compares pairwise.
+const MAX_WALL_SEGMENTS: usize = 2000;
+
+/// The thinnest wall of a profile: the smallest distance between two parallel lines of its
+/// loops with material between them (the point halfway between, over their overlap, inside the
+/// outline and outside its holes). Arcs count only as the polygon their ends make.
+fn profile_wall(p: &fieldcraft_geometry::ProfileGeometry) -> Option {
+ let polygon = |lp: &[Curve]| -> Vec<[f64; 2]> {
+ let mut pts = Vec::new();
+ for c in lp {
+ match c {
+ Curve::Line { a, .. } => pts.push(*a),
+ Curve::Arc { center, radius, start, sweep } => {
+ for k in 0..8 {
+ let t = start + sweep * f64::from(k) / 8.0;
+ pts.push([center[0] + radius * t.cos(), center[1] + radius * t.sin()]);
+ }
+ }
+ Curve::Circle { center, radius } => {
+ for k in 0..16 {
+ let t = std::f64::consts::TAU * f64::from(k) / 16.0;
+ pts.push([center[0] + radius * t.cos(), center[1] + radius * t.sin()]);
+ }
+ }
+ Curve::Other { .. } => {}
+ }
+ }
+ pts
+ };
+ let inside = |poly: &[[f64; 2]], q: [f64; 2]| {
+ let mut odd = false;
+ for (i, a) in poly.iter().enumerate() {
+ let b = poly[(i + 1) % poly.len()];
+ if (a[1] > q[1]) != (b[1] > q[1]) && q[0] < a[0] + (q[1] - a[1]) * (b[0] - a[0]) / (b[1] - a[1]) {
+ odd = !odd;
+ }
+ }
+ odd
+ };
+ let outer = polygon(&p.outer);
+ let holes: Vec> = p.holes.iter().map(|h| polygon(h)).collect();
+ let material = |q: [f64; 2]| inside(&outer, q) && !holes.iter().any(|h| inside(h, q));
+ let lines: Vec<([f64; 2], [f64; 2])> = std::iter::once(&p.outer)
+ .chain(&p.holes)
+ .flatten()
+ .filter_map(|c| if let Curve::Line { a, b } = c { Some((*a, *b)) } else { None })
+ .take(MAX_WALL_SEGMENTS)
+ .collect();
+ let mut best: Option = None;
+ for (i, (a, b)) in lines.iter().enumerate() {
+ let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
+ let len = (dx * dx + dy * dy).sqrt();
+ if !(len > 1e-12) {
+ continue;
+ }
+ let (ux, uy) = (dx / len, dy / len);
+ for (c, e) in lines.iter().skip(i + 1) {
+ let (ex, ey) = (e[0] - c[0], e[1] - c[1]);
+ if (ux * ey - uy * ex).abs() > 1e-9 * (ex * ex + ey * ey).sqrt().max(1e-12) {
+ continue;
+ }
+ let gap = ux * (c[1] - a[1]) - uy * (c[0] - a[0]);
+ if gap.abs() < 1e-9 {
+ continue;
+ }
+ // The overlap of the two along the direction.
+ let along = |q: [f64; 2]| (q[0] - a[0]) * ux + (q[1] - a[1]) * uy;
+ let (lo, hi) = (along(*c).min(along(*e)).max(0.0), along(*c).max(along(*e)).min(len));
+ if hi - lo <= 1e-9 {
+ continue;
+ }
+ let m = 0.5 * (lo + hi);
+ let q = [a[0] + ux * m - uy * gap / 2.0, a[1] + uy * m + ux * gap / 2.0];
+ if material(q) && best.is_none_or(|x| gap.abs() < x) {
+ best = Some(gap.abs());
+ }
+ }
+ }
+ best
+}
+
+/// Two in-plane axes square to `d`: the other two principal axes when `d` is one.
+fn in_plane_axes(d: V3) -> [V3; 2] {
+ if let Some(k) = AXES.iter().position(|a| parallel(*a, d)) {
+ let others: Vec = AXES.iter().enumerate().filter(|(i, _)| *i != k).map(|(_, a)| *a).collect();
+ if let [u, v] = others.as_slice() {
+ return [*u, *v];
+ }
+ }
+ let least = AXES.iter().copied().min_by(|a, b| dot(*a, d).abs().total_cmp(&dot(*b, d).abs())).unwrap_or([1.0, 0.0, 0.0]);
+ let u = unit(cross(d, least)).unwrap_or([1.0, 0.0, 0.0]);
+ [u, unit(cross(d, u)).unwrap_or([0.0, 1.0, 0.0])]
+}
+
+/// The frame cuts round the circles: for each circle (concentric ones together, the largest
+/// framing them) and each in-plane axis, a plane on either side, square to the axis, at a
+/// distance `h` from the centre; none on a side where the body's side is within 4 radii (it is
+/// the frame there). `h` is first the distance to the body's side on the other side (a square
+/// frame, when that is 1.4 to 4 radii), else 2, 1.75, 2.5, 1.5 or 3 radii, the first
+/// that keeps the plane inside the body, off its curved faces, at least `gap` off every face of
+/// the body square to the axis, clear of the other circles (1.3 of their radius), and not next to another cut (one
+/// within 0.6 of the radius serves instead). A side with no such place gets no cut, and a note.
+fn frame_cuts(circles: &[Circle], d: V3, body: &Clearance, cuts: &mut Vec) -> Vec {
+ let mut groups: Vec = Vec::new();
+ for c in circles.iter().filter(|c| c.framed) {
+ let same_axis = |g: &Circle| {
+ let off = std::array::from_fn::(|k| c.center[k] - g.center[k]);
+ let along = dot(off, d);
+ dot(off, off) - along * along < 1e-12
+ };
+ match groups.iter_mut().find(|g| same_axis(g)) {
+ Some(g) if c.radius > g.radius => *g = *c,
+ Some(_) => {}
+ None => groups.push(*c),
+ }
+ }
+ let gap = body.gap;
+ let mut notes = Vec::new();
+ for g in &groups {
+ let r = g.radius;
+ for a in in_plane_axes(d) {
+ let (amin, amax) = body.extent(a);
+ let c = dot(g.center, a);
+ for sign in [1.0, -1.0] {
+ let opposite = if sign > 0.0 { c - amin } else { amax - c };
+ let same = if sign > 0.0 { amax - c } else { c - amin };
+ // The body's side close by on this side frames the circle itself.
+ if same <= 4.0 * r {
+ continue;
+ }
+ let mut hs: Vec = [opposite].into_iter().filter(|h| (1.4 * r..=4.0 * r).contains(h)).collect();
+ hs.extend([2.0, 1.75, 2.5, 1.5, 3.0].map(|k| k * r));
+ let mut placed = false;
+ for h in hs {
+ let t = c + sign * h;
+ if !body.inside(a, t) || body.crosses_curved(a, t) || body.faces_near(a, t).next().is_some() {
+ continue;
+ }
+ if groups.iter().any(|o| !std::ptr::eq(o, g) && (t - dot(o.center, a)).abs() < 1.3 * o.radius) {
+ continue;
+ }
+ if cuts.iter().any(|k| parallel(k.normal, a) && (dot(k.point, a) - t).abs() < (0.6 * r).max(gap)) {
+ placed = true;
+ break;
+ }
+ cuts.push(PlanCut {
+ point: a.map(|x| x * t),
+ normal: a,
+ role: CutRole::Frame,
+ feature: Some(g.feature),
+ reason: format!("frame of the O-grid round the circle of radius {r:.4} at F{}: {h:.4} mm from its centre", g.feature),
+ });
+ placed = true;
+ break;
+ }
+ if !placed {
+ notes.push(format!(
+ "F{}: no frame cut on the {} side along {:?} (the body's side or a face is the frame there)",
+ g.feature,
+ if sign > 0.0 { "positive" } else { "negative" },
+ a
+ ));
+ }
+ }
+ }
+ }
+ notes
+}
+
+/// The rings round the circles: each circle once (concentric ones the largest), with the
+/// half-side of its square frame (an O-grid's: the nearest frame cut or side of the body square
+/// to an in-plane axis; a butterfly's core: half the radius) and the cylindrical faces of the
+/// feature it projects onto.
+fn plan_rings(
+ circles: &[Circle],
+ d: V3,
+ body: &Clearance,
+ cuts: &[PlanCut],
+ shape: &Shape,
+ surfaces: &[Option],
+ features: &[FeatureNode],
+) -> Vec {
+ let mut out: Vec = Vec::new();
+ for c in circles {
+ let same = |r: &PlanRing| {
+ let off: V3 = std::array::from_fn(|k| c.center[k] - r.center[k]);
+ let along = dot(off, d);
+ dot(off, off) - along * along < 1e-12
+ };
+ if out.iter().any(|r| same(r) && r.radius >= c.radius) {
+ continue;
+ }
+ out.retain(|r| !same(r));
+ let frame = match c.kind {
+ RingKind::Butterfly => 0.5 * c.radius,
+ RingKind::OGrid => in_plane_axes(d)
+ .iter()
+ .flat_map(|&a| {
+ let (lo, hi) = body.extent(a);
+ let t = dot(c.center, a);
+ let planes = cuts.iter().filter(move |k| parallel(k.normal, a)).map(move |k| (dot(k.point, a) - t).abs());
+ [t - lo, hi - t].into_iter().chain(planes)
+ })
+ .filter(|x| *x > c.radius)
+ .fold(f64::INFINITY, f64::min),
+ };
+ let faces = features
+ .iter()
+ .filter(|f| f.id == c.feature)
+ .flat_map(|f| &f.faces)
+ .filter(|name| {
+ let Some(i) = shape.face_index(name) else { return false };
+ match surfaces.get(i as usize).copied().flatten() {
+ Some(FaceSurface::Cylinder { origin, axis, radius }) => {
+ let off: V3 = std::array::from_fn(|k| c.center[k] - origin[k]);
+ let along = dot(off, d);
+ parallel(axis, d)
+ && (radius - c.radius).abs() < 1e-6 * c.radius.max(1.0)
+ && dot(off, off) - along * along < 1e-9 * c.radius.max(1.0)
+ }
+ _ => false,
+ }
+ })
+ .cloned()
+ .collect();
+ if let Some(axis) = canonical(d) {
+ out.push(PlanRing {
+ feature: c.feature,
+ kind: c.kind,
+ center: c.center,
+ axis,
+ radius: c.radius,
+ frame: if frame.is_finite() { frame } else { 2.0 * c.radius },
+ faces,
+ });
+ }
+ }
+ out
+}
+
+/// The other bodies of the design the plan's copy features (a pattern, a mirror) made from the
+/// same timeline: each lists that feature too.
+fn copies_of(design: &Design, body: &str, features: &[PlanFeature]) -> Result> {
+ let copy_ids: Vec = features.iter().filter(|f| f.template == "copy").map(|f| f.id).collect();
+ if copy_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+ let mut out = Vec::new();
+ for graph in design.features(None)? {
+ if graph.body != body && graph.features.iter().any(|f| copy_ids.contains(&f.id)) {
+ out.push(graph.body);
+ }
+ }
+ Ok(out)
+}
+
+/// Build the plan of `body` (a body of the design as opened).
+pub(crate) fn build(design: &Design, body: &str, size: Option, layers: u32, max_elements: usize) -> Result {
+ let shape = design.source_body(body).ok_or_else(|| {
+ let source = design.source_of(body).map(|s| format!(" (a cell of `{s}`: plan the body it was cut from)")).unwrap_or_default();
+ EngineError::Params(format!("no body `{body}` in the design as opened{source}"))
+ })?;
+ let graph = design.features(Some(body))?;
+ let features: Vec = graph.into_iter().next().map(|b| b.features).unwrap_or_default();
+ let surfaces = shape.face_surfaces()?;
+ let m = shape.measure()?;
+ let (lo, hi) = (m.bbox_min, m.bbox_max);
+ let diagonal = dot(std::array::from_fn(|k| hi[k] - lo[k]), std::array::from_fn(|k| hi[k] - lo[k])).sqrt();
+ if !(diagonal.is_finite() && diagonal > 0.0) {
+ return Err(EngineError::Params(format!("body `{body}` has no extent")));
+ }
+
+ // The sweep direction: the candidate most mapped features keep to, the first new body's own
+ // on a tie (candidates in the order the features give them).
+ let mapped: Vec<&FeatureNode> = features.iter().filter(|f| template_of(f).is_some() && f.error.is_none()).collect();
+ let primary = mapped.iter().find(|f| f.operation.as_deref() == Some("new_body")).and_then(|f| feature_directions(f).first().copied());
+ let mut candidates: Vec = Vec::new();
+ for d in primary.into_iter().chain(mapped.iter().flat_map(|f| feature_directions(f))).filter_map(canonical) {
+ if !candidates.iter().any(|c| parallel(*c, d)) {
+ candidates.push(d);
+ }
+ }
+ let score = |d: V3| mapped.iter().filter(|f| compatible(f, d, shape, &surfaces)).count();
+ let best = candidates.iter().map(|d| score(*d)).max().unwrap_or(0);
+ let directions: Vec = candidates.iter().copied().filter(|d| score(*d) == best && best > 0).collect();
+ let direction = directions.first().copied();
+
+ let mut plan_features = Vec::new();
+ let mut unmapped = Vec::new();
+ let mut notes = Vec::new();
+ let mut circle_list = Vec::new();
+ let mut imprints = Vec::new();
+ // The thinnest wall the features give, with what gives it.
+ let mut thin: Option<(f64, String)> = None;
+ let mut thinner = |t: f64, why: String| {
+ if t.is_finite() && t > 0.0 && thin.as_ref().is_none_or(|(x, _)| t < *x) {
+ thin = Some((t, why));
+ }
+ };
+ for f in &features {
+ let unmap = |reason: String| PlanUnmapped { id: f.id, name: f.name.clone(), kind: f.kind.clone(), reason };
+ if let Some(e) = &f.error {
+ unmapped.push(unmap(format!("failed to rebuild: {e}")));
+ continue;
+ }
+ let Some(template) = template_of(f) else {
+ let mut reason = unmapped_reason(f);
+ if let Some(d) = direction
+ && !f.faces.is_empty()
+ && faces_prismatic(shape, &surfaces, &f.faces, d)
+ {
+ reason.push_str("; its faces are prismatic along the sweep, so the sweep meshes them as part of the cross-section");
+ }
+ unmapped.push(unmap(reason));
+ continue;
+ };
+ let Some(d) = direction else {
+ unmapped.push(unmap("no sweep direction keeps to the body's features".into()));
+ continue;
+ };
+ if !compatible(f, d, shape, &surfaces) {
+ unmapped.push(unmap(format!("not prismatic along the sweep direction {d:?}: it needs blocks of its own (projection blocking, #399)")));
+ continue;
+ }
+ let v = |k: &str| f.values.get(k).copied();
+ let note = match (f.kind.as_str(), template) {
+ ("extrude", "sweep") => {
+ if let Some(t) = v("distance").filter(|_| f.direction.is_some_and(|x| parallel(x, d))) {
+ thinner(t, format!("{} extent", f.name));
+ }
+ for p in f.sketch.iter().flat_map(|sk| &sk.profiles) {
+ if let Some(t) = profile_wall(p) {
+ thinner(t, format!("{} profile's thinnest wall", f.name));
+ }
+ }
+ let along = if f.direction.is_some_and(|x| parallel(x, d)) {
+ "along its stored direction"
+ } else {
+ "across its extrude direction (its profile is prismatic along the sweep)"
+ };
+ format!("the profile's cross-section swept {along}; circles butterflied, circular holes O-gridded")
+ }
+ ("extrude", _) => "its profile imprinted on the swept cross-section between the levels of its extent".into(),
+ ("box", t) => {
+ let dims: Vec = ["length", "width", "height"].iter().filter_map(|k| v(k)).collect();
+ if let Some(min) = dims.iter().copied().reduce(f64::min) {
+ thinner(min, format!("{} thinnest side", f.name));
+ }
+ if t == "block" {
+ "a mapped block (part of the swept cross-section)".into()
+ } else {
+ "its rectangle imprinted on the swept cross-section".into()
+ }
+ }
+ ("cylinder", "butterfly") => format!("a butterfly round its circle (radius {:.4})", v("radius").unwrap_or_default()),
+ ("cylinder", _) => format!("an O-grid round its circle (radius {:.4})", v("radius").unwrap_or_default()),
+ ("hole", _) => format!("an O-grid round the hole (diameter {:.4}) along its stored axis", v("diameter").unwrap_or_default()),
+ ("shell", _) => {
+ let t = v("thickness").unwrap_or_default();
+ thinner(t, format!("{} thickness", f.name));
+ format!("{layers} layers through the {t:.4} mm wall")
+ }
+ ("combine", _) => format!("the cross-sections of {} imprinted on the target", f.tools.join(", ")),
+ ("pattern" | "mirror", _) => format!("the instance meshed and repeated by {} transforms", f.transforms.len()),
+ ("split", _) => "the pieces meshed as cells of the split".into(),
+ _ => String::new(),
+ };
+ circle_list.extend(placed(circles(f, d), f, d, shape, &surfaces));
+ // A tool: joined or cut by its own operation, or the tool body of a combine.
+ let role = match f.operation.as_deref() {
+ Some("join") => Some(false),
+ Some("cut") => Some(true),
+ _ => features.iter().find(|c| c.kind == "combine" && c.tools.iter().any(|t| f.creates.contains(t))).and_then(|c| {
+ match c.operation.as_deref() {
+ Some("join") => Some(false),
+ Some("cut") => Some(true),
+ _ => None,
+ }
+ }),
+ };
+ let template = if role.is_some() && template == "block" { "imprint" } else { template };
+ if let Some(cut) = role {
+ imprints.extend(imprint_of(f, d, cut));
+ }
+ plan_features.push(PlanFeature {
+ id: f.id,
+ name: f.name.clone(),
+ kind: f.kind.clone(),
+ template: template.into(),
+ note,
+ faces: f.faces.clone(),
+ });
+ }
+
+ // The element size: at most the one asked for (or the meshers' default), small enough for
+ // `layers` through the thinnest wall, but not so small the body takes more than
+ // `max_elements`.
+ let asked = size.unwrap_or(DEFAULT_RELATIVE_SIZE * diagonal);
+ let mut h = asked;
+ if let Some((t, why)) = &thin {
+ let through = t / f64::from(layers);
+ if through < h {
+ notes.push(format!("size {through:.4} mm: {layers} layers through the {t:.4} mm wall ({why})"));
+ h = through;
+ }
+ }
+ let floor = (m.volume.abs() / max_elements as f64).cbrt();
+ if h < floor {
+ notes.push(format!("size {floor:.4} mm instead of {h:.4}: at most {max_elements} elements (fewer layers through the thinnest wall)"));
+ h = floor;
+ }
+ let mut cuts = Vec::new();
+ let mut rings = Vec::new();
+ if let Some(d) = direction {
+ let planes = surfaces
+ .iter()
+ .zip(shape.faces())
+ .filter_map(|(s, f)| match s {
+ Some(FaceSurface::Plane { normal, point }) => Some((*normal, dot(*normal, *point), f.name.as_str())),
+ _ => None,
+ })
+ .collect();
+ // The curved faces' points, from a tessellation at the meshers' default tolerance.
+ let mesh = shape.tessellate(1e-3 * diagonal)?;
+ let mut curved: Vec> = vec![Vec::new(); surfaces.len()];
+ for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
+ if !matches!(surfaces.get(f as usize), Some(Some(FaceSurface::Plane { .. })))
+ && let Some(pts) = curved.get_mut(f as usize)
+ {
+ pts.extend(t.iter().filter_map(|&i| mesh.positions.get(i as usize).copied()));
+ }
+ }
+ curved.retain(|p| !p.is_empty());
+ let corners = (0..8)
+ .map(|i| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }])
+ .collect();
+ let body = Clearance { corners, planes, curved, gap: 0.5 * h };
+ imprint_cuts(&imprints, d, &body, &mut cuts);
+ notes.extend(frame_cuts(&circle_list, d, &body, &mut cuts));
+ rings = plan_rings(&circle_list, d, &body, &cuts, shape, &surfaces, &features);
+ }
+ // The tessellation: the meshers' default, finer where the body has small curves (a hundredth
+ // of the smallest radius), so short blend edges stay edges.
+ let radius = surfaces
+ .iter()
+ .filter_map(|s| match s {
+ Some(FaceSurface::Cylinder { radius, .. }) | Some(FaceSurface::Sphere { radius, .. }) => Some(*radius),
+ Some(FaceSurface::Torus { minor, .. }) => Some(*minor),
+ _ => None,
+ })
+ .fold(f64::INFINITY, f64::min);
+ let tolerance = (1e-3 * diagonal).min(0.01 * radius).max(MIN_RELATIVE_TOLERANCE * diagonal);
+ if tolerance < 1e-3 * diagonal {
+ notes.push(format!("tolerance {tolerance:.4} mm: a hundredth of the smallest radius ({radius:.4} mm)"));
+ }
+ let mesher = if cuts.is_empty() { PlanMesher::Sweep } else { PlanMesher::Cells };
+ let copies = copies_of(design, body, &plan_features)?;
+ let plan = MeshPlan {
+ body: body.into(),
+ size: h,
+ tolerance: Some(tolerance),
+ layers,
+ mesher,
+ direction,
+ directions,
+ features: plan_features,
+ cuts,
+ rings,
+ copies,
+ unmapped,
+ notes,
+ };
+ plan.check()?;
+ Ok(plan)
+}
+
+fn plan(s: &mut Session, p: &Value) -> Result {
+ let body: String = super::req(p, "body")?;
+ let size: Option = super::opt(p, "size")?;
+ if size.is_some_and(|v| !(v.is_finite() && v > 0.0)) {
+ return Err(EngineError::param("size", ProblemKind::NotPositive));
+ }
+ let layers: u32 = super::opt(p, "layers")?.unwrap_or(2);
+ if !(1..=10).contains(&layers) {
+ return Err(EngineError::Params("`layers` must be 1 to 10".into()));
+ }
+ let max_elements: usize = super::opt(p, "max_elements")?.unwrap_or(DEFAULT_MAX_ELEMENTS);
+ if !(1..=MAX_ELEMENTS).contains(&max_elements) {
+ return Err(EngineError::Params(format!("`max_elements` must be 1 to {MAX_ELEMENTS}")));
+ }
+ let name: String = super::opt(p, "name")?.unwrap_or_else(|| MeshPlan::default_name(&body));
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ let plan = build(design, &body, size, layers, max_elements)?;
+ s.model.set_mesh_plan(&name, plan.clone())?;
+ let mut reply = serde_json::to_value(&plan).map_err(|e| EngineError::Params(e.to_string()))?;
+ if let Some(o) = reply.as_object_mut() {
+ o.insert("name".into(), json!(name));
+ }
+ Ok(reply)
+}
+
+/// The partitions (and their datums) an earlier apply of plan `name` made.
+fn plan_cut_names(s: &Session, name: &str) -> Vec {
+ let prefix = format!("{name}-cut");
+ s.model
+ .partitions()
+ .iter()
+ .map(|p| p.name.clone())
+ .filter(|n| n.strip_prefix(&prefix).is_some_and(|k| !k.is_empty() && k.bytes().all(|b| b.is_ascii_digit())))
+ .collect()
+}
+
+fn apply(s: &mut Session, p: &Value) -> Result {
+ let (name, plan) = match p.get("plan") {
+ Some(Value::String(n)) => (n.clone(), s.model.mesh_plan(n).cloned().ok_or_else(|| EngineError::Params(format!("no mesh plan `{n}`")))?),
+ Some(v @ Value::Object(_)) => {
+ let plan: MeshPlan = serde_json::from_value(v.clone()).map_err(|e| EngineError::Params(format!("`plan`: {e}")))?;
+ let name: String = super::opt(p, "name")?.unwrap_or_else(|| MeshPlan::default_name(&plan.body));
+ s.model.set_mesh_plan(&name, plan.clone())?;
+ (name, plan)
+ }
+ _ => return Err(EngineError::Params("`plan` must be a saved plan's name or a plan object".into())),
+ };
+ let mut reply = apply_one(s, &name, &plan)?;
+ // The copies: each meshed by a plan of its own at this plan's size, saved beside it.
+ let mut copies = Vec::with_capacity(plan.copies.len());
+ for copy in &plan.copies {
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ if design.source_body(copy).is_none() {
+ return Err(EngineError::Params(format!("mesh plan `{name}`: no copy `{copy}` in the design")));
+ }
+ let mut theirs = build(design, copy, Some(plan.size), plan.layers, MAX_ELEMENTS)?;
+ // Its copies are this body and the others: they are meshed here, not again.
+ theirs.copies.clear();
+ theirs.size = plan.size;
+ let copy_name = MeshPlan::default_name(copy);
+ s.model.set_mesh_plan(©_name, theirs.clone())?;
+ let r = apply_one(s, ©_name, &theirs)?;
+ take_replaced(&mut reply, &r);
+ copies.push(json!({"body": copy, "plan": copy_name, "cells": r["cells"], "elements": r["elements"], "min_scaled_jacobian": r["min_scaled_jacobian"], "conforming": r["conforming"]}));
+ }
+ let mins: Vec = std::iter::once(&reply).chain(&copies).filter_map(|r| r["min_scaled_jacobian"].as_f64()).collect();
+ if let Some(o) = reply.as_object_mut() {
+ if !copies.is_empty() {
+ o.insert("copies".into(), json!(copies));
+ o.insert("min_scaled_jacobian".into(), json!(mins.iter().copied().reduce(f64::min)));
+ }
+ let results: Vec<&str> = s.solved_steps().collect();
+ if o.get("replaced").is_some_and(|r| r.as_array().is_some_and(|a| !a.is_empty())) && !results.is_empty() {
+ o.insert("results_cleared".into(), json!(results));
+ }
+ }
+ Ok(reply)
+}
+
+/// The bodies `mesh.plan.apply` fills (replace.rs): the cells of the plan's body and of its
+/// copies, as the design has them now (the body itself when it is not cut).
+pub(crate) fn plan_bodies(s: &Session, p: &Value) -> Result> {
+ let plan = match p.get("plan") {
+ Some(Value::String(n)) => s.model.mesh_plan(n).cloned().ok_or_else(|| EngineError::Params(format!("no mesh plan `{n}`")))?,
+ Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).map_err(|e| EngineError::Params(format!("`plan`: {e}")))?,
+ _ => return Err(EngineError::Params("`plan` must be a saved plan's name or a plan object".into())),
+ };
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ let sources: Vec<&str> = std::iter::once(plan.body.as_str()).chain(plan.copies.iter().map(String::as_str)).collect();
+ Ok(design.bodies.iter().filter(|b| design.source_of(&b.name).is_some_and(|x| sources.contains(&x))).map(|b| b.name.clone()).collect())
+}
+
+/// Move a mesher's `replaced` and `stale_sets` (replace.rs) into `into`, which reports them once
+/// for the whole command.
+fn take_replaced(into: &mut Value, from: &Value) {
+ for key in ["replaced", "stale_sets"] {
+ let Some(items) = from.get(key).and_then(Value::as_array).filter(|a| !a.is_empty()) else { continue };
+ if let Some(o) = into.as_object_mut() {
+ let list = o.entry(key).or_insert_with(|| json!([]));
+ if let Some(list) = list.as_array_mut() {
+ for item in items {
+ if !list.contains(item) {
+ list.push(item.clone());
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Carry out one plan: its cuts, then the mesh of the cells (or of the body when it has none).
+fn apply_one(s: &mut Session, name: &str, plan: &MeshPlan) -> Result {
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ let source = design
+ .source_body(&plan.body)
+ .ok_or_else(|| EngineError::Params(format!("the plan is of body `{}`, which the design does not have", plan.body)))?;
+ // The chordal tolerance the cells are tessellated with, from the body's extent (as mesh.cells
+ // takes it from the cells'), for the check of the cells and the mesher alike.
+ let tolerance = match plan.tolerance {
+ Some(t) => t,
+ None => {
+ let b = source.measure()?;
+ 1e-3 * (0..3).map(|k| (b.bbox_max[k] - b.bbox_min[k]).powi(2)).sum::().sqrt()
+ }
+ };
+ let cuts: Vec<&PlanCut> = plan.cuts.iter().collect();
+ let (cleared, made, skipped) = cut(s, name, &plan.body, &cuts, tolerance)?;
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ let cells: Vec = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(plan.body.as_str())).map(|b| b.name.clone()).collect();
+ // No fallback to a coarser plan: a cell the hex meshers cannot take is the error, with the
+ // cuts that made it, so the plan is edited rather than quietly paved.
+ let mesh = if cells.len() > 1 {
+ (super::cells::CELLS.run)(s, &json!({"bodies": cells, "size": plan.size, "default": "hex", "tolerance": tolerance}))
+ } else {
+ let body = cells.first().cloned().unwrap_or_else(|| plan.body.clone());
+ (super::hex::HEX_GENERATE.run)(s, &json!({"body": body, "size": plan.size, "method": "swept", "tolerance": tolerance}))
+ }
+ .map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}")))?;
+ let summary = summary(&mesh, &cells);
+ // What today's meshers cannot express yet: the rings round circles are built by the
+ // projection mesher (#399); until then the frame cuts box the circle and the sweep meshes the
+ // cell's cross-section.
+ let pending: Vec = plan
+ .rings
+ .iter()
+ .map(|r| {
+ let what = match r.kind {
+ RingKind::OGrid => "O-grid ring round the hole",
+ RingKind::Butterfly => "butterfly round the circle",
+ };
+ json!({"feature": r.feature, "ring": r.kind, "radius": r.radius, "needs": format!("the {what} is built by the projection blocking mesher (#399); today the frame cuts box it and the swept cell's cross-section is meshed by the sweep's quad mesher")})
+ })
+ .collect();
+ let mut reply = json!({
+ "name": name,
+ "body": plan.body,
+ "cuts": made,
+ "skipped_cuts": skipped.iter().map(|(n, why)| json!({"name": n, "reason": why})).collect::>(),
+ "cells": cells,
+ "size": plan.size,
+ "tolerance": tolerance,
+ "elements": summary.elements,
+ "min_scaled_jacobian": summary.min_scaled_jacobian,
+ "all_hex": summary.all_hex,
+ "conforming": summary.conforming,
+ "mapped": plan.features.iter().map(|f| json!({"id": f.id, "name": f.name, "type": f.kind, "template": f.template})).collect::>(),
+ "pending": pending,
+ "unmapped": plan.unmapped,
+ "mesh": mesh,
+ });
+ reply = cleared.report_if_any(s, reply);
+ if let Some(m) = reply.get_mut("mesh").and_then(Value::as_object_mut) {
+ let theirs = json!({"replaced": m.remove("replaced"), "stale_sets": m.remove("stale_sets")});
+ m.remove("results_cleared");
+ take_replaced(&mut reply, &theirs);
+ }
+ if let Some(o) = reply.as_object_mut() {
+ // Reported once for the whole command, by apply.
+ o.remove("results_cleared");
+ }
+ Ok(reply)
+}
+
+/// Replace plan `name`'s partitions of `body` with `cuts` (named `-cut` after their
+/// place in the plan). A cut the kernel cannot make (or that divides nothing left), or that
+/// leaves a cell whose boundary no mesher takes at `tolerance` (a plane the tessellation does
+/// not close along), is left out: the latest such cut first, one at a time. Returns the meshes
+/// the first recut removed, the cuts made and those left out with why.
+#[allow(clippy::type_complexity)]
+fn cut(
+ s: &mut Session,
+ name: &str,
+ body: &str,
+ cuts: &[&PlanCut],
+ tolerance: f64,
+) -> Result<(super::replace::Cleared, Vec, Vec<(String, String)>)> {
+ for old in plan_cut_names(s, name) {
+ s.model.remove_partition(&old)?;
+ if s.model.datum_users(&old).is_empty() && s.model.datum(&old).is_some() {
+ s.model.remove_datum(&old)?;
+ }
+ }
+ let mut names = Vec::with_capacity(cuts.len());
+ for (k, c) in cuts.iter().enumerate() {
+ let n = format!("{name}-cut{}", k + 1);
+ if s.model.datum(&n).is_some() {
+ return Err(EngineError::Params(format!("datum `{n}` exists and is not the plan's: rename it or the plan")));
+ }
+ s.model.add_datum(&n, Datum::Plane { point: c.point, normal: c.normal })?;
+ s.model.add_partition(Partition { name: n.clone(), datum: n.clone(), bodies: vec![body.to_string()], suppressed: false })?;
+ names.push(n);
+ }
+ let cleared = super::datum::recut(s)?;
+ let mut skipped: Vec<(String, String)> = Vec::new();
+ for _ in 0..=names.len() {
+ let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
+ let live: Vec<&String> = names.iter().filter(|n| !skipped.iter().any(|(k, _)| k == *n)).collect();
+ let mut failed: Vec<(String, String)> = live
+ .iter()
+ .filter_map(|n| {
+ let feature = format!("partition {n}");
+ let messages: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect();
+ (!messages.is_empty()).then(|| ((*n).clone(), messages.join("; ")))
+ })
+ .collect();
+ if failed.is_empty() {
+ let bad = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(body)).find_map(|cell| {
+ let why = super::datum::unmeshable(cell, Some(tolerance)).err()?;
+ // The latest of the plan's cuts that bound the cell.
+ let latest = live.iter().rev().find(|n| cell.faces().iter().any(|f| f.name.starts_with(&format!("partition:{n}:"))))?;
+ Some(((*latest).clone(), format!("cell `{}` it bounds has a boundary no mesher takes ({why})", cell.name)))
+ });
+ failed.extend(bad);
+ }
+ if failed.is_empty() {
+ break;
+ }
+ for (n, _) in &failed {
+ s.model.remove_partition(n)?;
+ s.model.remove_datum(n)?;
+ }
+ skipped.extend(failed);
+ // The meshes this recut removes are those of cells the first one made, which had none.
+ super::datum::recut(s)?;
+ }
+ let made = names.into_iter().filter(|n| !skipped.iter().any(|(f, _)| f == n)).collect();
+ Ok((cleared, made, skipped))
+}
+
+/// What a mesher's reply says of the mesh a plan made.
+struct Summary {
+ min_scaled_jacobian: Option,
+ elements: usize,
+ /// Every element a hexahedron.
+ all_hex: bool,
+ /// The cells joined node to node into one mesh: every interface merged (none tied), and the
+ /// merged interfaces connect every cell.
+ conforming: bool,
+}
+
+/// The [`Summary`] of a mesher's reply (`mesh.hex.generate`'s `bodies`, or `mesh.cells`'
+/// `cells` and `interfaces`) over `cells`.
+fn summary(mesh: &Value, cells: &[String]) -> Summary {
+ let mut min: Option = None;
+ let (mut elements, mut all_hex) = (0usize, true);
+ let rows = mesh.get("bodies").or_else(|| mesh.get("cells")).and_then(Value::as_array).into_iter().flatten();
+ for b in rows {
+ if let Some(v) = b.get("quality").and_then(|q| q.get("min_scaled_jacobian")).and_then(Value::as_f64) {
+ min = Some(min.map_or(v, |m: f64| m.min(v)));
+ }
+ elements += b.get("elements").and_then(Value::as_u64).map_or(0, |n| usize::try_from(n).unwrap_or(usize::MAX));
+ all_hex &= b.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("hex"));
+ }
+ // The cells the merged interfaces join, grown from the first.
+ let interfaces: Vec<&Value> = mesh.get("interfaces").and_then(Value::as_array).into_iter().flatten().collect();
+ let merged = |i: &Value| i.get("joined").and_then(Value::as_str) == Some("merged");
+ let mut joined: Vec<&str> = cells.first().map(String::as_str).into_iter().collect();
+ loop {
+ let before = joined.len();
+ for i in interfaces.iter().copied().filter(|i| merged(i)) {
+ let pair: Vec<&str> = i.get("cells").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
+ if let [a, b] = pair.as_slice() {
+ match (joined.contains(a), joined.contains(b)) {
+ (true, false) => joined.push(b),
+ (false, true) => joined.push(a),
+ _ => {}
+ }
+ }
+ }
+ if joined.len() == before {
+ break;
+ }
+ }
+ let conforming = interfaces.iter().all(|i| merged(i)) && cells.iter().all(|c| joined.contains(&c.as_str()));
+ Summary { min_scaled_jacobian: min, elements, all_hex, conforming }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::{Value, json};
+
+ use crate::Session;
+
+ fn open_script(script: &str) -> (crate::tests::TempDir, Session) {
+ let bytes = fieldcraft_geometry::script_design(script).unwrap();
+ let (dir, path) = crate::tests::design_file(&bytes);
+ let mut s = Session::default();
+ s.execute("geometry.open", &json!({"path": path})).unwrap();
+ (dir, s)
+ }
+
+ fn open(commands: Value) -> (crate::tests::TempDir, Session) {
+ open_script(&json!({"commands": commands}).to_string())
+ }
+
+ fn templates(plan: &Value) -> Vec<(String, String)> {
+ plan["features"].as_array().unwrap().iter().map(|f| (f["type"].as_str().unwrap().into(), f["template"].as_str().unwrap().into())).collect()
+ }
+
+ fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> {
+ list.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()
+ }
+
+ /// Plan, apply, and check the mesh is all-hex, conforming and fills the body.
+ fn apply(s: &mut Session, plan: &Value, min_sj: f64) -> Value {
+ let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
+ assert_eq!((r["all_hex"].as_bool(), r["conforming"].as_bool()), (Some(true), Some(true)), "{r}");
+ let sj = r["min_scaled_jacobian"].as_f64().unwrap();
+ assert!(sj >= min_sj, "min scaled Jacobian {sj} < {min_sj}: {r}");
+ r
+ }
+
+ /// The mesh's volume against the bodies'.
+ fn volume_matches(s: &mut Session) {
+ let q = s.execute("mesh.quality", &json!({})).unwrap();
+ let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
+ let geometry: f64 = bodies.as_array().unwrap().iter().map(|b| b["volume"].as_f64().unwrap()).sum();
+ let mesh = q["volume"].as_f64().unwrap_or_else(|| q["metrics"]["volume"]["sum"].as_f64().unwrap_or_default());
+ if mesh > 0.0 {
+ assert!((mesh - geometry).abs() < 0.02 * geometry, "mesh volume {mesh} vs {geometry}");
+ }
+ }
+
+ #[test]
+ fn a_plate_with_holes_is_a_block_with_o_grids_framed_by_cuts() {
+ let (_d, mut s) = open(json!([
+ {"command": "solid.box", "params": {"length": 60, "width": 40, "height": 6, "body_name": "Plate"}},
+ {"command": "solid.hole", "params": {"position": [15, 20, 6], "diameter": 8, "depth": 6}},
+ {"command": "solid.hole", "params": {"position": [45, 20, 6], "diameter": 8, "depth": 6}},
+ ]));
+ let plan = s.execute("mesh.plan", &json!({"body": "Plate"})).unwrap();
+ assert_eq!(templates(&plan), pairs(&[("box", "block"), ("hole", "o_grid"), ("hole", "o_grid")]));
+ assert_eq!(plan["direction"], json!([0.0, 0.0, 1.0]));
+ assert_eq!(plan["mesher"], "cells");
+ // Two O-grid rings, each projecting onto its hole's walls, framed short of the other hole.
+ let rings = plan["rings"].as_array().unwrap();
+ assert_eq!(rings.len(), 2, "{plan}");
+ for r in rings {
+ assert_eq!((r["kind"].as_str(), r["radius"].as_f64()), (Some("o_grid"), Some(4.0)));
+ assert!(!r["faces"].as_array().unwrap().is_empty(), "{r}");
+ assert!(r["frame"].as_f64().unwrap() > 4.0);
+ }
+ // The cut between the holes, and the frames above and below them.
+ assert!(plan["cuts"].as_array().unwrap().iter().any(|c| c["point"][0] == 30.0 && c["normal"] == json!([1.0, 0.0, 0.0])), "{plan}");
+ let r = apply(&mut s, &plan, 0.5);
+ assert!(r["cells"].as_array().unwrap().len() > 1, "{r}");
+ assert_eq!(r["pending"].as_array().unwrap().len(), 2, "{r}");
+ volume_matches(&mut s);
+ // Saved: applied again by name it replaces its cuts and mesh.
+ let again = s.execute("mesh.plan.apply", &json!({"plan": "Plan-Plate"})).unwrap();
+ assert_eq!(again["cuts"], r["cuts"]);
+ assert_eq!(s.model.partitions().len(), r["cuts"].as_array().unwrap().len());
+ assert!(!again["replaced"].as_array().unwrap().is_empty(), "{again}");
+ }
+
+ #[test]
+ fn a_sketched_plate_sweeps_its_profile_and_o_grids_its_circle() {
+ let bytes = fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap();
+ let (_d, path) = crate::tests::design_file(&bytes);
+ let mut s = Session::default();
+ s.execute("geometry.open", &json!({"path": path})).unwrap();
+ let body = s.execute("geometry.bodies", &json!({})).unwrap()[0]["name"].as_str().unwrap().to_string();
+ let plan = s.execute("mesh.plan", &json!({"body": body})).unwrap();
+ assert_eq!(templates(&plan), pairs(&[("extrude", "sweep"), ("extrude", "imprint")]));
+ assert_eq!(plan["rings"].as_array().unwrap().len(), 1, "{plan}");
+ assert_eq!(plan["rings"][0]["kind"], "o_grid");
+ apply(&mut s, &plan, 0.5);
+ volume_matches(&mut s);
+ }
+
+ #[test]
+ fn a_cylinder_is_a_butterfly_swept_along_its_axis() {
+ let (_d, mut s) = open(json!([{"command": "solid.cylinder", "params": {"diameter": 20, "height": 30, "body_name": "Cyl"}}]));
+ let plan = s.execute("mesh.plan", &json!({"body": "Cyl"})).unwrap();
+ assert_eq!(templates(&plan), pairs(&[("cylinder", "butterfly")]));
+ assert_eq!((plan["mesher"].as_str(), plan.get("cuts")), (Some("sweep"), None));
+ assert_eq!(plan["rings"][0]["kind"], "butterfly");
+ assert_eq!(plan["rings"][0]["frame"], 5.0);
+ apply(&mut s, &plan, 0.7);
+ volume_matches(&mut s);
+ }
+
+ #[test]
+ fn a_joined_boss_is_a_butterfly_on_the_block() {
+ let (_d, mut s) = open(json!([
+ {"command": "solid.box", "params": {"length": 40, "width": 40, "height": 10, "body_name": "Base"}},
+ {"command": "solid.cylinder", "params": {"diameter": 12, "height": 8, "base": [20, 20, 10], "operation": "join", "targets": ["Base"]}},
+ ]));
+ let plan = s.execute("mesh.plan", &json!({"body": "Base"})).unwrap();
+ assert_eq!(templates(&plan), pairs(&[("box", "block"), ("cylinder", "butterfly")]));
+ assert_eq!(plan["rings"][0]["kind"], "butterfly");
+ apply(&mut s, &plan, 0.5);
+ volume_matches(&mut s);
+ }
+
+ #[test]
+ fn a_shelled_box_takes_layers_through_its_wall() {
+ let (_d, mut s) = open(json!([
+ {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "body_name": "Case"}},
+ {"command": "solid.shell", "params": {"faces": [[20, 15, 20]], "thickness": 2, "body": "Case"}},
+ ]));
+ let plan = s.execute("mesh.plan", &json!({"body": "Case", "layers": 2})).unwrap();
+ assert_eq!(templates(&plan), pairs(&[("box", "block"), ("shell", "layers")]));
+ // Two elements through the 2 mm wall.
+ assert_eq!(plan["size"], 1.0);
+ apply(&mut s, &plan, 0.9);
+ volume_matches(&mut s);
+ }
+
+ #[test]
+ fn a_patterned_and_mirrored_part_meshes_every_copy() {
+ let (_d, mut s) = open(json!([
+ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 4, "body_name": "Tab"}},
+ {"command": "solid.hole", "params": {"position": [5, 5, 4], "diameter": 4, "depth": 4}},
+ {"command": "solid.pattern.rectangular", "params": {"bodies": ["Tab"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 15}},
+ {"command": "solid.mirror", "params": {"bodies": ["Tab"], "plane": {"origin": [0, 0, 0], "normal": [0, 1, 0]}}},
+ ]));
+ let plan = s.execute("mesh.plan", &json!({"body": "Tab.1"})).unwrap();
+ assert_eq!(templates(&plan), pairs(&[("box", "block"), ("hole", "o_grid"), ("pattern", "copy"), ("mirror", "copy")]));
+ let copies = plan["copies"].as_array().unwrap();
+ assert_eq!(copies.len(), 3, "{plan}");
+ let r = apply(&mut s, &plan, 0.5);
+ assert_eq!(r["copies"].as_array().unwrap().len(), 3, "{r}");
+ for c in r["copies"].as_array().unwrap() {
+ assert_eq!(c["conforming"], true, "{c}");
+ assert_eq!(c["elements"], r["elements"], "a copy meshes as the instance: {c}");
+ }
+ // Every body meshed.
+ for b in s.execute("geometry.bodies", &json!({})).unwrap().as_array().unwrap() {
+ assert!(!s.model.body_elements(b["name"].as_str().unwrap()).is_empty(), "{b}");
+ }
+ }
+
+ #[test]
+ fn fillets_are_unmapped_with_the_reason_and_hostile_plans_are_refused() {
+ let (_d, mut s) = open(json!([
+ {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "body_name": "B"}},
+ {"command": "solid.fillet", "params": {"edges": [[20, 10, 20]], "radius": 2}},
+ ]));
+ let plan = s.execute("mesh.plan", &json!({"body": "B"})).unwrap();
+ let unmapped = plan["unmapped"].as_array().unwrap();
+ assert_eq!(unmapped.len(), 1, "{plan}");
+ assert_eq!(unmapped[0]["type"], "fillet");
+ assert!(unmapped[0]["reason"].as_str().unwrap().contains("step 3"), "{plan}");
+ let mut bad = plan.clone();
+ bad["size"] = json!(-1.0);
+ assert!(s.execute("mesh.plan.apply", &json!({"plan": bad})).is_err());
+ let mut bad = plan.clone();
+ bad["rings"] = json!([{"feature": 1, "kind": "o_grid", "center": [0, 0, 0], "axis": [0, 0, 0], "radius": 1, "frame": 2}]);
+ assert!(s.execute("mesh.plan.apply", &json!({"plan": bad})).is_err());
+ assert!(s.execute("mesh.plan", &json!({"body": "Nope"})).is_err());
+ assert!(s.execute("mesh.plan", &json!({"body": "B", "layers": 0})).is_err());
+ assert!(s.execute("mesh.plan", &json!({"body": "B", "size": f64::MAX})).is_ok());
+ assert!(s.execute("mesh.plan.apply", &json!({"plan": "Nope"})).is_err());
+ }
+
+ /// The feature graph of every corpus design and the crank arm: every body lists the features
+ /// that built it, and a plan builds for every body (fillets, revolves and lofts unmapped).
+ #[test]
+ fn every_corpus_design_has_a_feature_graph_and_a_plan() {
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/corpus");
+ let mut scripts: Vec<(String, String)> = std::fs::read_dir(&dir)
+ .unwrap()
+ .filter_map(|e| {
+ let path = e.ok()?.path().join("recipe.json");
+ Some((path.parent()?.file_name()?.to_string_lossy().into_owned(), std::fs::read_to_string(path).ok()?))
+ })
+ .collect();
+ scripts.push(("crank-arm".into(), crate::sample::CRANK_ARM.into()));
+ scripts.sort();
+ assert!(scripts.len() > 10);
+ for (name, script) in scripts {
+ let (_d, mut s) = open_script(&script);
+ let graph = s.execute("geometry.features", &json!({})).unwrap();
+ for b in graph["bodies"].as_array().unwrap() {
+ let features = b["features"].as_array().unwrap();
+ assert!(!features.is_empty(), "{name}: {b}");
+ // Every feature's faces are faces of the body.
+ let body = b["body"].as_str().unwrap();
+ let plan = s.execute("mesh.plan", &json!({"body": body}));
+ let plan = plan.unwrap_or_else(|e| panic!("{name}/{body}: {e}"));
+ let mapped = plan["features"].as_array().map_or(0, Vec::len);
+ let unmapped = plan["unmapped"].as_array().map_or(0, Vec::len);
+ assert_eq!(mapped + unmapped, features.len(), "{name}/{body}: {plan}");
+ }
+ }
+ }
+}
diff --git a/crates/engine/src/cmd/relax.rs b/crates/engine/src/cmd/relax.rs
index 64e491b..ee2b453 100644
--- a/crates/engine/src/cmd/relax.rs
+++ b/crates/engine/src/cmd/relax.rs
@@ -52,6 +52,7 @@ const REMESH: &[&str] = &[
"mesh.hex.sweep",
"mesh.hex.mapped",
"mesh.hex.blocks",
+ "mesh.plan.apply",
"mesh.hex.octree",
"mesh.cells",
"mesh.block",
diff --git a/crates/engine/src/cmd/replace.rs b/crates/engine/src/cmd/replace.rs
index d814fb2..8b2ebe4 100644
--- a/crates/engine/src/cmd/replace.rs
+++ b/crates/engine/src/cmd/replace.rs
@@ -130,6 +130,7 @@ pub static MESHERS: &[(&str, Targets)] = &[
("mesh.hex.blocks", listed_or_all),
("mesh.hex.project", body_or_all),
("mesh.cells", listed_or_all),
+ ("mesh.plan.apply", super::plan::plan_bodies),
("mesh.planar", super::mesh::planar_body),
("mesh.mirror", super::mirror::image_names),
];
@@ -173,7 +174,7 @@ fn made_kind(command: &str, params: &Value) -> Option {
Some(if params.get("order").and_then(Value::as_str) == Some("quadratic") { ElementKind::Tet10 } else { ElementKind::Tet4 })
}
"mesh.hex.project" => Some(ElementKind::Hex8),
- "mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" => {
+ "mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" | "mesh.plan.apply" => {
Some(if params.get("order").and_then(Value::as_i64) == Some(2) { ElementKind::Hex20 } else { ElementKind::Hex8 })
}
_ => None,
@@ -313,6 +314,13 @@ mod tests {
("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5}), "Box.1", "Box.2"),
("mesh.hex.project", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5, "merge": 0}), "Box.1", "Box.2"),
("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5, "default": "hex"}), "Box.1", "Box.2"),
+ (
+ "mesh.plan.apply",
+ vec![tet_other.clone(), json!({"command": "mesh.plan", "params": {"body": "Box.1", "size": 2.5}})],
+ json!({"plan": "Plan-Box_1"}),
+ "Box.1",
+ "Box.2",
+ ),
("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box.1"}), "Box.1", "Box.2"),
// The image's tets (meshed before) are replaced by the mirrored hexahedra.
(
diff --git a/docs/features.md b/docs/features.md
index 512d9ba..a135938 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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.
-71 ready, 39 coming soon.
+72 ready, 39 coming soon.
## Geometry
@@ -14,7 +14,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|---|---|---|---|
| Import | Open Design — Open a SolidCraft design: its bodies with persistent face names | ready | `geometry.open` |
| Import | Tagged Mesh — Read a face-tagged surface mesh (3MF) | ready | `geometry.import.mesh` |
-| Inspect | Bodies — List the open design's bodies, volumes and named faces, edges and vertices | ready | `geometry.bodies`, `geometry.centre` |
+| Inspect | Bodies — List the open design's bodies, volumes and named faces, edges and vertices | ready | `geometry.bodies`, `geometry.centre`, `geometry.features` |
| Inspect | Tessellate — Tessellate a body into a face-tagged boundary surface | ready | `geometry.tessellate` |
| Inspect | Check Surface — Weld, closed, manifold, orientation and self-intersection checks; feature edges | ready | `mesh.surface.check` |
| Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`), (`geometry.datum.suppress`) |
@@ -37,6 +37,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Structured Hex | Quad Faces — All-quad meshes of faces on shared edge nodes | coming soon (M5) | |
| Structured Hex | Hex Mesh — Structured hex mesh of a body: automatic, or extruded, swept, revolved or mapped (the Method drop-down) | ready | `mesh.hex.generate`, (`mesh.hex.sweep`), (`mesh.hex.mapped`) |
| Structured Hex | Partitioned Mesh — One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions | ready | `mesh.cells`, (`mesh.hex.blocks`) |
+| Structured Hex | Mesh Plan — A hex-meshing plan from the features that built a body (sweeps, blocks, O-grids, layers, imprints, copies); saved, editable, applied with the structured meshers | ready | `mesh.plan`, `mesh.plan.apply` |
| Structured Hex | Mesh Block — A mapped block of hex, wedge or quad elements between corner points; projection blocking (a block topology projected onto the part) | ready | `mesh.block`, `mesh.hex.project` |
| Structured Hex | Decompose — Find sweepable and mappable regions of 3D parts (extruded cross-sections are already cut into blocks by Hex Mesh) | coming soon (M5) | |
| Structured Hex | Transition — Conforming tet–hex interfaces with pyramids | coming soon (M5) | |