use super::*;
use crate::mesh_orient::OrientVerdict;
fn cube(origin: [f32; 3]) -> (Vec<f32>, Vec<u32>) {
let [ox, oy, oz] = origin;
let mut positions = Vec::with_capacity(8 * 3);
for &x in &[0.0_f32, 1.0] {
for &y in &[0.0_f32, 1.0] {
for &z in &[0.0_f32, 1.0] {
positions.extend_from_slice(&[ox + x, oy + y, oz + z]);
}
}
}
let indices = vec![
0, 1, 3, 0, 3, 2, 4, 6, 7, 4, 7, 5, 0, 4, 5, 0, 5, 1, 2, 3, 7, 2, 7, 6, 0, 2, 6, 0, 6,
4, 1, 5, 7, 1, 7, 3,
];
(positions, indices)
}
const TOL: f64 = 1.0e-3;
#[test]
fn rtc_invariance_same_world_geometry() {
let world_origin = [1234.5_f32, -67.25, 8.5];
let (pos_a, idx) = cube(world_origin);
let a = hash_mesh_world(&pos_a, &idx, [0.0, 0.0, 0.0], TOL);
let shift = [999_000.0_f64, -2_000.0, 5_000.0];
let pos_b: Vec<f32> = pos_a
.chunks_exact(3)
.flat_map(|c| {
[
(c[0] as f64 - shift[0]) as f32,
(c[1] as f64 - shift[1]) as f32,
(c[2] as f64 - shift[2]) as f32,
]
})
.collect();
let b = hash_mesh_world(&pos_b, &idx, shift, TOL);
assert_eq!(a, b, "RTC offset must not change the geometry hash");
}
#[test]
fn translation_is_detected() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let moved: Vec<f32> = pos.chunks_exact(3).flat_map(|c| [c[0] + 1.0, c[1], c[2]]).collect();
assert_ne!(
hash_mesh_world(&pos, &idx, [0.0; 3], TOL),
hash_mesh_world(&moved, &idx, [0.0; 3], TOL),
"a 1 m move must change the hash"
);
}
#[test]
fn degenerate_triangles_do_not_affect_hash() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let base = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let mut noisy = idx.clone();
noisy.extend_from_slice(&[0, 0, 1]);
noisy.extend_from_slice(&[2, 2, 2]);
let with_noise = hash_mesh_world(&pos, &noisy, [0.0; 3], TOL);
assert_eq!(base, with_noise, "zero-area triangles must not change the hash");
}
#[test]
fn sub_tolerance_jitter_is_ignored() {
let cell = TOL * 10.0;
let base: Vec<f32> = (0..24).map(|i| (i as f32) * (cell as f32)).collect();
let idx: Vec<u32> = (0..(base.len() as u32 / 3) - 2)
.flat_map(|i| [i, i + 1, i + 2])
.collect();
let jitter = (TOL as f32) * 0.1;
let perturbed: Vec<f32> = base.iter().map(|v| v + jitter).collect();
assert_eq!(
hash_mesh_world(&base, &idx, [0.0; 3], TOL),
hash_mesh_world(&perturbed, &idx, [0.0; 3], TOL),
"jitter below the quantization grid must not change the hash"
);
}
#[test]
fn a_request_finer_than_the_floor_is_clamped_not_honoured() {
let origin = [2_600_000.0_f32, 0.0, 0.0];
let positions: Vec<f32> = vec![
origin[0],
origin[1],
origin[2],
origin[0] + 100.0,
origin[1],
origin[2],
origin[0],
origin[1] + 100.0,
origin[2],
];
let indices = vec![0u32, 1, 2];
let requested_1e_9 = hash_mesh_world(&positions, &indices, [0.0; 3], 1e-9);
let at_floor = hash_mesh_world(&positions, &indices, [0.0; 3], MIN_GEOM_HASH_TOLERANCE);
assert_eq!(
requested_1e_9, at_floor,
"a tolerance finer than MIN_GEOM_HASH_TOLERANCE must be clamped up to it, \
not honoured verbatim"
);
let at_default = hash_mesh_world(&positions, &indices, [0.0; 3], DEFAULT_GEOM_HASH_TOLERANCE);
assert_ne!(
at_floor, at_default,
"the floor and the (coarser) default must not collapse to the same grid"
);
}
#[test]
fn triangle_and_vertex_order_invariant() {
let (pos, idx) = cube([3.0, 3.0, 3.0]);
let canonical = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let mut shuffled = Vec::with_capacity(idx.len());
for tri in idx.chunks_exact(3).rev() {
shuffled.extend_from_slice(&[tri[1], tri[2], tri[0]]);
}
assert_eq!(
canonical,
hash_mesh_world(&pos, &shuffled, [0.0; 3], TOL),
"reordering triangles / rotating corners must not change the hash"
);
}
#[test]
fn winding_invariant() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let canonical = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let flipped: Vec<u32> =
idx.chunks_exact(3).flat_map(|t| [t[0], t[2], t[1]]).collect();
assert_eq!(
canonical,
hash_mesh_world(&pos, &flipped, [0.0; 3], TOL),
"reversing winding must not change the hash"
);
}
#[test]
fn segment_split_matches_single_segment() {
let (pos, idx) = cube([10.0, 0.0, -4.0]);
let single = hash_mesh_world(&pos, &idx, [0.0; 3], TOL);
let (first, second) = idx.split_at(idx.len() / 2);
let mut hasher = GeometryHasher::new(TOL, [0.0; 3]);
hasher.add_mesh(&pos, first);
hasher.add_mesh(&pos, second);
assert_eq!(single, hasher.finish(), "split segments must match a single mesh");
}
#[test]
fn distinct_shapes_differ() {
let (cube_pos, cube_idx) = cube([0.0, 0.0, 0.0]);
let (big_pos, big_idx) = cube([0.0, 0.0, 0.0]);
let scaled: Vec<f32> = big_pos.iter().map(|v| v * 2.0).collect();
assert_ne!(
hash_mesh_world(&cube_pos, &cube_idx, [0.0; 3], TOL),
hash_mesh_world(&scaled, &big_idx, [0.0; 3], TOL),
"a 2x-scaled cube must hash differently"
);
}
#[test]
fn tolerance_sweep_sensitivity() {
let (pos, idx) = cube([100.0, 50.0, 25.0]);
for &tol in &[1.0e-4_f64, 1.0e-3, 1.0e-2, 1.0e-1] {
let baseline = hash_mesh_world(&pos, &idx, [0.0; 3], tol);
let one_cell = tol as f32;
let moved: Vec<f32> =
pos.chunks_exact(3).flat_map(|c| [c[0] + one_cell, c[1], c[2]]).collect();
assert_ne!(
baseline,
hash_mesh_world(&moved, &idx, [0.0; 3], tol),
"tol={tol}: a one-cell move must be detected"
);
let tiny = (tol as f32) * 1.0e-3;
let nudged: Vec<f32> = pos.iter().map(|v| v + tiny).collect();
assert_eq!(
baseline,
hash_mesh_world(&nudged, &idx, [0.0; 3], tol),
"tol={tol}: sub-grid jitter must be absorbed"
);
}
}
#[test]
fn world_aabb_is_the_exact_unquantized_extent() {
let (pos, idx) = cube([2.5, -7.0, 0.25]);
let mut h = GeometryHasher::new(TOL, [0.0; 3]);
h.add_mesh(&pos, &idx);
let aabb = h.world_aabb().expect("cube produced corners");
assert_eq!(aabb, [2.5, -7.0, 0.25, 3.5, -6.0, 1.25]);
}
#[test]
fn world_aabb_is_rtc_invariant() {
let world_origin = [1234.5_f32, -67.25, 8.5];
let (pos_a, idx) = cube(world_origin);
let mut a = GeometryHasher::new(TOL, [0.0; 3]);
a.add_mesh(&pos_a, &idx);
let shift = [999_000.0_f64, -2_000.0, 5_000.0];
let pos_b: Vec<f32> = pos_a
.chunks_exact(3)
.flat_map(|c| {
[
(c[0] as f64 - shift[0]) as f32,
(c[1] as f64 - shift[1]) as f32,
(c[2] as f64 - shift[2]) as f32,
]
})
.collect();
let mut b = GeometryHasher::new(TOL, shift);
b.add_mesh(&pos_b, &idx);
assert_eq!(
a.world_aabb(),
b.world_aabb(),
"the file's RTC choice must not move the reported world box"
);
}
#[test]
fn world_aabb_folds_origin_and_unions_segments() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let mut h = GeometryHasher::new(TOL, [0.0; 3]);
h.add_mesh_with_origin(&pos, &idx, [10.0, 0.0, 0.0]);
h.add_mesh_with_origin(&pos, &idx, [-4.0, 2.0, 0.0]);
assert_eq!(
h.world_aabb().expect("two segments"),
[-4.0, 0.0, 0.0, 11.0, 3.0, 1.0]
);
}
#[test]
fn world_aabb_includes_hash_skipped_degenerate_triangles() {
let (mut pos, mut idx) = cube([0.0, 0.0, 0.0]);
let base = (pos.len() / 3) as u32;
pos.extend_from_slice(&[40.0, 0.0, 0.0, 40.0, 0.0, 0.0, 40.0, 1.0, 0.0]);
idx.extend_from_slice(&[base, base + 1, base + 2]);
let mut h = GeometryHasher::new(TOL, [0.0; 3]);
h.add_mesh(&pos, &idx);
assert_eq!(
h.world_aabb().expect("cube + sliver"),
[0.0, 0.0, 0.0, 40.0, 1.0, 1.0],
"a degenerate triangle contributes extent even though it carries no hash"
);
assert_eq!(
h.finish(),
hash_mesh_world(&pos, &{ idx[..idx.len() - 3].to_vec() }, [0.0; 3], TOL),
"the degenerate triangle must stay out of the hash"
);
}
#[test]
fn world_aabb_skips_out_of_range_triangles() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
let mut noisy = idx.clone();
noisy.extend_from_slice(&[0, 1, 9999]);
let mut h = GeometryHasher::new(TOL, [0.0; 3]);
h.add_mesh(&pos, &noisy);
assert_eq!(h.world_aabb().expect("cube"), [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
}
#[test]
fn world_aabb_is_none_without_geometry() {
let h = GeometryHasher::new(TOL, [0.0; 3]);
assert_eq!(h.world_aabb(), None);
let mut empty = GeometryHasher::new(TOL, [0.0; 3]);
empty.add_mesh(&[], &[]);
assert_eq!(empty.world_aabb(), None);
}
#[test]
fn world_aabb_is_none_when_any_single_axis_never_accumulated() {
let nan_on = |axis: usize| {
let mut pos: Vec<f32> = vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0];
for v in 0..3 {
pos[v * 3 + axis] = f32::NAN;
}
let mut h = GeometryHasher::new(TOL, [0.0; 3]);
h.add_mesh(&pos, &[0, 1, 2]);
h
};
for axis in 0..3 {
let h = nan_on(axis);
assert!(
!h.is_empty(),
"axis {axis}: the triangle must still hash — otherwise this test is not \
exercising the divergence it claims to"
);
assert_eq!(
h.world_aabb(),
None,
"axis {axis}: an axis that never accumulated must suppress the whole box, \
not be reported as an inverted/infinite span"
);
}
}
#[test]
fn a_hash_without_a_box_is_reachable() {
let mut h = GeometryHasher::new(TOL, [0.0; 3]);
h.add_mesh(
&[f32::NAN, 0.0, 0.0, f32::NAN, 1.0, 0.0, f32::NAN, 0.0, 1.0],
&[0, 1, 2],
);
assert!(!h.is_empty(), "the NaN-x triangle still carries a fingerprint");
assert_eq!(h.world_aabb(), None, "...and no box");
let empty = GeometryHasher::new(TOL, [0.0; 3]);
assert!(empty.is_empty() && empty.world_aabb().is_none());
}
#[test]
fn hash_values_are_pinned_against_a_silent_re_key() {
let (pos, idx) = cube([0.0, 0.0, 0.0]);
assert_eq!(
hash_mesh_world(&pos, &idx, [0.0; 3], TOL),
6_825_412_298_365_256_040
);
let (pos, idx) = cube([1234.5, -67.25, 8.5]);
assert_eq!(
hash_mesh_world(&pos, &idx, [999_000.0, -2_000.0, 5_000.0], TOL),
15_006_160_787_977_600_551
);
let mut h = GeometryHasher::new(1.0e-2, [3.0, -1.0, 0.5]);
let (pos, idx) = cube([10.0, 0.0, -4.0]);
h.add_mesh_with_origin(&pos, &idx, [0.125, 0.25, -0.5]);
assert_eq!(h.finish(), 12_803_763_652_453_329_586);
}
fn watertight_unit_cube(origin: [f32; 3]) -> (Vec<f32>, Vec<u32>) {
let [ox, oy, oz] = origin;
let c = [
[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0],
[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.0, 1.0, 1.0],
];
let faces: [[usize; 3]; 12] = [
[0, 2, 1], [0, 3, 2],
[4, 5, 6], [4, 6, 7],
[0, 1, 5], [0, 5, 4],
[2, 3, 7], [2, 7, 6],
[1, 2, 6], [1, 6, 5],
[0, 4, 7], [0, 7, 3],
];
let mut positions = Vec::new();
let mut indices = Vec::new();
for f in &faces {
for &vi in f {
positions.extend_from_slice(&[ox + c[vi][0], oy + c[vi][1], oz + c[vi][2]]);
}
let base = indices.len() as u32;
indices.extend_from_slice(&[base, base + 1, base + 2]);
}
(positions, indices)
}
fn closed_solid_verdict() -> OrientVerdict {
OrientVerdict {
flipped: false,
all_closed: true,
all_orientable: true,
components: 1,
}
}
#[test]
fn a_unit_cube_is_exactly_one_cubic_metre() {
let (positions, indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
h.add_oriented_mesh(&positions, &indices, [0.0; 3], closed_solid_verdict());
assert_eq!(h.volume(), Some(1.0));
assert!(h.closure().is_trustworthy_solid());
assert_eq!(h.closure().bits(), 0b1111);
}
#[test]
fn a_non_unit_box_gets_its_true_volume() {
let (mut positions, indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
for v in positions.chunks_exact_mut(3) {
v[0] *= 2.0;
v[1] *= 3.0;
v[2] *= 4.0;
}
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
h.add_oriented_mesh(&positions, &indices, [0.0; 3], closed_solid_verdict());
assert_eq!(h.volume(), Some(24.0));
}
#[test]
fn volume_is_translation_invariant_even_far_from_the_origin() {
let (positions, indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
let mut near = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
near.add_oriented_mesh(&positions, &indices, [0.0; 3], closed_solid_verdict());
let mut far = GeometryHasher::new(
DEFAULT_GEOM_HASH_TOLERANCE,
[412_345.678_9, -5_310_987.321_4, 91.234_5],
);
far.add_oriented_mesh(&positions, &indices, [0.0; 3], closed_solid_verdict());
assert_eq!(near.volume(), Some(1.0));
assert_eq!(far.volume(), Some(1.0), "the RTC/world offset must not reach the volume");
}
#[test]
fn an_inward_wound_closed_cube_still_reports_a_positive_volume() {
let (positions, mut indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
for t in indices.chunks_exact_mut(3) {
t.swap(1, 2);
}
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
h.add_oriented_mesh(&positions, &indices, [0.0; 3], closed_solid_verdict());
assert_eq!(h.volume(), Some(1.0));
}
#[test]
fn every_non_solid_verdict_refuses_a_volume() {
let (positions, indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
let cases = [
("open", OrientVerdict { all_closed: false, ..closed_solid_verdict() }),
("non-orientable", OrientVerdict { all_orientable: false, ..closed_solid_verdict() }),
("two components", OrientVerdict { components: 2, ..closed_solid_verdict() }),
("unanalysable", OrientVerdict::INDETERMINATE),
];
for (label, verdict) in cases {
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
h.add_oriented_mesh(&positions, &indices, [0.0; 3], verdict);
assert_eq!(
h.volume(),
None,
"{label}: the geometry is a perfectly ordinary cube, so only the verdict can refuse it"
);
assert_ne!(h.closure().bits(), 0b1111, "{label}: the flags must record the refusal");
}
}
#[test]
fn a_segment_added_without_a_verdict_disarms_the_volume() {
let (positions, indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
h.add_mesh_with_origin(&positions, &indices, [0.0; 3]);
assert_eq!(h.volume(), None);
}
#[test]
fn two_closed_segments_refuse_to_sum() {
let (a_pos, a_idx) = watertight_unit_cube([0.0, 0.0, 0.0]);
let (b_pos, b_idx) = watertight_unit_cube([10.0, 0.0, 0.0]);
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
h.add_oriented_mesh(&a_pos, &a_idx, [0.0; 3], closed_solid_verdict());
h.add_oriented_mesh(&b_pos, &b_idx, [0.0; 3], closed_solid_verdict());
assert_eq!(h.closure().segments, 2);
assert_eq!(
h.volume(),
None,
"even DISJOINT closed segments refuse: nothing here can prove they are disjoint"
);
assert_eq!(h.closure().bits(), 0b0111, "only the exactly-one-segment bit may be clear");
}
#[test]
fn an_empty_segment_does_not_count_against_the_gate() {
let (positions, indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [0.0; 3]);
h.add_oriented_mesh(&[], &[], [0.0; 3], OrientVerdict::INDETERMINATE);
h.add_oriented_mesh(&positions, &indices, [0.0; 3], closed_solid_verdict());
assert_eq!(h.closure().segments, 1);
assert_eq!(h.volume(), Some(1.0));
}
#[test]
fn volume_survives_the_local_frame_fold() {
let (positions, indices) = watertight_unit_cube([0.0, 0.0, 0.0]);
let mut h = GeometryHasher::new(DEFAULT_GEOM_HASH_TOLERANCE, [7.3, -3.7, 11.9]);
h.add_oriented_mesh(&positions, &indices, [100.1, 200.3, 300.7], closed_solid_verdict());
assert_eq!(h.volume(), Some(1.0));
}
#[test]
fn closure_flags_pack_one_bit_per_clause() {
let base = GeometryClosure {
all_closed: true,
all_orientable: true,
all_single_component: true,
segments: 1,
};
assert_eq!(base.bits(), 0b1111);
assert_eq!(GeometryClosure { all_closed: false, ..base }.bits(), 0b1110);
assert_eq!(GeometryClosure { all_orientable: false, ..base }.bits(), 0b1101);
assert_eq!(GeometryClosure { all_single_component: false, ..base }.bits(), 0b1011);
assert_eq!(GeometryClosure { segments: 2, ..base }.bits(), 0b0111);
}
const QUAD: [[f32; 3]; 4] = [
[-95.441_113, 650.0, 4.999_997], [-95.441_113, 895.808_18, 4.999_997], [-64.253_99, 843.158_94, 4.999_997], [-64.253_99, 650.0, 4.999_997], ];
fn quad_positions() -> Vec<f32> {
QUAD.iter().flat_map(|v| *v).collect()
}
#[test]
fn a_quad_split_along_the_other_diagonal_is_the_same_shape() {
let pos = quad_positions();
let ae = [0, 1, 2, 0, 2, 3];
let bd = [0, 1, 3, 1, 2, 3];
assert_eq!(
hash_mesh_world(&pos, &ae, [0.0; 3], TOL),
hash_mesh_world(&pos, &bd, [0.0; 3], TOL),
"re-splitting a flat quad along its other diagonal is not a shape change"
);
}
#[test]
fn a_fan_re_rooted_on_another_corner_is_the_same_shape() {
let pos: Vec<f32> = [
[0.0_f32, 0.0, 2.5],
[4.0, 0.0, 2.5],
[5.0, 3.0, 2.5],
[2.0, 5.0, 2.5],
[-1.0, 3.0, 2.5],
]
.iter()
.flat_map(|v| *v)
.collect();
let from_0 = [0, 1, 2, 0, 2, 3, 0, 3, 4];
let from_2 = [2, 3, 4, 2, 4, 0, 2, 0, 1];
assert_eq!(
hash_mesh_world(&pos, &from_0, [0.0; 3], TOL),
hash_mesh_world(&pos, &from_2, [0.0; 3], TOL),
"re-rooting a fan over the same polygon is not a shape change"
);
}
#[test]
fn an_oblique_polygon_refanned_is_the_same_shape() {
let pos: Vec<f32> = [
[0.0_f32, 0.0, 0.0],
[9.0, -6.0, 0.0],
[12.0, -4.0, -2.0],
[6.0, 4.0, -4.0],
[-3.0, 6.0, -2.0],
]
.iter()
.flat_map(|v| *v)
.collect();
let from_0 = [0, 1, 2, 0, 2, 3, 0, 3, 4];
let from_2 = [2, 3, 4, 2, 4, 0, 2, 0, 1];
assert_eq!(
hash_mesh_world(&pos, &from_0, [0.0; 3], TOL),
hash_mesh_world(&pos, &from_2, [0.0; 3], TOL),
"re-fanning a polygon on a slanted plane is not a shape change"
);
}
fn grid(n: usize, holes: &[(usize, usize)]) -> (Vec<f32>, Vec<u32>) {
let stride = n + 1;
let mut positions = Vec::with_capacity(stride * stride * 3);
for row in 0..stride {
for col in 0..stride {
positions.extend_from_slice(&[col as f32, row as f32, 0.0]);
}
}
let mut indices = Vec::new();
for row in 0..n {
for col in 0..n {
if holes.contains(&(col, row)) {
continue;
}
let a = (row * stride + col) as u32;
let (b, c, d) = (a + 1, a + stride as u32, a + stride as u32 + 1);
indices.extend_from_slice(&[a, b, c, b, d, c]);
}
}
(positions, indices)
}
#[test]
fn removing_triangles_is_still_a_change() {
let (pos, full) = grid(8, &[]);
let (_, cut) = grid(8, &[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6)]);
assert_eq!(full.len() / 3, 128);
assert_eq!(cut.len() / 3, 116);
assert_ne!(
hash_mesh_world(&pos, &full, [0.0; 3], TOL),
hash_mesh_world(&pos, &cut, [0.0; 3], TOL),
"triangles genuinely removed must still register as a change"
);
}
#[test]
fn removing_area_registers_even_when_every_vertex_survives() {
let (pos, full) = grid(4, &[]);
let (_, holed) = grid(4, &[(1, 1)]);
assert_ne!(
hash_mesh_world(&pos, &full, [0.0; 3], TOL),
hash_mesh_world(&pos, &holed, [0.0; 3], TOL),
"a hole punched between surviving vertices is a change"
);
}
#[test]
fn the_same_corners_covering_a_different_area_is_a_change() {
let pos = quad_positions();
let tiled = [0, 1, 2, 0, 2, 3];
let overlapping = [0, 1, 3, 0, 1, 2];
assert_ne!(
hash_mesh_world(&pos, &tiled, [0.0; 3], TOL),
hash_mesh_world(&pos, &overlapping, [0.0; 3], TOL),
"the same corners covering a different area is a shape change"
);
}
#[test]
fn a_coplanar_face_lifted_out_of_its_plane_is_a_change() {
let flat = quad_positions();
let mut folded = flat.clone();
folded[2 * 3 + 2] += 0.5; let idx = [0, 1, 2, 0, 2, 3];
assert_ne!(
hash_mesh_world(&flat, &idx, [0.0; 3], TOL),
hash_mesh_world(&folded, &idx, [0.0; 3], TOL),
"folding a flat face out of plane is a shape change"
);
}
#[test]
fn sliding_a_face_within_its_own_plane_is_a_change() {
let here: Vec<f32> = [[0.0_f32, 0.0, 0.0], [3.0, 0.0, 0.0], [3.0, 2.0, 0.0], [0.0, 2.0, 0.0]]
.iter()
.flat_map(|v| *v)
.collect();
let there: Vec<f32> =
here.chunks_exact(3).flat_map(|c| [c[0] + 10.0, c[1], c[2]]).collect();
let idx = [0, 1, 2, 0, 2, 3];
assert_ne!(
hash_mesh_world(&here, &idx, [0.0; 3], TOL),
hash_mesh_world(&there, &idx, [0.0; 3], TOL),
"the same face slid sideways within its plane is a change"
);
}
#[test]
fn two_faces_at_different_heights_are_not_one_face_of_double_the_area() {
let mut pos = Vec::new();
for &z in &[0.0_f32, 1.0] {
for &(x, y) in &[(0.0_f32, 0.0_f32), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0)] {
pos.extend_from_slice(&[x, y, z]);
}
}
let walls = [0, 1, 5, 0, 5, 4, 2, 3, 7, 2, 7, 6];
let one_each: Vec<u32> =
[&[0, 1, 2, 0, 2, 3][..], &[4, 5, 6, 4, 6, 7][..], &walls[..]].concat();
let both_low: Vec<u32> =
[&[0, 1, 2, 0, 2, 3][..], &[0, 1, 2, 0, 2, 3][..], &walls[..]].concat();
assert_ne!(
hash_mesh_world(&pos, &one_each, [0.0; 3], TOL),
hash_mesh_world(&pos, &both_low, [0.0; 3], TOL),
"a face at each height is not two coincident faces at one height"
);
}
#[test]
fn a_t_junction_vertex_is_still_reported_as_a_change() {
let pos = quad_positions();
let plain = hash_mesh_world(&pos, &[0, 1, 2, 0, 2, 3], [0.0; 3], TOL);
let mut with_mid = pos.clone();
with_mid.extend_from_slice(&[
(QUAD[0][0] + QUAD[1][0]) / 2.0,
(QUAD[0][1] + QUAD[1][1]) / 2.0,
QUAD[0][2],
]);
let split = hash_mesh_world(&with_mid, &[0, 4, 2, 4, 1, 2, 0, 2, 3], [0.0; 3], TOL);
assert_ne!(plain, split, "a new T-junction vertex is outside the invariance");
}