diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs
index 3ee9b14..81f9591 100644
--- a/crates/engine/src/cmd/plan.rs
+++ b/crates/engine/src/cmd/plan.rs
@@ -30,7 +30,10 @@
//! (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 std::collections::BTreeSet;
+
use fieldcraft_geometry::{Curve, Design, FaceSurface, FeatureNode, Shape};
+use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{CutRole, Datum, MeshPlan, Partition, PlanCut, PlanFeature, PlanMesher, PlanRing, PlanUnmapped, RingKind};
use serde_json::{Value, json};
@@ -63,7 +66,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`); `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`); `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],
@@ -1045,12 +1048,14 @@ fn apply(s: &mut Session, p: &Value) -> Result {
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 untagged = untagged_faces(s, &plan_bodies(s, p)?);
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)));
}
+ o.insert("untagged_faces".into(), json!(untagged));
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));
@@ -1059,6 +1064,33 @@ fn apply(s: &mut Session, p: &Value) -> Result {
Ok(reply)
}
+/// The faces of `bodies` (as the design has them) that no element face of the mesh is tagged
+/// with: a set, load or relaxation by such a face finds nothing. Only a face narrower than the
+/// elements beside it should be one.
+fn untagged_faces(s: &Session, bodies: &[String]) -> Vec {
+ let c = s.model.classification();
+ let mut tagged: BTreeSet = BTreeSet::new();
+ for t in c.elements().values() {
+ tagged.extend(t.faces.values().copied());
+ }
+ let tagged: BTreeSet<(&str, &str)> = tagged
+ .iter()
+ .filter_map(|&t| c.entity(t))
+ .filter_map(|g| match &g.entity {
+ Entity::Face(name) => Some((g.body.as_str(), name.as_str())),
+ _ => None,
+ })
+ .collect();
+ let Some(design) = s.design.as_deref() else { return Vec::new() };
+ bodies
+ .iter()
+ .filter_map(|b| design.body(b))
+ .flat_map(|b| b.faces().iter().map(move |f| (b.name.as_str(), f.name.as_str())))
+ .filter(|key| !tagged.contains(key))
+ .map(|(body, face)| json!({"body": body, "face": face}))
+ .collect()
+}
+
/// 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> {
@@ -1897,6 +1929,41 @@ mod acceptance {
assert!(lo > 0.65, "per corner min scaled Jacobian {worst:?}");
}
+ /// The enclosure base meshed by its symmetry (one quarter, mirrored twice): each corner
+ /// boss cuts the inner fillet into two pieces and a sliver narrower than the elements, and
+ /// every face of every cell, the slivers and their mirror images among them, has element
+ /// faces tagged with it, so a set by any face finds the mesh (#494).
+ #[test]
+ fn a_symmetric_filleted_base_tags_every_face_of_every_cell() {
+ let mut commands = vec![
+ json!({"command": "solid.box", "params": {"length": 56, "width": 42, "height": 16, "body_name": "Base"}}),
+ json!({"command": "solid.fillet", "params": {"edges": [[0, 0, 8], [56, 0, 8], [56, 42, 8], [0, 42, 8]], "radius": 5}}),
+ json!({"command": "solid.shell", "params": {"faces": [[28, 21, 16]], "thickness": 3, "body": "Base"}}),
+ ];
+ for (x, y) in [(-1.0, -1.0), (57.0, -1.0), (-1.0, 43.0), (57.0, 43.0)] {
+ commands.push(json!({"command": "solid.cylinder", "params": {"diameter": 13, "height": 16, "base": [x, y, 0], "operation": "join", "targets": ["Base"]}}));
+ }
+ let (_d, mut s) = open(json!(commands));
+ let plan = s.execute("mesh.plan", &json!({"body": "Base", "size": 1.5, "layers": 2})).unwrap();
+ let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
+ assert_eq!(r["cells"].as_array().map(Vec::len), Some(4), "{r}");
+ assert_eq!(r["untagged_faces"], json!([]), "{r}");
+ let design = s.design.clone().unwrap();
+ let mut blends = 0;
+ for cell in r["cells"].as_array().unwrap() {
+ let body = design.body(cell.as_str().unwrap()).unwrap();
+ for (k, face) in body.faces().iter().enumerate() {
+ blends += usize::from(face.name.contains("blend"));
+ let set = format!("F{k}");
+ s.execute("model.set.create", &json!({"name": set, "geometry": [{"body": body.name, "face": face.name}]})).unwrap();
+ let faces = s.model.resolve_set(&set).map(|x| x.faces.len());
+ assert!(faces.as_ref().is_ok_and(|n| *n > 0), "face {} of {}: {faces:?}", face.name, body.name);
+ s.execute("model.set.delete", &json!({"name": set})).unwrap();
+ }
+ }
+ assert!(blends >= 4, "the inner fillet's pieces");
+ }
+
/// An enclosure base: a box with rounded vertical edges, shelled open at the top, a screw
/// boss joined at each corner (centred outside it, nearly touching the inner fillet) with a
/// blind hole, and standoffs on the floor. Each boss's ring frames the whole circle,
@@ -2000,9 +2067,10 @@ mod tests_support {
}
/// All hex, one conforming mesh on the geometry, the volume the body's, no element below
- /// `min_sj` or above `max_edge_ratio`, no free faces inside.
+ /// `min_sj` or above `max_edge_ratio`, no free faces inside, every face of the body tagged.
pub(super) fn accept(s: &mut Session, r: &Value, min_sj: f64, max_edge_ratio: f64) {
assert_eq!((r["all_hex"].as_bool(), r["conforming"].as_bool()), (Some(true), Some(true)), "{r}");
+ assert_eq!(r["untagged_faces"], json!([]), "faces no element face is tagged with");
let q = s.execute("mesh.quality", &json!({})).unwrap();
let sj = q["metrics"]["scaled_jacobian"]["min"].as_f64().unwrap();
let er = q["metrics"]["edge_ratio"]["max"].as_f64().unwrap();