"""Détection des auto-intersections : paires de faces non voisines qui se coupent, contacts compris.""" import numpy as np import pytest from mouh.geometry import mesh from mouh.geometry.mesh import count_self_intersections, validate_mesh CUBE_V = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)] CUBE_F = [(0, 2, 1), (0, 3, 2), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4), (3, 6, 2), (3, 7, 6), (0, 4, 7), (0, 7, 3), (1, 2, 6), (1, 6, 5)] TET_V = [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)] TET_F = [(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)] def shifted(vertices, faces, offset, base): """Copie d'un maillage décalé, indices recalés à partir de ``base`` (aucun sommet partagé).""" v = [(x + offset[0], y + offset[1], z + offset[2]) for x, y, z in vertices] f = [(a + base, b + base, c + base) for a, b, c in faces] return v, f def test_two_disjoint_cubes_do_not_intersect(): v2, f2 = shifted(CUBE_V, CUBE_F, (2, 0, 0), len(CUBE_V)) found = count_self_intersections(np.array(CUBE_V + v2, float), np.array(CUBE_F + f2)) assert found["count"] == 0 and found["tested"] > 0 def test_two_overlapping_closed_cubes_are_flagged_by_validate_mesh(): # Chaque cube est fermé et orienté : l'ancien contrôle (étanchéité, orientation, volume) passerait. v2, f2 = shifted(CUBE_V, CUBE_F, (0.5, 0.5, 0.5), len(CUBE_V)) rep = validate_mesh(CUBE_V + v2, CUBE_F + f2) assert not rep.ok assert rep.stats["watertight"] and rep.stats["oriented"] assert any("auto-intersection" in e for e in rep.errors) assert rep.stats["self_intersections"] > 0 def test_a_triangle_piercing_a_closed_cube_is_flagged(): # Triangle dans le plan x = 0.5 qui traverse le cube unitaire : il coupe les faces z = 0, z = 1, y = 0, y = 1. piercing_v = [(0.5, -1, -1), (0.5, 2, -1), (0.5, 0.5, 2)] found = count_self_intersections(np.array(CUBE_V + piercing_v, float), np.array(CUBE_F + [(8, 9, 10)])) assert found["count"] > 0 # Le triangle ajouté est la face d'indice 12 ; chaque paire trouvée le contient. assert found["sample"] and all(12 in pair for pair in found["sample"]) def test_a_triangle_far_from_the_cube_is_not_flagged(): far_v = [(3, 0, 0), (4, 0, 0), (3, 1, 0)] found = count_self_intersections(np.array(CUBE_V + far_v, float), np.array(CUBE_F + [(8, 9, 10)])) assert found["count"] == 0 def test_coplanar_overlapping_triangles_are_flagged(): v = np.array([(0, 0, 0), (2, 0, 0), (0, 2, 0), (0.5, 0.5, 0), (2.5, 0.5, 0), (0.5, 2.5, 0)], float) f = np.array([(0, 1, 2), (3, 4, 5)]) assert count_self_intersections(v, f)["count"] == 1 def test_coplanar_disjoint_triangles_are_not_flagged(): v = np.array([(0, 0, 0), (2, 0, 0), (0, 2, 0), (5, 0, 0), (6, 0, 0), (5, 1, 0)], float) f = np.array([(0, 1, 2), (3, 4, 5)]) assert count_self_intersections(v, f)["count"] == 0 def test_triangles_sharing_a_vertex_are_not_compared(): # Deux triangles qui partagent un sommet ne sont pas comparés : pas de faux positif sur un maillage sain. v = np.array([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1), (0, -1, 0)], float) f = np.array([(0, 1, 2), (0, 3, 4)]) found = count_self_intersections(v, f) assert found["count"] == 0 and found["tested"] == 0 def test_a_tetrahedron_piercing_another_tetrahedron_is_flagged(): big = np.array([(-2, -2, -2), (4, -2, -2), (-2, 4, -2), (-2, -2, 4)], float) # Le petit tétraèdre sort par la face x + y + z = 0 du grand : ses arêtes percent cette face. small = np.array([(0, 0, 0), (0.2, 0, 0), (0, 0.2, 0), (0, 0, 2.5)], float) v = np.vstack([big, small]) f = np.array([(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3), (4, 6, 5), (4, 5, 7), (4, 7, 6), (5, 6, 7)]) assert count_self_intersections(v, f)["count"] > 0 def test_sample_is_bounded_and_lists_face_indices(): v2, f2 = shifted(CUBE_V, CUBE_F, (0.25, 0.25, 0.25), len(CUBE_V)) found = count_self_intersections(np.array(CUBE_V + v2, float), np.array(CUBE_F + f2)) assert 0 < len(found["sample"]) <= mesh.MAX_SAMPLE assert all(len(pair) == 2 for pair in found["sample"]) def test_dense_mesh_over_budget_is_refused_not_skipped(monkeypatch): v2, f2 = shifted(CUBE_V, CUBE_F, (2, 0, 0), len(CUBE_V)) monkeypatch.setattr(mesh, "MAX_CANDIDATE_PAIRS", 1) rep = validate_mesh(CUBE_V + v2, CUBE_F + f2) assert not rep.ok and any("impossible" in e for e in rep.errors) def test_self_test_is_skipped_when_an_earlier_check_already_fails(): rep = validate_mesh(CUBE_V, CUBE_F[2:]) assert not rep.ok assert rep.stats["self_intersections"] is None @pytest.mark.parametrize("offset", [0.05, 0.3, 0.9]) def test_overlap_depth_does_not_change_the_verdict(offset): v2, f2 = shifted(CUBE_V, CUBE_F, (offset, offset, offset), len(CUBE_V)) assert count_self_intersections(np.array(CUBE_V + v2, float), np.array(CUBE_F + f2))["count"] > 0 def uv_sphere(nlat, nlon, r=1.0, offset=(0.0, 0.0, 0.0)): """Sphère fermée en latitude/longitude, normales sortantes : maillage sain et dense.""" v = [(0.0, 0.0, -r)] for i in range(1, nlat): th = np.pi * i / nlat for j in range(nlon): ph = 2 * np.pi * j / nlon v.append((r * np.sin(th) * np.cos(ph), r * np.sin(th) * np.sin(ph), -r * np.cos(th))) v.append((0.0, 0.0, r)) v = np.array(v, float) + np.array(offset, float) def ring(i, j): return 1 + (i - 1) * nlon + (j % nlon) south, north = 0, len(v) - 1 f = [(south, ring(1, j + 1), ring(1, j)) for j in range(nlon)] for i in range(1, nlat - 1): for j in range(nlon): a, b, c, d = ring(i, j), ring(i, j + 1), ring(i + 1, j), ring(i + 1, j + 1) f += [(a, b, d), (a, d, c)] last = 1 + (nlat - 2) * nlon f += [(north, last + j, last + ((j + 1) % nlon)) for j in range(nlon)] return v, np.array(f, np.int64) def test_dense_closed_sphere_has_no_false_positive(): v, f = uv_sphere(40, 80) # 6 240 faces : aucune intersection réelle rep = validate_mesh(v, f) assert rep.ok, rep.errors assert rep.stats["self_intersections"] == 0 and rep.stats["pairs_tested"] > 1000 def test_two_dense_spheres_that_cross_are_refused(): v1, f1 = uv_sphere(20, 40) v2, f2 = uv_sphere(20, 40, offset=(0.5, 0.0, 0.0)) rep = validate_mesh(np.vstack([v1, v2]), np.vstack([f1, f2 + len(v1)])) assert not rep.ok assert any("auto-intersection" in e for e in rep.errors)