forked from 1b-agent/fieldcraft-upstream
Mesh: the hex meshers honour edge seeds
A seed is matched to a mesher's curve by position (ends and middle within the two tessellations' tolerance), which also orients its bias. Interval assignment holds exact seeds, raises minimums and takes free seeds as goals; an equality, loop minimum or parity it cannot meet with the exact seeds held names them. Seeded curves get the seed's spacing. Extrude and revolve seed cross-section curves and the layers of a level interval; sweep and mapped seed their B-rep edges; cells seed the shared edge once, so both cells keep it and stay conforming. Part of #230. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
bdd84a491d
commit
13b506150c
9 files changed
+677
-24
No files matched your search
@@ -36,8 +36,9 @@ use std::collections::BTreeMap;
|
||||
use super::brep::Brep;
|
||||
use super::geom::{TriangleTree, add, dist, scale};
|
||||
use super::mapped::{self, Cube, face_ring, other};
|
||||
use super::{Cells, Ctx, HexMesh, HexMethod, HexOptions, RELATIVE_TOLERANCE, Raw, Surface, finish, project_to_face, project_to_faces};
|
||||
use super::{Cells, Ctx, HexMesh, HexMethod, HexOptions, Raw, Surface, finish, project_to_face, project_to_faces};
|
||||
use crate::quad::{lattice_index, transfinite_grid, transfinite_volume};
|
||||
use crate::seeds::EdgeSeed;
|
||||
use crate::size::{SizeField, divide_polyline, ideal_divisions};
|
||||
use crate::surface::BoundarySurface;
|
||||
use crate::vec3::{P, cross, dot, norm, sub};
|
||||
@@ -112,8 +113,7 @@ pub fn hex_mesh_cells(cells: &[Cell], field: &dyn SizeField, opts: &HexOptions)
|
||||
super::check_estimate(&surfaces, field, opts)?;
|
||||
let breps: Vec<Brep> = cells.iter().map(|c| Brep::composite(c.surface, c.faces)).collect::<Result<_>>()?;
|
||||
let trees: Vec<TriangleTree> = cells.iter().map(|c| TriangleTree::new(c.surface)).collect::<Result<_>>()?;
|
||||
let ctxs: Vec<Ctx> =
|
||||
breps.iter().zip(&trees).map(|(brep, tree)| Ctx { brep, tree, field, opts, tol: RELATIVE_TOLERANCE * brep.diagonal }).collect();
|
||||
let ctxs: Vec<Ctx> = breps.iter().zip(&trees).map(|(brep, tree)| Ctx::new(brep, tree, field, opts)).collect::<Result<_>>()?;
|
||||
let names: Vec<&str> = cells.iter().map(|c| c.name).collect();
|
||||
let cubes: Vec<Cube> = breps
|
||||
.iter()
|
||||
@@ -317,12 +317,24 @@ impl Topology {
|
||||
fn intervals(&self, ctxs: &[Ctx], cubes: &[Cube], field: &dyn SizeField) -> Result<Vec<Division>> {
|
||||
let polylines: Vec<Vec<P>> = self.edges.iter().map(|e| self.polyline(ctxs, e)).collect::<Result<_>>()?;
|
||||
let goals: Vec<f64> = polylines.iter().map(|p| ideal_divisions(field, p)).collect::<Result<_>>()?;
|
||||
let first = self.solve(&goals)?;
|
||||
// The seed on each edge (a shared edge is one edge, so its seed holds in every cell it
|
||||
// bounds), oriented from `a` to `b`.
|
||||
let seeds: Vec<Option<(&EdgeSeed, bool)>> = self
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let (c, be, fwd) = e.owner;
|
||||
ctxs.get(c).and_then(|x| x.seed_of(be)).map(|(seed, rev)| (seed, rev == fwd))
|
||||
})
|
||||
.collect();
|
||||
let first = self.solve(&goals, &seeds)?;
|
||||
// Ends of edges that leave a curved face: the spacing along that face there.
|
||||
let ends = self.graded_ends(ctxs, cubes, &first);
|
||||
let mut graded = goals;
|
||||
let mut divisions = Vec::with_capacity(polylines.len());
|
||||
for ((poly, ends), goal) in polylines.into_iter().zip(ends).zip(graded.iter_mut()) {
|
||||
for (((poly, ends), goal), seed) in polylines.into_iter().zip(ends).zip(graded.iter_mut()).zip(&seeds) {
|
||||
// A seeded edge keeps the seed's spacing, not a grading.
|
||||
let ends = if seed.is_some() { [None, None] } else { ends };
|
||||
let poly = if ends.iter().any(Option::is_some) {
|
||||
let dense = densify(&poly);
|
||||
*goal = goal.max(ideal_divisions(&Grading { field, ends }, &dense)?);
|
||||
@@ -330,18 +342,21 @@ impl Topology {
|
||||
} else {
|
||||
poly
|
||||
};
|
||||
divisions.push(Division { count: 1, ends, polyline: poly });
|
||||
divisions.push(Division { count: 1, ends, polyline: poly, seed: seed.map(|(s, rev)| (s.clone(), rev)) });
|
||||
}
|
||||
for (d, n) in divisions.iter_mut().zip(self.solve(&graded)?) {
|
||||
for (d, n) in divisions.iter_mut().zip(self.solve(&graded, &seeds)?) {
|
||||
d.count = n;
|
||||
}
|
||||
Ok(divisions)
|
||||
}
|
||||
|
||||
fn solve(&self, goals: &[f64]) -> Result<Vec<usize>> {
|
||||
fn solve(&self, goals: &[f64], seeds: &[Option<(&EdgeSeed, bool)>]) -> Result<Vec<usize>> {
|
||||
let mut ia = super::interval::Intervals::default();
|
||||
for &g in goals {
|
||||
ia.var(g, 1);
|
||||
for (&g, seed) in goals.iter().zip(seeds) {
|
||||
let v = ia.var(g, 1);
|
||||
if let Some((seed, _)) = seed {
|
||||
ia.seed(v, seed);
|
||||
}
|
||||
}
|
||||
for groups in &self.cell_edges {
|
||||
for g in groups {
|
||||
@@ -499,6 +514,8 @@ struct Division {
|
||||
count: usize,
|
||||
ends: GradedEnds,
|
||||
polyline: Vec<P>,
|
||||
/// The edge's seed, and whether it runs from `b` to `a`.
|
||||
seed: Option<(EdgeSeed, bool)>,
|
||||
}
|
||||
|
||||
/// The size along a graded edge: the field's, capped near each graded end by the spacing there
|
||||
@@ -566,7 +583,9 @@ impl Builder<'_, '_, '_> {
|
||||
// Edges: divided once, on the geometry.
|
||||
for (edge, division) in topo.edges.iter().zip(self.divisions) {
|
||||
let n = division.count;
|
||||
let pts = if division.ends.iter().any(Option::is_some) {
|
||||
let pts = if let Some((seed, rev)) = &division.seed {
|
||||
crate::seeds::place(&division.polyline, &seed.fractions(n, *rev))?
|
||||
} else if division.ends.iter().any(Option::is_some) {
|
||||
divide_polyline(&Grading { field, ends: division.ends }, &division.polyline, n)?
|
||||
} else {
|
||||
divide_polyline(field, &division.polyline, n)?
|
||||
|
||||
@@ -37,6 +37,9 @@ pub(crate) struct Intervals {
|
||||
pub multiple: Vec<(Vec<usize>, usize)>,
|
||||
/// `Σ vars ≥ min`.
|
||||
pub at_least: Vec<(Vec<usize>, usize)>,
|
||||
/// Variables held at their goal (an exact edge seed), with the seed in words; empty or
|
||||
/// `None`: free.
|
||||
pub fixed: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
/// The largest count a variable may reach.
|
||||
@@ -50,6 +53,43 @@ impl Intervals {
|
||||
self.goals.len() - 1
|
||||
}
|
||||
|
||||
/// Apply an edge seed to variable `v`: an exact seed holds it at the seeded count, a minimum
|
||||
/// raises its lower bound (and its goal), a free seed sets its goal.
|
||||
pub(crate) fn seed(&mut self, v: usize, seed: &crate::seeds::EdgeSeed) {
|
||||
use fieldcraft_model::SeedConstraint;
|
||||
let n = seed.count.max(1);
|
||||
let (Some(goal), Some(min)) = (self.goals.get_mut(v), self.mins.get_mut(v)) else { return };
|
||||
match seed.constraint {
|
||||
SeedConstraint::Exact => {
|
||||
*goal = n as f64;
|
||||
*min = n;
|
||||
if self.fixed.len() <= v {
|
||||
self.fixed.resize(v + 1, None);
|
||||
}
|
||||
if let Some(slot) = self.fixed.get_mut(v) {
|
||||
*slot = Some(seed.describe());
|
||||
}
|
||||
}
|
||||
SeedConstraint::Min => {
|
||||
*min = (*min).max(n);
|
||||
*goal = goal.max(n as f64);
|
||||
}
|
||||
SeedConstraint::Free => *goal = n as f64,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_fixed(&self, v: usize) -> bool {
|
||||
self.fixed.get(v).is_some_and(Option::is_some)
|
||||
}
|
||||
|
||||
/// The conflict error: the exact seeds among `vars` cannot all keep their counts.
|
||||
fn conflict(&self, vars: &[usize], what: &str) -> Error {
|
||||
let mut seeds: Vec<&str> = vars.iter().filter_map(|&v| self.fixed.get(v).and_then(Option::as_deref)).collect();
|
||||
seeds.sort_unstable();
|
||||
seeds.dedup();
|
||||
Error::Invalid(format!("the exact edge seeds {} conflict: {what}; change a count or make a seed `min` or `free`", seeds.join(", ")))
|
||||
}
|
||||
|
||||
pub(crate) fn solve(&self) -> Result<Vec<usize>> {
|
||||
let n = self.goals.len();
|
||||
if self.mins.len() != n {
|
||||
@@ -110,13 +150,22 @@ impl Intervals {
|
||||
if cands.is_empty() {
|
||||
return Err(Error::Failed("interval assignment: a side constraint cannot be met (the same curves on both sides)".into()));
|
||||
}
|
||||
let cands: Vec<usize> = cands.into_iter().filter(|&i| !self.is_fixed(i)).collect();
|
||||
if cands.is_empty() {
|
||||
let all: Vec<usize> = a.iter().chain(b).copied().collect();
|
||||
return Err(self.conflict(&all, &format!("opposite sides of a mapped or swept face need equal counts ({sa} against {sb})")));
|
||||
}
|
||||
raise = Some(cands);
|
||||
break;
|
||||
}
|
||||
if raise.is_none() {
|
||||
for (l, m) in &self.at_least {
|
||||
if sum(l, &x) < *m {
|
||||
raise = Some(l.clone());
|
||||
let free: Vec<usize> = l.iter().copied().filter(|&i| !self.is_fixed(i)).collect();
|
||||
if free.is_empty() {
|
||||
return Err(self.conflict(l, &format!("a face's loop needs at least {m} divisions")));
|
||||
}
|
||||
raise = Some(free);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +179,10 @@ impl Intervals {
|
||||
if moving.is_empty() {
|
||||
return Err(Error::Failed("interval assignment: a loop's count cannot reach the multiple it needs".into()));
|
||||
}
|
||||
let moving: Vec<usize> = moving.into_iter().filter(|&i| !self.is_fixed(i)).collect();
|
||||
if moving.is_empty() {
|
||||
return Err(self.conflict(l, &format!("a face's loop needs a multiple of {m} divisions")));
|
||||
}
|
||||
raise = Some(moving);
|
||||
parity = true;
|
||||
break;
|
||||
@@ -205,6 +258,38 @@ mod tests {
|
||||
assert_eq!((x[a] + x[b] + x[c]) % 2, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_seeds_hold_and_conflicts_name_them() {
|
||||
use crate::seeds::EdgeSeed;
|
||||
use fieldcraft_model::SeedConstraint;
|
||||
let seed =
|
||||
|name: &str, count: usize, constraint| EdgeSeed { name: name.into(), points: Vec::new(), tolerance: 0.0, count, bias: None, constraint };
|
||||
// The other side follows an exact seed.
|
||||
let mut p = Intervals::default();
|
||||
let a = p.var(3.0, 1);
|
||||
let b = p.var(3.0, 1);
|
||||
p.equal.push((vec![a], vec![b]));
|
||||
p.seed(a, &seed("A", 7, SeedConstraint::Exact));
|
||||
assert_eq!(p.solve().unwrap(), vec![7, 7]);
|
||||
// Two exact seeds on opposite sides that differ.
|
||||
p.seed(b, &seed("B", 5, SeedConstraint::Exact));
|
||||
let e = p.solve().unwrap_err().to_string();
|
||||
assert!(e.contains("A (7 exact)") && e.contains("B (5 exact)"), "{e}");
|
||||
// A minimum and a free goal.
|
||||
let mut q = Intervals::default();
|
||||
let a = q.var(3.0, 1);
|
||||
let b = q.var(3.0, 1);
|
||||
q.seed(a, &seed("A", 6, SeedConstraint::Min));
|
||||
q.seed(b, &seed("B", 9, SeedConstraint::Free));
|
||||
assert_eq!(q.solve().unwrap(), vec![6, 9]);
|
||||
// An exact odd count on a loop that must be even, with nothing else to move.
|
||||
let mut r = Intervals::default();
|
||||
let a = r.var(3.0, 1);
|
||||
r.multiple.push((vec![a], 2));
|
||||
r.seed(a, &seed("A", 3, SeedConstraint::Exact));
|
||||
assert!(r.solve().unwrap_err().to_string().contains("multiple of 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infeasible_systems_are_errors_not_loops() {
|
||||
let mut p = Intervals::default();
|
||||
|
||||
@@ -34,9 +34,11 @@ use super::interval::Intervals;
|
||||
use super::region::{self, Scheme};
|
||||
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;
|
||||
@@ -262,6 +264,8 @@ pub(crate) struct Setup {
|
||||
roles: Vec<Role>,
|
||||
/// Sorted levels (heights or angles in `[0, 2π)`).
|
||||
levels: Vec<f64>,
|
||||
/// Sweep coordinates closer than this are one level.
|
||||
h_tol: f64,
|
||||
}
|
||||
|
||||
/// Classify faces and edges and find the levels; an `Unsupported` error says why the body does
|
||||
@@ -322,7 +326,7 @@ pub(crate) fn setup(ctx: &Ctx, frame: &Frame) -> Result<Setup> {
|
||||
if levels.len() < need {
|
||||
return Err(Error::Unsupported("no source and target faces across the sweep".into()));
|
||||
}
|
||||
Ok(Setup { frame: *frame, roles, levels })
|
||||
Ok(Setup { frame: *frame, roles, levels, h_tol })
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
@@ -452,6 +456,12 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
*slot = scheme;
|
||||
}
|
||||
}
|
||||
let seeds = Seeds::of(ctx, frame, h_ref, intervals, setup.h_tol, &(0..arr.curves.len()).map(curve3d).collect::<Vec<_>>())?;
|
||||
for (c, seed) in seeds.curves.iter().enumerate() {
|
||||
if let Some((seed, _)) = seed {
|
||||
ia.seed(c, seed);
|
||||
}
|
||||
}
|
||||
let counts = ia.solve()?;
|
||||
let r_max = arr.points.iter().map(|q| q[1].abs()).fold(0.0, f64::max);
|
||||
let mut layers = Vec::with_capacity(intervals.len());
|
||||
@@ -464,6 +474,13 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
if frame.periodic() {
|
||||
n = n.max(((hi - lo) / FRAC_PI_4).ceil() as usize);
|
||||
}
|
||||
if let Some(Some(l)) = seeds.layers.get(layers.len()) {
|
||||
let seeded = l.fractions.len().saturating_sub(1).max(1);
|
||||
n = match l.seed.constraint {
|
||||
SeedConstraint::Exact | SeedConstraint::Free => seeded,
|
||||
SeedConstraint::Min => n.max(seeded),
|
||||
};
|
||||
}
|
||||
layers.push(n);
|
||||
}
|
||||
// 2D nodes: curve ends, then curve interiors (projected onto analytic walls).
|
||||
@@ -472,7 +489,10 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
let mut curve_nodes: Vec<Vec<u32>> = Vec::with_capacity(arr.curves.len());
|
||||
for (c, curve) in arr.curves.iter().enumerate() {
|
||||
let n = counts.get(c).copied().unwrap_or(1);
|
||||
let pts3 = divide_polyline(ctx.field, &curve3d(c), n)?;
|
||||
let pts3 = match seeds.curves.get(c).copied().flatten() {
|
||||
Some((seed, rev)) => crate::seeds::place(&curve3d(c), &seed.fractions(n, rev))?,
|
||||
None => divide_polyline(ctx.field, &curve3d(c), n)?,
|
||||
};
|
||||
let wall = curve.tags.iter().find_map(|&f| brep.faces.get(f).and_then(|x| x.surface)).filter(|s| !matches!(s, Surface::Plane { .. }));
|
||||
let mut ids = Vec::with_capacity(pts3.len());
|
||||
let last = pts3.len().saturating_sub(1);
|
||||
@@ -548,8 +568,12 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
for (k, &(lo, hi)) in intervals.iter().enumerate() {
|
||||
first_layer.push(heights.len());
|
||||
let n = layers.get(k).copied().unwrap_or(1);
|
||||
for j in 0..n {
|
||||
heights.push(lo + (hi - lo) * j as f64 / n as f64);
|
||||
let fractions = match seeds.layers.get(k).and_then(Option::as_ref) {
|
||||
Some(l) if l.fractions.len() == n + 1 => l.fractions.clone(),
|
||||
_ => (0..=n).map(|j| j as f64 / n as f64).collect(),
|
||||
};
|
||||
for f in fractions.iter().take(n) {
|
||||
heights.push(lo + (hi - lo) * f);
|
||||
}
|
||||
}
|
||||
if !frame.periodic()
|
||||
@@ -614,6 +638,145 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
Ok(Raw { nodes, hexes, description })
|
||||
}
|
||||
|
||||
/// The edge seeds as a layered mesh takes them: on the cross-section's curves (seeds of edges
|
||||
/// across the sweep, projected into the cross-section) and on the level intervals (seeds of
|
||||
/// edges along the sweep, which set the interval's layers). Each with whether it runs against
|
||||
/// the curve, or from the interval's top down.
|
||||
struct Seeds<'s> {
|
||||
curves: Vec<Option<(&'s EdgeSeed, bool)>>,
|
||||
layers: Vec<Option<LayerSeed<'s>>>,
|
||||
}
|
||||
|
||||
/// A seed's layers in one level interval: its nodes there, as fractions from the interval's
|
||||
/// bottom (0) to its top (1).
|
||||
#[derive(Clone)]
|
||||
struct LayerSeed<'s> {
|
||||
seed: &'s EdgeSeed,
|
||||
fractions: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Split a seed's node positions (fractions from the bottom of the span it seeds, 0 to 1) over
|
||||
/// the level intervals in it (`bounds`: the levels from the bottom of the span to its top, as
|
||||
/// fractions): each inner level takes the nearest node still free, and the nodes between two
|
||||
/// levels are stretched to fit between them. Every interval gets at least one division; `None`
|
||||
/// when there are fewer nodes than that needs.
|
||||
fn split_over(nodes: &[f64], bounds: &[f64]) -> Option<Vec<Vec<f64>>> {
|
||||
let (n, m) = (nodes.len().checked_sub(1)?, bounds.len().checked_sub(1)?);
|
||||
if m == 0 || n < m {
|
||||
return None;
|
||||
}
|
||||
// The node index at each level.
|
||||
let mut at = vec![0usize; m + 1];
|
||||
at[m] = n;
|
||||
for i in 1..m {
|
||||
let b = bounds.get(i).copied()?;
|
||||
let (lo, hi) = (at[i - 1] + 1, n - (m - i));
|
||||
at[i] = (lo..=hi).min_by(|&a, &c| (nodes[a] - b).abs().total_cmp(&(nodes[c] - b).abs()))?;
|
||||
}
|
||||
(0..m)
|
||||
.map(|i| {
|
||||
let (j0, j1) = (at[i], at[i + 1]);
|
||||
let (z0, z1) = (nodes.get(j0).copied()?, nodes.get(j1).copied()?);
|
||||
let span = z1 - z0;
|
||||
Some(
|
||||
(j0..=j1)
|
||||
.map(|j| {
|
||||
if j == j0 {
|
||||
0.0
|
||||
} else if j == j1 {
|
||||
1.0
|
||||
} else {
|
||||
nodes.get(j).map_or(0.0, |z| (z - z0) / span)
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl<'s> Seeds<'s> {
|
||||
fn of(ctx: &'s Ctx, frame: &Frame, h_ref: f64, intervals: &[(f64, f64)], h_tol: f64, curves: &[Vec<P>]) -> Result<Seeds<'s>> {
|
||||
let brep = ctx.brep;
|
||||
let tol = ctx.tol;
|
||||
let mut out = Seeds { curves: vec![None; curves.len()], layers: vec![None; intervals.len()] };
|
||||
let stol = super::seed_tolerance(ctx.opts, tol);
|
||||
let conflict = |a: &EdgeSeed, b: &EdgeSeed, why: &str| {
|
||||
Error::Invalid(format!(
|
||||
"the exact edge seeds {} and {} conflict: {why}; change a count or make a seed `min` or `free`",
|
||||
a.describe(),
|
||||
b.describe()
|
||||
))
|
||||
};
|
||||
let unsupported = |seed: &EdgeSeed, why: &str| Error::Unsupported(format!("the exact edge seed {} {why}", seed.describe()));
|
||||
for e in 0..brep.edges.len() {
|
||||
let Some((seed, rev)) = ctx.seed_of(e) else { continue };
|
||||
let exact = seed.constraint == SeedConstraint::Exact;
|
||||
let pts: Vec<(Q, f64)> = brep.edge_points(e, true).into_iter().map(|p| frame.coords(p)).collect();
|
||||
let (Some(&(_, h0)), Some(&(_, h1))) = (pts.first(), pts.last()) else { continue };
|
||||
let across = pts.iter().all(|&(q, h)| frame.span(q, frame.dh(h, h0)) <= tol);
|
||||
if across {
|
||||
// The seed moved into the cross-section plane at the reference level.
|
||||
let projected = EdgeSeed { points: seed.points.iter().map(|&p| frame.point(frame.coords(p).0, h_ref)).collect(), ..seed.clone() };
|
||||
match curves.iter().position(|c| crate::seeds::matches(&projected, c, stol).is_some()) {
|
||||
Some(c) => {
|
||||
let r = crate::seeds::matches(&projected, curves.get(c).map(Vec::as_slice).unwrap_or(&[]), stol).unwrap_or(false);
|
||||
let slot = out.curves.get_mut(c).ok_or_else(|| Error::Failed("curve index".into()))?;
|
||||
if let Some((other, _)) = slot
|
||||
&& exact
|
||||
&& other.constraint == SeedConstraint::Exact
|
||||
&& other.count != seed.count
|
||||
{
|
||||
return Err(conflict(other, seed, "both lie on one curve of the swept cross-section"));
|
||||
}
|
||||
*slot = Some((seed, r));
|
||||
}
|
||||
None if exact => return Err(unsupported(seed, "is not one curve of the swept cross-section")),
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Along the sweep: the interval from its lower to its upper end.
|
||||
let span = frame.dh(h1, h0);
|
||||
let (lo, hi, down) = if span >= 0.0 { (h0, h0 + span, false) } else { (h0 + span, h0, true) };
|
||||
let same = |a: f64, b: f64| frame.dh(a, b).abs() <= h_tol;
|
||||
let first = intervals.iter().position(|&(l, _)| same(l, lo));
|
||||
let last = intervals.iter().position(|&(_, h)| same(h, hi));
|
||||
let (Some(k0), Some(k1)) = (first, last) else {
|
||||
if exact {
|
||||
return Err(unsupported(seed, "does not run from one level of the sweep to another"));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let spanned = intervals.get(k0..=k1).unwrap_or(&[]);
|
||||
let length = frame.dh(hi, lo).abs().max(f64::MIN_POSITIVE);
|
||||
let mut bounds: Vec<f64> = spanned.iter().map(|&(l, _)| frame.dh(l, lo) / length).collect();
|
||||
bounds.push(1.0);
|
||||
// The seed's nodes from the bottom: the seed runs down when the edge does and the
|
||||
// seed runs with it, or up against a downward edge.
|
||||
let nodes = seed.fractions(seed.count, rev != down);
|
||||
let Some(parts) = split_over(&nodes, &bounds) else {
|
||||
if exact {
|
||||
return Err(unsupported(seed, &format!("has fewer divisions than the {} level intervals it spans", spanned.len())));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
for (k, fractions) in (k0..=k1).zip(parts) {
|
||||
let slot = out.layers.get_mut(k).ok_or_else(|| Error::Failed("interval index".into()))?;
|
||||
if let Some(other) = slot
|
||||
&& exact
|
||||
&& other.seed.constraint == SeedConstraint::Exact
|
||||
&& other.fractions.len() != fractions.len()
|
||||
{
|
||||
return Err(conflict(other.seed, seed, "both set the layers between the same two levels"));
|
||||
}
|
||||
*slot = Some(LayerSeed { seed, fractions });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which level intervals each region of the arrangement fills (inside test at the region's
|
||||
/// centre, mid-interval), and which regions fill any.
|
||||
fn occupancy(arr: &Arrangement, frame: &Frame, intervals: &[(f64, f64)], inside: &mut Inside) -> Result<(Vec<Vec<bool>>, Vec<bool>)> {
|
||||
@@ -791,3 +954,22 @@ fn interior_point(arr: &Arrangement, region: &arrangement::Region) -> Result<Q>
|
||||
}
|
||||
best.filter(|(c, _)| *c > 0.0).map(|(_, p)| p).ok_or_else(|| Error::Failed("no point found inside a cross-section region".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seed_tests {
|
||||
use super::split_over;
|
||||
|
||||
#[test]
|
||||
fn a_seed_over_several_levels_is_split_at_them() {
|
||||
// Six even divisions over levels at 0, 0.4 and 1: the level takes the node at 1/3.
|
||||
let nodes: Vec<f64> = (0..=6).map(|j| j as f64 / 6.0).collect();
|
||||
let parts = split_over(&nodes, &[0.0, 0.4, 1.0]).unwrap();
|
||||
assert_eq!(parts.iter().map(|p| p.len() - 1).collect::<Vec<_>>(), vec![2, 4]);
|
||||
assert!(parts.iter().all(|p| p[0] == 0.0 && *p.last().unwrap() == 1.0 && p.windows(2).all(|w| w[0] < w[1])));
|
||||
// Every interval keeps at least one division; too few nodes is None.
|
||||
let parts = split_over(&[0.0, 0.5, 1.0], &[0.0, 0.1, 0.2, 1.0]);
|
||||
assert!(parts.is_none());
|
||||
let parts = split_over(&[0.0, 0.9, 0.95, 1.0], &[0.0, 0.1, 0.2, 1.0]).unwrap();
|
||||
assert_eq!(parts.iter().map(|p| p.len() - 1).collect::<Vec<_>>(), vec![1, 1, 1]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use super::brep::Brep;
|
||||
use super::interval::Intervals;
|
||||
use super::{Ctx, Raw, project_to_face, project_to_faces};
|
||||
use crate::quad::{lattice_index, transfinite_faces, transfinite_volume};
|
||||
use crate::size::{divide_polyline, ideal_divisions};
|
||||
use crate::size::ideal_divisions;
|
||||
use crate::vec3::{P, det, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -155,7 +155,7 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
|
||||
let groups = cube.groups;
|
||||
let mut ia = Intervals::default();
|
||||
for e in 0..brep.edges.len() {
|
||||
ia.var(ideal_divisions(ctx.field, &brep.edge_points(e, true))?, 1);
|
||||
ctx.edge_var(&mut ia, e, ideal_divisions(ctx.field, &brep.edge_points(e, true))?);
|
||||
}
|
||||
for g in &groups {
|
||||
let first = g.first().map(|x| x.0).unwrap_or(0);
|
||||
@@ -173,7 +173,7 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
|
||||
}
|
||||
// Edge node positions, low corner to high.
|
||||
let edge_pts = |(e, fwd): (usize, bool), n: usize| -> Result<Vec<P>> {
|
||||
let pts = divide_polyline(ctx.field, &brep.edge_points(e, fwd), n)?;
|
||||
let pts = ctx.divide_edge(e, fwd, n)?;
|
||||
let faces = brep.edges.get(e).map(|x| x.faces.to_vec()).unwrap_or_default();
|
||||
let last = pts.len().saturating_sub(1);
|
||||
Ok(pts.iter().enumerate().map(|(k, q)| if k == 0 || k == last { *q } else { project_to_faces(ctx, &faces, *q) }).collect())
|
||||
|
||||
@@ -52,6 +52,8 @@ use std::collections::HashMap;
|
||||
pub use cells::{Cell, CellsMesh, GROWTH, MAX_CELLS, hex_mesh_cells};
|
||||
pub use geom::Surface;
|
||||
|
||||
use crate::seeds::EdgeSeed;
|
||||
|
||||
use brep::Brep;
|
||||
use geom::{TriangleTree, add, dist, scale, unit};
|
||||
|
||||
@@ -109,11 +111,21 @@ pub struct HexOptions {
|
||||
pub max_elements: usize,
|
||||
/// Options of the face meshers.
|
||||
pub quad: QuadOptions,
|
||||
/// Edge seeds: the B-rep edges they lie on get their counts (exact seeds are held, and a
|
||||
/// method that cannot keep them names them) and their spacing.
|
||||
pub seeds: Vec<EdgeSeed>,
|
||||
}
|
||||
|
||||
impl Default for HexOptions {
|
||||
fn default() -> Self {
|
||||
HexOptions { method: HexMethod::Auto, quadratic: false, chordal_tolerance: 0.0, max_elements: 5_000_000, quad: QuadOptions::default() }
|
||||
HexOptions {
|
||||
method: HexMethod::Auto,
|
||||
quadratic: false,
|
||||
chordal_tolerance: 0.0,
|
||||
max_elements: 5_000_000,
|
||||
quad: QuadOptions::default(),
|
||||
seeds: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +188,49 @@ pub(crate) struct Ctx<'a> {
|
||||
pub opts: &'a HexOptions,
|
||||
/// Classification tolerance (model units).
|
||||
pub tol: f64,
|
||||
/// The seed on each B-rep edge (index into `opts.seeds`, and whether the edge runs from the
|
||||
/// seed's end to its start).
|
||||
pub edge_seeds: Vec<Option<(usize, bool)>>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub(crate) fn new(brep: &'a Brep<'a>, tree: &'a TriangleTree<'a>, field: &'a dyn SizeField, opts: &'a HexOptions) -> Result<Ctx<'a>> {
|
||||
let tol = RELATIVE_TOLERANCE * brep.diagonal;
|
||||
let curves: Vec<Vec<P>> = (0..brep.edges.len()).map(|e| brep.edge_points(e, true)).collect();
|
||||
let edge_seeds = crate::seeds::match_curves(&opts.seeds, &curves, seed_tolerance(opts, tol))?;
|
||||
Ok(Ctx { brep, tree, field, opts, tol, edge_seeds })
|
||||
}
|
||||
|
||||
/// The seed on B-rep edge `e`, and whether it runs against the edge.
|
||||
pub(crate) fn seed_of(&self, e: usize) -> Option<(&EdgeSeed, bool)> {
|
||||
let (i, rev) = self.edge_seeds.get(e).copied().flatten()?;
|
||||
Some((self.opts.seeds.get(i)?, rev))
|
||||
}
|
||||
|
||||
/// A division-count variable for B-rep edge `e` with goal `goal`, its seed applied.
|
||||
pub(crate) fn edge_var(&self, ia: &mut interval::Intervals, e: usize, goal: f64) -> usize {
|
||||
let v = ia.var(goal, 1);
|
||||
if let Some((seed, _)) = self.seed_of(e) {
|
||||
ia.seed(v, seed);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `n + 1` points along B-rep edge `e` (traversed forwards or not): at the seed's spacing
|
||||
/// when it has one, else evenly in the size field.
|
||||
pub(crate) fn divide_edge(&self, e: usize, forward: bool, n: usize) -> Result<Vec<P>> {
|
||||
let pts = self.brep.edge_points(e, forward);
|
||||
match self.seed_of(e) {
|
||||
Some((seed, rev)) => crate::seeds::place(&pts, &seed.fractions(n, rev == forward)),
|
||||
None => crate::size::divide_polyline(self.field, &pts, n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart a seed's polyline and a mesher's curve of the same edge may lie: both are
|
||||
/// tessellations of it.
|
||||
pub(crate) fn seed_tolerance(opts: &HexOptions, tol: f64) -> f64 {
|
||||
4.0 * opts.chordal_tolerance.max(0.0) + 16.0 * tol
|
||||
}
|
||||
|
||||
/// What a method produces: linear hexahedra in the model's node order.
|
||||
@@ -208,7 +263,7 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
|
||||
check_estimate(&[surface], field, opts)?;
|
||||
let brep = Brep::new(surface, faces)?;
|
||||
let tree = TriangleTree::new(surface)?;
|
||||
let ctx = Ctx { brep: &brep, tree: &tree, field, opts, tol: RELATIVE_TOLERANCE * brep.diagonal };
|
||||
let ctx = Ctx::new(&brep, &tree, field, opts)?;
|
||||
let methods: &[HexMethod] = match opts.method {
|
||||
HexMethod::Auto => &[HexMethod::Extrude, HexMethod::Sweep, HexMethod::Revolve, HexMethod::Mapped],
|
||||
HexMethod::Swept => &[HexMethod::Extrude, HexMethod::Sweep, HexMethod::Revolve],
|
||||
|
||||
@@ -32,7 +32,7 @@ use super::layered::{Frame, PlaneField};
|
||||
use super::region;
|
||||
use super::{Ctx, Raw, project_to_face, project_to_faces};
|
||||
use crate::quad::transfinite_grid;
|
||||
use crate::size::{divide_polyline, ideal_divisions};
|
||||
use crate::size::ideal_divisions;
|
||||
use crate::vec3::{P, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -215,7 +215,7 @@ fn build(ctx: &Ctx, topo: &Topology, refine: u32) -> Result<Raw> {
|
||||
let fine = f64::from(1u32 << refine.min(8));
|
||||
for e in 0..brep.edges.len() {
|
||||
let goal = ideal_divisions(ctx.field, &brep.edge_points(e, true))?;
|
||||
ia.var(if sides.contains(&e) { goal } else { goal * fine }, 1);
|
||||
ctx.edge_var(&mut ia, e, if sides.contains(&e) { goal } else { goal * fine });
|
||||
}
|
||||
let vars = |uses: &[(usize, bool)]| uses.iter().map(|x| x.0).collect::<Vec<_>>();
|
||||
for stages in &topo.rings {
|
||||
@@ -257,7 +257,7 @@ fn build(ctx: &Ctx, topo: &Topology, refine: u32) -> Result<Raw> {
|
||||
let mut edge_nodes: Vec<Vec<u32>> = Vec::with_capacity(brep.edges.len());
|
||||
for (e, edge) in brep.edges.iter().enumerate() {
|
||||
let n = counts.get(e).copied().unwrap_or(1);
|
||||
let pts = divide_polyline(ctx.field, &brep.edge_points(e, true), n)?;
|
||||
let pts = ctx.divide_edge(e, true, n)?;
|
||||
let last = pts.len().saturating_sub(1);
|
||||
let mut ids = Vec::with_capacity(pts.len());
|
||||
for (k, p) in pts.iter().enumerate() {
|
||||
|
||||
@@ -480,3 +480,105 @@ fn an_o_grid_round_a_hole_conforms_with_graded_radial_lines() {
|
||||
assert!(r.windows(3).all(|w| (w[2] - w[1]) <= 1.35 * (w[1] - w[0])), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn seed(
|
||||
name: &str,
|
||||
points: Vec<[f64; 3]>,
|
||||
count: usize,
|
||||
bias: Option<fieldcraft_model::Bias>,
|
||||
constraint: fieldcraft_model::SeedConstraint,
|
||||
) -> crate::seeds::EdgeSeed {
|
||||
crate::seeds::EdgeSeed { name: name.into(), points, tolerance: 0.0, count, bias, constraint }
|
||||
}
|
||||
|
||||
/// The distinct values of coordinate `k` of the nodes `on` accepts, sorted.
|
||||
fn along(m: &HexMesh, k: usize, on: impl Fn([f64; 3]) -> bool) -> Vec<f64> {
|
||||
let mut v: Vec<f64> = m.nodes.iter().filter(|p| on(**p)).map(|p| p[k]).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_give_a_swept_block_exact_counts_and_geometric_spacing() {
|
||||
use fieldcraft_model::{Bias, BiasToward, SeedConstraint};
|
||||
let s = prism(&[[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [0.0, 2.0]], 0.0, 3.0);
|
||||
let bias = Some(Bias { ratio: 4.0, toward: BiasToward::Start });
|
||||
let opts = HexOptions {
|
||||
seeds: vec![
|
||||
// Through the thickness, 7 layers small at z = 0 (the seed runs up).
|
||||
seed("thickness", vec![[0.0, 0.0, 0.0], [0.0, 0.0, 3.0]], 7, bias, SeedConstraint::Exact),
|
||||
// Along x on the top: 5 divisions, the seed runs from x = 4 to 0.
|
||||
seed("length", vec![[4.0, 2.0, 3.0], [0.0, 2.0, 3.0]], 5, None, SeedConstraint::Exact),
|
||||
],
|
||||
..HexOptions::default()
|
||||
};
|
||||
let m = hex_mesh(&s, &[], &UniformSize(1.0), &opts).unwrap();
|
||||
check(&m, 24.0);
|
||||
assert_eq!(m.element_count(), 5 * 2 * 7, "{}", m.report.description);
|
||||
// The nodes on the seeded edges.
|
||||
assert_eq!(along(&m, 0, |p| (p[1] - 2.0).abs() < 1e-9 && (p[2] - 3.0).abs() < 1e-9).len(), 6);
|
||||
let z = along(&m, 2, |p| p[0].abs() < 1e-9 && p[1].abs() < 1e-9);
|
||||
assert_eq!(z.len(), 8, "{z:?}");
|
||||
// Element heights grow geometrically from the bottom, the last 4 times the first.
|
||||
let dz: Vec<f64> = z.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
let q = 4f64.powf(1.0 / 6.0);
|
||||
assert!(dz.windows(2).all(|w| (w[1] / w[0] - q).abs() < 1e-9), "{dz:?}");
|
||||
// Seeded the other way round: small at the top.
|
||||
let down = HexOptions { seeds: vec![seed("t", vec![[0.0, 0.0, 3.0], [0.0, 0.0, 0.0]], 7, bias, SeedConstraint::Exact)], ..opts.clone() };
|
||||
let m = hex_mesh(&s, &[], &UniformSize(1.0), &down).unwrap();
|
||||
let z = along(&m, 2, |p| p[0].abs() < 1e-9 && p[1].abs() < 1e-9);
|
||||
assert!(z[7] - z[6] < z[1] - z[0], "{z:?}");
|
||||
// The one-to-one sweep and the mapped mesher keep the same seeds.
|
||||
for method in [HexMethod::Sweep, HexMethod::Mapped] {
|
||||
let m = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions { method, ..opts.clone() }).unwrap();
|
||||
let z = along(&m, 2, |p| p[0].abs() < 1e-9 && p[1].abs() < 1e-9);
|
||||
let dz: Vec<f64> = z.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
assert!(dz.len() == 7 && dz.windows(2).all(|w| (w[1] / w[0] - q).abs() < 1e-9), "{method:?} {dz:?}");
|
||||
assert_eq!(m.element_count(), 5 * 2 * 7, "{method:?}");
|
||||
}
|
||||
// A minimum raises a count; a free seed is a goal the other constraints may move (its
|
||||
// opposite edges want 4, so 6 holds and 2 would not).
|
||||
let min = HexOptions { seeds: vec![seed("x", vec![[0.0, 0.0, 0.0], [4.0, 0.0, 0.0]], 2, None, SeedConstraint::Min)], ..opts.clone() };
|
||||
let front = |p: [f64; 3]| p[1].abs() < 1e-9 && p[2].abs() < 1e-9;
|
||||
assert_eq!(along(&hex_mesh(&s, &[], &UniformSize(1.0), &min).unwrap(), 0, front).len(), 5);
|
||||
let free = HexOptions { seeds: vec![seed("x", vec![[0.0, 0.0, 0.0], [4.0, 0.0, 0.0]], 6, None, SeedConstraint::Free)], ..opts.clone() };
|
||||
assert_eq!(along(&hex_mesh(&s, &[], &UniformSize(1.0), &free).unwrap(), 0, front).len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_exact_seeds_are_refused_with_their_names() {
|
||||
use fieldcraft_model::SeedConstraint;
|
||||
let s = prism(&[[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [0.0, 2.0]], 0.0, 3.0);
|
||||
// Opposite edges of the mapped block (and of the swept cross-section) with different counts.
|
||||
let opts = HexOptions {
|
||||
method: HexMethod::Mapped,
|
||||
seeds: vec![
|
||||
seed("front", vec![[0.0, 0.0, 0.0], [4.0, 0.0, 0.0]], 5, None, SeedConstraint::Exact),
|
||||
seed("back", vec![[0.0, 2.0, 3.0], [4.0, 2.0, 3.0]], 3, None, SeedConstraint::Exact),
|
||||
],
|
||||
..HexOptions::default()
|
||||
};
|
||||
let e = hex_mesh(&s, &[], &UniformSize(1.0), &opts).unwrap_err().to_string();
|
||||
assert!(e.contains("front (5 exact)") && e.contains("back (3 exact)"), "{e}");
|
||||
let e = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions { method: HexMethod::Auto, ..opts }).unwrap_err().to_string();
|
||||
assert!(e.contains("front (5 exact)") && e.contains("back (3 exact)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_on_a_shared_edge_holds_in_every_cell() {
|
||||
use fieldcraft_model::{Bias, BiasToward, SeedConstraint};
|
||||
let pairs = boxes(&[([0.0, 2.0], [0.0, 1.0]), ([2.0, 3.0], [0.0, 1.0])], 1.0);
|
||||
let none = vec![Vec::new(); 2];
|
||||
// The edge both cells share, at x = 2, along z.
|
||||
let bias = Some(Bias { ratio: 3.0, toward: BiasToward::Both });
|
||||
let opts =
|
||||
HexOptions { seeds: vec![seed("shared", vec![[2.0, 0.0, 0.0], [2.0, 0.0, 1.0]], 6, bias, SeedConstraint::Exact)], ..HexOptions::default() };
|
||||
let m = hex_mesh_cells(&cells_of(&pairs, &none), &UniformSize(0.5), &opts).unwrap();
|
||||
assert!(m.blocks.iter().all(|b| b[2] == 6), "{:?}", m.blocks);
|
||||
assert!(distinct_positions(&m.mesh));
|
||||
let z = along(&m.mesh, 2, |p| (p[0] - 2.0).abs() < 1e-9 && p[1].abs() < 1e-9);
|
||||
assert_eq!(z.len(), 7, "{z:?}");
|
||||
// Small at both ends, symmetric.
|
||||
assert!((z[1] - z[0] - (z[6] - z[5])).abs() < 1e-9 && z[3] - z[2] > z[1] - z[0], "{z:?}");
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod octree;
|
||||
pub mod planar;
|
||||
pub mod quad;
|
||||
pub mod quality;
|
||||
pub mod seeds;
|
||||
pub mod size;
|
||||
pub mod surface;
|
||||
pub mod tet;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Edge seeds as the meshers take them: a design edge's polyline with the count, bias and
|
||||
//! constraint the model's seed gives it (`fieldcraft_model::seeds`).
|
||||
//!
|
||||
//! The meshers recover their own curves from the face-tagged boundary surface, so a seed is
|
||||
//! matched to a mesher's curve by position: the curve's end points lie on the seed's end points
|
||||
//! (either way round, which gives the orientation the bias is measured in) and each one's middle
|
||||
//! lies on the other, within the tolerance of the two tessellations. The seed's node positions
|
||||
//! are placed by arc length along the mesher's curve ([`place`]).
|
||||
|
||||
use fieldcraft_model::{Bias, SeedConstraint, seeds::spacing};
|
||||
|
||||
use crate::vec3::{P, dist, lerp, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A seed on one design edge.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct EdgeSeed {
|
||||
/// The edge's name, for messages.
|
||||
pub name: String,
|
||||
/// The edge's polyline, start to end.
|
||||
pub points: Vec<[f64; 3]>,
|
||||
/// How far the polyline may lie from the exact edge (its tessellation's chordal tolerance).
|
||||
pub tolerance: f64,
|
||||
/// The divisions the seed asks for.
|
||||
pub count: usize,
|
||||
pub bias: Option<Bias>,
|
||||
pub constraint: SeedConstraint,
|
||||
}
|
||||
|
||||
impl EdgeSeed {
|
||||
/// `n + 1` node positions as fractions of the arc length from the seed's start (reversed:
|
||||
/// from its end).
|
||||
pub fn fractions(&self, n: usize, reversed: bool) -> Vec<f64> {
|
||||
let f = spacing(u32::try_from(n).unwrap_or(u32::MAX), self.bias);
|
||||
if reversed { f.iter().rev().map(|x| 1.0 - x).collect() } else { f }
|
||||
}
|
||||
|
||||
/// The seed in words: `F2:a|F2:b (8 exact)`.
|
||||
pub fn describe(&self) -> String {
|
||||
format!("{} ({} {})", self.name, self.count, self.constraint.name())
|
||||
}
|
||||
}
|
||||
|
||||
fn length(poly: &[P]) -> f64 {
|
||||
poly.windows(2).map(|w| dist(w[0], w[1])).sum()
|
||||
}
|
||||
|
||||
/// The point at fraction `t` of a polyline's arc length.
|
||||
fn at_fraction(poly: &[P], t: f64) -> Option<P> {
|
||||
let total = length(poly);
|
||||
let target = t.clamp(0.0, 1.0) * total;
|
||||
let mut walked = 0.0;
|
||||
for w in poly.windows(2) {
|
||||
let l = dist(w[0], w[1]);
|
||||
if walked + l >= target && l > 0.0 {
|
||||
return Some(lerp(w[0], w[1], ((target - walked) / l).clamp(0.0, 1.0)));
|
||||
}
|
||||
walked += l;
|
||||
}
|
||||
poly.last().copied()
|
||||
}
|
||||
|
||||
fn point_segment(p: P, a: P, b: P) -> f64 {
|
||||
let ab = sub(b, a);
|
||||
let l2 = crate::vec3::dot(ab, ab);
|
||||
let t = if l2 > 0.0 { (crate::vec3::dot(sub(p, a), ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
dist(p, lerp(a, b, t))
|
||||
}
|
||||
|
||||
fn point_polyline(p: P, poly: &[P]) -> f64 {
|
||||
match poly {
|
||||
[] => f64::INFINITY,
|
||||
[a] => dist(p, *a),
|
||||
_ => poly.windows(2).map(|w| point_segment(p, w[0], w[1])).fold(f64::INFINITY, f64::min),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether polyline `curve` is the seed's edge within `tol`: `Some(reversed)` when it runs from
|
||||
/// the seed's end to its start.
|
||||
pub(crate) fn matches(seed: &EdgeSeed, curve: &[P], tol: f64) -> Option<bool> {
|
||||
let tol = tol + seed.tolerance;
|
||||
let (Some(&a), Some(&b), Some(&s0), Some(&s1)) = (curve.first(), curve.last(), seed.points.first(), seed.points.last()) else {
|
||||
return None;
|
||||
};
|
||||
let forward = dist(a, s0) <= tol && dist(b, s1) <= tol;
|
||||
let backward = dist(a, s1) <= tol && dist(b, s0) <= tol;
|
||||
if !(forward || backward) {
|
||||
return None;
|
||||
}
|
||||
// A closed edge (both ends at one point) is oriented by its first quarter.
|
||||
let reversed = if forward && backward {
|
||||
let q = at_fraction(curve, 0.25)?;
|
||||
let (f, r) = (at_fraction(&seed.points, 0.25)?, at_fraction(&seed.points, 0.75)?);
|
||||
dist(q, r) < dist(q, f)
|
||||
} else {
|
||||
backward
|
||||
};
|
||||
let (cm, sm) = (at_fraction(curve, 0.5)?, at_fraction(&seed.points, 0.5)?);
|
||||
(point_polyline(cm, &seed.points) <= tol && point_polyline(sm, curve) <= tol).then_some(reversed)
|
||||
}
|
||||
|
||||
/// The seed (index, reversed) of each curve, the first that matches; two seeds of different
|
||||
/// counts on one curve are an error naming both.
|
||||
pub(crate) fn match_curves(seeds: &[EdgeSeed], curves: &[Vec<P>], tol: f64) -> Result<Vec<Option<(usize, bool)>>> {
|
||||
let mut out = vec![None; curves.len()];
|
||||
if seeds.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
for (c, curve) in curves.iter().enumerate() {
|
||||
for (i, s) in seeds.iter().enumerate() {
|
||||
let Some(rev) = matches(s, curve, tol) else { continue };
|
||||
match out.get(c).copied().flatten() {
|
||||
None => {
|
||||
if let Some(slot) = out.get_mut(c) {
|
||||
*slot = Some((i, rev));
|
||||
}
|
||||
}
|
||||
Some((j, _)) => {
|
||||
if let Some(o) = seeds.get(j)
|
||||
&& (o.count, o.bias, o.constraint) != (s.count, s.bias, s.constraint)
|
||||
{
|
||||
return Err(Error::Invalid(format!("the edge seeds {} and {} are on the same edge", o.describe(), s.describe())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The points at the given arc-length fractions along `poly` (its end points exact).
|
||||
pub(crate) fn place(poly: &[P], fractions: &[f64]) -> Result<Vec<P>> {
|
||||
let (Some(&first), Some(&last)) = (poly.first(), poly.last()) else {
|
||||
return Err(Error::Invalid("empty polyline".into()));
|
||||
};
|
||||
let n = fractions.len();
|
||||
fractions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, &t)| {
|
||||
if k == 0 {
|
||||
Ok(first)
|
||||
} else if k + 1 == n {
|
||||
Ok(last)
|
||||
} else {
|
||||
at_fraction(poly, t).ok_or_else(|| Error::Failed("seed spacing walked off the curve".into()))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The element size a seed asks for at each of its nodes (the mean length of the elements on
|
||||
/// either side), for size fields.
|
||||
pub fn node_sizes(seed: &EdgeSeed) -> Vec<(P, f64)> {
|
||||
let f = seed.fractions(seed.count, false);
|
||||
let total = length(&seed.points);
|
||||
let Ok(pts) = place(&seed.points, &f) else { return Vec::new() };
|
||||
pts.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, p)| {
|
||||
let before = k.checked_sub(1).and_then(|j| Some(f.get(k)? - f.get(j)?));
|
||||
let after = f.get(k + 1).zip(f.get(k)).map(|(b, a)| b - a);
|
||||
let h = match (before, after) {
|
||||
(Some(x), Some(y)) => 0.5 * (x + y),
|
||||
(Some(x), None) | (None, Some(x)) => x,
|
||||
(None, None) => return None,
|
||||
} * total;
|
||||
(h > 0.0 && h.is_finite()).then_some((*p, h))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fieldcraft_model::BiasToward;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn seed(points: Vec<P>) -> EdgeSeed {
|
||||
EdgeSeed {
|
||||
name: "e".into(),
|
||||
points,
|
||||
tolerance: 0.0,
|
||||
count: 3,
|
||||
bias: Some(Bias { ratio: 4.0, toward: BiasToward::Start }),
|
||||
constraint: SeedConstraint::Exact,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_match_either_way_round_and_carry_the_orientation() {
|
||||
let s = seed(vec![[0.0; 3], [5.0, 0.0, 0.0], [10.0, 0.0, 0.0]]);
|
||||
assert_eq!(matches(&s, &[[0.0; 3], [10.0, 0.0, 0.0]], 1e-6), Some(false));
|
||||
assert_eq!(matches(&s, &[[10.0, 0.0, 0.0], [0.0; 3]], 1e-6), Some(true));
|
||||
// Same ends, another path.
|
||||
assert_eq!(matches(&s, &[[0.0; 3], [5.0, 3.0, 0.0], [10.0, 0.0, 0.0]], 1e-6), None);
|
||||
assert_eq!(matches(&s, &[[0.0; 3], [9.0, 0.0, 0.0]], 1e-6), None);
|
||||
// Reversed fractions mirror the spacing.
|
||||
let f = s.fractions(3, false);
|
||||
let r = s.fractions(3, true);
|
||||
assert!(f.iter().zip(r.iter().rev()).all(|(a, b)| (a - (1.0 - b)).abs() < 1e-12));
|
||||
let pts = place(&[[0.0; 3], [10.0, 0.0, 0.0]], &f).unwrap();
|
||||
assert!((pts[1][0] - 10.0 * f[1]).abs() < 1e-12);
|
||||
// Sizes: the mean of the neighbouring elements.
|
||||
let h = node_sizes(&s);
|
||||
assert_eq!(h.len(), 4);
|
||||
assert!((h[0].1 - 10.0 * f[1]).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user