feat(opstack): the box fold carries NUMBERS -- amplitude bounds alongside EVoxelOpEffect (DECOMPOSITION 0.2)

Its own commit, nothing else in it, because it changes the contract all thirteen tests rest on.
VoxelForge.OpStack.BoxVerdictFold is extended in the same commit, as required.

THE PROBLEM, restated because the fix only makes sense against it
Direction alone can never recover a FIELDED carve. A noise-threshold carve (TunnelNetwork's worms,
wall roughness) has no spatial bound: it answers CarveOnly on every box of every strate that enables
it, and that answer is TRUE. So it kills AllSolid everywhere and the archetype skips zero tiles. No
refinement of EffectOverBox can fix that.

But the AMPLITUDE is bounded, and for the worm it is trivial: the block only runs below the
threshold, so t = 1 - WormValue/WormThreshold is in [0,1] and NetworkMask is in [0,1], hence the
carve is at most WormStrength. "The rock is solid by more than the sum of every remaining carve" is
therefore provable.

THE CONTRACT
- IVoxelDensityOp gains MaxCarveOverBox / MaxFillOverBox (density units, FLT_MAX = unknown) and
  ForcedMarginOverBox (how far the density is guaranteed from zero, 0 = unknown).
- FVoxelBoxHypotheses gains SolidMargin / AirMargin. A forcing op sets one; each carve subtracts its
  amplitude; the hypothesis dies when the margin is no longer strictly positive.
- VF_ForceHypotheses and VF_FoldEffect take the new values as DEFAULTED parameters.

BACKWARDS COMPATIBILITY IS THE POINT, and it is structural rather than promised: the defaults are
FLT_MAX and 0, so an op that overrides nothing subtracts FLT_MAX from a margin of 0 and kills the
hypothesis exactly as the purely directional fold did. Not one existing verdict moves. The float
arithmetic is deliberately unguarded: 0 - FLT_MAX is -FLT_MAX, -FLT_MAX - FLT_MAX saturates to -inf,
-inf > 0 is false, and no NaN is reachable because both terms share a sign.

BOUNDS DECLARED (each proved from the code, not estimated -- over-estimating costs CPU,
under-estimating is a hole)
- FConstantFieldSource::ForcedMarginOverBox = |Value|. This is the missing FIRST TERM: without a
  source that states how solid the rock is, there is nothing for a bounded carve to be subtracted
  from, and every bound would still kill the hypothesis.
- FWormFieldSource: MaxCarve = WormStrength (the bound that had been written and unused since stage
  A), MaxFill = 0.
- FCaveRoughnessMod: 1.4 * SurfaceRoughness * VOXEL_NOISE_SCALE both ways.
- FLayerLineMod (LayerLineDepth), FRibbingMod (RibbingDepth), FScallopMod (ScallopStrength),
  FCaveOverhangMod (SCALE * Depth * Strength).

WHAT THIS DOES *NOT* DO YET, said plainly rather than implied
TunnelNetwork still proves ZERO tiles. The chain dies at FRoomGraphSource, which answers Both with
unknown amplitude, before any of the bounded ops are reached. Making it answer spatially means
building the SDF cache for the queried box, which only pays once ClassifyTile actually consumes
ClassifyBox -- so it belongs with that work, not here. The one case that changes today is a tunnel
strate with RoomDensity <= 0: the room source returns Identity and the bounded worm + roughness can
now leave AllSolid standing.

Seven ops keep the FLT_MAX default (terrace, cliff, arch, column, dome, pinch, floor bias). Their
amplitudes depend on room-relative data, and bounding them would change no verdict while the room
source is unbounded. Writing bounds nobody can consume is how a bound goes stale unnoticed.

ONE PRE-EXISTING DEBT MADE SHARPER, noted at every site: these bounds are computed from STRATE
params, and a per-room terrain op can write a LARGER amplitude (ApplyTo overwrites even where the
strate had 0). So a bound can be too small -- the dangerous direction. Same root as the too-optimistic
EffectOverBox flagged in C1, same fix, and it MUST land before ClassifyTile consumes ClassifyBox.

TEST: eight new blocks in BoxVerdictFold. The first two are the ones to read -- they assert that the
defaults reproduce the old fold exactly, and that "unknown" is not "zero". Also asserted: carve
amplitudes accumulate; equality loses the tile (the > is strict on purpose, since zero counts as air
at the mesher); a bounded Both no longer kills a margin it cannot cross; and nothing bounded can
resurrect an unprovable box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-07-28 03:53:02 +02:00
1 parent 64d0e11821
commit 353d504169
3 files changed
+304 -17

No files matched your search

