mesh: carry a biased seed's spacing to the edges tied to it (#257)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 11:11:14 -04:00
1 parent d23c2efaec
commit 3385209c6f
9 files changed
+208 -10

No files matched your search

+28 -1
View File
@@ -327,12 +327,17 @@ impl Topology {
ctxs.get(c).and_then(|x| x.seed_of(be)).map(|(seed, rev)| (seed, rev == fwd)) ctxs.get(c).and_then(|x| x.seed_of(be)).map(|(seed, rev)| (seed, rev == fwd))
}) })
.collect(); .collect();
// A biased seed's spacing carries to the unseeded edges parallel to it, through every
// cell they bound, oriented along each cell's direction (#257); the counts come from the
// edges' own seeds alone.
let carried = crate::seeds::carry(&seeds, |s: &&EdgeSeed| s.bias.is_some(), &self.parallel_ties());
let first = self.solve(&goals, &seeds)?; let first = self.solve(&goals, &seeds)?;
// Ends of edges that leave a curved face: the spacing along that face there. // Ends of edges that leave a curved face: the spacing along that face there.
let ends = self.graded_ends(ctxs, cubes, &first); let ends = self.graded_ends(ctxs, cubes, &first);
let mut graded = goals; let mut graded = goals;
let mut divisions = Vec::with_capacity(polylines.len()); let mut divisions = Vec::with_capacity(polylines.len());
for (((poly, ends), goal), seed) in polylines.into_iter().zip(ends).zip(graded.iter_mut()).zip(&seeds) { let spacing: Vec<Option<(&EdgeSeed, bool)>> = seeds.iter().zip(carried).map(|(own, carried)| own.or(carried)).collect();
for (((poly, ends), goal), seed) in polylines.into_iter().zip(ends).zip(graded.iter_mut()).zip(&spacing) {
// A seeded edge keeps the seed's spacing, not a grading. // A seeded edge keeps the seed's spacing, not a grading.
let ends = if seed.is_some() { [None, None] } else { ends }; let ends = if seed.is_some() { [None, None] } else { ends };
let poly = if ends.iter().any(Option::is_some) { let poly = if ends.iter().any(Option::is_some) {
@@ -350,6 +355,28 @@ impl Topology {
Ok(divisions) Ok(divisions)
} }
/// The four edges along each direction of every cell, tied pairwise, each traversed from the
/// cell's low corner to its high one.
fn parallel_ties(&self) -> Vec<[crate::seeds::Use; 2]> {
let mut ties = Vec::new();
for (groups, ids) in self.cell_edges.iter().zip(&self.corners) {
for (d, g) in groups.iter().enumerate() {
let uses: Vec<crate::seeds::Use> = g
.iter()
.enumerate()
.map(|(m, &e)| {
let (lo, _) = edge_ends(ids, d, m);
(e, self.edges.get(e).is_some_and(|x| x.a == lo))
})
.collect();
if let Some((&first, rest)) = uses.split_first() {
ties.extend(rest.iter().map(|&u| [first, u]));
}
}
}
ties
}
fn solve(&self, goals: &[f64], seeds: &[Option<(&EdgeSeed, bool)>]) -> Result<Vec<usize>> { fn solve(&self, goals: &[f64], seeds: &[Option<(&EdgeSeed, bool)>]) -> Result<Vec<usize>> {
let mut ia = super::interval::Intervals::default(); let mut ia = super::interval::Intervals::default();
for (&g, seed) in goals.iter().zip(seeds) { for (&g, seed) in goals.iter().zip(seeds) {
+13 -2
View File
@@ -37,7 +37,7 @@ use super::brep::{Brep, Face};
use super::decompose; use super::decompose;
use super::geom::{Inside, Surface, add, dist, scale, unit}; use super::geom::{Inside, Surface, add, dist, scale, unit};
use super::interval::Intervals; use super::interval::Intervals;
use super::region::Scheme; use super::region::{self, Scheme};
use super::section; use super::section;
use super::{Ctx, Raw}; use super::{Ctx, Raw};
use crate::predicates::Sign; use crate::predicates::Sign;
@@ -453,6 +453,17 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
} }
} }
let counts = ia.solve()?; 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<Option<(&EdgeSeed, bool)>> = seeds.curves.iter().zip(carried).map(|(own, carried)| own.or(carried)).collect();
let r_max = arr.points.iter().map(|q| q[1].abs()).fold(0.0, f64::max); let r_max = arr.points.iter().map(|q| q[1].abs()).fold(0.0, f64::max);
let mut layers = Vec::with_capacity(intervals.len()); let mut layers = Vec::with_capacity(intervals.len());
for &(lo, hi) in intervals.iter() { for &(lo, hi) in intervals.iter() {
@@ -475,7 +486,7 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
} }
// The cross-section's nodes (curve interiors projected onto analytic walls) and quadrilaterals. // The cross-section's nodes (curve interiors projected onto analytic walls) and quadrilaterals.
let place = |c: usize, n: usize| -> Result<Vec<Q>> { let place = |c: usize, n: usize| -> Result<Vec<Q>> {
let pts3 = match seeds.curves.get(c).copied().flatten() { let pts3 = match spacing.get(c).copied().flatten() {
Some((seed, rev)) => crate::seeds::place(&curve3d(c), &seed.fractions(n, rev))?, Some((seed, rev)) => crate::seeds::place(&curve3d(c), &seed.fractions(n, rev))?,
None => divide_polyline(ctx.field, &curve3d(c), n)?, None => divide_polyline(ctx.field, &curve3d(c), n)?,
}; };
+5 -1
View File
@@ -171,9 +171,13 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
if ni * nj * nk > ctx.opts.max_elements { if ni * nj * nk > ctx.opts.max_elements {
return Err(Error::Limit(format!("the mesh would have {} hexahedra; the limit is {}", ni * nj * nk, ctx.opts.max_elements))); return Err(Error::Limit(format!("the mesh would have {} hexahedra; the limit is {}", ni * nj * nk, ctx.opts.max_elements)));
} }
// A biased seed's spacing carries to the unseeded edges parallel to it: the four edges of a
// direction, all low corner to high.
let ties: Vec<[(usize, bool); 2]> = groups.iter().flat_map(|g| g.iter().skip(1).map(move |&u| [g[0], u])).collect();
let carried = ctx.carried(&ties);
// Edge node positions, low corner to high. // Edge node positions, low corner to high.
let edge_pts = |(e, fwd): (usize, bool), n: usize| -> Result<Vec<P>> { let edge_pts = |(e, fwd): (usize, bool), n: usize| -> Result<Vec<P>> {
let pts = ctx.divide_edge(e, fwd, n)?; let pts = ctx.divide_edge(e, fwd, n, &carried)?;
let faces = brep.edges.get(e).map(|x| x.faces.to_vec()).unwrap_or_default(); let faces = brep.edges.get(e).map(|x| x.faces.to_vec()).unwrap_or_default();
let last = pts.len().saturating_sub(1); 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()) Ok(pts.iter().enumerate().map(|(k, q)| if k == 0 || k == last { *q } else { project_to_faces(ctx, &faces, *q) }).collect())
+13 -3
View File
@@ -219,14 +219,24 @@ impl<'a> Ctx<'a> {
} }
/// `n + 1` points along B-rep edge `e` (traversed forwards or not): at the seed's spacing /// `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. /// when it has one, or the spacing `carried` to it from a biased seed on an edge it is tied
pub(crate) fn divide_edge(&self, e: usize, forward: bool, n: usize) -> Result<Vec<P>> { /// to ([`Ctx::carried`]), else evenly in the size field.
pub(crate) fn divide_edge(&self, e: usize, forward: bool, n: usize, carried: &[Option<(usize, bool)>]) -> Result<Vec<P>> {
let pts = self.brep.edge_points(e, forward); let pts = self.brep.edge_points(e, forward);
match self.seed_of(e) { let carried = carried.get(e).copied().flatten().and_then(|(i, rev)| Some((self.opts.seeds.get(i)?, rev)));
match self.seed_of(e).or(carried) {
Some((seed, rev)) => crate::seeds::place(&pts, &seed.fractions(n, rev == forward)), Some((seed, rev)) => crate::seeds::place(&pts, &seed.fractions(n, rev == forward)),
None => crate::size::divide_polyline(self.field, &pts, n), None => crate::size::divide_polyline(self.field, &pts, n),
} }
} }
/// The seeds carried along `ties` (uses of B-rep edges divided alike) from biased seeds to
/// unseeded edges ([`crate::seeds::carry`]), by index into the seeds.
pub(crate) fn carried(&self, ties: &[[crate::seeds::Use; 2]]) -> Vec<Option<(usize, bool)>> {
let mut own = self.edge_seeds.clone();
own.resize(self.brep.edges.len(), None);
crate::seeds::carry(&own, |&i| self.opts.seeds.get(i).is_some_and(|s| s.bias.is_some()), ties)
}
} }
/// How far apart a seed's polyline and a mesher's curve of the same edge may lie: both are /// How far apart a seed's polyline and a mesher's curve of the same edge may lie: both are
+16 -1
View File
@@ -109,13 +109,28 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
/// The curve variables of each side of a loop between its corners (positions in increasing /// The curve variables of each side of a loop between its corners (positions in increasing
/// order); the last side wraps round to the first corner. /// order); the last side wraps round to the first corner.
fn loop_sides<const N: usize>(lp: &[usize], c: [usize; N]) -> [Vec<usize>; N] { fn loop_sides<T: Copy, const N: usize>(lp: &[T], c: [usize; N]) -> [Vec<T>; N] {
std::array::from_fn(|k| { std::array::from_fn(|k| {
let (a, b) = (c[k], c.get(k + 1).copied().unwrap_or(c[0] + lp.len())); let (a, b) = (c[k], c.get(k + 1).copied().unwrap_or(c[0] + lp.len()));
(a..b).filter_map(|i| lp.get(i % lp.len()).copied()).collect() (a..b).filter_map(|i| lp.get(i % lp.len()).copied()).collect()
}) })
} }
/// The opposite sides of a mapped loop that are one curve each, as uses traversed the same way
/// (the loop runs along one side and back along the other), for carrying a biased seed's
/// spacing across ([`crate::seeds::carry`]).
pub(crate) fn opposite_ties(lp: &[(usize, bool)], scheme: Scheme) -> Vec<[crate::seeds::Use; 2]> {
let Scheme::Mapped(c) = scheme else { return Vec::new() };
let [s0, s1, s2, s3] = loop_sides(lp, c);
[(s0, s2), (s1, s3)]
.into_iter()
.filter_map(|(a, b)| match (a.as_slice(), b.as_slice()) {
([a], [(b, fwd)]) => Some([*a, (*b, !fwd)]),
_ => None,
})
.collect()
}
/// Add the scheme's constraints on the loops' curve variables. /// Add the scheme's constraints on the loops' curve variables.
pub(crate) fn constrain(ia: &mut Intervals, loops: &[Vec<usize>], scheme: Scheme) { pub(crate) fn constrain(ia: &mut Intervals, loops: &[Vec<usize>], scheme: Scheme) {
match scheme { match scheme {
+22 -1
View File
@@ -251,13 +251,34 @@ fn build(ctx: &Ctx, topo: &Topology, refine: u32) -> Result<Raw> {
let loop_vars: Vec<Vec<usize>> = source.loops.iter().map(|lp| vars(lp)).collect(); let loop_vars: Vec<Vec<usize>> = source.loops.iter().map(|lp| vars(lp)).collect();
region::constrain(&mut ia, &loop_vars, scheme); region::constrain(&mut ia, &loop_vars, scheme);
let counts = ia.solve()?; let counts = ia.solve()?;
// A biased seed along the sweep carries its layers to every side edge of its stage: a
// linking face's right side runs up as its left side runs down, and the first side of each
// stage runs up on every ring.
let mut ties: Vec<[(usize, bool); 2]> = Vec::new();
for stages in &topo.rings {
for ring in stages {
for l in ring {
ties.push([l.right, (l.left.0, !l.left.1)]);
}
}
}
if let Some(first) = topo.rings.first() {
for other in topo.rings.iter().skip(1).filter(|o| o.len() == first.len()) {
for (a, b) in first.iter().zip(other) {
if let (Some(a), Some(b)) = (a.first(), b.first()) {
ties.push([a.right, b.right]);
}
}
}
}
let carried = ctx.carried(&ties);
// Edge nodes: B-rep vertices once, interiors on both faces of the edge. // Edge nodes: B-rep vertices once, interiors on both faces of the edge.
let mut nodes = Nodes { xyz: Vec::new() }; let mut nodes = Nodes { xyz: Vec::new() };
let mut vertex_node: BTreeMap<u32, u32> = BTreeMap::new(); let mut vertex_node: BTreeMap<u32, u32> = BTreeMap::new();
let mut edge_nodes: Vec<Vec<u32>> = Vec::with_capacity(brep.edges.len()); let mut edge_nodes: Vec<Vec<u32>> = Vec::with_capacity(brep.edges.len());
for (e, edge) in brep.edges.iter().enumerate() { for (e, edge) in brep.edges.iter().enumerate() {
let n = counts.get(e).copied().unwrap_or(1); let n = counts.get(e).copied().unwrap_or(1);
let pts = ctx.divide_edge(e, true, n)?; let pts = ctx.divide_edge(e, true, n, &carried)?;
let last = pts.len().saturating_sub(1); let last = pts.len().saturating_sub(1);
let mut ids = Vec::with_capacity(pts.len()); let mut ids = Vec::with_capacity(pts.len());
for (k, p) in pts.iter().enumerate() { for (k, p) in pts.iter().enumerate() {
+40
View File
@@ -618,3 +618,43 @@ fn a_seed_on_a_shared_edge_holds_in_every_cell() {
// Small at both ends, symmetric. // 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:?}"); assert!((z[1] - z[0] - (z[6] - z[5])).abs() < 1e-9 && z[3] - z[2] > z[1] - z[0], "{z:?}");
} }
/// A biased seed on one edge carries its spacing to the edges interval assignment ties to it
/// (#257): the parallel edges of a mapped block, the opposite curves of an extruded
/// cross-section's mapped region, the side edges of a sweep's linking faces, and the parallel
/// edges of every cell they reach. The grid stays a grid: every node lies on one of the seed's
/// planes.
#[test]
fn a_biased_seed_grades_the_edges_tied_to_it() {
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: 3.0, toward: BiasToward::Start });
let all = |_: [f64; 3]| true;
// Along x on the front bottom edge, small at x = 4 (the seed runs from 4 to 0).
let x_seed = seed("x", vec![[4.0, 0.0, 0.0], [0.0, 0.0, 0.0]], 6, bias, SeedConstraint::Exact);
for method in [HexMethod::Mapped, HexMethod::Extrude] {
let m = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions { method, seeds: vec![x_seed.clone()], ..HexOptions::default() }).unwrap();
let x = along(&m, 0, all);
assert_eq!(x.len(), 7, "{method:?} {x:?}");
assert!(x[6] - x[5] < x[1] - x[0], "{method:?} {x:?}");
}
// Through the thickness at x = y = 0, small at z = 0: the block mapped, and an L swept
// (up z, the one way it sweeps).
let l = prism(&[[0.0, 0.0], [6.0, 0.0], [6.0, 2.0], [2.0, 2.0], [2.0, 6.0], [0.0, 6.0]], 0.0, 3.0);
let z_seed = seed("z", vec![[0.0, 0.0, 0.0], [0.0, 0.0, 3.0]], 6, bias, SeedConstraint::Exact);
for (body, method) in [(&s, HexMethod::Mapped), (&l, HexMethod::Sweep)] {
let m = hex_mesh(body, &[], &UniformSize(1.0), &HexOptions { method, seeds: vec![z_seed.clone()], ..HexOptions::default() }).unwrap();
let z = along(&m, 2, all);
assert_eq!(z.len(), 7, "{method:?} {z:?}");
assert!(z[1] - z[0] < z[6] - z[5], "{method:?} {z:?}");
}
// Two cells: a seed on the far side of the first carries through their interface to the
// far side of the second.
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];
let opts = HexOptions { seeds: vec![seed("z", vec![[0.0, 0.0, 0.0], [0.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();
let z = along(&m.mesh, 2, all);
assert_eq!(z.len(), 7, "{z:?}");
assert!(z[1] - z[0] < z[6] - z[5], "{z:?}");
}
+68
View File
@@ -154,6 +154,59 @@ pub(crate) fn place(poly: &[P], fractions: &[f64]) -> Result<Vec<P>> {
.collect() .collect()
} }
/// A use of a curve: its index and whether it is traversed from its start.
pub(crate) type Use = (usize, bool);
/// The spacing of biased seeds carried to the curves interval assignment ties to them (#257).
///
/// `own[c]` is curve `c`'s seed (any identity `T`) and whether the curve runs against it; each
/// tie says two curve uses run side by side with the same count (opposite sides of a mapped face
/// or a sweep's linking face, both traversed the same way along it). A biased seed's spacing
/// spreads along the ties to every curve without a seed of its own, which it reaches through
/// unseeded curves only; the curve takes the seed, oriented as the tie gives, so its nodes are
/// placed as the seeded curve's are. Seeds are spread in curve order and the first one to reach a
/// curve keeps it. Returns the carried seed of each unseeded curve (`None` elsewhere).
pub(crate) fn carry<T: Clone>(own: &[Option<(T, bool)>], biased: impl Fn(&T) -> bool, ties: &[[Use; 2]]) -> Vec<Option<(T, bool)>> {
let n = own.len();
let mut next: Vec<Vec<(usize, bool, bool)>> = vec![Vec::new(); n];
for &[(a, fa), (b, fb)] in ties {
if a == b || a >= n || b >= n {
continue;
}
// Traversed as tied, `a` runs forwards iff `fa`; `b`'s orientation follows from `a`'s.
if let Some(l) = next.get_mut(a) {
l.push((b, fa, fb));
}
if let Some(l) = next.get_mut(b) {
l.push((a, fb, fa));
}
}
let mut out: Vec<Option<(T, bool)>> = vec![None; n];
for (start, seed) in own.iter().enumerate() {
let Some((seed, rev)) = seed else { continue };
if !biased(seed) {
continue;
}
let mut stack = vec![(start, *rev)];
while let Some((c, rev_c)) = stack.pop() {
for &(d, fc, fd) in next.get(c).map(Vec::as_slice).unwrap_or(&[]) {
if own.get(d).is_some_and(Option::is_some) || out.get(d).is_some_and(Option::is_some) {
continue;
}
// Traversed as tied, `c` runs against the seed iff it runs forwards and against
// it, or backwards and with it; `d` runs the same way as `c`.
let against = rev_c == fc;
let rev_d = if against { fd } else { !fd };
if let Some(slot) = out.get_mut(d) {
*slot = Some((seed.clone(), rev_d));
}
stack.push((d, rev_d));
}
}
}
out
}
/// The element size a seed asks for at each of its nodes (the mean length of the elements on /// The element size a seed asks for at each of its nodes (the mean length of the elements on
/// either side), for size fields. /// either side), for size fields.
pub fn node_sizes(seed: &EdgeSeed) -> Vec<(P, f64)> { pub fn node_sizes(seed: &EdgeSeed) -> Vec<(P, f64)> {
@@ -211,4 +264,19 @@ mod tests {
assert_eq!(h.len(), 4); assert_eq!(h.len(), 4);
assert!((h[0].1 - 10.0 * f[1]).abs() < 1e-9); assert!((h[0].1 - 10.0 * f[1]).abs() < 1e-9);
} }
/// A biased seed's spacing carries along the ties to unseeded curves, oriented as each tie
/// says, and stops at a curve with a seed of its own (#257).
#[test]
fn biased_spacing_carries_along_ties() {
// 0 runs against its seed; 0 forwards beside 1 backwards; 1 forwards beside 2 forwards;
// 3 has its own seed and 4 lies beyond it.
let own = vec![Some((0, true)), None, None, Some((1, false)), None, None];
let ties = [[(0, true), (1, false)], [(1, true), (2, true)], [(2, true), (3, true)], [(3, true), (4, true)]];
let biased = [true, true];
let c = carry(&own, |&i: &usize| biased[i], &ties);
assert_eq!(c, vec![None, Some((0, false)), Some((0, false)), None, Some((1, false)), None]);
// Unbiased seeds keep to their own curve.
assert!(carry(&own, |_: &usize| false, &ties).iter().all(Option::is_none));
}
} }
+3 -1
View File
@@ -161,7 +161,9 @@ edges of partition cells matching counts.
both hold (opposite sides of a mapped face, the layers between two levels); the tet mesher both hold (opposite sides of a mapped face, the layers between two levels); the tet mesher
divides the edge at the seed's nodes and grades its size field from them (its reply's divides the edge at the seed's nodes and grades its size field from them (its reply's
`seeds` gives the mesh edges along each). A seed on an edge two partition cells share holds `seeds` gives the mesh edges along each). A seed on an edge two partition cells share holds
in both, so their meshes still conform. in both, so their meshes still conform. A biased seed's spacing carries to the unseeded edges
tied to it (the parallel edges of a mapped block or cell, the opposite side of a mapped face,
the other side edges of a sweep), so one biased edge grades the whole layer stack.
4. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when the design has no such 4. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when the design has no such
edge); `mesh.seed.clear {edges?}` removes them. Seeds are saved with the model. edge); `mesh.seed.clear {edges?}` removes them. Seeds are saved with the model.