diff --git a/apps/fieldcraft-cli/tests/update.rs b/apps/fieldcraft-cli/tests/update.rs
index ab658ae..6379b8c 100644
--- a/apps/fieldcraft-cli/tests/update.rs
+++ b/apps/fieldcraft-cli/tests/update.rs
@@ -12,7 +12,10 @@ fn cli(host: &str, args: &[&str]) -> std::process::Output {
#[test]
fn update_check_reports_the_latest_release() {
let server = Server::start().unwrap();
- server.release("latest", &json!({"tag_name": "v99.0.0", "html_url": "https://github.com/tangency-labs/fieldcraft/releases/tag/v99.0.0", "assets": []}));
+ server.release(
+ "latest",
+ &json!({"tag_name": "v99.0.0", "html_url": "https://github.com/tangency-labs/fieldcraft/releases/tag/v99.0.0", "assets": []}),
+ );
let out = cli(&server.url(), &["update", "--check"]);
assert!(out.status.success(), "{out:?}");
let report: Value = serde_json::from_slice(&out.stdout).unwrap();
diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index a3c0c62..0049cad 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -1736,6 +1736,9 @@ pub const MESH_PLAN_APPLY: &[Field] = &[
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"),
+ flag("corners", "Copy corners")
+ .help("With symmetry: when the body's two ends along another axis are alike (an enclosure's corners), mesh one end and mirror it onto the other, the middle meshed to fit both.")
+ .def("true"),
text("name", "Save as").help("The name a plan given as an object is saved under.").hint("Plan-
"),
];
pub const FEATURES: &[Field] = &[reference("body", "Body", C::Body).help("Only this body's features.").hint("every body")];
diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs
index a4f1a78..93f5533 100644
--- a/crates/engine/src/cmd/plan.rs
+++ b/crates/engine/src/cmd/plan.rs
@@ -33,6 +33,7 @@
use std::collections::BTreeSet;
use fieldcraft_geometry::{Curve, Design, FaceSurface, FeatureNode, Shape};
+use fieldcraft_mesh::project::Topology;
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{CutRole, Datum, MeshPlan, Partition, PlanCut, PlanFeature, PlanMesher, PlanRing, PlanUnmapped, RingKind};
use serde_json::{Value, json};
@@ -66,7 +67,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-), "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`); `untagged_faces` names each face of the meshed bodies no element face is tagged with (a set by it finds nothing: only a face narrower than the elements beside it); `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), "corners"?: true (with symmetry: when the cell's two ends along another in-plane axis are alike — the planes of its block layout mirror one another from both ends inward, and its surface there too — the cell is cut midway into the next sheet of blocks and at its image, the low end meshed and mirrored onto the high one, and the middle meshed with both cut faces fixed to those meshes, so both ends mesh exactly alike; reported in `corners`; false meshes the cell whole)} — 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`); `untagged_faces` names each face of the meshed bodies no element face is tagged with (a set by it finds nothing: only a face narrower than the elements beside it); `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],
@@ -1031,7 +1032,8 @@ fn apply(s: &mut Session, p: &Value) -> Result {
_ => return Err(EngineError::Params("`plan` must be a saved plan's name or a plan object".into())),
};
let symmetry: bool = super::opt(p, "symmetry")?.unwrap_or(true);
- let symmetric = if symmetry { apply_symmetric(s, &name, &plan)? } else { None };
+ let corners: bool = super::opt(p, "corners")?.unwrap_or(true);
+ let symmetric = if symmetry { apply_symmetric(s, &name, &plan, corners)? } else { None };
let mut reply = match symmetric {
Some(r) => r,
None => apply_one(s, &name, &plan)?,
@@ -1213,50 +1215,74 @@ const SYMMETRY_SAMPLES: usize = 3000;
fn symmetry_planes(shape: &Shape, d: V3, rings: &[PlanRing]) -> Result> {
let m = shape.measure()?;
let (lo, hi) = (m.bbox_min, m.bbox_max);
- let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::().sqrt();
- if !(diagonal.is_finite() && diagonal > 0.0) {
- return Ok(Vec::new());
- }
- // Fine enough that the chords lie well within `delta` of the surface.
- let mesh = shape.tessellate(2e-4 * diagonal)?;
- // Each triangle's middle and normal: a point of a face, away from its edges.
- let samples: Vec<(V3, V3)> = mesh
- .triangles
- .iter()
- .filter_map(|t| {
- let [a, b, c] = t.map(|i| mesh.positions.get(i as usize).copied().unwrap_or_default());
- let n = unit(cross(std::array::from_fn(|k| b[k] - a[k]), std::array::from_fn(|k| c[k] - a[k])))?;
- Some((std::array::from_fn(|k| (a[k] + b[k] + c[k]) / 3.0), n))
- })
- .collect();
- let step = (samples.len() / SYMMETRY_SAMPLES).max(1);
- let delta = 2e-3 * diagonal;
+ let Some(mirror) = Mirror::of(shape)? else { return Ok(Vec::new()) };
let mut out = Vec::new();
for a in in_plane_axes(d).into_iter().filter(|a| AXES.iter().any(|x| parallel(*x, *a))) {
let c = 0.5 * (dot(lo, a) + dot(hi, a));
if rings.iter().any(|r| (dot(r.center, a) - c).abs() < 1.01 * r.radius) {
continue;
}
- let mirror = |x: V3| -> V3 { std::array::from_fn(|k| x[k] - 2.0 * (dot(x, a) - c) * a[k]) };
- let symmetric = samples.iter().step_by(step).all(|(p, n)| {
- let inside = mirror(std::array::from_fn(|k| p[k] - delta * n[k]));
- let outside = mirror(std::array::from_fn(|k| p[k] + delta * n[k]));
- blocks::winding(&mesh, inside) > 0.5 && blocks::winding(&mesh, outside) < 0.5
- });
- if symmetric {
+ if mirror.symmetric(a, c, |_| true) {
out.push((a, c));
}
}
Ok(out)
}
+/// The mirror test of a body's surface: points of its faces (the middles of its tessellation's
+/// triangles, a little inside and a little outside along the triangle's normal) whose mirror
+/// images are inside and outside alike (the generalized winding number of the tessellation).
+struct Mirror {
+ mesh: fieldcraft_geometry::SurfaceMesh,
+ /// Each triangle's middle and normal: a point of a face, away from its edges.
+ samples: Vec<(V3, V3)>,
+ delta: f64,
+}
+
+impl Mirror {
+ /// `None` for a body with no extent.
+ fn of(shape: &Shape) -> Result