"""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()