diff --git a/CODEMAP.md b/CODEMAP.md index 4324c3f..9dcfa94 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -364,7 +364,9 @@ Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one stra boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded grid. When both `CoverPointA` and `CoverPointB` are set, the XY window is their AABB expanded by `CoverMarginVoxels`; otherwise it is the legacy square centered on `CenterXY` with -`RadiusInVoxels`. +`RadiusInVoxels`. Origin-rooted callers set `bIncludeOriginInCoverWindow` to expand that fitted +AABB to include `(0,0)` before the `MaxCells` check; an over-cap route window is refused rather +than cropped back to a mouth-only box. It performs one deterministic 6-connected air flood fill, and derives fractions, components, walkability, feature scale, and clearance from that grid. It then performs one bounded 4-neighbour flood fill over the projected XY columns that contain a walkable surface; this is a floor-continuity diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 08f425a..4a2e755 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -1946,3 +1946,75 @@ the world. Fix the measurement before touching generation again. - **Acceptance must be the measured property, never "the code was written."** Twice a task returned "built, and it does not meet the criterion — here is the number." Both times that honesty pointed straight at the real cause. + +--- + +## 16. Player-fit route-window audit — the origin is not the missing flip (2026-09-06) + +This pass tested the proposed sixth measurement artifact directly on the fixed Maze identity: +composer seed `0`, candidate index `50`, fourth one-based fixture slot (`SlotMaze == 3`). The +player-fit stencil remained `SampleStep=1`; every row below names its exact XY/Z window and cell +budget. `NotConnectedAtThisResolution` is a result of that stated grid, not a claim that the +infinite Maze graph is disconnected. + +### 16.1 Part A — three Maze windows + +| window | exact window / grid | MaxCells | fit cells | components | largest | arrival / departure | gap (voxels) | law | +|---|---|---:|---:|---:|---:|---:|---:|---| +| current mouth-AABB + margin | `X[-84.1,168.7) Y[39.5,53.3) Z[-1175,-937)`, `253×14×238`, `842,996` cells, step 1 | 4,000,000 | 1,352 | 50 | 237 | 128 / 1 | 254.492 | `NotConnectedAtThisResolution` | +| origin-inclusive mouth-AABB + margin | `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` cells, step 1 | 4,000,000 | 9,598 | 309 | 845 | 120 / 3 | 274.811 | `NotConnectedAtThisResolution` | +| whole-strate centered origin cap window | `X[-183,183) Y[-183,183) Z[-1175,-937)`, `366×366×238`, `31,881,528` cells, step 1 | 32,000,000 | 115,578 | 2,903 | 11,418 | 172 / 4 | 274.811 | `NotConnectedAtThisResolution` | + +The origin-inclusive box did **not** flip to `Connected`, and the larger centered window did not +either. The hypothesis is therefore **not confirmed** for this candidate. The whole-strate probe +used an explicit 32M cap so it could remain a real step-1 player-fit measurement; the production +fine-law cap remains 4M. A step-2 player-fit probe is not equivalent here: the player-fit API +intentionally refuses every `SampleStep != 1` because the capsule stencil cannot be resolved at +that resolution. + +### 16.2 Part B — route-aware window policy + +The measurement now has an explicit `bIncludeOriginInCoverWindow` flag. It expands the fitted mouth +AABB to include `(0,0)` **before** the `MaxCells` check. If that route window is over the cap, the +measurement refuses with `The requested sample grid exceeds MaxCells; measurement refused.` It does +not shrink back to a mouth-only box. + +The policy used by the eight-row law is: + +- origin-inclusive mouth-AABB: `Maze`, `VerticalShafts`, `TunnelNetwork`, and `Underwater`; +- mouth-AABB plus capsule margin: `CrystalChamber`, `FlatPlain`, `SurfaceWorld`, and + `FloatingIslands`. + +The first group is origin-rooted by its actual topology: Maze parent chains, VerticalShafts +drainage-tree chains, and the origin-flowing room graph shared by TunnelNetwork/Underwater. The +global structural spine exists for every stack, but it is not treated as the promised route for +the four local/independent families. + +### 16.3 Part C — corrected eight-row law + +These are the fixed seed-0 identities in the scale diagnosis. All valid rows are step 1. The +window column is part of the result, not an omitted implementation detail. + +| archetype | player-fit window (`X/Y/Z`, grid, cells) | fit cells | components | largest | arrival / departure | gap (voxels) | law | +|---|---|---:|---:|---:|---:|---:|---| +| CrystalChamber | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-537,-295)`, `27×68×242`, `444,312` | 992 | 102 | 165 | 40 / 6 | 50.070 | `NotConnectedAtThisResolution` | +| FlatPlain | mouth-AABB + margin; `X[-178.9,-152.8) Y[-38.2,29.1) Z[-534,-298)`, `27×68×236`, `433,296` | 2,346 | 9 | 2,337 | 1 / 2,337 | 138.105 | `NotConnectedAtThisResolution` | +| FloatingIslands | mouth-AABB + margin; `X[-143.5,-135.5) Y[121.8,129.8) Z[-537,-295)`, `8×8×242`, `15,488` | 0 | 0 | 0 | 0 / 0 | -1.000 | `StartCellNotPlayerFit` | +| Maze | origin-inclusive mouth-AABB + margin; `X[-84.1,168.7) Y[0.0,53.3) Z[-1175,-937)`, `253×54×238`, `3,251,556` | 9,598 | 309 | 845 | 120 / 3 | 274.811 | `NotConnectedAtThisResolution` | +| SurfaceWorld | mouth-AABB + margin; `X[-133.7,-76.1) Y[-68.8,47.5) Z[-1495,-1257)`, `58×117×238`, `1,615,068` | 1,720 | 248 | 801 | 1 / 3 | 101.454 | `NotConnectedAtThisResolution` | +| TunnelNetwork | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-536,-296)`, `179×68×240`, `2,921,280` | 3,684 | 213 | 1,176 | 108 / 82 | 83.361 | `NotConnectedAtThisResolution` | +| Underwater | origin-inclusive mouth-AABB + margin; `X[-178.9,0.0) Y[-38.2,29.1) Z[-535,-297)`, `179×68×238`, `2,896,936` | 1,669 | 79 | 618 | 109 / 109 | 0.000 | `CoarseLiedBudgetExhausted` (**unknown**, not a pass) | +| VerticalShafts | origin-inclusive mouth-AABB + margin; **refused before allocation** because the required grid exceeds `MaxCells=4,000,000` | — | — | — | — | — | `REFUSED(MaxCells)` | + +The valid-row tally is **0/7 Connected**, with **1/8 refused** and one `CoarseLiedBudgetExhausted` +unknown. The old one-cell/one-pocket success is not present in this corrected table. + +### 16.4 Plain verdict + +The remaining failure is not the cropped measurement window. Maze remains disconnected in player-fit +volume even when the origin is covered and when the full 32M-cell step-1 centered window is measured. +The proven spanning-tree graph and the player-fit volume are different claims: the graph has a route, +but the generated player-fitting corridor/mouth volume does not currently provide a continuous route +from these two passage mouths. The smallest next generation task is therefore at the mouths or their +player-fit join to the corridor network (with the possibility of a fine-floor/stencil break along +the route), not another window, size, density, or topology tuning pass. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp index cace7bf..7561d98 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp @@ -13,6 +13,7 @@ #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" #include "VoxelStratePreview.h" +#include "VoxelForgePlayerFitWindow.h" #include "VoxelTypes.h" namespace @@ -113,6 +114,7 @@ namespace FVoxelStrateMetrics FineMetrics; FVoxelConnectivityDiagnostics LegacyLaw; FVoxelConnectivityDiagnostics PlayerFitLaw; + FString FineWindowSummary; double SelectionScore = -1.0; FString LawText; int32 TargetStrateIndex = INDEX_NONE; @@ -247,22 +249,15 @@ namespace const FVoxelStrateMeasureSettings& BaseSettings, const FVoxelStrateFinePreviewSettings& FinePreviewSettings, const FVector& ArrivalPoint, - const FVector& DeparturePoint) + const FVector& DeparturePoint, + ECaveGeneratorType Archetype) { FVoxelStrateMeasureSettings Settings = FinePreviewSettings.MakeMeasureSettings(BaseSettings); - // The old fine preview was centred on the coarse largest-component representative. That - // is useful for a picture, but it can put both mouths outside the ROI and turn a real - // player-fit route into a meaningless OutOfWindow result. Fit the fine ROI to the two - // existing mouth XY points instead; the small explicit margin leaves room for the - // capsule's horizontal stencil at either endpoint without changing placement. - Settings.CenterXY = FVector2D( - 0.5f * (ArrivalPoint.X + DeparturePoint.X), - 0.5f * (ArrivalPoint.Y + DeparturePoint.Y)); - Settings.CoverPointA = FVector2D(ArrivalPoint.X, ArrivalPoint.Y); - Settings.CoverPointB = FVector2D(DeparturePoint.X, DeparturePoint.Y); - Settings.CoverMarginVoxels = FMath::CeilToFloat( - Settings.PlayerCapsuleRadiusVoxels) + 2.0f; + // The fine preview follows the measured route contract. Local topologies use the two + // mouths plus a capsule margin; origin-rooted topologies also cover the (0,0) spine. + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + Settings, ArrivalPoint, DeparturePoint, Archetype); return Settings; } @@ -366,7 +361,8 @@ namespace FinePreviewSettings.MaxCells = FinePreviewMaxCells; FVoxelStrateMeasureSettings FineMeasureSettings = VF_MakeFinePlayerFitSettings( - MeasureSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint); + MeasureSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint, + Candidate.Archetype); FVoxelStrateMetrics FineMetrics; const FVoxelConnectivityDiagnostics PlayerFitLaw = VF_DiagnosePlayerFitConnectivityWithSampler( @@ -374,6 +370,8 @@ namespace ArrivalPoint, DeparturePoint, FineMeasureSettings, &FineMetrics); OutSample.FineMetrics = FineMetrics; OutSample.PlayerFitLaw = PlayerFitLaw; + OutSample.FineWindowSummary = VoxelForgePlayerFitWindow::DescribeResolvedWindow( + FineMetrics, FineMeasureSettings.SampleStep, Candidate.Archetype); OutSample.LawText = FString::Printf( TEXT("legacy=%s%s; player_fit=%s%s"), VF_ShowcaseConnectivityName(Law.Result), @@ -948,7 +946,8 @@ namespace FString Report = FString::Printf( TEXT("%s (player-fit fields are fitted SampleStep=1; walkable floor area and " "median vertical clearance are coarse SampleStep=4 proxies)\n" - "archetype | player_fit_fraction_step1 | traversable_share_step1 | " + "archetype | player_fit_window | player_fit_fraction_step1 | " + "traversable_share_step1 | " "fit_cells_step1 | components_step1 | largest_component_cells_step1 | " "arrival_component_cells_step1 | departure_component_cells_step1 | " "mouth_component_gap_voxels_step1 | restricted_law_step1 | " @@ -965,6 +964,7 @@ namespace { Report += FString::Printf( TEXT("%s | no measured sample | no measured sample | no measured sample | " + "no measured sample | " "no measured sample | no measured sample | no measured sample | " "no measured sample | no measured sample | no measured sample | " "n/a | n/a | n/a\n"), @@ -979,9 +979,10 @@ namespace const float FloorAreaM2 = Found->Metrics.WalkableFloorAreaFraction * 128.0f * 128.0f; Report += FString::Printf( - TEXT("%s | %.6f | %.6f | %lld | %d | %lld | %lld | %lld | %.3f | %s | " + TEXT("%s | %s | %.6f | %.6f | %lld | %d | %lld | %lld | %lld | %.3f | %s | " "%.1f (%.6f footprint) | %.2f | %.3f\n"), VF_GetStrateArchetypeName(Archetype), + *Found->FineWindowSummary, M.bPlayerFitResolved ? M.PlayerFitFraction : 0.0f, M.bPlayerFitResolved ? M.TraversableComponentShare : 0.0f, static_cast(M.NumPlayerFitCells), @@ -1042,7 +1043,8 @@ namespace Stack.PrepareChunk(Context); FShowcaseStackSampler Sampler(Stack); const FVoxelStrateMeasureSettings FineSettings = VF_MakeFinePlayerFitSettings( - BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint); + BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint, + MazeSample.Candidate.Archetype); FVoxelStrateMetrics ZeroMetrics; const FVoxelConnectivityDiagnostics ZeroLaw = VF_DiagnosePlayerFitConnectivityWithSampler( @@ -1096,7 +1098,8 @@ namespace } const FVoxelStrateMeasureSettings FineSettings = VF_MakeFinePlayerFitSettings( - BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint); + BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint, + MazeSample.Candidate.Archetype); const float RadiusSweep[] = { 6.0f, 8.0f, 10.0f, 12.0f, 12.5f }; OutReport = TEXT( "Part A Maze corridor-radius sweep (same seed/index, fitted step-1 ROI; " @@ -1889,12 +1892,13 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) } const FString FineSummary = Sample.FineMetrics.bPlayerFitResolved ? FString::Printf( - TEXT("fit_step1=%.6f traversable_step1=%.6f fit_cells_step1=%lld " + TEXT("window=%s; fit_step1=%.6f traversable_step1=%.6f fit_cells_step1=%lld " "components_step1=%d largest_component_cells_step1=%lld " "arrival_component_cells_step1=%lld departure_component_cells_step1=%lld " "mouth_component_gap_step1=%.3f min_player_clearance_step1=%.2f " "restricted_law_step1=%s; coarse_step4_walkable=%.6f " "coarse_step4_clearance=%d voxels"), + *Sample.FineWindowSummary, Sample.FineMetrics.PlayerFitFraction, Sample.FineMetrics.TraversableComponentShare, static_cast(Sample.FineMetrics.NumPlayerFitCells), @@ -1908,8 +1912,9 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) Sample.Metrics.WalkableFraction, Sample.Metrics.MedianVerticalClearance) : FString::Printf( - TEXT("fit_step1=UNRESOLVED reason=%s; coarse_step4_walkable=%.6f " + TEXT("window=%s; fit_step1=UNRESOLVED reason=%s; coarse_step4_walkable=%.6f " "coarse_step4_clearance=%d voxels"), + *Sample.FineWindowSummary, Sample.FineMetrics.PlayerFitRefusalReason.IsEmpty() ? TEXT("not measured") : *Sample.FineMetrics.PlayerFitRefusalReason, Sample.Metrics.WalkableFraction, @@ -2114,7 +2119,8 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) World, Selected.TargetStrateIndex, FineArrivalPoint, FineDeparturePoint)); const FVoxelStrateMeasureSettings FineMeasureSettings = VF_MakeFinePlayerFitSettings( - MeasureSettings, FinePreviewSettings, FineArrivalPoint, FineDeparturePoint); + MeasureSettings, FinePreviewSettings, FineArrivalPoint, FineDeparturePoint, + Candidate.Archetype); FVoxelStrateSampleGrid FineGrid; const FVoxelStrateMetrics FineMetrics = VF_MeasureStrateWithSampler( Sampler, Selected.BottomVoxelZ, Selected.TopVoxelZ + 1, Context.EdgeSealThickness, @@ -2224,8 +2230,10 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) { const FShowcaseSample& Selected = PreviewSelections[ArchetypeIndex]; Summaries[ArchetypeIndex].Window = PreviewCandidates[ArchetypeIndex].Window; - Summaries[ArchetypeIndex].WindowSummary = VF_FormatShowcaseSummaryWindow( - Stats[ArchetypeIndex]); + Summaries[ArchetypeIndex].WindowSummary = FString::Printf( + TEXT("coarse_window=%s; player_fit_window=%s"), + *VF_FormatShowcaseSummaryWindow(Stats[ArchetypeIndex]), + *Selected.FineWindowSummary); Summaries[ArchetypeIndex].PlayerFitFractionSummary = FString::Printf( TEXT("%.6f (step=1)"), Selected.FineMetrics.PlayerFitFraction); Summaries[ArchetypeIndex].TraversableComponentShareSummary = FString::Printf( @@ -2239,7 +2247,7 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) "components_step1=%d, largest_component_cells_step1=%lld, " "arrival_component_cells_step1=%lld, departure_component_cells_step1=%lld, " "mouth_component_gap_voxels_step1=%.3f, player_clearance_step1=%.2f voxels, " - "restricted_law_step1=%s, window=%s"), + "restricted_law_step1=%s, coarse_window=%s, player_fit_window=%s"), VF_GetStrateArchetypeName(GShowcaseArchetypes[ArchetypeIndex]), Stats[ArchetypeIndex].Survivors.Num(), Selected.Candidate.Seed, Selected.Candidate.Index, @@ -2258,7 +2266,8 @@ bool FVoxelForgeComposerShowcaseTest::RunTest(const FString& Parameters) Selected.PlayerFitLaw.StartToGoalComponentDistanceCells, Selected.FineMetrics.MinimumPlayerClearanceVoxels, VF_ShowcaseConnectivityName(Selected.PlayerFitLaw.Result), - *PreviewCandidates[ArchetypeIndex].Window.Describe())); + *PreviewCandidates[ArchetypeIndex].Window.Describe(), + *Selected.FineWindowSummary)); } FString IndexPath; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePlayerFitWindow.h b/Source/VoxelForge/Private/Tests/VoxelForgePlayerFitWindow.h new file mode 100644 index 0000000..8d39e77 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgePlayerFitWindow.h @@ -0,0 +1,101 @@ +// Shared measurement-window policy for player-fit diagnostics. +// +// This is deliberately test/editor-side policy. The density generators do not know which two +// points a measurement is asking about, so the caller that knows the archetype must declare +// whether its topology can require the origin spine. + +#pragma once + +#if WITH_DEV_AUTOMATION_TESTS + +#include "CoreMinimal.h" +#include "VoxelStrateMeasure.h" +#include "VoxelStrateTypes.h" + +namespace VoxelForgePlayerFitWindow +{ + /** + * The four families below have an origin-rooted connectivity implication: + * + * - Maze: every lattice parent chain terminates at (0,0,0). + * - VerticalShafts: every drainage-tree parent chain terminates at the origin spine. + * - TunnelNetwork/Underwater: their origin-flowing room graph terminates at the origin hub. + * + * Slabs, SurfaceWorld, and FloatingIslands have local/independent interior topology for + * this law. The global structural spine is still generated for them, but it is not the + * topology that their mouth-to-mouth fit law promises to follow. + */ + inline bool NeedsOriginInWindow(ECaveGeneratorType Archetype) + { + 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; + } + } + + inline const TCHAR* RouteWindowName(ECaveGeneratorType Archetype) + { + return NeedsOriginInWindow(Archetype) + ? TEXT("origin-inclusive mouth-AABB + margin") + : TEXT("mouth-AABB + margin"); + } + + inline FString DescribeResolvedWindow( + const FVoxelStrateMetrics& Metrics, + int32 SampleStep, + ECaveGeneratorType Archetype) + { + if (!Metrics.bValid) + { + return FString::Printf( + TEXT("%s step=%d REFUSED: %s"), + RouteWindowName(Archetype), + SampleStep, + Metrics.RefusalReason.IsEmpty() ? TEXT("unknown") : *Metrics.RefusalReason); + } + return FString::Printf( + TEXT("%s step=%d X[%.1f,%.1f) Y[%.1f,%.1f) Z[%d,%d) " + "grid=%dx%dx%d cells=%lld"), + RouteWindowName(Archetype), + SampleStep, + Metrics.SampledMinX, + Metrics.SampledMaxX, + Metrics.SampledMinY, + Metrics.SampledMaxY, + Metrics.SampledMinZ, + Metrics.SampledMaxZ, + Metrics.SampledNumX, + Metrics.SampledNumY, + Metrics.SampledNumZ, + static_cast(Metrics.NumSampled)); + } + + /** Configure the exact fitted XY window used by the player-fit law. */ + inline void ConfigureMouthWindow( + FVoxelStrateMeasureSettings& InOutSettings, + const FVector& ArrivalPoint, + const FVector& DeparturePoint, + ECaveGeneratorType Archetype) + { + InOutSettings.CenterXY = FVector2D( + 0.5f * (ArrivalPoint.X + DeparturePoint.X), + 0.5f * (ArrivalPoint.Y + DeparturePoint.Y)); + InOutSettings.CoverPointA = FVector2D(ArrivalPoint.X, ArrivalPoint.Y); + InOutSettings.CoverPointB = FVector2D(DeparturePoint.X, DeparturePoint.Y); + InOutSettings.CoverMarginVoxels = FMath::CeilToFloat( + InOutSettings.PlayerCapsuleRadiusVoxels) + 2.0f; + InOutSettings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Archetype); + } +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp index 97e77f3..0326fd2 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp @@ -13,6 +13,7 @@ #include "VoxelForgeTestFixture.h" #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" +#include "VoxelForgePlayerFitWindow.h" #include "VoxelTypes.h" namespace @@ -703,17 +704,99 @@ namespace } } + int32 VF_MaxCenteredRadiusForCap( + int32 TopVoxelZ, + int32 BottomVoxelZ, + float BoundarySealThickness, + const FVoxelStrateMeasureSettings& Settings) + { + const int64 StrateHeight = static_cast(TopVoxelZ) + 1 + - static_cast(BottomVoxelZ); + const int64 MaxMargin64 = FMath::Max(1, StrateHeight / 4); + const int32 MaxMargin = static_cast(FMath::Min( + MaxMargin64, static_cast(INT32_MAX))); + int32 RequestedMargin = Settings.InteriorMarginVoxels; + if (RequestedMargin < 0) + { + RequestedMargin = FMath::CeilToInt(2.0f * BoundarySealThickness); + } + const int32 ResolvedMargin = FMath::Clamp(RequestedMargin, 0, MaxMargin); + const int64 InteriorHeight = StrateHeight - 2 * static_cast(ResolvedMargin); + if (InteriorHeight <= 0 || Settings.SampleStep <= 0 || Settings.MaxCells <= 0) + { + return 0; + } + + const int64 NumZ = (InteriorHeight + Settings.SampleStep - 1) + / static_cast(Settings.SampleStep); + const int64 MaxAxisCells = FMath::Min( + static_cast(INT32_MAX), + static_cast(FMath::Sqrt( + static_cast(Settings.MaxCells / FMath::Max(NumZ, 1))))); + int32 Radius = static_cast(FMath::Max( + 1, (MaxAxisCells * static_cast(Settings.SampleStep)) / 2)); + auto CellCountForRadius = [Settings, NumZ](int32 CandidateRadius) -> int64 + { + const int64 NumXY = (2 * static_cast(CandidateRadius) + + Settings.SampleStep - 1) + / static_cast(Settings.SampleStep); + if (NumXY <= 0 || NumXY > INT64_MAX / NumXY + || NumXY * NumXY > INT64_MAX / FMath::Max(NumZ, 1)) + { + return INT64_MAX; + } + return NumXY * NumXY * NumZ; + }; + while (Radius > 1 && CellCountForRadius(Radius) > Settings.MaxCells) + { + --Radius; + } + while (Radius < INT32_MAX + && CellCountForRadius(Radius + 1) <= Settings.MaxCells) + { + ++Radius; + } + return Radius; + } + void VF_ReportPlayerFitRow( FAutomationTestBase& Test, const TCHAR* Label, + const FString& WindowLabel, + int32 SampleStep, const FVoxelStrateMetrics& Metrics, const FVoxelConnectivityDiagnostics& Diagnostics) { + if (!Metrics.bValid || !Metrics.bPlayerFitResolved) + { + const FString& Refusal = !Metrics.RefusalReason.IsEmpty() + ? Metrics.RefusalReason : Metrics.PlayerFitRefusalReason; + Test.AddInfo(FString::Printf( + TEXT("PLAYER_FIT step=%d window=%s %s: REFUSED reason=%s"), + SampleStep, + *WindowLabel, + Label, + Refusal.IsEmpty() ? TEXT("player-fit metrics unresolved") : *Refusal)); + return; + } Test.AddInfo(FString::Printf( - TEXT("PLAYER_FIT step=1 fitted-window %s: fit_cells=%lld components=%d " + TEXT("PLAYER_FIT step=%d window=%s bounds=X[%.1f,%.1f) Y[%.1f,%.1f) " + "Z[%d,%d) grid=%dx%dx%d sampled=%lld %s: fit_cells=%lld components=%d " "largest_component_cells=%lld arrival_component_cells=%lld " "departure_component_cells=%lld mouth_component_gap_voxels=%.3f " "fit_fraction=%.9f traversable_share=%.9f law=%s"), + SampleStep, + *WindowLabel, + Metrics.SampledMinX, + Metrics.SampledMaxX, + Metrics.SampledMinY, + Metrics.SampledMaxY, + Metrics.SampledMinZ, + Metrics.SampledMaxZ, + Metrics.SampledNumX, + Metrics.SampledNumY, + Metrics.SampledNumZ, + static_cast(Metrics.NumSampled), Label, static_cast(Metrics.NumPlayerFitCells), Metrics.NumTraversableComponents, @@ -1016,7 +1099,115 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) ControlSettings.PlayerWalkableFloorAngleDegrees, ControlSettings.HeadroomCells)); - // H3 — the eight fixed showcase identities, measured at step 1 on a mouth-fitted window. + // PART A — direct route-window experiment on the requested Maze identity. The first row is + // intentionally the old mouth-only box; the second turns on the topology-aware origin + // coverage; the third uses the largest centered origin window that the same MaxCells cap can + // hold while retaining enough resolution to contain both mouths. + { + const int32 PartAMazeSlot = FTestWorld::SlotMaze; + VF_SetCandidateOnSlot(World, MazeCandidate, PartAMazeSlot); + FVector ArrivalPoint = FVector::ZeroVector; + FVector DeparturePoint = FVector::ZeroVector; + int32 ArrivalCount = 0; + int32 DepartureCount = 0; + for (const FVoxelPassage& Passage : World.StrateManager->GetPassages()) + { + if (Passage.LowerStrateIndex == PartAMazeSlot + && Passage.UpperStrateIndex + 1 == PartAMazeSlot) + { + ArrivalPoint = Passage.LowerPoint; + ++ArrivalCount; + } + if (Passage.UpperStrateIndex == PartAMazeSlot + && Passage.LowerStrateIndex == PartAMazeSlot + 1) + { + DeparturePoint = Passage.UpperPoint; + ++DepartureCount; + } + } + + TestEqual(TEXT("PART A Maze has one arrival mouth"), ArrivalCount, 1); + TestEqual(TEXT("PART A Maze has one departure mouth"), DepartureCount, 1); + int32 MazeTopVoxelZ = 0; + int32 MazeBottomVoxelZ = 0; + TestTrue(TEXT("PART A Maze has a vertical measurement range"), + World.GetSlotVoxelZRange(PartAMazeSlot, MazeTopVoxelZ, MazeBottomVoxelZ)); + if (ArrivalCount == 1 && DepartureCount == 1 + && World.GetSlotVoxelZRange(PartAMazeSlot, MazeTopVoxelZ, MazeBottomVoxelZ)) + { + FVoxelStrateMeasureSettings PartABase = CharacterFitSettings; + PartABase.SampleStep = 1; + PartABase.RadiusInVoxels = 64; + PartABase.MaxCells = FineDiagnosisMaxCells; + PartABase.MaxRouteRetries = 16; + PartABase.HeadroomCells = 2; + PartABase.InteriorMarginVoxels = -1; + + FGeneratorSampler GeneratorSampler(*World.Generator); + auto RunPartAWindow = + [&](const FString& WindowLabel, const FVoxelStrateMeasureSettings& Settings) + { + FVoxelStrateMetrics Metrics; + const FVoxelConnectivityDiagnostics Diagnostics = + VF_DiagnosePlayerFitConnectivityWithSampler( + GeneratorSampler, MazeBottomVoxelZ, MazeTopVoxelZ + 1, + VF_BoundarySealForCandidate(MazeCandidate), + ArrivalPoint, DeparturePoint, Settings, &Metrics); + VF_ReportPlayerFitRow( + *this, TEXT("Maze seed=0 index=50 slot=4"), WindowLabel, + Settings.SampleStep, Metrics, Diagnostics); + return TTuple( + MoveTemp(Metrics), Diagnostics); + }; + + FVoxelStrateMeasureSettings CurrentSettings = PartABase; + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + CurrentSettings, ArrivalPoint, DeparturePoint, ECaveGeneratorType::Maze); + CurrentSettings.bIncludeOriginInCoverWindow = false; + const auto CurrentResult = RunPartAWindow( + TEXT("current mouth-AABB + margin"), CurrentSettings); + + FVoxelStrateMeasureSettings OriginSettings = PartABase; + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + OriginSettings, ArrivalPoint, DeparturePoint, ECaveGeneratorType::Maze); + const auto OriginResult = RunPartAWindow( + TEXT("origin-inclusive mouth-AABB + margin"), OriginSettings); + + FVoxelStrateMeasureSettings WholeSettings = PartABase; + WholeSettings.MaxCells = FineDiagnosisMaxCells * 8; + WholeSettings.CenterXY = FVector2D::ZeroVector; + WholeSettings.CoverPointA.Reset(); + WholeSettings.CoverPointB.Reset(); + WholeSettings.bIncludeOriginInCoverWindow = false; + const float MouthMargin = FMath::CeilToFloat( + WholeSettings.PlayerCapsuleRadiusVoxels) + 2.0f; + const int32 MouthReach = FMath::CeilToInt(FMath::Max( + FMath::Max(FMath::Abs(ArrivalPoint.X), FMath::Abs(DeparturePoint.X)), + FMath::Max(FMath::Abs(ArrivalPoint.Y), FMath::Abs(DeparturePoint.Y))) + + MouthMargin); + WholeSettings.SampleStep = 1; + WholeSettings.RadiusInVoxels = VF_MaxCenteredRadiusForCap( + MazeTopVoxelZ, MazeBottomVoxelZ, + VF_BoundarySealForCandidate(MazeCandidate), WholeSettings); + const FString WholeWindowLabel = FString::Printf( + TEXT("whole-strate centered origin cap window (radius=%d; mouth_reach=%d; " + "MaxCells=%d)"), + WholeSettings.RadiusInVoxels, MouthReach, WholeSettings.MaxCells); + const auto WholeResult = RunPartAWindow(WholeWindowLabel, WholeSettings); + + AddInfo(FString::Printf( + TEXT("PART_A verdict: current=%s; origin-inclusive=%s; whole-strate-cap=%s; " + "origin flip=%s"), + VF_DiagnosisConnectivityName(CurrentResult.Get<1>().Result), + VF_DiagnosisConnectivityName(OriginResult.Get<1>().Result), + VF_DiagnosisConnectivityName(WholeResult.Get<1>().Result), + OriginResult.Get<1>().Result == EVoxelConnectivityResult::Connected + ? TEXT("CONFIRMED") : TEXT("NOT_CONFIRMED"))); + } + } + + // H3 — the eight fixed showcase identities, measured at step 1 on the topology-aware fitted + // window. Origin-rooted families include (0,0) before the MaxCells refusal check. // The Maze identity is intentionally the requested seed 0 / index 50 / fourth one-based slot. struct FShowcaseSelection { @@ -1065,6 +1256,7 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) int32 NumH3Connected = 0; int32 NumH3DegenerateConnected = 0; int32 NumH3Measured = 0; + int32 NumH3Refused = 0; for (const FShowcaseSelection& Selection : Selections) { @@ -1109,13 +1301,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) FVoxelStrateMeasureSettings Settings = CharacterFitSettings; Settings.SampleStep = 1; Settings.RadiusInVoxels = 64; - Settings.CenterXY = FVector2D( - 0.5f * (ArrivalPoint.X + DeparturePoint.X), - 0.5f * (ArrivalPoint.Y + DeparturePoint.Y)); - Settings.CoverPointA = FVector2D(ArrivalPoint.X, ArrivalPoint.Y); - Settings.CoverPointB = FVector2D(DeparturePoint.X, DeparturePoint.Y); - Settings.CoverMarginVoxels = FMath::CeilToFloat( - Settings.PlayerCapsuleRadiusVoxels) + 2.0f; + VoxelForgePlayerFitWindow::ConfigureMouthWindow( + Settings, ArrivalPoint, DeparturePoint, Selection.Archetype); Settings.MaxCells = FineDiagnosisMaxCells; Settings.MaxRouteRetries = 16; Settings.HeadroomCells = 2; @@ -1136,7 +1323,22 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) GeneratorSampler, BottomVoxelZ, TopVoxelZ + 1, VF_BoundarySealForCandidate(Candidate), ArrivalPoint, DeparturePoint, Settings, &Metrics); - VF_ReportPlayerFitRow(*this, Selection.Label, Metrics, Diagnostics); + VF_ReportPlayerFitRow( + *this, Selection.Label, + VoxelForgePlayerFitWindow::RouteWindowName(Selection.Archetype), + Settings.SampleStep, Metrics, Diagnostics); + if (!Metrics.bValid || !Metrics.bPlayerFitResolved) + { + ++NumH3Refused; + const FString& Refusal = !Metrics.RefusalReason.IsEmpty() + ? Metrics.RefusalReason : Metrics.PlayerFitRefusalReason; + AddInfo(FString::Printf( + TEXT("H3 %s law=REFUSED; window=%s; reason=%s"), + Selection.Label, + VoxelForgePlayerFitWindow::RouteWindowName(Selection.Archetype), + Refusal.IsEmpty() ? TEXT("player-fit metrics unresolved") : *Refusal)); + continue; + } ++NumH3Measured; if (Diagnostics.Result == EVoxelConnectivityResult::Connected) { @@ -1182,11 +1384,12 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("LAW_TALLY restricted step1: before_connected=1/8 " "before_connected_through_4_cells=1; after_connected=%d/%d " - "after_connected_through_4_cells=%d measured=%d/8"), + "after_connected_through_4_cells=%d measured=%d/8 refused=%d/8"), NumH3Connected, - UE_ARRAY_COUNT(Selections), + NumH3Measured, NumH3DegenerateConnected, - NumH3Measured)); + NumH3Measured, + NumH3Refused)); return true; } diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index d2a8309..920bbcb 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -202,6 +202,17 @@ namespace VoxelStrateMeasurePrivate MaxX64 = FMath::Max(static_cast(PointA.X), static_cast(PointB.X)) + Margin; MinY64 = FMath::Min(static_cast(PointA.Y), static_cast(PointB.Y)) - Margin; MaxY64 = FMath::Max(static_cast(PointA.Y), static_cast(PointB.Y)) + Margin; + if (Settings.bIncludeOriginInCoverWindow) + { + // The fitted box is a route-coverage contract, not merely an endpoint box. An + // origin-rooted graph may have its only mouth-to-mouth path through this spine. + // Expand the requested bounds before the MaxCells check below; never crop the + // route after discovering that the full box is too large. + MinX64 = FMath::Min(MinX64, 0.0); + MaxX64 = FMath::Max(MaxX64, 0.0); + MinY64 = FMath::Min(MinY64, 0.0); + MaxY64 = FMath::Max(MaxY64, 0.0); + } WindowCenter = FVector2D( static_cast(0.5 * (MinX64 + MaxX64)), static_cast(0.5 * (MinY64 + MaxY64))); @@ -2169,6 +2180,7 @@ namespace VoxelStrateMeasurePrivate } if (OutPlayerMetrics != nullptr) { + OutPlayerMetrics->RefusalReason = Reason; OutPlayerMetrics->PlayerFitRefusalReason = Reason; } }; @@ -2216,6 +2228,7 @@ namespace VoxelStrateMeasurePrivate } if (OutPlayerMetrics != nullptr) { + OutPlayerMetrics->RefusalReason = RefusalReason; OutPlayerMetrics->PlayerFitRefusalReason = RefusalReason; } return EVoxelConnectivityResult::OutOfWindow; diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index dd1e9d0..d1ea0bb 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -68,6 +68,11 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings TOptional CoverPointA; TOptional CoverPointB; float CoverMarginVoxels = 48.0f; + // Origin-rooted topologies can route from one mouth to the other through the (0,0) spine. + // When true, include (0,0) in the fitted AABB before applying MaxCells. A caller must keep + // this enabled for that topology; an over-cap route window is then refused honestly instead + // of being cropped back to a mouth-only box. + bool bIncludeOriginInCoverWindow = false; int32 MaxCells = 8000000; // Refuse a grid larger than this many cells. int32 MaxRouteRetries = 16; // Alternate coarse routes to full-resolution-check after the first route. int32 HeadroomCells = 2; // Legacy walkable-column metric only.