From 353d504169040d28a9ec1fede4f425a76054e75a Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 28 Jul 2026 03:53:02 +0200 Subject: [PATCH] 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 --- .../Tests/VoxelForgeClassifyTileTest.cpp | 103 +++++++++++++ .../Private/VoxelDensityOpStack.cpp | 83 ++++++++++- Source/VoxelForge/Public/VoxelDensityOp.h | 135 ++++++++++++++++-- 3 files changed, 304 insertions(+), 17 deletions(-) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp index af7b33b..6dcdbbc 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp @@ -323,6 +323,109 @@ bool FVoxelForgeOpFoldTest::RunTest(const FString& Parameters) } } + //========================================================================= + // LE PLIAGE NUMÉRIQUE — `OPSTACK-DECOMPOSITION §0.2` + //========================================================================= + // La direction seule ne récupère jamais un carve FIELDÉ : il peut creuser partout, donc il rend + // `CarveOnly` partout, et c'est VRAI. Ce que la direction ignore, c'est qu'il ne peut creuser que + // de `WormStrength` au plus. Le pliage porte donc deux nombres : une MARGE posée par l'opérateur + // forçant, et une AMPLITUDE retirée par chaque carve. + // + // ⚠️ LE PREMIER CONTRÔLE CI-DESSOUS EST LE PLUS IMPORTANT : il vérifie que la rétro-compatibilité + // est réelle. Les cinq blocs au-dessus n'ont pas changé d'une ligne et doivent rester verts — + // ils appellent les mêmes fonctions sans marge ni amplitude, donc avec les défauts + // (`Margin = 0`, `MaxCarve = FLT_MAX`), qui reproduisent le comportement purement directionnel. + + // 1. Les défauts REPRODUISENT l'ancien pliage — c'est ce qui rend le changement sûr. + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // marge par défaut = 0 + VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // amplitude par défaut = FLT_MAX + TestEqual(TEXT("with default margin and amplitude, a carve still kills AllSolid"), + (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed); + } + + // 2. Une amplitude INCONNUE tue même une grosse marge. « Je ne sais pas » n'est pas « zéro ». + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1000.0f); + VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // FLT_MAX + TestEqual(TEXT("an unbounded carve kills AllSolid however solid the rock is"), + (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed); + } + + // 3. LE CAS QUI JUSTIFIE TOUT : roc à 1.0, ver à 0.6 ⇒ il reste 0.4 de marge, la boîte est + // prouvablement pleine. C'est exactement `FConstantFieldSource` + `FWormFieldSource`. + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f); // BaseDensity + VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.6f, 0.0f); // WormStrength + TestEqual(TEXT("rock solid by more than the worm can carve stays provably AllSolid"), + (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid); + + // …et les carves S'ACCUMULENT : un second à 0.5 fait passer la marge sous zéro. + VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.5f, 0.0f); + TestEqual(TEXT("carve amplitudes accumulate until the margin runs out"), + (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed); + } + + // 4. ÉGALITÉ ⇒ ON PERD LA TUILE, délibérément. Une marge de 1.0 contre un carve de 1.0 peut + // atteindre exactement zéro, et zéro est du côté AIR pour le mesher. Le test `> 0` est + // STRICT, et il doit le rester : se tromper ici ferait un trou, pas une tuile en trop. + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f); + VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 1.0f, 0.0f); + TestEqual(TEXT("a carve exactly equal to the margin loses the tile (strict >, on purpose)"), + (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed); + } + + // 5. Le miroir côté AIR : une strate d'îles flottantes, surtout vide, avec un remplissage borné. + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::AllAir, 1.0f); + VF_FoldEffect(H, EVoxelOpEffect::FillOnly, 0.0f, 0.35f); + TestEqual(TEXT("air deeper than the fill can reach stays provably AllAir"), + (int32)H.Resolve(), (int32)EVoxelTileClass::AllAir); + } + + // 6. `Both` BORNÉ : la rugosité de paroi peut aller dans les deux sens, mais pas loin. Sur du + // roc forcé, l'hypothèse AIR est déjà morte (le forçage l'a tuée) ; ce qui compte est que + // l'hypothèse SOLIDE survive à un `Both` d'amplitude connue — impossible avant ce changement, + // où `Both` tuait tout inconditionnellement. + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f); + VF_FoldEffect(H, EVoxelOpEffect::Both, 0.3f, 0.3f); + TestEqual(TEXT("a bounded Both no longer kills a margin it cannot cross"), + (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid); + } + + // 7. LA PROPRIÉTÉ DE SÛRETÉ TIENT TOUJOURS : rien de borné ne ressuscite quoi que ce soit. + // Le pliage numérique ne fait que retarder la mort d'une hypothèse, jamais l'annuler. + { + for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly, + EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both }) + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::Mixed, 1000.0f); // marge ignorée sur Mixed + VF_FoldEffect(H, E, 0.0f, 0.0f); // amplitudes NULLES + TestEqual(TEXT("a zero-amplitude op cannot resurrect an unprovable box either"), + (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed); + } + } + + // 8. Une marge NULLE avec une amplitude NULLE : le carve ne retire rien, mais `0 > 0` est faux, + // donc l'hypothèse meurt quand même. C'est voulu — une marge inconnue reste inconnue, et un + // opérateur qui ne fait rien devrait rendre `Identity`, pas `CarveOnly` d'amplitude 0. + { + FVoxelBoxHypotheses H; + VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 0.0f); + VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.0f, 0.0f); + TestEqual(TEXT("zero margin dies even to a zero carve -- unknown is not zero"), + (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed); + } + return true; } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 71fa4c5..5230ff2 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -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; diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index 176a998..e973eeb 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -316,6 +316,68 @@ public: */ virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0; + /** + * ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de + * perf du plan. + * + * La direction seule ne suffit pas pour les opérateurs FIELDÉS. Un carve à seuil de bruit (les + * vers de TunnelNetwork, la rugosité de paroi) n'a AUCUNE borne spatiale : il rend `CarveOnly` + * sur CHAQUE boîte de CHAQUE strate qui l'active, donc il tue l'hypothèse `AllSolid` partout et + * l'archétype ne saute pas une tuile. Aucun raffinement de `EffectOverBox` ne peut le récupérer, + * parce que la réponse « oui, je peux creuser ici » est VRAIE. + * + * **Mais son AMPLITUDE est bornée, et souvent triviale** : pour un ver, `t ∈ [0,1]` et + * `Mask ∈ [0,1]`, donc il ne peut déplacer la densité vers l'air que de `WormStrength` au plus. + * Si le roc est solide d'une marge SUPÉRIEURE à la somme de tous les carves restants, la boîte + * est prouvablement pleine — quel que soit le bruit. + * + * D'où deux nombres, en unités de DENSITÉ (convention interne, positif = solide) : + * • `MaxCarveOverBox` — de combien AU PLUS cet opérateur peut baisser la densité sur la boîte, + * • `MaxFillOverBox` — de combien AU PLUS il peut la monter. + * + * **`FLT_MAX` = « je ne sais pas », et c'est le DÉFAUT.** Un opérateur qui ne redéfinit rien se + * comporte donc EXACTEMENT comme avant ce changement : le pliage retire `FLT_MAX` à la marge, + * elle passe sous zéro, l'hypothèse meurt. Les treize tests d'équivalence et + * `VoxelForge.OpStack.BoxVerdictFold` ne bougent pas d'un verdict. + * + * ⚠️ SENS DE L'ERREUR : SUR-estimer une amplitude coûte du CPU (une tuile maillée pour rien) ; + * SOUS-estimer produit un TROU. Comme partout ailleurs dans ce fichier, en cas de doute rendre + * `FLT_MAX`. Ce n'est pas une borne « raisonnable », c'est une borne PROUVÉE ou rien. + * + * The fold carries NUMBERS, not just directions. A fielded noise carve has no spatial bound but + * its AMPLITUDE is bounded, so "the rock is solid by more than the sum of every remaining carve" + * becomes provable. FLT_MAX means "unknown" and is the default, so every existing op is + * unchanged. Over-estimating costs CPU; under-estimating is a hole. + */ + virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + return FLT_MAX; + } + + virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + return FLT_MAX; + } + + /** + * Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien + * la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ? + * + * C'est l'autre moitié du pliage numérique. `MaxCarveOverBox` dit ce qu'on peut RETIRER ; ceci + * dit ce qu'il y avait à retirer. Sans les deux, la soustraction n'a pas de premier terme. + * + * Exemple, et c'est LE cas qui compte : `FConstantFieldSource` pose `Density = BaseDensity` + * partout. Sa marge est donc exactement `BaseDensity`. Un ver à `WormStrength = 0.6` sur un roc + * à `BaseDensity = 1.0` laisse 0.4 de marge ⇒ la boîte reste prouvablement pleine. + * + * **0 = « je ne sais pas », et c'est le DÉFAUT** : la marge tombe à zéro, le premier carve la + * fait passer sous zéro, l'hypothèse meurt — le comportement d'avant, à l'identique. + */ + virtual float ForcedMarginOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + return 0.0f; + } + /** * OPÉRATEURS FORÇANTS. Certains opérateurs ne « déplacent » pas la densité d'entrée : ils * l'ÉCRASENT. La question « dans quelle direction peux-tu bouger ce champ ? » n'a alors pas de @@ -386,6 +448,20 @@ struct FVoxelBoxHypotheses bool bCanBeAllSolid = true; bool bCanBeAllAir = true; + /** + * ⚠️ LES DEUX NOMBRES DU PLIAGE (`OPSTACK-DECOMPOSITION §0.2`). + * `SolidMargin` = de combien la densité est encore garantie AU-DESSUS de zéro partout dans la + * boîte, SOUS l'hypothèse « tout solide ». Un opérateur forçant la pose ; chaque carve en retire + * son amplitude maximale ; quand elle n'est plus strictement positive, l'hypothèse meurt. + * `AirMargin` est son miroir. + * + * **Elles valent 0 sur un état neuf, et c'est ce qui rend le changement rétro-compatible :** + * sans opérateur forçant qui déclare une marge, le premier carve fait `0 − FLT_MAX < 0` et tue + * l'hypothèse — le comportement exact d'avant le pliage numérique. + */ + float SolidMargin = 0.0f; + float AirMargin = 0.0f; + bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; } /** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */ @@ -399,28 +475,60 @@ struct FVoxelBoxHypotheses /** Poser l'état depuis un verdict FORÇANT (source, ou seal dans sa bande) : l'opérateur écrase * l'entrée, donc il écrase aussi tout ce que la pile avait conclu avant lui. Un verdict « tout * air » affirme du même coup « pas tout solide », et réciproquement. */ -FORCEINLINE void VF_ForceHypotheses(FVoxelBoxHypotheses& H, EVoxelTileClass ForcedVerdict) +/** @param Margin de combien la densité est garantie du bon côté de zéro dans toute la boîte. + * 0 (le défaut) = « je ne sais pas » ⇒ comportement d'avant le pliage numérique. */ +FORCEINLINE void VF_ForceHypotheses(FVoxelBoxHypotheses& H, EVoxelTileClass ForcedVerdict, + float Margin = 0.0f) { switch (ForcedVerdict) { - case EVoxelTileClass::AllSolid: H.bCanBeAllSolid = true; H.bCanBeAllAir = false; break; - case EVoxelTileClass::AllAir: H.bCanBeAllSolid = false; H.bCanBeAllAir = true; break; + case EVoxelTileClass::AllSolid: H.bCanBeAllSolid = true; H.bCanBeAllAir = false; + H.SolidMargin = Margin; H.AirMargin = 0.0f; break; + case EVoxelTileClass::AllAir: H.bCanBeAllSolid = false; H.bCanBeAllAir = true; + H.SolidMargin = 0.0f; H.AirMargin = Margin; break; case EVoxelTileClass::Mixed: - default: H.bCanBeAllSolid = false; H.bCanBeAllAir = false; break; + default: H.bCanBeAllSolid = false; H.bCanBeAllAir = false; + H.SolidMargin = 0.0f; H.AirMargin = 0.0f; break; } } -/** Plier l'effet DIRECTIONNEL d'un opérateur dans l'état. Monotone : ne fait que tuer. */ -FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect) +/** + * Plier l'effet d'un opérateur dans l'état. **Monotone : ne fait que tuer**, jamais ressusciter — + * c'est la propriété de sûreté, et le pliage numérique ne l'affaiblit pas : une marge ne peut que + * DESCENDRE, jamais remonter, en dehors d'un opérateur forçant. + * + * ⚠️ `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». La soustraction + * fait alors passer la marge très en dessous de zéro et l'hypothèse meurt, exactement comme la + * version purement directionnelle de ce pliage. Aucun opérateur existant ne change de verdict. + * (Arithmétique volontairement laissée en float sans garde : `0 − FLT_MAX` vaut `−FLT_MAX`, + * `−FLT_MAX − FLT_MAX` sature à `−inf`, et `−inf > 0` est faux. Pas de NaN possible, les deux + * termes étant de même signe.) + */ +FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect, + float MaxCarve = FLT_MAX, float MaxFill = FLT_MAX) { switch (Effect) { - case EVoxelOpEffect::Identity: break; - case EVoxelOpEffect::CarveOnly: H.bCanBeAllSolid = false; break; - case EVoxelOpEffect::FillOnly: H.bCanBeAllAir = false; break; + case EVoxelOpEffect::Identity: + break; + + case EVoxelOpEffect::CarveOnly: + H.SolidMargin -= MaxCarve; + if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; } + break; + + case EVoxelOpEffect::FillOnly: + H.AirMargin -= MaxFill; + if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; } + break; + case EVoxelOpEffect::Both: - default: H.bCanBeAllSolid = false; - H.bCanBeAllAir = false; break; + default: + H.SolidMargin -= MaxCarve; + if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; } + H.AirMargin -= MaxFill; + if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; } + break; } } @@ -448,8 +556,9 @@ FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op, const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx); if (Forced != EVoxelTileClass::Mixed) { - VF_ForceHypotheses(H, Forced); + VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx)); return; } - VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx)); + VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx), + Op.MaxCarveOverBox(VoxelBox, Ctx), Op.MaxFillOverBox(VoxelBox, Ctx)); }