diff --git a/CODEMAP.md b/CODEMAP.md index cefb23a..dd13107 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -106,7 +106,7 @@ reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for t | `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` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `PrepareChunk` / `Eval` / intrinsic and state-aware `EffectOverBox` / `MaxCarveOverBox` / `MaxFillOverBox` / `PropagateSdfOverBox` / `ClassifyBox` / `IsXYPure`. Box-query methods are never called per voxel; channel declarations are snapshotted during stack assembly. | +| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `RequiredResources` / `ProvidedResources`, `PrepareChunk` / `Eval`, and intrinsic/state-aware `EffectOverBox` / `MaxCarveOverBox` / `MaxFillOverBox` / `PropagateSdfOverBox` / `ClassifyBox` / `IsXYPure`. Box-query methods are never called per voxel; channel/resource declarations are snapshotted during stack assembly. | | `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` + `FVoxelBoxSdfInterval` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`, carrying the SDF interval between independent writers and consumers. Unknown is biased toward `Mixed`; the mapping and soundness rule are written out in the header. | @@ -144,7 +144,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar | Symbol | Role | Notes | |--------|------|-------| | `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::ValidateChannelOrder` | — | Assembly/diagnostic-only channel/resource DAG validator. It requires prior producers, rejects write-only clobbers and invalid additive declarations, and requires room/shaft/surface state providers before consumers. It 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. | @@ -438,8 +438,8 @@ One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMi atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to vertex colour, §8.15)), `GameplayTags`. Referenced from `UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14. -**`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp`** (Tier 4a) — offline -`FVoxelStrateCorpus` and `VF_RollStrateParams`. `LoadFromAssetRegistry` enumerates every project +**`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp`** (Tier 4a/4b) — offline +`FVoxelStrateCorpus`, `VF_RollStrateParams`, and `VF_RollStrateStructure`. `LoadFromAssetRegistry` enumerates every project `UVoxelStrateDefinition` through the Asset Registry, excludes `Saved/Autosaves` and `Saved/Cooked` copies, and adds one `VoxelStrateTypes.h` default vector for each of the eight exact archetypes. The corpus is grouped by exact `ECaveGeneratorType`: the 2026-09-04 run loaded **4 project @@ -454,6 +454,15 @@ from the dominant parent; integer/enum SNAP fields are not jittered. This API is runtime generation. The settings audit found **one unique path**, `/Game/VoxelForge/DA_Strate3`, which explains the old one-vector corpus; the project assets were never in that pool. +`FVoxelOpStackRecipe` is the serialisable Tier 4b manifest: root polarity, root/source/conversion +class IDs, an ordered modifier ID list, and one native parameter-family ID per entry. Posts are not +represented in the manifest; `VF_BuildStackFromRecipe` appends spine, vertical seal, passage carve, +and XY edge seal in the fixed order. The roller derives modifier legality from the op channel and +resource declarations, rolls 4–8 unique modifiers, and validates the recipe before materialising +it. `RequiredResources` / `ProvidedResources` close the room-state hole that channel masks alone +could not express. `VoxelForgeComposerStructureRollTest` rolls 64 candidates, measures them through +the offline sampler, and brute-forces every uniform box verdict from the novel stack itself. + ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; **negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`. @@ -517,7 +526,8 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | -| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. The final full-suite run: **59/64 survival (92.2%)**, **1,336 Mixed + 549 AllSolid + 675 AllAir = 1,224 proved boxes**, **1,629,144 lattice voxels checked, 0 violations**, 112.574 s total. | +| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. Current run: **59/64 survival (92.2%)**, **1,336 Mixed + 549 AllSolid + 675 AllAir = 1,224 proved boxes**, **1,629,144 lattice voxels checked, 0 violations**, 114.845 s total. | +| `VoxelForgeComposerStructureRollTest.cpp` | `VoxelForge.Composer.StructureRoll` | Rolls root polarity → legal shape source → polarity-derived conversion → 4–8 declaration-legal modifiers → mandatory structural posts; blends the six native parameter families independently, measures 64 novel stacks, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. Focused run: **46/64 survival (71.9%)**, **64 distinct recipes**, **RoomGraph 15 / Lattice 19 / Shaft 8 / Island 14 / Noise 8**, **k=4:11 / 5:13 / 6:11 / 7:10 / 8:19**, **489 proved boxes / 650,859 voxels / 0 violations**. | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | | `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | | `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test fits the measurement AABB to each arrival/departure mouth pair, reports exact before/after seed-6 cell counts, reruns every negative with doubled margin, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts axis landings, checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 10ff295..bb737a9 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -160,6 +160,40 @@ corpus is small. **⇒ Rule: vary STRUCTURE aggressively, vary PARAMETERS conservatively.** Shift the ratio as the corpus grows. +**Tier 4b implementation (2026-09-04):** `FVoxelOpStackRecipe` is the shipped-manifest shape: it +stores the one-bit `RootPolarity`, root/source/conversion `EVoxelStrateOpClass` IDs, an ordered list +of modifier IDs, and an `EVoxelStrateParamBlock` on every entry. The four structural posts are +deliberately not a field in the recipe. `VF_BuildStackFromRecipe` appends spine, vertical seal, +passage carving, and XY edge seal in that fixed order, so an invented strate cannot omit primordial +law. The conversion ID is checked against polarity rather than rolled independently. + +The roller's modifier catalogue is a deterministic array of declared ops, not a compatibility list. +It asks each op for its channel reads/writes, additive contract, required resources, and provided +resources, then draws 4–8 unique entries from the legal remainder. The channel declarations exposed +a real missing dependency: `FCaveTerraceMod` and the other room-relative cave modifiers use the +`FRoomGraphSource` room cache through a non-owning pointer, even though their channel masks only said +`Density`. `FShaftLedgeMod` similarly consumes shaft state, and `FOverhangShelfMod` consumes the +surface-column state. `RequiredResources` / `ProvidedResources` now declare and validate those +dependencies (RoomGeometry, ShaftGeometry, SurfaceColumn); the roller cannot place such an op before +its provider. `FCaveRoughnessMod` and `FWormFieldSource` were checked and do not require room state: +they read only their declared channels and parameters. + +The parameter decision is deliberately visible: structure and parameter rolls are separate. Each +candidate rolls the six native family blocks independently from the exact-archetype corpus using the +Tier 4a conservative blend/jitter procedure. An op consumes the block named on its recipe entry; +structural posts consume the shape source's family block. There is no unsafe 73-field cross-family +blend. The current `SurfaceWorld` block is still rolled and carried even when a structure has no +surface-column op, so the recipe remains a complete family-parameter record. + +The focused 64-candidate run produced 64 distinct recipes, 0 invalid recipes, and 0 box-verdict +violations. It checked 650,859 voxels across 489 proved boxes. Survival was 46/64 (71.9%) under the +same non-vacuous, largest-share ≥ 0.50, exact `Connected` criteria as Tier 4a; that lower rate is an +expected consequence of aggressive structural exploration, not a tuning target. The test also +rerolls every recipe and rebuilt stack, checks `WorldRadiusVoxels == 0`, and feeds the novel stack +through the offline Tier 2 sampler; no runtime generation path calls this API. The final focused +test took **76.466 s**. The complete `VoxelForge` namespace then passed **24/24 tests (0 failed, +0 not run)** in **339.797 s**. + ### 3.3 Parameters: the range problem **Verified facts, not remembered:** `FStrateGenerationParams` has **73 scalar fields** (+1 bool) — not @@ -300,8 +334,8 @@ produced **59/64 survivors (92.2%)**. There were 60 non-vacuous candidates, 60 w component share ≥ 0.50, 63 exact unsnapped arrival→departure law passes, and no roll failures. The complete 64-row table is emitted by `VoxelForge.Composer.ParameterRoll`; each row reports archetype, weighted parents, air fraction, largest share, walkable fraction, feature scale, and -arrival→departure verdict. The final full-suite run took **112.574 s** total (**0.015 s** corpus -load, **112.556 s** roll/measurement). +arrival→departure verdict. The current run took **114.845 s** total (**0.014 s** corpus load, +**114.827 s** roll/measurement). The §6.2 check is no longer vacuous: **1,224 boxes proved**, **1,629,144 voxels checked**, **0 violations**. Its verdict mix was 1,336 Mixed, 549 AllSolid, and 675 AllAir; the law check @@ -650,8 +684,11 @@ roughness and should stay late in the stack. - ✅ **Parameter roll (§3.3, Tier 4a)** — corpus spread + deterministic blend/jitter implementation and the 64-candidate measurement pass are built; the first run exposed a one-vector settings corpus and a vacuous cave box-verdict scan, both recorded above. -- ⬜ Structure roll (§3.2) · reject-and-resample driven by Tier 2 (§3.5) · the **offline season - pipeline** with Jahni's review and veto (§3.1). +- ✅ **Structure roll (§3.2, Tier 4b)** — serialisable recipes, root polarity, declaration-derived + resource-safe modifier legality, mandatory posts, 64-candidate measurement, and direct box-verdict + brute force are built; focused result is 46/64 survival with 0 invalid recipes and 0 violations. +- ⬜ Reject-and-resample driven by Tier 2 (§3.5) · the **offline season pipeline** with Jahni's review + and veto (§3.1). ### Tier 5 — the long game **Promotion** (good strates rejoin the corpus) · theme and tag draws for materials, creatures and audio · diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp new file mode 100644 index 0000000..6fc0927 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp @@ -0,0 +1,564 @@ +// Tier 4b — deterministic structure recipes, dependency-safe materialisation, and measurement. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "HAL/PlatformTime.h" +#include "Math/RandomStream.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelDensityOpStack.h" +#include "VoxelSettings.h" +#include "VoxelStrateComposer.h" +#include "VoxelStrateMeasure.h" + +namespace +{ + constexpr int32 NumCandidates = 64; + constexpr float LargestComponentSurvivalThreshold = 0.50f; + constexpr int32 BoxChecksPerCandidate = 40; + constexpr int32 BoxStep = 1; + constexpr int32 BoxCells = 8; + + const TCHAR* VF_ConnectivityName(EVoxelConnectivityResult Result) + { + switch (Result) + { + case EVoxelConnectivityResult::Connected: return TEXT("Connected"); + case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnected"); + case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid"); + case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid"); + case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow"); + case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget"); + } + return TEXT("Unknown"); + } + + const TCHAR* VF_ShapeName(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::RoomGraphSource: return TEXT("RoomGraph"); + case EVoxelStrateOpClass::LatticeCorridorSource:return TEXT("Lattice"); + case EVoxelStrateOpClass::ShaftFieldSource: return TEXT("Shaft"); + case EVoxelStrateOpClass::IslandBlobSource: return TEXT("Island"); + case EVoxelStrateOpClass::NoiseRibbonSource: return TEXT("Noise"); + default: return TEXT("invalid"); + } + } + + int32 VF_FloorDiv(int32 A, int32 B) + { + const int32 Q = A / B; + const int32 R = A % B; + return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; + } + + bool VF_BitEqual(float A, float B) + { + uint32 ABits = 0; + uint32 BBits = 0; + FMemory::Memcpy(&ABits, &A, sizeof(ABits)); + FMemory::Memcpy(&BBits, &B, sizeof(BBits)); + return ABits == BBits; + } + + struct FBlockSpec + { + EVoxelStrateParamBlock Block; + ECaveGeneratorType Archetype; + uint32 SeedSalt; + }; + + static const FBlockSpec GBlockSpecs[] = + { + { EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u }, + { EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u }, + { EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u }, + { EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u }, + { EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u }, + { EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu }, + }; + + void VF_CopyRollBlock(FVoxelStrateArchetypeParams& OutParams, + const FVoxelStrateRollInfo& Roll, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams; + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutParams.SlabParams = Roll.ArchetypeParams.SlabParams; + break; + case ECaveGeneratorType::Maze: + OutParams.MazeParams = Roll.ArchetypeParams.MazeParams; + break; + case ECaveGeneratorType::SurfaceWorld: + OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams; + break; + case ECaveGeneratorType::VerticalShafts: + OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams; + break; + case ECaveGeneratorType::FloatingIslands: + OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams; + break; + default: + break; + } + } + + void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params, + float TopWorldZ, float BottomWorldZ) + { + Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ; + Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ; + Params.SlabParams.StrateTopWorldZ = TopWorldZ; + Params.SlabParams.StrateBottomWorldZ = BottomWorldZ; + Params.MazeParams.StrateTopWorldZ = TopWorldZ; + Params.MazeParams.StrateBottomWorldZ = BottomWorldZ; + Params.SurfaceParams.StrateTopWorldZ = TopWorldZ; + Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ; + Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ; + Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ; + Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ; + Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ; + } + + class FStackDensitySampler final : public IVoxelStrateDensitySampler + { + public: + explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {} + + float SampleDensity(float WorldX, float WorldY, float WorldZ) const override + { + return Stack.EvalMC(WorldX, WorldY, WorldZ); + } + + private: + const FVoxelOpStack& Stack; + }; + + struct FBoxVerdictReport + { + int32 Mixed = 0; + int32 AllSolid = 0; + int32 AllAir = 0; + int32 Proved = 0; + int64 CheckedVoxels = 0; + int32 Violations = 0; + FString FirstViolation; + }; + + FBoxVerdictReport VF_CheckStackBoxVerdicts(const FVoxelOpStack& Stack, + const FVoxelOpContext& Context, + int32 CandidateIndex, + int32 BottomVoxelZ, + int32 TopVoxelZ) + { + FBoxVerdictReport Report; + constexpr int32 Extent = BoxStep * BoxCells; + constexpr int32 SpanCells = 40; + const int32 LoTile = VF_FloorDiv(BottomVoxelZ, Extent); + const int32 HiTile = FMath::Max(LoTile, VF_FloorDiv(TopVoxelZ, Extent)); + FRandomStream Rng(0x6C617731 ^ CandidateIndex); + + for (int32 BoxIndex = 0; BoxIndex < BoxChecksPerCandidate; ++BoxIndex) + { + const FIntVector Origin( + Rng.RandRange(-SpanCells, SpanCells) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, + Rng.RandRange(LoTile, HiTile) * Extent); + const FBox Box( + FVector((float)(Origin.X - BoxStep), (float)(Origin.Y - BoxStep), + (float)(Origin.Z - BoxStep)), + FVector((float)(Origin.X + (BoxCells + 1) * BoxStep), + (float)(Origin.Y + (BoxCells + 1) * BoxStep), + (float)(Origin.Z + (BoxCells + 1) * BoxStep))); + const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Context); + if (Verdict == EVoxelTileClass::Mixed) + { + ++Report.Mixed; + continue; + } + if (Verdict == EVoxelTileClass::AllSolid) + { + ++Report.AllSolid; + } + else + { + ++Report.AllAir; + } + ++Report.Proved; + + const int32 GridDim = BoxCells + 1; + const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid; + for (int32 GZ = -1; GZ <= GridDim; ++GZ) + for (int32 GY = -1; GY <= GridDim; ++GY) + for (int32 GX = -1; GX <= GridDim; ++GX) + { + const float X = (float)(Origin.X + GX * BoxStep); + const float Y = (float)(Origin.Y + GY * BoxStep); + const float Z = (float)(Origin.Z + GZ * BoxStep); + const float Density = Stack.EvalMC(X, Y, Z); + ++Report.CheckedVoxels; + const bool bAgrees = bClaimsSolid ? (Density < 0.0f) : (Density >= 0.0f); + if (!bAgrees) + { + ++Report.Violations; + if (Report.FirstViolation.IsEmpty()) + { + Report.FirstViolation = FString::Printf( + TEXT("candidate %d box %d origin (%d,%d,%d) said %s but MC density " + "at (%.0f,%.0f,%.0f) was %.9g"), + CandidateIndex, BoxIndex, Origin.X, Origin.Y, Origin.Z, + bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), + X, Y, Z, Density); + } + } + } + } + return Report; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeComposerStructureRollTest, + "VoxelForge.Composer.StructureRoll", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + (void)Parameters; + + const double TestStartSeconds = FPlatformTime::Seconds(); + const FString SettingsPath = TEXT("/Game/VoxelForge/DA_Settings.DA_Settings"); + UVoxelSettings* AuthoredSettings = LoadObject(nullptr, *SettingsPath); + if (AuthoredSettings == nullptr) + { + AddError(FString::Printf(TEXT("Could not load %s; structure roll did not run."), *SettingsPath)); + return false; + } + + FVoxelStrateCorpus Corpus; + FString CorpusReport; + const bool bCorpusLoaded = Corpus.LoadFromAssetRegistry(CorpusReport); + AddInfo(CorpusReport); + TestTrue(TEXT("structure roll has a usable corpus for parameter blocks"), + bCorpusLoaded && Corpus.IsValid()); + if (!bCorpusLoaded || !Corpus.IsValid()) + { + return false; + } + + FTestWorld World; + World.Build(1337, 2, true); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + TestTrue(TEXT("structure-roll fixture keeps WorldRadiusVoxels at 0"), + World.Settings->WorldRadiusVoxels == 0.0f); + + constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain; + int32 TopVoxelZ = 0; + int32 BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ)) + { + AddError(TEXT("structure-roll fixture has no candidate Z range")); + return false; + } + const float TopWorldZ = (float)TopVoxelZ + 1.0f; + const float BottomWorldZ = (float)BottomVoxelZ; + + FVector ArrivalPoint = FVector::ZeroVector; + FVector DeparturePoint = FVector::ZeroVector; + int32 ArrivalCount = 0; + int32 DepartureCount = 0; + for (const FVoxelPassage& Passage : World.StrateManager->GetPassages()) + { + if (Passage.LowerStrateIndex == CandidateStrateIndex + && Passage.UpperStrateIndex + 1 == CandidateStrateIndex) + { + ArrivalPoint = Passage.LowerPoint; + ++ArrivalCount; + } + if (Passage.UpperStrateIndex == CandidateStrateIndex + && Passage.LowerStrateIndex == CandidateStrateIndex + 1) + { + DeparturePoint = Passage.UpperPoint; + ++DepartureCount; + } + } + TestEqual(TEXT("candidate has exactly one arrival mouth"), ArrivalCount, 1); + TestEqual(TEXT("candidate has exactly one departure mouth"), DepartureCount, 1); + + FVoxelStrateMeasureSettings MeasureSettings; + MeasureSettings.SampleStep = 4; + MeasureSettings.RadiusInVoxels = 256; + MeasureSettings.CenterXY = FVector2D::ZeroVector; + MeasureSettings.MaxCells = 8000000; + MeasureSettings.MaxRouteRetries = 16; + MeasureSettings.HeadroomCells = 2; + MeasureSettings.InteriorMarginVoxels = -1; + + FString CandidateTable = TEXT( + "candidate | recipe (compact) | air | largest share | walkable | feature scale | arrival->departure\n"); + int32 NumSurvivors = 0; + int32 NumNonVacuous = 0; + int32 NumLargestEnough = 0; + int32 NumLawPasses = 0; + int32 NumInvalidRecipes = 0; + int32 NumParameterRollFailures = 0; + int32 TotalBoxMixed = 0; + int32 TotalBoxAllSolid = 0; + int32 TotalBoxAllAir = 0; + int32 TotalBoxProved = 0; + int64 TotalBoxCheckedVoxels = 0; + int32 TotalBoxViolations = 0; + FString FirstBoxViolation; + TArray ShapeCounts; + ShapeCounts.Init(0, 5); + int32 KCounts[9] = { 0 }; + TArray DistinctRecipes; + DistinctRecipes.Reserve(NumCandidates); + + const double RollAndMeasureStartSeconds = FPlatformTime::Seconds(); + for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex) + { + const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure( + AuthoredSettings->Seed, CandidateIndex); + const FVoxelOpStackRecipe RepeatRecipe = VF_RollStrateStructure( + AuthoredSettings->Seed, CandidateIndex); + TestTrue(FString::Printf(TEXT("candidate %d recipe reroll is identical"), CandidateIndex), + VF_AreStrateStructureRecipesIdentical(Recipe, RepeatRecipe)); + + const uint32 RecipeHash = VF_HashStrateStructureRecipe(Recipe); + bool bSeenRecipe = false; + for (const FVoxelOpStackRecipe& Existing : DistinctRecipes) + { + if (VF_HashStrateStructureRecipe(Existing) == RecipeHash + && VF_AreStrateStructureRecipesIdentical(Existing, Recipe)) + { + bSeenRecipe = true; + break; + } + } + if (!bSeenRecipe) { DistinctRecipes.Add(Recipe); } + + switch (Recipe.ShapeSource.OpClass) + { + case EVoxelStrateOpClass::RoomGraphSource: ++ShapeCounts[0]; break; + case EVoxelStrateOpClass::LatticeCorridorSource:++ShapeCounts[1]; break; + case EVoxelStrateOpClass::ShaftFieldSource: ++ShapeCounts[2]; break; + case EVoxelStrateOpClass::IslandBlobSource: ++ShapeCounts[3]; break; + case EVoxelStrateOpClass::NoiseRibbonSource: ++ShapeCounts[4]; break; + default: break; + } + if (Recipe.Modifiers.IsValidIndex(0)) + { + ++KCounts[Recipe.Modifiers.Num()]; + } + + FVoxelStrateArchetypeParams CandidateParams; + bool bParameterRollsValid = true; + for (const FBlockSpec& Spec : GBlockSpecs) + { + const FVoxelStrateRollInfo BlockRoll = VF_RollStrateParamsDetailedForArchetype( + Corpus, Spec.Archetype, + AuthoredSettings->Seed ^ static_cast(Spec.SeedSalt), CandidateIndex); + if (!BlockRoll.bValid) + { + bParameterRollsValid = false; + ++NumParameterRollFailures; + AddError(FString::Printf(TEXT("candidate %d parameter block %d failed: %s"), + CandidateIndex, static_cast(Spec.Block), + *BlockRoll.FailureReason)); + continue; + } + VF_CopyRollBlock(CandidateParams, BlockRoll, Spec.Archetype); + } + VF_SetAllRuntimeBounds(CandidateParams, TopWorldZ, BottomWorldZ); + + FString AirText = TEXT("invalid"); + FString LargestText = TEXT("invalid"); + FString WalkableText = TEXT("invalid"); + FString FeatureText = TEXT("invalid"); + FString LawText = TEXT("not measured"); + + FVoxelOpStack Stack; + FVoxelOpContext Context; + FString BuildError; + const bool bBuilt = bParameterRollsValid && VF_BuildStackFromRecipe( + Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f, + World.StrateManager.Get(), Stack, Context, &BuildError); + if (!bBuilt) + { + ++NumInvalidRecipes; + CandidateTable += FString::Printf( + TEXT("%d | %s | invalid | invalid | invalid | invalid | invalid | build=%s\n"), + CandidateIndex, *VF_FormatStrateStructureRecipe(Recipe), + bParameterRollsValid ? *BuildError : TEXT("parameter block roll failed")); + TestTrue(FString::Printf(TEXT("candidate %d recipe materialises"), CandidateIndex), false); + continue; + } + + TestEqual(FString::Printf(TEXT("candidate %d has four mandatory structural posts"), CandidateIndex), + Stack.Num(), Recipe.Modifiers.Num() + 7); + FString ValidationError; + TestTrue(FString::Printf(TEXT("candidate %d passes ValidateChannelOrder"), CandidateIndex), + Stack.ValidateChannelOrder(&ValidationError)); + TestTrue(FString::Printf(TEXT("candidate %d context keeps WorldRadiusVoxels at 0"), CandidateIndex), + Context.WorldRadiusVoxels == 0.0f); + + Stack.PrepareChunk(Context); + FStackDensitySampler Sampler(Stack); + const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, + Context.EdgeSealThickness, MeasureSettings); + + FVoxelConnectivityDiagnostics Law; + if (ArrivalCount == 1 && DepartureCount == 1) + { + Law = VF_DiagnoseConnectivityWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, + Context.EdgeSealThickness, ArrivalPoint, DeparturePoint, MeasureSettings); + LawText = VF_ConnectivityName(Law.Result); + if (Law.bStartSnapped || Law.bGoalSnapped) { LawText += TEXT(" (snapped)"); } + } + else + { + LawText = FString::Printf(TEXT("mouths=%d/%d"), ArrivalCount, DepartureCount); + } + + const bool bNonVacuous = Metrics.bValid + && Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0; + const bool bLargestEnough = bNonVacuous + && Metrics.LargestComponentShare >= LargestComponentSurvivalThreshold; + const bool bLawPass = ArrivalCount == 1 && DepartureCount == 1 + && Law.bValid && Law.Result == EVoxelConnectivityResult::Connected + && !Law.bStartSnapped && !Law.bGoalSnapped; + if (bNonVacuous) { ++NumNonVacuous; } + if (bLargestEnough) { ++NumLargestEnough; } + if (bLawPass) { ++NumLawPasses; } + if (bNonVacuous && bLargestEnough && bLawPass) { ++NumSurvivors; } + + if (Metrics.bValid) + { + AirText = FString::Printf(TEXT("%.6f"), Metrics.AirFraction); + LargestText = FString::Printf(TEXT("%.6f"), Metrics.LargestComponentShare); + WalkableText = FString::Printf(TEXT("%.6f"), Metrics.WalkableFraction); + FeatureText = FString::Printf(TEXT("%.6f"), Metrics.MedianFeatureScale); + } + + const FBoxVerdictReport BoxReport = VF_CheckStackBoxVerdicts( + Stack, Context, CandidateIndex, BottomVoxelZ, TopVoxelZ); + TotalBoxMixed += BoxReport.Mixed; + TotalBoxAllSolid += BoxReport.AllSolid; + TotalBoxAllAir += BoxReport.AllAir; + TotalBoxProved += BoxReport.Proved; + TotalBoxCheckedVoxels += BoxReport.CheckedVoxels; + TotalBoxViolations += BoxReport.Violations; + if (FirstBoxViolation.IsEmpty() && !BoxReport.FirstViolation.IsEmpty()) + { + FirstBoxViolation = BoxReport.FirstViolation; + } + + // Same recipe + same block vectors + same seed must yield the same world values. This is + // checked on a fixed point array for every candidate, including cache-backed sources. + FVoxelOpStack RepeatStack; + FVoxelOpContext RepeatContext; + FString RepeatBuildError; + const bool bRepeatBuilt = VF_BuildStackFromRecipe( + Recipe, CandidateParams, AuthoredSettings->Seed, 14.0f, + World.StrateManager.Get(), RepeatStack, RepeatContext, &RepeatBuildError); + TestTrue(FString::Printf(TEXT("candidate %d same recipe rebuild succeeds"), CandidateIndex), + bRepeatBuilt); + if (bRepeatBuilt) + { + RepeatStack.PrepareChunk(RepeatContext); + const FVector ProbePoints[] = { + FVector(-91.0f, -37.0f, BottomWorldZ + 17.0f), + FVector(0.0f, 0.0f, (BottomWorldZ + TopWorldZ) * 0.5f), + FVector(73.0f, 119.0f, TopWorldZ - 19.0f), + FVector(-256.0f, 256.0f, BottomWorldZ + 61.0f), + }; + bool bSameWorld = true; + for (const FVector& Point : ProbePoints) + { + if (!VF_BitEqual(Stack.EvalMC(Point.X, Point.Y, Point.Z), + RepeatStack.EvalMC(Point.X, Point.Y, Point.Z))) + { + bSameWorld = false; + break; + } + } + TestTrue(FString::Printf(TEXT("candidate %d same seed yields same world"), CandidateIndex), + bSameWorld); + } + + CandidateTable += FString::Printf( + TEXT("%d | %s | %s | %s | %s | %s | %s\n"), + CandidateIndex, + *VF_FormatStrateStructureRecipe(Recipe), + *AirText, *LargestText, *WalkableText, *FeatureText, *LawText); + } + const double RollAndMeasureSeconds = FPlatformTime::Seconds() - RollAndMeasureStartSeconds; + + AddInfo(CandidateTable); + FString ShapeDistribution = TEXT("Shape source distribution:"); + for (int32 Index = 0; Index < 5; ++Index) + { + const EVoxelStrateOpClass ShapeClass[] = { + EVoxelStrateOpClass::RoomGraphSource, + EVoxelStrateOpClass::LatticeCorridorSource, + EVoxelStrateOpClass::ShaftFieldSource, + EVoxelStrateOpClass::IslandBlobSource, + EVoxelStrateOpClass::NoiseRibbonSource, + }; + ShapeDistribution += FString::Printf(TEXT(" %s=%d"), + VF_ShapeName(ShapeClass[Index]), ShapeCounts[Index]); + } + FString KDistribution = TEXT("k distribution:"); + for (int32 K = 4; K <= 8; ++K) + { + KDistribution += FString::Printf(TEXT(" k%d=%d"), K, KCounts[K]); + } + AddInfo(ShapeDistribution); + AddInfo(KDistribution); + AddInfo(FString::Printf( + TEXT("Structure diversity: %d distinct recipes of %d; invalid recipes emitted=%d; " + "parameter-block roll failures=%d; recipe/build/measure time=%.3fs."), + DistinctRecipes.Num(), NumCandidates, NumInvalidRecipes, + NumParameterRollFailures, RollAndMeasureSeconds)); + AddInfo(FString::Printf( + TEXT("Survival: %d/%d (%.1f%%). Criteria: non-vacuous (valid, sampled, air>0, solid>0), " + "largest component share >= %.2f, and exact unsnapped arrival->departure law " + "Result=Connected. Intermediate counts: non-vacuous=%d, largest threshold=%d, " + "law pass=%d. Lower survival is expected for aggressive structure rolls."), + NumSurvivors, NumCandidates, 100.0f * (float)NumSurvivors / (float)NumCandidates, + LargestComponentSurvivalThreshold, NumNonVacuous, NumLargestEnough, NumLawPasses)); + AddInfo(FString::Printf( + TEXT("Rolled custom-stack box verdicts: mixed=%d, AllSolid=%d, AllAir=%d, proved=%d, " + "brute-force voxels checked=%lld, violations=%d. %s"), + TotalBoxMixed, TotalBoxAllSolid, TotalBoxAllAir, TotalBoxProved, + TotalBoxCheckedVoxels, TotalBoxViolations, + FirstBoxViolation.IsEmpty() ? TEXT("No violations.") : *FirstBoxViolation)); + + TestEqual(TEXT("exactly 64 structure candidates were requested"), NumCandidates, 64); + TestEqual(TEXT("invalid recipes emitted by the roller/build path"), NumInvalidRecipes, 0); + TestEqual(TEXT("custom-stack box verdict violations"), TotalBoxViolations, 0); + TestTrue(TEXT("structure roller emits 64 distinct structural recipes"), + DistinctRecipes.Num() == NumCandidates); + + const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds; + AddInfo(FString::Printf(TEXT("StructureRoll total runtime %.3fs; WorldRadiusVoxels=0."), + TestSeconds)); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 347179c..3cd2dde 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -19,6 +19,7 @@ // decomposition is right" into a proof. #include "VoxelDensityOpStack.h" +#include "VoxelStrateComposer.h" #include "VoxelDensityPrimitives.h" // VF_ApplyOriginSpine / seals / PassageCarving #include "VoxelCaveMorphology.h" // VoxelSDF::Capsule, VoxelHash @@ -296,6 +297,130 @@ namespace float Value; }; + //======================================================================= + // TIER 4b — GENERIC ROLLED SOURCE/MODIFIER PRIMITIVES + //======================================================================= + // These two small primitives keep the non-room channel space large enough for the promised + // k=4..8 draw. They are deliberately bounded: their box contracts are amplitude proofs, not + // guesses. They do not carry any room/shaft/surface pointer, so the resource graph correctly + // treats them as legal after every SDF shape source. + class FNoiseRibbonSource final : public IVoxelDensityOp + { + public: + FNoiseRibbonSource(const FMazeGenerationParams& InP, int32 InSeed) + : CellSize(FMath::Max(InP.CellSize, 8.0f)) + , CorridorRadius(FMath::Max(InP.CorridorRadius, 0.5f)) + , SeedU(static_cast(InSeed) ^ 0x5249626Eu) {} + + 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 {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float Frequency = 1.0f / CellSize; + const FVector NoisePos( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 3.17f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 7.31f), + WorldZ * Frequency + VoxelHash::SeedOffset(SeedU, 11.47f)); + const float Ribbon = FMath::Clamp( + FMath::Abs(VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + VoxelGenLOD::Eff(3), 2.0f, 0.5f)) + * VOXEL_NOISE_SCALE, + 0.0f, 2.0f); + InOut.Sdf = Ribbon * CellSize - CorridorRadius; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Identity; + } + + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox&, + const FVoxelOpContext&) const override + { + if (!FMath::IsFinite(CellSize) || !FMath::IsFinite(CorridorRadius) + || CellSize <= 0.0f || CorridorRadius < 0.0f) + { + InOut.SetUnknown(); + return; + } + // Eval clamps the FBM envelope to [0,2]. This interval is exact enough for safety + // and independent of the queried box, so it never claims a false empty region. + InOut.Set(-CorridorRadius, 2.0f * CellSize - CorridorRadius); + } + + const TCHAR* DebugName() const override { return TEXT("NoiseRibbonSource"); } + + private: + float CellSize; + float CorridorRadius; + uint32 SeedU; + }; + + class FDensityNoiseMod final : public IVoxelDensityOp + { + public: + FDensityNoiseMod(float InStrength, float InFrequency, int32 InOctaves, + int32 InSeed, bool bInFill) + : Strength(FMath::Max(InStrength, 0.0f)) + , Frequency(FMath::Max(InFrequency, 0.0001f)) + , Octaves(FMath::Clamp(InOctaves, 1, 8)) + , SeedU(static_cast(InSeed) ^ (bInFill ? 0x46696C6Cu : 0x43617276u)) + , bFill(bInFill) {} + + 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 {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (!(Strength > 0.0f)) { return; } + const FVector NoisePos( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 13.2f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 17.8f), + WorldZ * Frequency + VoxelHash::SeedOffset(SeedU, 23.4f)); + const float Noise01 = FMath::Clamp( + VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + VoxelGenLOD::Eff(Octaves), 2.0f, 0.5f) * 0.5f + 0.5f, + 0.0f, 1.0f); + const float Delta = Strength * Noise01; + InOut.Density += bFill ? Delta : -Delta; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + if (!(Strength > 0.0f)) { return EVoxelOpEffect::Identity; } + return bFill ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::CarveOnly; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return bFill ? 0.0f : Strength; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return bFill ? Strength : 0.0f; + } + + const TCHAR* DebugName() const override + { + return bFill ? TEXT("DensityNoiseFillMod") : TEXT("DensityNoiseCarveMod"); + } + + private: + float Strength; + float Frequency; + int32 Octaves; + uint32 SeedU; + bool bFill; + }; + //========================================================================= // RÔLE 1 — SOURCE : COULOIRS SUR TREILLIS 3D / 3D LATTICE CORRIDORS //========================================================================= @@ -950,6 +1075,7 @@ namespace EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask ProvidedResources() const override { return VoxelOpResources::SurfaceColumn; } bool IsAdditive() const override { return false; } bool IsXYPure() const override { return false; } // voir le bloc ci-dessus @@ -1081,6 +1207,7 @@ namespace EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::SurfaceColumn; } bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} bool IsXYPure() const override { return false; } // franchement non : voir `Frac` @@ -1771,6 +1898,7 @@ namespace EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + EVoxelOpResourceMask ProvidedResources() const override { return VoxelOpResources::ShaftGeometry; } bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -2194,6 +2322,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::ShaftGeometry; } bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -2577,6 +2706,7 @@ namespace EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + EVoxelOpResourceMask ProvidedResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return false; } void PrepareChunk(const FVoxelOpContext& Ctx) override @@ -3606,6 +3736,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -3715,6 +3846,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -3803,6 +3935,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -3886,6 +4019,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -3986,6 +4120,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -4060,6 +4195,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -4151,6 +4287,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -4267,6 +4404,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -4330,6 +4468,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -4437,6 +4576,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -4556,6 +4696,7 @@ namespace return VoxelOpChannels::Density | VoxelOpChannels::Sdf; } EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -4794,6 +4935,23 @@ bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const return Result.IsEmpty() ? TEXT("none") : Result; }; + auto DescribeResourceMask = [](EVoxelOpResourceMask Mask) -> FString + { + FString Result; + if ((Mask & VoxelOpResources::RoomGeometry) != 0) { Result += TEXT("RoomGeometry"); } + if ((Mask & VoxelOpResources::ShaftGeometry) != 0) + { + if (!Result.IsEmpty()) { Result += TEXT(", "); } + Result += TEXT("ShaftGeometry"); + } + if ((Mask & VoxelOpResources::SurfaceColumn) != 0) + { + if (!Result.IsEmpty()) { Result += TEXT(", "); } + Result += TEXT("SurfaceColumn"); + } + return Result.IsEmpty() ? TEXT("none") : Result; + }; + auto Fail = [&](int32 Index, const FOpEntry* Entry, const TCHAR* Rule) -> bool { if (OutError != nullptr) @@ -4803,8 +4961,10 @@ bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const 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)); + TEXT("op %d (%s) violates stack DAG: %s; reads=[%s], writes=[%s], requires=[%s], provides=[%s]"), + Index, Name, Rule, *DescribeMask(Reads), *DescribeMask(Writes), + *DescribeResourceMask(Entry != nullptr ? Entry->RequiredResources : VoxelOpResources::None), + *DescribeResourceMask(Entry != nullptr ? Entry->ProvidedResources : VoxelOpResources::None)); } return false; }; @@ -4812,6 +4972,7 @@ bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const // 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 }; + EVoxelOpResourceMask AvailableResources = VoxelOpResources::None; auto ChannelIndex = [](EVoxelOpChannelMask Channel) -> int32 { @@ -4823,6 +4984,21 @@ bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const const FOpEntry& Entry = Ops[Index]; if (Entry.Op.Get() == nullptr) { return Fail(Index, &Entry, TEXT("null operator")); } + if ((Entry.RequiredResources & VoxelOpResources::All) != Entry.RequiredResources) + { + return Fail(Index, &Entry, TEXT("requires an unknown op resource")); + } + if ((Entry.ProvidedResources & VoxelOpResources::All) != Entry.ProvidedResources) + { + return Fail(Index, &Entry, TEXT("provides an unknown op resource")); + } + if ((Entry.RequiredResources + & static_cast(~AvailableResources)) != 0) + { + return Fail(Index, &Entry, + TEXT("requires op state before an earlier provider published it")); + } + if ((Entry.Reads & VoxelOpChannels::All) != Entry.Reads) { return Fail(Index, &Entry, TEXT("reads an unknown sample channel")); @@ -4883,6 +5059,8 @@ bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const LastWriter[ChannelIndex(Channel)] = Index; } } + + AvailableResources |= Entry.ProvidedResources; } return true; @@ -4909,6 +5087,117 @@ void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBott namespace VoxelDensityOps { + bool GetStrateOpContract(EVoxelStrateOpClass OpClass, FVoxelStrateOpContract& OutContract) + { + const FStrateGenerationParams TunnelParams; + const FSlabGenerationParams SlabParams; + const FMazeGenerationParams MazeParams; + const FSurfaceGenerationParams SurfaceParams; + const FVerticalShaftParams ShaftParams; + const FFloatingIslandParams IslandParams; + + TUniquePtr Probe; + switch (OpClass) + { + case EVoxelStrateOpClass::ConstantRockSource: + Probe = MakeConstantRockSource(TunnelParams.BaseDensity); + break; + case EVoxelStrateOpClass::ConstantVoidSource: + Probe = MakeConstantVoidSource(IslandParams.BaseDensity); + break; + case EVoxelStrateOpClass::RoomGraphSource: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::LatticeCorridorSource: + Probe = MakeUnique(MazeParams, 0); + break; + case EVoxelStrateOpClass::ShaftFieldSource: + Probe = MakeUnique(ShaftParams, 0, 14.0f); + break; + case EVoxelStrateOpClass::IslandBlobSource: + Probe = MakeUnique(IslandParams, 0); + break; + case EVoxelStrateOpClass::NoiseRibbonSource: + Probe = MakeUnique(MazeParams, 0); + break; + case EVoxelStrateOpClass::SdfRoughnessMod: + Probe = MakeSdfRoughnessMod(MazeParams.SurfaceRoughness, 0.12f, 3, 8.0f); + break; + case EVoxelStrateOpClass::SdfCarve: + Probe = MakeSdfCarve(2.0f, TunnelParams.BaseDensity); + break; + case EVoxelStrateOpClass::SdfFill: + Probe = MakeSdfFill(2.0f, IslandParams.BaseDensity); + break; + case EVoxelStrateOpClass::GridColumnMod: + Probe = MakeUnique(SlabParams, 0); + break; + case EVoxelStrateOpClass::CaveRoughnessMod: + Probe = MakeUnique(TunnelParams, 0); + break; + case EVoxelStrateOpClass::CaveTerraceMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::LayerLineMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::RibbingMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::CaveOverhangMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::CaveCliffMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::ScallopMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::CaveArchMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::RoomColumnMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::DomeMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::PinchMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::FloorBiasMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::WormFieldSource: + Probe = MakeUnique(TunnelParams, 0); + break; + case EVoxelStrateOpClass::ShaftLedgeMod: + Probe = MakeUnique(ShaftParams, nullptr); + break; + case EVoxelStrateOpClass::DensityNoiseCarveMod: + Probe = MakeUnique(1.0f, 0.02f, 3, 0, false); + break; + case EVoxelStrateOpClass::DensityNoiseFillMod: + Probe = MakeUnique(1.0f, 0.02f, 3, 0, true); + break; + default: + return false; + } + + if (!Probe) + { + return false; + } + + OutContract.Role = Probe->GetRole(); + OutContract.Reads = Probe->ChannelReads(); + OutContract.Writes = Probe->ChannelWrites(); + OutContract.bAdditive = Probe->IsAdditive(); + OutContract.RequiredResources = Probe->RequiredResources(); + OutContract.ProvidedResources = Probe->ProvidedResources(); + return true; + } + TUniquePtr MakeConstantRockSource(float BaseDensity) { return MakeUnique(BaseDensity); @@ -5149,3 +5438,549 @@ namespace VoxelDensityOps P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); } } + +namespace VoxelStrateRecipePrivate +{ + const FStrateGenerationParams* Tunnel(const FVoxelStrateArchetypeParams& Params) + { + return &Params.TunnelNetworkParams; + } + + const FSlabGenerationParams* Slab(const FVoxelStrateArchetypeParams& Params) + { + return &Params.SlabParams; + } + + const FMazeGenerationParams* Maze(const FVoxelStrateArchetypeParams& Params) + { + return &Params.MazeParams; + } + + const FSurfaceGenerationParams* Surface(const FVoxelStrateArchetypeParams& Params) + { + return &Params.SurfaceParams; + } + + const FVerticalShaftParams* Shaft(const FVoxelStrateArchetypeParams& Params) + { + return &Params.VerticalShaftParams; + } + + const FFloatingIslandParams* Island(const FVoxelStrateArchetypeParams& Params) + { + return &Params.FloatingIslandParams; + } + + float BaseDensity(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->BaseDensity; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->BaseDensity; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->BaseDensity; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->BaseDensity; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->BaseDensity; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->BaseDensity; + default: return 8.0f; + } + } + + float BoundarySeal(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->BoundarySealThickness; + default: return 4.0f; + } + } + + float Top(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->StrateTopWorldZ; + default: return 0.0f; + } + } + + float Bottom(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->StrateBottomWorldZ; + default: return 0.0f; + } + } + + EVoxelStrateParamBlock ShapeBlock(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::RoomGraphSource: return EVoxelStrateParamBlock::TunnelNetwork; + case EVoxelStrateOpClass::LatticeCorridorSource: + case EVoxelStrateOpClass::NoiseRibbonSource: return EVoxelStrateParamBlock::Maze; + case EVoxelStrateOpClass::ShaftFieldSource: return EVoxelStrateParamBlock::VerticalShaft; + case EVoxelStrateOpClass::IslandBlobSource: return EVoxelStrateParamBlock::FloatingIsland; + default: return EVoxelStrateParamBlock::None; + } + } + + EVoxelStrateParamBlock FixedParamBlock(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::GridColumnMod: + return EVoxelStrateParamBlock::Slab; + case EVoxelStrateOpClass::CaveRoughnessMod: + case EVoxelStrateOpClass::CaveTerraceMod: + case EVoxelStrateOpClass::LayerLineMod: + case EVoxelStrateOpClass::RibbingMod: + case EVoxelStrateOpClass::CaveOverhangMod: + case EVoxelStrateOpClass::CaveCliffMod: + case EVoxelStrateOpClass::ScallopMod: + case EVoxelStrateOpClass::CaveArchMod: + case EVoxelStrateOpClass::RoomColumnMod: + case EVoxelStrateOpClass::DomeMod: + case EVoxelStrateOpClass::PinchMod: + case EVoxelStrateOpClass::FloorBiasMod: + case EVoxelStrateOpClass::WormFieldSource: + return EVoxelStrateParamBlock::TunnelNetwork; + case EVoxelStrateOpClass::ShaftLedgeMod: + return EVoxelStrateParamBlock::VerticalShaft; + default: + return EVoxelStrateParamBlock::None; + } + } + + float SdfBlend(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: + return Params.TunnelNetworkParams.SDFBlendRadius; + case EVoxelStrateParamBlock::Maze: + return 2.0f; + case EVoxelStrateParamBlock::VerticalShaft: + return 2.0f; + case EVoxelStrateParamBlock::FloatingIsland: + return FMath::Max(Params.FloatingIslandParams.SDFBlendRadius, 0.01f); + default: + return 2.0f; + } + } + + float SdfRoughnessStrength(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Params.TunnelNetworkParams.SurfaceRoughness; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.SurfaceRoughness; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.SurfaceRoughness; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.SurfaceRoughness; + default: return 2.0f; + } + } + + float SdfRoughnessFrequency(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Params.TunnelNetworkParams.RoughnessFrequency; + case EVoxelStrateParamBlock::Maze: return 0.12f; + case EVoxelStrateParamBlock::VerticalShaft: return 0.1f; + case EVoxelStrateParamBlock::FloatingIsland: return 0.08f; + default: return 0.1f; + } + } + + int32 SdfRoughnessOctaves(EVoxelStrateParamBlock Block) + { + return Block == EVoxelStrateParamBlock::FloatingIsland ? 4 : 3; + } + + float SdfRoughnessWindow(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + const float Strength = SdfRoughnessStrength(Params, Block); + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: + return Params.TunnelNetworkParams.SDFBlendRadius * 3.0f + Strength + 2.0f; + case EVoxelStrateParamBlock::Maze: + return FMath::Max(Params.MazeParams.CorridorRadius, 0.5f) + Strength + 2.0f; + case EVoxelStrateParamBlock::VerticalShaft: + return Strength + 4.0f; + case EVoxelStrateParamBlock::FloatingIsland: + return Strength + SdfBlend(Params, Block) + 2.0f; + default: + return Strength + 4.0f; + } + } + + float GenericStrength(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + return FMath::Max(0.25f, 0.35f * FMath::Abs(BaseDensity(Params, Block))); + } + + float GenericFrequency(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Maze: + return 1.0f / FMath::Max(Params.MazeParams.CellSize, 8.0f); + case EVoxelStrateParamBlock::VerticalShaft: + return 0.01f; + case EVoxelStrateParamBlock::FloatingIsland: + return 0.02f; + case EVoxelStrateParamBlock::TunnelNetwork: + return FMath::Max(Params.TunnelNetworkParams.RoughnessFrequency, 0.001f); + default: + return 0.02f; + } + } + + bool IsShapeSource(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::RoomGraphSource: + case EVoxelStrateOpClass::LatticeCorridorSource: + case EVoxelStrateOpClass::ShaftFieldSource: + case EVoxelStrateOpClass::IslandBlobSource: + case EVoxelStrateOpClass::NoiseRibbonSource: + return true; + default: + return false; + } + } + + bool ValidateRecipeContracts(const FVoxelOpStackRecipe& Recipe, FString& OutError) + { + TArray Classes; + Classes.Reserve(3 + Recipe.Modifiers.Num()); + Classes.Add(Recipe.Root.OpClass); + Classes.Add(Recipe.ShapeSource.OpClass); + Classes.Add(Recipe.Conversion.OpClass); + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) + { + Classes.Add(Entry.OpClass); + } + + auto ValidateParamBlock = [&](const FVoxelOpRecipeEntry& Entry, int32 EntryIndex) + { + if (Entry.ParamBlock == EVoxelStrateParamBlock::None) + { + OutError = FString::Printf(TEXT("recipe op %d has no parameter block"), EntryIndex); + return false; + } + const EVoxelStrateParamBlock Fixed = FixedParamBlock(Entry.OpClass); + if (Fixed != EVoxelStrateParamBlock::None && Entry.ParamBlock != Fixed) + { + OutError = FString::Printf(TEXT("recipe op %d uses the wrong parameter block"), EntryIndex); + return false; + } + return true; + }; + if (!ValidateParamBlock(Recipe.Root, 0) + || !ValidateParamBlock(Recipe.ShapeSource, 1) + || !ValidateParamBlock(Recipe.Conversion, 2)) + { + return false; + } + for (int32 ModifierIndex = 0; ModifierIndex < Recipe.Modifiers.Num(); ++ModifierIndex) + { + if (!ValidateParamBlock(Recipe.Modifiers[ModifierIndex], 3 + ModifierIndex)) + { + return false; + } + } + + int32 LastWriter[2] = { INDEX_NONE, INDEX_NONE }; + EVoxelOpResourceMask AvailableResources = VoxelOpResources::None; + const EVoxelOpChannelMask Channels[] = { VoxelOpChannels::Density, VoxelOpChannels::Sdf }; + auto ChannelIndex = [](EVoxelOpChannelMask Channel) { return Channel == VoxelOpChannels::Density ? 0 : 1; }; + + for (int32 Index = 0; Index < Classes.Num(); ++Index) + { + FVoxelStrateOpContract Contract; + if (!VoxelDensityOps::GetStrateOpContract(Classes[Index], Contract)) + { + OutError = FString::Printf(TEXT("recipe op %d has no declaration"), Index); + return false; + } + if ((Contract.Reads & VoxelOpChannels::All) != Contract.Reads + || (Contract.Writes & VoxelOpChannels::All) != Contract.Writes + || (Contract.RequiredResources & VoxelOpResources::All) != Contract.RequiredResources + || (Contract.ProvidedResources & VoxelOpResources::All) != Contract.ProvidedResources) + { + OutError = FString::Printf(TEXT("recipe op %d has an unknown declaration bit"), Index); + return false; + } + if ((Contract.RequiredResources + & static_cast(~AvailableResources)) != 0) + { + OutError = FString::Printf(TEXT("recipe op %d requires unavailable state"), Index); + return false; + } + if (Contract.bAdditive && Contract.Writes == VoxelOpChannels::None) + { + OutError = FString::Printf(TEXT("recipe op %d is additive but writes no channel"), Index); + return false; + } + if (Contract.bAdditive + && (Contract.Writes & static_cast(~Contract.Reads)) != 0) + { + OutError = FString::Printf(TEXT("recipe op %d is additive without reading its writes"), Index); + return false; + } + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Contract.Reads & Channel) == 0) { continue; } + const int32 CI = ChannelIndex(Channel); + const bool bRootIdentity = Contract.Role == EVoxelOpRole::FieldSource + && (Contract.Writes & Channel) != 0 && LastWriter[CI] == INDEX_NONE; + if (LastWriter[CI] == INDEX_NONE && !bRootIdentity) + { + OutError = FString::Printf(TEXT("recipe op %d reads an unpublished channel"), Index); + return false; + } + } + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Contract.Writes & Channel) == 0 || (Contract.Reads & Channel) != 0) { continue; } + if (LastWriter[ChannelIndex(Channel)] != INDEX_NONE) + { + OutError = FString::Printf(TEXT("recipe op %d clobbers a channel"), Index); + return false; + } + } + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Contract.Writes & Channel) != 0) + { + LastWriter[ChannelIndex(Channel)] = Index; + } + } + AvailableResources |= Contract.ProvidedResources; + } + return true; + } + + TUniquePtr BuildRecipeOp(const FVoxelOpRecipeEntry& Entry, + const FVoxelStrateArchetypeParams& Params, + int32 Seed, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + const FRoomGraphSource*& OutRoom, + const FShaftFieldSource*& OutShaft) + { + const EVoxelStrateParamBlock Block = Entry.ParamBlock; + switch (Entry.OpClass) + { + case EVoxelStrateOpClass::ConstantRockSource: + return VoxelDensityOps::MakeConstantRockSource(BaseDensity(Params, Block)); + case EVoxelStrateOpClass::ConstantVoidSource: + return VoxelDensityOps::MakeConstantVoidSource(BaseDensity(Params, Block)); + case EVoxelStrateOpClass::RoomGraphSource: + { + TUniquePtr Op = MakeUnique(*Tunnel(Params), Seed, StrateManager); + OutRoom = Op.Get(); + return Op; + } + case EVoxelStrateOpClass::LatticeCorridorSource: + return MakeUnique(*Maze(Params), Seed); + case EVoxelStrateOpClass::ShaftFieldSource: + { + TUniquePtr Op = MakeUnique(*Shaft(Params), Seed, SpineRadius); + OutShaft = Op.Get(); + return Op; + } + case EVoxelStrateOpClass::IslandBlobSource: + return MakeUnique(*Island(Params), Seed); + case EVoxelStrateOpClass::NoiseRibbonSource: + return MakeUnique(*Maze(Params), Seed); + case EVoxelStrateOpClass::SdfRoughnessMod: + return VoxelDensityOps::MakeSdfRoughnessMod( + SdfRoughnessStrength(Params, Block), + SdfRoughnessFrequency(Params, Block), + SdfRoughnessOctaves(Block), + SdfRoughnessWindow(Params, Block)); + case EVoxelStrateOpClass::SdfCarve: + return VoxelDensityOps::MakeSdfCarve(SdfBlend(Params, Block), BaseDensity(Params, Block)); + case EVoxelStrateOpClass::SdfFill: + return VoxelDensityOps::MakeSdfFill(SdfBlend(Params, Block), BaseDensity(Params, Block)); + case EVoxelStrateOpClass::GridColumnMod: + return MakeUnique(*Slab(Params), Seed); + case EVoxelStrateOpClass::CaveRoughnessMod: + return MakeUnique(*Tunnel(Params), Seed); + case EVoxelStrateOpClass::CaveTerraceMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::LayerLineMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::RibbingMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::CaveOverhangMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::CaveCliffMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::ScallopMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::CaveArchMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::RoomColumnMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::DomeMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::PinchMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::FloorBiasMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::WormFieldSource: + return MakeUnique(*Tunnel(Params), Seed); + case EVoxelStrateOpClass::ShaftLedgeMod: + return MakeUnique(*Shaft(Params), OutShaft); + case EVoxelStrateOpClass::DensityNoiseCarveMod: + return MakeUnique(GenericStrength(Params, Block), + GenericFrequency(Params, Block), 3, Seed, false); + case EVoxelStrateOpClass::DensityNoiseFillMod: + return MakeUnique(GenericStrength(Params, Block), + GenericFrequency(Params, Block), 3, Seed, true); + default: + return nullptr; + } + } +} + +bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, + const FVoxelStrateArchetypeParams& Params, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError) +{ + auto Fail = [&](const FString& Reason) -> bool + { + if (OutError != nullptr) { *OutError = Reason; } + return false; + }; + if (OutError != nullptr) { OutError->Reset(); } + + if (Recipe.Modifiers.Num() < 4 || Recipe.Modifiers.Num() > 8) + { + return Fail(TEXT("A structure recipe must draw between 4 and 8 modifiers.")); + } + const EVoxelStrateOpClass ExpectedRoot = Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::ConstantVoidSource : EVoxelStrateOpClass::ConstantRockSource; + const EVoxelStrateOpClass ExpectedConversion = Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::SdfFill : EVoxelStrateOpClass::SdfCarve; + if (Recipe.Root.OpClass != ExpectedRoot || Recipe.Conversion.OpClass != ExpectedConversion) + { + return Fail(TEXT("Recipe polarity does not match its root/conversion ids.")); + } + if (!VoxelStrateRecipePrivate::IsShapeSource(Recipe.ShapeSource.OpClass)) + { + return Fail(TEXT("Recipe shape source is not a rollable SDF source.")); + } + const EVoxelStrateParamBlock ShapeBlock = + VoxelStrateRecipePrivate::ShapeBlock(Recipe.ShapeSource.OpClass); + if (ShapeBlock == EVoxelStrateParamBlock::None + || Recipe.StructuralParamBlock != ShapeBlock + || Recipe.Root.ParamBlock == EVoxelStrateParamBlock::None + || Recipe.ShapeSource.ParamBlock != ShapeBlock + || Recipe.Conversion.ParamBlock != ShapeBlock) + { + return Fail(TEXT("Recipe parameter blocks do not match its shape source.")); + } + + FString ContractError; + if (!VoxelStrateRecipePrivate::ValidateRecipeContracts(Recipe, ContractError)) + { + return Fail(ContractError); + } + + FVoxelOpStack Candidate; + const FRoomGraphSource* RoomPtr = nullptr; + const FShaftFieldSource* ShaftPtr = nullptr; + + FVoxelOpRecipeEntry Root = Recipe.Root; + TUniquePtr RootOp = + VoxelStrateRecipePrivate::BuildRecipeOp(Root, Params, Seed, SpineRadius, StrateManager, RoomPtr, ShaftPtr); + if (!RootOp) { return Fail(TEXT("Recipe root could not be materialised.")); } + Candidate.Add(MoveTemp(RootOp)); + + TUniquePtr ShapeOp = + VoxelStrateRecipePrivate::BuildRecipeOp(Recipe.ShapeSource, Params, Seed, SpineRadius, + StrateManager, RoomPtr, ShaftPtr); + if (!ShapeOp) { return Fail(TEXT("Recipe shape source could not be materialised.")); } + Candidate.Add(MoveTemp(ShapeOp)); + + TUniquePtr ConversionOp = + VoxelStrateRecipePrivate::BuildRecipeOp(Recipe.Conversion, Params, Seed, SpineRadius, + StrateManager, RoomPtr, ShaftPtr); + if (!ConversionOp) { return Fail(TEXT("Recipe conversion could not be materialised.")); } + Candidate.Add(MoveTemp(ConversionOp)); + + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) + { + TUniquePtr Modifier = + VoxelStrateRecipePrivate::BuildRecipeOp(Entry, Params, Seed, SpineRadius, + StrateManager, RoomPtr, ShaftPtr); + if (!Modifier) + { + return Fail(TEXT("Recipe modifier could not be materialised.")); + } + Candidate.Add(MoveTemp(Modifier)); + } + + const float Top = VoxelStrateRecipePrivate::Top(Params, Recipe.StructuralParamBlock); + const float Bottom = VoxelStrateRecipePrivate::Bottom(Params, Recipe.StructuralParamBlock); + const float Seal = VoxelStrateRecipePrivate::BoundarySeal(Params, Recipe.StructuralParamBlock); + const float Base = VoxelStrateRecipePrivate::BaseDensity(Params, Recipe.StructuralParamBlock); + if (!FMath::IsFinite(Top) || !FMath::IsFinite(Bottom) || Top <= Bottom + || !FMath::IsFinite(Seal) || !FMath::IsFinite(Base)) + { + return Fail(TEXT("Recipe structural parameters do not define a finite positive strate.")); + } + + OutContext = FVoxelOpContext(); + OutContext.Seed = Seed; + OutContext.LayoutVersion = StrateManager != nullptr ? StrateManager->GetLayoutVersion() : 0; + OutContext.WorldRadiusVoxels = 0.0f; + OutContext.EdgeSealThickness = Seal; + OutContext.StrateTopWorldZ = Top; + OutContext.StrateBottomWorldZ = Bottom; + + // This is the only place a recipe can acquire world-law operators. There is no recipe field + // for posts, so a serialised manifest cannot forget them or reorder them. + Candidate.AppendStructuralPost(Top, Bottom, Seal, Base, SpineRadius, StrateManager); + + FString ValidationError; + if (!Candidate.ValidateChannelOrder(&ValidationError)) + { + return Fail(FString::Printf(TEXT("Materialised recipe failed ValidateChannelOrder: %s"), + *ValidationError)); + } + + OutStack = MoveTemp(Candidate); + return true; +} diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index aa02a9a..f41ed78 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -13,6 +13,7 @@ #include "VoxelSettings.h" #include "VoxelStrateDefinition.h" +#include "VoxelDensityOpStack.h" #include #include @@ -992,7 +993,8 @@ namespace } FVoxelStrateRollInfo VF_RollInternal(const FVoxelStrateCorpus& Corpus, - int32 Seed, int32 Index) + int32 Seed, int32 Index, + const ECaveGeneratorType* ForcedArchetype = nullptr) { FVoxelStrateRollInfo Result; if (!Corpus.IsValid()) @@ -1043,7 +1045,24 @@ namespace } int32 GroupIndex = 0; - if (TotalGroupWeight > 0.0f) + if (ForcedArchetype != nullptr) + { + GroupIndex = INDEX_NONE; + for (int32 CandidateGroup = 0; CandidateGroup < Groups.Num(); ++CandidateGroup) + { + if (Groups[CandidateGroup].Archetype == *ForcedArchetype) + { + GroupIndex = CandidateGroup; + break; + } + } + if (GroupIndex == INDEX_NONE) + { + Result.FailureReason = TEXT("The requested archetype has no corpus group."); + return Result; + } + } + else if (TotalGroupWeight > 0.0f) { float Cursor = Rng.FRand() * TotalGroupWeight; for (int32 CandidateGroup = 0; CandidateGroup < Groups.Num(); ++CandidateGroup) @@ -1141,6 +1160,165 @@ namespace Result.bValid = true; return Result; } + + struct FStructureModifierCandidate + { + EVoxelStrateOpClass OpClass; + EVoxelStrateParamBlock ParamBlock; + }; + + static const EVoxelStrateOpClass GStructureShapeSources[] = + { + EVoxelStrateOpClass::RoomGraphSource, + EVoxelStrateOpClass::LatticeCorridorSource, + EVoxelStrateOpClass::ShaftFieldSource, + EVoxelStrateOpClass::IslandBlobSource, + EVoxelStrateOpClass::NoiseRibbonSource, + }; + + // Fixed order is intentional. This is a source-controlled catalogue, not a hash-container + // iteration; changing it is a manifest-versioned design change rather than an accidental RNG + // change. + static const FStructureModifierCandidate GStructureModifiers[] = + { + { EVoxelStrateOpClass::SdfRoughnessMod, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::SdfCarve, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::SdfFill, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::CaveRoughnessMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::WormFieldSource, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::GridColumnMod, EVoxelStrateParamBlock::Slab }, + { EVoxelStrateOpClass::DensityNoiseCarveMod, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::DensityNoiseFillMod, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::CaveTerraceMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::LayerLineMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::RibbingMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::CaveOverhangMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::CaveCliffMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::ScallopMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::CaveArchMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::RoomColumnMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::DomeMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::PinchMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::FloorBiasMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::ShaftLedgeMod, EVoxelStrateParamBlock::VerticalShaft }, + }; + + uint32 VF_StructureRollSeed(int32 Seed, int32 Index) + { + uint32 Value = VF_Avalanche(static_cast(Seed) ^ 0xD1CEB00Bu); + Value = VF_Avalanche(Value ^ (static_cast(Index) + 0x7F4A7C15u)); + return Value; + } + + bool VF_IsContractLegalAfter(EVoxelOpChannelMask AvailableChannels, + EVoxelOpResourceMask AvailableResources, + const FVoxelStrateOpContract& Contract) + { + if ((Contract.Reads & static_cast(~AvailableChannels)) != 0 + || (Contract.RequiredResources + & static_cast(~AvailableResources)) != 0) + { + return false; + } + if (Contract.bAdditive + && (Contract.Writes == VoxelOpChannels::None + || (Contract.Writes & static_cast(~Contract.Reads)) != 0)) + { + return false; + } + for (const EVoxelOpChannelMask Channel : + { VoxelOpChannels::Density, VoxelOpChannels::Sdf }) + { + if ((Contract.Writes & Channel) == 0 || (Contract.Reads & Channel) != 0) + { + continue; + } + // All channels are already published by root + shape. A write-only candidate would + // therefore be a replacement clobber and is excluded before it can enter a recipe. + return false; + } + return true; + } + + FVoxelOpRecipeEntry VF_MakeRecipeEntry(EVoxelStrateOpClass OpClass, + EVoxelStrateParamBlock ParamBlock) + { + FVoxelOpRecipeEntry Entry; + Entry.OpClass = OpClass; + Entry.ParamBlock = ParamBlock; + return Entry; + } + + FVoxelOpStackRecipe VF_RollStructureInternal(int32 Seed, int32 Index) + { + FVoxelOpStackRecipe Recipe; + FRandomStream Rng(static_cast(VF_StructureRollSeed(Seed, Index))); + + Recipe.RootPolarity = Rng.RandRange(0, 1) == 0 + ? EVoxelStrateRootPolarity::RockCarve + : EVoxelStrateRootPolarity::VoidFill; + + const EVoxelStrateOpClass ShapeClass = GStructureShapeSources[ + Rng.RandRange(0, UE_ARRAY_COUNT(GStructureShapeSources) - 1)]; + const EVoxelStrateParamBlock ShapeBlock = + ShapeClass == EVoxelStrateOpClass::RoomGraphSource + ? EVoxelStrateParamBlock::TunnelNetwork + : ShapeClass == EVoxelStrateOpClass::ShaftFieldSource + ? EVoxelStrateParamBlock::VerticalShaft + : ShapeClass == EVoxelStrateOpClass::IslandBlobSource + ? EVoxelStrateParamBlock::FloatingIsland + : EVoxelStrateParamBlock::Maze; + + Recipe.Root = VF_MakeRecipeEntry( + Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::ConstantVoidSource + : EVoxelStrateOpClass::ConstantRockSource, + ShapeBlock); + Recipe.ShapeSource = VF_MakeRecipeEntry(ShapeClass, ShapeBlock); + Recipe.Conversion = VF_MakeRecipeEntry( + Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::SdfFill + : EVoxelStrateOpClass::SdfCarve, + ShapeBlock); + Recipe.StructuralParamBlock = ShapeBlock; + + FVoxelStrateOpContract ShapeContract; + const bool bShapeDeclared = VoxelDensityOps::GetStrateOpContract(ShapeClass, ShapeContract); + checkf(bShapeDeclared, TEXT("Structure catalogue contains an undeclared shape source.")); + + const EVoxelOpChannelMask AvailableChannels = VoxelOpChannels::Density | VoxelOpChannels::Sdf; + const EVoxelOpResourceMask AvailableResources = ShapeContract.ProvidedResources; + + TArray Legal; + for (const FStructureModifierCandidate& Candidate : GStructureModifiers) + { + FVoxelStrateOpContract Contract; + if (!VoxelDensityOps::GetStrateOpContract(Candidate.OpClass, Contract)) + { + checkf(false, TEXT("Structure catalogue contains an undeclared modifier.")); + continue; + } + if (VF_IsContractLegalAfter(AvailableChannels, AvailableResources, Contract)) + { + Legal.Add(Candidate); + } + } + + const int32 K = Rng.RandRange(4, 8); + checkf(Legal.Num() >= K, + TEXT("Structure roller has fewer legal modifiers than its rolled k.")); + for (int32 ModifierIndex = 0; ModifierIndex < K && Legal.Num() > 0; ++ModifierIndex) + { + const int32 Pick = Rng.RandRange(0, Legal.Num() - 1); + const FStructureModifierCandidate Candidate = Legal[Pick]; + Recipe.Modifiers.Add(VF_MakeRecipeEntry( + Candidate.OpClass, + Candidate.ParamBlock == EVoxelStrateParamBlock::None + ? ShapeBlock : Candidate.ParamBlock)); + Legal.RemoveAt(Pick, 1, EAllowShrinking::No); + } + return Recipe; + } } const TArray& FVoxelStrateCorpus::GetNonTunableFields() @@ -1849,8 +2027,164 @@ FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpu return Result; } +FVoxelStrateRollInfo VF_RollStrateParamsDetailedForArchetype( + const FVoxelStrateCorpus& Corpus, ECaveGeneratorType Archetype, int32 Seed, int32 Index) +{ + const FVoxelStrateRollInfo Result = VF_RollInternal(Corpus, Seed, Index, &Archetype); + +#if DO_CHECK + if (Result.bValid) + { + const FVoxelStrateRollInfo Repeat = VF_RollInternal(Corpus, Seed, Index, &Archetype); + checkf(Repeat.bValid && Repeat.Archetype == Result.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Repeat.ArchetypeParams, Result.ArchetypeParams, Result.Archetype) + && Repeat.ParentEntryIndices == Result.ParentEntryIndices + && Repeat.ParentWeights == Result.ParentWeights, + TEXT("VF_RollStrateParamsDetailedForArchetype lost determinism.")); + } +#endif + return Result; +} + FStrateGenerationParams VF_RollStrateParams(const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index) { return VF_RollStrateParamsDetailed(Corpus, Seed, Index).Params; } + +namespace +{ + const TCHAR* VF_RecipeOpCode(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::ConstantRockSource: return TEXT("rk"); + case EVoxelStrateOpClass::ConstantVoidSource: return TEXT("vd"); + case EVoxelStrateOpClass::RoomGraphSource: return TEXT("rg"); + case EVoxelStrateOpClass::LatticeCorridorSource: return TEXT("lt"); + case EVoxelStrateOpClass::ShaftFieldSource: return TEXT("sh"); + case EVoxelStrateOpClass::IslandBlobSource: return TEXT("is"); + case EVoxelStrateOpClass::NoiseRibbonSource: return TEXT("nr"); + case EVoxelStrateOpClass::SdfRoughnessMod: return TEXT("sr"); + case EVoxelStrateOpClass::SdfCarve: return TEXT("cv"); + case EVoxelStrateOpClass::SdfFill: return TEXT("fl"); + case EVoxelStrateOpClass::GridColumnMod: return TEXT("gc"); + case EVoxelStrateOpClass::CaveRoughnessMod: return TEXT("cr"); + case EVoxelStrateOpClass::CaveTerraceMod: return TEXT("tr"); + case EVoxelStrateOpClass::LayerLineMod: return TEXT("ll"); + case EVoxelStrateOpClass::RibbingMod: return TEXT("rb"); + case EVoxelStrateOpClass::CaveOverhangMod: return TEXT("co"); + case EVoxelStrateOpClass::CaveCliffMod: return TEXT("cc"); + case EVoxelStrateOpClass::ScallopMod: return TEXT("sc"); + case EVoxelStrateOpClass::CaveArchMod: return TEXT("ar"); + case EVoxelStrateOpClass::RoomColumnMod: return TEXT("rc"); + case EVoxelStrateOpClass::DomeMod: return TEXT("dm"); + case EVoxelStrateOpClass::PinchMod: return TEXT("pn"); + case EVoxelStrateOpClass::FloorBiasMod: return TEXT("fb"); + case EVoxelStrateOpClass::WormFieldSource: return TEXT("wm"); + case EVoxelStrateOpClass::ShaftLedgeMod: return TEXT("sl"); + case EVoxelStrateOpClass::DensityNoiseCarveMod: return TEXT("nc"); + case EVoxelStrateOpClass::DensityNoiseFillMod: return TEXT("nf"); + default: return TEXT("??"); + } + } + + const TCHAR* VF_RecipeBlockCode(EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return TEXT("T"); + case EVoxelStrateParamBlock::Slab: return TEXT("B"); + case EVoxelStrateParamBlock::Maze: return TEXT("M"); + case EVoxelStrateParamBlock::Surface: return TEXT("S"); + case EVoxelStrateParamBlock::VerticalShaft: return TEXT("V"); + case EVoxelStrateParamBlock::FloatingIsland: return TEXT("I"); + default: return TEXT("-"); + } + } + + FString VF_FormatRecipeEntry(const FVoxelOpRecipeEntry& Entry) + { + return FString::Printf(TEXT("%s/%s"), VF_RecipeOpCode(Entry.OpClass), + VF_RecipeBlockCode(Entry.ParamBlock)); + } +} + +FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index) +{ + const FVoxelOpStackRecipe Recipe = VF_RollStructureInternal(Seed, Index); + +#if DO_CHECK + const FVoxelOpStackRecipe Repeat = VF_RollStructureInternal(Seed, Index); + checkf(VF_AreStrateStructureRecipesIdentical(Recipe, Repeat), + TEXT("VF_RollStrateStructure lost determinism for the same seed/index.")); +#endif + return Recipe; +} + +FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe) +{ + FString Modifiers; + for (int32 Index = 0; Index < Recipe.Modifiers.Num(); ++Index) + { + if (Index > 0) { Modifiers += TEXT(","); } + Modifiers += VF_FormatRecipeEntry(Recipe.Modifiers[Index]); + } + return FString::Printf(TEXT("%s|r=%s|s=%s|x=%s|m=[%s]|posts=%s"), + Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill ? TEXT("VF") : TEXT("RC"), + *VF_FormatRecipeEntry(Recipe.Root), + *VF_FormatRecipeEntry(Recipe.ShapeSource), + *VF_FormatRecipeEntry(Recipe.Conversion), + *Modifiers, + VF_RecipeBlockCode(Recipe.StructuralParamBlock)); +} + +uint32 VF_HashStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe) +{ + uint32 Hash = 2166136261u; + auto AddByte = [&Hash](uint8 Value) + { + Hash ^= static_cast(Value); + Hash *= 16777619u; + }; + auto AddEntry = [&AddByte](const FVoxelOpRecipeEntry& Entry) + { + AddByte(static_cast(Entry.OpClass)); + AddByte(static_cast(Entry.ParamBlock)); + }; + + AddByte(static_cast(Recipe.RootPolarity)); + AddEntry(Recipe.Root); + AddEntry(Recipe.ShapeSource); + AddEntry(Recipe.Conversion); + AddByte(static_cast(Recipe.Modifiers.Num())); + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) { AddEntry(Entry); } + AddByte(static_cast(Recipe.StructuralParamBlock)); + return Hash; +} + +bool VF_AreStrateStructureRecipesIdentical(const FVoxelOpStackRecipe& A, + const FVoxelOpStackRecipe& B) +{ + if (A.RootPolarity != B.RootPolarity + || A.Root.OpClass != B.Root.OpClass || A.Root.ParamBlock != B.Root.ParamBlock + || A.ShapeSource.OpClass != B.ShapeSource.OpClass + || A.ShapeSource.ParamBlock != B.ShapeSource.ParamBlock + || A.Conversion.OpClass != B.Conversion.OpClass + || A.Conversion.ParamBlock != B.Conversion.ParamBlock + || A.StructuralParamBlock != B.StructuralParamBlock + || A.Modifiers.Num() != B.Modifiers.Num()) + { + return false; + } + for (int32 Index = 0; Index < A.Modifiers.Num(); ++Index) + { + if (A.Modifiers[Index].OpClass != B.Modifiers[Index].OpClass + || A.Modifiers[Index].ParamBlock != B.Modifiers[Index].ParamBlock) + { + return false; + } + } + return true; +} diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index afc34c4..98526bb 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -75,23 +75,28 @@ namespace VoxelStrateMeasurePrivate } bool BuildSampleGrid( - const UVoxelGenerator& Generator, - const UVoxelStrateManager& Manager, + const UVoxelGenerator* Generator, + const UVoxelStrateManager* Manager, + const IVoxelStrateDensitySampler* Sampler, int32 StrateIndex, + int32 ExplicitBottomWorldZ, + int32 ExplicitTopWorldZ, + float ExplicitBoundarySealThickness, + bool bUseExplicitBounds, const FVoxelStrateMeasureSettings& Settings, FSampleGrid& OutGrid, FString& OutReason) { - const TArray& Layout = Manager.GetLayout(); - if (!Layout.IsValidIndex(StrateIndex)) + if (bUseExplicitBounds) { - return Refuse(OutReason, TEXT("StrateIndex is outside the manager layout.")); + if (Sampler == nullptr) + { + return Refuse(OutReason, TEXT("An explicit measurement window requires a density sampler.")); + } } - - const FStrateSlot& Slot = Layout[StrateIndex]; - if (Slot.Definition == nullptr) + else if (Generator == nullptr || Manager == nullptr) { - return Refuse(OutReason, TEXT("The requested strate has no definition.")); + return Refuse(OutReason, TEXT("A generator and manager are required for a layout measurement.")); } if (Settings.SampleStep <= 0) { @@ -136,13 +141,37 @@ namespace VoxelStrateMeasurePrivate return Refuse(OutReason, TEXT("CoverMarginVoxels must be finite and non-negative.")); } } - if (Slot.TopChunkZ <= Slot.BottomChunkZ) + int64 StrateBottomZ = 0; + int64 StrateTopZ = 0; + if (bUseExplicitBounds) { - return Refuse(OutReason, TEXT("The requested strate has no positive vertical extent.")); + StrateBottomZ = static_cast(ExplicitBottomWorldZ); + StrateTopZ = static_cast(ExplicitTopWorldZ); + if (StrateTopZ <= StrateBottomZ) + { + return Refuse(OutReason, TEXT("The explicit strate has no positive vertical extent.")); + } } + else + { + const TArray& Layout = Manager->GetLayout(); + if (!Layout.IsValidIndex(StrateIndex)) + { + return Refuse(OutReason, TEXT("StrateIndex is outside the manager layout.")); + } - const int64 StrateBottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; - const int64 StrateTopZ = (static_cast(Slot.TopChunkZ) + 1) * CHUNK_SIZE; + const FStrateSlot& Slot = Layout[StrateIndex]; + if (Slot.Definition == nullptr) + { + return Refuse(OutReason, TEXT("The requested strate has no definition.")); + } + if (Slot.TopChunkZ <= Slot.BottomChunkZ) + { + return Refuse(OutReason, TEXT("The requested strate has no positive vertical extent.")); + } + StrateBottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + StrateTopZ = (static_cast(Slot.TopChunkZ) + 1) * CHUNK_SIZE; + } const int64 StrateHeight = StrateTopZ - StrateBottomZ; if (StrateHeight <= 0) { @@ -192,14 +221,23 @@ namespace VoxelStrateMeasurePrivate return Refuse(OutReason, TEXT("The requested XY window is not a finite positive voxel box.")); } - const int32 MidChunkZ = Slot.BottomChunkZ - + (Slot.TopChunkZ - Slot.BottomChunkZ) / 2; - const FIntVector RepresentativeChunk( - FMath::FloorToInt(WindowCenter.X / static_cast(CHUNK_SIZE)), - FMath::FloorToInt(WindowCenter.Y / static_cast(CHUNK_SIZE)), - MidChunkZ); - const FStrateGenerationParams RepresentativeParams = - Manager.GetGenerationParams(RepresentativeChunk); + FStrateGenerationParams RepresentativeParams; + if (bUseExplicitBounds) + { + RepresentativeParams.BoundarySealThickness = ExplicitBoundarySealThickness; + } + else + { + const TArray& Layout = Manager->GetLayout(); + const FStrateSlot& Slot = Layout[StrateIndex]; + const int32 MidChunkZ = Slot.BottomChunkZ + + (Slot.TopChunkZ - Slot.BottomChunkZ) / 2; + const FIntVector RepresentativeChunk( + FMath::FloorToInt(WindowCenter.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(WindowCenter.Y / static_cast(CHUNK_SIZE)), + MidChunkZ); + RepresentativeParams = Manager->GetGenerationParams(RepresentativeChunk); + } const int64 MaxMargin64 = FMath::Max(1, StrateHeight / 4); const int32 MaxMargin = static_cast( @@ -328,7 +366,9 @@ namespace VoxelStrateMeasurePrivate for (int32 X = 0; X < OutGrid.NumX; ++X) { const FVector Sample = OutGrid.CellCenter(X, Y, Z); - const float Density = Generator.GetDensityAt(Sample.X, Sample.Y, Sample.Z); + const float Density = Sampler != nullptr + ? Sampler->SampleDensity(Sample.X, Sample.Y, Sample.Z) + : Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z); if (!FMath::IsFinite(Density)) { bSawNonFiniteDensity = true; @@ -772,7 +812,8 @@ namespace VoxelStrateMeasurePrivate } bool FullResolutionAirOnSegment( - const UVoxelGenerator& Generator, + const UVoxelGenerator* Generator, + const IVoxelStrateDensitySampler* Sampler, const FVector& Start, const FVector& End) { @@ -784,7 +825,9 @@ namespace VoxelStrateMeasurePrivate { const float Alpha = static_cast(Step) / static_cast(NumSteps); const FVector Sample = Start + Delta * Alpha; - const float Density = Generator.GetDensityAt(Sample.X, Sample.Y, Sample.Z); + const float Density = Sampler != nullptr + ? Sampler->SampleDensity(Sample.X, Sample.Y, Sample.Z) + : Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z); if (!FMath::IsFinite(Density) || !(Density > 0.0f)) { return false; @@ -794,7 +837,8 @@ namespace VoxelStrateMeasurePrivate } bool FullResolutionPathIsAir( - const UVoxelGenerator& Generator, + const UVoxelGenerator* Generator, + const IVoxelStrateDensitySampler* Sampler, const FSampleGrid& Grid, const FVector& A, const FVector& B, @@ -812,7 +856,7 @@ namespace VoxelStrateMeasurePrivate int32 EndZ = 0; DecodeIndex(Grid, Path.Last(), EndX, EndY, EndZ); - if (!FullResolutionAirOnSegment(Generator, A, Grid.CellCenter(StartX, StartY, StartZ))) + if (!FullResolutionAirOnSegment(Generator, Sampler, A, Grid.CellCenter(StartX, StartY, StartZ))) { // The endpoint-to-cell-center segment has no coarse cell pair to exclude. It is // common to every route from this endpoint, so report unknown rather than claiming @@ -831,7 +875,7 @@ namespace VoxelStrateMeasurePrivate int32 CurrentZ = 0; DecodeIndex(Grid, Path[PathIndex], CurrentX, CurrentY, CurrentZ); if (!FullResolutionAirOnSegment( - Generator, + Generator, Sampler, Grid.CellCenter(PreviousX, PreviousY, PreviousZ), Grid.CellCenter(CurrentX, CurrentY, CurrentZ))) { @@ -840,7 +884,7 @@ namespace VoxelStrateMeasurePrivate } } - if (!FullResolutionAirOnSegment(Generator, Grid.CellCenter(EndX, EndY, EndZ), B)) + if (!FullResolutionAirOnSegment(Generator, Sampler, Grid.CellCenter(EndX, EndY, EndZ), B)) { // As above, the final endpoint segment is shared by every route that reaches the // goal cell and therefore has no precise coarse edge to exclude. @@ -930,7 +974,8 @@ namespace VoxelStrateMeasurePrivate } EVoxelConnectivityResult EvaluateConnectivityOnGrid( - const UVoxelGenerator& Generator, + const UVoxelGenerator* Generator, + const IVoxelStrateDensitySampler* Sampler, const FSampleGrid& Grid, const FVector& AVoxel, const FVector& BVoxel, @@ -980,7 +1025,7 @@ namespace VoxelStrateMeasurePrivate // spacing in the original voxel field. A single solid sample refutes only this // route, so exclude its precise coarse edge and search again. FCoarseEdge FailedEdge; - if (FullResolutionPathIsAir(Generator, Grid, AVoxel, BVoxel, Path, FailedEdge)) + if (FullResolutionPathIsAir(Generator, Sampler, Grid, AVoxel, BVoxel, Path, FailedEdge)) { return EVoxelConnectivityResult::Connected; } @@ -1126,9 +1171,14 @@ namespace VoxelStrateMeasurePrivate } EVoxelConnectivityResult QueryConnectivity( - const UVoxelGenerator& Generator, - const UVoxelStrateManager& Manager, + const UVoxelGenerator* Generator, + const UVoxelStrateManager* Manager, + const IVoxelStrateDensitySampler* Sampler, int32 StrateIndex, + int32 ExplicitBottomWorldZ, + int32 ExplicitTopWorldZ, + float ExplicitBoundarySealThickness, + bool bUseExplicitBounds, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, @@ -1161,7 +1211,10 @@ namespace VoxelStrateMeasurePrivate FSampleGrid Grid; FString RefusalReason; - if (!BuildSampleGrid(Generator, Manager, StrateIndex, Settings, Grid, RefusalReason)) + if (!BuildSampleGrid(Generator, Manager, Sampler, StrateIndex, + ExplicitBottomWorldZ, ExplicitTopWorldZ, + ExplicitBoundarySealThickness, bUseExplicitBounds, + Settings, Grid, RefusalReason)) { if (OutDiagnostics != nullptr) { @@ -1174,6 +1227,7 @@ namespace VoxelStrateMeasurePrivate int32 Goal = -1; const EVoxelConnectivityResult Result = EvaluateConnectivityOnGrid( Generator, + Sampler, Grid, AVoxel, BVoxel, @@ -1208,7 +1262,43 @@ FVoxelStrateMetrics VF_MeasureStrate( FVoxelStrateMetrics Result; VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( - Generator, Manager, StrateIndex, Settings, Grid, Result.RefusalReason)) + &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, + Settings, Grid, Result.RefusalReason)) + { + return Result; + } + + Result.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; + Result.SampledMinZ = Grid.SampledMinZ; + Result.SampledMaxZ = Grid.SampledMaxZ; + Result.SampledNumX = Grid.NumX; + Result.SampledNumY = Grid.NumY; + Result.SampledNumZ = Grid.NumZ; + Result.SampledMinX = Grid.MinX; + Result.SampledMaxX = Grid.MaxX; + Result.SampledMinY = Grid.MinY; + Result.SampledMaxY = Grid.MaxY; + VoxelStrateMeasurePrivate::BuildMetricsFromGrid(Grid, Settings, Result); + Result.bValid = Result.NumSampled > 0; + if (!Result.bValid) + { + Result.RefusalReason = TEXT("The measurement grid was empty."); + } + return Result; +} + +FVoxelStrateMetrics VF_MeasureStrateWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVoxelStrateMeasureSettings& Settings) +{ + FVoxelStrateMetrics Result; + VoxelStrateMeasurePrivate::FSampleGrid Grid; + if (!VoxelStrateMeasurePrivate::BuildSampleGrid( + nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, + BoundarySealThickness, true, Settings, Grid, Result.RefusalReason)) { return Result; } @@ -1244,9 +1334,11 @@ EVoxelConnectivityResult VF_AreConnected( { int32 NumRouteRetries = 0; return VoxelStrateMeasurePrivate::QueryConnectivity( - Generator, - Manager, + &Generator, + &Manager, + nullptr, StrateIndex, + 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, @@ -1268,9 +1360,11 @@ EVoxelConnectivityResult VF_AreConnected( int32& OutNumRouteRetries) { return VoxelStrateMeasurePrivate::QueryConnectivity( - Generator, - Manager, + &Generator, + &Manager, + nullptr, StrateIndex, + 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, @@ -1293,9 +1387,11 @@ EVoxelConnectivityResult VF_AreConnected( { int32 NumRouteRetries = 0; return VoxelStrateMeasurePrivate::QueryConnectivity( - Generator, - Manager, + &Generator, + &Manager, + nullptr, StrateIndex, + 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, @@ -1316,9 +1412,11 @@ FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( FVoxelConnectivityDiagnostics Result; int32 NumRouteRetries = 0; VoxelStrateMeasurePrivate::QueryConnectivity( - Generator, - Manager, + &Generator, + &Manager, + nullptr, StrateIndex, + 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, @@ -1328,3 +1426,39 @@ FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( &Result); return Result; } + +EVoxelConnectivityResult VF_AreConnectedWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + FVoxelConnectivityDiagnostics* OutDiagnostics) +{ + int32 NumRouteRetries = 0; + return VoxelStrateMeasurePrivate::QueryConnectivity( + nullptr, nullptr, &Sampler, INDEX_NONE, + StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, + AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped, + NumRouteRetries, OutDiagnostics); +} + +FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings) +{ + FVoxelConnectivityDiagnostics Result; + VF_AreConnectedWithSampler(Sampler, StrateBottomWorldZ, StrateTopWorldZ, + BoundarySealThickness, AVoxel, BVoxel, Settings, + Result.bStartSnapped, Result.bGoalSnapped, &Result); + return Result; +} diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index 09f93da..02e85da 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -154,6 +154,33 @@ namespace VoxelOpChannels static constexpr EVoxelOpChannelMask All = Density | Sdf; } +//============================================================================= +// OPERATOR RESOURCES / RESSOURCES D'OPERATEUR +//============================================================================= +// Sample-channel declarations describe the FVoxelOpSample only. Some operators also consume +// stateful geometry published by an earlier source (the room graph, shaft field, or surface +// column). Keep that dependency explicit as a second, tiny graph so the composer cannot place a +// room modifier on a stack that has no room source merely because its Density/Sdf masks happen to +// fit. +enum class EVoxelOpResource : uint8 +{ + None = 0, + RoomGeometry = 1 << 0, + ShaftGeometry = 1 << 1, + SurfaceColumn = 1 << 2, +}; + +using EVoxelOpResourceMask = uint8; + +namespace VoxelOpResources +{ + static constexpr EVoxelOpResourceMask None = 0; + static constexpr EVoxelOpResourceMask RoomGeometry = static_cast(EVoxelOpResource::RoomGeometry); + static constexpr EVoxelOpResourceMask ShaftGeometry = static_cast(EVoxelOpResource::ShaftGeometry); + static constexpr EVoxelOpResourceMask SurfaceColumn = static_cast(EVoxelOpResource::SurfaceColumn); + static constexpr EVoxelOpResourceMask All = RoomGeometry | ShaftGeometry | SurfaceColumn; +} + //============================================================================= // COMPOSITION / COMBINERS //============================================================================= @@ -382,6 +409,22 @@ public: virtual EVoxelOpChannelMask ChannelReads() const = 0; virtual EVoxelOpChannelMask ChannelWrites() const = 0; + /** + * Non-sample state required by this op. A declaration is satisfied only by a provider that + * appeared earlier in the stack. Defaults are deliberately empty so existing independent + * operators and external test doubles remain source-compatible. + */ + virtual EVoxelOpResourceMask RequiredResources() const + { + return VoxelOpResources::None; + } + + /** State published for later consumers, e.g. FRoomGraphSource's room geometry cache. */ + virtual EVoxelOpResourceMask ProvidedResources() const + { + return VoxelOpResources::None; + } + /** * 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 diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index b7b8e0b..0b1ae7f 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -39,6 +39,20 @@ #include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one class UVoxelStrateManager; +struct FVoxelOpStackRecipe; +struct FVoxelStrateArchetypeParams; +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; +}; /** * FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte. @@ -68,6 +82,8 @@ private: EVoxelOpChannelMask Reads = VoxelOpChannels::None; EVoxelOpChannelMask Writes = VoxelOpChannels::None; bool bAdditive = false; + EVoxelOpResourceMask RequiredResources = VoxelOpResources::None; + EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None; }; public: @@ -102,6 +118,8 @@ public: 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(); } Ops.Add(MoveTemp(Entry)); } @@ -258,6 +276,10 @@ private: 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 MakeConstantRockSource(float BaseDensity); @@ -439,3 +461,12 @@ namespace VoxelDensityOps int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager); } + +/** Materialise an offline recipe. Structural posts are appended internally and cannot be omitted. */ +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); diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index 2c486b8..11e5a9c 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -8,10 +8,109 @@ #include "CoreMinimal.h" #include "VoxelStrateTypes.h" +#include "VoxelDensityOpStack.h" + +#include "VoxelStrateComposer.generated.h" class UVoxelSettings; class UVoxelStrateDefinition; +/** The one-bit identity choice at the root of a rolled structure. */ +UENUM(BlueprintType) +enum class EVoxelStrateRootPolarity : uint8 +{ + RockCarve = 0, + VoidFill = 1, +}; + +/** Native parameter family an op reads when a recipe is materialised. */ +UENUM(BlueprintType) +enum class EVoxelStrateParamBlock : uint8 +{ + None = 0, + TunnelNetwork = 1, + Slab = 2, + Maze = 3, + Surface = 4, + VerticalShaft = 5, + FloatingIsland = 6, +}; + +/** Stable manifest id for every op the offline structure roller may place. */ +UENUM(BlueprintType) +enum class EVoxelStrateOpClass : uint8 +{ + ConstantRockSource = 0, + ConstantVoidSource = 1, + RoomGraphSource = 2, + LatticeCorridorSource = 3, + ShaftFieldSource = 4, + IslandBlobSource = 5, + NoiseRibbonSource = 6, + SdfRoughnessMod = 7, + SdfCarve = 8, + SdfFill = 9, + GridColumnMod = 10, + CaveRoughnessMod = 11, + CaveTerraceMod = 12, + LayerLineMod = 13, + RibbingMod = 14, + CaveOverhangMod = 15, + CaveCliffMod = 16, + ScallopMod = 17, + CaveArchMod = 18, + RoomColumnMod = 19, + DomeMod = 20, + PinchMod = 21, + FloorBiasMod = 22, + WormFieldSource = 23, + ShaftLedgeMod = 24, + DensityNoiseCarveMod = 25, + DensityNoiseFillMod = 26, +}; + +/** One creative op in the serialisable structure manifest. */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelOpRecipeEntry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateOpClass OpClass = EVoxelStrateOpClass::ConstantRockSource; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateParamBlock ParamBlock = EVoxelStrateParamBlock::None; +}; + +/** + * Serialisable creative structure. Structural posts are intentionally absent: the recipe builder + * appends spine, vertical seal, passage carve, and XY edge seal in that fixed order every time. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelOpStackRecipe +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateRootPolarity RootPolarity = EVoxelStrateRootPolarity::RockCarve; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + FVoxelOpRecipeEntry Root; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + FVoxelOpRecipeEntry ShapeSource; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + FVoxelOpRecipeEntry Conversion; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + TArray Modifiers; + + // Which native block supplies the mandatory structural post parameters. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateParamBlock StructuralParamBlock = EVoxelStrateParamBlock::None; +}; + /** The kind of value represented by one entry of VF_STRATE_PARAM_FIELDS. */ enum class EVoxelStrateFieldKind : uint8 { @@ -177,6 +276,19 @@ VOXELFORGE_API const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archety VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailed( const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index); +/** Roll one exact native family, used to feed the independent blocks of a novel recipe. */ +VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailedForArchetype( + const FVoxelStrateCorpus& Corpus, ECaveGeneratorType Archetype, int32 Seed, int32 Index); + +/** Pure structure roll: no retained RNG state and no corpus/runtime dependency. */ +VOXELFORGE_API FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index); + +/** Stable compact representation and equality/hash helpers for manifests and reports. */ +VOXELFORGE_API FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe); +VOXELFORGE_API uint32 VF_HashStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe); +VOXELFORGE_API bool VF_AreStrateStructureRecipesIdentical( + const FVoxelOpStackRecipe& A, const FVoxelOpStackRecipe& B); + /** The compact API requested by the composer design. */ VOXELFORGE_API FStrateGenerationParams VF_RollStrateParams( const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index); diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 003ff3b..07978d6 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -7,6 +7,14 @@ class UVoxelGenerator; class UVoxelStrateManager; +/** Read-only density source used by the offline Tier 2 pass for a materialised custom stack. */ +class VOXELFORGE_API IVoxelStrateDensitySampler +{ +public: + virtual ~IVoxelStrateDensitySampler() = default; + virtual float SampleDensity(float WorldX, float WorldY, float WorldZ) const = 0; +}; + /** Explicit outcome of a coarse connectivity query plus its full-resolution route re-check. */ enum class EVoxelConnectivityResult : uint8 { @@ -122,6 +130,14 @@ VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrate( int32 StrateIndex, const FVoxelStrateMeasureSettings& Settings); +/** The same Tier 2 pass over an explicit voxel Z window and a custom read-only sampler. */ +VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrateWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVoxelStrateMeasureSettings& Settings); + /** * Test coarse connectivity and re-check recovered coarse routes at full resolution. * @@ -188,3 +204,26 @@ VOXELFORGE_API FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings); + +/** Connectivity variant for the explicit custom-stack measurement window. */ +VOXELFORGE_API EVoxelConnectivityResult VF_AreConnectedWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + FVoxelConnectivityDiagnostics* OutDiagnostics = nullptr); + +/** Full diagnostics variant for a custom stack. */ +VOXELFORGE_API FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings);