Files
OneCAD-Simulation/crates/mesh/src/hex/tests.rs
T
jyvikoandClaude Opus 5.5 b6ce7aee69 hex: conforming multi-block meshes of partitioned parts
hex_mesh_cells meshes cells that meet face to face (SolidCraft split
bodies) as one conforming all-hex mesh: each cell a mapped block after
smoothly meeting faces are joined (virtual topology: the halves of a hole,
a plane running into a fillet), vertices, edges and faces shared by
identity, interval assignment over all cells, each shared face meshed
once, radial edges graded off curved faces. finish spans several cells:
tags per cell, hex20 mid-edge nodes shared across cells, a closed
2-manifold boundary and no free internal face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 04:56:46 -04:00

483 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use crate::size::UniformSize;
use crate::surface::{BoundarySurface, SurfaceOptions};
use crate::vec3::{cross, dot, sub};
/// Ear clipping of a simple planar polygon (counter-clockwise about `normal`).
fn ears(points: &[[f64; 3]], poly: &[u32]) -> Vec<[u32; 3]> {
let p = |i: u32| points[i as usize];
let normal = (0..poly.len()).fold([0.0; 3], |n, k| {
let (a, b) = (p(poly[k]), p(poly[(k + 1) % poly.len()]));
[n[0] + (a[1] - b[1]) * (a[2] + b[2]), n[1] + (a[2] - b[2]) * (a[0] + b[0]), n[2] + (a[0] - b[0]) * (a[1] + b[1])]
});
let turn = |a: [f64; 3], b: [f64; 3], c: [f64; 3]| dot(cross(sub(b, a), sub(c, a)), normal);
let mut v: Vec<u32> = poly.to_vec();
let mut out = Vec::new();
while v.len() > 3 {
let n = v.len();
let k = (0..n)
.find(|&k| {
let (a, b, c) = (p(v[(k + n - 1) % n]), p(v[k]), p(v[(k + 1) % n]));
turn(a, b, c) > 0.0
&& v.iter().all(|&o| {
let q = p(o);
o == v[(k + n - 1) % n]
|| o == v[k]
|| o == v[(k + 1) % n]
|| !(turn(a, b, q) >= 0.0 && turn(b, c, q) >= 0.0 && turn(c, a, q) >= 0.0)
})
})
.unwrap();
out.push([v[(k + n - 1) % n], v[k], v[(k + 1) % n]]);
v.remove(k);
}
out.push([v[0], v[1], v[2]]);
out
}
/// A closed surface from planar polygonal faces (counter-clockwise from outside), tagged by face.
pub(crate) fn polyhedron(points: &[[f64; 3]], faces: &[Vec<u32>]) -> BoundarySurface {
let mut tris = Vec::new();
let mut tags = Vec::new();
for (f, poly) in faces.iter().enumerate() {
for t in ears(points, poly) {
tris.push(t);
tags.push(f as u32);
}
}
BoundarySurface::new(points, &tris, &tags, &SurfaceOptions::default()).unwrap()
}
/// A prism over a counter-clockwise polygon from z0 to z1.
pub(crate) fn prism(poly: &[[f64; 2]], z0: f64, z1: f64) -> BoundarySurface {
let n = poly.len() as u32;
let mut pts: Vec<[f64; 3]> = poly.iter().map(|p| [p[0], p[1], z0]).collect();
pts.extend(poly.iter().map(|p| [p[0], p[1], z1]));
let mut faces = vec![(0..n).rev().collect::<Vec<_>>(), (n..2 * n).collect()];
for k in 0..n {
let j = (k + 1) % n;
faces.push(vec![k, j, n + j, n + k]);
}
polyhedron(&pts, &faces)
}
fn check(m: &HexMesh, volume: f64) {
assert!(m.report.gate.passed());
assert!((m.report.volume - volume).abs() < 1e-9 * volume, "{} vs {volume}", m.report.volume);
assert!(m.report.min_scaled_jacobian > 0.0);
// Every boundary face is tagged and the boundary faces are exactly the hex faces used once.
assert!(!m.boundary.is_empty());
}
#[test]
fn box_extrudes_into_a_grid() {
let s = prism(&[[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [0.0, 2.0]], 0.0, 3.0);
let m = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions::default()).unwrap();
check(&m, 24.0);
assert_eq!(m.element_count(), 24, "{}", m.report.description);
assert!((m.report.min_scaled_jacobian - 1.0).abs() < 1e-12);
// Boundary: 2·(4·2 + 4·3 + 2·3) = 52 quads.
assert_eq!(m.boundary.len(), 52);
}
#[test]
fn l_shape_extrudes_with_a_free_or_mapped_section() {
let l = [[0.0, 0.0], [6.0, 0.0], [6.0, 2.0], [2.0, 2.0], [2.0, 6.0], [0.0, 6.0]];
let s = prism(&l, 0.0, 2.0);
let m = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions::default()).unwrap();
check(&m, 40.0);
}
#[test]
fn quadratic_box() {
let s = prism(&[[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]], 0.0, 2.0);
let m = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions { quadratic: true, ..HexOptions::default() }).unwrap();
check(&m, 8.0);
assert_eq!(m.kind, ElementKind::Hex20);
// 3³ corner nodes + 3·(2·3·3) = 54 edge nodes.
assert_eq!(m.nodes.len(), 27 + 54);
}
/// A block whose top is the curved surface z = 2 + 0.3 sin(πx/4), tessellated finely.
fn curved_block() -> BoundarySurface {
let n = 16usize;
let top = |x: f64| 2.0 + 0.3 * (std::f64::consts::PI * x / 4.0).sin();
let mut pts: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0], [4.0, 0.0, 0.0], [4.0, 2.0, 0.0], [0.0, 2.0, 0.0]];
// Top grid rows at y = 0 and y = 2: ids 4..4+n+1 and 5+n..
for y in [0.0, 2.0] {
for k in 0..=n {
let x = 4.0 * k as f64 / n as f64;
pts.push([x, y, top(x)]);
}
}
let t0 = |k: usize| (4 + k) as u32;
let t2 = |k: usize| (5 + n + k) as u32;
let mut faces: Vec<Vec<u32>> = vec![vec![0, 3, 2, 1]];
// y = 0 side: 0, 1, then the top edge from x = 4 back to 0.
faces.push([0u32, 1].into_iter().chain((0..=n).rev().map(t0)).collect());
faces.push([2u32, 3].into_iter().chain((0..=n).map(t2)).collect());
faces.push(vec![1, 2, t2(n), t0(n)]);
faces.push(vec![3, 0, t0(0), t2(0)]);
// The top: strips of two triangles, one face.
let mut tris: Vec<[u32; 3]> = Vec::new();
let mut tags: Vec<u32> = Vec::new();
for (f, poly) in faces.iter().enumerate() {
for t in ears(&pts, poly) {
tris.push(t);
tags.push(f as u32);
}
}
for k in 0..n {
tris.push([t0(k), t0(k + 1), t2(k + 1)]);
tris.push([t0(k), t2(k + 1), t2(k)]);
tags.extend([5, 5]);
}
BoundarySurface::new(&pts, &tris, &tags, &SurfaceOptions::default()).unwrap()
}
#[test]
fn mapped_block_with_a_curved_top() {
let s = curved_block();
let opts = HexOptions { method: HexMethod::Mapped, ..HexOptions::default() };
let m = hex_mesh(&s, &[], &UniformSize(0.5), &opts).unwrap();
assert!(m.report.gate.passed());
assert!(m.report.min_scaled_jacobian > 0.8, "{}", m.report.min_scaled_jacobian);
assert!(m.report.description.contains("mapped block of 8 × 4 × 4"), "{}", m.report.description);
// Top nodes lie on the tessellated top.
let quadratic = hex_mesh(&s, &[], &UniformSize(0.5), &HexOptions { quadratic: true, ..opts }).unwrap();
assert!(quadratic.report.gate.passed());
assert!((quadratic.report.volume - s.volume()).abs() < 1e-3 * s.volume(), "{} vs {}", quadratic.report.volume, s.volume());
}
/// A thick-walled pipe along z (radii `ri` < `ro`, height `h`, `n` segments around, `n` even):
/// eight faces, each cylinder and annulus in two halves, with their analytic surfaces.
fn pipe(ri: f64, ro: f64, h: f64, n: usize) -> (BoundarySurface, Vec<Option<Surface>>) {
let ring = |r: f64, z: f64| -> Vec<[f64; 3]> {
(0..n)
.map(|k| {
let t = std::f64::consts::TAU * k as f64 / n as f64;
[r * t.cos(), r * t.sin(), z]
})
.collect()
};
let mut pts = Vec::new();
for (r, z) in [(ro, 0.0), (ri, 0.0), (ro, h), (ri, h)] {
pts.extend(ring(r, z));
}
let id = |ring: usize, k: usize| (ring * n + k % n) as u32;
let (bo, bi, to, ti) = (0, 1, 2, 3);
let mut tris: Vec<[u32; 3]> = Vec::new();
let mut tags: Vec<u32> = Vec::new();
// A quad as two triangles, turned to face `outward`.
let mut quad = |q: [u32; 4], outward: [f64; 3], tag: u32| {
let p = |i: u32| pts[i as usize];
let n0 = cross(sub(p(q[1]), p(q[0])), sub(p(q[2]), p(q[0])));
let q = if dot(n0, outward) < 0.0 { [q[0], q[3], q[2], q[1]] } else { q };
tris.push([q[0], q[1], q[2]]);
tris.push([q[0], q[2], q[3]]);
tags.extend([tag, tag]);
};
for k in 0..n {
let half = u32::from(k >= n / 2);
let t = std::f64::consts::TAU * (k as f64 + 0.5) / n as f64;
let radial = [t.cos(), t.sin(), 0.0];
quad([id(bo, k), id(bo, k + 1), id(to, k + 1), id(to, k)], radial, half);
quad([id(bi, k), id(bi, k + 1), id(ti, k + 1), id(ti, k)], [-radial[0], -radial[1], 0.0], 2 + half);
quad([id(bo, k), id(bo, k + 1), id(bi, k + 1), id(bi, k)], [0.0, 0.0, -1.0], 4 + half);
quad([id(to, k), id(to, k + 1), id(ti, k + 1), id(ti, k)], [0.0, 0.0, 1.0], 6 + half);
}
let surface = BoundarySurface::new(&pts, &tris, &tags, &SurfaceOptions::default()).unwrap();
let cyl = |r: f64| Some(Surface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: r });
let plane = |z: f64, nz: f64| Some(Surface::Plane { point: [0.0, 0.0, z], normal: [0.0, 0.0, nz] });
(surface, vec![cyl(ro), cyl(ro), cyl(ri), cyl(ri), plane(0.0, -1.0), plane(0.0, -1.0), plane(h, 1.0), plane(h, 1.0)])
}
/// Every node of the boundary faces tagged with `faces` lies at `radius` from the z axis.
fn on_radius(m: &HexMesh, faces: &[u32], radius: f64) -> bool {
m.boundary.iter().filter(|b| faces.contains(&b.brep_face)).all(|b| {
let local = m.kind.faces()[b.face as usize - 1];
let e = m.element(b.element as usize).unwrap();
local.iter().all(|&i| {
let p = m.nodes[e[i] as usize];
((p[0] * p[0] + p[1] * p[1]).sqrt() - radius).abs() < 1e-9 * radius
})
})
}
#[test]
fn pipe_extrudes_and_revolves_with_nodes_on_the_cylinders() {
let (s, faces) = pipe(2.0, 3.0, 4.0, 32);
for method in [HexMethod::Auto, HexMethod::Revolve] {
for quadratic in [false, true] {
let opts =
HexOptions { method, quadratic, chordal_tolerance: 3.0 * (1.0 - (std::f64::consts::PI / 32.0).cos()), ..HexOptions::default() };
let m = hex_mesh(&s, &faces, &UniformSize(0.5), &opts).unwrap();
let expected = if method == HexMethod::Auto { HexMethod::Extrude } else { HexMethod::Revolve };
assert_eq!(m.report.method, expected, "{}", m.report.description);
assert!(m.report.gate.passed());
assert!(m.report.min_scaled_jacobian > 0.5, "{method:?} {quadratic}: {} {}", m.report.min_scaled_jacobian, m.report.description);
assert!(on_radius(&m, &[0, 1], 3.0) && on_radius(&m, &[2, 3], 2.0), "{method:?} {quadratic}");
let exact = std::f64::consts::PI * (9.0 - 4.0) * 4.0;
let tolerance = if quadratic { 1e-3 } else { 2e-2 };
assert!((m.report.volume - exact).abs() < tolerance * exact, "{method:?} {quadratic}: {} vs {exact}", m.report.volume);
// Every boundary face is tagged with one of the pipe's faces.
assert!(m.boundary.iter().all(|b| b.brep_face < 8));
}
}
}
#[test]
fn box_faces_are_tagged_and_meshes_are_deterministic() {
let s = prism(&[[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [0.0, 2.0]], 0.0, 3.0);
let a = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions::default()).unwrap();
let b = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions::default()).unwrap();
assert_eq!(a, b);
// prism(): face 0 bottom, 1 top, then the sides along the polygon's edges.
let count = |f: u32| a.boundary.iter().filter(|x| x.brep_face == f).count();
assert_eq!([count(0), count(1), count(2), count(3), count(4), count(5)], [8, 8, 12, 6, 12, 6]);
// Each boundary face lies in its B-rep face's plane.
let plane = |f: u32| -> (usize, f64) {
match f {
0 => (2, 0.0),
1 => (2, 3.0),
2 => (1, 0.0),
3 => (0, 4.0),
4 => (1, 2.0),
_ => (0, 0.0),
}
};
for b in &a.boundary {
let (axis, value) = plane(b.brep_face);
let e = a.element(b.element as usize).unwrap();
for &i in ElementKind::Hex8.faces()[b.face as usize - 1] {
assert!((a.nodes[e[i] as usize][axis] - value).abs() < 1e-12, "{b:?}");
}
}
}
#[test]
fn an_l_prism_sweeps_one_to_one() {
let l = [[0.0, 0.0], [6.0, 0.0], [6.0, 2.0], [2.0, 2.0], [2.0, 6.0], [0.0, 6.0]];
let s = prism(&l, 0.0, 2.0);
let m = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions { method: HexMethod::Sweep, ..HexOptions::default() }).unwrap();
assert_eq!(m.report.method, HexMethod::Sweep);
check(&m, 40.0);
}
#[test]
fn unsweepable_bodies_say_why_and_hostile_input_is_an_error() {
// A tetrahedron: every face is slanted to every other.
let tet = polyhedron(
&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
&[vec![0, 2, 1], vec![0, 1, 3], vec![1, 2, 3], vec![0, 3, 2]],
);
match hex_mesh(&tet, &[], &UniformSize(0.2), &HexOptions::default()) {
Err(Error::Unsupported(why)) => {
for m in ["extrude", "sweep", "revolve", "mapped"] {
assert!(why.contains(m), "{why}");
}
}
other => panic!("{other:?}"),
}
let s = prism(&[[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [0.0, 2.0]], 0.0, 3.0);
let bad_sizes: [&dyn crate::size::SizeField; 3] = [&UniformSize(f64::NAN), &UniformSize(0.0), &UniformSize(-1.0)];
for f in bad_sizes {
assert!(hex_mesh(&s, &[], f, &HexOptions::default()).is_err());
}
assert!(matches!(hex_mesh(&s, &[], &UniformSize(1e-4), &HexOptions::default()), Err(Error::Limit(_))));
assert!(matches!(hex_mesh(&s, &[], &UniformSize(0.1), &HexOptions { max_elements: 100, ..HexOptions::default() }), Err(Error::Limit(_))));
assert!(hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions { chordal_tolerance: f64::NAN, ..HexOptions::default() }).is_err());
// Analytic surfaces with garbage numbers are treated as free-form, not trusted.
let garbage = vec![Some(Surface::Cylinder { origin: [f64::NAN; 3], axis: [0.0; 3], radius: -1.0 }); 6];
let m = hex_mesh(&s, &garbage, &UniformSize(1.0), &HexOptions::default()).unwrap();
assert_eq!(m.element_count(), 24);
}
// ---- Conforming cells ----
/// A prism over a counter-clockwise polygon from z0 to z1 whose side from vertex k to k + 1 gets
/// tag `side_tags[k]` (bottom 0, top 1); quads of one tag make one face.
fn tagged_prism(poly: &[[f64; 2]], side_tags: &[u32], z0: f64, z1: f64) -> BoundarySurface {
let n = poly.len();
let mut pts: Vec<[f64; 3]> = poly.iter().map(|p| [p[0], p[1], z0]).collect();
pts.extend(poly.iter().map(|p| [p[0], p[1], z1]));
let mut tris = Vec::new();
let mut tags = Vec::new();
for t in ears(&pts, &(0..n as u32).rev().collect::<Vec<_>>()) {
tris.push(t);
tags.push(0);
}
for t in ears(&pts, &(n as u32..2 * n as u32).collect::<Vec<_>>()) {
tris.push(t);
tags.push(1);
}
for (k, &tag) in side_tags.iter().enumerate().take(n) {
let (a, b) = (k as u32, ((k + 1) % n) as u32);
let (c, d) = (b + n as u32, a + n as u32);
tris.push([a, b, c]);
tris.push([a, c, d]);
tags.extend([tag, tag]);
}
BoundarySurface::new(&pts, &tris, &tags, &SurfaceOptions::default()).unwrap()
}
fn cells_of<'a>(surfaces: &'a [BoundarySurface], faces: &'a [Vec<Option<Surface>>]) -> Vec<Cell<'a>> {
surfaces.iter().zip(faces).map(|(surface, faces)| Cell { name: "cell", surface, faces }).collect()
}
/// Boxes `[x0, x1] × [y0, y1] × [0, h]` as prisms (faces 0 bottom, 1 top, 2..6 the sides).
fn boxes(spans: &[([f64; 2], [f64; 2])], h: f64) -> Vec<BoundarySurface> {
spans.iter().map(|(x, y)| prism(&[[x[0], y[0]], [x[1], y[0]], [x[1], y[1]], [x[0], y[1]]], 0.0, h)).collect()
}
/// Every node of the mesh at a distinct position: the cells' nodes were merged.
fn distinct_positions(m: &HexMesh) -> bool {
let mut keys: Vec<[u64; 3]> = m.nodes.iter().map(|p| p.map(|x| (x + 0.0).to_bits())).collect();
keys.sort_unstable();
keys.windows(2).all(|w| w[0] != w[1])
}
#[test]
fn boxes_side_by_side_conform() {
// Two boxes sharing x = 2, then a 2 × 2 arrangement whose middle edge four cells share.
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 m = hex_mesh_cells(&cells_of(&pairs, &none), &UniformSize(0.5), &HexOptions::default()).unwrap();
assert_eq!(m.interfaces, 1);
assert_eq!(m.blocks.iter().map(|b| b[0] * b[1] * b[2]).sum::<usize>(), m.mesh.element_count());
assert_eq!(m.mesh.element_count(), 6 * 2 * 2);
// One structured grid: 7 × 3 × 3 nodes, none duplicated on the interface.
assert_eq!(m.mesh.nodes.len(), 7 * 3 * 3);
assert!(distinct_positions(&m.mesh));
assert!((m.mesh.report.volume - 3.0).abs() < 1e-12);
assert!(m.mesh.report.gate.passed());
// The boundary is the outside only: 2·(6·2 + 6·2 + 2·2) quads.
assert_eq!(m.mesh.boundary.len(), 56);
assert_eq!(m.element_cell.iter().filter(|&&c| c == 0).count(), 16);
let grid = boxes(&[([0.0, 1.0], [0.0, 1.0]), ([1.0, 2.0], [0.0, 1.0]), ([0.0, 1.0], [1.0, 2.0]), ([1.0, 2.0], [1.0, 2.0])], 1.0);
let none = vec![Vec::new(); 4];
let a = hex_mesh_cells(&cells_of(&grid, &none), &UniformSize(0.25), &HexOptions::default()).unwrap();
assert_eq!(a.interfaces, 4);
assert_eq!(a.mesh.nodes.len(), 9 * 9 * 5);
assert!(distinct_positions(&a.mesh));
// Deterministic, and hex20 shares its mid-edge nodes across the interfaces too.
let b = hex_mesh_cells(&cells_of(&grid, &none), &UniformSize(0.25), &HexOptions::default()).unwrap();
assert_eq!(a, b);
let q = hex_mesh_cells(&cells_of(&grid, &none), &UniformSize(0.25), &HexOptions { quadratic: true, ..HexOptions::default() }).unwrap();
assert_eq!(q.mesh.kind, ElementKind::Hex20);
// Corner nodes plus one node per lattice edge: 9·9·5 + (8·9·5)·2 + 9·9·4.
assert_eq!(q.mesh.nodes.len(), 405 + 720 + 324);
assert!(distinct_positions(&q.mesh));
}
#[test]
fn cells_that_do_not_meet_face_to_face_are_refused() {
// One box against two half-height boxes: its face at x = 1 meets two faces (a T-junction).
let t = vec![
prism(&[[0.0, 0.0], [1.0, 0.0], [1.0, 2.0], [0.0, 2.0]], 0.0, 1.0),
prism(&[[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]], 0.0, 1.0),
prism(&[[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0]], 0.0, 1.0),
];
let none = vec![Vec::new(); 3];
match hex_mesh_cells(&cells_of(&t, &none), &UniformSize(0.5), &HexOptions::default()) {
Err(Error::Failed(why)) => assert!(why.contains("interface") || why.contains("conform"), "{why}"),
other => panic!("{other:?}"),
}
// A cell that is no logical cube says which.
let l = vec![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, 2.0)];
match hex_mesh_cells(&cells_of(&l, &[Vec::new()]), &UniformSize(1.0), &HexOptions::default()) {
Err(Error::Unsupported(why)) => assert!(why.starts_with("cell `cell`: not a logical cube"), "{why}"),
other => panic!("{other:?}"),
}
}
#[test]
fn hostile_cell_input_is_an_error() {
let two = boxes(&[([0.0, 1.0], [0.0, 1.0]), ([1.0, 2.0], [0.0, 1.0])], 1.0);
let none = vec![Vec::new(); 2];
let cells = cells_of(&two, &none);
assert!(hex_mesh_cells(&[], &UniformSize(1.0), &HexOptions::default()).is_err());
for method in [HexMethod::Sweep, HexMethod::Extrude, HexMethod::Revolve, HexMethod::Swept] {
assert!(matches!(hex_mesh_cells(&cells, &UniformSize(1.0), &HexOptions { method, ..HexOptions::default() }), Err(Error::Invalid(_))));
}
let bad_sizes: [&dyn crate::size::SizeField; 3] = [&UniformSize(f64::NAN), &UniformSize(0.0), &UniformSize(-1.0)];
for f in bad_sizes {
assert!(hex_mesh_cells(&cells, f, &HexOptions::default()).is_err());
}
assert!(matches!(hex_mesh_cells(&cells, &UniformSize(1e-4), &HexOptions::default()), Err(Error::Limit(_))));
assert!(matches!(hex_mesh_cells(&cells, &UniformSize(0.1), &HexOptions { max_elements: 100, ..HexOptions::default() }), Err(Error::Limit(_))));
assert!(hex_mesh_cells(&cells, &UniformSize(1.0), &HexOptions { chordal_tolerance: -1.0, ..HexOptions::default() }).is_err());
// The same cell twice: two cells on the same side of every face.
let same = [two[0].clone(), two[0].clone()];
assert!(hex_mesh_cells(&cells_of(&same, &none), &UniformSize(0.5), &HexOptions::default()).is_err());
// Garbage analytic surfaces are not trusted.
let garbage = vec![vec![Some(Surface::Cylinder { origin: [f64::NAN; 3], axis: [0.0; 3], radius: -1.0 }); 6]; 2];
let m = hex_mesh_cells(&cells_of(&two, &garbage), &UniformSize(0.5), &HexOptions::default()).unwrap();
assert_eq!(m.mesh.element_count(), 16);
}
/// The four cells of an O-grid round a hole of radius 1 in the square [−2, 2]², height `h`: each
/// cell the hole's quarter between two diagonals and a side of the square. The hole's quarter is
/// one face (tag 5) or two halves of the cylinder (tags 5 and 6: virtual topology joins them).
fn o_grid(h: f64, arc_segments: usize, split_arcs: bool) -> (Vec<BoundarySurface>, Vec<Vec<Option<Surface>>>) {
use std::f64::consts::FRAC_PI_4;
let mut surfaces = Vec::new();
let mut faces = Vec::new();
for q in 0..4 {
let turn = q as f64 * 2.0 * FRAC_PI_4;
let rot = |p: [f64; 2]| [p[0] * turn.cos() - p[1] * turn.sin(), p[0] * turn.sin() + p[1] * turn.cos()];
let mut poly = vec![rot([FRAC_PI_4.cos(), -FRAC_PI_4.sin()]), rot([2.0, -2.0]), rot([2.0, 2.0])];
let mut tags = vec![2, 3, 4];
for k in 0..arc_segments {
let t = FRAC_PI_4 - 2.0 * FRAC_PI_4 * k as f64 / arc_segments as f64;
poly.push(rot([t.cos(), t.sin()]));
tags.push(if split_arcs && 2 * k >= arc_segments { 6 } else { 5 });
}
// Exact corners where the cells meet.
let snap = |p: [f64; 2]| p.map(|x| if (x.abs() - 2.0).abs() < 1e-12 { 2.0 * x.signum() } else { x });
let poly: Vec<[f64; 2]> = poly.into_iter().map(snap).collect();
surfaces.push(tagged_prism(&poly, &tags, 0.0, h));
let cyl = Some(Surface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: 1.0 });
faces.push(vec![None, None, None, None, None, cyl, cyl]);
}
(surfaces, faces)
}
#[test]
fn an_o_grid_round_a_hole_conforms_with_graded_radial_lines() {
for split_arcs in [false, true] {
let (surfaces, faces) = o_grid(0.5, 24, split_arcs);
let opts = HexOptions { chordal_tolerance: 1.0 - (std::f64::consts::PI / 48.0).cos(), ..HexOptions::default() };
let m = hex_mesh_cells(&cells_of(&surfaces, &faces), &UniformSize(0.5), &opts).unwrap();
assert_eq!(m.interfaces, 4, "{split_arcs}");
assert!(m.mesh.report.gate.passed());
assert!(m.mesh.report.min_scaled_jacobian > 0.3, "{}", m.mesh.report.min_scaled_jacobian);
assert!(distinct_positions(&m.mesh));
// A ring of quads round the hole: every block is (around) × (radial) × (through).
let sorted = |b: [usize; 3]| {
let mut b = b;
b.sort_unstable();
b
};
assert!(m.blocks.iter().all(|&b| sorted(b) == sorted(m.blocks[0])), "{:?}", m.blocks);
let per_block = m.blocks[0].iter().product::<usize>();
assert_eq!(m.mesh.element_count(), 4 * per_block);
// Nodes on the hole lie on it.
let hole: Vec<u32> = if split_arcs { vec![5, 6] } else { vec![5] };
assert!(on_radius(&m.mesh, &hole, 1.0), "{split_arcs}");
let on_hole = m.mesh.boundary.iter().filter(|b| hole.contains(&b.brep_face)).count();
assert!(on_hole > 0);
// Radial grading: the first spacing off the hole is smaller than the last at the square.
let radii: std::collections::BTreeSet<u64> =
m.mesh.nodes.iter().filter(|p| p[2] == 0.0 && p[1].abs() < 1e-12 && p[0] > 0.0).map(|p| p[0].to_bits()).collect();
let r: Vec<f64> = radii.into_iter().map(f64::from_bits).collect();
assert!(r.len() >= 3, "{r:?}");
let first = r[1] - r[0];
let last = r[r.len() - 1] - r[r.len() - 2];
assert!(first < 0.75 * last, "{r:?}");
// Spacing grows by at most the growth rate plus the rounding of the count.
assert!(r.windows(3).all(|w| (w[2] - w[1]) <= 1.35 * (w[1] - w[0])), "{r:?}");
}
}