picking : trou ferme — verrou une_entite_scalee_est_raycee_a_sa_boite_monde_reelle (chemin Transform.scale -> espace local -> AABB -> BVH, traverse par personne). Mesures : distance 4,5000 / normal (-1,0,0) / local.y 0,400 = 1,2/3. Campagne mutate-pick-scale.py (3 cas, 0 ecart). Aucun defaut moteur : le trou etait l'absence d'observation.
This commit is contained in:
1 parent
dcc5b2451c
commit
f903cf7151
8 files changed
+282
-16
No files matched your search
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Campagne 15.5 — verrou `une_entite_scalee_est_raycee_a_sa_boite_monde_reelle`.
|
||||
|
||||
Le trou ferme n'etait pas un defaut du moteur mais une absence de couverture : le chemin
|
||||
`Transform.scale` -> espace local -> AABB -> BVH n'etait traverse par personne. Deux facons de
|
||||
casser ce chemin, toutes deux dans la production (`lib/src/scene/scene.rs`, raycast 9.2) :
|
||||
|
||||
- M1 le rayon n'est plus ramene en espace local (inverse remplace par l'identite) ;
|
||||
- M2 l'echelle est retiree de la matrice monde (translation seule).
|
||||
|
||||
Les deux rendent la jambe « cube porte a scale.y = 3, rayon a y = 1,2 » muette ; la jambe de
|
||||
controle (rayon a y = 0) continue de toucher, ce qui est exactement ce qui rend le verrou
|
||||
non decoratif : une seule jambe verte ne suffit pas a le satisfaire.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
SCENE = RACINE / "lib/src/scene/scene.rs"
|
||||
sys.path.insert(0, str(RACINE / "campaigns"))
|
||||
from skipdetect import count_skips
|
||||
|
||||
CIBLE = "une_entite_scalee_est_raycee"
|
||||
|
||||
INV = " let inv = world.inverse();"
|
||||
WORLD = " let world = entity.transform().to_matrix();"
|
||||
|
||||
CAS = {
|
||||
"T0": ("VERT", []),
|
||||
"M1": ("ROUGE", [(INV, " let inv = glam::Mat4::IDENTITY;", 1)]),
|
||||
"M2": ("ROUGE", [(WORLD,
|
||||
" let world = glam::Mat4::from_scale(glam::Vec3::ONE)\n * glam::Mat4::from_translation(entity.transform().translation);", 1)]),
|
||||
}
|
||||
|
||||
|
||||
def lancer() -> str:
|
||||
r = subprocess.run(
|
||||
["cargo", "test", "-q", "-p", "wsg-lib", "--lib", CIBLE, "--", "--test-threads=1"],
|
||||
cwd=RACINE, capture_output=True, text=True, timeout=2400,
|
||||
)
|
||||
return r.stdout + r.stderr
|
||||
|
||||
|
||||
def verdict(sortie: str) -> str:
|
||||
if re.search(r"(?m)^error\[E|could not compile", sortie):
|
||||
return "REFUSE(compilation)"
|
||||
n = count_skips(sortie)
|
||||
if n:
|
||||
return f"REFUSE({n} test(s) skipes)"
|
||||
res = re.findall(r"(?m)^test result: \w+\. (\d+) passed; (\d+) failed", sortie)
|
||||
if not res:
|
||||
return "REFUSE(aucune ligne test result:)"
|
||||
return "ROUGE" if sum(int(f) for _, f in res) else "VERT"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
original = SCENE.read_bytes()
|
||||
ecart = 0
|
||||
for nom, (attendu, paires) in CAS.items():
|
||||
texte = SCENE.read_text()
|
||||
ok = True
|
||||
for ancre, remplacement, attendues in paires:
|
||||
n = texte.count(ancre)
|
||||
if n != attendues:
|
||||
print(f"{nom:3s} attendu={attendu:6s} obtenu=REFUSE(ancre {n} occurrences, attendu {attendues})")
|
||||
ecart += 1
|
||||
ok = False
|
||||
break
|
||||
texte = texte.replace(ancre, remplacement)
|
||||
if not ok:
|
||||
SCENE.write_bytes(original)
|
||||
continue
|
||||
SCENE.write_text(texte)
|
||||
sortie = lancer()
|
||||
v = verdict(sortie)
|
||||
SCENE.write_bytes(original)
|
||||
if v != attendu:
|
||||
ecart += 1
|
||||
ligne = next((l for l in sortie.splitlines() if l.startswith("test result:")), "?")
|
||||
print(f"{nom:3s} attendu={attendu:6s} obtenu={v:8s} | {ligne}")
|
||||
for l in sortie.splitlines():
|
||||
if l.startswith("15.5 rayon") or "panicked at" in l or "le rayon a" in l:
|
||||
print(f" {l.strip()[:180]}")
|
||||
print(f"\n{len(CAS)} cas, {ecart} ecart")
|
||||
return 0 if ecart == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -206,7 +206,7 @@ de carte : c'est une étape, pas un verrou.
|
||||
| spot_test | The lit patch stays FIXED IN THE WORLD | le_spot_eclaire_un_faisceau_directe_et_non_un_pied_de_lumiere |
|
||||
| picking | Click an object and its NAME appears | the_cpu_raycast_and_the_gpu_pick_name_the_same_object_at_the_same_pixel |
|
||||
| picking | a miss must be reported, not silently ignored. | a_pick_on_empty_sky_reports_a_miss_not_a_hit, ray_at_ndc_misses_geometry_outside_the_frustum |
|
||||
| picking | The hit is the object UNDER the cursor | the_cpu_raycast_and_the_gpu_pick_name_the_same_object_at_the_same_pixel, the_pick_reports_the_frontmost_of_two_overlapping_instances |
|
||||
| picking | The hit is the object UNDER the cursor | the_cpu_raycast_and_the_gpu_pick_name_the_same_object_at_the_same_pixel, the_pick_reports_the_frontmost_of_two_overlapping_instances, une_entite_scalee_est_raycee_a_sa_boite_monde_reelle |
|
||||
| picking | the FRONT object wins, not the largest | the_pick_reports_the_frontmost_of_two_overlapping_instances |
|
||||
| picking | Orbiting changes which object is hit | ray_at_ndc_center_follows_the_view_axis, cursor_ray_follows_the_pixel_from_the_centre_to_the_right_edge |
|
||||
| picking | from the CURRENT camera, not from the one at startup. | ray_at_ndc_reads_the_projection_matrix_not_a_recomputed_fov, cursor_ray_direction_matches_the_projection_definition |
|
||||
@@ -392,12 +392,15 @@ sol bas, **119** dans la moitié haute. Campagne `mutate-spot.py` : 4 cas, 0 éc
|
||||
`the_pick_reports_the_frontmost_…`, `the_cpu_raycast_and_the_gpu_pick_name_the_same_object_…`,
|
||||
`ray_at_ndc_*`, `cursor_ray_*`). Cliquets **6 → 5 → 4**.
|
||||
|
||||
**Le trou qui reste, et il est mesuré** : aucun verrou du dépôt ne raycaste une **entité scalée**. Les
|
||||
**Le trou qui restait, et il est fermé** : aucun verrou du dépôt ne raycaste une **entité scalée**. Les
|
||||
verrous de `ray_at_ndc_*` utilisent une coquille (sphère UV) à rayon connu ; `the_cpu_raycast_and_the_gpu_pick_…`
|
||||
utilise des entités à `Transform::scale` identité. La phrase du bandeau « the hit is the object UNDER the
|
||||
cursor » est donc vraie pour les formes testées et **non exercée** pour un solide non unitaire : le chemin
|
||||
`Transform.scale` → AABB monde → BVH n'est traversé par personne. Le verrou à écrire est petit : une
|
||||
entité `cube(1.0)` avec `tf.scale = Vec3::new(1.0, 3.0, 1.0)`, un rayon qui vise un point qui n'est à
|
||||
l'intérieur **que** si l'échelle est appliquée, et un contrôle négatif au même point sur la même entité
|
||||
non scalée. Tant que ce verrou n'existe pas, la couverture du picking ne doit pas être présentée comme
|
||||
complète.
|
||||
utilise des entités à `Transform::scale` identité. Le chemin `Transform.scale` → espace local → AABB → BVH
|
||||
n'était traversé par personne. Fermé par `une_entite_scalee_est_raycee_a_sa_boite_monde_reelle`
|
||||
(`lib/src/core/pick_scaled_tests.rs`) : un `cube(1.0)` porté à `scale.y = 3` et un rayon horizontal à
|
||||
`y = 1,2`, hauteur comprise dans la boîte scalée et hors de la boîte non scalée. Mesures 2026-11-03 :
|
||||
`distance = 4,5000`, `point = (−0,500 ; 1,200 ; 0,000)`, `normal = (−1,000 ; 0 ; 0)`,
|
||||
`local = (−0,500 ; 0,400 ; 0,000)` — le `local_point` à 0,4 prouve que l'échelle est bien inversée (le champ
|
||||
est documenté pour le lookup UV). Campagne `mutate-pick-scale.py` : 3 cas, 0 écart — M1 la matrice inverse
|
||||
remplacée par l'identité, M2 l'échelle retirée de la matrice monde ; les deux rendent la jambe scalée
|
||||
muette pendant que le contrôle à `y = 0` continue de toucher. **Rien n'était cassé dans le moteur** : la
|
||||
mesure a confirmé que l'échelle est traitée ; ce qui manquait était l'observation.
|
||||
@@ -162,8 +162,62 @@ Ce sont des documents **verrouillés** : les modifier sans reposer le cliquet re
|
||||
| `lib/src/shaders/README.md`, `magenta_test.wgsl` | `magenta_test.wgsl` est un shader **de test** dans la source packagée, utilisé par `the_custom_shader_contract_is_documented` et par la campagne du contrat | soit il déménage (mais alors `include_str!("../src/shaders/magenta_test.wgsl")` casse), soit il est assumé comme partie de la source livrée. À trancher — un artefact de test embarqué dans l'API doit être nommé comme tel. |
|
||||
| `lib/Cargo.toml` | `[package]` sans métadonnées, `[features]`, 50 `[[example]]` | `description`, `license`, `repository`, `keywords`, `categories`, `rust-version`, `exclude`, `readme` **ou** rien. Les `[[example]]` ne bougent pas. |
|
||||
|
||||
## 8. Ordre de travail
|
||||
## 8. Prérequis : la réorganisation se fait dans une branche dédiée
|
||||
|
||||
Ce n'est pas une question de confort : **le plan change des chemins**, et trois verrous vivent sur des chemins —
|
||||
`repo_root()` (le `parent()` de `CARGO_MANIFEST_DIR`), `crate_root()`, `testutil::rs_files_under`. Un test qui
|
||||
ne trouve plus son ancrage ne peut pas conclure (gotcha 13.6, quatrième occurrence de la famille). Donc la
|
||||
branche sert à une seule chose : **pouvoir distinguer « la réorganisation a bougé » de « une régression est
|
||||
arrivée »** — sur `main`, ces deux états se ressemblent.
|
||||
|
||||
**La baseline se prend AVANT la branche, et elle est commitée** : `cargo package --list` sur l'arbre actuel, les
|
||||
valeurs de cliquets (17, 190), les bornes de non-vacuité (200 pour les liens, 4 / 5 / 15 pour le PLAN), et le T0
|
||||
de la campagne de la frontière. Sur la branche, chaque changement est confronté à cette baseline, **pas au
|
||||
souvenir** — l'inventaire remplace le souvenir, et c'est le souvenir qui perd (cinq fois).
|
||||
|
||||
**Un cliquet qui change sur la branche est reposé à la mesure, jamais pour repasser au vert** (12.15, 13.14 :
|
||||
un cliquet ne verrouille pas ce qui le fait *descendre*). Les deux bornes mesurées restent citées dans le
|
||||
commentaire du test — et c'est précisément ce que la branche rend possible : la baseline et la dégradation sont
|
||||
mesurées dans le même arbre, au même état.
|
||||
|
||||
Discipline de la branche, héritée des défauts déjà payés ici :
|
||||
|
||||
- **commiter avant de muter** (6.19) : un script de campagne qui restaure par `git checkout` restaure le HEAD,
|
||||
pas l'état d'il y a cinq minutes. C'est ainsi qu'une implémentation entière a été effacée, puis qu'un commit a
|
||||
décrit des renforcements de tests qui n'étaient plus dans le fichier (12.13). Une campagne ne restaure **que
|
||||
ce qu'elle a muté** — donc `git switch` ne doit jamais remplacer une restauration ciblée pendant une campagne.
|
||||
- **pas de commit avec la suite rouge** : `cargo test … | grep` commite dès que le motif est trouvé, que les
|
||||
tests aient réussi ou non (15.2, mesuré — c'est ainsi qu'un commit a été écrit avec `doc_consistency` rouge).
|
||||
Le verdict vient des compteurs : `./run-tests-honest.sh`, pas d'une sous-chaîne.
|
||||
- **un commit par décision** (D1 → D7), chacun portant sa mesure. Un seul gros commit rend impossible la question
|
||||
« quel nombre justifie quelle décision » — ce plan est une suite de décisions, pas une mutation.
|
||||
- **la branche ne supprime rien** : elle ne change que `lib/Cargo.toml` (métadonnées + `exclude`), les ancres de
|
||||
chemins, et les fichiers que le plan déplace. Tout le corpus (`docs/`, `campaigns/`, `lib/tests/`, les exemples
|
||||
et les assets) reste dans le dépôt. Ce que la branche retire est uniquement ce que **le tarball** contient, et
|
||||
ça se prouve par la liste, pas par l'intention.
|
||||
- **la campagne de la frontière est commitée sur la branche, avec son T0 et ses cas VERTS consignés**. Un verdict
|
||||
consigné dans un récit n'est pas un état du dépôt (13.6 ; l'en-tête du ROADMAP met en garde : une ligne porte
|
||||
un état) — sans script commité, un repreneur ne peut pas vérifier ce que le draft affirme.
|
||||
- **la branche de refus doit être vue en action** avant d'être présentée comme une garantie (mesuré 2026‑11‑02,
|
||||
la sonde qui injecte une ligne de skip construite à l'exécution). Sur la branche, des verrous peuvent se
|
||||
trouver devant un corpus amputé : « passe parce que vide » est le mode de défaillance le plus probable
|
||||
(13.9, huitième occurrence).
|
||||
- **le merge ferme la branche par la mesure, pas par le statut** : si une ligne d'étape est créée au moment du
|
||||
merge, elle est soumise aux règles D et E de `roadmap_consistency` — et le PLAN doit la refléter
|
||||
(`plan_agrees_with_the_roadmap`). Tant que les verrous ne sont pas joués, le statut est 🔶, pas ✅.
|
||||
|
||||
Ce que la branche **ne fait pas** : elle ne rend pas le paquet vérifiable par la suite. Un instrument ne peut pas
|
||||
packager lui‑même ce qu'il vérifie. Ce que la branche donne, c'est la possibilité de **revenir en arrière** sur
|
||||
une décision dont la mesure se révèle fausse — et dans ce dépôt, plusieurs mesures ont invalidé leur propre
|
||||
hypothèse après coup (14‑F6 D8b, 15.1, 6.12). La réversibilité est donc une exigence du plan, pas un détail
|
||||
d'organisation.
|
||||
|
||||
Nom proposé : `packaging-crate`. Une branche par itération, fermée par le merge du draft et de sa campagne.
|
||||
|
||||
## 9. Ordre de travail
|
||||
|
||||
0. **Baseline commitée sur `main`** (la liste du § 8), puis `git switch -c packaging-crate` — la branche ne se
|
||||
crée qu'après la baseline, sinon les deux ne sont plus mesurées dans le même état.
|
||||
1. **`cargo package --list` depuis `lib/`** — la seule autorité sur le contenu réel. Rien de ce draft ne vaut
|
||||
avant cette liste.
|
||||
2. Inventaire des fichiers **lus par les verrous** (`docs/`, `campaigns/`, `README*`, `assets/`,
|
||||
@@ -176,7 +230,7 @@ Ce sont des documents **verrouillés** : les modifier sans reposer le cliquet re
|
||||
5. `cargo publish --dry-run` sur un arbre **propre**. Dans ce dépôt, `--allow-dirty` est une régression de
|
||||
méthode, pas une commodité (6.19 : committer avant de muter).
|
||||
|
||||
## 9. Ce qui ne bouge pas
|
||||
## 10. Ce qui ne bouge pas
|
||||
|
||||
`[profile.release]` (`panic = "abort"`, `strip`, `lto = "fat"`) : au workspace root, hors paquet, et son
|
||||
commentaire dit déjà exactement ce qu'il coûte et à qui il s'applique — c'est l'un des rares endroits où la
|
||||
|
||||
@@ -41,7 +41,7 @@ Règle verrouillée (`archived_drafts_are_reachable_from_the_index`, `lib/tests/
|
||||
| 15.2 | ✅ | `DRAFT-15.2-reduction-du-roadmap.md` | Le format cible et les mesures **des deux côtés** de la réduction (101 → 0 lien, 8 739 → 321 caractères, 63 → 0, 47 → 0, 112 → 0). Le récit des trois fois où le réducteur **se reliait lui-même**, et ce qu'un cliquet posé sur la mesure d'un instrument faux verrouille en vrai. |
|
||||
| 15.3 | ✅ | `DRAFT-15.3-bandeau-fog-mesure.md` | La promesse « Press 4 and the fog VANISHES » confrontée à la scène de l'exemple : facteur de brouillard reconstruit au texel, et les trois fautes d'instrument trouvées (seuil inventé, dénominateur écrêté, sonde occultée par le cube précédent). |
|
||||
| 15.5 | 🔶 | `DRAFT-15.5-bandeaux-par-familles.md` | Le carnet de la file des 22 promesses de bandeau sans verrou : table exemple → phrases → porteur, famille par famille, et la piste de défaut du raycast sur entité scalée. 🔶 tant que la file n'est pas fermée — le statut est l'état de l'étape, pas celui du document. |
|
||||
| 16 | ⬜ | `DRAFT-16-packaging-crate.md` | La frontière du paquet : le package root est `lib/`, donc `docs/` ne peut pas être packagé (`exclude` ne traverse pas la frontière). L'invariant d'**auto-suffisance** — un fichier packagé ne lit que des fichiers packagés — et l'inventaire des verrous qui ne peuvent plus conclure sur le tarball. Le seul exclus nécessaire est `examples/assets/` (18 Mo) ; les tests, eux, coûtent rien à un consommateur. |
|
||||
| 16 | ⬜ | `DRAFT-16-packaging-crate.md` | La frontière du paquet : le package root est `lib/`, donc `docs/` ne peut pas être packagé (`exclude` ne traverse pas la frontière). L'invariant d'**auto-suffisance** — un fichier packagé ne lit que des fichiers packagés — et l'inventaire des verrous qui ne peuvent plus conclure sur le tarball. Le seul exclus nécessaire est `examples/assets/` (18 Mo) ; les tests, eux, coûtent rien à un consommateur. Et la baseline (liste du paquet + cliquets) est commitée **avant** la branche `packaging-crate` : un cliquet reposé sur la branche ne peut être distingué d'une régression que si l'état d'avant est mesuré. |
|
||||
| P1 | ✅ | `DRAFT-P1-roadmap-archive.md` | Récit des étapes de la phase, déplacé du ROADMAP par la réduction 15.2 (une ligne par phase, jamais supprimé). |
|
||||
| P2 | ✅ | `DRAFT-P2-roadmap-archive.md` | Idem — phase 2. |
|
||||
| P3 | ✅ | `DRAFT-P3-roadmap-archive.md` | Idem — phase 3. |
|
||||
@@ -136,6 +136,7 @@ recomptement, pas comme un fait, et la liste des 4 est **nommée** (une liste se
|
||||
| `campaigns/mutate-bloom.py` | les deux verrous neufs `bloom` : extraction vide, seuil forcé à 0 (le scénario « threshold far too low » du bandeau), post-process jamais construit | `lib/src/core/bloom_tests.rs` (4 cas, 0 écart — M1 et M3 font tomber **les deux** verrous ; M2 ne fait tomber **que** le verrou des coins : le verrou géométrique du halo reste vert car un seuil à 0 élargit encore le débordement — angle de couverture consigné, avec l'amplitude `intensity` qui n'est portée par aucun des deux) |
|
||||
| `campaigns/mutate-sky-msaa.py` | les trois verrous neufs `skybox` / `msaa` de la séance 15.5 | `lib/src/core/sky_tests.rs`, `lib/src/core/msaa_tests.rs` (4 cas, 0 écart — M1 `set_skybox` ignore son argument : seul le verrou du fond tombe ; M2 forcer 1 échantillon MSAA : **REFUSÉ par validation wgpu même après mutation de quatre sites** — le `sample_count` est une convention répartie sur ≥ 5 endroits, donc les deux verrous de `core::msaa_tests` n'ont pas encore de cas qui prouve qu'ils ne sont pas des décors (écart consigné) ; M3 seul `main_sample_count` muté : **VERT, mutation inerte** — ce champ ne décide pas de l'échantillonnage) |
|
||||
| `campaigns/mutate-doc-contract.py` | — | AGENTS.md |
|
||||
| `campaigns/mutate-pick-scale.py` | le verrou `une_entite_scalee_est_raycee_a_sa_boite_monde_reelle` — chemin `Transform.scale` → espace local → AABB → BVH, que **aucun** verrou ne traversait | `lib/src/scene/scene.rs` (3 cas, 0 écart — M1 `world.inverse()` remplacé par l'identité, M2 échelle retirée de la matrice monde : les deux rendent muette la jambe « cube porté à `scale.y = 3`, rayon à `y = 1,2` » tandis que le contrôle à `y = 0` continue de toucher. Mesures propres : `distance = 4,5000`, `point = (−0,500 ; 1,200 ; 0)`, `normal = (−1,000 ; 0 ; 0)`, `local = (−0,500 ; 0,400 ; 0)` — le `local_point` à 0,4 = 1,2 / 3 est la preuve que l'échelle est inversée. **Aucun défaut moteur n'a été trouvé** : le trou était l'absence d'observation) |
|
||||
| `campaigns/mutate-spot.py` | le verrou GPU `le_spot_eclaire_un_faisceau_directe_et_non_un_pied_de_lumiere` (la famille `spot` n'avait **aucun** verrou de rendu : seulement l’empaquetage CPU de la table et l’alignement de l’axe) | `lib/src/core/batch_gpu_tests.rs` (4 cas, 0 écart — M1 le cône ignore son axe : rapport centre/bord **2,94**, identique au contrôle ponctuelle, et le bord passe de 0 à 62 ; M2 le facteur de cône devient un seuil dur : le rapport reste à **182,00** mais la bande de texels intermédiaires tombe de **19 à 6** — seule la jambe de pénombre mord ; M3 l’axe retourné côté scène : `centre=0`. Deux remarques : le calcul du cône vit à **deux endroits** du shader standard (les deux entrées fragment — muter un seul site ne fait pas rougir le verrou, famille 6.7), et le seuil de pénombre est calibré sur les deux bornes mesurées 19 / 6) |
|
||||
| `campaigns/mutate-instances.py` | le verrou `basculer_entites_contre_lot_ne_change_que_le_nombre_de_draws` (draws 1 contre N, contrôles positifs dimensionnés, identité d’image à 0 texel) | `lib/src/core/batch_gpu_tests.rs` (4 cas, 0 écart — M1 la voie lot forcée vers les entités : `draws lot=24`, ROUGE ; M2 scène sortie du cadre : les deux voies ne peignent **plus un seul texel** (`px vus lot=0 entites=0`) et **les images restent identiques à 19 200 / 0** — ce qui fait rougir est le contrôle positif, pas l’identité ; M3 teinte différente selon la voie : 11 260 texels différents, ROUGE. Mesure propre : 1 draw contre 24, 11 260 texels vus dans les deux voies, **19 200 texels identiques, 0 différent** — l’instancing rend l’image au bit près) |
|
||||
| `campaigns/mutate-repetition.py` | les deux verrous neufs de `repetition_break` : localité par groupe dans l'exemple, et comparaison exacte du mode dans le shader (gotcha 6.26) | `lib/src/resources/material_tint_tests.rs` (5 cas, 0 écart — M1 `rmode == 1u \|\| rmode == 3u` → `rmode >= 1u` : ROUGE, et **deux occurrences mutées** — la porte vit dans les DEUX entrées fragment, famille 6.7 ; M2 le groupe du milieu renommé `left_` (6 sites) : ROUGE, le témoin est détruit ; M3 `flags_right: 3` : ROUGE, la droite n'est plus « noise ALONE » ; M4 la bascule du milieu remplacée par un armement constant : ROUGE, « Press 1 again returns to raw repetition » devient faux) |
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
# Suite de tests — mesurée, pas souvenir
|
||||
|
||||
- Généré le : 2026-11-04 10:03 +0100
|
||||
- Généré le : 2026-11-04 10:22 +0100
|
||||
- Périmètre : `cargo test --workspace --no-fail-fast --features debug-bbox,debug-wireframe,stats,import-obj,import-stl -- --nocapture --test-threads=2`
|
||||
- Verdict : **VERT HONNÊTE : 939 tests exécutés, 0 skip, 9 doc-tests ignorés.**
|
||||
- Verdict : **VERT HONNÊTE : 940 tests exécutés, 0 skip, 9 doc-tests ignorés.**
|
||||
- Lignes de skip (les trois formes `skip …`, `[skip] …`, `SKIP …`, lues par `campaigns/skipdetect.py` : tests GPU qui n'ont pas observé, donc comptés `passed` par le harnais) : **0**
|
||||
|
||||
| Binaire de test | passed | failed | ignored |
|
||||
|---|---:|---:|---:|
|
||||
| `unittests src/lib.rs` | 828 | 0 | 0 |
|
||||
| `unittests src/lib.rs` | 829 | 0 | 0 |
|
||||
| `tests/doc_consistency.rs` | 24 | 0 | 0 |
|
||||
| `tests/example_banners.rs` | 9 | 0 | 0 |
|
||||
| `tests/roadmap_consistency.rs` | 14 | 0 | 0 |
|
||||
@@ -19,7 +19,7 @@
|
||||
| `tests/texture_procedural.rs` | 4 | 0 | 0 |
|
||||
| `tests/wgsl_validate.rs` | 49 | 0 | 0 |
|
||||
| `Doc-tests wsg_lib` | 9 | 0 | 9 |
|
||||
| **Total (somme des lignes ci-dessus)** | **939** | **0** | **9** |
|
||||
| **Total (somme des lignes ci-dessus)** | **940** | **0** | **9** |
|
||||
|
||||
Ce fichier est **régénéré** à chaque exécution de `./run-tests-honest.sh` : un nombre qui y
|
||||
figure a été produit par les compteurs du harnais, pas recopié d'un souvenir. Ailleurs dans les
|
||||
|
||||
@@ -56,6 +56,8 @@ pub mod particles;
|
||||
pub mod pick;
|
||||
#[cfg(test)]
|
||||
mod pick_tests;
|
||||
#[cfg(test)]
|
||||
mod pick_scaled_tests;
|
||||
/// Banc P5 piste #1 (`poll(Wait)` contre `poll(Poll)`).
|
||||
/// `#[cfg(test)]` : ce module ne doit jamais entrer dans le binaire de release (verrou
|
||||
/// `every_test_is_masked_from_the_release_build`).
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Verrou 15.5 — le chemin `Transform.scale` → AABB monde → BVH n'était traversé par **aucun**
|
||||
//! verrou du dépôt. Constat mesuré avant d'écrire celui-ci : les verrous `ray_at_ndc_*` travaillent
|
||||
//! sur une coquille à rayon connu (9.4) et `the_cpu_raycast_and_the_gpu_pick_name_the_same_object_at_the_same_pixel`
|
||||
//! (13.6) utilise des entités d'échelle identité. La phrase du bandeau de `picking` — « the hit is the
|
||||
//! object UNDER the cursor » — était donc vraie pour les formes testées et **non exercée** pour un
|
||||
//! solide non unitaire.
|
||||
//!
|
||||
//! La scène est construite pour que la seule différence entre les deux jambes soit l'échelle : un rayon
|
||||
//! à hauteur `y = 1,2` traverse la boîte d'un `cube(1.0)` porté à `scale.y = 3` (y ∈ [−1,5 ; 1,5]) et
|
||||
//! rate le même cube non scalé (y ∈ [−0,5 ; 0,5]). Si l'AABB monde était calculé depuis le mesh sans
|
||||
//! l'échelle, la jambe « haute » répondrait `None` ; s'il était calculé sans la translation, la jambe
|
||||
//! « basse » répondrait à tort.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn une_entite_scalee_est_raycee_a_sa_boite_monde_reelle() {
|
||||
let Ok(context) = pollster::block_on(crate::core::Context::new_headless()) else {
|
||||
eprintln!("skip : aucun GPU headless");
|
||||
return;
|
||||
};
|
||||
|
||||
let construis = |haute: bool| -> crate::scene::Scene {
|
||||
let mut scene = crate::scene::Scene::new();
|
||||
scene.init_gpu(
|
||||
Arc::new(context.device.clone()),
|
||||
context.queue.clone(),
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
1,
|
||||
);
|
||||
scene.add_material_shader("mat", "standard").unwrap();
|
||||
scene
|
||||
.create_mesh(
|
||||
"cube",
|
||||
crate::mesh::primitives::cube(1.0),
|
||||
Some("mat"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut tf = crate::core::Transform::identity();
|
||||
if haute {
|
||||
tf.scale = glam::Vec3::new(1.0, 3.0, 1.0);
|
||||
}
|
||||
scene.add_entity_with_transform("tour", "cube", tf).unwrap();
|
||||
scene
|
||||
};
|
||||
|
||||
// Rayon horizontal à y = 1,2 — entre la demi-hauteur du cube non scalé (0,5) et celle du cube
|
||||
// scalé (1,5). Origine à x = −5, le cube est centré sur l'origine : première face à x = −0,5.
|
||||
let o = glam::Vec3::new(-5.0, 1.2, 0.0);
|
||||
let d = glam::Vec3::new(1.0, 0.0, 0.0);
|
||||
|
||||
let haut = construis(true);
|
||||
let bas = construis(false);
|
||||
|
||||
let hit_haut = haut
|
||||
.raycast_max(o, d, 20.0)
|
||||
.expect("le rayon à y = 1,2 ne touche pas le cube porté à scale.y = 3 : l'AABB monde ignore l'échelle de la transformation — le bandeau « the hit is the object UNDER the cursor » est faux pour tout solide non unitaire");
|
||||
eprintln!(
|
||||
"15.5 rayon scale : entite={} distance={:.4} point=({:.3},{:.3},{:.3}) normal=({:.3},{:.3},{:.3}) local=({:.3},{:.3},{:.3})",
|
||||
hit_haut.entity,
|
||||
hit_haut.distance,
|
||||
hit_haut.point.x,
|
||||
hit_haut.point.y,
|
||||
hit_haut.point.z,
|
||||
hit_haut.normal.x,
|
||||
hit_haut.normal.y,
|
||||
hit_haut.normal.z,
|
||||
hit_haut.local_point.x,
|
||||
hit_haut.local_point.y,
|
||||
hit_haut.local_point.z,
|
||||
);
|
||||
|
||||
assert_eq!(hit_haut.entity, "tour");
|
||||
// Le point d'attaque est sur la face −X, à la hauteur demandée.
|
||||
assert!(
|
||||
(hit_haut.point.y - 1.2).abs() < 1e-4,
|
||||
"le point d'intersection vaut y = {} alors que le rayon ne varie pas en y",
|
||||
hit_haut.point.y
|
||||
);
|
||||
assert!(
|
||||
(hit_haut.distance - 4.5).abs() < 1e-3,
|
||||
"distance = {} au lieu de 4,5 (x de −5 à la face à −0,5)",
|
||||
hit_haut.distance
|
||||
);
|
||||
assert!(
|
||||
(hit_haut.normal - glam::Vec3::NEG_X).length() < 1e-3,
|
||||
"normale rapportée = {:?} au lieu de (−1,0,0) — la normale vient du BVH scalé sans être retransformée (famille 9.4)",
|
||||
hit_haut.normal
|
||||
);
|
||||
|
||||
// Le champ `local_point` est documenté pour le lookup UV : il ne vaut 0,4 que si l'échelle a
|
||||
// été inversée (1,2 / 3). Mesure 2026-11-03 : local = (−0,500 ; 0,400 ; 0,000).
|
||||
assert!(
|
||||
(hit_haut.local_point.y - 0.4).abs() < 1e-3,
|
||||
"local_point.y = {} au lieu de 0,4 (= 1,2 / scale.y) : le point local n'est pas retransformé, donc tout lookup UV sur une entité scalée est faux",
|
||||
hit_haut.local_point.y
|
||||
);
|
||||
|
||||
// Contrôle négatif dimensionné : à la même hauteur, le cube non scalé n'est pas atteignable.
|
||||
assert!(
|
||||
bas.raycast_max(o, d, 20.0).is_none(),
|
||||
"le rayon à y = 1,2 touche le cube non scalé : la jambe négative ne mesure plus l'échelle — les deux jambes répondraient pareil quel que soit le traitement de `scale`"
|
||||
);
|
||||
|
||||
// Contrôle positif de la jambe négative : au centre (y = 0), le cube non scalé est bien rayçable.
|
||||
let centre = bas
|
||||
.raycast_max(glam::Vec3::new(-5.0, 0.0, 0.0), d, 20.0)
|
||||
.expect("le rayon à y = 0 ne touche pas le cube non scalé : la scène de contrôle ne rayçable pas du tout, le contrôle négatif ci-dessus ne dirait rien");
|
||||
assert_eq!(centre.entity, "tour");
|
||||
assert!(
|
||||
(centre.distance - 4.5).abs() < 1e-3,
|
||||
"jambe de contrôle : distance = {} au lieu de 4,5",
|
||||
centre.distance
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user