diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b1d35a2..217154a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -972,6 +972,41 @@ generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colou - **Status:** C++ ✅ BUILT & WORKING (ticked 2026-07-27). The master material graph is still editor-side work and is deliberately NOT ticked — that half is Jahni's, not the code's. +### 8.16 Explorer commandlet — one authoritative field, three consumers +`-run=VoxelForgeExplore` lives in the separate `VoxelForgeEditor` module, so the commandlet and its +PNG/JSON/OBJ writers are editor/commandlet-only and cannot enter a Game or Shipping link. It creates +only transient settings, definitions, manager, generator, diff layer, and mesher objects; no authored +asset, live manager, generator, or diff layer is mutated. The transient world keeps +`WorldRadiusVoxels=0` and `InterStrateGapChunks=0`, preserving the lateral-region gate. + +- **Render.** The renderer samples `UVoxelGenerator::GetDensityAt` at a fixed 0.25-voxel default + step (overrideable down to 0.125), detects the MC density sign crossing, and refines it with ten + bisection iterations. It never treats density as an SDF and never sphere-traces. Each of three + deterministic views overlays a projected 1.76 m human-height marker; the JSON records the step, + metre conversion, refinement count, and marker projection status. +- **Walk.** `VF_MeasurePlayerFitWalkWithSampler` builds the existing step-1 capsule/floor-fit stencil + and uses the same six-neighbour/full-resolution route gate as the existing diagnostics. Its + deterministic depth-first agent walk counts metres travelled (including branch backtracking), + dead ends per 100 m, the arrival component's reachable fit volume, and traversed edges touching a + narrow-gap proxy below 1.5× the 0.68 m capsule width. The proxy is explicitly axis-aligned air span; + it is not a second player-fit or movement definition. The explorer passes the shared measurement + `MaxCells` refusal at a 32,000,000-cell default cap, with a conservative 20-byte/cell estimate + against a documented 768 MiB working-memory budget. Reachable fit volume is explicitly the bounded + fitted route window because `WorldRadiusVoxels=0` deliberately has no finite whole-world volume. +- **Export.** `UVoxelMarchingCubesMesher::GenerateMesh` is the only mesher. Because that canonical + entry point deliberately clamps one call to a 32³ tile, a 128³ request is four-by-four-by-four + canonical calls whose returned vertices/triangles are streamed with an index offset; no second + mesher or density evaluator exists. The bounded result is metre-space OBJ with source winding, + voxel-space UVs, normals, and ground/ceiling material groups, accompanied by a manifest containing seed, + archetype, slot, bounds, and player dimensions. Tile skirts are disabled at the export boundary + because they are a render seam aid that would extend beyond the manifest box. A conservative + preflight cap is checked before the canonical mesher is called. + +The report and export manifest use fixed-order writers and serialize twice in-process to assert the +deterministic contract. Timings are console diagnostics only; putting wall-clock durations in the report would +break byte identity. The browser viewer remains a separate display-only consumer: it must load this +OBJ/manifest and never evaluate density or generate geometry. + ## 9. Multiplayer model (listen-server first, dedicated-friendly) > **Status: transport is DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a diff --git a/CODEMAP.md b/CODEMAP.md index e84cc7a..8579b32 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -112,9 +112,10 @@ remains `0`, and lateral regions remain gated off. ### 3.1 Module & build | File | Role | |------|------| -| `../../VoxelForge.uplugin` | Plugin manifest. One Runtime module `VoxelForge`. Beta. | +| `../../VoxelForge.uplugin` | Plugin manifest. Runtime module `VoxelForge` plus the Editor-only `VoxelForgeEditor` commandlet module. Beta. | | `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. | | `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). | +| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. | | `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. | ### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it) @@ -400,6 +401,16 @@ No UObject state, cache, actor, world, or PIE is required. | `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; resolves either the legacy centered square or the two-point fitted AABB, and refuses invalid bounds or a grid over `MaxCells`. | | `VF_AreConnected` | Coarse 6-connected BFS plus deterministic blocked-edge retries, with a full-resolution air recheck for every candidate route and explicit endpoint-solid, out-of-window, `NotConnectedAtThisResolution`, `CoarseLiedBudgetExhausted` (unknown), and connected outcomes; diagnostics include endpoint component facts and the retry count from that same grid. | | `VF_DiagnoseConnectivity` | The same query plus each mouth's air-component size/share and exact geometric distance to the nearest cell of the other component; proximity is measured in coarse-cell coordinates and does not claim a route through solid. | +| `FVoxelPlayerFitWalkReport` / `VF_MeasurePlayerFitWalkWithSampler` | Editor-only experiential instrument over the existing exact `VF_` capsule/floor-fit mask: deterministic graph-walk distance, dead ends, reachable fit volume, and a labelled narrow-gap proxy; no second movement model. | + +`VoxelForgeExploreCommandlet` builds a transient, fixed-definition world and calls the production +`UVoxelGenerator::GetDensityAt` and `UVoxelMarchingCubesMesher::GenerateMesh` paths. `render` uses +fixed density samples (default 0.25 voxel) with bisection at sign crossings instead of sphere tracing; +`walk` uses `VF_MeasurePlayerFitWalkWithSampler`; `export` calls the canonical 32³ mesher in a +deterministic tile grid (four tiles per axis for the 128³ default), streams only its returned tile +arrays into metre-space OBJ plus `manifest.json` (canonical UVs remain voxel-space), and never retains the full region mesh. Output JSON/manifest are emitted by fixed-order writers and compared with a second +serialization before it is written. The commandlet keeps `WorldRadiusVoxels=0`, does not touch an +authored asset or diff layer, and refuses bounded-grid/export requests before allocating them. ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` Header is rich with inline docs. Two namespaces + a per-chunk cache system. @@ -794,6 +805,8 @@ Stage order (negative=solid throughout). Each stage's anchor: are dropped in `ProcessPendingChunks`. Always carry the epoch through new async paths. - **Generated code** under `Intermediate/` and `Binaries/` is build output — never edit. `*.generated.h` / `*.gen.cpp` are UHT output for the `UCLASS`/`USTRUCT` above. +- **`UPackage` completeness:** files that call `NewObject(..., GetTransientPackage(), ...)` must include + `UObject/Package.h` directly; files that do not include `VoxelForgeTestFixture.h` do not get it transitively. --- diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 920bbcb..95d47cc 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -1338,6 +1338,37 @@ namespace VoxelStrateMeasurePrivate } } + void BuildPlayerFitConnectivityMetrics( + const FSampleGrid& Grid, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMetrics& OutMetrics, + TArray& OutPlayerFitMask) + { + OutMetrics = FVoxelStrateMetrics(); + OutMetrics.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; + OutMetrics.SampledMinZ = Grid.SampledMinZ; + OutMetrics.SampledMaxZ = Grid.SampledMaxZ; + OutMetrics.SampledNumX = Grid.NumX; + OutMetrics.SampledNumY = Grid.NumY; + OutMetrics.SampledNumZ = Grid.NumZ; + OutMetrics.SampledMinX = Grid.MinX; + OutMetrics.SampledMaxX = Grid.MaxX; + OutMetrics.SampledMinY = Grid.MinY; + OutMetrics.SampledMaxY = Grid.MaxY; + OutMetrics.NumSampled = Grid.CellCount; + for (const uint8 bAir : Grid.Air) + { + OutMetrics.NumAir += bAir != 0u ? 1 : 0; + } + OutMetrics.NumSolid = static_cast(Grid.CellCount) - OutMetrics.NumAir; + OutMetrics.AirFraction = OutMetrics.NumSampled > 0 + ? static_cast(static_cast(OutMetrics.NumAir) + / static_cast(OutMetrics.NumSampled)) + : 0.0f; + BuildPlayerFitMetrics(Grid, Settings, OutMetrics, &OutPlayerFitMask); + OutMetrics.bValid = OutMetrics.NumSampled > 0; + } + float MedianFloat(TArray& Values) { if (Values.Num() == 0) return 0.0f; @@ -1948,7 +1979,6 @@ namespace VoxelStrateMeasurePrivate bOutStartSnapped = false; bOutGoalSnapped = false; OutNumRouteRetries = 0; - const EEndpointCellResult StartCell = ResolveEndpointCell( Grid, AVoxel, OutStart, bOutStartSnapped, EligibilityMask); const EEndpointCellResult GoalCell = ResolveEndpointCell( @@ -2243,28 +2273,7 @@ namespace VoxelStrateMeasurePrivate // Manhattan feature transform here: those values belong to the separate legacy // measurement pass, and recomputing them for every fine candidate made the gate // needlessly expensive without changing its answer. - PlayerMetrics.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; - PlayerMetrics.SampledMinZ = Grid.SampledMinZ; - PlayerMetrics.SampledMaxZ = Grid.SampledMaxZ; - PlayerMetrics.SampledNumX = Grid.NumX; - PlayerMetrics.SampledNumY = Grid.NumY; - PlayerMetrics.SampledNumZ = Grid.NumZ; - PlayerMetrics.SampledMinX = Grid.MinX; - PlayerMetrics.SampledMaxX = Grid.MaxX; - PlayerMetrics.SampledMinY = Grid.MinY; - PlayerMetrics.SampledMaxY = Grid.MaxY; - PlayerMetrics.NumSampled = Grid.CellCount; - for (const uint8 bAir : Grid.Air) - { - PlayerMetrics.NumAir += bAir != 0u ? 1 : 0; - } - PlayerMetrics.NumSolid = static_cast(Grid.CellCount) - PlayerMetrics.NumAir; - PlayerMetrics.AirFraction = PlayerMetrics.NumSampled > 0 - ? static_cast(static_cast(PlayerMetrics.NumAir) - / static_cast(PlayerMetrics.NumSampled)) - : 0.0f; - BuildPlayerFitMetrics(Grid, Settings, PlayerMetrics, &PlayerFitMask); - PlayerMetrics.bValid = PlayerMetrics.NumSampled > 0; + BuildPlayerFitConnectivityMetrics(Grid, Settings, PlayerMetrics, PlayerFitMask); if (OutPlayerMetrics != nullptr) { *OutPlayerMetrics = PlayerMetrics; @@ -2314,6 +2323,227 @@ namespace VoxelStrateMeasurePrivate } return Result; } + + int32 GetSixNeighbour(const FSampleGrid& Grid, int32 Cell, int32 Direction) + { + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Cell, X, Y, Z); + switch (Direction) + { + case 0: return X + 1 < Grid.NumX ? Grid.Index(X + 1, Y, Z) : INDEX_NONE; + case 1: return X > 0 ? Grid.Index(X - 1, Y, Z) : INDEX_NONE; + case 2: return Y + 1 < Grid.NumY ? Grid.Index(X, Y + 1, Z) : INDEX_NONE; + case 3: return Y > 0 ? Grid.Index(X, Y - 1, Z) : INDEX_NONE; + case 4: return Z + 1 < Grid.NumZ ? Grid.Index(X, Y, Z + 1) : INDEX_NONE; + case 5: return Z > 0 ? Grid.Index(X, Y, Z - 1) : INDEX_NONE; + default: return INDEX_NONE; + } + } + + bool IsEligiblePlayerFitCell( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + int32 Cell) + { + return Cell >= 0 + && Cell < Grid.CellCount + && Grid.Air[Cell] != 0u + && PlayerFitMask[Cell] != 0u; + } + + int32 CountPlayerFitNeighbours( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + int32 Cell) + { + int32 Count = 0; + for (int32 Direction = 0; Direction < 6; ++Direction) + { + if (IsEligiblePlayerFitCell( + Grid, PlayerFitMask, GetSixNeighbour(Grid, Cell, Direction))) + { + ++Count; + } + } + return Count; + } + + int32 LocalAirSpanVoxels( + const FSampleGrid& Grid, + int32 Cell, + int32 Axis, + int32 MaxRelevantSpanVoxels) + { + if (Cell < 0 || Cell >= Grid.CellCount || Grid.Air[Cell] == 0u) + { + return 0; + } + + int32 CentreX = 0; + int32 CentreY = 0; + int32 CentreZ = 0; + DecodeIndex(Grid, Cell, CentreX, CentreY, CentreZ); + int32 Span = 1; + bool bTouchesWindowBoundary = false; + for (const int32 Sign : { -1, 1 }) + { + int32 Offset = 1; + for (;; ++Offset) + { + if (Span >= MaxRelevantSpanVoxels) + { + return Span; + } + const int32 X = Axis == 0 ? CentreX + Sign * Offset : CentreX; + const int32 Y = Axis == 1 ? CentreY + Sign * Offset : CentreY; + const int32 Z = CentreZ; + if (X < 0 || X >= Grid.NumX || Y < 0 || Y >= Grid.NumY) + { + bTouchesWindowBoundary = true; + break; + } + if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) + { + break; + } + ++Span; + } + } + // A window edge is a lower bound, not a solid wall. Do not label a corridor narrow merely + // because the fitted diagnostic box clipped the air span before reaching rock. + return bTouchesWindowBoundary ? MAX_int32 : Span * Grid.SampleStep; + } + + float LocalWidestHorizontalAirSpanVoxels( + const FSampleGrid& Grid, + int32 Cell, + float NarrowGapThresholdVoxels) + { + const int32 MaxRelevantSpanVoxels = FMath::Max( + 1, + FMath::CeilToInt(NarrowGapThresholdVoxels)); + return static_cast(FMath::Max( + LocalAirSpanVoxels(Grid, Cell, 0, MaxRelevantSpanVoxels), + LocalAirSpanVoxels(Grid, Cell, 1, MaxRelevantSpanVoxels))); + } + + void WalkPlayerFitGraph( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + int32 Start, + int32 Goal, + float NarrowGapThresholdVoxels, + FVoxelPlayerFitWalkReport& InOutReport) + { + if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, Start) + || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, Goal)) + { + return; + } + + TArray Visited; + Visited.Init(0u, Grid.CellCount); + TArray NarrowCell; + NarrowCell.Init(255u, Grid.CellCount); + TArray Stack; + TArray NextDirection; + // Reserve the full bounded capacity up front. TArray growth slack is otherwise allowed + // to exceed the walker's stated memory budget while the DFS discovers a large component. + Stack.Reserve(Grid.CellCount); + NextDirection.Reserve(Grid.CellCount); + Stack.Add(Start); + NextDirection.Add(0); + Visited[Start] = 1u; + + auto RecordTraversal = [&](int32 From, int32 To) + { + ++InOutReport.AgentGraphTraversals; + auto IsNarrowCell = [&](int32 Cell) + { + if (NarrowCell[Cell] == 255u) + { + NarrowCell[Cell] = LocalWidestHorizontalAirSpanVoxels( + Grid, Cell, NarrowGapThresholdVoxels) + < NarrowGapThresholdVoxels ? 1u : 0u; + } + return NarrowCell[Cell] != 0u; + }; + const bool bNarrow = IsNarrowCell(From) || IsNarrowCell(To); + if (bNarrow) + { + ++InOutReport.NarrowGapTraversals; + } + }; + + while (Stack.Num() > 0) + { + const int32 Current = Stack.Last(); + if (Current == Goal) + { + InOutReport.bCanReachDeparture = true; + break; + } + + bool bAdvanced = false; + while (NextDirection.Last() < 6) + { + const int32 Direction = NextDirection.Last()++; + const int32 Neighbour = GetSixNeighbour(Grid, Current, Direction); + if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, Neighbour) + || Visited[Neighbour] != 0u) + { + continue; + } + + Visited[Neighbour] = 1u; + RecordTraversal(Current, Neighbour); + Stack.Add(Neighbour); + NextDirection.Add(0); + bAdvanced = true; + break; + } + + if (bAdvanced) + { + continue; + } + + // A degree-one pose is an actual topological dead end. A higher-degree pose with all + // branches already visited is merely a DFS backtrack through a loop/junction. + if (Current != Start && Current != Goal + && CountPlayerFitNeighbours(Grid, PlayerFitMask, Current) <= 1) + { + ++InOutReport.DeadEndsEncountered; + } + + Stack.Pop(); + NextDirection.Pop(); + if (Stack.Num() > 0) + { + RecordTraversal(Current, Stack.Last()); + } + } + + const float DistanceMeters = static_cast(InOutReport.AgentGraphTraversals) + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + InOutReport.DistanceTravelledMeters = DistanceMeters; + InOutReport.NarrowGapFraction = InOutReport.AgentGraphTraversals > 0 + ? static_cast(static_cast(InOutReport.NarrowGapTraversals) + / static_cast(InOutReport.AgentGraphTraversals)) + : 0.0f; + InOutReport.NarrowGapEventsPer100m = DistanceMeters > KINDA_SMALL_NUMBER + ? static_cast(static_cast(InOutReport.NarrowGapTraversals) + * 100.0 / static_cast(DistanceMeters)) + : 0.0f; + InOutReport.DeadEndsPer100m = DistanceMeters > KINDA_SMALL_NUMBER + ? static_cast(static_cast(InOutReport.DeadEndsEncountered) + * 100.0 / static_cast(DistanceMeters)) + : 0.0f; + InOutReport.DeadEndsEncountered = FMath::Max( + InOutReport.DeadEndsEncountered, 0); + } } FVoxelStrateMetrics VF_MeasureStrate( @@ -2587,4 +2817,182 @@ FVoxelConnectivityDiagnostics VF_DiagnosePlayerFitConnectivityWithSampler( return Result; } +bool VF_MeasurePlayerFitWalkWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + FVoxelPlayerFitWalkReport& OutReport) +{ + using namespace VoxelStrateMeasurePrivate; + + OutReport = FVoxelPlayerFitWalkReport(); + OutReport.CapsuleRadiusMeters = Settings.PlayerCapsuleRadiusVoxels + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + OutReport.CapsuleWidthMeters = 2.0f * OutReport.CapsuleRadiusMeters; + OutReport.CapsuleHeightMeters = 2.0f * Settings.PlayerCapsuleHalfHeightVoxels + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + OutReport.NarrowGapThresholdMeters = 1.5f * OutReport.CapsuleWidthMeters; + OutReport.NarrowGapDefinition = TEXT( + "widest axis-aligned horizontal air span at each traversed fit-graph cell"); + + const bool bFiniteEndpoints = FMath::IsFinite(AVoxel.X) + && FMath::IsFinite(AVoxel.Y) + && FMath::IsFinite(AVoxel.Z) + && FMath::IsFinite(BVoxel.X) + && FMath::IsFinite(BVoxel.Y) + && FMath::IsFinite(BVoxel.Z); + if (!bFiniteEndpoints) + { + OutReport.RefusalReason = TEXT("Arrival and departure points must be finite."); + return false; + } + if (Settings.MaxRouteRetries < 0) + { + OutReport.RefusalReason = TEXT("MaxRouteRetries cannot be negative."); + return false; + } + if (Settings.SampleStep != 1) + { + OutReport.RefusalReason = TEXT( + "Player-fit walk refused: exact capsule fit requires SampleStep=1."); + return false; + } + + FSampleGrid Grid; + FString RefusalReason; + if (!BuildSampleGrid( + nullptr, + nullptr, + &Sampler, + INDEX_NONE, + StrateBottomWorldZ, + StrateTopWorldZ, + BoundarySealThickness, + true, + Settings, + true, + Grid, + RefusalReason)) + { + OutReport.RefusalReason = RefusalReason; + return false; + } + + TArray PlayerFitMask; + int64 NumPlayerFitCells = 0; + if (!BuildPlayerFitMask( + Grid, + Settings, + PlayerFitMask, + NumPlayerFitCells, + RefusalReason)) + { + OutReport.RefusalReason = RefusalReason; + return false; + } + + int32 Start = INDEX_NONE; + int32 Goal = INDEX_NONE; + int32 NumRouteRetries = 0; + const EVoxelConnectivityResult Result = EvaluateConnectivityOnGrid( + nullptr, + &Sampler, + Grid, + AVoxel, + BVoxel, + Start, + Goal, + OutReport.bStartSnapped, + OutReport.bGoalSnapped, + NumRouteRetries, + Settings.MaxRouteRetries, + &PlayerFitMask); + + FVoxelConnectivityDiagnostics Diagnostics; + PopulateConnectivityDiagnostics( + Grid, + Start, + Goal, + Result, + OutReport.bStartSnapped, + OutReport.bGoalSnapped, + NumRouteRetries, + Diagnostics, + &PlayerFitMask); + + OutReport.bValid = true; + OutReport.Result = Result; + OutReport.NumRouteRetries = NumRouteRetries; + OutReport.SampledNumX = Grid.NumX; + OutReport.SampledNumY = Grid.NumY; + OutReport.SampledNumZ = Grid.NumZ; + OutReport.SampledMinZ = Grid.SampledMinZ; + OutReport.SampledMaxZ = Grid.SampledMaxZ; + OutReport.SampledMinX = Grid.MinX; + OutReport.SampledMaxX = Grid.MaxX; + OutReport.SampledMinY = Grid.MinY; + OutReport.SampledMaxY = Grid.MaxY; + OutReport.PlayerFitVolumeCells = NumPlayerFitCells; + OutReport.ReachablePlayerFitCells = FMath::Max( + Diagnostics.StartComponentCells, 0); + OutReport.ReachablePlayerFitFraction = OutReport.PlayerFitVolumeCells > 0 + ? static_cast(static_cast(OutReport.ReachablePlayerFitCells) + / static_cast(OutReport.PlayerFitVolumeCells)) + : 0.0f; + OutReport.StraightLineMeters = FVector::Dist(AVoxel, BVoxel) + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + + if (Start >= 0 && Goal >= 0) + { + const float NarrowGapThresholdVoxels = OutReport.NarrowGapThresholdMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + WalkPlayerFitGraph( + Grid, + PlayerFitMask, + Start, + Goal, + NarrowGapThresholdVoxels, + OutReport); + } + + OutReport.bCanReachDeparture = Result == EVoxelConnectivityResult::Connected; + OutReport.Tortuosity = OutReport.StraightLineMeters > KINDA_SMALL_NUMBER + ? OutReport.DistanceTravelledMeters / OutReport.StraightLineMeters + : 0.0f; + OutReport.DeadEndsEncountered = FMath::Max( + OutReport.DeadEndsEncountered, 0); + OutReport.NarrowGapEventsPer100m = OutReport.DistanceTravelledMeters + > KINDA_SMALL_NUMBER + ? static_cast(static_cast(OutReport.NarrowGapTraversals) + * 100.0 / static_cast(OutReport.DistanceTravelledMeters)) + : 0.0f; + OutReport.DeadEndsPer100m = OutReport.DistanceTravelledMeters + > KINDA_SMALL_NUMBER + ? static_cast(static_cast(OutReport.DeadEndsEncountered) + * 100.0 / static_cast(OutReport.DistanceTravelledMeters)) + : 0.0f; + + if (Result == EVoxelConnectivityResult::StartCellNotPlayerFit) + { + OutReport.RefusalReason = TEXT("arrival endpoint is not a player-fitting floor cell"); + } + else if (Result == EVoxelConnectivityResult::GoalCellNotPlayerFit) + { + OutReport.RefusalReason = TEXT("departure endpoint is not a player-fitting floor cell"); + } + else if (Result == EVoxelConnectivityResult::OutOfWindow) + { + OutReport.RefusalReason = TEXT("arrival or departure point is outside the fitted walk window"); + } + else + { + OutReport.RefusalReason = Diagnostics.RefusalReason; + } + return true; +} + #endif // WITH_EDITOR — measurement never ships diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index d1ea0bb..7cb4e26 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -255,6 +255,61 @@ struct VOXELFORGE_API FVoxelConnectivityDiagnostics float GoalToStartComponentDistanceCells = -1.0f; }; +/** + * Experiential traversal facts over the exact VF player-fit mask. + * + * This is intentionally a report, not a new movement implementation. The mask is built by the + * same direct capsule stencil used by VF_DiagnosePlayerFitConnectivityWithSampler; the explorer's + * deterministic graph walk only instruments that already-resolved six-neighbour pose graph. + * Distances are metres so the result can be read beside a human-scale screenshot. + */ +struct VOXELFORGE_API FVoxelPlayerFitWalkReport +{ + bool bValid = false; + FString RefusalReason; + EVoxelConnectivityResult Result = EVoxelConnectivityResult::OutOfWindow; + bool bCanReachDeparture = false; + bool bStartSnapped = false; + bool bGoalSnapped = false; + int32 NumRouteRetries = 0; + + int64 PlayerFitVolumeCells = 0; + int64 ReachablePlayerFitCells = 0; + float ReachablePlayerFitFraction = 0.0f; + int32 SampledNumX = 0; + int32 SampledNumY = 0; + int32 SampledNumZ = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + float SampledMinX = 0.0f; + float SampledMaxX = 0.0f; + float SampledMinY = 0.0f; + float SampledMaxY = 0.0f; + + // The agent follows a fixed-order depth-first walk over the already-resolved fit graph. A + // branch edge is counted twice when the agent backtracks, which makes this an instrument of + // traversal experience rather than a shortest-path length. + int64 AgentGraphTraversals = 0; + int64 DeadEndsEncountered = 0; + float DeadEndsPer100m = 0.0f; + float DistanceTravelledMeters = 0.0f; + float StraightLineMeters = 0.0f; + float Tortuosity = 0.0f; + + // A narrow-gap event is a traversed graph edge touching a route cell whose widest + // axis-aligned horizontal air span is below the threshold. This deliberately reports the + // operational proxy instead of pretending the voxel mask contains an oriented corridor width. + int64 NarrowGapTraversals = 0; + float NarrowGapFraction = 0.0f; + float NarrowGapThresholdMeters = 0.0f; + float NarrowGapEventsPer100m = 0.0f; + + float CapsuleRadiusMeters = 0.0f; + float CapsuleWidthMeters = 0.0f; + float CapsuleHeightMeters = 0.0f; + FString NarrowGapDefinition; +}; + /** * Measure one strate in actor-space voxel coordinates. * @@ -395,3 +450,18 @@ VOXELFORGE_API FVoxelConnectivityDiagnostics VF_DiagnosePlayerFitConnectivityWit const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateMetrics* OutPlayerMetrics = nullptr); + +/** + * Instrument a deterministic agent over the same exact player-fit graph used by the route gate. + * The return value is false only when the bounded fine grid/stencil could not be built; a valid + * report may still say NotConnectedAtThisResolution or another explicit connectivity result. + */ +VOXELFORGE_API bool VF_MeasurePlayerFitWalkWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + FVoxelPlayerFitWalkReport& OutReport); diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeEditorModule.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeEditorModule.cpp new file mode 100644 index 0000000..e780070 --- /dev/null +++ b/Source/VoxelForgeEditor/Private/VoxelForgeEditorModule.cpp @@ -0,0 +1,11 @@ +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +class FVoxelForgeEditorModule final : public IModuleInterface +{ +public: + virtual void StartupModule() override {} + virtual void ShutdownModule() override {} +}; + +IMPLEMENT_MODULE(FVoxelForgeEditorModule, VoxelForgeEditor) diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp new file mode 100644 index 0000000..578d437 --- /dev/null +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -0,0 +1,1716 @@ +#include "VoxelForgeExploreCommandlet.h" + +#include "CoreMinimal.h" +#include "Commandlets/Commandlet.h" +#include "Containers/StringConv.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformTime.h" +#include "IImageWrapper.h" +#include "IImageWrapperModule.h" +#include "Misc/CommandLine.h" +#include "Misc/Crc.h" +#include "Misc/FileHelper.h" +#include "Misc/Parse.h" +#include "Misc/Paths.h" +#include "Modules/ModuleManager.h" +#include "Policies/CondensedJsonPrintPolicy.h" +#include "Serialization/Archive.h" +#include "Serialization/JsonWriter.h" +#include "UObject/Package.h" +#include "UObject/StrongObjectPtr.h" +#include "UObject/UObjectGlobals.h" +#include "VoxelDiffLayer.h" +#include "VoxelGenerator.h" +#include "VoxelMarchingCubesMesher.h" +#include "VoxelSettings.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" +#include "VoxelStrateMeasure.h" +#include "VoxelTypes.h" + +namespace +{ +using FExploreJsonWriter = TJsonWriter>; + +constexpr int32 DefaultRenderWidth = 192; +constexpr int32 DefaultRenderHeight = 128; +constexpr float DefaultRenderStepVoxels = 0.25f; +constexpr float DefaultRenderMaxDistanceVoxels = 256.0f; +constexpr int32 DefaultExportSize = 128; +constexpr int32 DefaultMaxWalkCells = 32000000; +constexpr int32 MaxSyntheticStrates = 64; +constexpr int32 MaxRenderPixels = 1048576; +constexpr int64 MaxRenderDensitySamples = 200000000ll; +constexpr int32 MaxExportSize = 128; +constexpr int64 MaxWalkWorkingBytes = 768ll * 1024ll * 1024ll; +// The exact fine-grid walk retains Air + Density + player-fit bits, and can transiently hold +// Parent + Queue + Path or Visited + NarrowCell + DFS stacks. 20 bytes/cell is a conservative +// preflight figure for those arrays, including allocator slack/headroom. +constexpr int64 EstimatedWalkBytesPerCell = 20ll; +constexpr int64 MaxExportWorkingBytes = 1024ll * 1024ll * 1024ll; + +const TCHAR* ArchetypeName(ECaveGeneratorType Archetype) +{ + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork"); + case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain"); + case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber"); + case ECaveGeneratorType::Maze: return TEXT("Maze"); + case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld"); + case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts"); + case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands"); + case ECaveGeneratorType::Underwater: return TEXT("Underwater"); + default: return TEXT("Unknown"); + } +} + +const TCHAR* ConnectivityResultName(EVoxelConnectivityResult Result) +{ + switch (Result) + { + case EVoxelConnectivityResult::Connected: return TEXT("Connected"); + case EVoxelConnectivityResult::NotConnectedAtThisResolution: + return TEXT("NotConnectedAtThisResolution"); + case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid"); + case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid"); + case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow"); + case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudgetExhausted"); + case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit"); + case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit"); + default: return TEXT("Unknown"); + } +} + +bool ParseArchetype(const FString& Text, ECaveGeneratorType& OutArchetype) +{ + FString Normalized = Text; + Normalized.TrimStartAndEndInline(); + Normalized.ToLowerInline(); + Normalized.ReplaceInline(TEXT("_"), TEXT("")); + Normalized.ReplaceInline(TEXT("-"), TEXT("")); + + const TPair Names[] = { + { TEXT("tunnelnetwork"), ECaveGeneratorType::TunnelNetwork }, + { TEXT("flatplain"), ECaveGeneratorType::FlatPlain }, + { TEXT("crystalchamber"), ECaveGeneratorType::CrystalChamber }, + { TEXT("maze"), ECaveGeneratorType::Maze }, + { TEXT("surfaceworld"), ECaveGeneratorType::SurfaceWorld }, + { TEXT("verticalshafts"), ECaveGeneratorType::VerticalShafts }, + { TEXT("floatingislands"), ECaveGeneratorType::FloatingIslands }, + { TEXT("floatingisland"), ECaveGeneratorType::FloatingIslands }, + { TEXT("underwater"), ECaveGeneratorType::Underwater }, + }; + for (const auto& Pair : Names) + { + if (Normalized == Pair.Key) + { + OutArchetype = Pair.Value; + return true; + } + } + return false; +} + +bool NeedsOriginInWindow(ECaveGeneratorType Archetype) +{ + // This is the same topology policy as VoxelForgePlayerFitWindow.h. That header is test-only, + // so the editor commandlet keeps the policy here while still delegating the actual fit test to + // VoxelStrateMeasure's shared VF_ stencil. + switch (Archetype) + { + case ECaveGeneratorType::Maze: + case ECaveGeneratorType::VerticalShafts: + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + case ECaveGeneratorType::SurfaceWorld: + case ECaveGeneratorType::FloatingIslands: + default: + return false; + } +} + +float BoundarySealForArchetype( + const UVoxelStrateDefinition& Definition, + ECaveGeneratorType Archetype) +{ + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Definition.SlabParams.BoundarySealThickness; + case ECaveGeneratorType::Maze: + return Definition.MazeParams.BoundarySealThickness; + case ECaveGeneratorType::SurfaceWorld: + return Definition.SurfaceParams.BoundarySealThickness; + case ECaveGeneratorType::VerticalShafts: + return Definition.VerticalShaftParams.BoundarySealThickness; + case ECaveGeneratorType::FloatingIslands: + return Definition.FloatingIslandParams.BoundarySealThickness; + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + default: + return Definition.GenerationParams.BoundarySealThickness; + } +} + +struct FExploreArguments +{ + int32 Seed = 0; + ECaveGeneratorType Archetype = ECaveGeneratorType::Maze; + int32 Slot = 4; + bool bRender = true; + bool bWalk = true; + bool bExport = true; + FString OutDirectory; + + int32 RenderWidth = DefaultRenderWidth; + int32 RenderHeight = DefaultRenderHeight; + float RenderStepVoxels = DefaultRenderStepVoxels; + float RenderMaxDistanceVoxels = DefaultRenderMaxDistanceVoxels; + int32 ExportSize = DefaultExportSize; + int32 MaxWalkCells = DefaultMaxWalkCells; + + FString CanonicalModes() const + { + FString Result; + if (bRender) Result += TEXT("render,"); + if (bWalk) Result += TEXT("walk,"); + if (bExport) Result += TEXT("export,"); + if (Result.EndsWith(TEXT(","))) + { + Result.LeftChopInline(1); + } + return Result; + } +}; + +bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FString& OutError) +{ + FString ArchetypeText = TEXT("Maze"); + FString ModesText = TEXT("render,walk,export"); + FString OutText; + + FParse::Value(*Params, TEXT("seed="), OutArguments.Seed); + FParse::Value(*Params, TEXT("archetype="), ArchetypeText); + FParse::Value(*Params, TEXT("slot="), OutArguments.Slot); + FParse::Value(*Params, TEXT("modes="), ModesText); + FParse::Value(*Params, TEXT("out="), OutText); + FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth); + FParse::Value(*Params, TEXT("renderheight="), OutArguments.RenderHeight); + FParse::Value(*Params, TEXT("renderstep="), OutArguments.RenderStepVoxels); + FParse::Value(*Params, TEXT("rendermaxdistance="), OutArguments.RenderMaxDistanceVoxels); + FParse::Value(*Params, TEXT("exportsize="), OutArguments.ExportSize); + FParse::Value(*Params, TEXT("maxwalkcells="), OutArguments.MaxWalkCells); + + if (!ParseArchetype(ArchetypeText, OutArguments.Archetype)) + { + OutError = FString::Printf( + TEXT("Unknown archetype '%s'. Expected Maze, TunnelNetwork, FlatPlain, " + "CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, or Underwater."), + *ArchetypeText); + return false; + } + + OutArguments.bRender = false; + OutArguments.bWalk = false; + OutArguments.bExport = false; + TArray Modes; + ModesText.ParseIntoArray(Modes, TEXT(","), true); + for (FString Mode : Modes) + { + Mode.TrimStartAndEndInline(); + Mode.ToLowerInline(); + if (Mode == TEXT("all")) + { + OutArguments.bRender = true; + OutArguments.bWalk = true; + OutArguments.bExport = true; + } + else if (Mode == TEXT("render")) + { + OutArguments.bRender = true; + } + else if (Mode == TEXT("walk")) + { + OutArguments.bWalk = true; + } + else if (Mode == TEXT("export")) + { + OutArguments.bExport = true; + } + else + { + OutError = FString::Printf( + TEXT("Unknown mode '%s'. Expected render, walk, export, or all."), *Mode); + return false; + } + } + if (!OutArguments.bRender && !OutArguments.bWalk && !OutArguments.bExport) + { + OutError = TEXT("At least one mode must be selected."); + return false; + } + + if (OutArguments.Slot < 1 || OutArguments.Slot > MaxSyntheticStrates - 2) + { + OutError = FString::Printf( + TEXT("slot must be an interior slot in [1,%d] so arrival and departure mouths exist."), + MaxSyntheticStrates - 2); + return false; + } + if (OutArguments.RenderWidth < 16 || OutArguments.RenderHeight < 16 + || static_cast(OutArguments.RenderWidth) + * static_cast(OutArguments.RenderHeight) > MaxRenderPixels) + { + OutError = FString::Printf( + TEXT("renderwidth*renderheight must be between 256 pixels and %d pixels."), + MaxRenderPixels); + return false; + } + if (!FMath::IsFinite(OutArguments.RenderStepVoxels) + || OutArguments.RenderStepVoxels < 0.125f + || OutArguments.RenderStepVoxels > 8.0f) + { + OutError = TEXT("renderstep must be finite and in [0.125,8] voxels."); + return false; + } + if (!FMath::IsFinite(OutArguments.RenderMaxDistanceVoxels) + || OutArguments.RenderMaxDistanceVoxels <= 0.0f + || OutArguments.RenderMaxDistanceVoxels > 2048.0f) + { + OutError = TEXT("rendermaxdistance must be finite and in (0,2048] voxels."); + return false; + } + const int64 StepsPerRay = static_cast(FMath::Max( + 1, + FMath::CeilToInt( + OutArguments.RenderMaxDistanceVoxels / OutArguments.RenderStepVoxels))); + const int64 EstimatedRenderSamples = static_cast(OutArguments.RenderWidth) + * static_cast(OutArguments.RenderHeight) + * 3ll + * StepsPerRay; + if (EstimatedRenderSamples > MaxRenderDensitySamples) + { + OutError = FString::Printf( + TEXT("render request would take about %lld fixed density samples, over the cap %lld."), + static_cast(EstimatedRenderSamples), + static_cast(MaxRenderDensitySamples)); + return false; + } + if (OutArguments.ExportSize < CHUNK_SIZE + || OutArguments.ExportSize > MaxExportSize + || OutArguments.ExportSize % CHUNK_SIZE != 0) + { + OutError = FString::Printf( + TEXT("exportsize must be a multiple of CHUNK_SIZE (%d) in [%d,%d] cells per axis."), + CHUNK_SIZE, + CHUNK_SIZE, + MaxExportSize); + return false; + } + if (OutArguments.MaxWalkCells <= 0) + { + OutError = TEXT("maxwalkcells must be greater than zero."); + return false; + } + + if (OutText.IsEmpty()) + { + OutText = FString::Printf( + TEXT("Saved/VoxelForge/Explore/seed%d_%s_slot%d"), + OutArguments.Seed, + ArchetypeName(OutArguments.Archetype), + OutArguments.Slot); + } + if (FPaths::IsRelative(OutText)) + { + OutText = FPaths::Combine(FPaths::ProjectDir(), OutText); + } + OutArguments.OutDirectory = FPaths::ConvertRelativePathToFull(OutText); + FPaths::NormalizeDirectoryName(OutArguments.OutDirectory); + return true; +} + +struct FExploreWorld +{ + TStrongObjectPtr Settings; + TStrongObjectPtr Manager; + TStrongObjectPtr DiffLayer; + TStrongObjectPtr Generator; + TStrongObjectPtr Mesher; + TArray> Definitions; + + int32 TargetBottomWorldZ = 0; + int32 TargetTopWorldZ = 0; + float TargetBoundarySealThickness = 4.0f; + + bool Build(const FExploreArguments& Arguments, FString& OutError) + { + const int32 NumStrates = Arguments.Slot + 2; + if (NumStrates > MaxSyntheticStrates) + { + OutError = TEXT("The synthetic layout would exceed the commandlet strate cap."); + return false; + } + + Settings = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Settings->Seed = Arguments.Seed; + Settings->TotalStrates = NumStrates; + Settings->InterStrateGapChunks = 0; + Settings->WorldRadiusVoxels = 0.0f; + Settings->EdgeSealThickness = 64.0f; + Settings->OriginSpineRadius = 14.0f; + Settings->bOpenSurfaceEntry = true; + + Definitions.Reserve(NumStrates); + for (int32 Index = 0; Index < NumStrates; ++Index) + { + UVoxelStrateDefinition* Definition = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + Definition->StrateName = FText::FromString(FString::Printf( + TEXT("VoxelForgeExplore_%s_slot%d"), + ArchetypeName(Index == Arguments.Slot + ? Arguments.Archetype + : ECaveGeneratorType::TunnelNetwork), + Index)); + Definition->StrateDescription = FText::FromString( + TEXT("Transient, deterministic commandlet definition.")); + Definition->StrateHeightInChunks = 8; + Definition->TransitionType = EVoxelStrateTransition::Hard; + Definition->GeneratorType = Index == Arguments.Slot + ? Arguments.Archetype + : ECaveGeneratorType::TunnelNetwork; + // Exercise the production operator-stack opt-in where it is implemented (Maze), + // while the runtime switch safely falls back for other archetypes. + Definition->bUseOperatorStack = true; + + const TSoftObjectPtr SoftDefinition(Definition); + Settings->FixedStrates.Add(Index, SoftDefinition); + Settings->StratePool.Add(SoftDefinition); + Definitions.Add(TStrongObjectPtr(Definition)); + } + + Manager = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + if (!Manager->Initialize(Settings.Get(), Settings->Seed)) + { + OutError = TEXT("UVoxelStrateManager::Initialize refused the transient layout."); + return false; + } + + if (!Manager->GetLayout().IsValidIndex(Arguments.Slot)) + { + OutError = TEXT("The requested target slot did not enter the manager layout."); + return false; + } + const FStrateSlot& Target = Manager->GetLayout()[Arguments.Slot]; + if (Target.Definition == nullptr) + { + OutError = TEXT("The requested target slot has no resolved definition."); + return false; + } + TargetBottomWorldZ = Target.BottomChunkZ * CHUNK_SIZE; + TargetTopWorldZ = (Target.TopChunkZ + 1) * CHUNK_SIZE; + TargetBoundarySealThickness = BoundarySealForArchetype( + *Target.Definition, Arguments.Archetype); + if (TargetTopWorldZ <= TargetBottomWorldZ) + { + OutError = TEXT("The target strate has no positive voxel-space height."); + return false; + } + + DiffLayer = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Generator = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Generator->InitializeSettings(Settings.Get()); + Generator->SetStrateManager(Manager.Get()); + Generator->SetDiffLayer(DiffLayer.Get()); + Mesher = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Mesher->SetGenerator(Generator.Get()); + + if (Settings->WorldRadiusVoxels != 0.0f + || Generator->WorldRadiusVoxels != 0.0f + || Settings->InterStrateGapChunks != 0) + { + OutError = TEXT( + "Explorer invariant failed: WorldRadiusVoxels must stay 0 and lateral regions must stay gated off."); + return false; + } + return true; + } +}; + +class FGeneratorDensitySampler final : public IVoxelStrateDensitySampler +{ +public: + explicit FGeneratorDensitySampler(const UVoxelGenerator& InGenerator) + : Generator(InGenerator) + { + } + + virtual float SampleDensity(float WorldX, float WorldY, float WorldZ) const override + { + return Generator.GetDensityAt(WorldX, WorldY, WorldZ); + } + +private: + const UVoxelGenerator& Generator; +}; + +struct FExploreRenderFrame +{ + FString FileName; + FVector CameraVoxels = FVector::ZeroVector; + FVector TargetVoxels = FVector::ZeroVector; + int32 HitPixels = 0; + bool bScaleMarkerProjected = false; +}; + +struct FExploreRenderOutput +{ + FString Status; + FString RefusalReason; + int32 Width = 0; + int32 Height = 0; + float FixedStepVoxels = 0.0f; + float FixedStepMeters = 0.0f; + int32 BisectionIterations = 0; + float MaxDistanceVoxels = 0.0f; + int64 EstimatedFixedDensitySamples = 0; + FString StepRationale; + TArray Frames; +}; + +struct FExploreWalkOutput +{ + FString Status; + FString RefusalReason; + bool bHasArrival = false; + bool bHasDeparture = false; + int32 ArrivalPassageCount = 0; + int32 DeparturePassageCount = 0; + FVector ArrivalVoxels = FVector::ZeroVector; + FVector DepartureVoxels = FVector::ZeroVector; + FString WindowPolicy; + FVoxelPlayerFitWalkReport Report; +}; + +struct FExploreExportOutput +{ + FString Status; + FString RefusalReason; + FString MeshFileName; + FString ManifestFileName; + FIntVector RegionOrigin = FIntVector::ZeroValue; + int32 RegionSize = 0; + int32 MesherTileCount = 0; + int64 EstimatedWorkingBytes = 0; + int64 WorkingMemoryCapBytes = MaxExportWorkingBytes; + int64 MeshArrayBytes = 0; + int64 MeshFileSizeBytes = 0; + int32 VertexCount = 0; + int32 TriangleCount = 0; +}; + +struct FExploreRunOutput +{ + FExploreRenderOutput Render; + FExploreWalkOutput Walk; + FExploreExportOutput Export; +}; + +bool FindMouths( + const UVoxelStrateManager& Manager, + int32 TargetSlot, + FVector& OutArrival, + FVector& OutDeparture, + int32& OutArrivalCount, + int32& OutDepartureCount) +{ + OutArrival = FVector::ZeroVector; + OutDeparture = FVector::ZeroVector; + OutArrivalCount = 0; + OutDepartureCount = 0; + for (const FVoxelPassage& Passage : Manager.GetPassages()) + { + if (Passage.LowerStrateIndex == TargetSlot + && Passage.UpperStrateIndex + 1 == TargetSlot) + { + if (OutArrivalCount == 0) + { + OutArrival = Passage.LowerPoint; + } + ++OutArrivalCount; + } + if (Passage.UpperStrateIndex == TargetSlot + && Passage.LowerStrateIndex == TargetSlot + 1) + { + if (OutDepartureCount == 0) + { + OutDeparture = Passage.UpperPoint; + } + ++OutDepartureCount; + } + } + return OutArrivalCount > 0 && OutDepartureCount > 0; +} + +struct FExploreCameraBasis +{ + FVector Forward = FVector::ForwardVector; + FVector Right = FVector::RightVector; + FVector Up = FVector::UpVector; + float Aspect = 1.0f; + float TanHalfFov = 1.0f; +}; + +bool BuildCameraBasis( + const FVector& Position, + const FVector& Target, + float FieldOfViewDegrees, + int32 Width, + int32 Height, + FExploreCameraBasis& OutBasis) +{ + OutBasis.Forward = (Target - Position).GetSafeNormal(); + if (OutBasis.Forward.IsNearlyZero()) + { + return false; + } + OutBasis.Right = FVector::CrossProduct(OutBasis.Forward, FVector::UpVector).GetSafeNormal(); + if (OutBasis.Right.IsNearlyZero()) + { + OutBasis.Right = FVector::CrossProduct(OutBasis.Forward, FVector::RightVector).GetSafeNormal(); + } + OutBasis.Up = FVector::CrossProduct(OutBasis.Right, OutBasis.Forward).GetSafeNormal(); + OutBasis.Aspect = static_cast(Width) / static_cast(Height); + OutBasis.TanHalfFov = FMath::Tan(FMath::DegreesToRadians(FieldOfViewDegrees * 0.5f)); + return OutBasis.Aspect > 0.0f && OutBasis.TanHalfFov > 0.0f; +} + +FVector MakeRayDirection( + const FExploreCameraBasis& Basis, + int32 PixelX, + int32 PixelY, + int32 Width, + int32 Height) +{ + const float NormalizedX = (2.0f * (static_cast(PixelX) + 0.5f) + / static_cast(Width)) - 1.0f; + const float NormalizedY = 1.0f - (2.0f * (static_cast(PixelY) + 0.5f) + / static_cast(Height)); + return (Basis.Forward + + Basis.Right * (NormalizedX * Basis.Aspect * Basis.TanHalfFov) + + Basis.Up * (NormalizedY * Basis.TanHalfFov)).GetSafeNormal(); +} + +bool TraceDensityRay( + const UVoxelGenerator& Generator, + const FVector& Origin, + const FVector& Direction, + float FixedStepVoxels, + float MaxDistanceVoxels, + int32 BisectionIterations, + FVector& OutHitPoint, + FVector& OutNormal) +{ + float PreviousDensity = Generator.GetDensityAt(Origin.X, Origin.Y, Origin.Z); + if (!FMath::IsFinite(PreviousDensity)) + { + return false; + } + if (PreviousDensity == 0.0f) + { + OutHitPoint = Origin; + OutNormal = FVector::UpVector; + return true; + } + + const int32 NumSteps = FMath::Max( + 1, + FMath::CeilToInt(MaxDistanceVoxels / FixedStepVoxels)); + float PreviousDistance = 0.0f; + for (int32 StepIndex = 1; StepIndex <= NumSteps; ++StepIndex) + { + const float CurrentDistance = FMath::Min( + MaxDistanceVoxels, + static_cast(StepIndex) * FixedStepVoxels); + const FVector CurrentPoint = Origin + Direction * CurrentDistance; + const float CurrentDensity = Generator.GetDensityAt( + CurrentPoint.X, CurrentPoint.Y, CurrentPoint.Z); + if (!FMath::IsFinite(CurrentDensity)) + { + return false; + } + + const bool bPreviousAir = PreviousDensity > 0.0f; + const bool bCurrentAir = CurrentDensity > 0.0f; + if (bPreviousAir != bCurrentAir || CurrentDensity == 0.0f) + { + float LowDistance = PreviousDistance; + float HighDistance = CurrentDistance; + const bool bLowAir = bPreviousAir; + for (int32 Iteration = 0; Iteration < BisectionIterations; ++Iteration) + { + const float MidDistance = 0.5f * (LowDistance + HighDistance); + const FVector MidPoint = Origin + Direction * MidDistance; + const float MidDensity = Generator.GetDensityAt( + MidPoint.X, MidPoint.Y, MidPoint.Z); + if (!FMath::IsFinite(MidDensity)) + { + return false; + } + if ((MidDensity > 0.0f) == bLowAir) + { + LowDistance = MidDistance; + } + else + { + HighDistance = MidDistance; + } + } + + const float HitDistance = 0.5f * (LowDistance + HighDistance); + OutHitPoint = Origin + Direction * HitDistance; + const float NormalStep = 0.5f; + const float DXP = Generator.GetDensityAt( + OutHitPoint.X + NormalStep, OutHitPoint.Y, OutHitPoint.Z); + const float DXN = Generator.GetDensityAt( + OutHitPoint.X - NormalStep, OutHitPoint.Y, OutHitPoint.Z); + const float DYP = Generator.GetDensityAt( + OutHitPoint.X, OutHitPoint.Y + NormalStep, OutHitPoint.Z); + const float DYN = Generator.GetDensityAt( + OutHitPoint.X, OutHitPoint.Y - NormalStep, OutHitPoint.Z); + const float DZP = Generator.GetDensityAt( + OutHitPoint.X, OutHitPoint.Y, OutHitPoint.Z + NormalStep); + const float DZN = Generator.GetDensityAt( + OutHitPoint.X, OutHitPoint.Y, OutHitPoint.Z - NormalStep); + if (!FMath::IsFinite(DXP) || !FMath::IsFinite(DXN) + || !FMath::IsFinite(DYP) || !FMath::IsFinite(DYN) + || !FMath::IsFinite(DZP) || !FMath::IsFinite(DZN)) + { + return false; + } + OutNormal = FVector(DXP - DXN, DYP - DYN, DZP - DZN).GetSafeNormal(); + if (OutNormal.IsNearlyZero()) + { + OutNormal = FVector::UpVector; + } + return true; + } + + PreviousDistance = CurrentDistance; + PreviousDensity = CurrentDensity; + } + return false; +} + +void PutPixel(TArray& Pixels, int32 Width, int32 Height, int32 X, int32 Y, const FColor& Color) +{ + if (X >= 0 && X < Width && Y >= 0 && Y < Height) + { + Pixels[X + Width * Y] = Color; + } +} + +void DrawLine( + TArray& Pixels, + int32 Width, + int32 Height, + int32 X0, + int32 Y0, + int32 X1, + int32 Y1, + const FColor& Color) +{ + int32 DX = FMath::Abs(X1 - X0); + const int32 SX = X0 < X1 ? 1 : -1; + int32 DY = -FMath::Abs(Y1 - Y0); + const int32 SY = Y0 < Y1 ? 1 : -1; + int32 Error = DX + DY; + for (;;) + { + PutPixel(Pixels, Width, Height, X0, Y0, Color); + if (X0 == X1 && Y0 == Y1) + { + break; + } + const int32 DoubleError = 2 * Error; + if (DoubleError >= DY) + { + Error += DY; + X0 += SX; + } + if (DoubleError <= DX) + { + Error += DX; + Y0 += SY; + } + } +} + +bool ProjectPoint( + const FVector& Point, + const FVector& CameraPosition, + const FExploreCameraBasis& Basis, + int32 Width, + int32 Height, + FIntPoint& OutPixel) +{ + const FVector Relative = Point - CameraPosition; + const float Depth = FVector::DotProduct(Relative, Basis.Forward); + if (Depth <= KINDA_SMALL_NUMBER) + { + return false; + } + const float NdcX = FVector::DotProduct(Relative, Basis.Right) + / (Depth * Basis.Aspect * Basis.TanHalfFov); + const float NdcY = FVector::DotProduct(Relative, Basis.Up) + / (Depth * Basis.TanHalfFov); + OutPixel.X = FMath::RoundToInt((NdcX * 0.5f + 0.5f) * static_cast(Width - 1)); + OutPixel.Y = FMath::RoundToInt((1.0f - (NdcY * 0.5f + 0.5f)) + * static_cast(Height - 1)); + return NdcX >= -1.0f && NdcX <= 1.0f && NdcY >= -1.0f && NdcY <= 1.0f; +} + +void DrawScaleMarker( + TArray& Pixels, + int32 Width, + int32 Height, + const FVector& CameraPosition, + const FVector& Target, + const FExploreCameraBasis& Basis, + bool& bOutProjected) +{ + constexpr float HumanHeightVoxels = 2.0f + * FVoxelPlayerCapsuleConstants::HalfHeightVoxels; + const FVector MarkerBase = FVector( + Target.X, + Target.Y, + Target.Z - FVoxelPlayerCapsuleConstants::HalfHeightVoxels) + - Basis.Right * 18.0f + + Basis.Forward * 48.0f; + const FVector MarkerTop = MarkerBase + FVector(0.0f, 0.0f, HumanHeightVoxels); + FIntPoint BasePixel = FIntPoint::ZeroValue; + FIntPoint TopPixel = FIntPoint::ZeroValue; + bOutProjected = ProjectPoint( + MarkerBase, CameraPosition, Basis, Width, Height, BasePixel) + && ProjectPoint(MarkerTop, CameraPosition, Basis, Width, Height, TopPixel); + if (!bOutProjected) + { + // The world-space marker is normally in frame by construction. Keep a visible fallback + // for unusual camera/FOV overrides, while the JSON records that it was not projected. + BasePixel = FIntPoint(12, Height - 12); + TopPixel = FIntPoint(12, FMath::Max(12, Height - 12 - Height / 5)); + } + + const FColor MarkerColor(255, 220, 32, 255); + DrawLine(Pixels, Width, Height, BasePixel.X, BasePixel.Y, TopPixel.X, TopPixel.Y, MarkerColor); + const int32 TickHalfWidth = 5; + for (const FIntPoint& Tick : { BasePixel, TopPixel }) + { + DrawLine( + Pixels, + Width, + Height, + Tick.X - TickHalfWidth, + Tick.Y, + Tick.X + TickHalfWidth, + Tick.Y, + MarkerColor); + } +} + +bool SavePng( + const FString& FilePath, + const TArray& Pixels, + int32 Width, + int32 Height, + FString& OutError) +{ + IImageWrapperModule& ImageWrapperModule = FModuleManager::LoadModuleChecked( + FName(TEXT("ImageWrapper"))); + TSharedPtr ImageWrapper = ImageWrapperModule.CreateImageWrapper( + EImageFormat::PNG); + if (!ImageWrapper.IsValid() + || !ImageWrapper->SetRaw( + Pixels.GetData(), + static_cast(Pixels.Num()) * sizeof(FColor), + Width, + Height, + ERGBFormat::BGRA, + 8)) + { + OutError = TEXT("ImageWrapper could not accept the BGRA render buffer."); + return false; + } + const TArray64& Compressed = ImageWrapper->GetCompressed(100); + if (!FFileHelper::SaveArrayToFile(Compressed, *FilePath)) + { + OutError = FString::Printf(TEXT("Could not write PNG '%s'."), *FilePath); + return false; + } + return true; +} + +bool RunRender( + const FExploreArguments& Arguments, + const FExploreWorld& World, + FExploreRenderOutput& OutOutput) +{ + OutOutput = FExploreRenderOutput(); + OutOutput.Width = Arguments.RenderWidth; + OutOutput.Height = Arguments.RenderHeight; + OutOutput.FixedStepVoxels = Arguments.RenderStepVoxels; + OutOutput.FixedStepMeters = Arguments.RenderStepVoxels + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + OutOutput.BisectionIterations = 10; + OutOutput.MaxDistanceVoxels = Arguments.RenderMaxDistanceVoxels; + OutOutput.EstimatedFixedDensitySamples = static_cast(Arguments.RenderWidth) + * static_cast(Arguments.RenderHeight) + * 3ll + * static_cast(FMath::Max( + 1, + FMath::CeilToInt( + Arguments.RenderMaxDistanceVoxels / Arguments.RenderStepVoxels))); + OutOutput.StepRationale = TEXT( + "Density has no signed-distance/Lipschitz contract. The renderer therefore advances at a " + "fixed 0.25-voxel default step (override with -renderstep), detects density sign crossings, " + "and refines each crossing with 10 bisection iterations; it never sphere-traces."); + + const float MiddleZ = 0.5f * static_cast(World.TargetBottomWorldZ + World.TargetTopWorldZ); + const FVector Target(0.0f, 0.0f, MiddleZ); + const FVector CameraPositions[] = { + FVector(0.0f, -192.0f, MiddleZ + 32.0f), + FVector(192.0f, -160.0f, MiddleZ + 64.0f), + FVector(-160.0f, 128.0f, MiddleZ + 96.0f), + }; + + for (int32 ViewIndex = 0; ViewIndex < UE_ARRAY_COUNT(CameraPositions); ++ViewIndex) + { + const FVector CameraPosition = CameraPositions[ViewIndex]; + FExploreCameraBasis Basis; + if (!BuildCameraBasis( + CameraPosition, + Target, + 70.0f, + Arguments.RenderWidth, + Arguments.RenderHeight, + Basis)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("Could not construct a render camera basis."); + return false; + } + + TArray Pixels; + Pixels.Init(FColor(7, 11, 18, 255), Arguments.RenderWidth * Arguments.RenderHeight); + int32 HitPixels = 0; + const FVector LightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); + for (int32 PixelY = 0; PixelY < Arguments.RenderHeight; ++PixelY) + { + for (int32 PixelX = 0; PixelX < Arguments.RenderWidth; ++PixelX) + { + const FVector RayDirection = MakeRayDirection( + Basis, + PixelX, + PixelY, + Arguments.RenderWidth, + Arguments.RenderHeight); + FVector HitPoint; + FVector Normal; + if (!TraceDensityRay( + *World.Generator, + CameraPosition, + RayDirection, + Arguments.RenderStepVoxels, + Arguments.RenderMaxDistanceVoxels, + OutOutput.BisectionIterations, + HitPoint, + Normal)) + { + continue; + } + + ++HitPixels; + const float Diffuse = FMath::Max(0.0f, FVector::DotProduct(Normal, LightDirection)); + const float Brightness = 0.18f + 0.82f * Diffuse; + const uint8 RockR = static_cast(FMath::Clamp(54.0f * Brightness, 0.0f, 255.0f)); + const uint8 RockG = static_cast(FMath::Clamp(72.0f * Brightness, 0.0f, 255.0f)); + const uint8 RockB = static_cast(FMath::Clamp(92.0f * Brightness, 0.0f, 255.0f)); + PutPixel( + Pixels, + Arguments.RenderWidth, + Arguments.RenderHeight, + PixelX, + PixelY, + FColor(RockR, RockG, RockB, 255)); + } + } + + bool bScaleMarkerProjected = false; + DrawScaleMarker( + Pixels, + Arguments.RenderWidth, + Arguments.RenderHeight, + CameraPosition, + Target, + Basis, + bScaleMarkerProjected); + + FExploreRenderFrame& Frame = OutOutput.Frames.AddDefaulted_GetRef(); + Frame.FileName = FString::Printf(TEXT("render_%02d.png"), ViewIndex); + Frame.CameraVoxels = CameraPosition; + Frame.TargetVoxels = Target; + Frame.HitPixels = HitPixels; + Frame.bScaleMarkerProjected = bScaleMarkerProjected; + + FString Error; + if (!SavePng( + FPaths::Combine(Arguments.OutDirectory, Frame.FileName), + Pixels, + Arguments.RenderWidth, + Arguments.RenderHeight, + Error)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = Error; + return false; + } + } + + OutOutput.Status = TEXT("ok"); + return true; +} + +bool RunWalk( + const FExploreArguments& Arguments, + const FExploreWorld& World, + FExploreWalkOutput& OutOutput) +{ + OutOutput = FExploreWalkOutput(); + OutOutput.WindowPolicy = NeedsOriginInWindow(Arguments.Archetype) + ? TEXT("origin-inclusive mouth-AABB + margin") + : TEXT("mouth-AABB + margin"); + + const int64 EstimatedWorkingBytes = static_cast(Arguments.MaxWalkCells) + * EstimatedWalkBytesPerCell; + if (EstimatedWorkingBytes > MaxWalkWorkingBytes) + { + OutOutput.Status = TEXT("refused"); + OutOutput.RefusalReason = FString::Printf( + TEXT("Refused before sampling: estimated walk memory %lld bytes exceeds cap %lld bytes."), + static_cast(EstimatedWorkingBytes), + static_cast(MaxWalkWorkingBytes)); + return false; + } + + if (!FindMouths( + *World.Manager, + Arguments.Slot, + OutOutput.ArrivalVoxels, + OutOutput.DepartureVoxels, + OutOutput.ArrivalPassageCount, + OutOutput.DeparturePassageCount)) + { + OutOutput.Status = TEXT("refused"); + OutOutput.RefusalReason = TEXT( + "The target slot did not have both one-step chain mouths in the generated passage list."); + return false; + } + OutOutput.bHasArrival = true; + OutOutput.bHasDeparture = true; + + FVoxelStrateMeasureSettings Settings; + Settings.SampleStep = 1; + Settings.MaxCells = Arguments.MaxWalkCells; + Settings.MaxRouteRetries = 16; + Settings.CoverPointA = FVector2D(OutOutput.ArrivalVoxels.X, OutOutput.ArrivalVoxels.Y); + Settings.CoverPointB = FVector2D(OutOutput.DepartureVoxels.X, OutOutput.DepartureVoxels.Y); + Settings.CoverMarginVoxels = FMath::CeilToFloat( + Settings.PlayerCapsuleRadiusVoxels) + 2.0f; + Settings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Arguments.Archetype); + + const FGeneratorDensitySampler Sampler(*World.Generator); + if (!VF_MeasurePlayerFitWalkWithSampler( + Sampler, + World.TargetBottomWorldZ, + World.TargetTopWorldZ, + World.TargetBoundarySealThickness, + OutOutput.ArrivalVoxels, + OutOutput.DepartureVoxels, + Settings, + OutOutput.Report)) + { + OutOutput.Status = TEXT("refused"); + OutOutput.RefusalReason = OutOutput.Report.RefusalReason; + return false; + } + + OutOutput.Status = TEXT("ok"); + return true; +} + +int64 EstimateExportWorkingBytes(int32 RegionSize) +{ + const int64 TileCells = static_cast(CHUNK_SIZE) + * static_cast(CHUNK_SIZE) + * static_cast(CHUNK_SIZE); + const int64 TileGridSamples = static_cast(CHUNK_SIZE + 3) + * static_cast(CHUNK_SIZE + 3) + * static_cast(CHUNK_SIZE + 3); + // Export streams one canonical tile at a time. This preflight covers one mesher tile's scalar + // grid, edge map, mesh arrays, and temporary triangle lists; the region's OBJ is not retained + // in memory. RegionSize is still accepted here to keep the call site's contract explicit. + (void)RegionSize; + return TileGridSamples * static_cast(sizeof(float)) + TileCells * 448ll; +} + +bool WriteUtf8(FArchive& Archive, const FString& Text) +{ + FTCHARToUTF8 Converted(*Text); + if (Converted.Length() > 0) + { + Archive.Serialize(const_cast(Converted.Get()), Converted.Length()); + } + return !Archive.IsError(); +} + +bool ValidateCanonicalTile(const FVoxelMeshData& MeshData, FString& OutError) +{ + if (MeshData.Vertices.Num() != MeshData.Normals.Num() + || MeshData.Vertices.Num() != MeshData.UVs.Num() + || MeshData.Vertices.Num() != MeshData.Colors.Num() + || MeshData.Triangles.Num() % 3 != 0) + { + OutError = TEXT("Canonical mesher returned non-parallel mesh arrays."); + return false; + } + return true; +} + +bool WriteObjHeader(FArchive& Archive) +{ + bool bOk = WriteUtf8(Archive, TEXT("# VoxelForge authoritative marching-cubes mesh\n")); + bOk = bOk && WriteUtf8(Archive, + TEXT("# Positions are metres; UVs retain canonical voxel-space values; source winding is preserved.\n")); + bOk = bOk && WriteUtf8(Archive, TEXT("o VoxelForgeExplore\n")); + return bOk && !Archive.IsError(); +} + +bool WriteObjTile( + FArchive& Archive, + const FVoxelMeshData& MeshData, + int32 VertexOffset, + FString& OutError) +{ + if (!ValidateCanonicalTile(MeshData, OutError)) + { + return false; + } + bool bOk = true; + for (const FVector& Vertex : MeshData.Vertices) + { + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("v %.9f %.9f %.9f\n"), + Vertex.X / 100.0, + Vertex.Y / 100.0, + Vertex.Z / 100.0)); + } + for (const FVector2D& UV : MeshData.UVs) + { + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("vt %.9f %.9f\n"), UV.X, UV.Y)); + } + for (const FVector& Normal : MeshData.Normals) + { + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("vn %.9f %.9f %.9f\n"), Normal.X, Normal.Y, Normal.Z)); + } + + const int32 NumTriangles = MeshData.Triangles.Num() / 3; + const int32 FirstCeilingTriangle = FMath::Clamp( + NumTriangles - MeshData.NumCeilingTriangles, 0, NumTriangles); + if (NumTriangles > 0) + { + bOk = bOk && WriteUtf8(Archive, TEXT("usemtl VoxelGround\n")); + } + for (int32 Triangle = 0; Triangle < NumTriangles; ++Triangle) + { + if (Triangle == FirstCeilingTriangle) + { + bOk = bOk && WriteUtf8(Archive, TEXT("usemtl VoxelCeiling\n")); + } + const int32 Base = Triangle * 3; + const int32 SourceA = MeshData.Triangles[Base]; + const int32 SourceB = MeshData.Triangles[Base + 1]; + const int32 SourceC = MeshData.Triangles[Base + 2]; + if (SourceA < 0 || SourceB < 0 || SourceC < 0 + || SourceA >= MeshData.Vertices.Num() + || SourceB >= MeshData.Vertices.Num() + || SourceC >= MeshData.Vertices.Num()) + { + OutError = TEXT("Canonical mesher returned an out-of-range triangle index."); + return false; + } + const int32 A = SourceA + 1 + VertexOffset; + const int32 B = SourceB + 1 + VertexOffset; + const int32 C = SourceC + 1 + VertexOffset; + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("f %d/%d/%d %d/%d/%d %d/%d/%d\n"), + A, A, A, B, B, B, C, C, C)); + } + if (!bOk || Archive.IsError()) + { + OutError = TEXT("Could not write a canonical OBJ tile."); + return false; + } + return true; +} + +void WriteJsonVector(FExploreJsonWriter& Writer, const TCHAR* Key, const FVector& Value, float Scale) +{ + Writer.WriteObjectStart(Key); + Writer.WriteValue(TEXT("x"), static_cast(Value.X) * Scale); + Writer.WriteValue(TEXT("y"), static_cast(Value.Y) * Scale); + Writer.WriteValue(TEXT("z"), static_cast(Value.Z) * Scale); + Writer.WriteObjectEnd(); +} + +void WriteJsonIntVector(FExploreJsonWriter& Writer, const TCHAR* Key, const FIntVector& Value) +{ + Writer.WriteObjectStart(Key); + Writer.WriteValue(TEXT("x"), Value.X); + Writer.WriteValue(TEXT("y"), Value.Y); + Writer.WriteValue(TEXT("z"), Value.Z); + Writer.WriteObjectEnd(); +} + +void WriteJsonBounds( + FExploreJsonWriter& Writer, + const TCHAR* Key, + const FIntVector& Origin, + int32 Size, + float VoxelToMeters) +{ + Writer.WriteObjectStart(Key); + Writer.WriteObjectStart(TEXT("min")); + Writer.WriteValue(TEXT("x"), Origin.X); + Writer.WriteValue(TEXT("y"), Origin.Y); + Writer.WriteValue(TEXT("z"), Origin.Z); + Writer.WriteObjectEnd(); + Writer.WriteObjectStart(TEXT("max_exclusive")); + Writer.WriteValue(TEXT("x"), Origin.X + Size); + Writer.WriteValue(TEXT("y"), Origin.Y + Size); + Writer.WriteValue(TEXT("z"), Origin.Z + Size); + Writer.WriteObjectEnd(); + Writer.WriteValue(TEXT("units"), TEXT("voxels")); + Writer.WriteValue(TEXT("voxel_size_m"), static_cast(VoxelToMeters)); + Writer.WriteObjectEnd(); +} + +FString BuildManifestJson( + const FExploreArguments& Arguments, + const FExploreExportOutput& Export) +{ + FString Json; + TSharedRef Writer = + TJsonWriterFactory>::Create(&Json); + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("schema_version"), 1); + Writer->WriteValue(TEXT("format"), TEXT("OBJ")); + Writer->WriteValue(TEXT("mesh_file"), Export.MeshFileName); + Writer->WriteValue(TEXT("mesh_positions_units"), TEXT("metres")); + Writer->WriteValue(TEXT("mesh_uv_units"), TEXT("voxels")); + Writer->WriteValue(TEXT("load_notes"), TEXT( + "Load geometry.obj directly in a browser OBJ loader; do not regenerate density. " + "The manifest bounds are max-exclusive voxel bounds, OBJ positions are metres, and OBJ UVs " + "are the canonical voxel-space UVs.")); + Writer->WriteValue(TEXT("skirts"), false); + Writer->WriteValue(TEXT("vertex_count"), Export.VertexCount); + Writer->WriteValue(TEXT("triangle_count"), Export.TriangleCount); + Writer->WriteValue(TEXT("mesh_file_size_bytes"), Export.MeshFileSizeBytes); + Writer->WriteValue(TEXT("mesh_array_bytes_sum_over_tiles"), Export.MeshArrayBytes); + Writer->WriteValue(TEXT("canonical_mesher_tile_cells"), CHUNK_SIZE); + Writer->WriteValue(TEXT("canonical_mesher_tile_count"), Export.MesherTileCount); + Writer->WriteValue(TEXT("seed"), Arguments.Seed); + Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype)); + Writer->WriteValue(TEXT("slot"), Arguments.Slot); + WriteJsonIntVector(*Writer, TEXT("region_origin_voxels"), Export.RegionOrigin); + WriteJsonBounds(*Writer, TEXT("region_bounds_voxels"), Export.RegionOrigin, Export.RegionSize, 0.25f); + Writer->WriteObjectStart(TEXT("region_bounds_metres")); + Writer->WriteObjectStart(TEXT("min")); + Writer->WriteValue(TEXT("x"), static_cast(Export.RegionOrigin.X) * 0.25); + Writer->WriteValue(TEXT("y"), static_cast(Export.RegionOrigin.Y) * 0.25); + Writer->WriteValue(TEXT("z"), static_cast(Export.RegionOrigin.Z) * 0.25); + Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("max_exclusive")); + Writer->WriteValue(TEXT("x"), static_cast(Export.RegionOrigin.X + Export.RegionSize) * 0.25); + Writer->WriteValue(TEXT("y"), static_cast(Export.RegionOrigin.Y + Export.RegionSize) * 0.25); + Writer->WriteValue(TEXT("z"), static_cast(Export.RegionOrigin.Z + Export.RegionSize) * 0.25); + Writer->WriteObjectEnd(); + Writer->WriteValue(TEXT("units"), TEXT("metres")); + Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("player_dimensions")); + Writer->WriteValue(TEXT("capsule_radius_m"), static_cast(FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0); + Writer->WriteValue(TEXT("capsule_width_m"), static_cast(2.0f * FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0); + Writer->WriteValue(TEXT("capsule_half_height_m"), static_cast(FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0); + Writer->WriteValue(TEXT("capsule_height_m"), static_cast(2.0f * FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0); + Writer->WriteValue(TEXT("max_step_height_m"), static_cast(FVoxelPlayerCapsuleConstants::MaxStepHeightMeters)); + Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("world_invariants")); + Writer->WriteValue(TEXT("world_radius_voxels"), 0); + Writer->WriteValue(TEXT("lateral_regions_enabled"), false); + Writer->WriteValue(TEXT("diff_layer_mutated"), false); + Writer->WriteObjectEnd(); + Writer->WriteObjectEnd(); + return Writer->Close() ? Json : FString(); +} + +bool RunExport( + const FExploreArguments& Arguments, + FExploreWorld& World, + FExploreExportOutput& OutOutput) +{ + OutOutput = FExploreExportOutput(); + OutOutput.RegionSize = Arguments.ExportSize; + const int32 TilesPerAxis = Arguments.ExportSize / CHUNK_SIZE; + OutOutput.MesherTileCount = TilesPerAxis * TilesPerAxis * TilesPerAxis; + const int32 TargetMiddleZ = FMath::FloorToInt( + 0.5f * static_cast(World.TargetBottomWorldZ + World.TargetTopWorldZ)); + OutOutput.RegionOrigin = FIntVector( + -Arguments.ExportSize / 2, + -Arguments.ExportSize / 2, + TargetMiddleZ - Arguments.ExportSize / 2); + OutOutput.EstimatedWorkingBytes = EstimateExportWorkingBytes(Arguments.ExportSize); + if (OutOutput.EstimatedWorkingBytes > OutOutput.WorkingMemoryCapBytes) + { + OutOutput.Status = TEXT("refused"); + OutOutput.RefusalReason = FString::Printf( + TEXT("Refused before meshing: estimated working memory %lld bytes exceeds cap %lld bytes."), + static_cast(OutOutput.EstimatedWorkingBytes), + static_cast(OutOutput.WorkingMemoryCapBytes)); + return false; + } + + // Skirts are a tile-rendering seam aid and extend beyond a bounded export box. The mesh is + // still generated by the canonical mesher; disabling only that presentation option keeps the + // browser asset's geometry inside the manifest bounds. + World.Mesher->bGenerateSkirts = false; + FString Error; + OutOutput.MeshFileName = TEXT("geometry.obj"); + OutOutput.ManifestFileName = TEXT("manifest.json"); + const FString MeshPath = FPaths::Combine(Arguments.OutDirectory, OutOutput.MeshFileName); + TUniquePtr Archive(IFileManager::Get().CreateFileWriter(*MeshPath)); + if (!Archive.IsValid()) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = FString::Printf(TEXT("Could not create OBJ '%s'."), *MeshPath); + return false; + } + if (!WriteObjHeader(*Archive)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("Could not write the OBJ header."); + return false; + } + + int32 VertexOffset = 0; + for (int32 TileZ = 0; TileZ < TilesPerAxis; ++TileZ) + { + for (int32 TileY = 0; TileY < TilesPerAxis; ++TileY) + { + for (int32 TileX = 0; TileX < TilesPerAxis; ++TileX) + { + const FIntVector TileOrigin = OutOutput.RegionOrigin + + FIntVector(TileX * CHUNK_SIZE, TileY * CHUNK_SIZE, TileZ * CHUNK_SIZE); + const FVoxelMeshData TileMesh = World.Mesher->GenerateMesh( + TileOrigin, + 1, + CHUNK_SIZE); + if (!WriteObjTile(*Archive, TileMesh, VertexOffset, Error)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = Error; + return false; + } + const int64 TileArrayBytes = static_cast(TileMesh.Vertices.Num()) * sizeof(FVector) + + static_cast(TileMesh.Normals.Num()) * sizeof(FVector) + + static_cast(TileMesh.UVs.Num()) * sizeof(FVector2D) + + static_cast(TileMesh.Colors.Num()) * sizeof(FColor) + + static_cast(TileMesh.Triangles.Num()) * sizeof(int32); + OutOutput.MeshArrayBytes += TileArrayBytes; + OutOutput.VertexCount += TileMesh.Vertices.Num(); + OutOutput.TriangleCount += TileMesh.Triangles.Num() / 3; + VertexOffset += TileMesh.Vertices.Num(); + } + } + } + if (Archive->IsError()) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("Could not finish the streamed OBJ."); + return false; + } + Archive.Reset(); + OutOutput.MeshFileSizeBytes = IFileManager::Get().FileSize(*MeshPath); + if (OutOutput.MeshFileSizeBytes < 0) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("OBJ was written but its file size could not be read."); + return false; + } + + const FString ManifestJson = BuildManifestJson(Arguments, OutOutput); + const FString ManifestJsonRepeat = BuildManifestJson(Arguments, OutOutput); + if (ManifestJson.IsEmpty() || ManifestJsonRepeat.IsEmpty()) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("The export manifest JSON writer failed."); + return false; + } + if (ManifestJson != ManifestJsonRepeat) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("The export manifest failed its deterministic repeat assertion."); + return false; + } + if (!FFileHelper::SaveStringToFile( + ManifestJson, + *FPaths::Combine(Arguments.OutDirectory, OutOutput.ManifestFileName), + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("Could not write export manifest.json."); + return false; + } + OutOutput.Status = TEXT("ok"); + return true; +} + +void WritePlayerDimensions(FExploreJsonWriter& Writer, const TCHAR* Key) +{ + Writer.WriteObjectStart(Key); + Writer.WriteValue(TEXT("capsule_radius_m"), static_cast(FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0); + Writer.WriteValue(TEXT("capsule_width_m"), static_cast(2.0f * FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0); + Writer.WriteValue(TEXT("capsule_half_height_m"), static_cast(FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0); + Writer.WriteValue(TEXT("capsule_height_m"), static_cast(2.0f * FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0); + Writer.WriteValue(TEXT("max_step_height_m"), static_cast(FVoxelPlayerCapsuleConstants::MaxStepHeightMeters)); + Writer.WriteValue(TEXT("walkable_floor_angle_degrees"), static_cast(FVoxelPlayerCapsuleConstants::WalkableFloorAngleDegrees)); + Writer.WriteObjectEnd(); +} + +FString BuildExploreJson( + const FExploreArguments& Arguments, + const FExploreWorld& World, + const FExploreRunOutput& Output, + bool bDeterminismAssertion, + uint32 PayloadCrc32) +{ + FString Json; + TSharedRef Writer = + TJsonWriterFactory>::Create(&Json); + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("schema_version"), 1); + Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore")); + Writer->WriteValue(TEXT("read_only_generation"), true); + + Writer->WriteObjectStart(TEXT("input")); + Writer->WriteValue(TEXT("seed"), Arguments.Seed); + Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype)); + Writer->WriteValue(TEXT("slot"), Arguments.Slot); + Writer->WriteArrayStart(TEXT("modes")); + if (Arguments.bRender) Writer->WriteValue(TEXT("render")); + if (Arguments.bWalk) Writer->WriteValue(TEXT("walk")); + if (Arguments.bExport) Writer->WriteValue(TEXT("export")); + Writer->WriteArrayEnd(); + Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory); + Writer->WriteValue(TEXT("canonical_invocation"), FString::Printf( + TEXT("UnrealEditor-Cmd VoxelM.uproject -run=VoxelForgeExplore -seed=%d -archetype=%s " + "-slot=%d -modes=%s -out=Saved/VoxelForge/Explore/"), + Arguments.Seed, + ArchetypeName(Arguments.Archetype), + Arguments.Slot, + *Arguments.CanonicalModes())); + Writer->WriteObjectEnd(); + + Writer->WriteObjectStart(TEXT("world")); + Writer->WriteValue(TEXT("voxel_size_m"), 0.25); + Writer->WriteValue(TEXT("world_radius_voxels"), 0); + Writer->WriteValue(TEXT("lateral_regions_enabled"), false); + Writer->WriteValue(TEXT("diff_layer_mutated"), false); + Writer->WriteValue(TEXT("target_strate_slot"), Arguments.Slot); + Writer->WriteValue(TEXT("target_bottom_voxel_z"), World.TargetBottomWorldZ); + Writer->WriteValue(TEXT("target_top_voxel_z_exclusive"), World.TargetTopWorldZ); + Writer->WriteValue(TEXT("target_bottom_m"), static_cast(World.TargetBottomWorldZ) * 0.25); + Writer->WriteValue(TEXT("target_top_m_exclusive"), static_cast(World.TargetTopWorldZ) * 0.25); + Writer->WriteValue(TEXT("target_boundary_seal_thickness_voxels"), + static_cast(World.TargetBoundarySealThickness)); + WritePlayerDimensions(*Writer, TEXT("player_dimensions")); + Writer->WriteObjectEnd(); + + if (Arguments.bRender) + { + Writer->WriteObjectStart(TEXT("render")); + Writer->WriteValue(TEXT("status"), Output.Render.Status); + Writer->WriteValue(TEXT("refusal_or_error"), Output.Render.RefusalReason); + Writer->WriteValue(TEXT("width_px"), Output.Render.Width); + Writer->WriteValue(TEXT("height_px"), Output.Render.Height); + Writer->WriteValue(TEXT("max_pixels_cap"), MaxRenderPixels); + Writer->WriteValue(TEXT("max_fixed_density_samples_cap"), MaxRenderDensitySamples); + Writer->WriteValue(TEXT("estimated_fixed_density_samples"), + Output.Render.EstimatedFixedDensitySamples); + Writer->WriteValue(TEXT("fixed_step_voxels"), static_cast(Output.Render.FixedStepVoxels)); + Writer->WriteValue(TEXT("fixed_step_m"), static_cast(Output.Render.FixedStepMeters)); + Writer->WriteValue(TEXT("max_distance_voxels"), static_cast(Output.Render.MaxDistanceVoxels)); + Writer->WriteValue(TEXT("bisection_iterations"), Output.Render.BisectionIterations); + Writer->WriteValue(TEXT("human_marker_height_m"), 1.76); + Writer->WriteValue(TEXT("step_rationale"), Output.Render.StepRationale); + Writer->WriteArrayStart(TEXT("images")); + for (const FExploreRenderFrame& Frame : Output.Render.Frames) + { + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("file"), Frame.FileName); + WriteJsonVector(*Writer, TEXT("camera_voxels"), Frame.CameraVoxels, 1.0f); + WriteJsonVector(*Writer, TEXT("target_voxels"), Frame.TargetVoxels, 1.0f); + Writer->WriteValue(TEXT("hit_pixels"), Frame.HitPixels); + Writer->WriteValue(TEXT("scale_marker_projected"), Frame.bScaleMarkerProjected); + Writer->WriteValue(TEXT("scale_marker_height_m"), 1.76); + Writer->WriteObjectEnd(); + } + Writer->WriteArrayEnd(); + Writer->WriteObjectEnd(); + } + + if (Arguments.bWalk) + { + const FVoxelPlayerFitWalkReport& Report = Output.Walk.Report; + Writer->WriteObjectStart(TEXT("walk")); + Writer->WriteValue(TEXT("status"), Output.Walk.Status); + Writer->WriteValue(TEXT("refusal_or_error"), Output.Walk.RefusalReason); + Writer->WriteValue(TEXT("window_policy"), Output.Walk.WindowPolicy); + Writer->WriteValue(TEXT("agent_policy"), TEXT( + "fixed-order depth-first walk over the exact VF player-fit six-neighbour graph; stop at departure")); + Writer->WriteValue(TEXT("player_fit_volume_scope"), TEXT( + "bounded fitted route window; WorldRadiusVoxels=0 has no finite whole-world volume")); + Writer->WriteValue(TEXT("max_cells_cap"), Arguments.MaxWalkCells); + Writer->WriteValue(TEXT("working_memory_estimate_per_cell_bytes"), EstimatedWalkBytesPerCell); + Writer->WriteValue(TEXT("estimated_working_bytes_at_cell_cap"), + static_cast(Arguments.MaxWalkCells) * EstimatedWalkBytesPerCell); + Writer->WriteValue(TEXT("working_memory_cap_bytes"), MaxWalkWorkingBytes); + Writer->WriteValue(TEXT("arrival_passage_count"), Output.Walk.ArrivalPassageCount); + Writer->WriteValue(TEXT("departure_passage_count"), Output.Walk.DeparturePassageCount); + if (Output.Walk.bHasArrival) + { + WriteJsonVector(*Writer, TEXT("arrival_mouth_voxels"), Output.Walk.ArrivalVoxels, 1.0f); + WriteJsonVector(*Writer, TEXT("arrival_mouth_metres"), Output.Walk.ArrivalVoxels, 0.25f); + } + if (Output.Walk.bHasDeparture) + { + WriteJsonVector(*Writer, TEXT("departure_mouth_voxels"), Output.Walk.DepartureVoxels, 1.0f); + WriteJsonVector(*Writer, TEXT("departure_mouth_metres"), Output.Walk.DepartureVoxels, 0.25f); + } + Writer->WriteValue(TEXT("connectivity_result"), ConnectivityResultName(Report.Result)); + Writer->WriteValue(TEXT("connectivity_result_code"), static_cast(Report.Result)); + Writer->WriteValue(TEXT("can_reach_departure"), Report.bCanReachDeparture); + Writer->WriteValue(TEXT("start_snapped"), Report.bStartSnapped); + Writer->WriteValue(TEXT("goal_snapped"), Report.bGoalSnapped); + Writer->WriteValue(TEXT("route_retries"), Report.NumRouteRetries); + Writer->WriteValue(TEXT("player_fit_volume_cells"), Report.PlayerFitVolumeCells); + Writer->WriteValue(TEXT("reachable_player_fit_cells"), Report.ReachablePlayerFitCells); + Writer->WriteValue(TEXT("reachable_player_fit_fraction"), static_cast(Report.ReachablePlayerFitFraction)); + Writer->WriteValue(TEXT("sampled_num_x"), Report.SampledNumX); + Writer->WriteValue(TEXT("sampled_num_y"), Report.SampledNumY); + Writer->WriteValue(TEXT("sampled_num_z"), Report.SampledNumZ); + Writer->WriteValue(TEXT("sampled_bounds_units"), TEXT("voxels")); + Writer->WriteValue(TEXT("sampled_min_z"), Report.SampledMinZ); + Writer->WriteValue(TEXT("sampled_max_z_exclusive"), Report.SampledMaxZ); + Writer->WriteValue(TEXT("sampled_min_x"), static_cast(Report.SampledMinX)); + Writer->WriteValue(TEXT("sampled_max_x_exclusive"), static_cast(Report.SampledMaxX)); + Writer->WriteValue(TEXT("sampled_min_y"), static_cast(Report.SampledMinY)); + Writer->WriteValue(TEXT("sampled_max_y_exclusive"), static_cast(Report.SampledMaxY)); + Writer->WriteValue(TEXT("agent_graph_traversals"), Report.AgentGraphTraversals); + Writer->WriteValue(TEXT("distance_travelled_m"), static_cast(Report.DistanceTravelledMeters)); + Writer->WriteValue(TEXT("straight_line_m"), static_cast(Report.StraightLineMeters)); + Writer->WriteValue(TEXT("tortuosity"), static_cast(Report.Tortuosity)); + Writer->WriteValue(TEXT("dead_ends_encountered"), Report.DeadEndsEncountered); + Writer->WriteValue(TEXT("dead_ends_per_100m"), static_cast(Report.DeadEndsPer100m)); + Writer->WriteValue(TEXT("capsule_radius_m"), static_cast(Report.CapsuleRadiusMeters)); + Writer->WriteValue(TEXT("capsule_width_m"), static_cast(Report.CapsuleWidthMeters)); + Writer->WriteValue(TEXT("capsule_height_m"), static_cast(Report.CapsuleHeightMeters)); + Writer->WriteValue(TEXT("narrow_gap_threshold_m"), static_cast(Report.NarrowGapThresholdMeters)); + Writer->WriteValue(TEXT("narrow_gap_traversals"), Report.NarrowGapTraversals); + Writer->WriteValue(TEXT("narrow_gap_fraction"), static_cast(Report.NarrowGapFraction)); + Writer->WriteValue(TEXT("narrow_gap_events_per_100m"), static_cast(Report.NarrowGapEventsPer100m)); + Writer->WriteValue(TEXT("narrow_gap_definition"), Report.NarrowGapDefinition); + Writer->WriteObjectEnd(); + } + + if (Arguments.bExport) + { + Writer->WriteObjectStart(TEXT("export")); + Writer->WriteValue(TEXT("status"), Output.Export.Status); + Writer->WriteValue(TEXT("refusal_or_error"), Output.Export.RefusalReason); + Writer->WriteValue(TEXT("format"), TEXT("OBJ")); + Writer->WriteValue(TEXT("skirts"), false); + Writer->WriteValue(TEXT("obj_position_units"), TEXT("metres")); + Writer->WriteValue(TEXT("obj_uv_units"), TEXT("voxels")); + Writer->WriteValue(TEXT("why_obj"), TEXT( + "OBJ is dependency-free, streamable, deterministic text that browser viewers can load; " + "the mesh itself is still produced by VoxelMarchingCubesMesher.")); + Writer->WriteValue(TEXT("mesh_file"), Output.Export.MeshFileName); + Writer->WriteValue(TEXT("manifest_file"), Output.Export.ManifestFileName); + WriteJsonIntVector(*Writer, TEXT("region_origin_voxels"), Output.Export.RegionOrigin); + WriteJsonBounds(*Writer, TEXT("region_bounds_voxels"), Output.Export.RegionOrigin, Output.Export.RegionSize, 0.25f); + Writer->WriteValue(TEXT("region_size_cells_per_axis"), Output.Export.RegionSize); + Writer->WriteValue(TEXT("canonical_mesher_tile_cells"), CHUNK_SIZE); + Writer->WriteValue(TEXT("canonical_mesher_tile_count"), Output.Export.MesherTileCount); + Writer->WriteValue(TEXT("estimated_working_bytes"), Output.Export.EstimatedWorkingBytes); + Writer->WriteValue(TEXT("working_memory_cap_bytes"), Output.Export.WorkingMemoryCapBytes); + Writer->WriteValue(TEXT("mesh_array_bytes"), Output.Export.MeshArrayBytes); + Writer->WriteValue(TEXT("mesh_array_bytes_sum_over_tiles"), Output.Export.MeshArrayBytes); + Writer->WriteValue(TEXT("mesh_file_size_bytes"), Output.Export.MeshFileSizeBytes); + Writer->WriteValue(TEXT("vertex_count"), Output.Export.VertexCount); + Writer->WriteValue(TEXT("triangle_count"), Output.Export.TriangleCount); + Writer->WriteObjectEnd(); + } + + Writer->WriteObjectStart(TEXT("determinism")); + Writer->WriteValue(TEXT("same_arguments_byte_identical_contract"), true); + Writer->WriteValue(TEXT("canonical_repeat_equal"), bDeterminismAssertion); + Writer->WriteValue(TEXT("assertion"), bDeterminismAssertion ? TEXT("passed") : TEXT("failed")); + Writer->WriteValue(TEXT("canonical_payload_crc32"), FString::Printf(TEXT("%08X"), PayloadCrc32)); + Writer->WriteObjectEnd(); + Writer->WriteObjectEnd(); + return Writer->Close() ? Json : FString(); +} + +} // namespace + +UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet() +{ + IsClient = false; + IsServer = false; + IsEditor = true; + LogToConsole = true; +} + +int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) +{ + const double MainStartSeconds = FPlatformTime::Seconds(); + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] invoked: %s"), FCommandLine::Get()); + + FExploreArguments Arguments; + FString Error; + if (!ParseArguments(Params, Arguments, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] %s"), *Error); + return 1; + } + if (!IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] Could not create output directory '%s'."), + *Arguments.OutDirectory); + return 1; + } + + const double SetupStartSeconds = FPlatformTime::Seconds(); + FExploreWorld World; + if (!World.Build(Arguments, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] %s"), *Error); + return 1; + } + const double SetupSeconds = FPlatformTime::Seconds() - SetupStartSeconds; + + FExploreRunOutput Output; + bool bRequestedModeFailed = false; + if (Arguments.bRender) + { + const double Start = FPlatformTime::Seconds(); + if (!RunRender(Arguments, World, Output.Render)) + { + bRequestedModeFailed = true; + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render %.3fs (%s)"), + FPlatformTime::Seconds() - Start, *Output.Render.Status); + } + if (Arguments.bWalk) + { + const double Start = FPlatformTime::Seconds(); + if (!RunWalk(Arguments, World, Output.Walk)) + { + bRequestedModeFailed = true; + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] walk %.3fs (%s)"), + FPlatformTime::Seconds() - Start, *Output.Walk.Status); + } + if (Arguments.bExport) + { + const double Start = FPlatformTime::Seconds(); + if (!RunExport(Arguments, World, Output.Export)) + { + bRequestedModeFailed = true; + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] export %.3fs (%s)"), + FPlatformTime::Seconds() - Start, *Output.Export.Status); + } + + // Build the payload twice before adding the assertion itself. This is deliberately a string + // comparison over a fixed-order writer, not a TMap/pretty-printer whose key order could drift. + const FString Payload = BuildExploreJson(Arguments, World, Output, false, 0); + const FString PayloadRepeat = BuildExploreJson(Arguments, World, Output, false, 0); + if (Payload.IsEmpty() || PayloadRepeat.IsEmpty()) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] JSON writer failed before report creation.")); + return 1; + } + // Hash the exact UTF-8 representation that is written to disk. This keeps the reported + // digest independent of TCHAR width (and therefore meaningful when the same run is compared + // across editor platforms). + FTCHARToUTF8 PayloadUtf8(*Payload); + const uint32 PayloadCrc32 = FCrc::MemCrc32(PayloadUtf8.Get(), PayloadUtf8.Length()); + const bool bPayloadRepeatEqual = Payload == PayloadRepeat; + FString Json = BuildExploreJson( + Arguments, World, Output, bPayloadRepeatEqual, PayloadCrc32); + const FString JsonRepeat = BuildExploreJson( + Arguments, World, Output, bPayloadRepeatEqual, PayloadCrc32); + if (Json.IsEmpty() || JsonRepeat.IsEmpty()) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] JSON writer failed during report creation.")); + return 1; + } + const bool bFinalRepeatEqual = Json == JsonRepeat; + if (!bFinalRepeatEqual) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeExplore] deterministic JSON assertion failed before write.")); + bRequestedModeFailed = true; + Json = BuildExploreJson(Arguments, World, Output, false, PayloadCrc32); + } + + const FString ReportPath = FPaths::Combine(Arguments.OutDirectory, TEXT("explore.json")); + if (!FFileHelper::SaveStringToFile( + Json, + *ReportPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] Could not write '%s'."), *ReportPath); + return 1; + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeExplore] setup %.3fs, total %.3fs; report=%s; json_deterministic=%s"), + SetupSeconds, + FPlatformTime::Seconds() - MainStartSeconds, + *ReportPath, + (bPayloadRepeatEqual && bFinalRepeatEqual) ? TEXT("yes") : TEXT("no")); + return bRequestedModeFailed ? 2 : 0; +} diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.h b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.h new file mode 100644 index 0000000..c28c08d --- /dev/null +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.h @@ -0,0 +1,16 @@ +#pragma once + +#include "Commandlets/Commandlet.h" +#include "VoxelForgeExploreCommandlet.generated.h" + +/** Headless, editor-only explorer for authoritative density, traversal, and mesh output. */ +UCLASS() +class VOXELFORGEEDITOR_API UVoxelForgeExploreCommandlet : public UCommandlet +{ + GENERATED_BODY() + +public: + UVoxelForgeExploreCommandlet(); + + virtual int32 Main(const FString& Params) override; +}; diff --git a/Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs b/Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs new file mode 100644 index 0000000..c9d9321 --- /dev/null +++ b/Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs @@ -0,0 +1,29 @@ +// Editor-only commandlets and offline explorers. This module is not linked by Game/Shipping. + +using UnrealBuildTool; + +public class VoxelForgeEditor : ModuleRules +{ + public VoxelForgeEditor(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + // The commandlet's PNG shading, metre conversions, and JSON fields are part of its + // deterministic artifact contract, so keep the editor-side arithmetic on the same precise + // floating-point model as the authoritative Runtime module. + FPSemantics = FPSemanticsMode.Precise; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "Json", + "ImageWrapper", + "VoxelForge", + }); + } +} diff --git a/VoxelForge.uplugin b/VoxelForge.uplugin index a35f345..a24a5e6 100644 --- a/VoxelForge.uplugin +++ b/VoxelForge.uplugin @@ -19,6 +19,11 @@ "Name": "VoxelForge", "Type": "Runtime", "LoadingPhase": "Default" + }, + { + "Name": "VoxelForgeEditor", + "Type": "Editor", + "LoadingPhase": "Default" } ] }