//! Multi-level sweeping along a straight direction (extrusions) or around an axis (revolutions).
//!
//! **Recognition.** In a sweep frame every point has cross-section coordinates `q` and a sweep
//! coordinate `h` (height along the direction, or angle about the axis). A body is sweepable in
//! the frame when every face is a *cap* (a plane across the sweep: normal along the direction, or
//! a half-plane through the axis) or a *wall* (along the sweep: planes and cylinders parallel to
//! the direction; planes across the axis and coaxial cylinders, cones, spheres and tori), and
//! every edge runs either across the sweep (constant `h`) or along it (constant `q`). The caps
//! and the across edges sit at a finite set of *levels*.
//!
//! **Decomposition.** Projecting into the cross-section every edge that starts a wall upward
//! (the lower outline of each wall) and imprinting them on each other gives a planar arrangement
//! ([`super::arrangement`]) whose regions are the columns of the body: over each region the body
//! is a union of whole level intervals. This is many-to-many sweeping by imprinting all sources
//! and targets into one projection plane (Lai, Benzley, White, IJNME 49(1–2):261–275, 2000;
//! Knupp, *Next-generation sweep tool: a method for generating all-hex meshes on two-and-one-half
//! dimensional geometries*, Proc. 7th International Meshing Roundtable, 1998). Which intervals a
//! region fills is decided by the inside test at its centre ([`super::geom::Inside`]).
//!
//! **Blocks.** Regions no structured scheme fits are cut into blocks that fit
//! ([`super::decompose`]: holes apart, O-grids, cuts from concave corners and fillets), and the
//! arrangement is laid out again with the cuts, so they are curves like the others.
//!
//! **Meshing.** Interval assignment ([`super::interval`]) gives every curve its count so that
//! every region's scheme fits ([`super::section`]: four-sided regions mapped with opposite sides
//! equal, three-sided ones the triangle primitive, rings and disks O-grids, the rest even loops
//! for the free all-quad mesher); each level interval gets its own layer count. Each region's quads are
//! stacked through its intervals; shared curves and levels share nodes, so the hexahedra
//! conform. Nodes of curves on analytic walls are projected onto them; in a revolution, the
//! rotated nodes then lie exactly on every surface of revolution.
use std::collections::HashMap;
use std::f64::consts::{FRAC_PI_4, PI, TAU};
use super::arrangement::{self, Arrangement, Q};
use super::brep::{Brep, Face};
use super::decompose;
use super::geom::{Inside, Surface, add, dist, scale, unit};
use super::interval::Intervals;
use super::region::{self, Scheme};
use super::section;
use super::{Ctx, Raw};
use crate::predicates::Sign;
use crate::seeds::EdgeSeed;
use crate::size::{SizeField, divide_polyline, edge_divisions, ideal_divisions};
use crate::vec3::{P, cross, dot, norm, sub};
use crate::{Error, Result};
use fieldcraft_model::SeedConstraint;
/// Angular tolerance (radians) of "parallel" and "perpendicular" for the recognition.
const ANGLE_TOL: f64 = 1e-6;
/// Distance (relative to the diagonal) within which two axes of revolution are one.
const RELATIVE_AXIS: f64 = 1e-6;
/// The most candidate frames tried per method.
pub(crate) const MAX_CANDIDATES: usize = 6;
/// A sweep frame.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Frame {
/// Cross-section `(p·u, p·v)` relative to `o`, height `p·d`; `u × v = d`.
Translate { o: P, u: P, v: P, d: P },
/// Cross-section `(z, r)` (along the axis, from the axis), angle about `a` from `e1`
/// counter-clockwise; `e2 = a × e1`.
Rotate { o: P, a: P, e1: P, e2: P },
}
fn perpendicular(d: P) -> P {
let k = if d[0].abs() <= d[1].abs() && d[0].abs() <= d[2].abs() {
[1.0, 0.0, 0.0]
} else if d[1].abs() <= d[2].abs() {
[0.0, 1.0, 0.0]
} else {
[0.0, 0.0, 1.0]
};
unit(cross(d, k)).unwrap_or([1.0, 0.0, 0.0])
}
impl Frame {
pub(crate) fn translate(o: P, d: P) -> Option {
let d = unit(d)?;
let u = perpendicular(d);
Some(Frame::Translate { o, u, v: cross(d, u), d })
}
pub(crate) fn rotate(o: P, a: P) -> Option {
let a = unit(a)?;
let e1 = perpendicular(a);
Some(Frame::Rotate { o, a, e1, e2: cross(a, e1) })
}
pub(crate) fn coords(&self, p: P) -> (Q, f64) {
match *self {
Frame::Translate { o, u, v, d } => {
let w = sub(p, o);
([dot(w, u), dot(w, v)], dot(w, d))
}
Frame::Rotate { o, a, e1, e2 } => {
let w = sub(p, o);
let z = dot(w, a);
let (x, y) = (dot(w, e1), dot(w, e2));
let t = y.atan2(x);
([z, x.hypot(y)], if t < 0.0 { t + TAU } else { t })
}
}
}
pub(crate) fn point(&self, q: Q, h: f64) -> P {
match *self {
Frame::Translate { o, u, v, d } => add(o, add(add(scale(u, q[0]), scale(v, q[1])), scale(d, h))),
Frame::Rotate { o, a, e1, e2 } => add(o, add(scale(a, q[0]), scale(add(scale(e1, h.cos()), scale(e2, h.sin())), q[1]))),
}
}
fn periodic(&self) -> bool {
matches!(self, Frame::Rotate { .. })
}
/// The signed sweep distance from `h0` to `h1` (angles wrapped to (−π, π]).
fn dh(&self, h1: f64, h0: f64) -> f64 {
let d = h1 - h0;
match self {
Frame::Translate { .. } => d,
Frame::Rotate { .. } => {
let w = d.rem_euclid(TAU);
if w > PI { w - TAU } else { w }
}
}
}
/// The length a sweep distance `dh` spans at cross-section point `q`.
fn span(&self, q: Q, dh: f64) -> f64 {
match self {
Frame::Translate { .. } => dh.abs(),
Frame::Rotate { .. } => dh.abs() * q[1].abs(),
}
}
pub(crate) fn describe(&self) -> String {
let f = |p: P| format!("({:.6}, {:.6}, {:.6})", p[0], p[1], p[2]);
match *self {
Frame::Translate { d, .. } => format!("extruded along {}", f(d)),
Frame::Rotate { o, a, .. } => format!("revolved about the axis through {} along {}", f(o), f(a)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Role {
Cap,
Wall,
}
fn parallel(a: P, b: P) -> bool {
norm(cross(a, b)) <= ANGLE_TOL
}
fn perpendicular_to(a: P, b: P) -> bool {
dot(a, b).abs() <= ANGLE_TOL
}
/// Distance from `p` to the line through `o` along unit `a`.
fn line_distance(p: P, o: P, a: P) -> f64 {
let w = sub(p, o);
norm(sub(w, scale(a, dot(w, a))))
}
/// The face's role in the frame, or why it has none.
fn role(brep: &Brep, face: &Face, frame: &Frame, tol: f64) -> std::result::Result {
let name = face.id;
match *frame {
Frame::Translate { d, .. } => {
if let Some((_, n)) = face.plane {
if parallel(n, d) {
return Ok(Role::Cap);
}
if perpendicular_to(n, d) {
return Ok(Role::Wall);
}
return Err(format!("planar face {name} is slanted to the direction"));
}
match face.surface {
Some(Surface::Cylinder { axis, .. }) if parallel(axis, d) => Ok(Role::Wall),
Some(Surface::Cylinder { .. }) => Err(format!("cylindrical face {name} is not parallel to the direction")),
Some(_) => Err(format!("face {name} is curved across the direction")),
None => {
// A free-form face is a wall when every triangle is parallel to the direction.
let ok = face.triangles.iter().all(|&t| {
let Some(tri) = brep.surface.triangles().get(t as usize) else { return false };
let [a, b, c] = tri.map(|v| brep.point(v));
unit(cross(sub(b, a), sub(c, a))).is_some_and(|n| perpendicular_to(n, d))
});
if ok { Ok(Role::Wall) } else { Err(format!("free-form face {name} is not parallel to the direction")) }
}
}
}
Frame::Rotate { o, a, .. } => {
let coaxial = |origin: P, axis: P| parallel(axis, a) && line_distance(origin, o, a) <= tol;
if let Some((p, n)) = face.plane {
if perpendicular_to(n, a) && dot(sub(p, o), n).abs() <= tol {
return Ok(Role::Cap);
}
if parallel(n, a) {
return Ok(Role::Wall);
}
return Err(format!("planar face {name} neither contains nor crosses the axis"));
}
match face.surface {
Some(Surface::Cylinder { origin, axis, .. })
| Some(Surface::Cone { apex: origin, axis, .. })
| Some(Surface::Torus { center: origin, axis, .. })
if coaxial(origin, axis) =>
{
Ok(Role::Wall)
}
Some(Surface::Sphere { center, .. }) if line_distance(center, o, a) <= tol => Ok(Role::Wall),
Some(_) => Err(format!("face {name} is not a surface of revolution about the axis")),
None => Err(format!("free-form face {name}: a revolution needs analytic faces")),
}
}
}
}
/// Candidate extrusion directions: the normals of planar faces and the axes of cylinders, most
/// common first, over the faces of every body given.
pub(crate) fn translations(breps: &[&Brep]) -> Vec {
let mut dirs: Vec<(P, f64)> = Vec::new();
let mut push = |d: P, weight: f64| {
let Some(mut d) = unit(d) else { return };
// A canonical sign: the largest component positive.
let k = (0..3).max_by(|&i, &j| d[i].abs().total_cmp(&d[j].abs()).then(j.cmp(&i))).unwrap_or(0);
if d.get(k).copied().unwrap_or(0.0) < 0.0 {
d = scale(d, -1.0);
}
match dirs.iter_mut().find(|(e, _)| parallel(*e, d)) {
Some(slot) => slot.1 += weight,
None => dirs.push((d, weight)),
}
};
for f in breps.iter().flat_map(|b| &b.faces) {
if let Some((_, n)) = f.plane {
push(n, f.area);
} else if let Some(Surface::Cylinder { axis, .. }) = f.surface {
push(axis, 0.0);
}
}
dirs.sort_by(|a, b| b.1.total_cmp(&a.1));
let Some(o) = breps.first().map(|b| b.point(0)) else { return Vec::new() };
dirs.into_iter().filter_map(|(d, _)| Frame::translate(o, d)).collect()
}
/// Candidate axes of revolution: the axes of the analytic surfaces of revolution of every body
/// given.
pub(crate) fn rotations(breps: &[&Brep]) -> Vec {
let mut axes: Vec<(P, P)> = Vec::new();
let diagonal = breps.iter().map(|b| b.diagonal).fold(0.0, f64::max);
for f in breps.iter().flat_map(|b| &b.faces) {
let (o, a) = match f.surface {
Some(Surface::Cylinder { origin, axis, .. })
| Some(Surface::Cone { apex: origin, axis, .. })
| Some(Surface::Torus { center: origin, axis, .. }) => (origin, axis),
_ => continue,
};
if !axes.iter().any(|(p, b)| parallel(*b, a) && line_distance(o, *p, *b) <= RELATIVE_AXIS * diagonal) {
axes.push((o, a));
}
}
axes.into_iter().filter_map(|(o, a)| Frame::rotate(o, a)).collect()
}
/// The frame's classification of the bodies swept together: face roles (per body) and levels.
pub(crate) struct Setup {
frame: Frame,
roles: Vec>,
/// Sorted levels (heights or angles in `[0, 2π)`).
levels: Vec,
/// Sweep coordinates closer than this are one level.
h_tol: f64,
}
/// Classify faces and edges and find the levels of the bodies (one context each, sharing one
/// tolerance) swept together; an `Unsupported` error says why a body does not sweep in this
/// frame, naming it when there are several.
pub(crate) fn setup(ctxs: &[Ctx], frame: &Frame) -> Result {
let tol = ctxs.first().map_or(0.0, |c| c.tol);
let r_max = ctxs.iter().flat_map(|c| c.brep.surface.points()).map(|&p| frame.coords(p).0[1].abs()).fold(0.0, f64::max);
let h_tol = match frame {
Frame::Translate { .. } => tol,
Frame::Rotate { .. } => tol / r_max.max(tol),
};
let (mut roles, mut heights) = (Vec::with_capacity(ctxs.len()), Vec::new());
for (c, ctx) in ctxs.iter().enumerate() {
let named = |e: Error| match e {
Error::Unsupported(m) if ctxs.len() > 1 => Error::Unsupported(format!("cell {c}: {m}")),
e => e,
};
roles.push(classify(ctx, frame, tol, &mut heights).map_err(named)?);
}
heights.sort_by(f64::total_cmp);
let mut levels: Vec = Vec::new();
for h in heights {
if levels.last().is_none_or(|&l| h - l > h_tol) {
levels.push(h);
}
}
if frame.periodic()
&& levels.len() > 1
&& let (Some(&first), Some(&last)) = (levels.first(), levels.last())
&& first + TAU - last <= h_tol
{
levels.pop();
}
let need = if frame.periodic() { 1 } else { 2 };
if levels.len() < need {
return Err(Error::Unsupported("no source and target faces across the sweep".into()));
}
Ok(Setup { frame: *frame, roles, levels, h_tol })
}
/// One body's face roles in the frame; the heights of its edges across the sweep and of its caps
/// are added to `heights`.
fn classify(ctx: &Ctx, frame: &Frame, tol: f64, heights: &mut Vec) -> Result> {
let brep = ctx.brep;
let mut roles = Vec::with_capacity(brep.faces.len());
for f in &brep.faces {
roles.push(role(brep, f, frame, tol).map_err(Error::Unsupported)?);
}
if frame.periodic() {
// A revolved region touching the axis would collapse its elements there.
if let Some(p) = brep.surface.points().iter().find(|&&p| frame.coords(p).0[1] <= tol) {
return Err(Error::Unsupported(format!("the body reaches the axis of revolution (at {p:?}); revolved hexahedra would collapse there")));
}
}
for (e, edge) in brep.edges.iter().enumerate() {
let pts: Vec<(Q, f64)> = edge.points.iter().map(|&v| frame.coords(brep.point(v))).collect();
let Some(&(q0, h0)) = pts.first() else { continue };
let across = pts.iter().all(|&(q, h)| frame.span(q, frame.dh(h, h0)) <= tol);
let along = pts.iter().all(|&(q, _)| dist([q[0], q[1], 0.0], [q0[0], q0[1], 0.0]) <= tol);
if across {
heights.push(h0);
} else if !along {
let [f0, f1] = edge.faces.map(|f| brep.face_tag(f));
return Err(Error::Unsupported(format!("edge {e} between faces {f0} and {f1} runs neither along nor across the sweep")));
}
}
for (f, r) in brep.faces.iter().zip(&roles) {
if *r == Role::Cap
&& let Some(&v) = f.vertices.first()
{
heights.push(frame.coords(brep.point(v)).1);
}
}
Ok(roles)
}
impl Setup {
/// The number of levels the frame sweeps through (for ordering candidates).
pub(crate) fn level_count(&self) -> usize {
self.levels.len()
}
}
/// A size field on the cross-section plane at sweep coordinate `h`.
pub(crate) struct PlaneField<'a> {
pub field: &'a dyn SizeField,
pub frame: Frame,
pub h: f64,
}
impl SizeField for PlaneField<'_> {
fn size(&self, p: [f64; 3]) -> f64 {
self.field.size(self.frame.point([p[0], p[1]], self.h))
}
}
/// The cross-section of a frame the bodies sweep in: the arrangement (cut into blocks),
/// the level intervals and which body fills each region in each of them. It does not depend on
/// the element size, so refinement retries reuse it.
pub(crate) struct Prepared {
setup: Setup,
arr: Arrangement,
intervals: Vec<(f64, f64)>,
filled: Vec>>,
used: Vec,
/// The first arrangement tag of each body's faces (curve tags number the faces of all bodies
/// one after the other).
offsets: Vec,
}
impl Prepared {
/// The body and face of an arrangement tag.
fn face(&self, ctxs: &[Ctx], tag: usize) -> Option<(usize, usize)> {
let c = self.offsets.iter().rposition(|&o| o <= tag)?;
let f = tag - self.offsets.get(c)?;
ctxs.get(c).filter(|ctx| f < ctx.brep.faces.len()).map(|_| (c, f))
}
}
/// Lay out the cross-section of `setup`'s frame, for the bodies of `ctxs` (those `setup` was
/// made for) swept together: the walls of every body are imprinted on each other, so where two
/// bodies meet on a wall they get the same curves, and each region of each level interval is
/// filled by the body it lies in.
pub(crate) fn prepare(ctxs: &[Ctx], setup: Setup) -> Result {
let ctx = ctxs.first().ok_or_else(|| Error::Failed("no body to sweep".into()))?;
let tol = ctx.tol;
let frame = &setup.frame;
let levels = &setup.levels;
// Lower outlines of the walls, and every vertex, of every body.
let mut polylines: Vec<(Vec, usize)> = Vec::new();
let mut vertices: Vec = Vec::new();
let mut offsets = Vec::with_capacity(ctxs.len());
let mut offset = 0;
for (c, body) in ctxs.iter().enumerate() {
let brep = body.brep;
let roles = setup.roles.get(c).map(Vec::as_slice).unwrap_or(&[]);
offsets.push(offset);
for (e, edge) in brep.edges.iter().enumerate() {
let pts: Vec<(Q, f64)> = edge.points.iter().map(|&v| frame.coords(brep.point(v))).collect();
let Some(&(_, h0)) = pts.first() else { continue };
if !pts.iter().all(|&(q, h)| frame.span(q, frame.dh(h, h0)) <= tol) {
continue;
}
for &f in &edge.faces {
if roles.get(f) != Some(&Role::Wall) {
continue;
}
// The wall's triangle on the edge's first segment: is its third vertex above?
let Some(v) = brep.third_vertex(e, f) else { continue };
let (qc, hc) = frame.coords(brep.point(v));
if frame.dh(hc, h0) > 0.0 && frame.span(qc, frame.dh(hc, h0)) > tol {
polylines.push((pts.iter().map(|x| x.0).collect(), offset + f));
}
}
}
vertices.extend(
brep.edges
.iter()
.flat_map(|e| [e.first(), e.last()])
.chain(brep.is_vertex.iter().enumerate().filter(|(_, v)| **v).map(|(i, _)| i as u32))
.map(|v| frame.coords(brep.point(v)).0),
);
offset += brep.faces.len();
}
let coincide = 2.0 * ctx.opts.chordal_tolerance.max(0.0) + 4.0 * tol;
// Level intervals; a revolution's last one wraps round to the first level.
let (Some(&first_level), Some(rest)) = (levels.first(), levels.get(1..)) else {
return Err(Error::Unsupported("no levels".into()));
};
let ends: Vec = if frame.periodic() { rest.iter().copied().chain(std::iter::once(first_level + TAU)).collect() } else { rest.to_vec() };
let intervals: Vec<(f64, f64)> = levels.iter().copied().zip(ends).collect();
let mut inside = Occupants::new(ctxs, tol);
let mut arr = arrangement::build(&vertices, &polylines, tol, coincide)?;
let (mut filled, mut used) = occupancy(&arr, frame, &intervals, &mut inside)?;
// Cut the filled regions into blocks the structured schemes fit, and lay the arrangement out
// again with the cuts.
let h_ref = levels.first().copied().unwrap_or(0.0);
let plane = PlaneField { field: ctx.field, frame: *frame, h: h_ref };
let settings = decompose::Settings { field: &plane, tol, opts: &ctx.opts.quad };
let locked = seeded_curves(ctxs, frame, h_ref, &intervals, setup.h_tol, &arr);
let cut_lines = decompose::cuts(&arr, &used, &locked, &settings)?;
if !cut_lines.is_empty() {
polylines.extend(cut_lines.into_iter().map(|c| (c, decompose::CUT)));
arr = arrangement::build(&vertices, &polylines, tol, coincide)?;
(filled, used) = occupancy(&arr, frame, &intervals, &mut inside)?;
}
Ok(Prepared { setup, arr, intervals, filled, used, offsets })
}
/// Mesh the prepared cross-section, with its curves `2^refine` times finer than the size asks;
/// with the body (index into `ctxs`) of each hexahedron.
pub(crate) fn mesh(ctxs: &[Ctx], prepared: &Prepared, refine: u32) -> Result<(Raw, Vec)> {
let ctx = ctxs.first().ok_or_else(|| Error::Failed("no body to sweep".into()))?;
let Prepared { setup, arr, intervals, filled, used, .. } = prepared;
let (frame, levels) = (&setup.frame, &setup.levels);
let h_ref = levels.first().copied().unwrap_or(0.0);
// Interval assignment over the curves of filled regions, and the layer counts.
let mut ia = Intervals::default();
let plane_field = PlaneField { field: ctx.field, frame: *frame, h: h_ref };
let curve3d = |c: usize| -> Vec
{
arr.curves
.get(c)
.map(|cv| cv.points.iter().map(|&p| frame.point(arr.points.get(p).copied().unwrap_or([0.0; 2]), h_ref)).collect())
.unwrap_or_default()
};
let fine = f64::from(1u32 << refine.min(8));
for c in 0..arr.curves.len() {
let goal = ideal_divisions(ctx.field, &curve3d(c))?;
ia.var(goal * fine, 1);
}
let schemes = section::schemes(arr, used, &mut ia);
let seeds = Seeds::of(ctxs, frame, h_ref, intervals, setup.h_tol, &(0..arr.curves.len()).map(curve3d).collect::>())?;
for (c, seed) in seeds.curves.iter().enumerate() {
if let Some((seed, _)) = seed {
ia.seed(c, seed);
}
}
let counts = ia.solve()?;
// A biased seed's spacing carries across the cross-section's mapped regions to the opposite
// curves without seeds of their own (#257).
let ties: Vec<[crate::seeds::Use; 2]> = arr
.regions
.iter()
.zip(&schemes)
.filter_map(|(region, &scheme)| region.loops.first().map(|lp| region::opposite_ties(lp, scheme)))
.flatten()
.collect();
let carried = crate::seeds::carry(&seeds.curves, |s: &&EdgeSeed| s.bias.is_some(), &ties);
let spacing: Vec