Spaces:
Running
Running
File size: 5,835 Bytes
884b5f8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | """Validation géométrique des maillages et porte d'export vers la file sky-trajectories."""
import json
import pytest
from mouh.geometry.mesh import export_trajectory, gate_trajectory, validate_mesh
# Cube unitaire, faces triangulaires orientées vers l'extérieur (volume signé = +1).
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)]
# Tétraèdre, normales sortantes (volume = 1/6).
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 test_outward_cube_is_accepted_with_unit_volume_and_euler_two():
rep = validate_mesh(CUBE_V, CUBE_F)
assert rep.ok, rep.errors
assert rep.stats["volume"] == pytest.approx(1.0)
assert rep.stats["euler"] == 2 and rep.stats["genus"] == 0
assert rep.stats["watertight"] and rep.stats["oriented"] and rep.stats["manifold"]
# Les faces opposées du cube ne partagent aucun sommet : elles sont comparées, et ne se coupent pas.
assert rep.stats["self_intersections"] == 0 and rep.stats["pairs_tested"] > 0
assert any("voisines" in item for item in rep.to_dict()["not_checked"])
def test_tetrahedron_is_accepted():
rep = validate_mesh(TET_V, TET_F)
assert rep.ok, rep.errors
assert rep.stats["volume"] == pytest.approx(1 / 6)
def test_inward_normals_are_rejected():
rep = validate_mesh(TET_V, [(a, c, b) for a, b, c in TET_F])
assert not rep.ok
assert any("volume signé négatif" in e for e in rep.errors)
def test_open_surface_is_rejected_as_a_hole():
rep = validate_mesh(CUBE_V, CUBE_F[2:]) # on retire le dessus
assert not rep.ok
assert any("arête(s) de bord" in e for e in rep.errors)
def test_open_surface_passes_only_when_closure_is_not_required():
rep = validate_mesh(CUBE_V, CUBE_F[2:], require_closed=False)
assert rep.ok and any("arête(s) de bord" in w for w in rep.warnings)
def test_a_face_flipped_against_its_neighbours_is_rejected():
rep = validate_mesh(CUBE_V, [(0, 1, 2)] + CUBE_F[1:])
assert not rep.ok
assert any("orientation incohérente" in e for e in rep.errors)
def test_three_faces_on_one_edge_are_rejected():
rep = validate_mesh(TET_V + [(0.5, 0.5, -1)], TET_F + [(0, 1, 4)])
assert not rep.ok
assert any("plus de deux faces" in e for e in rep.errors)
def test_two_closed_pieces_touching_at_one_point_are_rejected():
v = TET_V + [(3, 0, 0), (3, 1, 0), (3, 0, 1)]
f = TET_F + [(0, 6, 5), (0, 5, 4), (0, 4, 6), (4, 5, 6)]
rep = validate_mesh(v, f)
assert not rep.ok
assert any("non-variété" in e for e in rep.errors)
def test_non_finite_coordinates_are_rejected():
for bad in (float("nan"), float("inf")):
rep = validate_mesh([(bad, 0, 0)] + TET_V[1:], TET_F)
assert not rep.ok and any("non finies" in e for e in rep.errors)
def test_face_index_out_of_range_is_rejected():
rep = validate_mesh(TET_V, TET_F + [(0, 1, 9)])
assert not rep.ok and any("hors de" in e for e in rep.errors)
def test_face_with_a_repeated_vertex_is_rejected():
rep = validate_mesh(TET_V, TET_F + [(0, 0, 1)])
assert not rep.ok and any("sommet répété" in e for e in rep.errors)
def test_zero_area_sliver_is_rejected():
rep = validate_mesh(TET_V + [(0.5, 0, 0), (0.6, 0, 0), (0.7, 0, 0)], TET_F + [(4, 5, 6)])
assert not rep.ok and any("aire quasi nulle" in e for e in rep.errors)
def test_flat_sheet_is_rejected():
rep = validate_mesh([(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)], [(0, 1, 2), (0, 2, 3), (0, 2, 1), (0, 3, 2)])
assert not rep.ok and any("plate" in e for e in rep.errors)
def test_huge_coordinates_are_rejected():
rep = validate_mesh([(1e5, 0, 0)] + TET_V[1:], TET_F)
assert not rep.ok and any("au-delà" in e for e in rep.errors)
def test_malformed_shapes_and_types_are_rejected():
assert not validate_mesh([[0, 0, 0], [1, 0]], TET_F).ok # sommets irréguliers
assert not validate_mesh([["a", "b", "c"]] * 4, TET_F).ok # texte à la place de nombres
assert not validate_mesh(TET_V, [[0.5, 1.0, 2.0]] * 4).ok # indices non entiers
assert not validate_mesh(TET_V, TET_F[:3]).ok # moins de quatre faces
assert not validate_mesh(None, None).ok
def test_gate_requires_a_dict_and_a_real_instruction():
assert not gate_trajectory("texte").ok
record = {"vertices": CUBE_V, "faces": CUBE_F}
rep = gate_trajectory(record)
assert not rep.ok and any("instruction" in e for e in rep.errors)
record["instruction"] = " "
assert not gate_trajectory(record).ok
record["instruction"] = "Modéliser un cube unitaire"
assert gate_trajectory(record).ok
def test_export_writes_a_valid_geometry_once(tmp_path):
queue = tmp_path / "sky-trajectories"
record = {"vertices": CUBE_V, "faces": CUBE_F, "instruction": "Cube unitaire"}
rep = export_trajectory(record, queue)
assert rep.ok and rep.stats["written"] and len(rep.stats["id"]) == 64
again = export_trajectory(record, queue)
assert again.ok and not again.stats["written"]
assert any("doublon" in w for w in again.warnings)
lines = (queue / "staging.jsonl").read_text(encoding="utf-8").splitlines()
assert len(lines) == 1
entry = json.loads(lines[0])
assert entry["checks"]["watertight"] is True and entry["instruction"] == "Cube unitaire"
def test_a_rejected_trajectory_leaves_no_trace(tmp_path):
queue = tmp_path / "sky-trajectories"
rep = export_trajectory({"vertices": CUBE_V, "faces": CUBE_F[2:], "instruction": "Cube ouvert"}, queue)
assert not rep.ok and not rep.stats["written"]
assert not queue.exists()
|