Files
VoxelForge/Source/VoxelForge/Public/VoxelDensityOpStack.h
T
Claude f83c4bd8c1 refactor(generator): delete the pre-operator-stack archetype functions
With the operator stack as the only density path, the per-archetype
density functions have no caller left in the game:

- Delete UVoxelGenerator::GetSlabDensity, GetMazeDensity,
  GetSurfaceDensity, SurfaceDensityFromColumn, GetVerticalShaftDensity
  and GetFloatingIslandDensity (~1000 lines) with their declarations.
- Drop GetDensityWithParams' OutWarpedPosition out-parameter; only
  the native GetDensityAt branch removed in the previous commit used it.
- OpStack Slab/Maze/VerticalShaft/FloatingIsland and SurfaceHeight
  tests: remove the stack-vs-legacy comparisons; keep decomposition,
  finiteness, liveness, window-invariance, cache-cost and box-verdict
  checks (Shaft cache timing now measures the stack source).
- PassageLandsInOpenSpace: the source-only footing probe evaluates
  the archetype stack built without a strate manager instead of the
  deleted functions.
- Comments that cited the deleted functions now cite the operators
  (FSlabVoidSource, FLatticeCorridorSource, FIslandBlobSource, ...).
  GSurfColCache is documented as ClassifyTile's column cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URDGLsu7c4nunqNFPJEFPU
2026-09-28 16:04:51 +00:00

