diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index 70bbf10..31d03f3 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1706,7 +1706,12 @@ pub const MESH_PLAN: &[Field] = &[
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 MESH_PLAN_APPLY: &[Field] = &[
+ text("plan", "Plan").help("The name of a saved mesh plan (mesh.plan).").req(),
+ flag("symmetry", "Use symmetry")
+ .help("Mesh one cell of a body that is its own mirror image across planes through its middle, and mirror it.")
+ .def("true"),
+];
pub const FEATURES: &[Field] = &[reference("body", "Body", C::Body).help("Only this body's features.").hint("every body")];
pub const GEOMETRY_CHECK: &[Field] = &[
reference("body", "Body", C::Body).help("The body to check; every body when empty.").hint("every body"),
diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs
index b5a63b3..e1cf703 100644
--- a/crates/engine/src/cmd/plan.rs
+++ b/crates/engine/src/cmd/plan.rs
@@ -62,7 +62,7 @@ pub const PLAN: CommandSpec = CommandSpec {
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"#,
+ 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-), "symmetry"?: true (a projection plan on a body mirror symmetric across planes through its middle square to the sweep meshes one cell and mirrors it: false meshes the whole body)} — 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],
@@ -677,13 +677,18 @@ fn copies_of(design: &Design, body: &str, features: &[PlanFeature]) -> Result(design: &'a Design, body: &str) -> Option<&'a Shape> {
+ design.source_body(body).or_else(|| design.body(body))
+}
+
+/// Build the plan of `body`: a body of the design as opened, or a cell of one (planned from the
+/// features that built the body it was cut from, with the rings the cell holds).
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 shape = plan_shape(design, body).ok_or_else(|| EngineError::Params(format!("no body `{body}` in the design")))?;
+ let source = design.source_of(body).unwrap_or(body);
+ let is_cell = source != body;
+ let graph = design.features(Some(source))?;
let features: Vec = graph.into_iter().next().map(|b| b.features).unwrap_or_default();
let surfaces = shape.face_surfaces()?;
let m = shape.measure()?;
@@ -869,6 +874,15 @@ pub(crate) fn build(design: &Design, body: &str, size: Option, layers: u32,
imprint_cuts(&imprints, d, &body, &mut cuts);
frame_notes = frame_cuts(&circle_list, d, &body, &mut cuts);
rings = plan_rings(&circle_list, d, &body, &cuts, shape, &surfaces, &features);
+ // A cell holds the rings whose axis runs through it.
+ if is_cell {
+ rings.retain(|r| {
+ (0..3).all(|k| {
+ (r.center[k] - d[k] * dot(r.center, d)) >= lo[k] - d[k] * dot(lo, d) - 1e-9
+ && (r.center[k] - d[k] * dot(r.center, d)) <= hi[k] - d[k] * dot(hi, d) + 1e-9
+ })
+ });
+ }
}
// The tessellation: the meshers' default, finer where the body has small curves (a hundredth
// of the smallest radius), so short blend edges stay edges.
@@ -885,7 +899,8 @@ pub(crate) fn build(design: &Design, body: &str, size: Option, layers: u32,
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)?;
+ // A cell's copies are its body's (meshed with it), not its own.
+ let copies = if !is_cell { copies_of(design, body, &plan_features)? } else { Vec::new() };
let mut plan = MeshPlan {
body: body.into(),
size: h,
@@ -937,7 +952,7 @@ fn layout_of(design: &Design, plan: &MeshPlan) -> Result