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)); }