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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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))
})
.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)?;
// 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), 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.
let ends = if seed.is_some() { [None, None] } else { ends };
let poly = if ends.iter().any(Option::is_some) {
@@ -350,6 +355,28 @@ impl Topology {
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>> {
let mut ia = super::interval::Intervals::default();
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::geom::{Inside, Surface, add, dist, scale, unit};
use super::interval::Intervals;
use super::region::Scheme;
use super::region::{self, Scheme};
use super::section;
use super::{Ctx, Raw};
use crate::predicates::Sign;
@@ -453,6 +453,17 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
}
}
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 mut layers = Vec::with_capacity(intervals.len());
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.
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))?,
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 {
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.
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 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())
+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
/// 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>> {
/// when it has one, or the spacing `carried` to it from a biased seed on an edge it is tied
/// 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);
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)),
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
+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
/// 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| {
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()
})
}
/// 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.
pub(crate) fn constrain(ia: &mut Intervals, loops: &[Vec<usize>], scheme: 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();
region::constrain(&mut ia, &loop_vars, scheme);
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.
let mut nodes = Nodes { xyz: Vec::new() };
let mut vertex_node: BTreeMap<u32, u32> = BTreeMap::new();
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 = ctx.divide_edge(e, true, n)?;
let pts = ctx.divide_edge(e, true, n, &carried)?;
let last = pts.len().saturating_sub(1);
let mut ids = Vec::with_capacity(pts.len());
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.
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()
}
/// 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
/// either side), for size fields.
pub fn node_sizes(seed: &EdgeSeed) -> Vec<(P, f64)> {
@@ -211,4 +264,19 @@ mod tests {
assert_eq!(h.len(), 4);
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
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
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
edge); `mesh.seed.clear {edges?}` removes them. Seeds are saved with the model.