//! 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 std::collections::BTreeSet;
use fieldcraft_geometry::{Curve, Design, FaceSurface, FeatureNode, Shape};
use fieldcraft_mesh::project::{ProjectOptions, Topology};
use fieldcraft_model::geometry::Entity;
use fieldcraft_model::{CutRole, Datum, MeshPlan, Partition, PlanCut, PlanFeature, PlanMesher, PlanRing, PlanUnmapped, RingKind};
use serde_json::{Value, json};
mod blocks;
mod decompose;
mod pillow;
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`; with unmapped features that the sweep cannot mesh, a `decomposition` proposal (not saved): the partition `cuts` (point, normal) that box those features' faces into one cell, one element size clear of them, inside the body and clear of its faces (`open_sides` where no such cut fits), the boxed `tet_region`, an upper bound of its share of the volume, and the `commands` that make the cuts and mesh the cells with mesh.cells (the boxed cell tet, joined to the hex cells by pyramids)"#,
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), "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],
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; a merged face on no one surface, when
/// each of its B-rep faces is.
fn faces_prismatic(shape: &Shape, surfaces: &[Option], faces: &[String], d: V3) -> bool {
let prismatic = |s: Option| match s {
Some(FaceSurface::Plane { normal, .. }) => parallel(normal, d) || perpendicular(normal, d),
Some(FaceSurface::Cylinder { axis, .. }) => parallel(axis, d),
_ => false,
};
faces.iter().all(|name| {
let Some(i) = shape.face_index(name) else { return true };
match surfaces.get(i as usize).copied().flatten() {
Some(s) => prismatic(Some(s)),
None => shape.member_surfaces(i).is_ok_and(|m| m.len() > 1 && m.into_iter().all(prismatic)),
}
})
}
/// 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