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