From f903cf71512fcbb52f0cb07a91c2d2ebda31407f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Wed, 4 Nov 2026 10:22:10 +0100 Subject: [PATCH] =?UTF-8?q?picking=20:=20trou=20ferme=20=E2=80=94=20verrou?= =?UTF-8?q?=20une=5Fentite=5Fscalee=5Fest=5Fraycee=5Fa=5Fsa=5Fboite=5Fmond?= =?UTF-8?q?e=5Freelle=20(chemin=20Transform.scale=20->=20espace=20local=20?= =?UTF-8?q?->=20AABB=20->=20BVH,=20traverse=20par=20personne).=20Mesures?= =?UTF-8?q?=20:=20distance=204,5000=20/=20normal=20(-1,0,0)=20/=20local.y?= =?UTF-8?q?=200,400=20=3D=201,2/3.=20Campagne=20mutate-pick-scale.py=20(3?= =?UTF-8?q?=20cas,=200=20ecart).=20Aucun=20defaut=20moteur=20:=20le=20trou?= =?UTF-8?q?=20etait=20l'absence=20d'observation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- campaigns/mutate-pick-scale.py | 91 ++++++++++++++ docs/BANNER-CLAIMS.md | 2 +- .../DRAFT-15.5-bandeaux-par-familles.md | 19 +-- docs/drafts/DRAFT-16-packaging-crate.md | 58 ++++++++- docs/drafts/README.md | 3 +- docs/generated/TEST-SUITE.md | 8 +- lib/src/core/mod.rs | 2 + lib/src/core/pick_scaled_tests.rs | 115 ++++++++++++++++++ 8 files changed, 282 insertions(+), 16 deletions(-) create mode 100644 campaigns/mutate-pick-scale.py create mode 100644 lib/src/core/pick_scaled_tests.rs diff --git a/campaigns/mutate-pick-scale.py b/campaigns/mutate-pick-scale.py new file mode 100644 index 0000000..a1ae5e2 --- /dev/null +++ b/campaigns/mutate-pick-scale.py @@ -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()) diff --git a/docs/BANNER-CLAIMS.md b/docs/BANNER-CLAIMS.md index bf1980b..242c517 100644 --- a/docs/BANNER-CLAIMS.md +++ b/docs/BANNER-CLAIMS.md @@ -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 | diff --git a/docs/drafts/DRAFT-15.5-bandeaux-par-familles.md b/docs/drafts/DRAFT-15.5-bandeaux-par-familles.md index 515917d..a20c51d 100644 --- a/docs/drafts/DRAFT-15.5-bandeaux-par-familles.md +++ b/docs/drafts/DRAFT-15.5-bandeaux-par-familles.md @@ -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. diff --git a/docs/drafts/DRAFT-16-packaging-crate.md b/docs/drafts/DRAFT-16-packaging-crate.md index e539d9e..e9f4b34 100644 --- a/docs/drafts/DRAFT-16-packaging-crate.md +++ b/docs/drafts/DRAFT-16-packaging-crate.md @@ -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 diff --git a/docs/drafts/README.md b/docs/drafts/README.md index 5e1fbc8..081aaa0 100644 --- a/docs/drafts/README.md +++ b/docs/drafts/README.md @@ -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) | diff --git a/docs/generated/TEST-SUITE.md b/docs/generated/TEST-SUITE.md index f6262b8..94cf3d4 100644 --- a/docs/generated/TEST-SUITE.md +++ b/docs/generated/TEST-SUITE.md @@ -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 diff --git a/lib/src/core/mod.rs b/lib/src/core/mod.rs index ef704b7..652ebf9 100644 --- a/lib/src/core/mod.rs +++ b/lib/src/core/mod.rs @@ -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`). diff --git a/lib/src/core/pick_scaled_tests.rs b/lib/src/core/pick_scaled_tests.rs new file mode 100644 index 0000000..b9bf93b --- /dev/null +++ b/lib/src/core/pick_scaled_tests.rs @@ -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 + ); +}