@@ -134,6 +134,16 @@ namespace
return EVoxelOpEffect::Both; // jamais atteint sauf Value == 0 : ClassifyBox répond avant
}
/** ⚠️ LA MOITIÉ MANQUANTE DU PLIAGE NUMÉRIQUE (`OPSTACK-DECOMPOSITION §0.2`).
* `MaxCarveOverBox` dit ce qu'un opérateur peut RETIRER ; ceci dit ce qu'il y avait à
* retirer. Un champ constant est le seul opérateur du plugin qui connaisse cette marge
* EXACTEMENT : la densité vaut `Value` partout, donc la marge est `|Value|`. Sans elle la
* soustraction n'a pas de premier terme et tout carve borné tue quand même l'hypothèse. */
float ForcedMarginOverBox(const FBox&, const FVoxelOpContext&) const override
{
return FMath::Abs(Value);
}
private:
float Value;
};
@@ -2305,7 +2315,17 @@ namespace
return (P.SurfaceRoughness > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
}
/** La borne d'amplitude, en unités de densité. Pas encore consommée par le pliage. */
/**
* ✅ Borne consommée par le pliage numérique. `RoughNoise` et `FineNoise` respectent tous
* deux le contrat `[-1, 1]` de fBM/Ridged/Cellular, mis à l'échelle par `VOXEL_NOISE_SCALE`,
* et `TotalRough = Rough·S + Fine·S·0.4` ⇒ `|TotalRough| ≤ 1.4 · S · SCALE`. Le fade est
* dans `[0,1]`. Le clamp anti-remplissage ne fait que RÉDUIRE côté fill ; on ne s'appuie pas
* dessus (il ne s'applique que dans l'air certain), donc la borne fill reste la même.
*/
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); }
float MaxFillOverBox (const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); }
/** La borne d'amplitude, en unités de densité. */
float MaxAmplitude() const
{
return (P.SurfaceRoughness > 0.0f)
@@ -2476,6 +2496,17 @@ namespace
? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
/** `LineValue = max(sin,0)³ ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ retrait ≤ `LayerLineDepth`.
* ⚠️ Borne calculée sur les params de la STRATE : un op `LayerLines` par salle peut écrire
* une profondeur PLUS GRANDE, ce qui rendrait cette borne fausse dans le sens dangereux.
* C'est la même dette que la note d'`EffectOverBox` juste au-dessus, et elle doit être
* réglée par le même correctif — AVANT que `ClassifyTile` ne consomme `ClassifyBox`. */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f;
}
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
@@ -2532,6 +2563,14 @@ namespace
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
/** `RibValue = max(sin,0)² ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ ajout ≤ `RibbingDepth`.
* Même réserve « params de strate » que `FLayerLineMod::MaxCarveOverBox`. */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.RibbingSpacing > 0.0f) ? FMath::Max(P.RibbingDepth, 0.0f) : 0.0f;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
@@ -2594,6 +2633,15 @@ namespace
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
/** fBM ∈ [-1,1] × `VOXEL_NOISE_SCALE`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ ajout
* ≤ `SCALE · Depth · Strength`. Même réserve « params de strate ». */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override
{
return VOXEL_NOISE_SCALE * FMath::Max(P.OverhangDepth, 0.0f)
* FMath::Max(P.OverhangStrength, 0.0f);
}
private:
FStrateGenerationParams P;
uint32 SeedU;
@@ -2725,6 +2773,14 @@ namespace
return (P.ScallopStrength > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
/** `Cellular3D ∈ [-1,1]`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ retrait ≤
* `ScallopStrength`. Même réserve « params de strate ». */
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
{
return FMath::Max(P.ScallopStrength, 0.0f);
}
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; }
private:
FStrateGenerationParams P;
uint32 SeedU;
@@ -3223,9 +3279,28 @@ namespace
: EVoxelOpEffect::Identity;
}
/** L'amplitude max de carve, en unités de densité. Pas encore consommée par le pliage —
* posée ici pour que la borne de `§0.2` ait déjà un domicile quand les intervalles
* arriveront. / The bound §0.2 needs, given a home before it has a consumer. */
/**
* ✅ **LA BORNE DE `§0.2`, MAINTENANT CONSOMMÉE.** Elle a passé plusieurs entrées de journal
* écrite mais inutilisée, faute d'un pliage capable de porter un nombre ; ce pliage existe.
*
* La preuve tient en une ligne : `t = 1 − WormValue/WormThreshold ∈ [0,1]` (le bloc ne
* s'exécute que sous le seuil) et `NetworkMask ∈ [0,1]` par construction, donc
* `t · WormStrength · NetworkMask ≤ WormStrength`. C'est une borne PROUVÉE, pas prudente —
* la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait
* un trou.
*/
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
{
return MaxCarveAmplitude();
}
/** Le ver ne REMPLIT jamais : `InOut.Density -= …` avec un terme positif. */
float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override
{
return 0.0f;
}
/** L'amplitude max de carve, en unités de densité. */
float MaxCarveAmplitude() const
{
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f;