diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6ee7bed..b86a299 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -378,6 +378,11 @@ driven by `EditorBrush*` props. no loads AND no LOD mismatches outstanding. - **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1 sampling must not thrash the (expensive) rebuild. +- **Operator channel metadata** (`FVoxelOpStack`): `ChannelReads`, `ChannelWrites`, and + `IsAdditive` are called only when an operator is added to a stack and their values are cached in + the stack entry. `ValidateChannelOrder` is assembly/diagnostic-only; no declaration virtual may + enter `Eval`'s per-voxel loop. If `FVoxelOpSample` gains a field, add its channel bit and update + every operator declaration and the validator together. - **VerticalShafts field cache**: shaft rolls and connector decisions, including the structural tree and structural-spine connector endpoint, are rebuilt only when the thread-local centre cell or a geometry-affecting parameter changes. Each rebuild rolls a wider direct-indexed geometric diff --git a/CODEMAP.md b/CODEMAP.md index 95a67a5..27797d7 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -102,10 +102,11 @@ reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for t | Symbol | Notes | |--------|-------| | `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→vertical seal→passage→XY edge seal→diff, appended automatically, never author-omittable). | +| `EVoxelOpChannel` / `EVoxelOpChannelMask` | The explicit `FVoxelOpSample` channel set: `Density` and `Sdf` (plus `None`/`All` masks). Every density op declares the channels it reads and writes. | | `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. | | `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. | | `FVoxelOpContext` | Chunk-constant inputs. Carries `LayoutVersion` plus the copied global `WorldRadiusVoxels`/`EdgeSealThickness`, so a new op cannot forget them. | -| `IVoxelDensityOp` | `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. | +| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. The three declarations are pure virtual and are snapshotted during stack assembly; none is called per voxel. | | `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the vertical seal and XY edge seal inside their proven bands) overwrite the input entirely, which pure direction cannot express. | | `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. | | `FVoxelBoxHypotheses` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`. Reproduces today's hand-written `ClassifyTile` line for line — the mapping is written out in the header. | @@ -142,7 +143,8 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar | Symbol | Role | Notes | |--------|------|-------| -| `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold lets a later forcing post overwrite a dead hypothesis). | +| `FVoxelOpStack` | — | Ordered move-only list of `TUniquePtr` entries. `Add` snapshots channel metadata; `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold lets a later forcing post overwrite a dead hypothesis). | +| `FVoxelOpStack::ValidateChannelOrder` | — | Assembly/diagnostic-only channel DAG validator. It requires prior producers, rejects write-only clobbers and invalid additive declarations, and permits the one root SDF identity fold used by Maze. `VoxelForge.OpStack.ChannelDAG` validates all 8 shipping stacks. | | `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. | | `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). | | `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. | @@ -159,7 +161,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar | `VoxelDensityOps::BuildVerticalShaftStack` | — | 9 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). Passes the runtime spine radius into `FShaftFieldSource`, which mirrors the generator's connector-only origin endpoint and roughness-safe spine capsule. The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. | | `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)` — **any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `−K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|A−B\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. | | `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. | -| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 20 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C) → 4 structural posts. Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). | +| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 20 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C) → 4 structural posts. Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). The requested 4b→4h fuse is pending: all 10 intervening ops touch `Density`, so moving the corrective phase fails the bit-identity precondition. | | `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. | | `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. | | `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 43e9a47..6dca95c 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -88,12 +88,16 @@ terrain from those, deterministically, exactly as today. **No runtime compositio ### 3.2 Structure: how a stack gets assembled -The three real builders, for reference: +The current shipping builders, for reference (structural posts included; TunnelNetwork's builder is +also the Underwater builder): ``` -TunnelNetwork (19) rock source -> room graph (SDF) -> SdfCarve -> 12 density modifiers -> worm source -> structural -Maze (7) rock source -> lattice corridors (SDF) -> SdfRoughness (SDF) -> SdfCarve -> structural -FloatingIslands (7) VOID source -> island blobs (SDF) -> SdfRoughness (SDF) -> SdfFill -> structural +TunnelNetwork / Underwater (20) rock source -> room graph (SDF) -> SdfCarve -> 12 density modifiers -> worm source -> structural +FlatPlain / CrystalChamber (6) slab-void source -> grid columns -> structural +Maze (8) rock source -> lattice corridors (SDF) -> SdfRoughness (SDF) -> SdfCarve -> structural +SurfaceWorld (6) surface column -> overhang -> structural +VerticalShafts (9) rock source -> shaft field (SDF) -> SdfRoughness (SDF) -> SdfCarve -> ledges -> structural +FloatingIslands (8) VOID source -> island blobs (SDF) -> SdfRoughness (SDF) -> SdfFill -> structural ``` **⭐ Most ordering is MECHANICAL — derive it, do not author it.** Roughness sits on the *SDF* before @@ -103,6 +107,13 @@ which it writes**. ⇒ **Every operator declares its channel reads/writes.** Dependencies form a DAG and **any topological sort of that DAG is a legal stack** — no authored order, and it produces orderings nobody wrote down. +**Built status — 2026-09-04:** `EVoxelOpChannel` now names the two fields actually carried by +`FVoxelOpSample` (`Density` and `Sdf`); all 29 concrete density operators declare reads, writes, and +additive/transformative behavior. `FVoxelOpStack::Add` snapshots that metadata, and +`ValidateChannelOrder` checks the resulting channel DAG without entering the voxel loop. +`VoxelForge.OpStack.ChannelDAG` builds all eight shipping stacks and validates them: **22 tests +succeeded, 0 failed, 0 not run**. + **⭐ Root polarity is a one-bit identity lever.** FloatingIslands is Maze's op classes rooted in a **void** source with a **fill** instead of a rock source with a carve — and you get islands instead of tunnels. @@ -114,8 +125,17 @@ into one op with two internal phases. That removes the invalid state instead of ops that compose in any legal order beat eighteen with a footnote. (Fusing with identical order and math leaves the field bit-identical.) +**Current implementation finding:** the fuse is not landed. Ten operators intervene between 4b and +4h — `FCaveTerraceMod`, `FLayerLineMod`, `FRibbingMod`, `FCaveOverhangMod`, `FCaveCliffMod`, +`FScallopMod`, `FCaveArchMod`, `FRoomColumnMod`, `FDomeMod`, and `FPinchMod` — and every one reads +and writes `Density`. Moving the floor phase next to roughness would therefore reorder the live +density updates; the required bit-identical gate cannot be assumed. The current 20-op stack remains +in its original order until a fusion that preserves that order and math is available. + **One declaration covers the rest:** an op is **additive** (small displacement, commutes freely — shuffle at will) or **transformative** (clamps, multiplies, gates — position matters, needs explicit placement). +“Commutes” here is algebraic: IEEE-754 float accumulation is not generally bitwise commutative, so a +shuffle still needs the equivalence gate. **The structure roll:** `root polarity -> shape source -> conversion (follows from polarity) -> draw k modifiers legal in the @@ -485,8 +505,10 @@ they became "findings" (§13). (fall exposure, openness, traversal mix, tortuosity, corpus-centroid distance). ### Tier 3 — op-system prerequisites for free composition -- **Channel read/write declarations** on every op, so ordering derives from a DAG (§3.2). -- **Fuse corrective ops** (`FFloorBiasMod` into `FCaveRoughnessMod`). +- ✅ **Channel read/write declarations** on every op, plus the stack DAG validator (§3.2; built + 2026-09-04). +- ⏸ **Fuse corrective ops** (`FFloorBiasMod` into `FCaveRoughnessMod`) — blocked by the ten + intervening `Density` readers/writers under the bit-identity requirement; see §3.2. - ⚠️ **`EffectOverBox` correct in ISOLATION** (§6.1) — the gate on everything after. - **The XY edge seal** (§6.7). @@ -560,6 +582,12 @@ DiffLayerContention · FloatingIslandEquivalence · LargeSeedSurvives · **Layou LiveEditInvalidation · MazeEquivalence · SlabEquivalence · **StrateParamBlendCoverage** · SurfaceHeightEquivalence · TunnelNetworkSpineEquivalence · VerticalShaftEquivalence. +**2026-09-04 — Tier 3b validation run.** The specified UE 5.7 `VoxelMEditor Win64 Development` +build succeeded (14 actions). The full headless suite reported **22 succeeded, 0 failed, 0 not run, +0 succeeded-with-warnings**, including `VoxelForge.OpStack.ChannelDAG`; the existing equivalence, +determinism, box-verdict, and connectivity checks stayed green. The 4b→4h fuse was deliberately +not applied after the intervening-channel audit above. + **⭐ This closes the pending-verification thread opened by `OPSTACK-HANDOFF.md`.** That file flagged commits `4d33321` (Sol's boundary fold) and `91585ea` as *built but NOT re-verified*, with two acceptance conditions. Both now met: diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackChannelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackChannelTest.cpp new file mode 100644 index 0000000..860eae5 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackChannelTest.cpp @@ -0,0 +1,204 @@ +// VoxelForgeOpStackChannelTest.cpp +// The channel declarations are executable stack-assembly metadata, not comments. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelDensityOpStack.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeOpStackChannelTest, + "VoxelForge.OpStack.ChannelDAG", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + /** A deliberately small declaration-only op for the validator's negative controls. */ + class FDeclarationProbeOp final : public IVoxelDensityOp + { + public: + FDeclarationProbeOp(EVoxelOpChannelMask InReads, + EVoxelOpChannelMask InWrites, + bool bInAdditive) + : Reads(InReads), Writes(InWrites), bAdditive(bInAdditive) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return Reads; } + EVoxelOpChannelMask ChannelWrites() const override { return Writes; } + bool IsAdditive() const override { return bAdditive; } + void PrepareChunk(const FVoxelOpContext&) override {} + void Eval(float, float, float, FVoxelOpSample&) const override {} + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Identity; + } + + const TCHAR* DebugName() const override { return TEXT("DeclarationProbeOp"); } + + private: + EVoxelOpChannelMask Reads; + EVoxelOpChannelMask Writes; + bool bAdditive; + }; +} + +bool FVoxelForgeOpStackChannelTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const UVoxelGenerator* Generator = World.Generator.Get(); + const int32 Seed = World.Settings->Seed; + + auto MidChunkForSlot = [&](int32 Slot, const TCHAR* Name, int32& OutMidChunkZ) -> bool + { + int32 TopVoxelZ = 0; + int32 BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(Slot, TopVoxelZ, BottomVoxelZ)) + { + AddError(FString::Printf( + TEXT("The fixture has no %s slot; check FTestWorld::Build's Archetypes[] and " + "the corresponding slot constant."), Name)); + return false; + } + OutMidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE; + return true; + }; + + auto Validate = [&](FVoxelOpStack& Stack, const TCHAR* Name, int32 ExpectedNum) + { + TestEqual(*FString::Printf(TEXT("%s shipping stack op count"), Name), + Stack.Num(), ExpectedNum); + + FString Error; + const bool bValid = Stack.ValidateChannelOrder(&Error); + if (!bValid) + { + AddError(FString::Printf(TEXT("%s shipping stack failed channel validation: %s"), + Name, *Error)); + } + TestTrue(*FString::Printf(TEXT("%s shipping stack has a legal channel DAG"), Name), bValid); + }; + + // These are the eight builders currently shipped behind bUseOperatorStack. Keep the + // archetype list explicit so adding a builder without adding its validator assertion is + // visible in this test. + int32 MidChunkZ = 0; + + if (MidChunkForSlot(FTestWorld::SlotTunnelNetwork, TEXT("TunnelNetwork"), MidChunkZ)) + { + const FStrateGenerationParams Params = + World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildTunnelNetworkStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("TunnelNetwork"), 20); + } + + if (MidChunkForSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"), MidChunkZ)) + { + const FSlabGenerationParams Params = + World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildSlabStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("FlatPlain"), 6); + } + + if (MidChunkForSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"), MidChunkZ)) + { + const FSlabGenerationParams Params = + World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildSlabStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("CrystalChamber"), 6); + } + + if (MidChunkForSlot(FTestWorld::SlotMaze, TEXT("Maze"), MidChunkZ)) + { + const FMazeGenerationParams Params = + World.StrateManager->GetMazeParamsForChunk(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildMazeStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("Maze"), 8); + } + + if (MidChunkForSlot(FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld"), MidChunkZ)) + { + const FSurfaceGenerationParams Params = + World.StrateManager->GetSurfaceParamsForChunk(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildSurfaceStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("SurfaceWorld"), 6); + } + + if (MidChunkForSlot(FTestWorld::SlotVerticalShafts, TEXT("VerticalShafts"), MidChunkZ)) + { + const FVerticalShaftParams Params = + World.StrateManager->GetVerticalShaftParamsForChunk(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildVerticalShaftStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("VerticalShafts"), 9); + } + + if (MidChunkForSlot(FTestWorld::SlotFloatingIsland, TEXT("FloatingIslands"), MidChunkZ)) + { + const FFloatingIslandParams Params = + World.StrateManager->GetFloatingIslandParamsForChunk(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildFloatingIslandStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("FloatingIslands"), 8); + } + + if (MidChunkForSlot(FTestWorld::SlotUnderwater, TEXT("Underwater"), MidChunkZ)) + { + const FStrateGenerationParams Params = + World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ)); + FVoxelOpStack Stack; + VoxelDensityOps::BuildTunnelNetworkStack( + Stack, Params, Seed, Generator->OriginSpineRadius, World.StrateManager.Get()); + Validate(Stack, TEXT("Underwater"), 20); + } + + // A declaration that reads an unavailable channel must fail, and a second write-only + // replacement must not silently erase the first producer. These controls pin the validator's + // two most important safety rules independently of the shipping builders. + { + FVoxelOpStack MissingProducer; + MissingProducer.Add(MakeUnique( + VoxelOpChannels::Density, VoxelOpChannels::Density, true)); + FString Error; + TestFalse(TEXT("a read before its first producer is rejected"), + MissingProducer.ValidateChannelOrder(&Error)); + } + + { + FVoxelOpStack Clobber; + Clobber.Add(MakeUnique( + VoxelOpChannels::None, VoxelOpChannels::Density, false)); + Clobber.Add(MakeUnique( + VoxelOpChannels::None, VoxelOpChannels::Density, false)); + FString Error; + TestFalse(TEXT("a second write-only replacement is rejected"), + Clobber.ValidateChannelOrder(&Error)); + } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 4c4fa9f..4389d19 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -226,6 +226,9 @@ namespace explicit FConstantFieldSource(float InValue) : Value(InValue) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} bool IsXYPure() const override { return true; } // constant ⇒ trivialement sans Z @@ -291,6 +294,9 @@ namespace {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Sdf; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -464,6 +470,9 @@ namespace } EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} // ⚠️⚠️ CORRIGÉ 2026-07-27 : c'était `true`, ET C'ÉTAIT FAUX. @@ -901,6 +910,9 @@ namespace const FSurfaceGenerationParams& GetParams() const { return P; } EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } bool IsXYPure() const override { return false; } // voir le bloc ci-dessus /** @@ -1029,6 +1041,9 @@ namespace : P(InP), SeedU((uint32)Seed), Column(InColumn) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} bool IsXYPure() const override { return false; } // franchement non : voir `Frac` @@ -1097,6 +1112,9 @@ namespace , BaseOctaves(InBaseOctaves), ApplyWithin(InApplyWithin) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Sdf; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } // gate depends on the SDF it writes void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -1163,6 +1181,9 @@ namespace {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} bool IsXYPure() const override { return true; } // cylindres de hauteur infinie @@ -1303,6 +1324,12 @@ namespace : Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign), MinDivisor(InMinDivisor) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float, float, float, FVoxelOpSample& InOut) const override @@ -1341,6 +1368,9 @@ namespace : TopZ(InTopZ), BotZ(InBotZ), Seal(InSeal), Base(InBase), Radius(InRadius) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override @@ -1383,6 +1413,9 @@ namespace : TopZ(InTopZ), BotZ(InBotZ), Thickness(InThickness), Base(InBase) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float, float, float Z, FVoxelOpSample& InOut) const override @@ -1452,6 +1485,9 @@ namespace : Base(InBase) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext& Ctx) override { @@ -1511,6 +1547,9 @@ namespace : Manager(InManager), Base(InBase), Seal(InSeal) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override @@ -1569,6 +1608,9 @@ namespace , ExtraReach(InExtraReach), SpineRadius(InSpineRadius) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} struct FShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; }; @@ -2193,6 +2235,12 @@ namespace : P(InP), Field(InField) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -2276,6 +2324,9 @@ namespace , ExtraReach(InExtraReach) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} struct FIsland { float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; }; @@ -2510,6 +2561,9 @@ namespace {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext& Ctx) override { @@ -3318,6 +3372,12 @@ namespace : P(InP), SeedU((uint32)Seed), RoomsForBox(InRoomsForBox) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -3489,6 +3549,12 @@ namespace : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -3587,6 +3653,12 @@ namespace : P(InP), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override @@ -3663,6 +3735,12 @@ namespace : P(InP), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override @@ -3727,6 +3805,12 @@ namespace : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -3807,6 +3891,12 @@ namespace : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -3870,6 +3960,12 @@ namespace : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -3942,6 +4038,12 @@ namespace : P(InP), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -4048,6 +4150,12 @@ namespace : P(InP), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override @@ -4099,6 +4207,12 @@ namespace : P(InP), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -4195,6 +4309,12 @@ namespace : P(InP), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -4290,6 +4410,10 @@ namespace // ⚠️ 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. + // + // TIER 3b AUDIT (2026-09-04): this op is intentionally still separate. Ten operators lie + // between 4b and this phase (4c through 4h), and every one reads+writes Density. Moving this + // phase next to 4b would reorder those density updates, so a bit-identical fuse is not legal. class FFloorBiasMod final : public IVoxelDensityOp { public: @@ -4297,6 +4421,12 @@ namespace : P(InP), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override @@ -4357,6 +4487,12 @@ namespace : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -4546,6 +4682,116 @@ VoxelDensityOps::FRoomBoxDiagnostic VoxelDensityOps::GetLastRoomBoxDiagnostic() // FVoxelOpStack //============================================================================= +bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const +{ + if (OutError != nullptr) { OutError->Reset(); } + + auto DescribeMask = [](EVoxelOpChannelMask Mask) -> FString + { + FString Result; + if ((Mask & VoxelOpChannels::Density) != 0) { Result += TEXT("Density"); } + if ((Mask & VoxelOpChannels::Sdf) != 0) + { + if (!Result.IsEmpty()) { Result += TEXT(", "); } + Result += TEXT("Sdf"); + } + return Result.IsEmpty() ? TEXT("none") : Result; + }; + + auto Fail = [&](int32 Index, const FOpEntry* Entry, const TCHAR* Rule) -> bool + { + if (OutError != nullptr) + { + const TCHAR* Name = (Entry != nullptr && Entry->Op.Get() != nullptr) + ? Entry->Op->DebugName() : TEXT("(null op)"); + const EVoxelOpChannelMask Reads = Entry != nullptr ? Entry->Reads : VoxelOpChannels::None; + const EVoxelOpChannelMask Writes = Entry != nullptr ? Entry->Writes : VoxelOpChannels::None; + *OutError = FString::Printf( + TEXT("op %d (%s) violates channel DAG: %s; reads=[%s], writes=[%s]"), + Index, Name, Rule, *DescribeMask(Reads), *DescribeMask(Writes)); + } + return false; + }; + + // The two array slots are deliberately explicit: FVoxelOpSample has exactly two fields, and + // adding a third field requires extending EVoxelOpChannel and this validator together. + int32 LastWriter[2] = { INDEX_NONE, INDEX_NONE }; + + auto ChannelIndex = [](EVoxelOpChannelMask Channel) -> int32 + { + return Channel == VoxelOpChannels::Density ? 0 : 1; + }; + + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + if (Entry.Op.Get() == nullptr) { return Fail(Index, &Entry, TEXT("null operator")); } + + if ((Entry.Reads & VoxelOpChannels::All) != Entry.Reads) + { + return Fail(Index, &Entry, TEXT("reads an unknown sample channel")); + } + if ((Entry.Writes & VoxelOpChannels::All) != Entry.Writes) + { + return Fail(Index, &Entry, TEXT("writes an unknown sample channel")); + } + + if (Entry.bAdditive && Entry.Writes == VoxelOpChannels::None) + { + return Fail(Index, &Entry, TEXT("an additive operator must publish a channel")); + } + if (Entry.bAdditive + && (Entry.Writes & static_cast(~Entry.Reads)) != 0) + { + return Fail(Index, &Entry, + TEXT("an additive operator must read every channel it writes")); + } + + const EVoxelOpChannelMask Channels[] = { + VoxelOpChannels::Density, VoxelOpChannels::Sdf + }; + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Entry.Reads & Channel) == 0) { continue; } + + const int32 Writer = LastWriter[ChannelIndex(Channel)]; + const bool bRootIdentityFold = Entry.Op->GetRole() == EVoxelOpRole::FieldSource + && (Entry.Writes & Channel) != 0 + && Writer == INDEX_NONE; + if (Writer == INDEX_NONE && !bRootIdentityFold) + { + return Fail(Index, &Entry, + TEXT("reads a channel before a producer has published it")); + } + } + + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Entry.Writes & Channel) == 0 || (Entry.Reads & Channel) != 0) { continue; } + + // A write-only op is an assignment/replacement. It may establish the first version, + // but it may not erase a previously produced channel without declaring a read of it. + if (LastWriter[ChannelIndex(Channel)] != INDEX_NONE) + { + return Fail(Index, &Entry, + TEXT("a write-only replacement would clobber an existing channel")); + } + } + + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Entry.Writes & Channel) != 0) + { + // Every read above has either consumed this writer's predecessor or the explicit + // root identity. Updating the version here makes all later consumer edges forward. + LastWriter[ChannelIndex(Channel)] = Index; + } + } + } + + return true; +} + void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ, float SealThickness, float BaseDensity, float SpineRadius, const UVoxelStrateManager* StrateManager) diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index fc3f083..c38b627 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -113,6 +113,34 @@ enum class EVoxelOpRole : uint8 StructuralPost, }; +//============================================================================= +// CANAUX DE L'ÉCHANTILLON / SAMPLE CHANNELS +//============================================================================= +// `FVoxelOpSample` is deliberately a small, explicit state record. Keep its channel set here, +// next to the interface that declares which fields an operator consumes and publishes. The enum +// values are bits so a declaration can name more than one channel without introducing another +// per-voxel object or a dynamic container. +// +// `Density` is the internal positive=solid field; `Sdf` is the standard negative=inside field. +// If a future field is added to `FVoxelOpSample`, it must also be added here and to every operator +// declaration before the composer can treat that field as part of the dependency graph. +enum class EVoxelOpChannel : uint8 +{ + None = 0, + Density = 1 << 0, + Sdf = 1 << 1, +}; + +using EVoxelOpChannelMask = uint8; + +namespace VoxelOpChannels +{ + static constexpr EVoxelOpChannelMask None = 0; + static constexpr EVoxelOpChannelMask Density = static_cast(EVoxelOpChannel::Density); + static constexpr EVoxelOpChannelMask Sdf = static_cast(EVoxelOpChannel::Sdf); + static constexpr EVoxelOpChannelMask All = Density | Sdf; +} + //============================================================================= // COMPOSITION / COMBINERS //============================================================================= @@ -270,6 +298,28 @@ public: virtual EVoxelOpRole GetRole() const = 0; + /** + * Channel contract for the composer. A read is a field inspected from `InOut` before this op + * publishes its result; a write is a field whose value may differ when the op returns. A + * read/write declaration therefore means "transform the current version". `+=` and `-=` are + * both reads and writes; an assignment that ignores the old field is a write-only producer / + * replacement. Context, coordinates, caches and referenced objects are not sample channels. + * + * These are graph metadata, not voxel work: the stack snapshots them while it is assembled and + * the validator reads the snapshot. They must never be called from `Eval`. + */ + virtual EVoxelOpChannelMask ChannelReads() const = 0; + virtual EVoxelOpChannelMask ChannelWrites() const = 0; + + /** + * True when this op contributes a delta to its written channels without replacing, clamping, + * selecting, or otherwise depending on the previous value of those written channels. Such an + * op may be shuffled with another compatible additive delta; a false result means its position + * is explicit. A gate on a channel the op itself writes is transformative even if its final + * arithmetic contains `+=`. + */ + virtual bool IsAdditive() const = 0; + /** * Hisser ici TOUT le travail constant sur le chunk : listes de salles, grilles de biome, * caches de colonnes, cuissons de treillis. Appelé une fois par chunk et par worker. diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index ba53966..310fd7f 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -59,6 +59,17 @@ class UVoxelStrateManager; */ 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 Op; + EVoxelOpChannelMask Reads = VoxelOpChannels::None; + EVoxelOpChannelMask Writes = VoxelOpChannels::None; + bool bAdditive = false; + }; + 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 @@ -66,13 +77,13 @@ public: // // ⚠️ 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>`, d'où + // d'affectation par copie — impossible à générer pour un `TArray`, 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> (error C2280). Export the out-of-line + // cannot be generated for a TArray (error C2280). Export the out-of-line // method instead. FVoxelOpStack() = default; FVoxelOpStack(FVoxelOpStack&&) = default; @@ -80,7 +91,20 @@ public: FVoxelOpStack(const FVoxelOpStack&) = delete; FVoxelOpStack& operator=(const FVoxelOpStack&) = delete; - void Add(TUniquePtr Op) { Ops.Add(MoveTemp(Op)); } + void Add(TUniquePtr Op) + { + 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(); + } + Ops.Add(MoveTemp(Entry)); + } int32 Num() const { return Ops.Num(); } @@ -89,7 +113,7 @@ public: * mensonge utile qui finirait par masquer une course. */ void PrepareChunk(const FVoxelOpContext& Ctx) { - for (const TUniquePtr& Op : Ops) { Op->PrepareChunk(Ctx); } + for (const FOpEntry& Entry : Ops) { Entry.Op->PrepareChunk(Ctx); } } /** @@ -110,7 +134,7 @@ public: FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const { FVoxelOpSample S; - for (const TUniquePtr& Op : Ops) { Op->Eval(WorldX, WorldY, WorldZ, S); } + for (const FOpEntry& Entry : Ops) { Entry.Op->Eval(WorldX, WorldY, WorldZ, S); } return S; } @@ -128,9 +152,9 @@ public: EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const { FVoxelBoxHypotheses H; - for (const TUniquePtr& Op : Ops) + for (const FOpEntry& Entry : Ops) { - VF_FoldOp(H, *Op, VoxelBox, Ctx); + VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx); // 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 carving). } @@ -165,7 +189,7 @@ public: const bool bSolidBefore = H.bCanBeAllSolid; const bool bAirBefore = H.bCanBeAllAir; - VF_FoldOp(H, *Ops[i], VoxelBox, Ctx); + VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx); if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; } if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; } @@ -177,9 +201,31 @@ public: /** 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]->DebugName() : TEXT("(none)"); + 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 → seal de limite XY. @@ -201,7 +247,7 @@ public: const UVoxelStrateManager* StrateManager); private: - TArray> Ops; + TArray Ops; }; //=============================================================================