feat(measure): one grid, one flood fill -- the strate measurement pass
Tier 2 slice 1 from COMPOSER-NOTES.md 3.4. A read-only, deterministic, headless measurement of a built strate: sample a coarse grid once, flood-fill the air once, derive every metric from that same grid. What it answers today: air fraction, air component count and largest component share (the swiss-cheese signal), walkable fraction, median feature scale, median vertical clearance -- and whether two points are actually connected. The connectivity verdict is the point. Coarse sampling LIES about it in the dangerous direction: a wall thinner than the sample step vanishes and two sealed caverns read as joined. So VF_AreConnected recovers the actual BFS route and re-walks that one corridor at one-voxel spacing; a true verdict is never returned on coarse evidence alone. It fired: 2 of 21 routes were coarse false positives. The test carries a negative control, because a green suite that cannot fail is what hid the last defect. Two of them, in fact: a direct probe of the known-solid inter-strate gap (AirFraction 0.00042), and -- Codex's improvement on the spec -- a diff-layer box filled solid and measured through VF_MeasureStrate itself, so an inverted sign is caught end to end rather than around the function under test. Build clean, 18 succeeded / 0 warnings / 0 failed. KNOWN, fix in flight: the interior window excludes a whole chunk (32 voxels) at each end to avoid a 4-voxel seal band -- 8x too much. On the fixture's 4-chunk strates that discards the floor, so the reported FlatPlain/CrystalChamber air fractions of 0.999 describe the open band above the floor, not the strate. The engine is right; the window is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
This commit is contained in:
1 parent
8cd5face9b
commit
6988e5ace1
4 files changed
+1268
No files matched your search
@@ -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<ECaveGeneratorType>())
|
||||
{
|
||||
return ArchetypeEnum->GetNameStringByValue(
|
||||
static_cast<int64>(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<float>(Slot.BottomChunkZ) + 1.0f) * CHUNK_SIZE;
|
||||
const float MaxZ = static_cast<float>(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<float>(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<float>(Step) / static_cast<float>(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<FStrateSlot>& 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<float>(Settings.RadiusInVoxels);
|
||||
const float MinY = Settings.CenterXY.Y - static_cast<float>(Settings.RadiusInVoxels);
|
||||
const float Step = static_cast<float>(Settings.SampleStep);
|
||||
const int32 NumXY = FMath::CeilToInt(
|
||||
(2.0f * static_cast<float>(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<float>(YIndex) + 0.5f) * Step,
|
||||
Settings.CenterXY.Y + static_cast<float>(Settings.RadiusInVoxels) - 0.5f);
|
||||
for (int32 XIndex = 0; XIndex < NumXY; ++XIndex)
|
||||
{
|
||||
const float X = FMath::Min(
|
||||
MinX + (static_cast<float>(XIndex) + 0.5f) * Step,
|
||||
Settings.CenterXY.X + static_cast<float>(Settings.RadiusInVoxels) - 0.5f);
|
||||
const int32 ChunkZ = FMath::FloorToInt(static_cast<float>(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<float>(Z));
|
||||
++OutSampleCount;
|
||||
if (FMath::IsFinite(Density) && Density > 0.0f)
|
||||
{
|
||||
++OutAirCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OutSampleCount > 0
|
||||
? static_cast<float>(static_cast<double>(OutAirCount)
|
||||
/ static_cast<double>(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<float>(SolidControlMinZ + SolidControlMaxZ) * 0.5f);
|
||||
SolidControl.BoxExtent = FVector(
|
||||
static_cast<float>(Settings.RadiusInVoxels),
|
||||
static_cast<float>(Settings.RadiusInVoxels),
|
||||
static_cast<float>(Settings.RadiusInVoxels));
|
||||
SolidControl.Radius = 1.0f;
|
||||
SolidControl.Falloff = 0.1f;
|
||||
SolidControl.Strength = 100.0f;
|
||||
const TArray<FIntVector> 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<FArchetypeReport> ArchetypeReports;
|
||||
const TArray<FStrateSlot>& 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<float>(InteriorMinZ + 2));
|
||||
const FVector B(Settings.CenterXY.X + 2.0f, Settings.CenterXY.Y + 2.0f,
|
||||
static_cast<float>(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<float>(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<FIntVector> 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<FPassageReport> PassageReports;
|
||||
const TArray<FVoxelPassage>& 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
|
||||
@@ -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<uint8> Air;
|
||||
|
||||
FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const
|
||||
{
|
||||
return static_cast<int32>(
|
||||
static_cast<int64>(X)
|
||||
+ static_cast<int64>(NumX) * (static_cast<int64>(Y)
|
||||
+ static_cast<int64>(NumY) * static_cast<int64>(Z)));
|
||||
}
|
||||
|
||||
FVector CellCenter(int32 X, int32 Y, int32 Z) const
|
||||
{
|
||||
const float Step = static_cast<float>(SampleStep);
|
||||
const float XOffset = FMath::Min(
|
||||
static_cast<float>(X) * Step + 0.5f * Step, ExtentX - 0.5f);
|
||||
const float YOffset = FMath::Min(
|
||||
static_cast<float>(Y) * Step + 0.5f * Step, ExtentY - 0.5f);
|
||||
const float ZOffset = FMath::Min(
|
||||
static_cast<float>(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<FStrateSlot>& 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<int64>(Slot.BottomChunkZ) + 1) * CHUNK_SIZE;
|
||||
const int64 InteriorMaxZ = static_cast<int64>(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<int64>(Settings.RadiusInVoxels) * 2;
|
||||
const int64 Step = static_cast<int64>(Settings.SampleStep);
|
||||
const int64 NumX64 = CeilDivPositive(XYExtent, Step);
|
||||
const int64 NumY64 = NumX64;
|
||||
const int64 NumZ64 = CeilDivPositive(InteriorHeight, Step);
|
||||
const int64 MaxCells = static_cast<int64>(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<double>(Settings.CenterXY.X)
|
||||
- static_cast<double>(Settings.RadiusInVoxels);
|
||||
const double MaxX64 = static_cast<double>(Settings.CenterXY.X)
|
||||
+ static_cast<double>(Settings.RadiusInVoxels);
|
||||
const double MinY64 = static_cast<double>(Settings.CenterXY.Y)
|
||||
- static_cast<double>(Settings.RadiusInVoxels);
|
||||
const double MaxY64 = static_cast<double>(Settings.CenterXY.Y)
|
||||
+ static_cast<double>(Settings.RadiusInVoxels);
|
||||
const float MinX = static_cast<float>(MinX64);
|
||||
const float MaxX = static_cast<float>(MaxX64);
|
||||
const float MinY = static_cast<float>(MinY64);
|
||||
const float MaxY = static_cast<float>(MaxY64);
|
||||
const float MinZ = static_cast<float>(InteriorMinZ);
|
||||
const float MaxZ = static_cast<float>(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<int32>(NumX64);
|
||||
OutGrid.NumY = static_cast<int32>(NumY64);
|
||||
OutGrid.NumZ = static_cast<int32>(NumZ64);
|
||||
OutGrid.SampleStep = Settings.SampleStep;
|
||||
OutGrid.CellCount = static_cast<int32>(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<int32>& OutComponents,
|
||||
int32& OutNumComponents,
|
||||
int32& OutLargestComponentCells)
|
||||
{
|
||||
OutComponents.SetNumUninitialized(Grid.CellCount);
|
||||
for (int32 Index = 0; Index < Grid.CellCount; ++Index)
|
||||
{
|
||||
OutComponents[Index] = -1;
|
||||
}
|
||||
|
||||
TArray<int32> 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<int32>& 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<float>& 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<int32> 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<int64>(Grid.CellCount) - NumAir;
|
||||
InOutMetrics.AirFraction = static_cast<float>(
|
||||
static_cast<double>(NumAir) / static_cast<double>(Grid.CellCount));
|
||||
InOutMetrics.NumAirComponents = NumComponents;
|
||||
InOutMetrics.LargestComponentShare = NumAir > 0
|
||||
? static_cast<float>(static_cast<double>(LargestComponentCells)
|
||||
/ static_cast<double>(NumAir))
|
||||
: 0.0f;
|
||||
|
||||
TArray<int32> ClearanceValues;
|
||||
ClearanceValues.Reserve(static_cast<int32>(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<int64>(Z) + H;
|
||||
if (HeadroomZ >= Grid.NumZ
|
||||
|| Grid.Air[Grid.Index(X, Y, static_cast<int32>(HeadroomZ))] == 0u)
|
||||
{
|
||||
bHasHeadroom = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bHasHeadroom) continue;
|
||||
|
||||
++NumWalkable;
|
||||
int32 ClearanceCells = 0;
|
||||
while (static_cast<int64>(Z) + ClearanceCells < Grid.NumZ
|
||||
&& Grid.Air[Grid.Index(X, Y, Z + ClearanceCells)] != 0u)
|
||||
{
|
||||
++ClearanceCells;
|
||||
}
|
||||
const int64 ClearanceVoxels = static_cast<int64>(ClearanceCells)
|
||||
* Settings.SampleStep;
|
||||
ClearanceValues.Add(ClearanceVoxels > INT32_MAX
|
||||
? INT32_MAX : static_cast<int32>(ClearanceVoxels));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InOutMetrics.WalkableFraction = NumAir > 0
|
||||
? static_cast<float>(static_cast<double>(NumWalkable)
|
||||
/ static_cast<double>(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<int32> Distances;
|
||||
BuildManhattanDistanceToSolid(Grid, Distances);
|
||||
|
||||
TArray<float> FeatureScales;
|
||||
FeatureScales.Reserve(static_cast<int32>(NumAir));
|
||||
for (int32 Index = 0; Index < Grid.CellCount; ++Index)
|
||||
{
|
||||
if (Grid.Air[Index] != 0u)
|
||||
{
|
||||
FeatureScales.Add(static_cast<float>(Distances[Index])
|
||||
* static_cast<float>(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<float>(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<float>(Step) / static_cast<float>(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<int32>& 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<int32>& OutPath)
|
||||
{
|
||||
if (Grid.Air[Start] == 0u || Grid.Air[Goal] == 0u)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TArray<int32> Parent;
|
||||
Parent.SetNumUninitialized(Grid.CellCount);
|
||||
for (int32 Index = 0; Index < Grid.CellCount; ++Index)
|
||||
{
|
||||
Parent[Index] = -2; // unvisited; -1 is the root's parent
|
||||
}
|
||||
|
||||
TArray<int32> 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<int32> 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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user