feat(opstack B4): port Columns (4d), Domes (4g), Pinch (4h), FloorBias + Column op in the test pool

STAGE B group 4 of 5 -- and with it, ALL TWELVE detail modifiers are ported. 19 ops:
  ConstantRock -> RoomGraph -> SdfCarve -> Roughness -> Terrace -> LayerLines -> Ribbing
  -> Overhang -> Cliff -> Scallop -> Arch -> RoomColumn -> Dome -> Pinch -> FloorBias
  -> Worms -> [structural x3]
Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. What is missing is C1,
the per-room op override -- which adds NO operator, it changes what eleven of them READ.

TRANSCRIBED
- FRoomColumnMod (4d): walks SDFCache.Columns. Not FGridColumnMod -- that one rolls cylinders on a
  world grid for FlatPlain/CrystalChamber; this one iterates a per-room bake.
- FDomeMod (4g): upward half-ellipsoid, up to 2 per room, capped at 0.85 * room radius.
- FPinchMod (4h): perimeter-anchored ellipsoid with the SideFactor term that leaves the passage
  axis clear.
- FFloorBiasMod: quadratic fill below room centre, only in definite air. LAST in the chain and not
  interchangeable -- it exists to undo what roughness (4b) did to floors. That is why the operator
  order in BuildTunnelNetworkStack is the original's order line for line.

THE TRAP IN THIS GROUP, WHICH IS THE PIT LESSON A THIRD TIME
Columns have NO strate parameter. The per-voxel loop has no `if (ColumnDensity > 0)`, and the bake
reads OpParams (a FRESH struct with only the room's op applied), so FStrateGenerationParams::
ColumnDensity is read by nothing. Consequences, all three of which shaped this commit:
  1. The `ColumnDensity = 0` line in the old DisableStageBModifiers was inert. It was never what
     kept columns off; the absence of a Column op in the pool was.
  2. No FeatureProbes entry can cover columns -- there is no param to switch off. And a second
     stack built on a pool without the Column op would SHARE the thread_local SDF cache (the pool
     is not in its key) and return identical densities, i.e. it would lie. Exactly the trap that
     was documented for pits.
  3. So the stage-A guard that ERRORED on TotalColumns > 0 is inverted in this same commit: it now
     REQUIRES TotalColumns > 0. Asking the bake what it baked is the only sound check here.

DisableStageBModifiers is deleted rather than left empty: its emptiness is the measure of stage B
being complete, and an empty function still being called is how a step gets forgotten.

TEST (same commit): op count 15 -> 19; MakeShaftOpPool renamed MakeRoomOpPool and given a third
entry (Column, Probability 1.0 on all three so every room draws one of the three evenly); three
more param probes (dome/pinch/floor bias); the column guard inverted.

⚠️ THE POOL MUST STAY Pit/Chimney/Column UNTIL C1. Eleven of the twelve modifiers read the per-room
param copy in the original and the strate params here; those agree only while no room carries a
DETAIL-type op, and ApplyTo(Pit/Chimney/Column) writes only pit/chimney/column fields. A Terrace
entry would break the equivalence -- which is precisely the test C1 adds, and the only possible
proof that the override works.

IF THIS GROUP IS WRONG: dome/pinch/arch diffs land inside open cave near room centres and
perimeters; a column error shows up as a ring of diffs at fixed XY through the whole room height.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 03:31:55 +02:00
parent 6ec60099d6
commit b063d437c1
3 changed files with 432 additions and 71 deletions
@@ -1,9 +1,11 @@
// VoxelForgeOpStackTunnelTest.cpp // VoxelForgeOpStackTunnelTest.cpp
// TunnelNetwork — ÉTAPE A (squelette SDF) + ÉTAPES B1B3 (huit modificateurs de détail, 4b4c). // TunnelNetwork — ÉTAPE A (squelette SDF) + ÉTAPE B COMPLÈTE (les douze modificateurs, 4b4h).
// TunnelNetwork — STAGE A (the SDF spine) + STAGES B1-B3 (eight detail modifiers, 4b-4c). // TunnelNetwork — STAGE A (the SDF spine) + ALL OF STAGE B (the twelve detail modifiers, 4b-4h).
// //
// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE // POURQUOI UN TEST D'UNE PILE INCOMPLÈTE
// `GetDensityWithParams` fait ~1080 lignes et treize modificateurs de détail. Tout porter avant de // `GetDensityWithParams` fait ~1080 lignes et douze modificateurs de détail (le chiffre « treize »
// traînait dans les notes ; il y en a douze, et onze seulement lisent la copie de params par salle).
// Tout porter avant de
// pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées — // pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées —
// exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite. // exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite.
// //
@@ -15,17 +17,22 @@
// Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise // Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise
// dans l'autre sens. // dans l'autre sens.
// //
// L'ÉTAPE B REMONTE CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté sort // L'ÉTAPE B A REMONTÉ CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté sortait
// de `DisableStageBModifiers` et entre dans `EnableTunnelFeatures`, avec (i) une sonde de couverture // de la liste des amplitudes éteintes pour entrer dans `EnableTunnelFeatures`, avec (i) une sonde de couverture
// qui prouve qu'il a réellement bougé quelque chose et (ii) le compte d'ops de la pile qui augmente. // qui prouve qu'il a réellement bougé quelque chose et (ii) le compte d'ops de la pile qui augmente.
// • B1 : rugosité de paroi, STEP 4b. // • B1 : rugosité de paroi, STEP 4b.
// • B2 : terrasses, lignes de strates, nervures — STEP 4c. // • B2 : terrasses, lignes de strates, nervures — STEP 4c.
// • B3 (ce commit) : surplombs, falaise, festons, arches — STEP 4c. // • B3 : surplombs, falaise, festons, arches — STEP 4c.
// • B4 (ce commit) : colonnes (4d), dômes (4g), pincement (4h), biais de sol. La liste des
// amplitudes éteintes est vide : **l'étape B est complète**.
// //
// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les quatre modificateurs restants (colonnes, // CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur l'override d'op PAR SALLE — onze des douze
// dômes, pincement, biais de sol), rien sur l'override d'op par salle, et rien sur le saut de // modificateurs lisent, dans l'original, une copie des params où l'op de la salle la plus proche a
// tuile — `FRoomGraphSource::EffectOverBox` rend `Both`, donc aucun verdict n'est prouvable à ce // été appliqué, et la pile lit les params de la strate. Les deux coïncident **uniquement** parce que
// stade. Ce sont les étapes B4, B5 et C. // le pool du test ne contient que Pit / Chimney / Column, dont les `ApplyTo` n'écrivent aucun champ
// de détail. Un op `Terrace` dans ce pool casserait l'équivalence : c'est exactement le test que C1
// ajoutera, et c'est la seule preuve possible de l'override. Rien non plus sur le saut de tuile —
// `FRoomGraphSource::EffectOverBox` rend `Both`.
// //
// ⚠️ ÉCHANTILLONNAGE PAR GRAPPES, PAS UNIFORME. Le cache SDF se reconstruit quand la requête sort de // ⚠️ ÉCHANTILLONNAGE PAR GRAPPES, PAS UNIFORME. Le cache SDF se reconstruit quand la requête sort de
// sa boîte de recherche ; 20 000 points uniformément aléatoires feraient ~20 000 `BuildChunkCache` // sa boîte de recherche ; 20 000 points uniformément aléatoires feraient ~20 000 `BuildChunkCache`
@@ -57,23 +64,21 @@ namespace
constexpr int32 PointsPerChunk = 250; constexpr int32 PointsPerChunk = 250;
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk; constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
/** //=========================================================================
* Met à zéro tout ce qui n'est PAS ENCORE porté, pour que l'original prenne le même chemin. // ⚠️ `DisableStageBModifiers` A DISPARU, ET SA DISPARITION EST LE RÉSULTAT DE L'ÉTAPE B
* Ces champs-là sont déjà à zéro par défaut ; on les écrit quand même, parce qu'un test qui //=========================================================================
* dépend d'un défaut se casse le jour où le défaut change, et silencieusement. // Cette fonction mettait à zéro tout ce que le portage n'avait pas encore atteint, pour que
* // l'original emprunte le même chemin que la pile. À chaque groupe (B1…B4) une ligne en sortait
* ⚠️ CETTE LISTE RÉTRÉCIT À CHAQUE GROUPE DE L'ÉTAPE B. Une ligne qui part d'ici doit arriver // pour entrer dans `EnableTunnelFeatures` ET dans `FeatureProbes` ; à B4 il n'en restait plus.
* dans `EnableTunnelFeatures` ET dans `FeatureProbes` : la déplacer sans la sonder rendrait le // Sa liste vide EST la mesure de l'avancement — d'où le fait de le dire ici plutôt que de
* groupe « activé » sans aucune preuve qu'il s'exécute. `SurfaceRoughness` (B1) est le premier // laisser une fonction vide qu'on continuerait d'appeler sans y penser.
* à avoir fait le trajet — c'était le seul non nul par défaut (5.0). //
*/ // Une ligne y était de toute façon inopérante et mérite d'être notée : `ColumnDensity = 0`
void DisableStageBModifiers(FStrateGenerationParams& P) // n'éteignait rien. La cuisson des colonnes lit `OpParams` (un struct NEUF où seul l'op de la
{ // salle a été appliqué), jamais le champ de la strate — même mécanisme que pour les pits. Ce qui
P.ColumnDensity = 0.0f; // ⚠️ celui-ci se cuit dans SDFCache.Columns, pas un `if` // tenait les colonnes éteintes à l'étape A, c'était l'ABSENCE d'op `Column` dans le pool, et le
P.DomeDensity = 0.0f; // garde-fou qui ERREUR-ait si la cuisson en produisait quand même. B4 ajoute l'op au pool et
P.PinchDensity = 0.0f; // retourne ce garde-fou : il EXIGE maintenant des colonnes.
P.FloorBias = 0.0f;
}
/** /**
* ⚠️ DENSIFIÉ après le premier run vert. Aux défauts (`RoomSpacing = 80`, `RoomDensity = 0.35`) * ⚠️ DENSIFIÉ après le premier run vert. Aux défauts (`RoomSpacing = 80`, `RoomDensity = 0.35`)
@@ -93,7 +98,7 @@ namespace
* *
* NOT here: PitDensity / ChimneyDensity. Setting them was a mistake that check 3b caught — the * NOT here: PitDensity / ChimneyDensity. Setting them was a mistake that check 3b caught — the
* bake reads a FRESH param struct with only the room's op applied, so those fields are never * bake reads a FRESH param struct with only the room's op applied, so those fields are never
* read. Pits exist only through a per-room terrain-op asset. See MakeShaftOpPool below. * read. Pits exist only through a per-room terrain-op asset. See MakeRoomOpPool below.
*/ */
void EnableTunnelFeatures(FStrateGenerationParams& P) void EnableTunnelFeatures(FStrateGenerationParams& P)
{ {
@@ -137,26 +142,46 @@ namespace
P.ArchDensity = 0.9f; P.ArchDensity = 0.9f;
P.ArchMinRadius = 3.0f; P.ArchMinRadius = 3.0f;
P.ArchMaxRadius = 6.0f; P.ArchMaxRadius = 6.0f;
// ── ÉTAPE B4 : dômes · pincement · biais de sol (4g, 4h) ─────────────────────────────
// ⚠️ LES COLONNES (4d) NE SONT PAS ICI, et ce n'est pas un oubli : elles n'ont AUCUN
// paramètre de strate. Ni la cuisson ni la boucle par voxel ne lisent `ColumnDensity` ;
// elles n'existent que par un op `Column` dans le pool (voir `MakeRoomOpPool`) et leur
// couverture se prouve au contrôle 3b, pas par une sonde de params.
P.DomeDensity = 0.9f; // 2 tirages par salle ⇒ il en faut beaucoup pour couvrir
P.DomeMinRadius = 8.0f;
P.DomeMaxRadius = 15.0f;
P.DomeHeightRatio = 0.8f;
P.PinchDensity = 0.9f; // idem, 3 tirages par salle
P.PinchStrength = 5.0f;
P.PinchLength = 12.0f;
P.FloorBias = 0.8f; // ne s'applique que dans l'air certain (CaveSDF < 0)
} }
/** /**
* Le SEUL moyen d'obtenir des pits et des cheminées : donner à la strate un pool d'ops de * Le SEUL moyen d'obtenir des pits, des cheminées et des colonnes : donner à la strate un pool
* terrain, que `BuildChunkCache` tire par salle. Deux entrées à `Probability = 0.5` ⇒ le pool * d'ops de terrain, que `BuildChunkCache` tire par salle. Trois entrées à `Probability = 1.0` ⇒
* est entièrement réclamé, donc **chaque salle reçoit un op** (moitié pits, moitié cheminées). * `TotalOpProb = 3`, `NormFactor = 1/3`, curseurs à 1/3, 2/3, 1 : **chaque salle reçoit un op**,
* un tiers de chaque sorte.
* *
* ⚠️ SÛR POUR L'ÉTAPE A, et ce n'est pas une évidence : l'override par salle du chemin d'origine * ⚠️⚠️ CE POOL DOIT RESTER Pit / Chimney / Column JUSQU'À L'ÉTAPE C1, ET LA RAISON EST SUBTILE.
* applique l'op de la salle sur une COPIE des params, laquelle pilote les 13 modificateurs de * L'override par salle du chemin d'origine applique l'op de la salle sur une COPIE des params,
* détail — ceux que l'étape A n'a pas portés. Mais `ApplyTo` n'écrit, pour `Pit`, que les quatre * et ONZE des douze modificateurs de détail lisent cette copie (la rugosité 4b, non : le shadow
* champs de pit (idem `Chimney`). Aucun champ de détail n'est touché, donc aucun modificateur ne * est déclaré après elle). L'étape B les a portés contre les params de la STRATE. Les deux ne
* s'allume et l'équivalence de l'étape A tient. Un op `Terrace` ici la casserait — c'est * coïncident que tant qu'aucune salle ne porte un op de type DÉTAIL — or `ApplyTo(Pit)` n'écrit
* exactement ce que l'étape B ajoutera, exprès. * que les quatre champs de pit, `ApplyTo(Chimney)` que les quatre de cheminée, `ApplyTo(Column)`
* que les trois de colonne. Aucun champ de détail touché ⇒ aucun modificateur ne diverge.
* *
* Safe at stage A because ApplyTo(Pit) writes only the four pit fields: no detail modifier wakes * **Mettre un op `Terrace` ici casserait l'équivalence — et c'est exactement le test que C1
* up. A Terrace op here WOULD break stage A — which is precisely what stage B will add. * ajoutera**, parce que c'est la seule preuve possible de l'override par salle.
*
* The pool must stay Pit/Chimney/Column until C1: eleven of the twelve modifiers read the
* per-room param copy in the original and the strate params here, and those agree only while no
* room carries a detail-type op. A Terrace entry is what C1 adds, deliberately.
* *
* @param OutKeepAlive les assets transitoires, à garder vivants pour la durée du test. * @param OutKeepAlive les assets transitoires, à garder vivants pour la durée du test.
*/ */
void MakeShaftOpPool(UVoxelStrateDefinition* Def, void MakeRoomOpPool(UVoxelStrateDefinition* Def,
TArray<TStrongObjectPtr<UVoxelTerrainOpDefinition>>& OutKeepAlive) TArray<TStrongObjectPtr<UVoxelTerrainOpDefinition>>& OutKeepAlive)
{ {
UVoxelTerrainOpDefinition* PitOp = NewObject<UVoxelTerrainOpDefinition>( UVoxelTerrainOpDefinition* PitOp = NewObject<UVoxelTerrainOpDefinition>(
@@ -177,19 +202,38 @@ namespace
ChimOp->ChimneyHeight = 18.0f; ChimOp->ChimneyHeight = 18.0f;
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(ChimOp)); OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(ChimOp));
// ⚠️ AJOUTÉ À L'ÉTAPE B4 : sans cette entrée, `SDFCache.Columns` reste VIDE et l'opérateur
// de colonnes ne s'exécute sur rien — ce que le contrôle 3b exigeait à l'étape A et qu'il
// exige désormais dans l'autre sens.
UVoxelTerrainOpDefinition* ColOp = NewObject<UVoxelTerrainOpDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
ColOp->Type = EVoxelTerrainOpType::Column;
ColOp->ColumnDensity = 0.9f;
ColOp->ColumnMinRadius = 2.0f;
ColOp->ColumnMaxRadius = 5.0f;
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(ColOp));
// `Probability = 1.0` sur les trois : la somme dépasse 1, donc la sélection est normalisée
// et chaque salle tire exactement un op parmi les trois, à parts égales.
FStrateTerrainOpEntry PitEntry; FStrateTerrainOpEntry PitEntry;
PitEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(PitOp); PitEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(PitOp);
PitEntry.Weight = 1.0f; PitEntry.Weight = 1.0f;
PitEntry.Probability = 0.5f; PitEntry.Probability = 1.0f;
FStrateTerrainOpEntry ChimEntry; FStrateTerrainOpEntry ChimEntry;
ChimEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(ChimOp); ChimEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(ChimOp);
ChimEntry.Weight = 1.0f; ChimEntry.Weight = 1.0f;
ChimEntry.Probability = 0.5f; ChimEntry.Probability = 1.0f;
FStrateTerrainOpEntry ColEntry;
ColEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(ColOp);
ColEntry.Weight = 1.0f;
ColEntry.Probability = 1.0f;
Def->TerrainOperations.Reset(); Def->TerrainOperations.Reset();
Def->TerrainOperations.Add(PitEntry); Def->TerrainOperations.Add(PitEntry);
Def->TerrainOperations.Add(ChimEntry); Def->TerrainOperations.Add(ChimEntry);
Def->TerrainOperations.Add(ColEntry);
} }
/** Fraction minimale d'échantillons devant tomber en grotte ouverte pour que l'équivalence /** Fraction minimale d'échantillons devant tomber en grotte ouverte pour que l'équivalence
@@ -238,6 +282,15 @@ namespace
[](FStrateGenerationParams& Q) { Q.ScallopStrength = 0.0f; } }, [](FStrateGenerationParams& Q) { Q.ScallopStrength = 0.0f; } },
{ TEXT("B3 arches (STEP 4c)"), { TEXT("B3 arches (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.ArchDensity = 0.0f; } }, [](FStrateGenerationParams& Q) { Q.ArchDensity = 0.0f; } },
{ TEXT("B4 domes (STEP 4g)"),
[](FStrateGenerationParams& Q) { Q.DomeDensity = 0.0f; } },
{ TEXT("B4 pinch (STEP 4h)"),
[](FStrateGenerationParams& Q) { Q.PinchDensity = 0.0f; } },
{ TEXT("B4 floor bias"),
[](FStrateGenerationParams& Q) { Q.FloorBias = 0.0f; } },
// ⚠️ PAS DE SONDE POUR LES COLONNES (STEP 4d), et ce n'est pas un oubli : elles n'ont aucun
// paramètre de strate qu'on puisse éteindre. Leur couverture se prouve au contrôle 3b, en
// demandant à la cuisson combien elle en a produit. Voir la note là-bas.
// ⚠️ UNE SONDE PAR OPÉRATEUR, PAS UNE PAR GROUPE. Le groupe B2 en contient trois ; une seule // ⚠️ UNE SONDE PAR OPÉRATEUR, PAS UNE PAR GROUPE. Le groupe B2 en contient trois ; une seule
// sonde « B2 » serait verte tant qu'UN des trois tire, et les deux autres pourraient être // sonde « B2 » serait verte tant qu'UN des trois tire, et les deux autres pourraient être
// faux sans que rien ne le dise. La granularité de la sonde est la granularité de la preuve. // faux sans que rien ne le dise. La granularité de la sonde est la granularité de la preuve.
@@ -295,7 +348,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
if (World.Definitions.IsValidIndex(FTestWorld::SlotTunnelNetwork) if (World.Definitions.IsValidIndex(FTestWorld::SlotTunnelNetwork)
&& World.Definitions[FTestWorld::SlotTunnelNetwork].IsValid()) && World.Definitions[FTestWorld::SlotTunnelNetwork].IsValid())
{ {
MakeShaftOpPool(World.Definitions[FTestWorld::SlotTunnelNetwork].Get(), OpAssets); MakeRoomOpPool(World.Definitions[FTestWorld::SlotTunnelNetwork].Get(), OpAssets);
} }
else else
{ {
@@ -322,18 +375,17 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
return false; return false;
} }
DisableStageBModifiers(P);
EnableTunnelFeatures(P); EnableTunnelFeatures(P);
FVoxelOpStack Stack; FVoxelOpStack Stack;
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed, VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get()); Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + roomgraph + carve + **8 modificateurs (B1 · B2 · B3)** + worms + 3 structurels = 15. // rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 3 structurels = 19.
// Les quatre modificateurs restants viendront s'insérer entre les arches et les vers, donc ce // L'étape C n'ajoute AUCUN opérateur — elle change ce que onze d'entre eux LISENT — donc ce
// nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une // nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une
// formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté. // formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté.
TestEqual(TEXT("the stage-A+B1..B3 tunnel stack is decomposed into 15 ops"), Stack.Num(), 15); TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 19 ops"), Stack.Num(), 19);
FVoxelOpContext Ctx; FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed; Ctx.Seed = (uint32)World.Settings->Seed;
@@ -413,7 +465,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
TEXT("is covered ONLY insofar as the bake-coverage and group-coverage lines below report ") TEXT("is covered ONLY insofar as the bake-coverage and group-coverage lines below report ")
TEXT("non-zero -- this message used to CLAIM coverage outright, and was wrong for a ") TEXT("non-zero -- this message used to CLAIM coverage outright, and was wrong for a ")
TEXT("whole run. NOT covered at all: whatever is still listed in ") TEXT("whole run. NOT covered at all: whatever is still listed in ")
TEXT("DisableStageBModifiers, the per-room op override, and any tile verdict."), TEXT("the per-room op override (stage C1), and any tile verdict."),
NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock)); NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock));
AddInfo(FString::Printf( AddInfo(FString::Printf(
@@ -745,15 +797,17 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
TestTrue(TEXT("the bake produced chimneys, so the chimney loop has data to run on"), TestTrue(TEXT("the bake produced chimneys, so the chimney loop has data to run on"),
TotalChimneys > 0); TotalChimneys > 0);
if (TotalColumns > 0) // ⚠️ CE GARDE-FOU S'EST RETOURNÉ À L'ÉTAPE B4, ET C'EST LE SEUL MOYEN DE COUVRIR LES
{ // COLONNES. Il ERREUR-ait si la cuisson produisait des colonnes (l'étape A n'avait pas porté
AddError(FString::Printf( // `STEP 4d`) ; il EXIGE maintenant qu'elle en produise. Il n'y a pas d'alternative par
TEXT("The bake produced %d columns, but stage A has NOT ported the column loop ") // paramètre : ni la cuisson ni la boucle par voxel ne lisent `FStrateGenerationParams::
TEXT("(STEP 4d). The equivalence above should have failed; if it did not, the ") // ColumnDensity`, donc aucune sonde de `FeatureProbes` ne peut éteindre les colonnes — et
TEXT("sample points simply missed every column. Remove the column op from the pool ") // une pile bâtie sur un pool SANS op `Column` partagerait le cache `thread_local` (le pool
TEXT("until stage B."), // n'est pas dans sa clé) et rendrait exactement la même densité, c'est-à-dire mentirait.
TotalColumns)); // Reste à demander à la cuisson ce qu'elle a cuit. Troisième application de la même leçon.
} TestTrue(TEXT("the bake produced columns, so the STEP 4d loop has data to run on ")
TEXT("(no params probe can cover this one -- see the comment above)"),
TotalColumns > 0);
} }
//========================================================================= //=========================================================================
@@ -2735,6 +2735,307 @@ namespace
const FRoomGraphSource* Rooms; // NON possédant const FRoomGraphSource* Rooms; // NON possédant
}; };
//=========================================================================
// RÔLE 3 — MODIFIER : COLONNES DE SALLE / ROOM COLUMNS (TunnelNetwork, STEP 4d)
//=========================================================================
// ⚠️ CE N'EST PAS `FGridColumnMod` (FlatPlain / CrystalChamber). Celui-là pose des cylindres sur
// une GRILLE MONDE et se tire par cellule ; celui-ci PARCOURT une liste PRÉ-CUITE par
// `BuildChunkCache`, salle par salle. `OPSTACK-DECOMPOSITION §1` les sépare explicitement.
//
// ⚠️⚠️ IL N'A **AUCUN** PARAMÈTRE DE STRATE, ET C'EST LE PIÈGE DE CE GROUPE.
// Le code d'origine n'écrit aucun `if (Params.ColumnDensity > 0)` : il itère la liste cuite,
// point. `FStrateGenerationParams::ColumnDensity` n'est JAMAIS lu par la cuisson non plus (elle
// lit `OpParams`, un struct NEUF où seul l'op de la salle a été appliqué). Donc :
// • mettre `ColumnDensity = 0` dans les params N'ÉTEINT PAS les colonnes ;
// • la seule façon d'avoir des colonnes est un `UVoxelTerrainOpDefinition` de type `Column`
// dans le pool de la strate ;
// • et la seule façon de PROUVER qu'elles ont tiré est de regarder `SDFCache.Columns.Num()`.
// C'est exactement la leçon des pits, une troisième fois. Le test l'applique au contrôle 3b.
class FRoomColumnMod final : public IVoxelDensityOp
{
public:
FRoomColumnMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms)
: P(InP), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override
{
if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; }
if (Rooms == nullptr) { return; }
for (const FCachedColumn& Col : Rooms->GetCache().Columns)
{
const float DX = WorldX - Col.CenterX;
const float DY = WorldY - Col.CenterY;
const float XYDistSq = DX * DX + DY * DY;
if (XYDistSq > Col.BoundXYRadiusSq) { continue; }
const float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius;
const float ColBlend = 3.0f;
if (CylSDF < ColBlend)
{
float Fill = FMath::Clamp((ColBlend - CylSDF) / (ColBlend * 2.0f), 0.0f, 1.0f);
Fill = SmoothStep01(Fill);
InOut.Density += Fill * Col.BaseDensity * 1.5f;
}
}
}
/** N'AJOUTE que du solide ⇒ `FillOnly`. On ne peut pas rendre `Identity` sans consulter le
* cache pour la boîte interrogée — même dette que le graphe de salles. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return EVoxelOpEffect::FillOnly;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
// RÔLE 3 — MODIFIER : DÔMES / CATHEDRAL CEILINGS (TunnelNetwork, STEP 4g)
//=========================================================================
// Un demi-ellipsoïde creusé VERS LE HAUT depuis un point ancré au-dessus du centre de la salle.
// Relatif à la salle comme les arches, même porte `CaveSDF < SDFBlendRadius`.
class FDomeMod final : public IVoxelDensityOp
{
public:
FDomeMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms)
: P(InP), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (Rooms == nullptr) { return; }
const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx();
if (!(P.DomeDensity > 0.0f && CaveSDF < P.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0))
{
return;
}
const FChunkSDFCache& Cache = Rooms->GetCache();
if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod
const FCachedRoom& Room = Cache.Rooms[NearestRoomIdx];
const int32 MaxDomes = 2;
for (int32 i = 0; i < MaxDomes; i++)
{
const uint32 DH = VoxelHash::Mix(Room.Hash ^ (0xD0AE0u + (uint32)i * 8191u));
if (VoxelHash::ToFloat01(DH) > P.DomeDensity) { continue; }
const uint32 DH2 = VoxelHash::Mix(DH ^ 0xD0A0u);
const float DmX = Room.Center.X + VoxelHash::ToFloatSigned(DH2) * Room.RadiusXY * 0.4f;
const float DmY = Room.Center.Y
+ VoxelHash::ToFloatSigned(VoxelHash::Mix(DH2)) * Room.RadiusXY * 0.4f;
const uint32 DH3 = VoxelHash::Mix(DH2 ^ 0x90DEu);
const float DmRadius = FMath::Min(
FMath::Lerp(P.DomeMinRadius, P.DomeMaxRadius, VoxelHash::ToFloat01(DH3)),
Room.RadiusXY * 0.85f
);
const uint32 DH4 = VoxelHash::Mix(DH3 ^ 0xCAFEu);
const float DmCenterZ = Room.Center.Z + Room.RadiusZ * 0.2f
+ VoxelHash::ToFloat01(DH4) * Room.RadiusZ * 0.3f;
const float DmHeight = DmRadius * P.DomeHeightRatio;
if (WorldZ > DmCenterZ + DmHeight + 3.0f || WorldZ < DmCenterZ - 3.0f) { continue; }
const float DXDm = WorldX - DmX;
const float DYDm = WorldY - DmY;
const float DZDm = WorldZ - DmCenterZ;
if (DZDm < 0.0f) { continue; } // ne creuse que vers le haut
const float NormX = DXDm / DmRadius;
const float NormY = DYDm / DmRadius;
const float NormZ = DZDm / DmHeight;
const float EllipDist = FMath::Sqrt(NormX * NormX + NormY * NormY + NormZ * NormZ) - 1.0f;
const float DomeSDF = EllipDist * FMath::Min(DmRadius, DmHeight);
const float DmBlend = 3.0f;
if (DomeSDF < DmBlend)
{
float Carve = FMath::Clamp((DmBlend - DomeSDF) / (DmBlend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve);
InOut.Density -= Carve * P.BaseDensity * 1.5f;
}
}
}
/** Ne SOUSTRAIT que ⇒ `CarveOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.DomeDensity > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
// RÔLE 3 — MODIFIER : PINCEMENT / BOTTLENECK (TunnelNetwork, STEP 4h)
//=========================================================================
// Resserre un passage PAR LES CÔTÉS. Placé sur le PÉRIMÈTRE de la salle (offset 0.85 · rayon) —
// là où les tunnels débouchent — et pas au centre, sinon il boucherait la salle elle-même.
// `SideFactor` (distance à l'axe, clampée) est ce qui laisse l'axe du passage libre : le
// remplissage est nul sur l'axe et maximal sur les bords de l'ellipsoïde.
class FPinchMod final : public IVoxelDensityOp
{
public:
FPinchMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms)
: P(InP), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (Rooms == nullptr) { return; }
const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx();
if (!(P.PinchDensity > 0.0f && CaveSDF < P.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0))
{
return;
}
const FChunkSDFCache& Cache = Rooms->GetCache();
if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod
const FCachedRoom& Room = Cache.Rooms[NearestRoomIdx];
const float Spread = 0.85f;
const int32 MaxPinches = 3;
for (int32 i = 0; i < MaxPinches; i++)
{
const uint32 PnH = VoxelHash::Mix(Room.Hash ^ (0xF1C400u + (uint32)i * 5417u));
if (VoxelHash::ToFloat01(PnH) > P.PinchDensity) { continue; }
const uint32 PnH2 = VoxelHash::Mix(PnH ^ 0xF1C4u);
const float PnX = Room.Center.X + VoxelHash::ToFloatSigned(PnH2) * Room.RadiusXY * Spread;
const float PnY = Room.Center.Y
+ VoxelHash::ToFloatSigned(VoxelHash::Mix(PnH2)) * Room.RadiusXY * Spread;
const uint32 PnH3 = VoxelHash::Mix(PnH2 ^ 0x5432u);
const float PnZ = Room.Center.Z + VoxelHash::ToFloatSigned(PnH3) * Room.RadiusZ * 0.5f;
const uint32 PnH4 = VoxelHash::Mix(PnH3 ^ 0x9A3Bu);
const float PnAngle = VoxelHash::ToFloat01(PnH4) * PI;
const float CosPN = FMath::Cos(PnAngle);
const float SinPN = FMath::Sin(PnAngle);
const float DXPn = WorldX - PnX;
const float DYPn = WorldY - PnY;
const float DZPn = WorldZ - PnZ;
const float MaxExtent = FMath::Max(P.PinchLength, P.PinchStrength) + 5.0f;
if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > MaxExtent) { continue; }
const float Along = DXPn * CosPN + DYPn * SinPN;
const float Across = -DXPn * SinPN + DYPn * CosPN;
const float HalfLength = P.PinchLength * 0.5f;
const float HalfNarrow = P.PinchStrength;
const float HalfVertical = P.PinchStrength * 1.5f;
const float NAlong = Along / HalfLength;
const float NAcross = Across / HalfNarrow;
const float NUp = DZPn / HalfVertical;
const float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp;
if (EllipDist < 1.0f)
{
float Fill = 1.0f - EllipDist;
Fill = SmoothStep01(Fill);
const float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp);
const float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f);
InOut.Density += Fill * SideFactor * P.BaseDensity * 1.5f;
}
}
}
/** N'AJOUTE que du solide ⇒ `FillOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.PinchDensity > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
// RÔLE 3 — MODIFIER : BIAIS DE SOL / FLOOR BIAS (TunnelNetwork, fin de 4h)
//=========================================================================
// Rend de la densité dans la moitié BASSE de la salle pour contrer le relief que la rugosité
// laisse sur les sols — un sol praticable au lieu d'un sol bosselé. Ne s'applique QUE dans l'air
// certain (`CaveSDF < 0`) : dans la paroi, le clamp anti-remplissage de la rugosité tient déjà.
//
// ⚠️ DERNIER DE LA CHAÎNE, ET CE N'EST PAS INTERCHANGEABLE : il corrige ce que la rugosité (4b)
// a fait. Le déplacer avant elle le rendrait sans objet. C'est la raison pour laquelle l'ordre
// des opérateurs dans `BuildTunnelNetworkStack` est celui de l'original, ligne pour ligne.
class FFloorBiasMod final : public IVoxelDensityOp
{
public:
FFloorBiasMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms)
: P(InP), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (Rooms == nullptr) { return; }
const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx();
if (!(P.FloorBias > 0.0f && NearestRoomIdx >= 0 && CaveSDF < 0.0f)) { return; }
const FChunkSDFCache& Cache = Rooms->GetCache();
if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod
const FCachedRoom& NR = Cache.Rooms[NearestRoomIdx];
// NormZ : -1 = sol de la salle, 0 = centre, +1 = plafond.
const float NormZ = (WorldZ - NR.Center.Z) / FMath::Max(NR.RadiusZ, 1.0f);
if (NormZ < 0.0f)
{
const float FloorFactor = NormZ * NormZ; // 0 au centre, 1 au sol
InOut.Density += FloorFactor * P.FloorBias;
}
}
/** N'AJOUTE que du solide ⇒ `FillOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.FloorBias > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
};
//========================================================================= //=========================================================================
// RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork) // RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork)
//========================================================================= //=========================================================================
@@ -2983,12 +3284,14 @@ namespace VoxelDensityOps
void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{ {
// ⚠️ ÉTAPES A + B1 + B2 + B3 — LA PILE EST ENCORE INCOMPLÈTE, ET DÉLIBÉRÉMENT. // ⚠️ ÉTAPES A + B (B1 À B5) — LES DOUZE MODIFICATEURS DE DÉTAIL SONT PORTÉS.
// Sont portés : l'échelle verticale, le roc de base, le warp, le graphe de salles (+ pits // **CE QUI RESTE, C'EST L'ÉTAPE C** : l'override d'op PAR SALLE. Tant qu'il manque, les
// + cheminées), le carve, **la rugosité (4b), les terrasses, les lignes de strates, les // onze modificateurs qui, dans l'original, lisent la copie de params de la salle la plus
// nervures, les surplombs, la falaise, les festons, les arches**, les vers, le post // proche lisent ici les params de la STRATE. Les deux coïncident exactement tant qu'aucune
// structurel. **NE SONT PAS ENCORE PORTÉS** les quatre modificateurs restants (colonnes, // salle ne porte un op de type détail (Terrace, LayerLines, Ribbing, Cliff, Scallop,
// mes, pincement, biais de sol), ni l'override d'op PAR SALLE. // Overhang, Arch, Dome, Pinch) — le test garde donc son pool à Pit/Cheminée/Colonne, dont
// les `ApplyTo` n'écrivent aucun champ de détail. **C'est CETTE condition, et rien d'autre,
// qui rend l'étape B vérifiable avant l'étape C.**
// //
// C'est pour cela que `UsesOperatorStackForChunk` rend encore **false** pour TunnelNetwork : // C'est pour cela que `UsesOperatorStackForChunk` rend encore **false** pour TunnelNetwork :
// brancher une pile incomplète sur le monde en retirerait tout le détail. Le test compare // brancher une pile incomplète sur le monde en retirerait tout le détail. Le test compare
@@ -3036,7 +3339,10 @@ namespace VoxelDensityOps
OutStack.Add(MakeUnique<FCaveCliffMod>(P, Seed)); // 4c — falaise OutStack.Add(MakeUnique<FCaveCliffMod>(P, Seed)); // 4c — falaise
OutStack.Add(MakeUnique<FScallopMod>(P, Seed)); // 4c — festons OutStack.Add(MakeUnique<FScallopMod>(P, Seed)); // 4c — festons
OutStack.Add(MakeUnique<FCaveArchMod>(P, RoomPtr)); // 4c — arches OutStack.Add(MakeUnique<FCaveArchMod>(P, RoomPtr)); // 4c — arches
// [ÉTAPE B4 ira ici : colonnes, dômes, pincement, biais de sol] OutStack.Add(MakeUnique<FRoomColumnMod>(P, RoomPtr)); // 4d — colonnes (pré-cuites)
OutStack.Add(MakeUnique<FDomeMod>(P, RoomPtr)); // 4g — dômes
OutStack.Add(MakeUnique<FPinchMod>(P, RoomPtr)); // 4h — pincement
OutStack.Add(MakeUnique<FFloorBiasMod>(P, RoomPtr)); // fin 4h — biais de sol
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed)); OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
@@ -266,14 +266,15 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager); const UVoxelStrateManager* StrateManager);
/** /**
* TunnelNetwork — **ÉTAPES A + B1 + B2 + B3, PILE ENCORE INCOMPLÈTE** : * TunnelNetwork — **ÉTAPES A + B COMPLÈTES, 19 ops** :
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b) * ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch * → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
* → Worms → [structural ×3] * → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×3]
* *
* ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les quatre * ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C :
* modificateurs de détail restants (4c4h) et l'override d'op par salle ne sont pas portés, donc * l'override d'op PAR SALLE n'est pas porté, donc les onze modificateurs qui devraient lire les
* le monde y perdrait du détail. Le test compare avec ces amplitudes à zéro. * params de la salle la plus proche lisent ceux de la strate. Identique tant qu'aucune salle ne
* porte un op de type détail — ce que le test garantit et qu'un monde réel ne garantit pas.
* *
* ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit * ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit
* pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE * pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE