From 3385209c6fc302f0a4a36a89db843c8ca88850eb Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:45:03 -0400 Subject: [PATCH] mesh: carry a biased seed's spacing to the edges tied to it (#257) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/mesh/src/hex/cells.rs | 29 ++++++++++++++- crates/mesh/src/hex/layered.rs | 15 +++++++- crates/mesh/src/hex/mapped.rs | 6 ++- crates/mesh/src/hex/mod.rs | 16 ++++++-- crates/mesh/src/hex/region.rs | 17 ++++++++- crates/mesh/src/hex/sweep.rs | 23 +++++++++++- crates/mesh/src/hex/tests.rs | 40 ++++++++++++++++++++ crates/mesh/src/seeds.rs | 68 ++++++++++++++++++++++++++++++++++ docs/mcp.md | 4 +- 9 files changed, 208 insertions(+), 10 deletions(-) diff --git a/crates/mesh/src/hex/cells.rs b/crates/mesh/src/hex/cells.rs index 5c69914..ac845f7 100644 --- a/crates/mesh/src/hex/cells.rs +++ b/crates/mesh/src/hex/cells.rs @@ -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> = 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 = 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> { let mut ia = super::interval::Intervals::default(); for (&g, seed) in goals.iter().zip(seeds) { diff --git a/crates/mesh/src/hex/layered.rs b/crates/mesh/src/hex/layered.rs index 2f218ec..f43be6d 100644 --- a/crates/mesh/src/hex/layered.rs +++ b/crates/mesh/src/hex/layered.rs @@ -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 { } } 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> = 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 { } // The cross-section's nodes (curve interiors projected onto analytic walls) and quadrilaterals. let place = |c: usize, n: usize| -> Result> { - 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)?, }; diff --git a/crates/mesh/src/hex/mapped.rs b/crates/mesh/src/hex/mapped.rs index d7fbcca..3783520 100644 --- a/crates/mesh/src/hex/mapped.rs +++ b/crates/mesh/src/hex/mapped.rs @@ -171,9 +171,13 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result { 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> { - 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()) diff --git a/crates/mesh/src/hex/mod.rs b/crates/mesh/src/hex/mod.rs index 004bbe8..c061781 100644 --- a/crates/mesh/src/hex/mod.rs +++ b/crates/mesh/src/hex/mod.rs @@ -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> { + /// 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> { 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> { + 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 diff --git a/crates/mesh/src/hex/region.rs b/crates/mesh/src/hex/region.rs index abe006e..decea9d 100644 --- a/crates/mesh/src/hex/region.rs +++ b/crates/mesh/src/hex/region.rs @@ -109,13 +109,28 @@ pub(crate) fn classify(loops: &[Vec>]) -> 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(lp: &[usize], c: [usize; N]) -> [Vec; N] { +fn loop_sides(lp: &[T], c: [usize; N]) -> [Vec; 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], scheme: Scheme) { match scheme { diff --git a/crates/mesh/src/hex/sweep.rs b/crates/mesh/src/hex/sweep.rs index 6c0c13b..a8568db 100644 --- a/crates/mesh/src/hex/sweep.rs +++ b/crates/mesh/src/hex/sweep.rs @@ -251,13 +251,34 @@ fn build(ctx: &Ctx, topo: &Topology, refine: u32) -> Result { let loop_vars: Vec> = 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 = BTreeMap::new(); let mut edge_nodes: Vec> = 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() { diff --git a/crates/mesh/src/hex/tests.rs b/crates/mesh/src/hex/tests.rs index 71aa3cb..80aa5a5 100644 --- a/crates/mesh/src/hex/tests.rs +++ b/crates/mesh/src/hex/tests.rs @@ -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:?}"); +} diff --git a/crates/mesh/src/seeds.rs b/crates/mesh/src/seeds.rs index 0101582..6430f07 100644 --- a/crates/mesh/src/seeds.rs +++ b/crates/mesh/src/seeds.rs @@ -154,6 +154,59 @@ pub(crate) fn place(poly: &[P], fractions: &[f64]) -> Result> { .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(own: &[Option<(T, bool)>], biased: impl Fn(&T) -> bool, ties: &[[Use; 2]]) -> Vec> { + let n = own.len(); + let mut next: Vec> = 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> = 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)); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 2ed4283..d684b37 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.