Files
OneCAD-Simulation/crates/mesh/src/project/tests.rs
T
jyvikoandClaude Opus 5.5 febd40fadc mesh: split a projection block through its whole sheet; name twisted elements' block junction (#399)
Topology::split cuts a block at given fractions and every block chained to it
through parallel edges, orienting the fractions through the chain, so the
topology stays conforming. A certified-invalid element is reported with its
block, cell, centre and the block vertex and blocks meeting there; the mesh is
never emitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:57:05 -04:00

260 lines
12 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 std::f64::consts::{PI, TAU};
use super::target::{Constraint, Geometry, place};
use super::*;
use crate::surface::{BoundarySurface, SurfaceOptions};
use crate::tet::geometry::{AnalyticSurface, Tessellation};
/// A UV sphere of radius `r` about the origin, one face, with its exact surface.
fn sphere(r: f64, n: usize) -> (BoundarySurface, Tessellation) {
let rings = n / 2;
let mut p = vec![[0.0, 0.0, -r], [0.0, 0.0, r]];
for j in 1..rings {
let th = PI * j as f64 / rings as f64 - PI / 2.0;
for i in 0..n {
let a = TAU * i as f64 / n as f64;
p.push([r * th.cos() * a.cos(), r * th.cos() * a.sin(), r * th.sin()]);
}
}
let id = |j: usize, i: usize| (2 + (j - 1) * n + i % n) as u32;
let mut tris = Vec::new();
for i in 0..n {
tris.push([0, id(1, i + 1), id(1, i)]);
tris.push([1, id(rings - 1, i), id(rings - 1, i + 1)]);
}
for j in 1..rings - 1 {
for i in 0..n {
tris.push([id(j, i), id(j, i + 1), id(j + 1, i + 1)]);
tris.push([id(j, i), id(j + 1, i + 1), id(j + 1, i)]);
}
}
let face = vec![0; tris.len()];
let s = BoundarySurface::new(&p, &tris, &face, &SurfaceOptions::default()).unwrap();
let t = Tessellation::new(&s, vec![Some(AnalyticSurface::Sphere { center: [0.0; 3], radius: r })]);
(s, t)
}
/// A closed cylinder of radius `r` from z = 0 to `h`: faces 0 (bottom), 1 (top), 2 (side).
fn cylinder(r: f64, h: f64, n: usize) -> (BoundarySurface, Tessellation) {
let mut p = vec![[0.0, 0.0, 0.0], [0.0, 0.0, h]];
for z in [0.0, h] {
for i in 0..n {
let a = TAU * i as f64 / n as f64;
p.push([r * a.cos(), r * a.sin(), z]);
}
}
let (b, t) = (|i: usize| (2 + i % n) as u32, |i: usize| (2 + n + i % n) as u32);
let mut tris = Vec::new();
let mut face = Vec::new();
for i in 0..n {
tris.push([0, b(i + 1), b(i)]);
face.push(0);
tris.push([1, t(i), t(i + 1)]);
face.push(1);
tris.push([b(i), b(i + 1), t(i + 1)]);
tris.push([b(i), t(i + 1), t(i)]);
face.extend([2, 2]);
}
let s = BoundarySurface::new(&p, &tris, &face, &SurfaceOptions::default()).unwrap();
let analytic = vec![
Some(AnalyticSurface::Plane { normal: [0.0, 0.0, -1.0], point: [0.0; 3] }),
Some(AnalyticSurface::Plane { normal: [0.0, 0.0, 1.0], point: [0.0, 0.0, h] }),
Some(AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: r }),
];
let t = Tessellation::new(&s, analytic);
(s, t)
}
fn options(size: f64) -> ProjectOptions {
ProjectOptions { size, ..ProjectOptions::default() }
}
/// The 7-block sphere: all hex, every boundary node on the sphere, min scaled Jacobian at least
/// 0.5 at every refinement, and the volume converging to the ball's.
#[test]
fn seven_block_sphere_converges_without_losing_quality() {
let r = 10.0;
let (s, t) = sphere(r, 96);
let topo = Topology::sphere([0.0; 3], r, 0.55).unwrap();
assert_eq!(topo.blocks.len(), 7);
let exact = 4.0 / 3.0 * PI * r * r * r;
let mut last_error = f64::INFINITY;
for size in [4.0, 2.0, 1.0] {
let m = project_mesh(&topo, &s, &t, &options(size)).unwrap();
let rep = &m.report;
assert!(rep.min_scaled_jacobian >= 0.5, "size {size}: {rep:?}");
assert!(rep.max_gap < 1e-9 * r, "size {size}: {rep:?}");
for b in &m.boundary {
let hex = m.hexes[b.element as usize];
for &l in ElementKind::Hex8.faces()[b.face as usize - 1] {
let p = m.nodes[hex[l] as usize];
assert!((crate::vec3::norm(p) - r).abs() < 1e-9, "{p:?}");
}
}
let error = (rep.volume - exact).abs() / exact;
assert!(error < last_error, "size {size}: {error} after {last_error}");
last_error = error;
}
assert!(last_error < 0.01, "{last_error}");
}
/// A disc: the five-block butterfly, its rim on the circle where the side meets the caps.
#[test]
fn butterfly_disc_meshes_with_its_rim_on_the_circle() {
let (r, h) = (10.0, 2.0);
let (s, t) = cylinder(r, h, 128);
let topo = Topology::butterfly([0.0; 3], [0.0, 0.0, 1.0], r, &[0.0, h], 0.55).unwrap();
assert_eq!(topo.blocks.len(), 5);
let mut last = f64::INFINITY;
for size in [2.0, 1.0, 0.5] {
let m = project_mesh(&topo, &s, &t, &options(size)).unwrap();
let rep = &m.report;
assert!(rep.min_scaled_jacobian >= 0.5, "size {size}: {rep:?}");
// Every node on the side lies on the cylinder; the rims on both circles.
let side: Vec<&BoundaryFace> = m.boundary.iter().filter(|b| b.brep_face == 2).collect();
assert!(!side.is_empty());
for b in side {
let hex = m.hexes[b.element as usize];
for &l in ElementKind::Hex8.faces()[b.face as usize - 1] {
let p = m.nodes[hex[l] as usize];
assert!((p[0].hypot(p[1]) - r).abs() < 1e-9, "{p:?}");
}
}
let tags: std::collections::BTreeSet<u32> = m.boundary.iter().map(|b| b.brep_face).collect();
assert_eq!(tags.len(), 3);
let error = (rep.volume - PI * r * r * h).abs();
assert!(error < last, "size {size}");
last = error;
}
}
/// A plain grid on a box: corners land on the box's corners, edges on its edges.
#[test]
fn a_grid_on_a_cylinder_is_refused_where_corners_would_flatten() {
// A single block projected onto a cylinder puts its four side edges on the circle: the
// block's corners flatten to 180°, which the mesher reports instead of meshing.
let (s, t) = cylinder(5.0, 4.0, 64);
let topo = Topology::grid([&[-3.0, 3.0], &[-3.0, 3.0], &[0.0, 4.0]]).unwrap();
let e = project_mesh(&topo, &s, &t, &options(1.0)).unwrap_err().to_string();
assert!(e.contains("O-grid") || e.contains("inverted"), "{e}");
}
/// A geometry of exact surfaces, for the curve and corner iterations.
struct Exact(Vec<AnalyticSurface>);
impl Geometry for Exact {
fn project(&self, face: u32, p: P) -> Option<P> {
self.0.get(face as usize)?.project(p)
}
fn normal(&self, face: u32, p: P) -> Option<P> {
self.0.get(face as usize)?.normal_at(p)
}
}
/// Nodes on two targets converge to their intersection curve; on three, to the common point.
#[test]
fn curve_and_corner_nodes_converge_by_tangent_planes() {
let g = Exact(vec![
AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [1.0, 0.0, 0.0], radius: 10.0 },
AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: 6.0 },
AnalyticSurface::Plane { normal: [0.0, 1.0, 0.0], point: [0.0; 3] },
]);
let targets = vec![vec![0], vec![1], vec![2]];
for start in [[3.0, 3.0, 9.0], [-5.0, 1.0, 7.0], [1.0, -4.0, 11.0]] {
let p = place(&g, &targets, Constraint::Curve(0, 1), start, 1e-13);
assert!((p[1].hypot(p[2]) - 10.0).abs() < 1e-9 && (p[0].hypot(p[1]) - 6.0).abs() < 1e-9, "{p:?}");
}
let p = place(&g, &targets, Constraint::Corner(0, 1, 2), [5.0, 1.0, 9.0], 1e-13);
assert!((p[0] - 6.0).abs() < 1e-9 && p[1].abs() < 1e-9 && (p[2] - 10.0).abs() < 1e-9, "{p:?}");
}
#[test]
fn o_grid_keeps_neighbours_and_opens_faces() {
let mut t = Topology::grid([&[0.0, 1.0, 2.0], &[0.0, 1.0], &[0.0, 1.0]]).unwrap();
let on = t.o_grid(0, &[2], [0.5; 3]).unwrap();
assert_eq!(on[0].len(), 5);
// Inner block + five side blocks (top open), the neighbour untouched.
assert_eq!(t.blocks.len(), 7);
t.check().unwrap();
let uses = lattice::face_uses(&t).unwrap();
// The top of the old block is now five faces, each used once.
// Inner block to its five side blocks, the four ring blocks to the bottom one and to each
// other, and the untouched neighbour.
assert_eq!(uses.values().filter(|&&u| u == 2).count(), 5 + 4 + 4 + 1, "{uses:?}");
let made = t.extrude(&on[0], [0.0, 0.0, 1.0], 2).unwrap();
assert_eq!(made.len(), 10);
t.check().unwrap();
let face = t.face_towards(made[8], [0.0, 0.0, 1.0]).unwrap();
assert_eq!(face, 2);
let up = t.extrude(&[(made[8], 2)], [0.0, 0.0, 1.0], 1).unwrap();
let side = t.face_towards(1, [1.0, 0.0, 0.0]).unwrap();
t.connect((up[0], 2), (1, side), 2).unwrap();
t.remove(&[1]).unwrap();
t.check().unwrap();
}
/// A split runs through the whole sheet of blocks: here a 2×2 grid split across its first
/// direction, and the reversed neighbour counts its fractions from the shared end.
#[test]
fn split_cuts_the_whole_sheet_and_stays_conforming() {
let mut t = Topology::grid([&[0.0, 1.0, 2.0], &[0.0, 1.0, 2.0], &[0.0, 1.0]]).unwrap();
// Block 1 relabelled so its direction 0 runs the other way (still right-handed).
t.blocks[1] = [t.blocks[1][2], t.blocks[1][3], t.blocks[1][0], t.blocks[1][1], t.blocks[1][6], t.blocks[1][7], t.blocks[1][4], t.blocks[1][5]];
t.check().unwrap();
// Splitting block 0 along Y cuts block 1 (its X neighbour shares the Y edges) but not 2 or 3.
let made = t.split(0, 1, &[0.25]).unwrap();
assert_eq!(made.len(), 2);
assert_eq!(t.blocks.len(), 6);
t.check().unwrap();
let uses = lattice::face_uses(&t).unwrap();
assert!(uses.values().all(|&u| u <= 2));
// Every new vertex is a quarter of the way up in Y; the cut faces are shared.
assert!(t.vertices.iter().any(|v| (v[1] - 0.25).abs() < 1e-12));
assert!(t.vertices.iter().all(|v| [0.0, 0.25, 1.0, 2.0].iter().any(|y| (v[1] - y).abs() < 1e-12)));
// Splitting along X goes through the whole grid's X sheet: four more blocks.
let made = t.split(2, 0, &[1.0 / 3.0, 2.0 / 3.0]).unwrap();
assert_eq!(t.blocks.len(), 6 + 3 * 2);
assert!(!made.is_empty());
t.check().unwrap();
assert!(lattice::face_uses(&t).unwrap().values().all(|&u| u <= 2));
let mut u = t.clone();
for bad in [vec![], vec![0.0], vec![0.5, 0.4], vec![1.2], vec![f64::NAN]] {
assert!(u.split(0, 0, &bad).is_err(), "{bad:?}");
}
assert!(u.split(0, 3, &[0.5]).is_err() && u.split(99, 0, &[0.5]).is_err());
assert_eq!(u, t);
}
#[test]
fn hostile_topologies_are_errors() {
let (s, g) = sphere(1.0, 32);
let cube = Topology::grid([&[0.0, 1.0], &[0.0, 1.0], &[0.0, 1.0]]).unwrap();
let mut inside_out = cube.clone();
inside_out.blocks[0].swap(1, 3);
let mut repeated = cube.clone();
repeated.blocks[0][1] = repeated.blocks[0][0];
let mut missing = cube.clone();
missing.blocks[0][7] = 99;
let mut nan = cube.clone();
nan.vertices[0] = [f64::NAN, 0.0, 0.0];
for t in [Topology::default(), inside_out, repeated, missing, nan] {
assert!(project_mesh(&t, &s, &g, &options(0.5)).is_err(), "{t:?}");
}
for size in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert!(project_mesh(&cube, &s, &g, &options(size)).is_err());
}
assert!(project_mesh(&cube, &s, &g, &ProjectOptions { size: 1e-6, max_elements: 1000, ..ProjectOptions::default() }).is_err());
let bad_face = ProjectOptions { faces: vec![(0, 9, vec![0])], ..options(0.5) };
assert!(project_mesh(&cube, &s, &g, &bad_face).is_err());
let bad_target = ProjectOptions { faces: vec![(0, 1, vec![7])], ..options(0.5) };
assert!(project_mesh(&cube, &s, &g, &bad_target).is_err());
let mut t = cube.clone();
assert!(t.o_grid(0, &[], [1.5; 3]).is_err() && t.o_grid(0, &[7], [0.5; 3]).is_err() && t.o_grid(3, &[], [0.5; 3]).is_err());
assert!(t.extrude(&[(0, 0)], [0.0, 0.0, 1.0], 1).is_err() && t.extrude(&[(0, 1)], [f64::NAN; 3], 1).is_err());
assert!(Topology::grid([&[0.0], &[0.0, 1.0], &[0.0, 1.0]]).is_err());
assert!(Topology::grid([&[1.0, 0.0], &[0.0, 1.0], &[0.0, 1.0]]).is_err());
assert!(Topology::sphere([0.0; 3], -1.0, 0.5).is_err() && Topology::butterfly([0.0; 3], [0.0; 3], 1.0, &[0.0, 1.0], 0.5).is_err());
let mut g3 = Topology::grid([&[0.0, 1.0], &[0.0, 1.0], &[0.0, 1.0]]).unwrap();
assert!(g3.carve([1, 1, 1], &[[[0, 0], [0, 0], [0, 0]]]).is_err());
}