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:
Jérôme Bousquié committed 2026-11-04 10:22:10 +01:00
1 parent dcc5b2451c
commit f903cf7151
8 files changed
+282 -16

No files matched your search

+91
View File
@@ -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())
+1 -1
View File
@@ -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.
+56 -2
View File
@@ -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
+2 -1
View File
@@ -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 -4
View File
@@ -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
+2
View File
@@ -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`).
+115
View File
@@ -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
);
}