731 lines
38 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// VoxelDensityOpStack.h
// La PILE : un conteneur ordonné d'opérateurs, plus les fabriques d'opérateurs concrets.
// The STACK: an ordered container of operators, plus the concrete-operator factories.
//
// ⚠️ CECI EST LE CHEMIN DE GÉNÉRATION DU JEU.
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk pour chaque strate :
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork,
// Underwater (8 sur 8)**. `ClassifyTile` construit la même pile pour les archétypes de cave et plie
// `ClassifyBox`; les gaps et SurfaceWorld gardent leurs preuves exactes dédiées.
//
// THIS IS THE GAME'S GENERATION PATH. GetDensityAt builds the stack per chunk for every strate.
// ClassifyTile uses the same stack for cave archetypes; gaps and SurfaceWorld retain their exact
// hand-written proofs.
//
// POURQUOI CETTE FORME / WHY THIS SHAPE
// La question à laquelle cette forme répond n'est pas « est-ce que ça marche ? » mais
// **« est-ce que la séparation source / modifier tombe naturellement du code existant ? »**
// (OPSTACK-PLAN §4). Chaque archétype a été porté puis comparé bit à bit à sa fonction
// d'origine avant qu'elle ne soit retirée : la réponse a été MESURÉE plutôt qu'une opinion.
#pragma once
#include "CoreMinimal.h"
#include "VoxelDensityOp.h"
#include "VoxelDensityProfile.h"
#include "VoxelStrateTypes.h" // FMazeGenerationParams
#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one
class UVoxelGenerator;
class UVoxelStrateManager;
struct FVoxelOpStackRecipe;
struct FVoxelStrateArchetypeParams;
struct FVoxelStrateRegionManifest;
enum class EVoxelStrateOpClass : uint8;
/** The declarations the offline structure roller reads before it ever materialises an op. */
struct VOXELFORGE_API FVoxelStrateOpContract
{
EVoxelOpRole Role = EVoxelOpRole::DetailModifier;
EVoxelOpChannelMask Reads = VoxelOpChannels::None;
EVoxelOpChannelMask Writes = VoxelOpChannels::None;
bool bAdditive = false;
EVoxelOpResourceMask RequiredResources = VoxelOpResources::None;
EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None;
};
/**
* A lowered evaluator selected when a stack has a proven, canonical implementation.
*
* The authored operator list remains present for classification, diagnostics, tests, and the
* fallback evaluator. This is a plan choice made while the stack is assembled, not a runtime
* CPU-feature dispatch and not a second semantic graph.
*/
enum class EVoxelOpFusedEvaluator : uint8
{
None,
TunnelNetwork,
};
/**
* FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
*
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
* l'ordre spine → seal → passage → seal XY est garanti par cette fonction, pas par l'auteur. Un auteur ne
* peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester
* possible, les seals doivent tenir, les passages doivent percer).
*
* PROPRIÉTÉ (threading) : la pile est LUE par les workers. Les opérateurs concrets qui ont besoin
* d'un cache par cellule/chunk le tiennent en `thread_local` à l'intérieur de leur `Eval`, comme le
* fait déjà chaque fonction d'archétype. En Phase 3, quand les opérateurs deviendront des assets
* partagés, il faudra un objet d'état PAR WORKER — noté ici pour que ça ne surprenne personne.
*
* THREADING: the stack is READ by workers. Concrete ops that need a per-cell/per-chunk cache keep it
* thread_local inside Eval, exactly as every archetype function already does. Phase 3 (ops as shared
* assets) will need a per-worker state object — flagged here so it is not a surprise.
*/
class FVoxelOpStack
{
private:
/** Metadata is snapshotted when an op enters the stack so no declaration virtual is ever
* dispatched from `EvalSample`'s per-voxel loop. */
struct FOpEntry
{
TUniquePtr<IVoxelDensityOp> Op;
EVoxelOpChannelMask Reads = VoxelOpChannels::None;
EVoxelOpChannelMask Writes = VoxelOpChannels::None;
bool bAdditive = false;
EVoxelOpResourceMask RequiredResources = VoxelOpResources::None;
EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None;
const TCHAR* ProfileName = nullptr;
VoxelDensityProfile::EBucket ProfileBucket = VoxelDensityProfile::EBucket::OtherOp;
};
public:
// DÉPLAÇABLE, PAS COPIABLE — et c'est la bonne sémantique, pas un contournement de compilateur :
// une pile POSSÈDE ses opérateurs de façon unique. La copier voudrait dire cloner des opérateurs
// polymorphes, ce qui n'a pas de sens ici (une pile n'existe qu'une fois par strate).
//
// ⚠️ NOTE COMPILATEUR : ne PAS remettre `VOXELFORGE_API` sur la classe. Sous MSVC, dllexport sur
// une classe force l'instanciation de TOUS ses membres implicites, y compris l'opérateur
// d'affectation par copie — impossible à générer pour un `TArray<FOpEntry>`, d'où
// l'erreur C2280 « fonction supprimée ». L'export va sur la seule méthode hors-ligne.
//
// MOVE-ONLY, and that is the correct semantics rather than a compiler workaround: a stack
// uniquely OWNS its operators. Do NOT put VOXELFORGE_API back on the class — under MSVC,
// dllexport forces instantiation of every implicit member including copy-assignment, which
// cannot be generated for a TArray<FOpEntry> (error C2280). Export the out-of-line
// method instead.
FVoxelOpStack() = default;
FVoxelOpStack(FVoxelOpStack&&) = default;
FVoxelOpStack& operator=(FVoxelOpStack&&) = default;
FVoxelOpStack(const FVoxelOpStack&) = delete;
FVoxelOpStack& operator=(const FVoxelOpStack&) = delete;
void Add(TUniquePtr<IVoxelDensityOp> Op)
{
// Adding an op invalidates any previously selected lowered plan. Factories mark the plan
// only after the complete authored graph has been assembled.
FusedEvaluator = EVoxelOpFusedEvaluator::None;
FOpEntry Entry;
Entry.Op = MoveTemp(Op);
if (Entry.Op.Get() != nullptr)
{
// Assembly-time metadata only. ValidateChannelOrder reuses this snapshot, and Eval
// never asks an operator for its declaration.
Entry.Reads = Entry.Op->ChannelReads();
Entry.Writes = Entry.Op->ChannelWrites();
Entry.bAdditive = Entry.Op->IsAdditive();
Entry.RequiredResources = Entry.Op->RequiredResources();
Entry.ProvidedResources = Entry.Op->ProvidedResources();
Entry.ProfileName = Entry.Op->DebugName();
Entry.ProfileBucket = VoxelDensityProfile::BucketFromName(Entry.ProfileName);
}
Ops.Add(MoveTemp(Entry));
}
int32 Num() const { return Ops.Num(); }
/** Select the lowered evaluator for this complete authored graph. */
void SetFusedEvaluator(EVoxelOpFusedEvaluator InEvaluator)
{
FusedEvaluator = InEvaluator;
}
/** The lowered plan, or None when the general interpreted/block path must be used. */
EVoxelOpFusedEvaluator GetFusedEvaluator() const
{
return FusedEvaluator;
}
/** True when this stack publishes the requested immutable geometry resource. */
bool ProvidesResource(EVoxelOpResourceMask Resource) const
{
if (Resource == VoxelOpResources::None)
{
return false;
}
for (const FOpEntry& Entry : Ops)
{
if ((Entry.ProvidedResources & Resource) == Resource)
{
return true;
}
}
return false;
}
// Allocator-backed bytes owned by the stack, including each concrete operator allocation.
// The enclosing cache entry adds sizeof(FVoxelOpStack) separately.
SIZE_T GetAllocatedSize() const;
/** Hoist chunk-constant work for every op. Une fois par chunk et par worker.
* Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un
* mensonge utile qui finirait par masquer une course. */
void PrepareChunk(const FVoxelOpContext& Ctx)
{
PreparedContext = Ctx;
bHasPreparedContext = true;
for (const FOpEntry& Entry : Ops) { Entry.Op->PrepareChunk(Ctx); }
}
/**
* Évalue la pile complète en un point. Rend la densité en convention INTERNE
* (positif = solide) — l'appelant négate UNE FOIS pour le marching cubes.
*
* Returns INTERNAL-convention density (positive = solid). The caller negates once for MC.
*/
float EvalInternal(float WorldX, float WorldY, float WorldZ) const
{
return EvalSample(WorldX, WorldY, WorldZ).Density;
}
/** L'état COMPLET (densité + SDF) après toute la pile. Diagnostic : quand deux évaluations
* divergent, c'est le canal SDF qui dit si l'écart naît avant ou après la conversion.
* / The full state after the stack — the SDF channel is what says whether a divergence is
* born before or after the carve. */
FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const
{
FVoxelOpSample S;
// Fine operation timers are a diagnostic mode. Keeping their construction out of the
// ordinary scalar path matters for callers that still use fractional samples or an
// archetype without a block session; the block path already times one scope per op/block.
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
for (const FOpEntry& Entry : Ops)
{
if (bProfileOps)
{
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
Entry.Op->Eval(WorldX, WorldY, WorldZ, S);
}
else
{
Entry.Op->Eval(WorldX, WorldY, WorldZ, S);
}
}
// The room-graph source publishes the authored floor ownership while evaluating the
// shape, but it must not be reasserted here. This stack is followed by the common
// disturbance/passage writers; composing the floor at this point would make every authored
// floor sample look like a post-hoc clamp (and counted 100% of them). The generator
// consumes the same immutable core result after those writers and composes the floor once,
// at the final MC boundary. The metadata remains on the sample for that hand-off and for
// diagnostics, but it is not allowed to modify density inside the operator stack.
return S;
}
/**
* Evaluate one regular lattice in operator order. The caller owns and initializes the sample
* array (normally to FVoxelOpSample defaults); each operator transforms that same array before
* the next operator runs. The timer is intentionally one scope per operator/block rather than
* one scope per operator/sample. Operators without a block implementation use the conservative
* scalar fallback supplied by IVoxelDensityOp, so this entry point is safe to introduce before
* converting the concrete operators.
*/
void EvalBlock(const FVoxelOpBlock& Block) const
{
if (Block.Samples == nullptr || Block.Step <= 0
|| Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0)
{
return;
}
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
for (const FOpEntry& Entry : Ops)
{
if (bProfileOps)
{
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
Entry.Op->EvalBlock(Block);
}
else
{
Entry.Op->EvalBlock(Block);
}
}
}
/** Evaluate only the entries that the interval fold proved non-identity for this block. */
void EvalBlock(const FVoxelOpBlock& Block, const TArray<int32>& ActiveOps) const
{
if (Block.Samples == nullptr || Block.Step <= 0
|| Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0)
{
return;
}
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
for (const int32 OpIndex : ActiveOps)
{
if (!Ops.IsValidIndex(OpIndex)) { continue; }
const FOpEntry& Entry = Ops[OpIndex];
if (bProfileOps)
{
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
Entry.Op->EvalBlock(Block);
}
else
{
Entry.Op->EvalBlock(Block);
}
}
}
/**
* Build a conservative active list for one exact mesher lattice block. An operator may be
* omitted only when its state-aware box contract says Identity and it does not publish SDF.
* SDF writers remain in the list even when their density effect is Identity: their SDF is a
* channel consumed by a later converter, and an unchanged-looking interval is not by itself a
* pointwise proof. Unknown costs the operator visit, never the geometry.
*/
int32 BuildActiveOpList(const FBox& VoxelBox, int32 InStep,
FIntVector InLatticeOrigin, TArray<int32>& OutActiveOps) const
{
OutActiveOps.Reset();
if (!bHasPreparedContext)
{
for (int32 Index = 0; Index < Ops.Num(); ++Index) { OutActiveOps.Add(Index); }
return OutActiveOps.Num();
}
FVoxelOpContext Ctx = PreparedContext;
Ctx.Step = FMath::Max(InStep, 1);
Ctx.LatticeOriginVoxels = InLatticeOrigin;
Ctx.bUseLatticeProof = true;
FVoxelBoxHypotheses H;
for (int32 Index = 0; Index < Ops.Num(); ++Index)
{
const FOpEntry& Entry = Ops[Index];
const bool bWritesSdf = (Entry.Writes & VoxelOpChannels::Sdf) != 0;
const bool bPublishesResource = Entry.ProvidedResources != VoxelOpResources::None;
const EVoxelTileClass Forced = Entry.Op->ClassifyBox(VoxelBox, Ctx);
bool bCanSkip = false;
if (Forced != EVoxelTileClass::Mixed)
{
VF_ForceHypotheses(H, Forced, Entry.Op->ForcedMarginOverBox(VoxelBox, Ctx));
if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
}
else
{
const EVoxelOpEffect Effect =
Entry.Op->EffectOverBox(VoxelBox, Ctx, H);
bCanSkip = Effect == EVoxelOpEffect::Identity
&& !bWritesSdf
&& !bPublishesResource;
VF_FoldEffect(H,
Effect,
Entry.Op->MaxCarveOverBox(VoxelBox, Ctx, H),
Entry.Op->MaxFillOverBox(VoxelBox, Ctx, H));
if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
}
if (!bCanSkip) { OutActiveOps.Add(Index); }
}
return OutActiveOps.Num();
}
/** Le même, négaté pour le mesher (négatif = solide). */
float EvalMC(float WorldX, float WorldY, float WorldZ) const
{
return -EvalInternal(WorldX, WorldY, WorldZ);
}
/** Return a same-sample structural-core result published by a source in this stack. */
bool TryGetLastTunnelCoreWorldEvaluation(
FTunnelCoreWorldEvaluation& OutEvaluation) const;
/**
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un
* opérateur FORÇANT (les seals dans leurs bandes) écrase ce que la pile avait conclu avant lui.
*/
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
FVoxelBoxHypotheses H;
for (const FOpEntry& Entry : Ops)
{
VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx,
(Entry.Writes & VoxelOpChannels::Sdf) != 0);
// Do not early-out on a dead hypothesis: a later forcing structural post may
// deliberately overwrite it (the XY edge seal is appended after passage tube/landing
// carving and support).
}
return H.Resolve();
}
/**
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
*
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même ordre,
* y compris le fait de laisser un post forçant ultérieur ressusciter une hypothèse morte. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
* l'autre change avec lui.
*
* `OutSolidKiller` / `OutAirKiller` reçoivent l'INDEX du premier opérateur qui fait passer
* l'hypothèse correspondante de vraie à fausse, ou `INDEX_NONE` si elle a survécu. Le nom
* lisible s'obtient par `GetOpDebugName(index)`.
*
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
* ClassifyBox, including later forcing posts that may overwrite a dead hypothesis — a
* diagnostic that takes a slightly different path sends you hunting in the wrong place.
*/
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
int32& OutSolidKiller, int32& OutAirKiller) const
{
OutSolidKiller = INDEX_NONE;
OutAirKiller = INDEX_NONE;
FVoxelBoxHypotheses H;
for (int32 i = 0; i < Ops.Num(); ++i)
{
const bool bSolidBefore = H.bCanBeAllSolid;
const bool bAirBefore = H.bCanBeAllAir;
VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx,
(Ops[i].Writes & VoxelOpChannels::Sdf) != 0);
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
}
return H.Resolve();
}
/**
* Per-operator box-bound audit. This deliberately replays the same fold as ClassifyBox while
* also brute-forcing Eval on the queried lattice. It is report/test infrastructure only: it
* must never be used to make a generation decision.
*/
struct FOpBoxDiagnostic
{
int32 Index = INDEX_NONE;
FString Name;
EVoxelOpEffect Effect = EVoxelOpEffect::Identity;
EVoxelTileClass ForcedVerdict = EVoxelTileClass::Mixed;
bool bForced = false;
bool bWritesSdf = false;
float ForcedMargin = 0.0f;
float MaxCarve = 0.0f;
float MaxFill = 0.0f;
bool bHasDensityDelta = false;
float ActualDensityDeltaMin = FLT_MAX;
float ActualDensityDeltaMax = -FLT_MAX;
bool bHasSdfValue = false;
float ActualSdfMin = FLT_MAX;
float ActualSdfMax = -FLT_MAX;
bool bHasSdfBound = false;
float SdfBoundMin = FLT_MAX;
float SdfBoundMax = FLT_MAX;
bool bCarveBoundViolated = false;
bool bFillBoundViolated = false;
uint64 SampleCount = 0;
};
VOXELFORGE_API void DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
int32 SampleStep, TArray<FOpBoxDiagnostic>& OutDiagnostics,
EVoxelTileClass* OutVerdict = nullptr) const;
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
const TCHAR* GetOpDebugName(int32 Index) const
{
return Ops.IsValidIndex(Index) ? Ops[Index].Op->DebugName() : TEXT("(none)");
}
/**
* Validate the stack's channel dependency DAG without evaluating a voxel.
*
* Rule: each channel has a current version. A channel is unavailable until an earlier op
* writes it. The one deliberate exception is a root `FieldSource` that reads and writes the
* same channel as an identity fold (for example `min(FLT_MAX, Sdf)`); it may consume that
* channel's `FVoxelOpSample` initial identity while publishing the first real version. An op
* may read only the version visible on entry; a read/write op consumes that version and
* publishes the next one. A write-only op is a replacement/producer, so it is legal only as
* the first producer of that channel; allowing a second write-only producer would silently
* clobber the earlier field. An additive op must read every channel it writes, because an
* additive delta cannot introduce or replace a channel. All producer-to-consumer edges
* therefore point forward in the list, and the list is a legal topological ordering of the
* channel DAG. A later read deliberately sees the writer's post-op version; a consumer that
* needs a pre-write value must be placed before that writer. The declarations do not invent
* dependencies from names, roles, or Eval side effects.
*
* This is assembly/diagnostic work only. It must not be called from Eval or any voxel loop.
* Returns false and optionally describes the first violation.
*/
VOXELFORGE_API bool ValidateChannelOrder(FString* OutError = nullptr) const;
/**
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
* spine (0,0) → seal de frontière → carve de passage → [posts spécifiques] → seal de limite XY.
*
* ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
* portées et que la question de convention MC-vs-interne sera tranchée pour de bon
* (OPSTACK-DECOMPOSITION §10.2). Tant que la pile n'alimente pas le jeu, c'est sans effet.
*
* The diff layer is NOT here: it lives in GetDensityAt, AFTER the MC negate, with disturbances.
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
*
* @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel).
* Le seal XY reste toujours présent ; son rayon/thickness viennent de Ctx
* au moment de PrepareChunk (0 = monde non borné).
*/
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
float SealThickness, float BaseDensity, float SpineRadius,
const UVoxelStrateManager* StrateManager,
bool bAppendEdgeSeal = true);
private:
TArray<FOpEntry> Ops;
FVoxelOpContext PreparedContext;
bool bHasPreparedContext = false;
EVoxelOpFusedEvaluator FusedEvaluator = EVoxelOpFusedEvaluator::None;
};
//=============================================================================
// FABRIQUES / FACTORIES
//=============================================================================
namespace VoxelDensityOps
{
/** Query the concrete op declaration used by the offline recipe roller. */
VOXELFORGE_API bool GetStrateOpContract(EVoxelStrateOpClass OpClass,
FVoxelStrateOpContract& OutContract);
/** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit.
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity);
/** Rôle 1 — le MÊME opérateur au signe près : `Density = -BaseDensity`, un grand vide ouvert.
* `ClassifyBox` → **AllAir**, ce qu'aucune source n'avait encore su rendre — c'est ce qui rend
* une strate d'îles flottantes (surtout vide) sautable là où aucune île n'arrive. Racine de
* FloatingIslands. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
* Écrit le canal SDF uniquement. Chaque nœud hors origine choisit un parent vers l'origine;
* les arêtes canoniques (nœud inférieur + axe) testent localement les deux parents. La source
* met en cache les décisions de la fenêtre {-1,0}³ par cellule; les chunks adjacents voient
* donc exactement le même graphe, sans collect global ni couture. */
/** The source answers only for its own SDF field. Its box query publishes an SDF interval;
* each later converter or modifier consumes that interval through the generic fold. It does
* not know, and must not answer for, any downstream operator. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
int32 Seed);
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
* un gain d'authoring gratuit, et §2.6 autorise explicitement le re-tune. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfRoughnessMod(float Strength, float Frequency,
int32 BaseOctaves, float ApplyWithin);
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur.
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts.
* @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original
* écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0, où
* `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`,
* donc ce paramètre est une vraie différence, pas une précaution. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity,
float MinDivisor = 0.0f);
/** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur.
* C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1
* étant exacte en IEEE-754, le chemin carve reste bit pour bit ce qu'il était. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity);
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
* bornées par le supremum prouvé de FBM (1.5, not the nominal parameter label). Sert
* FlatPlain **et** CrystalChamber. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
* `FillOnly` quand une colonne atteint la boîte, `Identity` (le cas courant) sinon. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed);
/**
* SurfaceWorld, COMPLET : colonne (sol + voûte) → densité, overhang 3D, post structurel, et le
* mélange de biomes quand `PerBiomeParams` est non vide.
*
* @param PerBiomeParams vide ⇒ pas de biomes, chemin d'origine strictement inchangé. Non vide
* ⇒ une pile de hauteur COMPLÈTE par biome, sol mélangé / voûte
* sélectionnée, amplitude d'overhang interpolée (§5, combiner `Mask`).
* @param BiomeField **transféré** à la pile, qui le possède. Doit répondre pour les mêmes
* indices que `PerBiomeParams`. `nullptr` avec des params non vides ⇒
* biome 0 partout (dégradation sûre, pas un crash).
*/
VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
const TArray<FSurfaceGenerationParams>& PerBiomeParams =
TArray<FSurfaceGenerationParams>(),
TUniquePtr<IVoxelBiomeField> BiomeField = nullptr,
bool bAppendStructuralPosts = true);
/**
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
* SlabVoidSource → GridColumnMod → [structural post ×4]
*
* Deux des huit archétypes tiennent dans un opérateur (OPSTACK-PLAN §4) : leur différence est
* un jeu de valeurs par défaut, pas du code.
*/
VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
bool bAppendStructuralPosts = true);
/**
* VerticalShafts — 11 ops, dont les connecteurs air/sol et les quatre structural posts :
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge
* → ShaftConnectorAir → ShaftConnectorFloor → [structural post ×4]
*
* Trois de ces ops sont ceux de Maze (`§2.5`), avec une source différente et d'autres réglages
* (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
*/
VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
bool bAppendStructuralPosts = true);
/**
* TunnelNetwork — **COMPLET** :
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [spine, boundary, passage, graph-air, XY seal]
*
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
* ONZE des douze modificateurs y lisent leurs champs. La rugosité (4b) lit les params de la
* STRATE, parce que dans l'original elle précède la déclaration du shadow.
*
* ⚠️ `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
* §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est
* d'avoir UNE définition de chaque idée.
*/
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
const FStrateGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
bool bAppendStructuralPosts = true);
/**
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle
* de `FRoomGraphSource` évaluée sur ce thread. **Tests uniquement. N'entre dans aucune décision
* de génération.**
*
* ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
* pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
* DEUXIÈME définition du critère, qui dériverait de la vraie et mentirait exactement le jour où
* on la croirait. C'est la même raison qui a fait exister `VF_BuildOpStackForChunk`. On expose
* donc ce que l'opérateur a réellement calculé.
*
* `Hit*` = combien de primitives de cette classe atteignent la boîte (0 partout ⇒ `Identity`).
* `Num*` = combien le cache en contenait, ce qui distingue « aucune n'atteint » de « il n'y en
* avait aucune » — deux zéros de sens opposé.
*
* Reads back what the operator actually computed, rather than letting the test re-derive the
* criterion: a second copy would drift and would lie on the day it was believed.
*/
struct FRoomBoxDiagnostic
{
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
/** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte
* est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt
* qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de
* resserrement autour du mauvais terme. */
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
float WarpDilation = 0.0f;
/** Conservative candidate for the world-space tunnel-core post that runs after EvalMC.
* This is derived from the final post's own world-chain bounds, not from the warped SDF
* source's broader threshold hit count. */
bool bMayHaveTunnelCoreAir = false;
bool bMayHaveTunnelSupportFloor = false;
bool bMayHaveTunnelCoreTail = false;
uint32 ExactTailQueries = 0;
uint32 ExactTailEvaluated = 0;
uint64 ExactTailCycles = 0;
uint64 PropagateCycles = 0;
uint64 ExactPrimitiveCycles = 0;
uint64 CacheWindowCycles = 0;
int32 NumRoomFloorJoins = 0;
};
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
/** Current worker-local room-graph cache footprint; profiling/diagnostic use only. */
VOXELFORGE_API void ReportWorkerRoomGraphCacheFootprint();
/** Current process-wide immutable room-graph proof-cache footprint; diagnostic use only. */
VOXELFORGE_API void ReportSharedRoomGraphCacheFootprint();
/** Drop completed proof caches from older manager lifetimes, or all of them when zero. */
VOXELFORGE_API void TrimSharedRoomGraphCache(uint64 KeepManagerLifetimeId = 0);
/**
* FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4]
*
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et
* `FSdfConvertOp` sont les mêmes classes au signe près, et `FSdfRoughnessMod` est repris sans
* une ligne de changement (4ᵉ archétype). Seul le blob d'île est nouveau.
*
* The stack that runs backwards: void source + fill instead of rock source + carve, using the
* SAME operators with the opposite sign.
*/
VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
bool bAppendStructuralPosts = true);
/**
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
* ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]
*
* C'est le test de la Phase 1 : si Maze ne se décompose pas ainsi, l'abstraction est mauvaise
* pour ce domaine (OPSTACK-PLAN §4, déclencheur d'arrêt).
*/
VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
bool bAppendStructuralPosts = true);
}
/** Materialise an offline recipe. Structural posts are appended by default; lateral region cores
* deliberately pass false so the parent stack can append the global posts exactly once. */
VOXELFORGE_API bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe,
const FVoxelStrateArchetypeParams& Params,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext,
FString* OutError = nullptr,
bool bAppendStructuralPosts = true);
/** Build the region combiner stack. Creative cores are independent; structural posts are global. */
#if WITH_EDITOR
VOXELFORGE_API bool VF_BuildStrateRegionStack(
const FVoxelStrateRegionManifest& Manifest,
float SpineRadius,
const UVoxelStrateManager* StrateManager,
FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext,
FString* OutError = nullptr);
#endif