TARGET_SPACE / tests /test_geometry.py
RDS777's picture
Upload 115 files
884b5f8 verified
Raw History Blame Contribute Delete
5.84 kB
"""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()