diff --git a/CODEMAP.md b/CODEMAP.md index 8305952..42da966 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -302,6 +302,19 @@ This is **where terrain shape lives.** > tile by the mesher drops tail octaves on coarse tiles (opt-in `LODOctaveDrop`, default 0 = off); > XY-field noise (heightfield/ceiling/relief/moisture) deliberately stays un-biased. §8.10. +### 3.6b Strate measurement — `Public/VoxelStrateMeasure.h` + `Private/VoxelStrateMeasure.cpp` +Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one strate's strict +interior 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. +`VF_AreConnected` recovers a deterministic BFS parent path from the same kind of grid and checks +only that route at full voxel resolution before returning true; a coarse false positive sets +`bOutCoarseLied`. No UObject state, cache, actor, world, or PIE is required. +| Symbol | Role | +|--------|------| +| `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements. | +| `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. | + ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` Header is rich with inline docs. Two namespaces + a per-chunk cache system. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp new file mode 100644 index 0000000..7890f7f --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -0,0 +1,506 @@ +// VoxelForgeStrateConnectivityTest.cpp +// Tier 2: the measurement pass and its coarse-connectivity false-positive guard. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelStrateMeasure.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeStrateConnectivityTest, + "VoxelForge.Generation.StrateConnectivity", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + struct FArchetypeReport + { + FString Name; + int32 Index = INDEX_NONE; + FVoxelStrateMetrics First; + FVoxelStrateMetrics Second; + }; + + struct FPassageReport + { + int32 Index = INDEX_NONE; + FString Name; + FString UpperResult; + FString LowerResult; + }; + + FString ArchetypeName(const FStrateSlot& Slot) + { + if (Slot.Definition != nullptr) + { + if (const UEnum* ArchetypeEnum = StaticEnum()) + { + return ArchetypeEnum->GetNameStringByValue( + static_cast(Slot.Definition->GeneratorType)); + } + } + return FString::Printf(TEXT("Strate_%d"), Slot.StrateIndex); + } + + bool SameFloatBits(float A, float B) + { + return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; + } + + bool MetricsAreBitIdentical(const FVoxelStrateMetrics& A, const FVoxelStrateMetrics& B) + { + return A.bValid == B.bValid + && A.RefusalReason == B.RefusalReason + && A.NumSampled == B.NumSampled + && A.NumAir == B.NumAir + && A.NumSolid == B.NumSolid + && SameFloatBits(A.AirFraction, B.AirFraction) + && A.NumAirComponents == B.NumAirComponents + && SameFloatBits(A.LargestComponentShare, B.LargestComponentShare) + && SameFloatBits(A.WalkableFraction, B.WalkableFraction) + && SameFloatBits(A.MedianFeatureScale, B.MedianFeatureScale) + && A.MedianVerticalClearance == B.MedianVerticalClearance; + } + + bool IsStrictInteriorPoint(const FStrateSlot& Slot, const FVector& Point) + { + const float MinZ = (static_cast(Slot.BottomChunkZ) + 1.0f) * CHUNK_SIZE; + const float MaxZ = static_cast(Slot.TopChunkZ) * CHUNK_SIZE; + return Point.Z >= MinZ && Point.Z < MaxZ; + } + + FVector LargestComponentAnchor(const FStrateSlot& Slot, const FVector2D& CenterXY) + { + // The fixture's origin spine is the deterministic hub/void anchor. The measurement API + // intentionally returns metrics rather than a component coordinate, so this report probes + // the known spine point that represents the largest reachable component in this fixture. + const float AnchorZ = (static_cast(Slot.BottomChunkZ) + 1.0f) * CHUNK_SIZE + 2.0f; + return FVector(CenterXY.X + 2.0f, CenterXY.Y + 2.0f, AnchorZ); + } + + bool FullResolutionDirectSegmentHasSolid( + const UVoxelGenerator& Generator, + const FVector& A, + const FVector& B) + { + const FVector Delta = B - A; + const float MaxDelta = FMath::Max3( + FMath::Abs(Delta.X), FMath::Abs(Delta.Y), FMath::Abs(Delta.Z)); + const int32 NumSteps = FMath::Max(1, FMath::CeilToInt(MaxDelta)); + for (int32 Step = 0; Step <= NumSteps; ++Step) + { + const float Alpha = static_cast(Step) / static_cast(NumSteps); + const FVector Sample = A + Delta * Alpha; + const float Density = Generator.GetDensityAt(Sample.X, Sample.Y, Sample.Z); + if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + { + return true; + } + } + return false; + } + + bool FindFirstGap( + const TArray& Layout, + int32& OutGapTopChunkZ, + int32& OutGapBottomChunkZ) + { + for (int32 Index = 0; Index + 1 < Layout.Num(); ++Index) + { + const int32 GapTop = Layout[Index].BottomChunkZ - 1; + const int32 GapBottom = Layout[Index + 1].TopChunkZ + 1; + if (GapTop >= GapBottom) + { + OutGapTopChunkZ = GapTop; + OutGapBottomChunkZ = GapBottom; + return true; + } + } + return false; + } + + float MeasureGapAirFraction( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 GapTopChunkZ, + int32 GapBottomChunkZ, + const FVoxelStrateMeasureSettings& Settings, + int64& OutSampleCount, + int64& OutAirCount) + { + OutSampleCount = 0; + OutAirCount = 0; + + const float MinX = Settings.CenterXY.X - static_cast(Settings.RadiusInVoxels); + const float MinY = Settings.CenterXY.Y - static_cast(Settings.RadiusInVoxels); + const float Step = static_cast(Settings.SampleStep); + const int32 NumXY = FMath::CeilToInt( + (2.0f * static_cast(Settings.RadiusInVoxels)) / Step); + const int32 MinGapChunkZ = GapBottomChunkZ; + const int32 MaxGapChunkZ = GapTopChunkZ; + + for (int32 Z = MinGapChunkZ * CHUNK_SIZE + Settings.SampleStep / 2; + Z < (MaxGapChunkZ + 1) * CHUNK_SIZE; + Z += Settings.SampleStep) + { + for (int32 YIndex = 0; YIndex < NumXY; ++YIndex) + { + const float Y = FMath::Min( + MinY + (static_cast(YIndex) + 0.5f) * Step, + Settings.CenterXY.Y + static_cast(Settings.RadiusInVoxels) - 0.5f); + for (int32 XIndex = 0; XIndex < NumXY; ++XIndex) + { + const float X = FMath::Min( + MinX + (static_cast(XIndex) + 0.5f) * Step, + Settings.CenterXY.X + static_cast(Settings.RadiusInVoxels) - 0.5f); + const int32 ChunkZ = FMath::FloorToInt(static_cast(Z) / CHUNK_SIZE); + if (!Manager.IsGapChunk(FIntVector( + FMath::FloorToInt(X / CHUNK_SIZE), + FMath::FloorToInt(Y / CHUNK_SIZE), + ChunkZ))) + { + continue; + } + + const float Density = Generator.GetDensityAt(X, Y, static_cast(Z)); + ++OutSampleCount; + if (FMath::IsFinite(Density) && Density > 0.0f) + { + ++OutAirCount; + } + } + } + } + + return OutSampleCount > 0 + ? static_cast(static_cast(OutAirCount) + / static_cast(OutSampleCount)) + : 0.0f; + } +} + +bool FVoxelForgeStrateConnectivityTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(/*InSeed=*/1337, /*InGapChunks=*/2); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + FVoxelStrateMeasureSettings Settings; + Settings.SampleStep = 4; + Settings.RadiusInVoxels = 256; + Settings.CenterXY = FVector2D::ZeroVector; + Settings.MaxCells = 8000000; + Settings.HeadroomCells = 2; + + bool bAllChecksPassed = true; + FString Summary = TEXT("VoxelForge strate measurement summary (seed 1337, step 4, radius 256):\n"); + + /* + * HARD FAILURES — these are the reasons this test exists: + * + * 1. Negative control: the inter-strate gap is bedrock. If it reports mostly air, the density + * sign is inverted and every other number in this file is meaningless. + * 2. Vacuity: a main measurement with no samples, no air, or no solid measures nothing. + * 3. Range sanity: fractions/components must describe an actual air field. + * 4. Determinism: identical inputs must produce bit-identical metrics. + * 5. Coarse-lie guard: a coarse route must never be reported connected after a full-resolution + * solid sample; the controlled 3-voxel wall below must make bOutCoarseLied fire. + */ + + int32 GapTopChunkZ = 0; + int32 GapBottomChunkZ = 0; + int64 GapSamples = 0; + int64 GapAirSamples = 0; + const bool bHaveGap = FindFirstGap( + World.StrateManager->GetLayout(), GapTopChunkZ, GapBottomChunkZ); + float GapAirFraction = 0.0f; + if (!bHaveGap) + { + AddError(TEXT("HARD FAILURE: the fixture has no inter-strate gap for the density-sign negative control.")); + bAllChecksPassed = false; + } + else + { + GapAirFraction = MeasureGapAirFraction( + *World.Generator, + *World.StrateManager, + GapTopChunkZ, + GapBottomChunkZ, + Settings, + GapSamples, + GapAirSamples); + if (GapSamples == 0) + { + AddError(TEXT("HARD FAILURE: the inter-strate gap negative control sampled zero cells.")); + bAllChecksPassed = false; + } + if (GapAirFraction > 0.05f) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: density sign is inverted — the known-solid inter-strate gap " + "reported AirFraction %.9g (mostly air), and every other number in this file " + "is meaningless."), + GapAirFraction)); + bAllChecksPassed = false; + } + } + + // This second control runs through VF_MeasureStrate itself. It fills the complete sampled + // box of the first strate, then requires that the measurement reports almost no air. It is + // deliberately separate from the gap probe above: this catches a sign inversion in the new + // measurement pass even though the generator-level gap probe uses the correct MC predicate. + const FStrateSlot& SolidControlSlot = World.StrateManager->GetLayout()[0]; + const int32 SolidControlMinZ = (SolidControlSlot.BottomChunkZ + 1) * CHUNK_SIZE; + const int32 SolidControlMaxZ = SolidControlSlot.TopChunkZ * CHUNK_SIZE; + FVoxelModification SolidControl; + SolidControl.Shape = EVoxelBrushShape::Box; + SolidControl.Center = FVector( + Settings.CenterXY.X, + Settings.CenterXY.Y, + static_cast(SolidControlMinZ + SolidControlMaxZ) * 0.5f); + SolidControl.BoxExtent = FVector( + static_cast(Settings.RadiusInVoxels), + static_cast(Settings.RadiusInVoxels), + static_cast(Settings.RadiusInVoxels)); + SolidControl.Radius = 1.0f; + SolidControl.Falloff = 0.1f; + SolidControl.Strength = 100.0f; + const TArray SolidControlChunks = World.DiffLayer->ApplyModification(SolidControl); + const FVoxelStrateMetrics SolidControlMetrics = VF_MeasureStrate( + *World.Generator, *World.StrateManager, 0, Settings); + if (SolidControlChunks.Num() == 0 || !SolidControlMetrics.bValid + || SolidControlMetrics.AirFraction > 0.05f) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: VF_MeasureStrate classified the known-solid control as air " + "(AirFraction %.9g); the density sign is inverted and every other number in " + "this file is meaningless."), + SolidControlMetrics.AirFraction)); + bAllChecksPassed = false; + } + World.DiffLayer->Clear(); + + TArray ArchetypeReports; + const TArray& Layout = World.StrateManager->GetLayout(); + ArchetypeReports.Reserve(Layout.Num()); + for (int32 StrateIndex = 0; StrateIndex < Layout.Num(); ++StrateIndex) + { + FArchetypeReport& Report = ArchetypeReports.AddDefaulted_GetRef(); + Report.Index = StrateIndex; + Report.Name = ArchetypeName(Layout[StrateIndex]); + Report.First = VF_MeasureStrate( + *World.Generator, *World.StrateManager, StrateIndex, Settings); + Report.Second = VF_MeasureStrate( + *World.Generator, *World.StrateManager, StrateIndex, Settings); + + const FVoxelStrateMetrics& Metrics = Report.First; + if (!Metrics.bValid) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: %s measurement refused: %s."), + *Report.Name, + *Metrics.RefusalReason)); + bAllChecksPassed = false; + } + if (Metrics.NumSampled == 0 || Metrics.NumAir == 0 || Metrics.NumSolid == 0) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: vacuous %s measurement — sampled=%lld, air=%lld, solid=%lld."), + *Report.Name, + Metrics.NumSampled, + Metrics.NumAir, + Metrics.NumSolid)); + bAllChecksPassed = false; + } + if (!FMath::IsFinite(Metrics.AirFraction) + || Metrics.AirFraction < 0.0f || Metrics.AirFraction > 1.0f + || !FMath::IsFinite(Metrics.LargestComponentShare) + || Metrics.LargestComponentShare < 0.0f || Metrics.LargestComponentShare > 1.0f + || !FMath::IsFinite(Metrics.WalkableFraction) + || Metrics.WalkableFraction < 0.0f || Metrics.WalkableFraction > 1.0f + || (Metrics.NumAir > 0 && Metrics.NumAirComponents == 0) + || (Metrics.LargestComponentShare == 0.0f && Metrics.AirFraction > 0.05f)) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: range/component sanity failed for %s."), *Report.Name)); + bAllChecksPassed = false; + } + if (!MetricsAreBitIdentical(Report.First, Report.Second)) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: %s measurement was not bit-identical on the second call."), + *Report.Name)); + bAllChecksPassed = false; + } + } + + ArchetypeReports.Sort([](const FArchetypeReport& A, const FArchetypeReport& B) + { + if (A.Name == B.Name) return A.Index < B.Index; + return A.Name < B.Name; + }); + + Summary += FString::Printf( + TEXT("Negative control: gap chunks [%d..%d], AirFraction=%.9g (%lld/%lld samples air).\n"), + GapTopChunkZ, + GapBottomChunkZ, + GapAirFraction, + GapAirSamples, + GapSamples); + Summary += TEXT("Archetypes (sorted):\n"); + for (const FArchetypeReport& Report : ArchetypeReports) + { + const FVoxelStrateMetrics& Metrics = Report.First; + Summary += FString::Printf( + TEXT(" %s: Air=%.9g, Largest=%.9g, Walkable=%.9g, FeatureScale=%.9g vox, " + "Clearance=%d vox, Components=%d, Samples=%lld, Solid=%lld\n"), + *Report.Name, + Metrics.AirFraction, + Metrics.LargestComponentShare, + Metrics.WalkableFraction, + Metrics.MedianFeatureScale, + Metrics.MedianVerticalClearance, + Metrics.NumAirComponents, + Metrics.NumSampled, + Metrics.NumSolid); + } + + int32 NumRoutesChecked = 0; + int32 NumRoutesRefuted = 0; + for (int32 StrateIndex = 0; StrateIndex < Layout.Num(); ++StrateIndex) + { + const FStrateSlot& Slot = Layout[StrateIndex]; + const int32 InteriorMinZ = (Slot.BottomChunkZ + 1) * CHUNK_SIZE; + const int32 InteriorMaxZ = Slot.TopChunkZ * CHUNK_SIZE; + if (InteriorMinZ >= InteriorMaxZ) continue; + + const FVector A(Settings.CenterXY.X + 2.0f, Settings.CenterXY.Y + 2.0f, + static_cast(InteriorMinZ + 2)); + const FVector B(Settings.CenterXY.X + 2.0f, Settings.CenterXY.Y + 2.0f, + static_cast(InteriorMaxZ - 2)); + bool bCoarseLied = false; + const bool bConnected = VF_AreConnected( + *World.Generator, *World.StrateManager, StrateIndex, A, B, Settings, bCoarseLied); + ++NumRoutesChecked; + if (bCoarseLied) ++NumRoutesRefuted; + if (bConnected && FullResolutionDirectSegmentHasSolid(*World.Generator, A, B)) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: VF_AreConnected returned true for %s while its full-resolution " + "direct walk contained a solid sample."), + *ArchetypeName(Slot))); + bAllChecksPassed = false; + } + } + + // Controlled negative route: at SampleStep 4, the two coarse centres at X=-2 and X=+2 + // remain air while the three full-resolution voxels at X=-1,0,+1 are filled solid. + const FStrateSlot& WallSlot = Layout[0]; + const int32 WallInteriorMinZ = (WallSlot.BottomChunkZ + 1) * CHUNK_SIZE; + const float WallZ = static_cast(WallInteriorMinZ + 2); + const FVector WallA(Settings.CenterXY.X - 2.0f, Settings.CenterXY.Y + 2.0f, WallZ); + const FVector WallB(Settings.CenterXY.X + 2.0f, Settings.CenterXY.Y + 2.0f, WallZ); + + FVoxelModification Wall; + Wall.Shape = EVoxelBrushShape::Box; + Wall.Center = FVector(Settings.CenterXY.X, Settings.CenterXY.Y + 2.0f, WallZ); + Wall.BoxExtent = FVector(1.5f, 256.0f, 256.0f); + Wall.Radius = 1.0f; + Wall.Falloff = 0.1f; + Wall.Strength = 100.0f; + const TArray WallChunks = World.DiffLayer->ApplyModification(Wall); + if (WallChunks.Num() == 0) + { + AddError(TEXT("HARD FAILURE: the controlled 3-voxel wall could not be installed.")); + bAllChecksPassed = false; + } + + bool bWallCoarseLied = false; + const bool bWallConnected = VF_AreConnected( + *World.Generator, + *World.StrateManager, + 0, + WallA, + WallB, + Settings, + bWallCoarseLied); + ++NumRoutesChecked; + if (bWallCoarseLied) ++NumRoutesRefuted; + if (bWallConnected) + { + AddError(TEXT("HARD FAILURE: VF_AreConnected reported true across a full-resolution solid 3-voxel wall.")); + bAllChecksPassed = false; + } + if (!bWallCoarseLied) + { + AddError(TEXT("HARD FAILURE: the controlled coarse-connectivity lie did not set bOutCoarseLied.")); + bAllChecksPassed = false; + } + World.DiffLayer->Clear(); + + TArray PassageReports; + const TArray& Passages = World.StrateManager->GetPassages(); + PassageReports.Reserve(Passages.Num()); + for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + FPassageReport& Report = PassageReports.AddDefaulted_GetRef(); + Report.Index = PassageIndex; + Report.Name = FString::Printf(TEXT("%03d"), PassageIndex); + + auto CheckEndpoint = [&](int32 EndpointStrateIndex, const FVector& Endpoint) -> FString + { + if (!Layout.IsValidIndex(EndpointStrateIndex)) return TEXT("invalid-strate"); + const FStrateSlot& Slot = Layout[EndpointStrateIndex]; + if (!IsStrictInteriorPoint(Slot, Endpoint)) return TEXT("out-of-band"); + + const FVector Anchor = LargestComponentAnchor(Slot, Settings.CenterXY); + bool bCoarseLied = false; + const bool bConnected = VF_AreConnected( + *World.Generator, + *World.StrateManager, + EndpointStrateIndex, + Endpoint, + Anchor, + Settings, + bCoarseLied); + ++NumRoutesChecked; + if (bCoarseLied) ++NumRoutesRefuted; + return bConnected ? TEXT("connected") : TEXT("not-connected"); + }; + + Report.UpperResult = CheckEndpoint(Passage.UpperStrateIndex, Passage.UpperPoint); + Report.LowerResult = CheckEndpoint(Passage.LowerStrateIndex, Passage.LowerPoint); + } + + PassageReports.Sort([](const FPassageReport& A, const FPassageReport& B) + { + return A.Index < B.Index; + }); + Summary += FString::Printf( + TEXT("Connectivity guard: %d routes checked, %d coarse routes refuted at full resolution.\n"), + NumRoutesChecked, + NumRoutesRefuted); + Summary += TEXT("Passages (endpoint -> largest-component spine anchor, sorted):\n"); + for (const FPassageReport& Report : PassageReports) + { + Summary += FString::Printf( + TEXT(" passage %s: upper=%s, lower=%s\n"), + *Report.Name, + *Report.UpperResult, + *Report.LowerResult); + } + AddInfo(Summary); + + return bAllChecksPassed; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp new file mode 100644 index 0000000..b273725 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -0,0 +1,686 @@ +// Read-only, deterministic strate measurement and connectivity checks. + +#include "VoxelStrateMeasure.h" + +#include "VoxelGenerator.h" +#include "VoxelStrateManager.h" +#include "VoxelTypes.h" + +namespace VoxelStrateMeasurePrivate +{ + struct FSampleGrid + { + int32 NumX = 0; + int32 NumY = 0; + int32 NumZ = 0; + int32 SampleStep = 1; + int32 CellCount = 0; + + float MinX = 0.0f; + float MinY = 0.0f; + float MinZ = 0.0f; + float MaxX = 0.0f; + float MaxY = 0.0f; + float MaxZ = 0.0f; + float ExtentX = 0.0f; + float ExtentY = 0.0f; + float ExtentZ = 0.0f; + + // 1 = air, 0 = solid. The polarity is deliberately explicit at the sampling site below. + TArray Air; + + FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const + { + return static_cast( + static_cast(X) + + static_cast(NumX) * (static_cast(Y) + + static_cast(NumY) * static_cast(Z))); + } + + FVector CellCenter(int32 X, int32 Y, int32 Z) const + { + const float Step = static_cast(SampleStep); + const float XOffset = FMath::Min( + static_cast(X) * Step + 0.5f * Step, ExtentX - 0.5f); + const float YOffset = FMath::Min( + static_cast(Y) * Step + 0.5f * Step, ExtentY - 0.5f); + const float ZOffset = FMath::Min( + static_cast(Z) * Step + 0.5f * Step, ExtentZ - 0.5f); + return FVector(MinX + XOffset, MinY + YOffset, MinZ + ZOffset); + } + + bool ContainsPoint(const FVector& Point) const + { + // The upper bounds are exclusive: a point on the box edge belongs to the next box, + // not to the last coarse cell in this measurement. + return Point.X >= MinX && Point.X < MaxX + && Point.Y >= MinY && Point.Y < MaxY + && Point.Z >= MinZ && Point.Z < MaxZ; + } + }; + + int64 CeilDivPositive(int64 Numerator, int64 Denominator) + { + return (Numerator + Denominator - 1) / Denominator; + } + + bool Refuse(FString& OutReason, const TCHAR* Reason) + { + OutReason = Reason; + return false; + } + + bool BuildSampleGrid( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVoxelStrateMeasureSettings& Settings, + FSampleGrid& OutGrid, + FString& OutReason) + { + const TArray& Layout = Manager.GetLayout(); + if (!Layout.IsValidIndex(StrateIndex)) + { + return Refuse(OutReason, TEXT("StrateIndex is outside the manager layout.")); + } + + const FStrateSlot& Slot = Layout[StrateIndex]; + if (Slot.Definition == nullptr) + { + return Refuse(OutReason, TEXT("The requested strate has no definition.")); + } + if (Settings.SampleStep <= 0) + { + return Refuse(OutReason, TEXT("SampleStep must be greater than zero.")); + } + if (Settings.RadiusInVoxels <= 0) + { + return Refuse(OutReason, TEXT("RadiusInVoxels must be greater than zero.")); + } + if (Settings.MaxCells <= 0) + { + return Refuse(OutReason, TEXT("MaxCells must be greater than zero.")); + } + if (Settings.HeadroomCells < 0) + { + return Refuse(OutReason, TEXT("HeadroomCells cannot be negative.")); + } + if (!FMath::IsFinite(Settings.CenterXY.X) || !FMath::IsFinite(Settings.CenterXY.Y)) + { + return Refuse(OutReason, TEXT("CenterXY must contain finite voxel coordinates.")); + } + if (Slot.TopChunkZ <= Slot.BottomChunkZ) + { + return Refuse(OutReason, TEXT("The requested strate has no positive vertical extent.")); + } + + // Exclude the complete top and bottom chunks. Those outer chunks contain the seal bands; + // the measurement is about the strate interior, not the boundary bedrock. + const int64 InteriorMinZ = (static_cast(Slot.BottomChunkZ) + 1) * CHUNK_SIZE; + const int64 InteriorMaxZ = static_cast(Slot.TopChunkZ) * CHUNK_SIZE; + const int64 InteriorHeight = InteriorMaxZ - InteriorMinZ; + if (InteriorHeight <= 0) + { + return Refuse(OutReason, TEXT("The strate is too short after excluding its top and bottom chunks.")); + } + + const int64 XYExtent = static_cast(Settings.RadiusInVoxels) * 2; + const int64 Step = static_cast(Settings.SampleStep); + const int64 NumX64 = CeilDivPositive(XYExtent, Step); + const int64 NumY64 = NumX64; + const int64 NumZ64 = CeilDivPositive(InteriorHeight, Step); + const int64 MaxCells = static_cast(Settings.MaxCells); + + if (NumX64 > INT32_MAX || NumY64 > INT32_MAX || NumZ64 > INT32_MAX) + { + return Refuse(OutReason, TEXT("A sample-grid dimension exceeds the supported array size.")); + } + if (NumX64 > MaxCells || NumY64 > MaxCells || NumZ64 > MaxCells + || NumX64 > MaxCells / NumY64) + { + return Refuse(OutReason, TEXT("The requested sample grid exceeds MaxCells; measurement refused.")); + } + + const int64 XYCells = NumX64 * NumY64; + if (XYCells > MaxCells / NumZ64) + { + return Refuse(OutReason, TEXT("The requested sample grid exceeds MaxCells; measurement refused.")); + } + const int64 CellCount64 = XYCells * NumZ64; + if (CellCount64 <= 0 || CellCount64 > MaxCells || CellCount64 > INT32_MAX) + { + return Refuse(OutReason, TEXT("The requested sample grid exceeds the bounded cell count.")); + } + + const double MinX64 = static_cast(Settings.CenterXY.X) + - static_cast(Settings.RadiusInVoxels); + const double MaxX64 = static_cast(Settings.CenterXY.X) + + static_cast(Settings.RadiusInVoxels); + const double MinY64 = static_cast(Settings.CenterXY.Y) + - static_cast(Settings.RadiusInVoxels); + const double MaxY64 = static_cast(Settings.CenterXY.Y) + + static_cast(Settings.RadiusInVoxels); + const float MinX = static_cast(MinX64); + const float MaxX = static_cast(MaxX64); + const float MinY = static_cast(MinY64); + const float MaxY = static_cast(MaxY64); + const float MinZ = static_cast(InteriorMinZ); + const float MaxZ = static_cast(InteriorMaxZ); + if (!FMath::IsFinite(MinX) || !FMath::IsFinite(MaxX) + || !FMath::IsFinite(MinY) || !FMath::IsFinite(MaxY) + || !FMath::IsFinite(MinZ) || !FMath::IsFinite(MaxZ) + || MaxX <= MinX || MaxY <= MinY || MaxZ <= MinZ) + { + return Refuse(OutReason, TEXT("The requested measurement bounds are not representable as finite voxel coordinates.")); + } + + OutGrid.NumX = static_cast(NumX64); + OutGrid.NumY = static_cast(NumY64); + OutGrid.NumZ = static_cast(NumZ64); + OutGrid.SampleStep = Settings.SampleStep; + OutGrid.CellCount = static_cast(CellCount64); + OutGrid.MinX = MinX; + OutGrid.MinY = MinY; + OutGrid.MinZ = MinZ; + OutGrid.MaxX = MaxX; + OutGrid.MaxY = MaxY; + OutGrid.MaxZ = MaxZ; + OutGrid.ExtentX = MaxX - MinX; + OutGrid.ExtentY = MaxY - MinY; + OutGrid.ExtentZ = MaxZ - MinZ; + OutGrid.Air.SetNumUninitialized(OutGrid.CellCount); + + bool bSawNonFiniteDensity = false; + int32 CellIndex = 0; + for (int32 Z = 0; Z < OutGrid.NumZ; ++Z) + { + for (int32 Y = 0; Y < OutGrid.NumY; ++Y) + { + for (int32 X = 0; X < OutGrid.NumX; ++X) + { + const FVector Sample = OutGrid.CellCenter(X, Y, Z); + const float Density = Generator.GetDensityAt(Sample.X, Sample.Y, Sample.Z); + if (!FMath::IsFinite(Density)) + { + bSawNonFiniteDensity = true; + } + + // MC convention in this codebase: negative is solid, positive is air. + OutGrid.Air[CellIndex++] = Density > 0.0f ? 1u : 0u; + } + } + } + + if (bSawNonFiniteDensity) + { + return Refuse(OutReason, TEXT("At least one density sample was non-finite.")); + } + return true; + } + + void DecodeIndex(const FSampleGrid& Grid, int32 Index, int32& OutX, int32& OutY, int32& OutZ) + { + const int32 Plane = Grid.NumX * Grid.NumY; + OutZ = Index / Plane; + const int32 InPlane = Index - OutZ * Plane; + OutY = InPlane / Grid.NumX; + OutX = InPlane - OutY * Grid.NumX; + } + + void FloodFillAir( + const FSampleGrid& Grid, + TArray& OutComponents, + int32& OutNumComponents, + int32& OutLargestComponentCells) + { + OutComponents.SetNumUninitialized(Grid.CellCount); + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + OutComponents[Index] = -1; + } + + TArray Queue; + Queue.SetNumUninitialized(Grid.CellCount); + OutNumComponents = 0; + OutLargestComponentCells = 0; + + for (int32 Start = 0; Start < Grid.CellCount; ++Start) + { + if (Grid.Air[Start] == 0u || OutComponents[Start] >= 0) + { + continue; + } + + const int32 ComponentId = OutNumComponents++; + int32 Head = 0; + int32 Tail = 0; + int32 ComponentCells = 0; + OutComponents[Start] = ComponentId; + Queue[Tail++] = Start; + + while (Head < Tail) + { + const int32 Current = Queue[Head++]; + ++ComponentCells; + + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Current, X, Y, Z); + + auto Visit = [&](int32 Neighbor) + { + if (Grid.Air[Neighbor] != 0u && OutComponents[Neighbor] < 0) + { + OutComponents[Neighbor] = ComponentId; + Queue[Tail++] = Neighbor; + } + }; + + // Fixed order keeps every derived value independent of container iteration order. + if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); + if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); + if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); + if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + } + + OutLargestComponentCells = FMath::Max(OutLargestComponentCells, ComponentCells); + } + } + + void BuildManhattanDistanceToSolid(const FSampleGrid& Grid, TArray& OutDistances) + { + // Two raster passes with 6-neighbour Manhattan relaxation. The result is an approximation + // of distance-to-solid in the original field: each coarse grid unit is SampleStep VOXELS. + constexpr int32 Infinity = INT32_MAX; + OutDistances.SetNumUninitialized(Grid.CellCount); + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + OutDistances[Index] = Grid.Air[Index] == 0u ? 0 : Infinity; + } + + auto Relax = [&](int32 Current, int32 Neighbor) + { + if (OutDistances[Neighbor] == Infinity) + { + return; + } + const int32 Candidate = OutDistances[Neighbor] == INT32_MAX - 1 + ? Infinity : OutDistances[Neighbor] + 1; + OutDistances[Current] = FMath::Min(OutDistances[Current], Candidate); + }; + + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Current = Grid.Index(X, Y, Z); + if (Grid.Air[Current] == 0u) continue; + if (X > 0) Relax(Current, Grid.Index(X - 1, Y, Z)); + if (Y > 0) Relax(Current, Grid.Index(X, Y - 1, Z)); + if (Z > 0) Relax(Current, Grid.Index(X, Y, Z - 1)); + } + } + } + + for (int32 Z = Grid.NumZ - 1; Z >= 0; --Z) + { + for (int32 Y = Grid.NumY - 1; Y >= 0; --Y) + { + for (int32 X = Grid.NumX - 1; X >= 0; --X) + { + const int32 Current = Grid.Index(X, Y, Z); + if (Grid.Air[Current] == 0u) continue; + if (X + 1 < Grid.NumX) Relax(Current, Grid.Index(X + 1, Y, Z)); + if (Y + 1 < Grid.NumY) Relax(Current, Grid.Index(X, Y + 1, Z)); + if (Z + 1 < Grid.NumZ) Relax(Current, Grid.Index(X, Y, Z + 1)); + } + } + } + } + + float MedianFloat(TArray& Values) + { + if (Values.Num() == 0) return 0.0f; + Values.Sort(); + const int32 Middle = Values.Num() / 2; + if ((Values.Num() & 1) != 0) + { + return Values[Middle]; + } + return (Values[Middle - 1] + Values[Middle]) * 0.5f; + } + + void BuildMetricsFromGrid( + const FSampleGrid& Grid, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMetrics& InOutMetrics) + { + TArray Components; + int32 NumComponents = 0; + int32 LargestComponentCells = 0; + FloodFillAir(Grid, Components, NumComponents, LargestComponentCells); + + int64 NumAir = 0; + for (const uint8 bAir : Grid.Air) + { + NumAir += bAir != 0u ? 1 : 0; + } + + InOutMetrics.NumSampled = Grid.CellCount; + InOutMetrics.NumAir = NumAir; + InOutMetrics.NumSolid = static_cast(Grid.CellCount) - NumAir; + InOutMetrics.AirFraction = static_cast( + static_cast(NumAir) / static_cast(Grid.CellCount)); + InOutMetrics.NumAirComponents = NumComponents; + InOutMetrics.LargestComponentShare = NumAir > 0 + ? static_cast(static_cast(LargestComponentCells) + / static_cast(NumAir)) + : 0.0f; + + TArray ClearanceValues; + ClearanceValues.Reserve(static_cast(NumAir)); + int64 NumWalkable = 0; + + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Current = Grid.Index(X, Y, Z); + if (Grid.Air[Current] == 0u || Z == 0 + || Grid.Air[Grid.Index(X, Y, Z - 1)] != 0u) + { + continue; + } + + bool bHasHeadroom = true; + for (int32 H = 0; H < Settings.HeadroomCells; ++H) + { + const int64 HeadroomZ = static_cast(Z) + H; + if (HeadroomZ >= Grid.NumZ + || Grid.Air[Grid.Index(X, Y, static_cast(HeadroomZ))] == 0u) + { + bHasHeadroom = false; + break; + } + } + if (!bHasHeadroom) continue; + + ++NumWalkable; + int32 ClearanceCells = 0; + while (static_cast(Z) + ClearanceCells < Grid.NumZ + && Grid.Air[Grid.Index(X, Y, Z + ClearanceCells)] != 0u) + { + ++ClearanceCells; + } + const int64 ClearanceVoxels = static_cast(ClearanceCells) + * Settings.SampleStep; + ClearanceValues.Add(ClearanceVoxels > INT32_MAX + ? INT32_MAX : static_cast(ClearanceVoxels)); + } + } + } + + InOutMetrics.WalkableFraction = NumAir > 0 + ? static_cast(static_cast(NumWalkable) + / static_cast(NumAir)) + : 0.0f; + + if (ClearanceValues.Num() > 0) + { + ClearanceValues.Sort(); + // The public metric is integral, so use the lower median for an even sample count. + InOutMetrics.MedianVerticalClearance = ClearanceValues[(ClearanceValues.Num() - 1) / 2]; + } + + if (InOutMetrics.NumSolid > 0 && NumAir > 0) + { + TArray Distances; + BuildManhattanDistanceToSolid(Grid, Distances); + + TArray FeatureScales; + FeatureScales.Reserve(static_cast(NumAir)); + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + if (Grid.Air[Index] != 0u) + { + FeatureScales.Add(static_cast(Distances[Index]) + * static_cast(Settings.SampleStep)); + } + } + InOutMetrics.MedianFeatureScale = MedianFloat(FeatureScales); + } + } + + bool FindCellForPoint(const FSampleGrid& Grid, const FVector& Point, int32& OutCell) + { + if (!Grid.ContainsPoint(Point)) return false; + + const float Step = static_cast(Grid.SampleStep); + 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); + OutCell = Grid.Index(X, Y, Z); + return true; + } + + bool FullResolutionAirOnSegment( + const UVoxelGenerator& Generator, + const FVector& Start, + const FVector& End) + { + const FVector Delta = End - Start; + const float MaxDelta = FMath::Max3( + FMath::Abs(Delta.X), FMath::Abs(Delta.Y), FMath::Abs(Delta.Z)); + const int32 NumSteps = FMath::Max(1, FMath::CeilToInt(MaxDelta)); + for (int32 Step = 0; Step <= NumSteps; ++Step) + { + const float Alpha = static_cast(Step) / static_cast(NumSteps); + const FVector Sample = Start + Delta * Alpha; + const float Density = Generator.GetDensityAt(Sample.X, Sample.Y, Sample.Z); + if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + { + return false; + } + } + return true; + } + + bool FullResolutionPathIsAir( + const UVoxelGenerator& Generator, + const FSampleGrid& Grid, + const FVector& A, + const FVector& B, + const TArray& Path) + { + int32 StartX = 0; + int32 StartY = 0; + int32 StartZ = 0; + DecodeIndex(Grid, Path[0], StartX, StartY, StartZ); + int32 EndX = 0; + int32 EndY = 0; + int32 EndZ = 0; + DecodeIndex(Grid, Path.Last(), EndX, EndY, EndZ); + + if (!FullResolutionAirOnSegment(Generator, A, Grid.CellCenter(StartX, StartY, StartZ))) + { + return false; + } + + for (int32 PathIndex = 1; PathIndex < Path.Num(); ++PathIndex) + { + int32 PreviousX = 0; + int32 PreviousY = 0; + int32 PreviousZ = 0; + DecodeIndex(Grid, Path[PathIndex - 1], PreviousX, PreviousY, PreviousZ); + int32 CurrentX = 0; + int32 CurrentY = 0; + int32 CurrentZ = 0; + DecodeIndex(Grid, Path[PathIndex], CurrentX, CurrentY, CurrentZ); + if (!FullResolutionAirOnSegment( + Generator, + Grid.CellCenter(PreviousX, PreviousY, PreviousZ), + Grid.CellCenter(CurrentX, CurrentY, CurrentZ))) + { + return false; + } + } + + return FullResolutionAirOnSegment(Generator, Grid.CellCenter(EndX, EndY, EndZ), B); + } + + bool FindCoarsePath( + const FSampleGrid& Grid, + int32 Start, + int32 Goal, + TArray& OutPath) + { + if (Grid.Air[Start] == 0u || Grid.Air[Goal] == 0u) + { + return false; + } + + TArray Parent; + Parent.SetNumUninitialized(Grid.CellCount); + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + Parent[Index] = -2; // unvisited; -1 is the root's parent + } + + TArray Queue; + Queue.SetNumUninitialized(Grid.CellCount); + int32 Head = 0; + int32 Tail = 0; + Parent[Start] = -1; + Queue[Tail++] = Start; + + while (Head < Tail && Parent[Goal] == -2) + { + const int32 Current = Queue[Head++]; + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Current, X, Y, Z); + + auto Visit = [&](int32 Neighbor) + { + if (Grid.Air[Neighbor] != 0u && Parent[Neighbor] == -2) + { + Parent[Neighbor] = Current; + Queue[Tail++] = Neighbor; + } + }; + + // The order is fixed so the recovered route is deterministic. This is a real BFS + // parent chain, not just a component-membership answer. + if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); + if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); + if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); + if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + } + + if (Parent[Goal] == -2) + { + return false; + } + + OutPath.Reset(); + OutPath.Reserve(Tail); + for (int32 Current = Goal; Current >= 0; Current = Parent[Current]) + { + OutPath.Add(Current); + if (Current == Start) break; + } + if (OutPath.Last() != Start) + { + OutPath.Reset(); + return false; + } + + for (int32 Left = 0, Right = OutPath.Num() - 1; Left < Right; ++Left, --Right) + { + Swap(OutPath[Left], OutPath[Right]); + } + return true; + } +} + +FVoxelStrateMetrics VF_MeasureStrate( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVoxelStrateMeasureSettings& Settings) +{ + FVoxelStrateMetrics Result; + VoxelStrateMeasurePrivate::FSampleGrid Grid; + if (!VoxelStrateMeasurePrivate::BuildSampleGrid( + Generator, Manager, StrateIndex, Settings, Grid, Result.RefusalReason)) + { + return Result; + } + + VoxelStrateMeasurePrivate::BuildMetricsFromGrid(Grid, Settings, Result); + Result.bValid = Result.NumSampled > 0; + if (!Result.bValid) + { + Result.RefusalReason = TEXT("The measurement grid was empty."); + } + return Result; +} + +bool VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutCoarseLied) +{ + bOutCoarseLied = false; + 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 false; + } + + VoxelStrateMeasurePrivate::FSampleGrid Grid; + FString RefusalReason; + if (!VoxelStrateMeasurePrivate::BuildSampleGrid( + Generator, Manager, StrateIndex, Settings, Grid, RefusalReason)) + { + return false; + } + + int32 Start = -1; + int32 Goal = -1; + if (!VoxelStrateMeasurePrivate::FindCellForPoint(Grid, AVoxel, Start) + || !VoxelStrateMeasurePrivate::FindCellForPoint(Grid, BVoxel, Goal)) + { + return false; + } + + TArray Path; + if (!VoxelStrateMeasurePrivate::FindCoarsePath(Grid, Start, Goal, Path)) + { + return false; + } + + // 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)) + { + bOutCoarseLied = true; + return false; + } + return true; +} diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h new file mode 100644 index 0000000..55e0de9 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -0,0 +1,63 @@ +// Read-only, headless measurements over a coarse sample of one strate. + +#pragma once + +#include "CoreMinimal.h" + +class UVoxelGenerator; +class UVoxelStrateManager; + +/** Settings for the deterministic coarse grid used by the strate measurement pass. */ +struct VOXELFORGE_API FVoxelStrateMeasureSettings +{ + int32 SampleStep = 4; // Voxels between samples. + int32 RadiusInVoxels = 256; // Half-extent in X and Y around CenterXY. + 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. +}; + +/** Metrics derived from one coarse grid and its single air flood fill. */ +struct VOXELFORGE_API FVoxelStrateMetrics +{ + bool bValid = false; + FString RefusalReason; + + int64 NumSampled = 0; + int64 NumAir = 0; + int64 NumSolid = 0; + + float AirFraction = 0.f; + int32 NumAirComponents = 0; + float LargestComponentShare = 0.f; + float WalkableFraction = 0.f; + float MedianFeatureScale = 0.f; + int32 MedianVerticalClearance = 0; +}; + +/** + * Measure one strate in actor-space voxel coordinates. + * + * The pass samples only the interior chunks of the requested strate, flood-fills its air once, + * and derives all returned metrics from that same grid. It never mutates either input object. + */ +VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrate( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVoxelStrateMeasureSettings& Settings); + +/** + * Test coarse connectivity and then re-check the one recovered coarse route at full resolution. + * + * bOutCoarseLied is true only when the coarse route existed but a full-resolution sample on that + * route was solid (or non-finite), in which case this function always returns false. + */ +VOXELFORGE_API bool VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutCoarseLied);