//! 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};
mod blocks;
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-
), "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],
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",
("fillet" | "chamfer", _) => "blend",
_ => return None,
})
}
/// Why a feature with no template is not mapped.
fn unmapped_reason(f: &FeatureNode) -> String {
match f.kind.as_str() {
"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,
// A blend is a layer of blocks along the body's sides, whatever its edges' direction.
"fillet" | "chamfer" => 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 middle of the cut's rectangle: which way is out of the opening from each side.
center: Option,
}
/// 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 mut center = None;
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]));
}
center = Some(std::array::from_fn(|i| p[i] + 0.5 * dims[i]));
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 {
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 mid = lines.iter().fold([0.0; 2], |m, (a, _)| [m[0] + a[0] / 4.0, m[1] + a[1] / 4.0]);
center = Some(world(mid));
for (a, b) in lines {
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() }, center })
}
/// 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 (a level exactly on a tool's own face is that face carried on: no
/// sliver), and not next to another cut. A frame side on a face of the body (an opening's end
/// wall) is moved one element (twice `gap`) out of the opening: the kernel cannot cut along a
/// face (#427, #455), and a strip one element wide is left between the cut and the face.
fn imprint_cuts(tools: &[Imprint], d: V3, body: &Clearance, cuts: &mut Vec) {
let tool_ids: Vec = tools.iter().map(|t| t.feature).collect();
// `off`: the distance from `t` of the face a frame side was moved off, allowed exactly.
let mut add = |n: V3, t: f64, reason: String, feature: u64, off: Option| {
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)| {
if let Some(o) = off {
return (x - o).abs() > 1e-6;
}
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, None);
}
}
for (n, off) in &tool.sides {
let why = format!("frame: a side of the opening F{} cuts, carried across the body", tool.feature);
let on_face = body.faces_near(*n, *off).any(|(x, _)| x <= 1e-6);
match tool.center.filter(|_| on_face) {
Some(c) => {
let out = (off - dot(*n, c)).signum();
let shift = 2.0 * body.gap;
add(*n, off + out * shift, format!("{why}, {shift:.4} mm out of the opening off its end face"), tool.feature, Some(shift));
}
None => add(*n, *off, why, tool.feature, None),
}
}
}
}
/// 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
};
// Concentric circles each get a ring (a boss and its hole: squares inside squares).
if out.iter().any(|r| same(r) && (r.radius - c.radius).abs() < 1e-9 * c.radius.max(1.0)) {
continue;
}
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)
}
/// The shape of `body`: a body of the design as opened, or a cell partitions cut from one.
fn plan_shape<'a>(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 = 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()?;
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(),
("fillet", _) => {
format!("a blend layer of blocks along its edges, {:.4} mm deep, projected onto the fillet faces", v("radius").unwrap_or_default())
}
("chamfer", _) => {
format!("a blend layer of blocks along its edges, {:.4} mm deep, projected onto the chamfer faces", v("distance").unwrap_or_default())
}
_ => 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();
// Where the frame cuts could not go: the swept cells' business only.
let mut frame_notes = 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);
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.
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 };
// 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,
tolerance: Some(tolerance),
layers,
mesher,
direction,
directions,
features: plan_features,
cuts,
rings,
copies,
unmapped,
notes,
};
plan.check()?;
// Rings and blends: one block topology over the whole body when it fits (no cuts needed);
// else the frame cuts and swept cells, with the reason.
match layout_of(design, &plan)? {
Some(Ok(layout)) => {
plan.mesher = PlanMesher::Project;
plan.cuts.clear();
// The rings' frames are the layout's.
if let Some(d) = plan.direction {
for r in &mut plan.rings {
if let Some(half) = layout.frame_of(r.center, d).filter(|_| r.kind == RingKind::OGrid) {
r.frame = half;
}
}
}
plan.notes.push(layout.summary());
}
Some(Err(why)) => {
plan.notes.extend(frame_notes);
plan.notes.push(format!("no projection layout ({why}): the frame cuts box the rings and the cells are swept"));
}
None => plan.notes.extend(frame_notes),
}
plan.check()?;
Ok(plan)
}
/// The block layout of `plan` for the projection mesher, when the plan has rings or blends
/// (`None` when it has neither: the sweep meshes it); `Err` inside says why the body does not
/// take one.
fn layout_of(design: &Design, plan: &MeshPlan) -> Result