diff --git a/CODEMAP.md b/CODEMAP.md index 1d32c62..2ee39da 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -304,7 +304,7 @@ This is **where terrain shape lives.** ### 3.6b Strate measurement — `Public/VoxelStrateMeasure.h` + `Private/VoxelStrateMeasure.cpp` Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one strate between a -boundary-seal-derived (or explicitly overridden) interior margin into a bounded coarse grid, +boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded coarse grid, performs one deterministic 6-connected air flood fill, and derives fractions, components, walkability, feature scale, and clearance from that grid. Metrics report the resolved margin and the inclusive/exclusive voxel Z window used, plus the largest component's deterministic lowest-cell @@ -318,9 +318,10 @@ because a corridor thinner than `SampleStep` is invisible. No UObject state, cac or PIE is required. | Symbol | Role | |--------|------| -| `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin, and results identify the resolved margin/Z window and deterministic component representatives. | +| `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin (including zero to sample the seal), and results identify the resolved margin/Z window and deterministic component representatives. | | `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; refuses invalid bounds or a grid over `MaxCells`. | | `VF_AreConnected` | Coarse 6-connected BFS plus one full-resolution recheck of its recovered route, with explicit endpoint-solid, out-of-window, `CoarseLied`, `NotConnectedAtThisResolution`, and connected outcomes. | +| `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. | ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` Header is rich with inline docs. Two namespaces + a per-chunk cache system. @@ -485,6 +486,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | | `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | +| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and a VerticalShafts refinement sweep. The refinement test explicitly checks margins 0/2/4/8 at step 2/radius 192, then conditionally reports mouth components/proximity and a 16-seed arrival→departure result. | ## 4. The density pipeline (most-edited hot path) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index 6ca6f39..f9bd2a6 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -246,6 +246,7 @@ namespace { int32 SampleStep = 0; int32 RadiusInVoxels = 0; + int32 RequestedMarginVoxels = -1; FVoxelStrateMetrics Metrics; FConnectivityProbe Probe; }; @@ -1037,6 +1038,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet { int32 SampleStep; int32 RadiusInVoxels; + int32 InteriorMarginVoxels = -1; }; static constexpr FRefinementCase Cases[] = { {4, 256}, @@ -1056,10 +1058,12 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet FRefinementSweepRow& Row = Rows.AddDefaulted_GetRef(); Row.SampleStep = TestCase.SampleStep; Row.RadiusInVoxels = TestCase.RadiusInVoxels; + Row.RequestedMarginVoxels = TestCase.InteriorMarginVoxels; FVoxelStrateMeasureSettings RowSettings = BaseSettings; RowSettings.SampleStep = TestCase.SampleStep; RowSettings.RadiusInVoxels = TestCase.RadiusInVoxels; + RowSettings.InteriorMarginVoxels = TestCase.InteriorMarginVoxels; Row.Metrics = VF_MeasureStrate( *World.Generator, @@ -1125,6 +1129,106 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet } const double SweepSeconds = FPlatformTime::Seconds() - SweepStartSeconds; + static constexpr int32 MarginCases[] = {0, 2, 4, 8}; + TArray MarginRows; + MarginRows.Reserve(UE_ARRAY_COUNT(MarginCases)); + + const double MarginSweepStartSeconds = FPlatformTime::Seconds(); + for (const int32 RequestedMargin : MarginCases) + { + FRefinementSweepRow& Row = MarginRows.AddDefaulted_GetRef(); + Row.SampleStep = 2; + Row.RadiusInVoxels = 192; + Row.RequestedMarginVoxels = RequestedMargin; + + FVoxelStrateMeasureSettings RowSettings = BaseSettings; + RowSettings.SampleStep = Row.SampleStep; + RowSettings.RadiusInVoxels = Row.RadiusInVoxels; + RowSettings.InteriorMarginVoxels = RequestedMargin; + + Row.Metrics = VF_MeasureStrate( + *World.Generator, + *World.StrateManager, + VerticalShaftsIndex, + RowSettings); + + Row.Probe.bChecked = true; + Row.Probe.Result = VF_AreConnected( + *World.Generator, + *World.StrateManager, + VerticalShaftsIndex, + ArrivalPoint, + DeparturePoint, + RowSettings, + Row.Probe.bStartSnapped, + Row.Probe.bGoalSnapped); + + if (!Row.Metrics.bValid + || Row.Metrics.NumSampled <= 0 + || Row.Metrics.NumSampled > BaseSettings.MaxCells + || Row.Metrics.NumAir <= 0 + || Row.Metrics.NumSolid <= 0) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: VerticalShafts margin row margin=%d did not produce a " + "bounded non-vacuous measurement (valid=%s, reason='%s', cells=%lld, " + "air=%lld, solid=%lld)."), + RequestedMargin, + Row.Metrics.bValid ? TEXT("true") : TEXT("false"), + *Row.Metrics.RefusalReason, + Row.Metrics.NumSampled, + Row.Metrics.NumAir, + Row.Metrics.NumSolid)); + bAllChecksPassed = false; + } + + if (Row.Metrics.ResolvedMarginVoxels != RequestedMargin) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: VerticalShafts margin row requested margin=%d but the " + "measurement resolved margin=%d."), + RequestedMargin, + Row.Metrics.ResolvedMarginVoxels)); + bAllChecksPassed = false; + } + + if (Row.Probe.Result == EVoxelConnectivityResult::OutOfWindow + || Row.Probe.Result == EVoxelConnectivityResult::StartCellSolid + || Row.Probe.Result == EVoxelConnectivityResult::GoalCellSolid) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: VerticalShafts margin row margin=%d could not query both " + "in-window air mouths: %s."), + RequestedMargin, + *ConnectivityProbeText(Row.Probe))); + bAllChecksPassed = false; + } + + if (Row.Probe.bStartSnapped || Row.Probe.bGoalSnapped) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: VerticalShafts margin row margin=%d snapped an endpoint; " + "the controlled comparison is not a mouth-to-mouth query."), + RequestedMargin)); + bAllChecksPassed = false; + } + } + const double MarginSweepSeconds = FPlatformTime::Seconds() - MarginSweepStartSeconds; + + bool bMarginArtifact = false; + bool bMarginSweepUsable = MarginRows.Num() == UE_ARRAY_COUNT(MarginCases); + for (const FRefinementSweepRow& Row : MarginRows) + { + bMarginArtifact |= Row.Probe.Result == EVoxelConnectivityResult::Connected; + bMarginSweepUsable &= Row.Metrics.bValid + && Row.Metrics.ResolvedMarginVoxels == Row.RequestedMarginVoxels + && Row.Probe.Result != EVoxelConnectivityResult::OutOfWindow + && Row.Probe.Result != EVoxelConnectivityResult::StartCellSolid + && Row.Probe.Result != EVoxelConnectivityResult::GoalCellSolid + && !Row.Probe.bStartSnapped + && !Row.Probe.bGoalSnapped; + } + FString Summary = TEXT( "VerticalShafts arrival->departure refinement (seed 1337; source mouth pair; " "derived interior window):\n"); @@ -1158,6 +1262,199 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet Summary += FString::Printf( TEXT("VerticalShafts refinement sweep wall-clock: %.3f seconds.\n"), SweepSeconds); + + Summary += TEXT( + "Part A — margin artifact check (SampleStep 2, RadiusInVoxels 192; explicit margins):\n"); + Summary += TEXT( + " margin | verdict | components | largest share | sampled cells | Z window | endpoint snaps\n"); + for (const FRefinementSweepRow& Row : MarginRows) + { + const FString VerdictText = ConnectivityProbeText(Row.Probe); + FString SnapText = TEXT("none"); + if (Row.Probe.bStartSnapped || Row.Probe.bGoalSnapped) + { + SnapText = ConnectivityProbeText(Row.Probe); + } + Summary += FString::Printf( + TEXT(" %d | %s | %d | %.9g | %lld | [%d,%d) | %s\n"), + Row.RequestedMarginVoxels, + *VerdictText, + Row.Metrics.NumAirComponents, + Row.Metrics.LargestComponentShare, + Row.Metrics.NumSampled, + Row.Metrics.SampledMinZ, + Row.Metrics.SampledMaxZ, + *SnapText); + } + Summary += FString::Printf( + TEXT("Part A verdict: %s. Margin sweep wall-clock: %.3f seconds.\n"), + bMarginArtifact + ? TEXT("ARTIFACT — at least one margin-0/2/4 query is CONNECTED; the default margin excluded the connection") + : TEXT("FINDING SURVIVES — no margin-0/2/4 query is CONNECTED; the full window is exonerated"), + MarginSweepSeconds); + + if (!bMarginArtifact && bMarginSweepUsable) + { + FVoxelStrateMeasureSettings DiagnosticSettings = BaseSettings; + DiagnosticSettings.SampleStep = 2; + DiagnosticSettings.RadiusInVoxels = 192; + DiagnosticSettings.InteriorMarginVoxels = 0; + + const FVoxelConnectivityDiagnostics Diagnostics = VF_DiagnoseConnectivity( + *World.Generator, + *World.StrateManager, + VerticalShaftsIndex, + ArrivalPoint, + DeparturePoint, + DiagnosticSettings); + if (!Diagnostics.bValid + || Diagnostics.NumAirCells <= 0 + || Diagnostics.StartComponentCells <= 0 + || Diagnostics.GoalComponentCells <= 0 + || Diagnostics.StartToGoalComponentDistanceCells < 0 + || Diagnostics.GoalToStartComponentDistanceCells < 0) + { + AddError(TEXT("HARD FAILURE: the surviving VerticalShafts finding could not produce " + "component and separation diagnostics.")); + bAllChecksPassed = false; + } + + const float MouthXYSeparation = FMath::Sqrt( + FMath::Square(ArrivalPoint.X - DeparturePoint.X) + + FMath::Square(ArrivalPoint.Y - DeparturePoint.Y)); + const float MouthZDelta = DeparturePoint.Z - ArrivalPoint.Z; + const float MouthStraightLine = FVector::Dist(ArrivalPoint, DeparturePoint); + const float CoarseStraightLineCells = MouthStraightLine + / static_cast(DiagnosticSettings.SampleStep); + + Summary += TEXT("Part B — VerticalShafts diagnosis (seed 1337; margin 0; step 2; radius 192):\n"); + Summary += FString::Printf( + TEXT(" arrival LowerPoint: (%.3f, %.3f, %.3f); departure UpperPoint: " + "(%.3f, %.3f, %.3f); XY separation %.3f voxels; Z delta %.3f voxels.\n"), + ArrivalPoint.X, + ArrivalPoint.Y, + ArrivalPoint.Z, + DeparturePoint.X, + DeparturePoint.Y, + DeparturePoint.Z, + MouthXYSeparation, + MouthZDelta); + Summary += FString::Printf( + TEXT(" mouth components: air=%lld; arrival=%lld (share %.9g); departure=%lld " + "(share %.9g).\n"), + Diagnostics.NumAirCells, + Diagnostics.StartComponentCells, + Diagnostics.StartComponentShare, + Diagnostics.GoalComponentCells, + Diagnostics.GoalComponentShare); + Summary += FString::Printf( + TEXT(" coarse-grid separation: straight-line %.3f cells (%.3f voxels); " + "arrival to nearest departure-component cell %.3f cells (%.3f voxels); " + "departure to nearest arrival-component cell %.3f cells (%.3f voxels).\n"), + CoarseStraightLineCells, + MouthStraightLine, + Diagnostics.StartToGoalComponentDistanceCells, + Diagnostics.StartToGoalComponentDistanceCells * static_cast(DiagnosticSettings.SampleStep), + Diagnostics.GoalToStartComponentDistanceCells, + Diagnostics.GoalToStartComponentDistanceCells * static_cast(DiagnosticSettings.SampleStep)); + Summary += FString::Printf( + TEXT(" diagnostic route verdict: %s; endpoint snaps: start=%s, goal=%s.\n"), + ConnectivityResultName(Diagnostics.Result), + Diagnostics.bStartSnapped ? TEXT("yes") : TEXT("no"), + Diagnostics.bGoalSnapped ? TEXT("yes") : TEXT("no")); + + static constexpr int32 SeedCases[] = { + 1337, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, + }; + int32 SeedPasses = 0; + int32 SeedFailures = 0; + int32 SeedSnapEvents = 0; + int32 SeedResultCounts[6] = {}; + const double SeedSweepStartSeconds = FPlatformTime::Seconds(); + for (const int32 Seed : SeedCases) + { + FTestWorld SeedWorld; + SeedWorld.Build(Seed, /*InGapChunks=*/2); + if (!SeedWorld.IsValid()) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: seed %d could not build the VerticalShafts fixture."), + Seed)); + ++SeedFailures; + continue; + } + + FVector SeedArrivalPoint = FVector::ZeroVector; + FVector SeedDeparturePoint = FVector::ZeroVector; + if (!FindChainMouths( + SeedWorld.StrateManager->GetPassages(), + VerticalShaftsIndex, + SeedArrivalPoint, + SeedDeparturePoint)) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: seed %d did not produce exactly one VerticalShafts " + "arrival and departure mouth."), + Seed)); + ++SeedFailures; + continue; + } + + bool bStartSnapped = false; + bool bGoalSnapped = false; + const EVoxelConnectivityResult SeedResult = VF_AreConnected( + *SeedWorld.Generator, + *SeedWorld.StrateManager, + VerticalShaftsIndex, + SeedArrivalPoint, + SeedDeparturePoint, + DiagnosticSettings, + bStartSnapped, + bGoalSnapped); + ++SeedResultCounts[static_cast(SeedResult)]; + const int32 SnapEvents = (bStartSnapped ? 1 : 0) + (bGoalSnapped ? 1 : 0); + SeedSnapEvents += SnapEvents; + if (SeedResult == EVoxelConnectivityResult::Connected + && !bStartSnapped + && !bGoalSnapped) + { + ++SeedPasses; + } + else + { + ++SeedFailures; + } + } + const double SeedSweepSeconds = FPlatformTime::Seconds() - SeedSweepStartSeconds; + + Summary += FString::Printf( + TEXT(" multi-seed arrival->departure sweep (%d seeds; margin 0, step 2, radius 192): " + "%d pass, %d fail, %d snapped endpoint events; wall-clock %.3f seconds.\n"), + UE_ARRAY_COUNT(SeedCases), + SeedPasses, + SeedFailures, + SeedSnapEvents, + SeedSweepSeconds); + Summary += FString::Printf( + TEXT(" seed result breakdown: CONNECTED=%d, NOT_CONNECTED_AT_THIS_RESOLUTION=%d, " + "START_CELL_SOLID=%d, GOAL_CELL_SOLID=%d, OUT_OF_WINDOW=%d, COARSE_LIED=%d.\n"), + SeedResultCounts[static_cast(EVoxelConnectivityResult::Connected)], + SeedResultCounts[static_cast(EVoxelConnectivityResult::NotConnectedAtThisResolution)], + SeedResultCounts[static_cast(EVoxelConnectivityResult::StartCellSolid)], + SeedResultCounts[static_cast(EVoxelConnectivityResult::GoalCellSolid)], + SeedResultCounts[static_cast(EVoxelConnectivityResult::OutOfWindow)], + SeedResultCounts[static_cast(EVoxelConnectivityResult::CoarseLied)]); + } + else + { + Summary += FString::Printf( + TEXT("Part B diagnosis and multi-seed sweep: SKIPPED because Part A %s.\n"), + bMarginArtifact + ? TEXT("found the connection in the previously excluded band") + : TEXT("did not produce a usable four-row measurement/query set")); + } + AddInfo(Summary); return bAllChecksPassed; diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 6af34f5..8435317 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -164,14 +164,14 @@ namespace VoxelStrateMeasurePrivate } else if (DoubleSealThickness <= static_cast(INT32_MIN)) { - RequestedMargin = 1; + RequestedMargin = 0; } else { RequestedMargin = FMath::CeilToInt(DoubleSealThickness); } } - const int32 ResolvedMargin = FMath::Clamp(RequestedMargin, 1, MaxMargin); + const int32 ResolvedMargin = FMath::Clamp(RequestedMargin, 0, MaxMargin); const int64 InteriorMinZ = StrateBottomZ + static_cast(ResolvedMargin); const int64 InteriorMaxZ = StrateTopZ - static_cast(ResolvedMargin); @@ -803,6 +803,94 @@ namespace VoxelStrateMeasurePrivate } return true; } + + EVoxelConnectivityResult EvaluateConnectivityOnGrid( + const UVoxelGenerator& Generator, + const FSampleGrid& Grid, + const FVector& AVoxel, + const FVector& BVoxel, + int32& OutStart, + int32& OutGoal, + bool& bOutStartSnapped, + bool& bOutGoalSnapped) + { + OutStart = -1; + OutGoal = -1; + bOutStartSnapped = false; + bOutGoalSnapped = false; + + const EEndpointCellResult StartCell = ResolveEndpointCell( + Grid, AVoxel, OutStart, bOutStartSnapped); + const EEndpointCellResult GoalCell = ResolveEndpointCell( + Grid, BVoxel, OutGoal, bOutGoalSnapped); + if (StartCell == EEndpointCellResult::OutOfWindow + || GoalCell == EEndpointCellResult::OutOfWindow) + { + return EVoxelConnectivityResult::OutOfWindow; + } + if (StartCell == EEndpointCellResult::Solid) + { + return EVoxelConnectivityResult::StartCellSolid; + } + if (GoalCell == EEndpointCellResult::Solid) + { + return EVoxelConnectivityResult::GoalCellSolid; + } + + TArray Path; + if (!FindCoarsePath(Grid, OutStart, OutGoal, Path)) + { + return EVoxelConnectivityResult::NotConnectedAtThisResolution; + } + + // The coarse route is evidence, not a pass. Re-check this one BFS route at one-voxel + // spacing in the original voxel field. A single solid sample is the dangerous coarse + // false positive. + if (!FullResolutionPathIsAir(Generator, Grid, AVoxel, BVoxel, Path)) + { + return EVoxelConnectivityResult::CoarseLied; + } + return EVoxelConnectivityResult::Connected; + } + + float DistanceToComponent( + const FSampleGrid& Grid, + int32 StartCell, + int32 TargetComponent, + const TArray& Components) + { + if (Components[StartCell] == TargetComponent) + { + return 0.0f; + } + + int32 StartX = 0; + int32 StartY = 0; + int32 StartZ = 0; + DecodeIndex(Grid, StartCell, StartX, StartY, StartZ); + int64 BestDistanceSquared = MAX_int64; + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + if (Components[Index] != TargetComponent) + { + continue; + } + + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Index, X, Y, Z); + const int64 DX = static_cast(X) - static_cast(StartX); + const int64 DY = static_cast(Y) - static_cast(StartY); + const int64 DZ = static_cast(Z) - static_cast(StartZ); + const int64 DistanceSquared = DX * DX + DY * DY + DZ * DZ; + BestDistanceSquared = FMath::Min(BestDistanceSquared, DistanceSquared); + } + + return BestDistanceSquared == MAX_int64 + ? -1.0f + : FMath::Sqrt(static_cast(BestDistanceSquared)); + } } FVoxelStrateMetrics VF_MeasureStrate( @@ -859,38 +947,109 @@ EVoxelConnectivityResult VF_AreConnected( int32 Start = -1; int32 Goal = -1; - const VoxelStrateMeasurePrivate::EEndpointCellResult StartCell = - VoxelStrateMeasurePrivate::ResolveEndpointCell( - Grid, AVoxel, Start, bOutStartSnapped); - const VoxelStrateMeasurePrivate::EEndpointCellResult GoalCell = - VoxelStrateMeasurePrivate::ResolveEndpointCell( - Grid, BVoxel, Goal, bOutGoalSnapped); - if (StartCell == VoxelStrateMeasurePrivate::EEndpointCellResult::OutOfWindow - || GoalCell == VoxelStrateMeasurePrivate::EEndpointCellResult::OutOfWindow) + return VoxelStrateMeasurePrivate::EvaluateConnectivityOnGrid( + Generator, + Grid, + AVoxel, + BVoxel, + Start, + Goal, + bOutStartSnapped, + bOutGoalSnapped); +} + +FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings) +{ + FVoxelConnectivityDiagnostics Result; + if (!FMath::IsFinite(AVoxel.X) || !FMath::IsFinite(AVoxel.Y) || !FMath::IsFinite(AVoxel.Z) + || !FMath::IsFinite(BVoxel.X) || !FMath::IsFinite(BVoxel.Y) || !FMath::IsFinite(BVoxel.Z)) { - return EVoxelConnectivityResult::OutOfWindow; + return Result; } - if (StartCell == VoxelStrateMeasurePrivate::EEndpointCellResult::Solid) + + VoxelStrateMeasurePrivate::FSampleGrid Grid; + FString RefusalReason; + if (!VoxelStrateMeasurePrivate::BuildSampleGrid( + Generator, Manager, StrateIndex, Settings, Grid, RefusalReason)) + { + return Result; + } + + int32 Start = -1; + int32 Goal = -1; + Result.Result = VoxelStrateMeasurePrivate::EvaluateConnectivityOnGrid( + Generator, + Grid, + AVoxel, + BVoxel, + Start, + Goal, + Result.bStartSnapped, + Result.bGoalSnapped); + Result.bValid = Result.Result != EVoxelConnectivityResult::OutOfWindow + || (Start >= 0 && Goal >= 0); + if (Start < 0 || Goal < 0) { - return EVoxelConnectivityResult::StartCellSolid; + return Result; } - if (GoalCell == VoxelStrateMeasurePrivate::EEndpointCellResult::Solid) + + TArray Components; + int32 NumComponents = 0; + int64 LargestComponentCells = 0; + int32 LargestComponentLowestCell = INDEX_NONE; + int32 NumComponentsAtLeast1Pct = 0; + int64 NumAir = 0; + for (const uint8 bAir : Grid.Air) + { + NumAir += bAir != 0u ? 1 : 0; + } + FloodFillAir( + Grid, + Components, + NumComponents, + NumAir, + LargestComponentCells, + LargestComponentLowestCell, + NumComponentsAtLeast1Pct); + + const int32 StartComponent = Components[Start]; + const int32 GoalComponent = Components[Goal]; + if (StartComponent < 0 || GoalComponent < 0) { - return EVoxelConnectivityResult::GoalCellSolid; + return Result; } - TArray Path; - if (!VoxelStrateMeasurePrivate::FindCoarsePath(Grid, Start, Goal, Path)) + TArray ComponentCells; + ComponentCells.Init(0, NumComponents); + for (const int32 Component : Components) { - return EVoxelConnectivityResult::NotConnectedAtThisResolution; + if (Component >= 0) + { + ++ComponentCells[Component]; + } } - // The coarse route is evidence, not a pass. Re-check this one BFS route at one-voxel spacing - // in the original voxel field. A single solid sample is the dangerous coarse false positive. - if (!VoxelStrateMeasurePrivate::FullResolutionPathIsAir( - Generator, Grid, AVoxel, BVoxel, Path)) + Result.NumAirCells = NumAir; + Result.StartComponentCells = ComponentCells[StartComponent]; + Result.GoalComponentCells = ComponentCells[GoalComponent]; + if (NumAir > 0) { - return EVoxelConnectivityResult::CoarseLied; + Result.StartComponentShare = static_cast( + static_cast(Result.StartComponentCells) / static_cast(NumAir)); + Result.GoalComponentShare = static_cast( + static_cast(Result.GoalComponentCells) / static_cast(NumAir)); } - return EVoxelConnectivityResult::Connected; + Result.StartToGoalComponentDistanceCells = + VoxelStrateMeasurePrivate::DistanceToComponent( + Grid, Start, GoalComponent, Components); + Result.GoalToStartComponentDistanceCells = + VoxelStrateMeasurePrivate::DistanceToComponent( + Grid, Goal, StartComponent, Components); + return Result; } diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 8cb65cc..e4f98c3 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -26,7 +26,7 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings FVector2D CenterXY = FVector2D::ZeroVector; // Actor-space voxel coordinates. int32 MaxCells = 8000000; // Refuse a grid larger than this many cells. int32 HeadroomCells = 2; // Air cells above a floor cell required for walkable. - int32 InteriorMarginVoxels = -1; // <0 = derive from BoundarySealThickness (2x, clamped). + int32 InteriorMarginVoxels = -1; // <0 = derive from BoundarySealThickness (2x, clamped); explicit 0 includes the seal. }; /** Metrics derived from one coarse grid and its single air flood fill. */ @@ -55,6 +55,33 @@ struct VOXELFORGE_API FVoxelStrateMetrics int32 SampledMaxZ = 0; }; +/** + * Component facts for one coarse mouth-to-mouth query. + * + * The component shares are fractions of the sampled air cells. The two component distances are + * exact spatial Euclidean distances in coarse-cell coordinates to the nearest cell of the other + * mouth's component. Solid cells do not participate in that proximity calculation because it + * measures geometric separation between air components, not an alternate route. A distance of + * zero means both mouths resolve to the same component. Negative distances mean that the + * requested grid or endpoint could not be analyzed. + */ +struct VOXELFORGE_API FVoxelConnectivityDiagnostics +{ + bool bValid = false; + EVoxelConnectivityResult Result = EVoxelConnectivityResult::OutOfWindow; + bool bStartSnapped = false; + bool bGoalSnapped = false; + + int64 NumAirCells = 0; + int64 StartComponentCells = 0; + int64 GoalComponentCells = 0; + float StartComponentShare = 0.0f; + float GoalComponentShare = 0.0f; + + float StartToGoalComponentDistanceCells = -1.0f; + float GoalToStartComponentDistanceCells = -1.0f; +}; + /** * Measure one strate in actor-space voxel coordinates. * @@ -87,3 +114,17 @@ VOXELFORGE_API EVoxelConnectivityResult VF_AreConnected( const FVoxelStrateMeasureSettings& Settings, bool& bOutStartSnapped, bool& bOutGoalSnapped); + +/** + * Return the component sizes and spatial separation facts for the same query performed by + * VF_AreConnected. The returned Result uses the same full-resolution route re-check. bValid means + * the sample grid was built and both endpoint coordinates were in its bounds; component fields + * remain zero/negative when an endpoint has no air cell to analyze. + */ +VOXELFORGE_API FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings);