diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp index 8b09201..2d4adcc 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp @@ -4,6 +4,10 @@ #include "Misc/AutomationTest.h" +#include "Components/CapsuleComponent.h" +#include "GameFramework/Character.h" +#include "GameFramework/CharacterMovementComponent.h" + #include "VoxelCaveMorphology.h" #include "VoxelDensityOpStack.h" #include "VoxelForgeTestFixture.h" @@ -557,6 +561,126 @@ namespace const UVoxelGenerator& Generator; }; + class FVerticalWallSampler final : public IVoxelStrateDensitySampler + { + public: + float SampleDensity(float WorldX, float, float) const override + { + // A wall that spans the whole measured height has no lower supporting surface. + // It catches a stencil that treats a nearby solid column as a floor pixel. + return FMath::Abs(WorldX) < 1.0f ? -1.0f : 1.0f; + } + }; + + class FIsolatedLedgeSampler final : public IVoxelStrateDensitySampler + { + public: + float SampleDensity(float WorldX, float WorldY, float WorldZ) const override + { + // With one-voxel sampling and bounds [-8,8), this is exactly one solid floor cell + // at the (0.5,0.5,0.5) sample. It must fail the 4-of-5 support patch. + const bool bSingleCell = WorldZ < 1.0f + && FMath::Abs(WorldX) < 1.0f + && FMath::Abs(WorldY) < 1.0f; + return bSingleCell ? -1.0f : 1.0f; + } + }; + + class FOverhangSampler final : public IVoxelStrateDensitySampler + { + public: + float SampleDensity(float, float, float WorldZ) const override + { + // Air below an overhang/ceiling, with solid continuing above the measured window. + // A downward-only support search must not use that ceiling as a floor. + return WorldZ >= 4.0f ? -1.0f : 1.0f; + } + }; + + class FSteepRampSampler final : public IVoxelStrateDensitySampler + { + public: + float SampleDensity(float WorldX, float, float WorldZ) const override + { + // One voxel of rise per one voxel of run is 45 degrees. The project CDO's 44.8 + // degree limit must reject the support patch instead of averaging this into floor. + const float FloorHeight = 8.0f + WorldX; + return WorldZ < FloorHeight ? -1.0f : 1.0f; + } + }; + + bool VF_ApplyProjectCharacterFitDefaults( + FAutomationTestBase& Test, + FVoxelStrateMeasureSettings& InOutSettings) + { + constexpr const TCHAR* CharacterCDOPath = + TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter.BP_FirstPersonCharacter_C"); + UClass* CharacterClass = LoadObject(nullptr, CharacterCDOPath); + if (CharacterClass == nullptr || !CharacterClass->IsChildOf(ACharacter::StaticClass())) + { + Test.AddError(FString::Printf( + TEXT("Could not load the project character CDO at %s."), CharacterCDOPath)); + return false; + } + + const ACharacter* CharacterCDO = CharacterClass->GetDefaultObject(); + const UCapsuleComponent* Capsule = CharacterCDO != nullptr + ? CharacterCDO->GetCapsuleComponent() : nullptr; + const UCharacterMovementComponent* Movement = CharacterCDO != nullptr + ? CharacterCDO->GetCharacterMovement() : nullptr; + if (Capsule == nullptr || Movement == nullptr) + { + Test.AddError(TEXT("The project character CDO has no capsule or movement component.")); + return false; + } + + const float RadiusCentimeters = Capsule->GetUnscaledCapsuleRadius(); + const float HalfHeightCentimeters = Capsule->GetUnscaledCapsuleHalfHeight(); + const float MaxStepHeightMeters = Movement->MaxStepHeight / 100.0f; + const float WalkableFloorAngleDegrees = Movement->GetWalkableFloorAngle(); + if (!FMath::IsFinite(RadiusCentimeters) || RadiusCentimeters <= 0.0f + || !FMath::IsFinite(HalfHeightCentimeters) || HalfHeightCentimeters <= 0.0f + || !FMath::IsFinite(MaxStepHeightMeters) || MaxStepHeightMeters < 0.0f + || !FMath::IsFinite(WalkableFloorAngleDegrees) + || WalkableFloorAngleDegrees < 0.0f || WalkableFloorAngleDegrees > 90.0f) + { + Test.AddError(TEXT("The project character CDO has invalid fit parameters.")); + return false; + } + + // Keep the existing 34 cm / 88 cm metric capsule fixed for before/after comparability. + // The task-specific movement limits below are read from the project character CDO. + Test.AddInfo(FString::Printf( + TEXT("PLAYER_CHARACTER capsule CDO reports radius=%.1f cm, half_height=%.1f cm; " + "measurement dimensions remain radius=%.1f cm, half_height=%.1f cm " + "to isolate the stencil change"), + RadiusCentimeters, + HalfHeightCentimeters, + FVoxelPlayerCapsuleConstants::RadiusCentimeters, + FVoxelPlayerCapsuleConstants::HalfHeightCentimeters)); + InOutSettings.PlayerMaxStepHeightMeters = MaxStepHeightMeters; + InOutSettings.PlayerWalkableFloorAngleDegrees = WalkableFloorAngleDegrees; + return true; + } + + int32 VF_CountSupportFootprintColumns(float RadiusVoxels) + { + const int32 MaxOffset = FMath::CeilToInt(RadiusVoxels); + int32 Count = 0; + for (int32 Y = -MaxOffset; Y <= MaxOffset; ++Y) + { + for (int32 X = -MaxOffset; X <= MaxOffset; ++X) + { + if (static_cast(X * X + Y * Y) + <= RadiusVoxels * RadiusVoxels + KINDA_SMALL_NUMBER) + { + ++Count; + } + } + } + return Count; + } + float VF_BoundarySealForCandidate(const FVoxelStrateComposerCandidate& Candidate) { switch (Candidate.Archetype) @@ -639,6 +763,11 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) AddError(World.WhyInvalid()); return false; } + TestTrue(TEXT("scale diagnosis keeps WorldRadiusVoxels at zero"), + World.Settings->WorldRadiusVoxels == 0.0f + && World.Generator->WorldRadiusVoxels == 0.0f); + TestFalse(TEXT("scale diagnosis keeps lateral regions gated off"), + VF_LateralRegionsAreShippable()); const FVoxelStrateComposerCandidate MazeCandidate = VF_RollStrateCandidate( Corpus, DiagnosisSeed, MazeCandidateIndex, false); @@ -757,9 +886,42 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) ShaftCore.Negative.CrossingRadiusVoxels * 0.25f)); } + FVoxelStrateMeasureSettings CharacterFitSettings; + if (!VF_ApplyProjectCharacterFitDefaults(*this, CharacterFitSettings)) + { + return false; + } + + const float MinimumWalkableNormalZ = FMath::Cos(FMath::DegreesToRadians( + CharacterFitSettings.PlayerWalkableFloorAngleDegrees)); + const int32 SupportFootprintColumns = VF_CountSupportFootprintColumns( + CharacterFitSettings.PlayerCapsuleRadiusVoxels); + const int32 RequiredSupportColumns = FMath::Clamp( + FMath::CeilToInt(CharacterFitSettings.PlayerSupportPatchMinCoverageFraction + * static_cast(SupportFootprintColumns)), + 1, SupportFootprintColumns); + const int32 MaxDownwardSearchCells = FMath::CeilToInt( + CharacterFitSettings.PlayerMaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters); + AddInfo(FString::Printf( + TEXT("PLAYER_CHARACTER movement source=%s: MaxStepHeight=%.3f m " + "(%.3f vox; search<=%d cells), WalkableFloorAngle=%.1f deg " + "(normal_z>=%.6f); support_patch=%.1f%% requires %d/%d integer footprint columns " + "and always requires the centre"), + TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter CDO"), + CharacterFitSettings.PlayerMaxStepHeightMeters, + CharacterFitSettings.PlayerMaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters, + MaxDownwardSearchCells, + CharacterFitSettings.PlayerWalkableFloorAngleDegrees, + MinimumWalkableNormalZ, + CharacterFitSettings.PlayerSupportPatchMinCoverageFraction * 100.0f, + RequiredSupportColumns, + SupportFootprintColumns)); + // H2 — a known-open control with a solid floor. This must produce non-zero fit cells. FEmptyRoomSampler EmptyRoom; - FVoxelStrateMeasureSettings ControlSettings; + FVoxelStrateMeasureSettings ControlSettings = CharacterFitSettings; ControlSettings.SampleStep = 1; ControlSettings.RadiusInVoxels = 40; // 80 voxels = 20 m across. ControlSettings.CenterXY = FVector2D::ZeroVector; @@ -781,12 +943,65 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) ControlMetrics.NumTraversableComponents, static_cast(ControlMetrics.LargestTraversableComponentCells), ControlMetrics.TraversableComponentShare)); + + FVoxelStrateMeasureSettings CoarseControlSettings = ControlSettings; + CoarseControlSettings.SampleStep = 4; + const FVoxelStrateMetrics CoarseControlMetrics = VF_MeasureStrateWithSampler( + EmptyRoom, 0, 32, 0.0f, CoarseControlSettings, nullptr); + const bool bCoarseRefused = CoarseControlMetrics.bValid + && !CoarseControlMetrics.bPlayerFitResolved + && CoarseControlMetrics.PlayerFitRefusalReason.Contains(TEXT("SampleStep=4")); + TestTrue(TEXT("coarse player-fit control refuses unresolved capsule fit"), bCoarseRefused); + AddInfo(FString::Printf( + TEXT("CONTROL coarse_step4_player_fit: refused=%s reason=%s"), + bCoarseRefused ? TEXT("true") : TEXT("false"), + CoarseControlMetrics.PlayerFitRefusalReason.IsEmpty() + ? TEXT("none") : *CoarseControlMetrics.PlayerFitRefusalReason)); + + // A permissive stencil must fail these deliberately pathological supports. The wall has no + // downward transition, the ledge covers only 1/5 of the footprint, and the overhang has + // solid above rather than below the candidate. All controls use the same character-derived + // settings and a bounded 16x16x12-voxel window. + FVoxelStrateMeasureSettings RejectionSettings = CharacterFitSettings; + RejectionSettings.SampleStep = 1; + RejectionSettings.RadiusInVoxels = 8; + RejectionSettings.CenterXY = FVector2D::ZeroVector; + RejectionSettings.MaxCells = 100000; + RejectionSettings.InteriorMarginVoxels = 0; + auto RunStencilRejectionControl = [this, &RejectionSettings]( + const TCHAR* Label, const IVoxelStrateDensitySampler& Sampler) + { + const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler( + Sampler, 0, 12, 0.0f, RejectionSettings, nullptr); + const bool bRejected = Metrics.bValid && Metrics.bPlayerFitResolved + && Metrics.NumPlayerFitCells == 0; + TestTrue(FString::Printf(TEXT("stencil rejects %s"), Label), bRejected); + AddInfo(FString::Printf( + TEXT("CONTROL stencil_rejection %s: rejected=%s fit_cells=%lld " + "resolved=%s reason=%s"), + Label, + bRejected ? TEXT("true") : TEXT("false"), + static_cast(Metrics.NumPlayerFitCells), + Metrics.bPlayerFitResolved ? TEXT("true") : TEXT("false"), + Metrics.PlayerFitRefusalReason.IsEmpty() + ? TEXT("none") : *Metrics.PlayerFitRefusalReason)); + }; + FVerticalWallSampler VerticalWall; + FIsolatedLedgeSampler IsolatedLedge; + FOverhangSampler Overhang; + FSteepRampSampler SteepRamp; + RunStencilRejectionControl(TEXT("vertical_shaft_wall"), VerticalWall); + RunStencilRejectionControl(TEXT("isolated_1_voxel_ledge"), IsolatedLedge); + RunStencilRejectionControl(TEXT("overhang_underside"), Overhang); + RunStencilRejectionControl(TEXT("over_44_degree_ramp"), SteepRamp); + AddInfo(FString::Printf( TEXT("PLAYER_STENCIL radius=%.3f voxels (%.2f m), half_height=%.3f voxels " "(%.2f m), height=%.3f voxels (%.2f m); rows=ceil(height)=%d " - "relative rows 0..%d; floor=air anchor with exactly one solid cell at Z-1; " - "no floor tolerance; horizontal integer offsets are center plus four cardinals " - "in rows 0..6 and center-only forced in row 7; HeadroomCells=%d is legacy-only"), + "relative rows 0..%d; support=nearest scalar solid-to-air surface searched " + "down <=%.3f m/%d cells; support patch=%d/%d columns with centre required; " + "walkable slope<=%.1f deg; capsule clearance is re-tested at resolved support; " + "HeadroomCells=%d is legacy-only"), ControlSettings.PlayerCapsuleRadiusVoxels, ControlSettings.PlayerCapsuleRadiusVoxels * 0.25f, ControlSettings.PlayerCapsuleHalfHeightVoxels, @@ -795,6 +1010,11 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) FVoxelPlayerCapsuleConstants::HeightVoxels * 0.25f, FMath::CeilToInt(2.0f * ControlSettings.PlayerCapsuleHalfHeightVoxels), FMath::CeilToInt(2.0f * ControlSettings.PlayerCapsuleHalfHeightVoxels) - 1, + ControlSettings.PlayerMaxStepHeightMeters, + MaxDownwardSearchCells, + RequiredSupportColumns, + SupportFootprintColumns, + ControlSettings.PlayerWalkableFloorAngleDegrees, ControlSettings.HeadroomCells)); // H3 — the eight fixed showcase identities, measured at step 1 on a mouth-fitted window. @@ -818,13 +1038,44 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) { ECaveGeneratorType::VerticalShafts, 0, 34, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") }, }; + // Baseline rows from the pre-fix fitted-window report. The supplied VerticalShafts row is + // retained verbatim (it came from the owner's scale report); it is deliberately labelled as + // a comparison row rather than recomputed by this test. + struct FShowcaseBaseline + { + int64 FitCells; + int32 Components; + int64 LargestComponent; + int64 ArrivalComponent; + int64 DepartureComponent; + float MouthGap; + const TCHAR* Law; + }; + const FShowcaseBaseline Baselines[] = { + {655, 197, 24, 1, 1, 47.529f, TEXT("NotConnectedAtThisResolution")}, + {1116, 45, 259, 1, 1, 48.104f, TEXT("NotConnectedAtThisResolution")}, + {4, 2, 3, 1, 1, 0.000f, TEXT("Connected")}, + {2801, 309, 176, 43, 81, 206.630f, TEXT("NotConnectedAtThisResolution")}, + {713, 520, 6, 1, 1, 110.648f, TEXT("NotConnectedAtThisResolution")}, + {2748, 467, 1292, 2, 1, 164.405f, TEXT("NotConnectedAtThisResolution")}, + {337, 86, 35, 3, 11, 142.499f, TEXT("NotConnectedAtThisResolution")}, + {1156, 112, 96, 3, 36, 94.000f, TEXT("NotConnectedAtThisResolution")}, + }; + static_assert(UE_ARRAY_COUNT(Selections) == UE_ARRAY_COUNT(Baselines), + "H3 before/after rows must remain paired."); + int32 NumH3Connected = 0; + int32 NumH3DegenerateConnected = 0; + int32 NumH3Measured = 0; + for (const FShowcaseSelection& Selection : Selections) { const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( Corpus, Selection.Seed, Selection.CandidateIndex, false); TestTrue(FString::Printf(TEXT("H3 %s candidate identity is valid"), Selection.Label), - Candidate.bValid && Candidate.Archetype == Selection.Archetype); - if (!Candidate.bValid || Candidate.Archetype != Selection.Archetype) + Candidate.bValid && Candidate.Archetype == Selection.Archetype + && Candidate.Regions.IsSingleRegion()); + if (!Candidate.bValid || Candidate.Archetype != Selection.Archetype + || !Candidate.Regions.IsSingleRegion()) { continue; } @@ -856,7 +1107,7 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) continue; } - FVoxelStrateMeasureSettings Settings; + FVoxelStrateMeasureSettings Settings = CharacterFitSettings; Settings.SampleStep = 1; Settings.RadiusInVoxels = 64; Settings.CenterXY = FVector2D( @@ -887,8 +1138,57 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) VF_BoundarySealForCandidate(Candidate), ArrivalPoint, DeparturePoint, Settings, &Metrics); VF_ReportPlayerFitRow(*this, Selection.Label, Metrics, Diagnostics); + ++NumH3Measured; + if (Diagnostics.Result == EVoxelConnectivityResult::Connected) + { + ++NumH3Connected; + if (Metrics.NumPlayerFitCells <= 4) + { + ++NumH3DegenerateConnected; + } + } + const FShowcaseBaseline& Before = Baselines[&Selection - Selections]; + FString DeltaReport = TEXT("H3_DELTA step1 "); + DeltaReport += Selection.Label; + DeltaReport += FString::Printf( + TEXT(": before=%lld/%d/%lld/%lld/%lld/%.3f/"), + static_cast(Before.FitCells), Before.Components, + static_cast(Before.LargestComponent), + static_cast(Before.ArrivalComponent), + static_cast(Before.DepartureComponent), Before.MouthGap); + DeltaReport += Before.Law; + DeltaReport += FString::Printf( + TEXT("; after=%lld/%d/%lld/%lld/%lld/%.3f/"), + static_cast(Metrics.NumPlayerFitCells), Metrics.NumTraversableComponents, + static_cast(Metrics.LargestTraversableComponentCells), + static_cast(Diagnostics.StartComponentCells), + static_cast(Diagnostics.GoalComponentCells), + Diagnostics.StartToGoalComponentDistanceCells); + DeltaReport += VF_DiagnosisConnectivityName(Diagnostics.Result); + DeltaReport += FString::Printf( + TEXT("; delta_fit=%lld delta_components=%d delta_largest=%lld " + "delta_arrival=%lld delta_departure=%lld delta_gap=%.3f"), + static_cast(Metrics.NumPlayerFitCells - Before.FitCells), + Metrics.NumTraversableComponents - Before.Components, + static_cast(Metrics.LargestTraversableComponentCells - Before.LargestComponent), + static_cast(Diagnostics.StartComponentCells - Before.ArrivalComponent), + static_cast(Diagnostics.GoalComponentCells - Before.DepartureComponent), + Diagnostics.StartToGoalComponentDistanceCells - Before.MouthGap); + AddInfo(DeltaReport); } + // The old tally's one Connected row was the four-cell FloatingIslands pocket. Keep that + // degenerate fact visible beside the new tally so a tiny connected island is not mistaken for + // an instrument-wide success. + 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"), + NumH3Connected, + UE_ARRAY_COUNT(Selections), + NumH3DegenerateConnected, + NumH3Measured)); + return true; } diff --git a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp index 94b9b0b..f029b29 100644 --- a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp +++ b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp @@ -1957,7 +1957,15 @@ namespace VoxelSeasonCompositionPrivate && FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels) && Settings.PlayerCapsuleRadiusVoxels > 0.0f && FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels) - && Settings.PlayerCapsuleHalfHeightVoxels > 0.0f; + && Settings.PlayerCapsuleHalfHeightVoxels > 0.0f + && FMath::IsFinite(Settings.PlayerMaxStepHeightMeters) + && Settings.PlayerMaxStepHeightMeters >= 0.0f + && FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees) + && Settings.PlayerWalkableFloorAngleDegrees >= 0.0f + && Settings.PlayerWalkableFloorAngleDegrees <= 90.0f + && FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction) + && Settings.PlayerSupportPatchMinCoverageFraction > 0.0f + && Settings.PlayerSupportPatchMinCoverageFraction <= 1.0f; } bool IsBossSlot(int32 DepthIndex, int32 TotalStrates, int32 Interval) diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 38108cc..d2a8309 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -34,7 +34,8 @@ namespace VoxelStrateMeasurePrivate // 1 = air, 0 = solid. The polarity is deliberately explicit at the sampling site below. TArray Air; - // Optional exact scalar samples retained only for an explicit preview capture. + // Exact scalar samples retained when the player-fit stencil needs a bounded local + // surface/clearance interpolation, or when an explicit preview capture requests them. TArray Density; FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const @@ -587,6 +588,302 @@ namespace VoxelStrateMeasurePrivate TArray HorizontalOffsets; }; + bool HasScalarDensity(const FSampleGrid& Grid) + { + return Grid.Density.Num() == Grid.CellCount; + } + + float CellDensity(const FSampleGrid& Grid, int32 Cell) + { + return HasScalarDensity(Grid) + ? Grid.Density[Cell] + : (Grid.Air[Cell] != 0u ? 1.0f : -1.0f); + } + + bool SampleGridDensity( + const FSampleGrid& Grid, + const FVector& Point, + float& OutDensity) + { + if (!Grid.ContainsPoint(Point)) + { + return false; + } + + const float Step = static_cast(Grid.SampleStep); + const float LocalX = (Point.X - Grid.MinX) / Step - 0.5f; + const float LocalY = (Point.Y - Grid.MinY) / Step - 0.5f; + const float LocalZ = (Point.Z - Grid.MinZ) / Step - 0.5f; + + if (!HasScalarDensity(Grid)) + { + const int32 X = FMath::Clamp( + FMath::FloorToInt((Point.X - Grid.MinX) / Step), 0, Grid.NumX - 1); + const int32 Y = FMath::Clamp( + FMath::FloorToInt((Point.Y - Grid.MinY) / Step), 0, Grid.NumY - 1); + const int32 Z = FMath::Clamp( + FMath::FloorToInt((Point.Z - Grid.MinZ) / Step), 0, Grid.NumZ - 1); + OutDensity = CellDensity(Grid, Grid.Index(X, Y, Z)); + return FMath::IsFinite(OutDensity); + } + + const int32 X0 = FMath::Clamp(FMath::FloorToInt(LocalX), 0, Grid.NumX - 1); + const int32 Y0 = FMath::Clamp(FMath::FloorToInt(LocalY), 0, Grid.NumY - 1); + const int32 Z0 = FMath::Clamp(FMath::FloorToInt(LocalZ), 0, Grid.NumZ - 1); + const int32 X1 = FMath::Min(X0 + 1, Grid.NumX - 1); + const int32 Y1 = FMath::Min(Y0 + 1, Grid.NumY - 1); + const int32 Z1 = FMath::Min(Z0 + 1, Grid.NumZ - 1); + const float AlphaX = FMath::Clamp(LocalX - static_cast(X0), 0.0f, 1.0f); + const float AlphaY = FMath::Clamp(LocalY - static_cast(Y0), 0.0f, 1.0f); + const float AlphaZ = FMath::Clamp(LocalZ - static_cast(Z0), 0.0f, 1.0f); + + const float D000 = CellDensity(Grid, Grid.Index(X0, Y0, Z0)); + const float D100 = CellDensity(Grid, Grid.Index(X1, Y0, Z0)); + const float D010 = CellDensity(Grid, Grid.Index(X0, Y1, Z0)); + const float D110 = CellDensity(Grid, Grid.Index(X1, Y1, Z0)); + const float D001 = CellDensity(Grid, Grid.Index(X0, Y0, Z1)); + const float D101 = CellDensity(Grid, Grid.Index(X1, Y0, Z1)); + const float D011 = CellDensity(Grid, Grid.Index(X0, Y1, Z1)); + const float D111 = CellDensity(Grid, Grid.Index(X1, Y1, Z1)); + const float D00 = FMath::Lerp(D000, D100, AlphaX); + const float D10 = FMath::Lerp(D010, D110, AlphaX); + const float D01 = FMath::Lerp(D001, D101, AlphaX); + const float D11 = FMath::Lerp(D011, D111, AlphaX); + const float D0 = FMath::Lerp(D00, D10, AlphaY); + const float D1 = FMath::Lerp(D01, D11, AlphaY); + OutDensity = FMath::Lerp(D0, D1, AlphaZ); + return FMath::IsFinite(OutDensity); + } + + bool FindSupportSurfaceBelow( + const FSampleGrid& Grid, + int32 X, + int32 Y, + int32 CandidateAnchorZ, + int32 MaxDownwardSearchCells, + float MaxStepHeightVoxels, + float& OutSurfaceHeight, + int32& OutSurfaceAnchorZ) + { + const float Step = static_cast(Grid.SampleStep); + const float CandidateBottom = Grid.MinZ + + static_cast(CandidateAnchorZ) * Step; + const float LowestAllowedSurface = CandidateBottom - MaxStepHeightVoxels; + const int32 LowestAnchorZ = FMath::Max( + 1, CandidateAnchorZ - MaxDownwardSearchCells); + + // Search from the free pose downwards. The first transition in a column is the nearest + // supporting surface; lower transitions are relevant only after the candidate has been + // lifted by the bounded step-height tolerance. + for (int32 SurfaceAnchorZ = CandidateAnchorZ; + SurfaceAnchorZ >= LowestAnchorZ; + --SurfaceAnchorZ) + { + const int32 SolidZ = SurfaceAnchorZ - 1; + if (SolidZ < 0 || SurfaceAnchorZ >= Grid.NumZ) + { + continue; + } + const int32 SolidCell = Grid.Index(X, Y, SolidZ); + const int32 AirCell = Grid.Index(X, Y, SurfaceAnchorZ); + if (Grid.Air[SolidCell] != 0u || Grid.Air[AirCell] == 0u) + { + continue; + } + + float SurfaceHeight = Grid.MinZ + static_cast(SurfaceAnchorZ) * Step; + if (HasScalarDensity(Grid)) + { + const float LowerDensity = CellDensity(Grid, SolidCell); + const float UpperDensity = CellDensity(Grid, AirCell); + const float Denominator = UpperDensity - LowerDensity; + if (!FMath::IsFinite(LowerDensity) || !FMath::IsFinite(UpperDensity) + || !FMath::IsFinite(Denominator) || Denominator <= 0.0f) + { + continue; + } + const float Fraction = FMath::Clamp( + -LowerDensity / Denominator, 0.0f, 1.0f); + SurfaceHeight = Grid.CellCenter(X, Y, SolidZ).Z + Fraction * Step; + } + + if (FMath::IsFinite(SurfaceHeight) + && SurfaceHeight <= CandidateBottom + KINDA_SMALL_NUMBER + && SurfaceHeight >= LowestAllowedSurface - KINDA_SMALL_NUMBER) + { + OutSurfaceHeight = SurfaceHeight; + OutSurfaceAnchorZ = SurfaceAnchorZ; + return true; + } + } + return false; + } + + bool BuildPlayerSupportPatchOffsets( + const FVoxelStrateMeasureSettings& Settings, + TArray& OutOffsets, + int32& OutCentreOffsetIndex, + FString& OutReason) + { + OutOffsets.Reset(); + OutCentreOffsetIndex = INDEX_NONE; + const double Radius = static_cast(Settings.PlayerCapsuleRadiusVoxels); + if (!FMath::IsFinite(Radius) || Radius <= 0.0) + { + return Refuse(OutReason, + TEXT("Player capsule radius must be finite and greater than zero.")); + } + + const double MaxOffsetReal = FMath::CeilToDouble(Radius); + constexpr double MaxSupportPatchOffsets = static_cast(1ll << 20); + if (!FMath::IsFinite(MaxOffsetReal) || MaxOffsetReal > 4096.0) + { + return Refuse(OutReason, + TEXT("The player support patch radius exceeds the bounded fit stencil.")); + } + const int32 MaxOffset = static_cast(MaxOffsetReal); + int64 NumOffsets = 0; + for (int32 OffsetY = -MaxOffset; OffsetY <= MaxOffset; ++OffsetY) + { + for (int32 OffsetX = -MaxOffset; OffsetX <= MaxOffset; ++OffsetX) + { + if (static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + > Radius * Radius + KINDA_SMALL_NUMBER) + { + continue; + } + if (NumOffsets >= static_cast(MaxSupportPatchOffsets)) + { + return Refuse( + OutReason, + TEXT("The player support patch exceeds its bounded work limit.")); + } + if (OffsetX == 0 && OffsetY == 0) + { + OutCentreOffsetIndex = OutOffsets.Num(); + } + OutOffsets.Add(FIntPoint(OffsetX, OffsetY)); + ++NumOffsets; + } + } + if (OutCentreOffsetIndex == INDEX_NONE || OutOffsets.IsEmpty()) + { + return Refuse(OutReason, TEXT("The player support patch has no footprint samples.")); + } + return true; + } + + bool IsWalkableSupportPatch( + const TArray& SupportOffsets, + const TArray& SupportHeights, + const TArray& bSupported, + int32 CentreOffsetIndex, + int32 RequiredSupportCount, + float MinimumWalkableNormalZ, + int32& OutSupportCount, + float& OutSupportHeight, + int32& OutSupportAnchorZ, + const TArray& SupportAnchorZs) + { + OutSupportCount = 0; + OutSupportHeight = -FLT_MAX; + OutSupportAnchorZ = INDEX_NONE; + if (!bSupported.IsValidIndex(CentreOffsetIndex) + || bSupported[CentreOffsetIndex] == 0u) + { + return false; + } + + for (int32 Index = 0; Index < SupportOffsets.Num(); ++Index) + { + if (bSupported[Index] == 0u) + { + continue; + } + ++OutSupportCount; + if (SupportHeights[Index] > OutSupportHeight + || (SupportHeights[Index] == OutSupportHeight + && (OutSupportAnchorZ == INDEX_NONE + || SupportAnchorZs[Index] > OutSupportAnchorZ))) + { + OutSupportHeight = SupportHeights[Index]; + OutSupportAnchorZ = SupportAnchorZs[Index]; + } + } + if (OutSupportCount < RequiredSupportCount || OutSupportAnchorZ == INDEX_NONE) + { + return false; + } + + // The worst pairwise secant is intentionally conservative: an isolated height jump in + // the footprint cannot hide behind an average plane normal. For the default 1.36-voxel + // capsule this checks the centre and the four cardinal support samples. + float MaximumGradient = 0.0f; + for (int32 First = 0; First < SupportOffsets.Num(); ++First) + { + if (bSupported[First] == 0u) + { + continue; + } + for (int32 Second = First + 1; Second < SupportOffsets.Num(); ++Second) + { + if (bSupported[Second] == 0u) + { + continue; + } + const float DX = static_cast( + SupportOffsets[Second].X - SupportOffsets[First].X); + const float DY = static_cast( + SupportOffsets[Second].Y - SupportOffsets[First].Y); + const float HorizontalDistance = FMath::Sqrt(DX * DX + DY * DY); + if (HorizontalDistance <= KINDA_SMALL_NUMBER) + { + continue; + } + MaximumGradient = FMath::Max( + MaximumGradient, + FMath::Abs(SupportHeights[Second] - SupportHeights[First]) + / HorizontalDistance); + } + } + const float SupportNormalZ = 1.0f / FMath::Sqrt( + 1.0f + MaximumGradient * MaximumGradient); + return FMath::IsFinite(SupportNormalZ) + && SupportNormalZ + KINDA_SMALL_NUMBER >= MinimumWalkableNormalZ; + } + + bool CapsuleClearAtSupportHeight( + const FSampleGrid& Grid, + const TArray& StencilRows, + int32 X, + int32 Y, + float SupportHeight) + { + const float Step = static_cast(Grid.SampleStep); + const float BaseX = Grid.MinX + (static_cast(X) + 0.5f) * Step; + const float BaseY = Grid.MinY + (static_cast(Y) + 0.5f) * Step; + for (const FPlayerCapsuleStencilRow& Row : StencilRows) + { + const float SampleZ = SupportHeight + static_cast(Row.RelativeZ) + 0.5f * Step; + for (const FIntPoint& Offset : Row.HorizontalOffsets) + { + float Density = 0.0f; + if (!SampleGridDensity( + Grid, + FVector( + BaseX + static_cast(Offset.X) * Step, + BaseY + static_cast(Offset.Y) * Step, + SampleZ), + Density) + || !(Density > 0.0f)) + { + return false; + } + } + } + return true; + } + bool IsPointInsideCapsule( float RelativeZ, int32 OffsetX, @@ -594,9 +891,9 @@ namespace VoxelStrateMeasurePrivate float Radius, float HalfHeight) { - // The capsule is upright and its centre is HalfHeight above the top face of the solid - // floor cell. This is a direct capsule test in the fine voxel lattice; it deliberately - // does not infer occupancy from Manhattan distance. + // The capsule is upright and this row is evaluated relative to the resolved support + // surface. This is a direct capsule test in the fine voxel lattice; it deliberately does + // not infer occupancy from Manhattan distance. const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius); const float DistanceToAxis = FMath::Max( FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f); @@ -682,10 +979,9 @@ namespace VoxelStrateMeasurePrivate } } - // The full-height floor rule still requires one clear voxel in rows whose voxel - // centre lies outside the rounded cap (normally the top partial voxel of a 7.04 - // voxel player). This is what makes 176 cm a full-height requirement rather than a - // 7-centre-point approximation. + // Keep a centre sample for every row. This matters for the top partial row of a + // capsule whose height is not an integer number of voxels: the full-height clearance + // requirement must not silently become a seven-centre-point approximation. if (Row.HorizontalOffsets.IsEmpty()) { Row.HorizontalOffsets.Add(FIntPoint::ZeroValue); @@ -712,6 +1008,62 @@ namespace VoxelStrateMeasurePrivate Grid.SampleStep)); } + if (!FMath::IsFinite(Settings.PlayerMaxStepHeightMeters) + || Settings.PlayerMaxStepHeightMeters < 0.0f) + { + return Refuse( + OutReason, + TEXT("PlayerMaxStepHeightMeters must be finite and non-negative.")); + } + if (!FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees) + || Settings.PlayerWalkableFloorAngleDegrees < 0.0f + || Settings.PlayerWalkableFloorAngleDegrees > 90.0f) + { + return Refuse( + OutReason, + TEXT("PlayerWalkableFloorAngleDegrees must be finite and within [0,90].")); + } + if (!FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction) + || Settings.PlayerSupportPatchMinCoverageFraction <= 0.0f + || Settings.PlayerSupportPatchMinCoverageFraction > 1.0f) + { + return Refuse( + OutReason, + TEXT("PlayerSupportPatchMinCoverageFraction must be within (0,1].")); + } + + const double MaxStepHeightVoxelsReal = + static_cast(Settings.PlayerMaxStepHeightMeters) + / static_cast(FVoxelPlayerCapsuleConstants::VoxelSizeMeters); + constexpr double MaxSupportSearchHeightVoxels = 4096.0; + if (!FMath::IsFinite(MaxStepHeightVoxelsReal) + || MaxStepHeightVoxelsReal < 0.0 + || MaxStepHeightVoxelsReal > MaxSupportSearchHeightVoxels) + { + return Refuse( + OutReason, + TEXT("PlayerMaxStepHeightMeters exceeds the bounded support search.")); + } + const int32 MaxDownwardSearchCells = static_cast( + FMath::CeilToDouble(MaxStepHeightVoxelsReal)); + const float MaxStepHeightVoxels = static_cast(MaxStepHeightVoxelsReal); + const float MinimumWalkableNormalZ = FMath::Cos(FMath::DegreesToRadians( + Settings.PlayerWalkableFloorAngleDegrees)); + + TArray SupportOffsets; + int32 CentreOffsetIndex = INDEX_NONE; + if (!BuildPlayerSupportPatchOffsets( + Settings, SupportOffsets, CentreOffsetIndex, OutReason)) + { + return false; + } + const int32 RequiredSupportCount = FMath::Clamp( + FMath::CeilToInt( + Settings.PlayerSupportPatchMinCoverageFraction + * static_cast(SupportOffsets.Num())), + 1, + SupportOffsets.Num()); + TArray StencilRows; if (!BuildPlayerCapsuleStencil(Settings, StencilRows, OutReason)) { @@ -719,6 +1071,12 @@ namespace VoxelStrateMeasurePrivate } OutPlayerFit.Init(0u, Grid.CellCount); + TArray SupportHeights; + SupportHeights.SetNumUninitialized(SupportOffsets.Num()); + TArray SupportAnchorZs; + SupportAnchorZs.SetNumUninitialized(SupportOffsets.Num()); + TArray bSupported; + bSupported.Init(0u, SupportOffsets.Num()); for (int32 Z = 1; Z < Grid.NumZ; ++Z) { for (int32 Y = 0; Y < Grid.NumY; ++Y) @@ -726,44 +1084,95 @@ namespace VoxelStrateMeasurePrivate for (int32 X = 0; X < Grid.NumX; ++X) { const int32 Current = Grid.Index(X, Y, Z); - if (Grid.Air[Current] == 0u - || Grid.Air[Grid.Index(X, Y, Z - 1)] != 0u) + // This is the free-pose candidate. Its support is resolved independently + // below, so an air cell one or two voxels above a surface can be lowered to + // that surface when the character's named step height permits it. + if (Grid.Air[Current] == 0u) { continue; } - bool bFits = true; - for (const FPlayerCapsuleStencilRow& Row : StencilRows) + int32 NumSupported = 0; + for (int32 SupportIndex = 0; + SupportIndex < SupportOffsets.Num(); + ++SupportIndex) { - const int32 TargetZ = Z + Row.RelativeZ; - if (TargetZ < 0 || TargetZ >= Grid.NumZ) + const FIntPoint& Offset = SupportOffsets[SupportIndex]; + const int32 SupportX = X + Offset.X; + const int32 SupportY = Y + Offset.Y; + bSupported[SupportIndex] = 0u; + if (SupportX < 0 || SupportX >= Grid.NumX + || SupportY < 0 || SupportY >= Grid.NumY) { - bFits = false; - break; + continue; } - for (const FIntPoint& Offset : Row.HorizontalOffsets) + + float SurfaceHeight = 0.0f; + int32 SurfaceAnchorZ = INDEX_NONE; + if (FindSupportSurfaceBelow( + Grid, + SupportX, + SupportY, + Z, + MaxDownwardSearchCells, + MaxStepHeightVoxels, + SurfaceHeight, + SurfaceAnchorZ)) { - const int32 TargetX = X + Offset.X; - const int32 TargetY = Y + Offset.Y; - if (TargetX < 0 || TargetX >= Grid.NumX - || TargetY < 0 || TargetY >= Grid.NumY - || Grid.Air[Grid.Index(TargetX, TargetY, TargetZ)] == 0u) - { - bFits = false; - break; - } - } - if (!bFits) - { - break; + bSupported[SupportIndex] = 1u; + SupportHeights[SupportIndex] = SurfaceHeight; + SupportAnchorZs[SupportIndex] = SurfaceAnchorZ; + ++NumSupported; } } - if (bFits) + if (NumSupported < RequiredSupportCount + || bSupported[CentreOffsetIndex] == 0u) { - OutPlayerFit[Current] = 1u; - ++OutNumPlayerFitCells; + continue; } + + int32 SupportCount = 0; + float SupportHeight = 0.0f; + int32 SupportAnchorZ = INDEX_NONE; + if (!IsWalkableSupportPatch( + SupportOffsets, + SupportHeights, + bSupported, + CentreOffsetIndex, + RequiredSupportCount, + MinimumWalkableNormalZ, + SupportCount, + SupportHeight, + SupportAnchorZ, + SupportAnchorZs)) + { + continue; + } + + // Resolve the pose to the highest coherent support in the patch and run the + // full capsule stencil again there. The support search alone is not allowed + // to turn a ledge or an overhang into a fit result. Keep the original free + // pose cell in the mask: every accepted pose is a valid point in the + // existing 6-neighbour traversal graph, and retaining the bounded vertical + // band is what lets adjacent gently sloped surfaces overlap instead of + // becoming diagonal, disconnected floor anchors. + if (!CapsuleClearAtSupportHeight( + Grid, StencilRows, X, Y, SupportHeight)) + { + continue; + } + + if (SupportAnchorZ < 1 || SupportAnchorZ >= Grid.NumZ) + { + continue; + } + if (OutPlayerFit[Current] != 0u) + { + continue; + } + OutPlayerFit[Current] = 1u; + ++OutNumPlayerFitCells; } } } @@ -1341,8 +1750,8 @@ namespace VoxelStrateMeasurePrivate if (EligibilityMask != nullptr) { - // A player-fit route is deliberately a graph over floor anchors, not a second air - // route with a coarse-cell illusion. The mask was built at SampleStep=1 and every + // A player-fit route is deliberately a graph over supported free poses, not a second + // air route with a coarse-cell illusion. The mask was built at SampleStep=1 and every // edge below is between adjacent player-fitting cells. for (int32 PathIndex = 0; PathIndex < Path.Num(); ++PathIndex) { @@ -1797,7 +2206,7 @@ namespace VoxelStrateMeasurePrivate if (!BuildSampleGrid(Generator, Manager, Sampler, StrateIndex, ExplicitBottomWorldZ, ExplicitTopWorldZ, ExplicitBoundarySealThickness, bUseExplicitBounds, - Settings, false, Grid, RefusalReason)) + Settings, bPlayerFitRestricted, Grid, RefusalReason)) { if (OutDiagnostics != nullptr) { @@ -1909,7 +2318,8 @@ FVoxelStrateMetrics VF_MeasureStrate( VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, - Settings, OutSampleGrid != nullptr, Grid, Result.RefusalReason)) + Settings, OutSampleGrid != nullptr || Settings.SampleStep == 1, + Grid, Result.RefusalReason)) { return Result; } @@ -1953,7 +2363,8 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler( VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, - BoundarySealThickness, true, Settings, OutSampleGrid != nullptr, + BoundarySealThickness, true, Settings, + OutSampleGrid != nullptr || Settings.SampleStep == 1, Grid, Result.RefusalReason)) { return Result; diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 39986ff..dd1e9d0 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -8,18 +8,27 @@ class UVoxelGenerator; class UVoxelStrateManager; /** - * The confirmed base ACharacter capsule in voxel-space defaults. + * The pinned metric capsule dimensions in voxel-space. * * 25 cm/voxel is the authored world scale: radius = 34 cm / 25 = 1.36 voxels and * half-height = 88 cm / 25 = 3.52 voxels, hence a 176 cm / 25 = 7.04 voxel total height. * The measure settings copy these values so a future character can change the query without - * changing generation. + * changing generation. The diagnosis test separately reads movement limits from the project's + * character CDO. */ struct VOXELFORGE_API FVoxelPlayerCapsuleConstants { static constexpr float VoxelSizeCentimeters = 25.0f; + static constexpr float VoxelSizeMeters = VoxelSizeCentimeters / 100.0f; static constexpr float RadiusCentimeters = 34.0f; static constexpr float HalfHeightCentimeters = 88.0f; + // UCharacterMovementComponent's UE 5.7 default. Callers that have a character CDO should + // copy its authored value into PlayerMaxStepHeightMeters. + static constexpr float MaxStepHeightCentimeters = 45.0f; + static constexpr float MaxStepHeightMeters = MaxStepHeightCentimeters / 100.0f; + // UE 5.7's WalkableFloorZ=0.71 resolves to about 44.8 degrees; a character CDO may expose + // the precise angle. The diagnosis test copies that precise project value when available. + static constexpr float WalkableFloorAngleDegrees = 44.8f; static constexpr float RadiusVoxels = RadiusCentimeters / VoxelSizeCentimeters; static constexpr float HalfHeightVoxels = HalfHeightCentimeters / VoxelSizeCentimeters; static constexpr float HeightVoxels = 2.0f * HalfHeightVoxels; @@ -61,7 +70,7 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings float CoverMarginVoxels = 48.0f; 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; // Air cells above a floor cell required for walkable. + int32 HeadroomCells = 2; // Legacy walkable-column metric only. int32 InteriorMarginVoxels = -1; // <0 = derive from BoundarySealThickness (2x, clamped); explicit 0 includes the seal. // Base ACharacter defaults at 25 cm/voxel: 34 cm radius / 25 = 1.36 voxels and @@ -69,6 +78,15 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings // generation values, so another playable capsule can be measured without a code change. float PlayerCapsuleRadiusVoxels = FVoxelPlayerCapsuleConstants::RadiusVoxels; float PlayerCapsuleHalfHeightVoxels = FVoxelPlayerCapsuleConstants::HalfHeightVoxels; + + // Character-derived fit parameters. MaxStepHeight is deliberately named and stored in + // metres, matching UCharacterMovementComponent's authored unit. The measurement converts it + // to voxels only for the bounded downward support search. + float PlayerMaxStepHeightMeters = FVoxelPlayerCapsuleConstants::MaxStepHeightMeters; + float PlayerWalkableFloorAngleDegrees = FVoxelPlayerCapsuleConstants::WalkableFloorAngleDegrees; + // A support patch is sampled at the integer columns covered by the capsule radius. 0.75 with + // the default 1.36-voxel radius requires 4 of 5 columns, including the centre column. + float PlayerSupportPatchMinCoverageFraction = 0.75f; }; /** @@ -163,16 +181,16 @@ struct VOXELFORGE_API FVoxelStrateMetrics int32 MedianVerticalClearance = 0; // Player-fit metrics are answered only by a one-voxel grid. A coarse grid refuses to infer - // clearance because SampleStep=4 cannot resolve a 1.36-voxel radius capsule. + // support or clearance because SampleStep=4 cannot resolve a 1.36-voxel radius capsule. bool bPlayerFitResolved = false; FString PlayerFitRefusalReason; int64 NumPlayerFitCells = 0; - float PlayerFitFraction = 0.0f; // Player-fitting floor anchors / all air cells. + float PlayerFitFraction = 0.0f; // Accepted free supported poses / all air cells. int32 NumTraversableComponents = 0; int64 LargestTraversableComponentCells = 0; float TraversableComponentShare = 0.0f; // Largest player-fit component / player-fit cells. - // Minimum horizontal Chebyshev clearance, in voxels, among player-fit floor anchors. This - // is a diagnostic only; player occupancy itself uses a direct capsule stencil, never the old + // Minimum horizontal Chebyshev clearance, in voxels, among accepted supported poses. This is + // a diagnostic only; player occupancy itself uses a direct capsule stencil, never the old // Manhattan distance transform. float MinimumPlayerClearanceVoxels = 0.0f;