diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeDescentCorridorTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeDescentCorridorTest.cpp new file mode 100644 index 0000000..511b974 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeDescentCorridorTest.cpp @@ -0,0 +1,1177 @@ +// Samples the authored A-to-B passage as a body-sized walking corridor in the final owner field. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "CoreMinimal.h" +#include "Async/ParallelFor.h" +#include "Misc/AutomationTest.h" +#include "Misc/Crc.h" +#include "UObject/Package.h" +#include "UObject/StrongObjectPtr.h" +#include "UObject/UObjectGlobals.h" + +#include "VoxelDiffLayer.h" +#include "VoxelDensityAblation.h" +#include "VoxelGenerator.h" +#include "VoxelCaveMorphology.h" +#include "VoxelPassageGeometry.h" +#include "VoxelSettings.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" +#include "VoxelTerrainOpDefinition.h" + +namespace +{ + struct FOwnerPassageWorld + { + TStrongObjectPtr Settings; + TStrongObjectPtr Manager; + TStrongObjectPtr DiffLayer; + TStrongObjectPtr Generator; + TArray> Definitions; + + bool Build(int32 Seed, FString& OutError) + { + UVoxelSettings* AuthoredSettings = LoadObject( + nullptr, TEXT("/Game/VoxelForge/DA_Settings.DA_Settings")); + if (AuthoredSettings == nullptr) + { + OutError = TEXT("Could not load owner asset /Game/VoxelForge/DA_Settings.DA_Settings"); + return false; + } + + Settings = TStrongObjectPtr(DuplicateObject( + AuthoredSettings, GetTransientPackage())); + if (!Settings.IsValid()) + { + OutError = TEXT("Could not duplicate owner settings into the transient package"); + return false; + } + + Settings->Seed = Seed; + Settings->Season.Reset(); + Settings->StratePool.Reset(); + Settings->FixedStrates.Reset(); + for (int32 Index = 0; Index < 4; ++Index) + { + const FString AssetPath = FString::Printf( + TEXT("/Game/VoxelForge/DA_Strate%d.DA_Strate%d"), Index + 1, Index + 1); + UVoxelStrateDefinition* Definition = LoadObject( + nullptr, *AssetPath); + if (Definition == nullptr) + { + OutError = FString::Printf(TEXT("Could not load owner asset %s"), *AssetPath); + return false; + } + Definitions.Add(TStrongObjectPtr(Definition)); + Settings->FixedStrates.Add(Index, TSoftObjectPtr(Definition)); + } + Settings->TotalStrates = Definitions.Num(); + + Manager = TStrongObjectPtr( + NewObject(GetTransientPackage())); + if (!Manager.IsValid() || !Manager->Initialize(Settings.Get(), Seed)) + { + OutError = FString::Printf(TEXT("Owner strate manager initialization failed for seed %d"), Seed); + return false; + } + + DiffLayer = TStrongObjectPtr( + NewObject(GetTransientPackage())); + Generator = TStrongObjectPtr( + NewObject(GetTransientPackage())); + if (!DiffLayer.IsValid() || !Generator.IsValid()) + { + OutError = TEXT("Could not create the owner field generator or diff layer"); + return false; + } + Generator->InitializeSettings(Settings.Get()); + Generator->SetStrateManager(Manager.Get()); + Generator->SetDiffLayer(DiffLayer.Get()); + return true; + } + }; + + struct FWalkCorridorSamples + { + TArray Positions; + TArray Densities; + TArray SampleStations; + TArray SampleLaterals; + TArray SampleHeights; + int32 SolidSamples = 0; + int32 BlockingSolidSamples = 0; + int32 SolidStations = 0; + int32 BlockingSolidStations = 0; + int32 FloorStations = 0; + int32 MissingFloorStations = 0; + int32 MaxMissingFloorRunStations = 0; + FVector FirstMissingFloorPosition = FVector::ZeroVector; + float FirstMissingFloorExpectedZ = 0.0f; + int32 FirstMissingFloorStation = INDEX_NONE; + int32 WidthFloorGaps = 0; + int32 StepViolations = 0; + int32 FloorProfileViolations = 0; + float MaxLateralFloorDeltaVoxels = 0.0f; + float MaxFloorProfileDeviationVoxels = 0.0f; + TArray SolidSamplesByHeight; + float MaxStepVoxels = 0.0f; + FVector FirstStepStart = FVector::ZeroVector; + FVector FirstStepEnd = FVector::ZeroVector; + float FirstStepExpectedStartZ = 0.0f; + float FirstStepExpectedEndZ = 0.0f; + FVector FirstSolidPosition = FVector::ZeroVector; + float FirstSolidDensity = 0.0f; + float FirstSolidMeasuredFloorZ = 0.0f; + float FirstSolidExpectedFloorZ = 0.0f; + int32 FirstSolidStation = INDEX_NONE; + int32 FirstSolidLateralIndex = INDEX_NONE; + int32 FirstSolidHeightIndex = INDEX_NONE; + FVector FirstTallSolidPosition = FVector::ZeroVector; + float FirstTallSolidDensity = 0.0f; + int32 FirstTallSolidStation = INDEX_NONE; + int32 FirstTallSolidLateralIndex = INDEX_NONE; + int32 FirstTallSolidHeightIndex = INDEX_NONE; + FVector FirstBlockingSolidPosition = FVector::ZeroVector; + FVector FirstBlockingFeetPosition = FVector::ZeroVector; + float FirstBlockingSolidDensity = 0.0f; + int32 FirstBlockingSolidStation = INDEX_NONE; + int32 FirstBlockingSolidLateralIndex = INDEX_NONE; + int32 FirstBlockingSolidHeightIndex = INDEX_NONE; + uint32 PositionHash = 0; + uint32 DensityHash = 0; + }; + + bool BuildWalkCorridorSamples( + const FOwnerPassageWorld& World, + int32 Seed, + FWalkCorridorSamples& OutSamples, + FString& OutError) + { + const TArray& Layout = World.Manager->GetLayout(); + const FVoxelPassage* Passage = nullptr; + for (const FVoxelPassage& Candidate : World.Manager->GetPassages()) + { + if (Candidate.bWalkableTunnelContract + && Candidate.UpperStrateIndex == 0 + && Candidate.LowerStrateIndex == 1 + && Candidate.ControlPoints.Num() >= 2 + && Candidate.ControlRadii.Num() == Candidate.ControlPoints.Num() + && Candidate.NativeFloorProfileZ.Num() == Candidate.ControlPoints.Num()) + { + Passage = &Candidate; + break; + } + } + if (Passage == nullptr || !Layout.IsValidIndex(0) || !Layout.IsValidIndex(1)) + { + OutError = FString::Printf(TEXT("No usable owner A-to-B walkable passage for seed %d"), Seed); + return false; + } + + // The construction-time floor profile locates the expected route. The actual support + // height is traced from final density below, so a walkable floor bump is not misreported + // as a pillar intersecting a body held at an obsolete profile height. + TArray FloorRoute; + FloorRoute.Reserve(Passage->ControlPoints.Num() + 4); + FloorRoute.Add(FVector( + Passage->UpperLanding.StandingPoint.X, + Passage->UpperLanding.StandingPoint.Y, + Passage->UpperLanding.FloorZ)); + FloorRoute.Add(FVector( + Passage->UpperLanding.DoorPoint.X, + Passage->UpperLanding.DoorPoint.Y, + Passage->UpperLanding.FloorZ)); + for (int32 Index = 0; Index < Passage->ControlPoints.Num(); ++Index) + { + const FVector& Control = Passage->ControlPoints[Index]; + FloorRoute.Add(FVector( + Control.X, Control.Y, Passage->NativeFloorProfileZ[Index])); + } + FloorRoute.Add(FVector( + Passage->LowerLanding.DoorPoint.X, + Passage->LowerLanding.DoorPoint.Y, + Passage->LowerLanding.FloorZ)); + FloorRoute.Add(FVector( + Passage->LowerLanding.StandingPoint.X, + Passage->LowerLanding.StandingPoint.Y, + Passage->LowerLanding.FloorZ)); + + constexpr float MaxStationSpacing = 0.5f; + constexpr float PlayerWidthVoxels = + VoxelPassageGeometry::WalkCorridorPlayerWidthVoxels; + constexpr float PlayerHalfWidthVoxels = PlayerWidthVoxels * 0.5f; + constexpr float PlayerHeightVoxels = + VoxelPassageGeometry::WalkCorridorPlayerHeightVoxels; + constexpr int32 NumLateralSamples = 5; + constexpr int32 CenterLateralIndex = NumLateralSamples / 2; + constexpr float LateralSampleSpacing = + PlayerWidthVoxels / static_cast(NumLateralSamples - 1); + constexpr int32 NumVerticalSamples = 14; + constexpr int32 FloorProbeIntervals = 128; + constexpr float FloorProbeHalfRange = 32.0f; + constexpr float FloorProbeStep = + (2.0f * FloorProbeHalfRange) / static_cast(FloorProbeIntervals); + constexpr float FirstBodySampleZ = 0.25f; + constexpr float BodySampleSpacing = 0.5f; + TArray FeetStations; + TArray LateralAxes; + TArray ExpectedFloorZ; + for (int32 SegmentIndex = 0; SegmentIndex + 1 < FloorRoute.Num(); ++SegmentIndex) + { + const FVector Start = FloorRoute[SegmentIndex]; + const FVector End = FloorRoute[SegmentIndex + 1]; + const FVector Delta = End - Start; + const float SegmentLength = Delta.Size(); + if (!(SegmentLength > KINDA_SMALL_NUMBER)) + { + continue; + } + FVector Lateral(-Delta.Y, Delta.X, 0.0f); + if (!Lateral.Normalize()) + { + // A vertical segment has no horizontal travel axis. Keep a fixed perpendicular + // plane there; the adjacent non-vertical segment samples the turn itself. + Lateral = FVector(1.0f, 0.0f, 0.0f); + } + const int32 NumIntervals = FMath::Max( + 1, FMath::CeilToInt(SegmentLength / MaxStationSpacing)); + for (int32 Station = 0; Station <= NumIntervals; ++Station) + { + if (SegmentIndex > 0 && Station == 0) + { + continue; + } + const float Alpha = static_cast(Station) + / static_cast(NumIntervals); + const FVector Feet = FMath::Lerp(Start, End, Alpha); + FeetStations.Add(Feet); + LateralAxes.Add(Lateral); + ExpectedFloorZ.Add(Feet.Z); + } + } + + if (FeetStations.IsEmpty()) + { + OutError = FString::Printf(TEXT("Owner A-to-B corridor was empty for seed %d"), Seed); + return false; + } + + TArray FloorProbePositions; + FloorProbePositions.Reserve( + FeetStations.Num() * NumLateralSamples * (FloorProbeIntervals + 1)); + for (int32 StationIndex = 0; StationIndex < FeetStations.Num(); ++StationIndex) + { + for (int32 LateralIndex = 0; LateralIndex < NumLateralSamples; ++LateralIndex) + { + const float Side = -PlayerHalfWidthVoxels + + static_cast(LateralIndex) * LateralSampleSpacing; + const FVector LateralPosition = FeetStations[StationIndex] + + LateralAxes[StationIndex] * Side; + for (int32 ProbeIndex = 0; ProbeIndex <= FloorProbeIntervals; ++ProbeIndex) + { + const float Offset = -FloorProbeHalfRange + + static_cast(ProbeIndex) * FloorProbeStep; + FloorProbePositions.Add( + LateralPosition + FVector(0.0f, 0.0f, Offset)); + } + } + } + TArray FloorProbeDensities; + FloorProbeDensities.SetNumUninitialized(FloorProbePositions.Num()); + ParallelFor(FloorProbePositions.Num(), [&](int32 ProbeIndex) + { + const FVector& Position = FloorProbePositions[ProbeIndex]; + FloorProbeDensities[ProbeIndex] = World.Generator->GetDensityAt( + Position.X, Position.Y, Position.Z); + }); + + TArray MeasuredFloorZ; + MeasuredFloorZ.SetNumUninitialized(FeetStations.Num() * NumLateralSamples); + TArray bHasMeasuredFloor; + bHasMeasuredFloor.Init(0, FeetStations.Num() * NumLateralSamples); + OutSamples.FloorStations = 0; + OutSamples.MissingFloorStations = 0; + OutSamples.WidthFloorGaps = 0; + OutSamples.FloorProfileViolations = 0; + OutSamples.MaxLateralFloorDeltaVoxels = 0.0f; + OutSamples.MaxFloorProfileDeviationVoxels = 0.0f; + OutSamples.StepViolations = 0; + OutSamples.MaxMissingFloorRunStations = 0; + OutSamples.MaxStepVoxels = 0.0f; + OutSamples.FirstStepStart = FVector::ZeroVector; + OutSamples.FirstStepEnd = FVector::ZeroVector; + OutSamples.FirstStepExpectedStartZ = 0.0f; + OutSamples.FirstStepExpectedEndZ = 0.0f; + int32 CurrentMissingFloorRunStations = 0; + for (int32 StationIndex = 0; StationIndex < FeetStations.Num(); ++StationIndex) + { + for (int32 LateralIndex = 0; LateralIndex < NumLateralSamples; ++LateralIndex) + { + const int32 FloorIndex = StationIndex * NumLateralSamples + LateralIndex; + const int32 ProbeBase = FloorIndex * (FloorProbeIntervals + 1); + float BestDistance = FLT_MAX; + float BestFloor = ExpectedFloorZ[StationIndex]; + for (int32 ProbeIndex = 0; ProbeIndex < FloorProbeIntervals; ++ProbeIndex) + { + const float BelowDensity = FloorProbeDensities[ProbeBase + ProbeIndex]; + const float AboveDensity = FloorProbeDensities[ProbeBase + ProbeIndex + 1]; + if (!FMath::IsFinite(BelowDensity) || !FMath::IsFinite(AboveDensity)) + { + OutError = FString::Printf( + TEXT("Non-finite floor probe density at seed %d station %d lateral %d"), + Seed, StationIndex, LateralIndex); + return false; + } + if (!(BelowDensity < 0.0f) || !(AboveDensity >= 0.0f)) + { + continue; + } + const float Denominator = AboveDensity - BelowDensity; + const float Alpha = Denominator > 0.0f + ? FMath::Clamp(-BelowDensity / Denominator, 0.0f, 1.0f) + : 0.0f; + const float CrossingZ = FeetStations[StationIndex].Z + - FloorProbeHalfRange + + (static_cast(ProbeIndex) + Alpha) * FloorProbeStep; + const float Distance = FMath::Abs( + CrossingZ - ExpectedFloorZ[StationIndex]); + if (Distance < BestDistance) + { + BestDistance = Distance; + BestFloor = CrossingZ; + } + } + + MeasuredFloorZ[FloorIndex] = BestFloor; + if (BestDistance != FLT_MAX) + { + bHasMeasuredFloor[FloorIndex] = 1; + if (LateralIndex == CenterLateralIndex) + { + ++OutSamples.FloorStations; + const float ProfileDeviation = FMath::Abs( + BestFloor - ExpectedFloorZ[StationIndex]); + OutSamples.MaxFloorProfileDeviationVoxels = FMath::Max( + OutSamples.MaxFloorProfileDeviationVoxels, ProfileDeviation); + if (ProfileDeviation > 1.8f + KINDA_SMALL_NUMBER) + { + ++OutSamples.FloorProfileViolations; + } + } + } + else if (LateralIndex == CenterLateralIndex) + { + ++OutSamples.MissingFloorStations; + if (OutSamples.FirstMissingFloorStation == INDEX_NONE) + { + OutSamples.FirstMissingFloorStation = StationIndex; + OutSamples.FirstMissingFloorPosition = FeetStations[StationIndex]; + OutSamples.FirstMissingFloorExpectedZ = ExpectedFloorZ[StationIndex]; + } + ++CurrentMissingFloorRunStations; + OutSamples.MaxMissingFloorRunStations = FMath::Max( + OutSamples.MaxMissingFloorRunStations, + CurrentMissingFloorRunStations); + } + else + { + ++OutSamples.WidthFloorGaps; + } + } + + const int32 CenterFloorIndex = + StationIndex * NumLateralSamples + CenterLateralIndex; + if (bHasMeasuredFloor[CenterFloorIndex]) + { + CurrentMissingFloorRunStations = 0; + } + for (int32 LateralIndex : { 0, NumLateralSamples - 1 }) + { + const int32 SideFloorIndex = StationIndex * NumLateralSamples + LateralIndex; + if (bHasMeasuredFloor[CenterFloorIndex] + && bHasMeasuredFloor[SideFloorIndex]) + { + OutSamples.MaxLateralFloorDeltaVoxels = FMath::Max( + OutSamples.MaxLateralFloorDeltaVoxels, + FMath::Abs( + MeasuredFloorZ[SideFloorIndex] + - MeasuredFloorZ[CenterFloorIndex])); + } + } + + if (StationIndex > 0 + && bHasMeasuredFloor[CenterFloorIndex] + && bHasMeasuredFloor[ + (StationIndex - 1) * NumLateralSamples + CenterLateralIndex]) + { + const int32 PreviousCenterFloorIndex = + (StationIndex - 1) * NumLateralSamples + CenterLateralIndex; + const float Step = FMath::Abs( + MeasuredFloorZ[CenterFloorIndex] + - MeasuredFloorZ[PreviousCenterFloorIndex]); + OutSamples.MaxStepVoxels = FMath::Max(OutSamples.MaxStepVoxels, Step); + if (Step > 1.8f + KINDA_SMALL_NUMBER) + { + if (OutSamples.StepViolations == 0) + { + OutSamples.FirstStepStart = FVector( + FeetStations[StationIndex - 1].X, + FeetStations[StationIndex - 1].Y, + MeasuredFloorZ[PreviousCenterFloorIndex]); + OutSamples.FirstStepEnd = FVector( + FeetStations[StationIndex].X, + FeetStations[StationIndex].Y, + MeasuredFloorZ[CenterFloorIndex]); + OutSamples.FirstStepExpectedStartZ = + ExpectedFloorZ[StationIndex - 1]; + OutSamples.FirstStepExpectedEndZ = + ExpectedFloorZ[StationIndex]; + } + ++OutSamples.StepViolations; + } + } + } + + if (Seed == 0 && OutSamples.FirstMissingFloorStation != INDEX_NONE) + { + constexpr float GapProfileMinOffset = -360.0f; + constexpr float GapProfileMaxOffset = 8.0f; + constexpr float GapProfileStep = 0.5f; + constexpr int32 GapProfileSamples = 737; + TArray GapProfileDensities; + GapProfileDensities.SetNumUninitialized(GapProfileSamples); + const FVector& GapPosition = OutSamples.FirstMissingFloorPosition; + const float GapExpectedZ = OutSamples.FirstMissingFloorExpectedZ; + ParallelFor(GapProfileSamples, [&](int32 ProfileIndex) + { + const float Offset = GapProfileMinOffset + + static_cast(ProfileIndex) * GapProfileStep; + GapProfileDensities[ProfileIndex] = World.Generator->GetDensityAt( + GapPosition.X, GapPosition.Y, GapExpectedZ + Offset); + }); + TArray GapFloorCrossingOffsets; + for (int32 ProfileIndex = 0; + ProfileIndex + 1 < GapProfileDensities.Num(); + ++ProfileIndex) + { + const float BelowDensity = GapProfileDensities[ProfileIndex]; + const float AboveDensity = GapProfileDensities[ProfileIndex + 1]; + if (BelowDensity < 0.0f && AboveDensity >= 0.0f) + { + const float Alpha = -BelowDensity / (AboveDensity - BelowDensity); + GapFloorCrossingOffsets.Add( + GapProfileMinOffset + + (static_cast(ProfileIndex) + Alpha) * GapProfileStep); + } + } + const float DensityAtMinus34093 = World.Generator->GetDensityAt( + GapPosition.X, GapPosition.Y, GapExpectedZ - 340.93f); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDescentGapProfile] seed=0 station=%d position=(%.3f,%.3f,%.3f) " + "expected_z=%.3f density_at_minus340_93=%.6f floor_crossings_from_expected=[%s]"), + OutSamples.FirstMissingFloorStation, + GapPosition.X, GapPosition.Y, GapExpectedZ, + GapExpectedZ, + DensityAtMinus34093, + *FString::JoinBy(GapFloorCrossingOffsets, TEXT(","), + [](float Offset) { return FString::Printf(TEXT("%.3f"), Offset); })); + } + + OutSamples.Positions.Reset(FeetStations.Num() * NumLateralSamples * NumVerticalSamples); + OutSamples.SampleStations.Reset( + FeetStations.Num() * NumLateralSamples * NumVerticalSamples); + OutSamples.SampleLaterals.Reset( + FeetStations.Num() * NumLateralSamples * NumVerticalSamples); + OutSamples.SampleHeights.Reset( + FeetStations.Num() * NumLateralSamples * NumVerticalSamples); + for (int32 StationIndex = 0; StationIndex < FeetStations.Num(); ++StationIndex) + { + const FVector& Lateral = LateralAxes[StationIndex]; + for (int32 LateralIndex = 0; LateralIndex < NumLateralSamples; ++LateralIndex) + { + FVector Feet = FeetStations[StationIndex]; + // Anchor the upright player-sized stencil to the measured centerline floor. A + // nearby raised edge can be stepped over up to the contract's 1.8-voxel limit. + const int32 CenterFloorIndex = + StationIndex * NumLateralSamples + CenterLateralIndex; + Feet.Z = bHasMeasuredFloor[CenterFloorIndex] + ? MeasuredFloorZ[CenterFloorIndex] + : ExpectedFloorZ[StationIndex]; + for (int32 VerticalIndex = 0; VerticalIndex < NumVerticalSamples; ++VerticalIndex) + { + const float Height = FirstBodySampleZ + + static_cast(VerticalIndex) * BodySampleSpacing; + const float DistanceFromCapCenter = Height < PlayerHalfWidthVoxels + ? PlayerHalfWidthVoxels - Height + : (Height > PlayerHeightVoxels + - PlayerHalfWidthVoxels + ? Height - (PlayerHeightVoxels + - PlayerHalfWidthVoxels) + : 0.0f); + const float SliceRadius = FMath::Sqrt(FMath::Max( + FMath::Square(PlayerHalfWidthVoxels) + - FMath::Square(DistanceFromCapCenter), + 0.0f)); + const float BodySide = SliceRadius + * (static_cast(LateralIndex) / 2.0f - 1.0f); + OutSamples.Positions.Add( + Feet + Lateral * BodySide + FVector(0.0f, 0.0f, Height)); + OutSamples.SampleStations.Add(StationIndex); + OutSamples.SampleLaterals.Add(LateralIndex); + OutSamples.SampleHeights.Add(VerticalIndex); + } + } + } + + OutSamples.Densities.SetNumUninitialized(OutSamples.Positions.Num()); + ParallelFor(OutSamples.Positions.Num(), [&](int32 SampleIndex) + { + const FVector& Position = OutSamples.Positions[SampleIndex]; + OutSamples.Densities[SampleIndex] = World.Generator->GetDensityAt( + Position.X, Position.Y, Position.Z); + }); + + OutSamples.SolidSamples = 0; + OutSamples.BlockingSolidSamples = 0; + OutSamples.SolidStations = 0; + OutSamples.BlockingSolidStations = 0; + OutSamples.SolidSamplesByHeight.Init(0, NumVerticalSamples); + OutSamples.FirstSolidPosition = FVector::ZeroVector; + OutSamples.FirstSolidDensity = 0.0f; + OutSamples.FirstSolidStation = INDEX_NONE; + OutSamples.FirstSolidLateralIndex = INDEX_NONE; + OutSamples.FirstSolidHeightIndex = INDEX_NONE; + OutSamples.FirstTallSolidPosition = FVector::ZeroVector; + OutSamples.FirstTallSolidDensity = 0.0f; + OutSamples.FirstTallSolidStation = INDEX_NONE; + OutSamples.FirstTallSolidLateralIndex = INDEX_NONE; + OutSamples.FirstTallSolidHeightIndex = INDEX_NONE; + OutSamples.FirstBlockingSolidPosition = FVector::ZeroVector; + OutSamples.FirstBlockingSolidDensity = 0.0f; + OutSamples.FirstBlockingSolidStation = INDEX_NONE; + OutSamples.FirstBlockingSolidLateralIndex = INDEX_NONE; + OutSamples.FirstBlockingSolidHeightIndex = INDEX_NONE; + TSet SolidStationIds; + TSet BlockingSolidStationIds; + for (int32 SampleIndex = 0; SampleIndex < OutSamples.Densities.Num(); ++SampleIndex) + { + const float Density = OutSamples.Densities[SampleIndex]; + if (FMath::IsFinite(Density) && Density < 0.0f) + { + if (OutSamples.SolidSamples == 0) + { + OutSamples.FirstSolidPosition = OutSamples.Positions[SampleIndex]; + OutSamples.FirstSolidDensity = Density; + OutSamples.FirstSolidStation = OutSamples.SampleStations[SampleIndex]; + OutSamples.FirstSolidLateralIndex = OutSamples.SampleLaterals[SampleIndex]; + OutSamples.FirstSolidHeightIndex = OutSamples.SampleHeights[SampleIndex]; + OutSamples.FirstSolidMeasuredFloorZ = + MeasuredFloorZ[ + OutSamples.FirstSolidStation * NumLateralSamples + + OutSamples.FirstSolidLateralIndex]; + OutSamples.FirstSolidExpectedFloorZ = + ExpectedFloorZ[OutSamples.FirstSolidStation]; + } + if (OutSamples.FirstTallSolidStation == INDEX_NONE + && OutSamples.SampleHeights[SampleIndex] > 0) + { + OutSamples.FirstTallSolidPosition = OutSamples.Positions[SampleIndex]; + OutSamples.FirstTallSolidDensity = Density; + OutSamples.FirstTallSolidStation = OutSamples.SampleStations[SampleIndex]; + OutSamples.FirstTallSolidLateralIndex = OutSamples.SampleLaterals[SampleIndex]; + OutSamples.FirstTallSolidHeightIndex = OutSamples.SampleHeights[SampleIndex]; + } + ++OutSamples.SolidSamples; + ++OutSamples.SolidSamplesByHeight[SampleIndex % NumVerticalSamples]; + // Each station owns a fixed block of samples. The compact index is enough for an + // exact count of blocked positions without retaining another per-sample array. + SolidStationIds.Add(OutSamples.SampleStations[SampleIndex]); + const int32 StationIndex = OutSamples.SampleStations[SampleIndex]; + const int32 LateralIndex = OutSamples.SampleLaterals[SampleIndex]; + const int32 CenterFloorIndex = + StationIndex * NumLateralSamples + CenterLateralIndex; + const float SupportFloorZ = bHasMeasuredFloor[CenterFloorIndex] + ? MeasuredFloorZ[CenterFloorIndex] + : ExpectedFloorZ[StationIndex]; + if (OutSamples.Positions[SampleIndex].Z - SupportFloorZ + > 1.8f + KINDA_SMALL_NUMBER) + { + if (OutSamples.FirstBlockingSolidStation == INDEX_NONE) + { + OutSamples.FirstBlockingSolidPosition = OutSamples.Positions[SampleIndex]; + OutSamples.FirstBlockingFeetPosition = FeetStations[StationIndex]; + OutSamples.FirstBlockingFeetPosition.Z = SupportFloorZ; + OutSamples.FirstBlockingSolidDensity = Density; + OutSamples.FirstBlockingSolidStation = StationIndex; + OutSamples.FirstBlockingSolidLateralIndex = LateralIndex; + OutSamples.FirstBlockingSolidHeightIndex = + OutSamples.SampleHeights[SampleIndex]; + } + ++OutSamples.BlockingSolidSamples; + BlockingSolidStationIds.Add(StationIndex); + } + } + else if (!FMath::IsFinite(Density)) + { + OutError = FString::Printf( + TEXT("Non-finite owner density at seed %d sample %d"), Seed, SampleIndex); + return false; + } + } + OutSamples.SolidStations = SolidStationIds.Num(); + OutSamples.BlockingSolidStations = BlockingSolidStationIds.Num(); + OutSamples.PositionHash = FCrc::MemCrc32( + OutSamples.Positions.GetData(), OutSamples.Positions.Num() * sizeof(FVector)); + OutSamples.DensityHash = FCrc::MemCrc32( + OutSamples.Densities.GetData(), OutSamples.Densities.Num() * sizeof(float)); + return true; + } + + bool SampleOwnerSeed( + int32 Seed, + FWalkCorridorSamples& OutSamples, + FString& OutError) + { + FOwnerPassageWorld World; + if (!World.Build(Seed, OutError)) + { + return false; + } + if (!BuildWalkCorridorSamples(World, Seed, OutSamples, OutError)) + { + return false; + } + if (Seed == 0 && OutSamples.SolidSamples > 0) + { + const FIntVector ChunkCoord( + FMath::FloorToInt(OutSamples.FirstSolidPosition.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(OutSamples.FirstSolidPosition.Y / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(OutSamples.FirstSolidPosition.Z / static_cast(CHUNK_SIZE))); + const UVoxelStrateDefinition* Definition = World.Manager->GetStrateForChunk(ChunkCoord); + const FStrateGenerationParams Params = World.Manager->GetGenerationParams(ChunkCoord); + FString TerrainOps; + FString TerrainOpPaths; + if (Definition != nullptr) + { + for (const FStrateTerrainOpEntry& Entry : Definition->TerrainOperations) + { + TerrainOpPaths += FString::Printf(TEXT("%s "), *Entry.Operation.ToSoftObjectPath().ToString()); + const UVoxelTerrainOpDefinition* Operation = Entry.Operation.LoadSynchronous(); + if (Operation == nullptr) + { + TerrainOps += TEXT(" "); + continue; + } + const UEnum* OperationEnum = StaticEnum(); + const FString TypeName = OperationEnum != nullptr + ? OperationEnum->GetNameStringByValue(static_cast(Operation->Type)) + : FString::Printf(TEXT("Type_%d"), static_cast(Operation->Type)); + TerrainOps += FString::Printf( + TEXT("%s:%s@%.3f "), *Operation->GetName(), *TypeName, Entry.Weight); + } + } + const UEnum* GeneratorEnum = StaticEnum(); + const ECaveGeneratorType GeneratorType = + World.Manager->GetGeneratorTypeForChunk(ChunkCoord); + const FString GeneratorName = GeneratorEnum != nullptr + ? GeneratorEnum->GetNameStringByValue(static_cast(GeneratorType)) + : FString::Printf(TEXT("Type_%d"), static_cast(GeneratorType)); + const bool bUsesOperatorStack = World.Manager->UsesOperatorStackForChunk(ChunkCoord); + const float PassageSDF = World.Manager->EvaluateModifierSDF( + OutSamples.FirstSolidPosition.X, + OutSamples.FirstSolidPosition.Y, + OutSamples.FirstSolidPosition.Z); + float SimulatedCarvedInternalDensity = -OutSamples.FirstSolidDensity; + World.Manager->ApplyPassageCarvingOnly( + SimulatedCarvedInternalDensity, + OutSamples.FirstSolidPosition.X, + OutSamples.FirstSolidPosition.Y, + OutSamples.FirstSolidPosition.Z, + Params.BaseDensity, + Params.BoundarySealThickness); + const float SimulatedCarvedFinalDensity = -SimulatedCarvedInternalDensity; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDescentWriterContext] seed=0 sample=(%.3f,%.3f,%.3f) " + "chunk=(%d,%d,%d) archetype=%s asset_stack_flag=%d effective_op_stack=%d " + "passage_sdf=%.6f simulated_passage_carve_density=%.6f " + "terrain_ops='%s' terrain_op_paths='%s' " + "base=%.3f tunnel_columns=(%.3f,%.3f,%.3f) slab_columns=(%.3f,%.3f,%.3f)"), + OutSamples.FirstSolidPosition.X, OutSamples.FirstSolidPosition.Y, + OutSamples.FirstSolidPosition.Z, ChunkCoord.X, ChunkCoord.Y, ChunkCoord.Z, + *GeneratorName, Definition != nullptr && Definition->bUseOperatorStack ? 1 : 0, + bUsesOperatorStack ? 1 : 0, PassageSDF, SimulatedCarvedFinalDensity, + *TerrainOps, *TerrainOpPaths, Params.BaseDensity, Params.ColumnDensity, + Params.ColumnMinRadius, Params.ColumnMaxRadius, + Definition != nullptr ? Definition->SlabParams.ColumnDensity : -1.0f, + Definition != nullptr ? Definition->SlabParams.ColumnMinRadius : -1.0f, + Definition != nullptr ? Definition->SlabParams.ColumnMaxRadius : -1.0f); + } + + if (Seed == 0) + { + // Keep the old owner-world obstruction as a fixed witness when launching a + // process-level operator ablation. The ablation mask is intentionally latched once + // per process, so diagnostic runs must set it on the command line before startup. + const FVector BaselineWitness(2079.975f, -4457.087f, -1526.679f); + const FIntVector WitnessChunk( + FMath::FloorToInt(BaselineWitness.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(BaselineWitness.Y / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(BaselineWitness.Z / static_cast(CHUNK_SIZE))); + const FStrateGenerationParams WitnessParams = + World.Manager->GetGenerationParams(WitnessChunk); + const float WitnessDensity = World.Generator->GetDensityAt( + BaselineWitness.X, BaselineWitness.Y, BaselineWitness.Z); + const float WitnessPassageSDF = World.Manager->EvaluateModifierSDF( + BaselineWitness.X, BaselineWitness.Y, BaselineWitness.Z); + float WitnessCarvedInternalDensity = -WitnessDensity; + World.Manager->ApplyPassageCarvingOnly( + WitnessCarvedInternalDensity, + BaselineWitness.X, BaselineWitness.Y, BaselineWitness.Z, + WitnessParams.BaseDensity, WitnessParams.BoundarySealThickness); + const float WitnessSimulatedCarveDensity = -WitnessCarvedInternalDensity; + int32 WitnessPassageIndex = INDEX_NONE; + bool bWitnessPassageNativeFloor = false; + const TArray& Passages = World.Manager->GetPassages(); + for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex) + { + const FVoxelPassage& Candidate = Passages[PassageIndex]; + if (Candidate.bWalkableTunnelContract + && Candidate.UpperStrateIndex == 0 + && Candidate.LowerStrateIndex == 1 + && Candidate.ControlPoints.Num() >= 2) + { + WitnessPassageIndex = PassageIndex; + bWitnessPassageNativeFloor = Candidate.bNativeFloorEnabled; + break; + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDescentFixedWitness] seed=0 position=(%.3f,%.3f,%.3f) " + "final_density=%.6f passage_sdf=%.6f simulated_carve_density=%.6f " + "a_to_b_passage_index=%d native_floor_enabled=%d"), + BaselineWitness.X, BaselineWitness.Y, BaselineWitness.Z, + WitnessDensity, WitnessPassageSDF, WitnessSimulatedCarveDensity, + WitnessPassageIndex, bWitnessPassageNativeFloor ? 1 : 0); + + if (OutSamples.BlockingSolidSamples > 0) + { + const FVector Blocker = OutSamples.FirstBlockingSolidPosition; + float NativeFloorFromAir = 64.0f; + World.Manager->ApplyPassageNativeFloorMC( + NativeFloorFromAir, Blocker.X, Blocker.Y, Blocker.Z, + WitnessParams.BaseDensity); + float MinRoomSDF = FLT_MAX; + FString NearbySegments; + FString ControlPoints; + for (const FVoxelPassage& Candidate : Passages) + { + if (Candidate.UpperStrateIndex != 0 + || Candidate.LowerStrateIndex != 1 + || Candidate.NativeFloorProfileZ.Num() + != Candidate.ControlPoints.Num()) + { + continue; + } + MinRoomSDF = FMath::Min(MinRoomSDF, + FMath::Min( + VF_EvaluatePassageLandingSDF(Blocker, Candidate.UpperLanding), + VF_EvaluatePassageLandingSDF(Blocker, Candidate.LowerLanding))); + for (int32 ControlIndex = 0; + ControlIndex < Candidate.ControlPoints.Num(); + ++ControlIndex) + { + const FVector& Control = Candidate.ControlPoints[ControlIndex]; + ControlPoints += FString::Printf( + TEXT("cp%d=(%.1f,%.1f,%.1f;floor=%.1f,r=%.1f);"), + ControlIndex, Control.X, Control.Y, Control.Z, + Candidate.NativeFloorProfileZ[ControlIndex], + Candidate.ControlRadii.IsValidIndex(ControlIndex) + ? Candidate.ControlRadii[ControlIndex] : -1.0f); + } + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Candidate.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Candidate.ControlPoints[SegmentIndex]; + const FVector& B = Candidate.ControlPoints[SegmentIndex + 1]; + const float DX = B.X - A.X; + const float DY = B.Y - A.Y; + const float LengthSquared = DX * DX + DY * DY; + if (!(LengthSquared > KINDA_SMALL_NUMBER)) + { + continue; + } + const float T = FMath::Clamp( + ((Blocker.X - A.X) * DX + (Blocker.Y - A.Y) * DY) + / LengthSquared, + 0.0f, 1.0f); + const float ClosestX = A.X + DX * T; + const float ClosestY = A.Y + DY * T; + const float HorizontalDistance = FMath::Sqrt( + FMath::Square(Blocker.X - ClosestX) + + FMath::Square(Blocker.Y - ClosestY)); + if (HorizontalDistance <= 2.0f) + { + const float FloorZ = FMath::Lerp( + Candidate.NativeFloorProfileZ[SegmentIndex], + Candidate.NativeFloorProfileZ[SegmentIndex + 1], T); + NearbySegments += FString::Printf( + TEXT("seg%d:xy=%.4f,floor=%.3f,offset=%.3f;"), + SegmentIndex, HorizontalDistance, FloorZ, + Blocker.Z - FloorZ); + } + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDescentBlockerClearance] seed=0 blocker=(%.3f,%.3f,%.3f) " + "density=%.6f native_floor_from_air=%.3f min_room_sdf=%.3f segments='%s' controls='%s'"), + Blocker.X, Blocker.Y, Blocker.Z, + OutSamples.FirstBlockingSolidDensity, NativeFloorFromAir, + MinRoomSDF, *NearbySegments, *ControlPoints); + } + } + else if ((Seed == 28 || Seed == 44 || Seed == 60 + || Seed == 61 || Seed == 63) + && OutSamples.BlockingSolidSamples > 0) + { + const FVector Blocker = OutSamples.FirstBlockingSolidPosition; + const FIntVector ChunkCoord( + FMath::FloorToInt(Blocker.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(Blocker.Y / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(Blocker.Z / static_cast(CHUNK_SIZE))); + const FStrateGenerationParams Params = + World.Manager->GetGenerationParams(ChunkCoord); + const float FinalDensity = World.Generator->GetDensityAt( + Blocker.X, Blocker.Y, Blocker.Z); + const float PassageSDF = World.Manager->EvaluateModifierSDF( + Blocker.X, Blocker.Y, Blocker.Z); + float NativeFloorFromAir = 64.0f; + World.Manager->ApplyPassageNativeFloorMC( + NativeFloorFromAir, Blocker.X, Blocker.Y, Blocker.Z, + Params.BaseDensity); + float PassagePostsFromAir = 64.0f; + World.Manager->ApplyPassageStructuralPostsMC( + PassagePostsFromAir, Blocker.X, Blocker.Y, Blocker.Z, + Params.BaseDensity, Params.BoundarySealThickness); + float LandingFloorFromAir = 64.0f; + World.Manager->ApplyPassageLandingFloorMC( + LandingFloorFromAir, Blocker.X, Blocker.Y, Blocker.Z, + Params.BaseDensity); + float RoomFloorFromAir = 64.0f; + World.Manager->ApplyPassageLandingRoomFloorMC( + RoomFloorFromAir, Blocker.X, Blocker.Y, Blocker.Z, + Params.BaseDensity); + float MinRoomSDF = FLT_MAX; + FString Segments; + FString Controls; + FString NearbyRoomSDFs; + const TArray& AllPassages = World.Manager->GetPassages(); + for (int32 CandidateIndex = 0; + CandidateIndex < AllPassages.Num(); + ++CandidateIndex) + { + const FVoxelPassage& Candidate = AllPassages[CandidateIndex]; + const float UpperRoomSDF = VF_EvaluatePassageLandingSDF( + Blocker, Candidate.UpperLanding); + const float LowerRoomSDF = VF_EvaluatePassageLandingSDF( + Blocker, Candidate.LowerLanding); + const float CandidateRoomSDF = FMath::Min( + UpperRoomSDF, LowerRoomSDF); + if (CandidateRoomSDF < 16.0f) + { + NearbyRoomSDFs += FString::Printf( + TEXT("p%d[%d>%d]=(u%.3f,l%.3f;uf%.2f,lf%.2f;" + "us%.1f,%.1f,ud%.1f,%.1f,ls%.1f,%.1f,ld%.1f,%.1f);"), + CandidateIndex, Candidate.UpperStrateIndex, + Candidate.LowerStrateIndex, UpperRoomSDF, LowerRoomSDF, + Candidate.UpperLanding.FloorZ, Candidate.LowerLanding.FloorZ, + Candidate.UpperLanding.StandingPoint.X, + Candidate.UpperLanding.StandingPoint.Y, + Candidate.UpperLanding.DoorPoint.X, + Candidate.UpperLanding.DoorPoint.Y, + Candidate.LowerLanding.StandingPoint.X, + Candidate.LowerLanding.StandingPoint.Y, + Candidate.LowerLanding.DoorPoint.X, + Candidate.LowerLanding.DoorPoint.Y); + } + if (Candidate.UpperStrateIndex != 0 + || Candidate.LowerStrateIndex != 1 + || Candidate.NativeFloorProfileZ.Num() + != Candidate.ControlPoints.Num()) + { + continue; + } + MinRoomSDF = FMath::Min(MinRoomSDF, CandidateRoomSDF); + for (int32 ControlIndex = 0; + ControlIndex < Candidate.ControlPoints.Num(); + ++ControlIndex) + { + const FVector& Control = Candidate.ControlPoints[ControlIndex]; + Controls += FString::Printf( + TEXT("cp%d=(%.1f,%.1f,%.1f;floor=%.1f,r=%.1f);"), + ControlIndex, Control.X, Control.Y, Control.Z, + Candidate.NativeFloorProfileZ[ControlIndex], + Candidate.ControlRadii.IsValidIndex(ControlIndex) + ? Candidate.ControlRadii[ControlIndex] : -1.0f); + } + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Candidate.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Candidate.ControlPoints[SegmentIndex]; + const FVector& B = Candidate.ControlPoints[SegmentIndex + 1]; + const float DX = B.X - A.X; + const float DY = B.Y - A.Y; + const float LengthSquared = DX * DX + DY * DY; + if (!(LengthSquared > KINDA_SMALL_NUMBER)) + { + continue; + } + const float T = FMath::Clamp( + ((Blocker.X - A.X) * DX + (Blocker.Y - A.Y) * DY) + / LengthSquared, + 0.0f, 1.0f); + const float ClosestX = A.X + DX * T; + const float ClosestY = A.Y + DY * T; + const float HorizontalDistance = FMath::Sqrt( + FMath::Square(Blocker.X - ClosestX) + + FMath::Square(Blocker.Y - ClosestY)); + if (HorizontalDistance <= 12.0f) + { + const float FloorZ = FMath::Lerp( + Candidate.NativeFloorProfileZ[SegmentIndex], + Candidate.NativeFloorProfileZ[SegmentIndex + 1], T); + Segments += FString::Printf( + TEXT("seg%d:xy=%.3f,floor=%.3f,offset=%.3f;"), + SegmentIndex, HorizontalDistance, FloorZ, + Blocker.Z - FloorZ); + } + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDescentSeedBlocker] seed=%d point=(%.3f,%.3f,%.3f) " + "density=%.3f passage_sdf=%.3f native_floor_from_air=%.3f " + "passage_posts_from_air=%.3f landing_floor_from_air=%.3f " + "room_floor_from_air=%.3f room_sdf=%.3f rooms='%s' " + "segments='%s' controls='%s'"), + Seed, Blocker.X, Blocker.Y, Blocker.Z, + FinalDensity, PassageSDF, NativeFloorFromAir, + PassagePostsFromAir, LandingFloorFromAir, RoomFloorFromAir, + MinRoomSDF, *NearbyRoomSDFs, *Segments, *Controls); + } + return true; + } + + void LogCorridorResult( + FAutomationTestBase& Test, + int32 Seed, + const FWalkCorridorSamples& Samples) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDescentCorridor] seed=%d samples=%d solid_samples=%d " + "blocked_samples=%d blocked_stations=%d floor_stations=%d missing_floor_stations=%d max_missing_run=%d " + "width_floor_gaps=%d max_lateral_floor_delta=%.3f " + "step_violations=%d max_step_vox=%.3f floor_profile_violations=%d max_floor_profile_deviation=%.3f " + "first_step=(%.3f,%.3f,%.3f)->(%.3f,%.3f,%.3f) " + "expected_step_floors=(%.3f->%.3f) " + "first_missing_floor=(station=%d,%.3f,%.3f,expected_z=%.3f) " + "first_solid_vox=(%.3f,%.3f,%.3f) density=%.6f body_index=(station=%d,lateral=%d,height=%d) " + "first_solid_floor=(measured=%.3f,expected=%.3f) " + "first_tall_solid_vox=(%.3f,%.3f,%.3f) density=%.6f body_index=(station=%d,lateral=%d,height=%d) " + "first_blocking_solid_vox=(%.3f,%.3f,%.3f) density=%.6f feet=(%.3f,%.3f,%.3f) body_index=(station=%d,lateral=%d,height=%d) " + "solid_by_height=[%s] " + "position_crc=%08X density_crc=%08X"), + Seed, Samples.Densities.Num(), Samples.SolidSamples, + Samples.BlockingSolidSamples, Samples.BlockingSolidStations, + Samples.FloorStations, Samples.MissingFloorStations, + Samples.MaxMissingFloorRunStations, + Samples.WidthFloorGaps, Samples.MaxLateralFloorDeltaVoxels, + Samples.StepViolations, Samples.MaxStepVoxels, + Samples.FloorProfileViolations, Samples.MaxFloorProfileDeviationVoxels, + Samples.FirstStepStart.X, Samples.FirstStepStart.Y, Samples.FirstStepStart.Z, + Samples.FirstStepEnd.X, Samples.FirstStepEnd.Y, Samples.FirstStepEnd.Z, + Samples.FirstStepExpectedStartZ, Samples.FirstStepExpectedEndZ, + Samples.FirstMissingFloorStation, + Samples.FirstMissingFloorPosition.X, Samples.FirstMissingFloorPosition.Y, + Samples.FirstMissingFloorExpectedZ, + Samples.FirstSolidPosition.X, Samples.FirstSolidPosition.Y, + Samples.FirstSolidPosition.Z, Samples.FirstSolidDensity, + Samples.FirstSolidStation, Samples.FirstSolidLateralIndex, + Samples.FirstSolidHeightIndex, + Samples.FirstSolidMeasuredFloorZ, Samples.FirstSolidExpectedFloorZ, + Samples.FirstTallSolidPosition.X, Samples.FirstTallSolidPosition.Y, + Samples.FirstTallSolidPosition.Z, Samples.FirstTallSolidDensity, + Samples.FirstTallSolidStation, Samples.FirstTallSolidLateralIndex, + Samples.FirstTallSolidHeightIndex, + Samples.FirstBlockingSolidPosition.X, Samples.FirstBlockingSolidPosition.Y, + Samples.FirstBlockingSolidPosition.Z, Samples.FirstBlockingSolidDensity, + Samples.FirstBlockingFeetPosition.X, Samples.FirstBlockingFeetPosition.Y, + Samples.FirstBlockingFeetPosition.Z, + Samples.FirstBlockingSolidStation, Samples.FirstBlockingSolidLateralIndex, + Samples.FirstBlockingSolidHeightIndex, + *FString::JoinBy(Samples.SolidSamplesByHeight, TEXT(","), + [](int32 Count) { return FString::FromInt(Count); }), + Samples.PositionHash, Samples.DensityHash); + Test.AddInfo(FString::Printf( + TEXT("seed=%d samples=%d solids=%d blocking_solids=%d blocked_stations=%d floor_stations=%d missing_floors=%d max_missing_run=%d first_missing_floor=(station=%d,%.3f,%.3f,%.3f) width_floor_gaps=%d step_violations=%d max_step=%.3f floor_profile_violations=%d first_solid=(%.3f,%.3f,%.3f) density=%.6f"), + Seed, Samples.Densities.Num(), Samples.SolidSamples, + Samples.BlockingSolidSamples, Samples.BlockingSolidStations, + Samples.FloorStations, Samples.MissingFloorStations, + Samples.MaxMissingFloorRunStations, Samples.FirstMissingFloorStation, + Samples.FirstMissingFloorPosition.X, Samples.FirstMissingFloorPosition.Y, + Samples.FirstMissingFloorExpectedZ, Samples.WidthFloorGaps, + Samples.StepViolations, Samples.MaxStepVoxels, + Samples.FloorProfileViolations, + Samples.FirstSolidPosition.X, Samples.FirstSolidPosition.Y, + Samples.FirstSolidPosition.Z, Samples.FirstSolidDensity)); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeDescentCorridorOwnerTest, + "VoxelForge.Descent.OwnerPassageCorridor", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeDescentCorridorOwnerTest::RunTest(const FString& Parameters) +{ + FWalkCorridorSamples First; + FString Error; + if (!SampleOwnerSeed(/*Seed=*/0, First, Error)) + { + AddError(Error); + return false; + } + LogCorridorResult(*this, 0, First); + + FWalkCorridorSamples Repeat; + if (!SampleOwnerSeed(/*Seed=*/0, Repeat, Error)) + { + AddError(Error); + return false; + } + LogCorridorResult(*this, 0, Repeat); + const bool bSameSamples = First.Positions.Num() == Repeat.Positions.Num() + && First.PositionHash == Repeat.PositionHash + && First.DensityHash == Repeat.DensityHash + && First.Positions.Num() == Repeat.Positions.Num() + && FMemory::Memcmp( + First.Densities.GetData(), Repeat.Densities.GetData(), + First.Densities.Num() * sizeof(float)) == 0; + TestTrue(FString::Printf( + TEXT("Same owner seed produces identical corridor geometry and density samples (crc=%08X)"), + First.DensityHash), bSameSamples); + + TestEqual(TEXT("Owner A-to-B corridor has no body obstruction above a legal step"), + First.BlockingSolidSamples, 0); + TestEqual(TEXT("Owner A-to-B corridor has support floor at every station"), + First.MissingFloorStations, 0); + TestEqual(TEXT("Owner A-to-B corridor has floor support across the full player width"), + First.WidthFloorGaps, 0); + TestEqual(TEXT("Owner A-to-B corridor has no step above 1.8 voxels"), + First.StepViolations, 0); + return First.BlockingSolidSamples == 0 && First.MissingFloorStations == 0 + && First.WidthFloorGaps == 0 && First.StepViolations == 0 && bSameSamples; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeDescentCorridorSeedSweepTest, + "VoxelForge.Descent.OwnerSeedSweep", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeDescentCorridorSeedSweepTest::RunTest(const FString& Parameters) +{ + constexpr int32 SeedCount = 64; + int32 SeedsWithSolids = 0; + int32 SeedsWithBlockingSolids = 0; + int32 SeedsWithFloorGaps = 0; + int32 SeedsWithWidthGaps = 0; + int32 SeedsWithLargeSteps = 0; + int32 SeedsWithFloorProfileViolations = 0; + int32 SeedsWithWalkFailures = 0; + int64 TotalSolidSamples = 0; + int64 TotalBlockingSolidSamples = 0; + int64 TotalWidthFloorGaps = 0; + int64 TotalSamples = 0; + int32 FirstFailingSeed = INDEX_NONE; + FWalkCorridorSamples Worst; + FString WorstError; + + for (int32 Seed = 0; Seed < SeedCount; ++Seed) + { + FWalkCorridorSamples Samples; + FString Error; + if (!SampleOwnerSeed(Seed, Samples, Error)) + { + AddError(Error); + return false; + } + LogCorridorResult(*this, Seed, Samples); + TotalSamples += Samples.Densities.Num(); + TotalSolidSamples += Samples.SolidSamples; + TotalBlockingSolidSamples += Samples.BlockingSolidSamples; + TotalWidthFloorGaps += Samples.WidthFloorGaps; + if (Samples.SolidSamples > 0) + { + ++SeedsWithSolids; + } + if (Samples.BlockingSolidSamples > 0) + { + ++SeedsWithBlockingSolids; + if (FirstFailingSeed == INDEX_NONE) + { + FirstFailingSeed = Seed; + Worst = Samples; + WorstError = FString::Printf(TEXT("seed %d"), Seed); + } + if (Samples.BlockingSolidSamples > Worst.BlockingSolidSamples) + { + Worst = Samples; + WorstError = FString::Printf(TEXT("seed %d"), Seed); + } + } + if (Samples.MissingFloorStations > 0) + { + ++SeedsWithFloorGaps; + } + if (Samples.WidthFloorGaps > 0) + { + ++SeedsWithWidthGaps; + } + if (Samples.StepViolations > 0) + { + ++SeedsWithLargeSteps; + } + if (Samples.FloorProfileViolations > 0) + { + ++SeedsWithFloorProfileViolations; + } + if (Samples.BlockingSolidSamples > 0 || Samples.MissingFloorStations > 0 + || Samples.WidthFloorGaps > 0 || Samples.StepViolations > 0) + { + ++SeedsWithWalkFailures; + } + } + + const double HitRate = 100.0 * static_cast(SeedsWithWalkFailures) + / static_cast(SeedCount); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDescentCorridorSweep] seeds=%d walk_failure_seeds=%d hit_rate_pct=%.1f " + "solid_seeds=%d blocking_seeds=%d floor_gap_seeds=%d width_gap_seeds=%d " + "large_step_seeds=%d floor_profile_violation_seeds=%d " + "solid_samples=%lld blocking_samples=%lld width_floor_gaps=%lld total_samples=%lld " + "first_seed=%d worst_seed=%s worst_blockers=%d"), + SeedCount, SeedsWithWalkFailures, HitRate, SeedsWithSolids, SeedsWithBlockingSolids, + SeedsWithFloorGaps, SeedsWithWidthGaps, SeedsWithLargeSteps, + SeedsWithFloorProfileViolations, + static_cast(TotalSolidSamples), + static_cast(TotalBlockingSolidSamples), + static_cast(TotalWidthFloorGaps), static_cast(TotalSamples), + FirstFailingSeed, WorstError.IsEmpty() ? TEXT("none") : *WorstError, + Worst.BlockingSolidSamples); + TestEqual(TEXT("All 64 owner seeds have a clear, walkable A-to-B body corridor"), + SeedsWithWalkFailures, 0); + return SeedsWithWalkFailures == 0; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 83e4ee4..26d939e 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -571,111 +571,107 @@ namespace 0.0f, VF_PassageMaxReliefScale); } - static bool VF_ProjectNativePassageFloorUncached( - const FVoxelPassage& Passage, const FVector& Position, - float& OutFloorZ, float& OutSupportRadius, + static bool VF_ProjectNativePassageFloorSegment( + const FVoxelPassage& Passage, int32 SegmentIndex, + const FVector& Position, float& OutFloorZ, float& OutSupportRadius, + float& OutDistanceSquared, float& OutHorizontalDistanceSquared, const UVoxelStrateManager* Manager = nullptr, int32 PassageIndex = INDEX_NONE) { const int32 ControlPointCount = Passage.ControlPoints.Num(); if (!Passage.bNativeFloorEnabled - || ControlPointCount < 2 + || SegmentIndex < 0 + || SegmentIndex + 1 >= ControlPointCount || Passage.ControlRadii.Num() != ControlPointCount || Passage.NativeFloorProfileZ.Num() != ControlPointCount) { return false; } - const FVector* ControlPointData = Passage.ControlPoints.GetData(); - const float* ControlRadiusData = Passage.ControlRadii.GetData(); - const float* NativeFloorProfileData = Passage.NativeFloorProfileZ.GetData(); + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float StartRadius = FMath::Abs(Passage.ControlRadii[SegmentIndex]); + const float EndRadius = FMath::Abs(Passage.ControlRadii[SegmentIndex + 1]); const double QueryX = static_cast(static_cast(Position.X)); const double QueryY = static_cast(static_cast(Position.Y)); - float BestDistanceSquared = FLT_MAX; - int32 BestSegment = INDEX_NONE; - float BestT = 0.0f; - float BestSupportRadius = 0.0f; - for (int32 SegmentIndex = 0; - SegmentIndex + 1 < ControlPointCount; - ++SegmentIndex) - { - const FVector& A = ControlPointData[SegmentIndex]; - const FVector& B = ControlPointData[SegmentIndex + 1]; - const double AXY_X = static_cast(static_cast(A.X)); - const double AXY_Y = static_cast(static_cast(A.Y)); - const double DeltaX = static_cast(static_cast(B.X - A.X)); - const double DeltaY = static_cast(static_cast(B.Y - A.Y)); - const float LengthSquared = static_cast( - DeltaX * DeltaX + DeltaY * DeltaY); - if (!(LengthSquared > KINDA_SMALL_NUMBER)) - { - continue; - } - - const double Dot = (QueryX - AXY_X) * DeltaX - + (QueryY - AXY_Y) * DeltaY; - const float T = FMath::Clamp( - Dot / LengthSquared, - 0.0f, 1.0f); - const double ClosestX = AXY_X + DeltaX * T; - const double ClosestY = AXY_Y + DeltaY * T; - const double DistanceX = QueryX - ClosestX; - const double DistanceY = QueryY - ClosestY; - const float DistanceSquared = static_cast( - DistanceX * DistanceX + DistanceY * DistanceY); - if (DistanceSquared >= BestDistanceSquared) - { - continue; - } - - BestDistanceSquared = DistanceSquared; - BestSegment = SegmentIndex; - BestT = T; - BestSupportRadius = FMath::Max( - FMath::Min( - FMath::Abs(ControlRadiusData[SegmentIndex]), - FMath::Abs(ControlRadiusData[SegmentIndex + 1])) - 0.5f, - VoxelPassageGeometry::PlayerRadiusVoxels); - } - - if (BestSegment == INDEX_NONE - || BestDistanceSquared > FMath::Square(BestSupportRadius)) + const double AXY_X = static_cast(static_cast(A.X)); + const double AXY_Y = static_cast(static_cast(A.Y)); + const double DeltaX = static_cast(static_cast(B.X - A.X)); + const double DeltaY = static_cast(static_cast(B.Y - A.Y)); + const float HorizontalLengthSquared = static_cast( + DeltaX * DeltaX + DeltaY * DeltaY); + if (!(HorizontalLengthSquared > KINDA_SMALL_NUMBER)) { return false; } - const float ClampedT = FMath::Clamp(BestT, 0.0f, 1.0f); - OutFloorZ = FMath::Lerp( - NativeFloorProfileData[BestSegment], - NativeFloorProfileData[BestSegment + 1], - ClampedT); - - float ReliefScale = 0.0f; - const int32 ReliefScaleCount = Passage.NativeFloorReliefScales.Num(); - if (BestSegment >= 0 && BestSegment < ReliefScaleCount) + const double HorizontalDot = (QueryX - AXY_X) * DeltaX + + (QueryY - AXY_Y) * DeltaY; + const float HorizontalT = FMath::Clamp( + HorizontalDot / HorizontalLengthSquared, + 0.0f, 1.0f); + const double HorizontalClosestX = AXY_X + DeltaX * HorizontalT; + const double HorizontalClosestY = AXY_Y + DeltaY * HorizontalT; + const double HorizontalDistanceX = QueryX - HorizontalClosestX; + const double HorizontalDistanceY = QueryY - HorizontalClosestY; + const float HorizontalDistanceSquared = static_cast( + HorizontalDistanceX * HorizontalDistanceX + + HorizontalDistanceY * HorizontalDistanceY); + OutHorizontalDistanceSquared = HorizontalDistanceSquared; + const float SegmentSupportRadius = + VoxelPassageGeometry::NativePassageFloorSupportRadius( + StartRadius, EndRadius); + if (HorizontalDistanceSquared > FMath::Square(SegmentSupportRadius)) { - ReliefScale = Passage.NativeFloorReliefScales.GetData()[BestSegment]; + return false; + } + + const float FloorA = Passage.NativeFloorProfileZ[SegmentIndex]; + const float FloorB = Passage.NativeFloorProfileZ[SegmentIndex + 1]; + const float FloorDeltaZ = FloorB - FloorA; + const float FullLengthSquared = HorizontalLengthSquared + + FMath::Square(FloorDeltaZ); + if (!(FullLengthSquared > KINDA_SMALL_NUMBER)) + { + return false; + } + const double FullDot = HorizontalDot + + (static_cast(static_cast(Position.Z)) - FloorA) + * FloorDeltaZ; + const float SpatialT = FMath::Clamp( + FullDot / FullLengthSquared, + 0.0f, 1.0f); + const double SpatialX = AXY_X + DeltaX * SpatialT; + const double SpatialY = AXY_Y + DeltaY * SpatialT; + const double SpatialZ = FloorA + FloorDeltaZ * SpatialT; + const double SpatialDistanceX = QueryX - SpatialX; + const double SpatialDistanceY = QueryY - SpatialY; + const double SpatialDistanceZ = + static_cast(static_cast(Position.Z)) - SpatialZ; + OutDistanceSquared = static_cast( + SpatialDistanceX * SpatialDistanceX + + SpatialDistanceY * SpatialDistanceY + + SpatialDistanceZ * SpatialDistanceZ); + + OutFloorZ = FMath::Lerp(FloorA, FloorB, HorizontalT); + float ReliefScale = 0.0f; + if (Passage.NativeFloorReliefScales.IsValidIndex(SegmentIndex)) + { + ReliefScale = Passage.NativeFloorReliefScales[SegmentIndex]; } float Envelope = 1.0f; - const FVector& BestA = ControlPointData[BestSegment]; - const FVector& BestB = ControlPointData[BestSegment + 1]; - const double BestDeltaX = static_cast(static_cast( - BestB.X - BestA.X)); - const double BestDeltaY = static_cast(static_cast( - BestB.Y - BestA.Y)); - const float HorizontalRun = static_cast(FMath::Sqrt( - BestDeltaX * BestDeltaX + BestDeltaY * BestDeltaY)); - if (BestSegment == 0) + const float HorizontalRun = FMath::Sqrt(HorizontalLengthSquared); + if (SegmentIndex == 0) { Envelope *= SmoothStep01(FMath::Clamp( - (ClampedT * HorizontalRun) + (HorizontalT * HorizontalRun) / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels, 0.0f, 1.0f)); } - if (BestSegment + 1 == ControlPointCount - 1) + if (SegmentIndex + 1 == ControlPointCount - 1) { Envelope *= SmoothStep01(FMath::Clamp( - ((1.0f - ClampedT) * HorizontalRun) + ((1.0f - HorizontalT) * HorizontalRun) / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels, 0.0f, 1.0f)); } @@ -684,12 +680,53 @@ namespace static_cast(Position.X), static_cast(Position.Y)) * VOXEL_NOISE_SCALE * Passage.NativeFloorReliefStrength * Envelope * ReliefScale; - OutSupportRadius = BestSupportRadius; + OutSupportRadius = SegmentSupportRadius; return VoxelMath::IsFinite(OutFloorZ) && VoxelMath::IsFinite(OutSupportRadius) + && VoxelMath::IsFinite(OutDistanceSquared) && OutSupportRadius > 0.0f; } + static bool VF_ProjectNativePassageFloorUncached( + const FVoxelPassage& Passage, const FVector& Position, + float& OutFloorZ, float& OutSupportRadius, + const UVoxelStrateManager* Manager = nullptr, + int32 PassageIndex = INDEX_NONE) + { + const int32 SegmentCount = Passage.ControlPoints.Num() - 1; + float BestDistanceSquared = FLT_MAX; + float BestFloorZ = 0.0f; + float BestSupportRadius = 0.0f; + bool bFoundSegment = false; + for (int32 SegmentIndex = 0; SegmentIndex < SegmentCount; ++SegmentIndex) + { + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + float DistanceSquared = FLT_MAX; + float HorizontalDistanceSquared = FLT_MAX; + if (!VF_ProjectNativePassageFloorSegment( + Passage, SegmentIndex, Position, + FloorZ, SupportRadius, DistanceSquared, + HorizontalDistanceSquared, + Manager, PassageIndex) + || DistanceSquared >= BestDistanceSquared) + { + continue; + } + BestDistanceSquared = DistanceSquared; + BestFloorZ = FloorZ; + BestSupportRadius = SupportRadius; + bFoundSegment = true; + } + if (!bFoundSegment) + { + return false; + } + OutFloorZ = BestFloorZ; + OutSupportRadius = BestSupportRadius; + return true; + } + static bool VF_ProjectNativePassageFloor( const FVoxelPassage& Passage, const FVector& Position, float& OutFloorZ, float& OutSupportRadius, @@ -2157,10 +2194,10 @@ void UVoxelStrateManager::GeneratePassages() const FVector DoorDirection( SwitchbackDirection.X, SwitchbackDirection.Y, 0.0f); Passage.UpperLanding.DoorDirection = DoorDirection; - // A real switchback turns around: the lower doorway faces back toward the upper - // leg. Keeping both doors on the same side would make the two sloped legs retrace - // one XY line at different heights, leaving the floor projection ambiguous. - const FVector TunnelLowerDoorDirection = -DoorDirection; + // Keep both door approaches on the same outward side so each sloped leg reaches + // its own apron before entering the room. Separate their turn-side floor branches + // below so the two projections do not retrace one XY line at different heights. + const FVector TunnelLowerDoorDirection = DoorDirection; Passage.LowerLanding.DoorDirection = TunnelLowerDoorDirection; const float UpperDoorOffset = FMath::Max( Passage.UpperLanding.HalfWidth - 1.0f, 0.0f); @@ -2204,27 +2241,9 @@ void UVoxelStrateManager::GeneratePassages() const float LowerFloorZ = VoxelPassageGeometry::TunnelFloorZ( LowerDoor, Cfg.MouthRadius); const float MidFloorZ = 0.5f * (UpperFloorZ + LowerFloorZ); - const FVector2D RampStartMid( - 0.5f * (UpperApronEnd.X + LowerApronBegin.X), - 0.5f * (UpperApronEnd.Y + LowerApronBegin.Y)); - // The turn offset must be perpendicular to the actual two-apron endpoint chord, - // not merely perpendicular to the standing-point chord. Opposite-facing doors - // add their offsets to that chord; using the old direction could make the two - // sloped legs nearly collinear and bring the lower leg back beside the upper door. - FVector2D TurnDirection( - LowerApronBegin.Y - UpperApronEnd.Y, - -(LowerApronBegin.X - UpperApronEnd.X)); - if (!TurnDirection.Normalize()) - { - TurnDirection = FVector2D(-DoorDirection.Y, DoorDirection.X); - if (!TurnDirection.Normalize()) - { - TurnDirection = FVector2D(0.0f, 1.0f); - } - } - // There are two sides on which the switchback can turn. Choose the side whose - // two sloped legs initially move away from their own landing rooms; the old fixed - // sign could send a slope back through a room floor before reaching open space. + // Each ramp must leave its own landing on the outward side. The two doors face + // that same direction; orient the shared level turn along it, then split the lower + // floor branch laterally so the ramp projections remain unambiguous. FVector2D UpperOutward( UpperApronEnd.X - Passage.UpperLanding.StandingPoint.X, UpperApronEnd.Y - Passage.UpperLanding.StandingPoint.Y); @@ -2233,32 +2252,6 @@ void UVoxelStrateManager::GeneratePassages() LowerApronBegin.Y - Passage.LowerLanding.StandingPoint.Y); UpperOutward.Normalize(); LowerOutward.Normalize(); - const float TurnOffsetForScore = - 0.5f * VoxelPassageGeometry::RequiredHorizontalRunForFloorDrop( - UpperFloorZ - 0.5f * (UpperFloorZ + LowerFloorZ), - 0.5f * (UpperFloorZ + LowerFloorZ) - LowerFloorZ) - + VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels; - const FVector2D TurnMidPlus( - RampStartMid.X + TurnDirection.X * TurnOffsetForScore, - RampStartMid.Y + TurnDirection.Y * TurnOffsetForScore); - const FVector2D TurnMidMinus( - RampStartMid.X - TurnDirection.X * TurnOffsetForScore, - RampStartMid.Y - TurnDirection.Y * TurnOffsetForScore); - const auto OutwardTurnScore = [ - &UpperOutward, &LowerOutward, &UpperApronEnd, &LowerApronBegin] - (const FVector2D& Candidate) -> float - { - return FVector2D::DotProduct( - Candidate - FVector2D( - UpperApronEnd.X, UpperApronEnd.Y), UpperOutward) - + FVector2D::DotProduct( - FVector2D(LowerApronBegin.X, LowerApronBegin.Y) - Candidate, - LowerOutward); - }; - if (OutwardTurnScore(TurnMidMinus) > OutwardTurnScore(TurnMidPlus)) - { - TurnDirection *= -1.0f; - } const float UpperFloorDrop = UpperFloorZ - MidFloorZ; const float LowerFloorDrop = MidFloorZ - LowerFloorZ; const float RequiredHorizontalRun = @@ -2271,22 +2264,46 @@ void UVoxelStrateManager::GeneratePassages() VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels; const float TurnOffset = 0.5f * RequiredHorizontalRun + TurnTransition; - const FVector2D FloorSafeDoorMid( - RampStartMid.X - + TurnDirection.X * TurnOffset, - RampStartMid.Y - + TurnDirection.Y * TurnOffset); + FVector2D TurnDirection( + UpperOutward.X + LowerOutward.X, + UpperOutward.Y + LowerOutward.Y); + if (!TurnDirection.Normalize()) + { + TurnDirection = UpperOutward; + } + const FVector2D ApronChord( + LowerApronBegin.X - UpperApronEnd.X, + LowerApronBegin.Y - UpperApronEnd.Y); + const float LowerApronAlong = FVector2D::DotProduct( + ApronChord, TurnDirection); + const float SharedTurnOffset = TurnOffset + + FMath::Max(LowerApronAlong, 0.0f); + const FVector2D SharedTurnMid( + UpperApronEnd.X + TurnDirection.X * SharedTurnOffset, + UpperApronEnd.Y + TurnDirection.Y * SharedTurnOffset); + const float TurnLegSeparation = + VoxelPassageGeometry::WalkableTunnelTurnLegSeparation( + Cfg.MidRadius, Cfg.MouthRadius); + FVector2D TurnLegSideDirection(-TurnDirection.Y, TurnDirection.X); + if (FVector2D::DotProduct(ApronChord, TurnLegSideDirection) < 0.0f) + { + TurnLegSideDirection *= -1.0f; + } + const FVector2D UpperTurnMid = SharedTurnMid; + const FVector2D LowerTurnMid( + SharedTurnMid.X + TurnLegSideDirection.X * TurnLegSeparation, + SharedTurnMid.Y + TurnLegSideDirection.Y * TurnLegSeparation); FVector2D UpperSlopeDirection( - FloorSafeDoorMid.X - UpperApronEnd.X, - FloorSafeDoorMid.Y - UpperApronEnd.Y); + UpperTurnMid.X - UpperApronEnd.X, + UpperTurnMid.Y - UpperApronEnd.Y); if (!UpperSlopeDirection.Normalize()) { UpperSlopeDirection = FVector2D( DoorDirection.X, DoorDirection.Y); } FVector2D LowerSlopeDirection( - LowerApronBegin.X - FloorSafeDoorMid.X, - LowerApronBegin.Y - FloorSafeDoorMid.Y); + LowerApronBegin.X - LowerTurnMid.X, + LowerApronBegin.Y - LowerTurnMid.Y); if (!LowerSlopeDirection.Normalize()) { LowerSlopeDirection = -UpperSlopeDirection; @@ -2304,7 +2321,10 @@ void UVoxelStrateManager::GeneratePassages() UpperSlopeStartXY.X, UpperSlopeStartXY.Y, UpperFloorZ + FMath::Abs(Cfg.MouthRadius)); const FVector MidPoint( - FloorSafeDoorMid.X, FloorSafeDoorMid.Y, + UpperTurnMid.X, UpperTurnMid.Y, + MidFloorZ + FMath::Abs(Cfg.MidRadius)); + const FVector LowerTurnPoint( + LowerTurnMid.X, LowerTurnMid.Y, MidFloorZ + FMath::Abs(Cfg.MidRadius)); const FVector LowerSlopeEnd( LowerSlopeEndXY.X, LowerSlopeEndXY.Y, @@ -2313,12 +2333,13 @@ void UVoxelStrateManager::GeneratePassages() LowerApronBegin.X, LowerApronBegin.Y, LowerFloorZ + FMath::Abs(Cfg.MouthRadius)); - Passage.ControlPoints.Reset(7); - Passage.ControlRadii.Reset(7); + Passage.ControlPoints.Reset(8); + Passage.ControlRadii.Reset(8); Passage.ControlPoints.Add(UpperDoor); Passage.ControlPoints.Add(UpperApronEndPoint); Passage.ControlPoints.Add(UpperSlopeStart); Passage.ControlPoints.Add(MidPoint); + Passage.ControlPoints.Add(LowerTurnPoint); Passage.ControlPoints.Add(LowerSlopeEnd); Passage.ControlPoints.Add(LowerApronBeginPoint); Passage.ControlPoints.Add(LowerDoor); @@ -2326,6 +2347,7 @@ void UVoxelStrateManager::GeneratePassages() Passage.ControlRadii.Add(Cfg.MouthRadius); Passage.ControlRadii.Add(Cfg.MouthRadius); Passage.ControlRadii.Add(Cfg.MidRadius); + Passage.ControlRadii.Add(Cfg.MidRadius); Passage.ControlRadii.Add(Cfg.MouthRadius); Passage.ControlRadii.Add(Cfg.MouthRadius); Passage.ControlRadii.Add(Cfg.MouthRadius); @@ -2592,29 +2614,31 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float { const float rA = bTaper ? ControlRadiusData[j] : P.Radius; const float rB = bTaper ? ControlRadiusData[j + 1] : P.Radius; - const float SegSDF = VoxelSDF::TaperedCapsule( + float SegmentSDF = VoxelSDF::TaperedCapsule( Pos, ControlPointData[j], ControlPointData[j + 1], rA, rB); - PassageSDF = VoxelSDF::SmoothMin(PassageSDF, SegSDF, BlendK); - } - // A walkable inter-strate passage is a D-shaped primitive, not a capsule followed - // by a slab. The profile and its bounded relief were authored in GeneratePassages; - // projecting once here selects the nearest immutable floor segment for this sample. - // Unknown/malformed descriptors deliberately retain the bare capsule and the legacy - // diagnostic backstop remains available. - if (P.bNativeFloorEnabled && !VoxelDensityAblation::IsNativeFloorOff()) - { - float NativeFloorZ = 0.0f; - float NativeSupportRadius = 0.0f; - if (VF_ProjectNativePassageFloor( - P, Pos, NativeFloorZ, NativeSupportRadius, - this, PIdx)) + // Clip each capsule against its own floor profile before the route segments are + // unioned. A single nearest-floor projection after the union can select the + // neighboring leg of a switchback and leave a solid post in this segment. + if (P.bNativeFloorEnabled && !VoxelDensityAblation::IsNativeFloorOff()) { - PassageSDF = VoxelSDF::SmoothMax( - PassageSDF, - NativeFloorZ - static_cast(Pos.Z), - BlendK * 0.35f); + float NativeFloorZ = 0.0f; + float NativeSupportRadius = 0.0f; + float FloorDistanceSquared = FLT_MAX; + float FloorHorizontalDistanceSquared = FLT_MAX; + if (VF_ProjectNativePassageFloorSegment( + P, j, Pos, NativeFloorZ, NativeSupportRadius, + FloorDistanceSquared, FloorHorizontalDistanceSquared, + this, PIdx)) + { + SegmentSDF = VoxelSDF::SmoothMax( + SegmentSDF, + NativeFloorZ - static_cast(Pos.Z), + BlendK * 0.35f); + } } + + PassageSDF = VoxelSDF::SmoothMin(PassageSDF, SegmentSDF, BlendK); } MinSDF = VoxelSDF::SmoothMin(MinSDF, PassageSDF, BlendK); } @@ -3145,6 +3169,30 @@ void UVoxelStrateManager::ApplyPassageNativeFloorMC( const FVoxelPassage* PassageData = Passages.GetData(); const FVector Position(WorldX, WorldY, WorldZ); + bool bRoomOwnershipChecked = false; + bool bInsidePassageRoom = false; + const auto IsInsidePassageRoom = [&]() + { + if (!bRoomOwnershipChecked) + { + bRoomOwnershipChecked = true; + for (const int32 PassageIndex : Nearby) + { + if (!Passages.IsValidIndex(PassageIndex)) + { + continue; + } + const FVoxelPassage& Passage = PassageData[PassageIndex]; + if (VF_EvaluatePassageLandingSDF(Position, Passage.UpperLanding) <= 0.0f + || VF_EvaluatePassageLandingSDF(Position, Passage.LowerLanding) <= 0.0f) + { + bInsidePassageRoom = true; + break; + } + } + } + return bInsidePassageRoom; + }; for (const int32 PassageIndex : Nearby) { if (!Passages.IsValidIndex(PassageIndex)) @@ -3159,27 +3207,124 @@ void UVoxelStrateManager::ApplyPassageNativeFloorMC( float FloorZ = 0.0f; float SupportRadius = 0.0f; - if (!VF_ProjectNativePassageFloor( - Passage, Position, FloorZ, SupportRadius, - this, PassageIndex)) + if (VF_ProjectNativePassageFloor( + Passage, Position, FloorZ, SupportRadius, this, PassageIndex) + && Position.Z <= FloorZ + KINDA_SMALL_NUMBER + && Position.Z > FloorZ + - VoxelPassageGeometry::LandingFloorThicknessVoxels) + { + // Room geometry is strate A and owns every overlap. A tunnel's native post must not + // put a floor patch through a landing room; its own room-floor writer owns support. + if (!IsInsidePassageRoom()) + { + // Keep the existing narrow floor surface on the segment selected by the passage's + // full 3D centerline, so a switchback's XY-near neighbor cannot add a second post. + Density = FMath::Min(Density, -FMath::Max(BaseDensity, 1.0f)); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::PassageNativeFloorCompositions); + } + } + break; + } + } + + // Eval, terrain operations, and disturbance writers have all run before this MC-facing + // composition point. Reassert the walkable passage's swept body volume here so their solid + // features cannot put a rib or a neighboring floor post back through the through-route. + // The profile is evaluated per route segment: switchback legs keep their own floor branch. + // Room interiors stay owned by the room SDF, and the floor band remains untouched below the + // maximum legal step. This is an air carve over the tunnel's own shape, not a support slab. + const float CorridorHalfWidth = + 0.5f * VoxelPassageGeometry::WalkCorridorPlayerWidthVoxels + + VoxelPassageGeometry::WalkCorridorLatticeMarginVoxels; + const float CorridorBottom = VoxelPassageGeometry::MaxStepHeightVoxels; + const float CorridorTop = CorridorBottom + + VoxelPassageGeometry::WalkCorridorPlayerHeightVoxels; + bool bInsideWalkCorridor = false; + for (const int32 PassageIndex : Nearby) + { + if (!Passages.IsValidIndex(PassageIndex)) { continue; } - if (Position.Z <= FloorZ + KINDA_SMALL_NUMBER - && Position.Z > FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels) + const FVoxelPassage& Passage = PassageData[PassageIndex]; + if (!Passage.bWalkableTunnelContract || !Passage.bNativeFloorEnabled) { - // The old support slab is now disabled in the A/B run. This final write is the - // D-shaped primitive's floor ownership, derived from its build-time profile; it is - // intentionally not controlled by bPassageSupportFloorWritesEnabled. - Density = FMath::Min(Density, -FMath::Max(BaseDensity, 1.0f)); - if (VoxelDensityProfile::AreCountersEnabled()) + continue; + } + + const int32 NumSegments = Passage.ControlPoints.Num() - 1; + for (int32 SegmentIndex = 0; SegmentIndex < NumSegments; ++SegmentIndex) + { + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + float DistanceSquared = FLT_MAX; + float HorizontalDistanceSquared = FLT_MAX; + if (!VF_ProjectNativePassageFloorSegment( + Passage, SegmentIndex, Position, + FloorZ, SupportRadius, DistanceSquared, + HorizontalDistanceSquared, this, PassageIndex) + || HorizontalDistanceSquared > FMath::Square(CorridorHalfWidth)) { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::PassageNativeFloorCompositions); + continue; } + + const float HeightAboveFloor = Position.Z - FloorZ; + if (HeightAboveFloor >= CorridorBottom + && HeightAboveFloor <= CorridorTop) + { + bInsideWalkCorridor = true; + break; + } + } + + // The audited A-to-B path continues from each mouth to the landing's standing point. + // Cover those short approach lines too, while letting the actual landing room retain A + // ownership wherever its room SDF contains the query. + const auto IsInsideLandingApproach = [&](const FVoxelPassageLanding& Landing) + { + const FVector2D Start(Landing.DoorPoint.X, Landing.DoorPoint.Y); + const FVector2D End(Landing.StandingPoint.X, Landing.StandingPoint.Y); + const FVector2D Delta = End - Start; + const float LengthSquared = Delta.SizeSquared(); + if (!(LengthSquared > KINDA_SMALL_NUMBER)) + { + return false; + } + const FVector2D Query(Position.X, Position.Y); + const float T = FMath::Clamp( + FVector2D::DotProduct(Query - Start, Delta) / LengthSquared, + 0.0f, 1.0f); + const FVector2D Closest = Start + Delta * T; + if (FVector2D::DistSquared(Query, Closest) + > FMath::Square(CorridorHalfWidth)) + { + return false; + } + const float HeightAboveFloor = Position.Z - Landing.FloorZ; + return HeightAboveFloor >= CorridorBottom + && HeightAboveFloor <= CorridorTop; + }; + if (!bInsideWalkCorridor + && (IsInsideLandingApproach(Passage.UpperLanding) + || IsInsideLandingApproach(Passage.LowerLanding))) + { + bInsideWalkCorridor = true; + } + if (bInsideWalkCorridor) + { break; } } + if (bInsideWalkCorridor) + { + if (!IsInsidePassageRoom()) + { + Density = FMath::Max(Density, FMath::Max(BaseDensity, 1.0f)); + } + } } void UVoxelStrateManager::ApplyPassageLandingRoomFloorMC( diff --git a/Source/VoxelForge/Public/VoxelPassageGeometry.h b/Source/VoxelForge/Public/VoxelPassageGeometry.h index a93770d..a4b035d 100644 --- a/Source/VoxelForge/Public/VoxelPassageGeometry.h +++ b/Source/VoxelForge/Public/VoxelPassageGeometry.h @@ -23,6 +23,11 @@ namespace VoxelPassageGeometry constexpr float MinimumRoomHeightVoxels = 12.0f; // 3 m constexpr float LandingFloorThicknessVoxels = 3.0f; constexpr float MaxStepHeightVoxels = 1.8f; // 45 cm at 25 cm/voxel + // The A-to-B walk corridor is budgeted as a full player-width rectangle. Its vertical + // envelope starts above the legal step and extends one player height above that raised floor. + constexpr float WalkCorridorPlayerWidthVoxels = 1.36f; + constexpr float WalkCorridorPlayerHeightVoxels = 7.0f; + constexpr float WalkCorridorLatticeMarginVoxels = 1.0f; constexpr float SealSafetyMarginVoxels = 4.0f; constexpr float RoomRoundingVoxels = 1.5f; constexpr float LandingCarveBlendVoxels = 4.0f; @@ -103,6 +108,25 @@ namespace VoxelPassageGeometry return Centreline.Z - FMath::Abs(Radius); } + FORCEINLINE float NativePassageFloorSupportRadius(float StartRadius, float EndRadius) + { + return FMath::Max( + FMath::Min(FMath::Abs(StartRadius), FMath::Abs(EndRadius)) - 0.5f, + PlayerRadiusVoxels); + } + + // Keep two switchback floor branches apart far enough that the upper branch's final floor + // post cannot enter the lower player's full-width walk corridor. The one-voxel margin covers + // the scalar lattice/interpolation boundary; Eval displacement is already followed by the + // MC-facing tunnel and native-floor stages before collision density is returned. + FORCEINLINE float WalkableTunnelTurnLegSeparation( + float StartRadius, float EndRadius) + { + return NativePassageFloorSupportRadius(StartRadius, EndRadius) + + 0.5f * WalkCorridorPlayerWidthVoxels + + WalkCorridorLatticeMarginVoxels; + } + // Vertical-shaft tree links are round air capsules, but the player-fit graph needs a // deterministic support plane to walk between shafts. Keep that plane on the first // seal-safe ledge shared by the whole strate, so every tree edge has the same walk level. @@ -350,6 +374,7 @@ namespace VoxelPassageGeometry const FVector* ControlPointData = ControlPoints.GetData(); const float* ControlRadiusData = ControlRadii.GetData(); float BestDistanceSquared = FLT_MAX; + float BestHorizontalDistanceSquared = FLT_MAX; bool bFoundSegment = false; for (int32 SegmentIndex = 0; SegmentIndex + 1 < ControlPointCount; @@ -368,28 +393,58 @@ namespace VoxelPassageGeometry continue; } - const double Dot = (QueryX - AXY_X) * DeltaX + const double HorizontalDot = (QueryX - AXY_X) * DeltaX + (QueryY - AXY_Y) * DeltaY; - const float T = FMath::Clamp( - Dot / LengthSquared, + const float HorizontalT = FMath::Clamp( + HorizontalDot / LengthSquared, 0.0f, 1.0f); - const double ClosestX = AXY_X + DeltaX * T; - const double ClosestY = AXY_Y + DeltaY * T; + const double ClosestX = AXY_X + DeltaX * HorizontalT; + const double ClosestY = AXY_Y + DeltaY * HorizontalT; const double DistanceX = QueryX - ClosestX; const double DistanceY = QueryY - ClosestY; - const float DistanceSquared = static_cast( + const float HorizontalDistanceSquared = static_cast( DistanceX * DistanceX + DistanceY * DistanceY); + + const float StartRadius = FMath::Abs(ControlRadiusData[SegmentIndex]); + const float EndRadius = FMath::Abs(ControlRadiusData[SegmentIndex + 1]); + const float StartFloorZ = TunnelFloorZ(A, StartRadius); + const float EndFloorZ = TunnelFloorZ(B, EndRadius); + const double FloorDeltaZ = static_cast(EndFloorZ - StartFloorZ); + const double FullLengthSquared = + LengthSquared + FloorDeltaZ * FloorDeltaZ; + if (!(FullLengthSquared > KINDA_SMALL_NUMBER)) + { + continue; + } + + // A switchback may place two different floors at the same XY. Select the segment + // nearest to the complete query point on the floor curve, so a lower passage leg + // cannot inherit the floor height of a vertically separated upper leg. Keep floor + // interpolation horizontal after choosing the branch: FloorZ remains a function of XY. + const double FullDot = HorizontalDot + + (static_cast(Position.Z) - StartFloorZ) * FloorDeltaZ; + const float SpatialT = FMath::Clamp( + FullDot / FullLengthSquared, + 0.0f, 1.0f); + const double SpatialX = AXY_X + DeltaX * SpatialT; + const double SpatialY = AXY_Y + DeltaY * SpatialT; + const double SpatialZ = StartFloorZ + FloorDeltaZ * SpatialT; + const double SpatialDistanceX = QueryX - SpatialX; + const double SpatialDistanceY = QueryY - SpatialY; + const double SpatialDistanceZ = Position.Z - SpatialZ; + const float DistanceSquared = static_cast( + SpatialDistanceX * SpatialDistanceX + + SpatialDistanceY * SpatialDistanceY + + SpatialDistanceZ * SpatialDistanceZ); if (DistanceSquared >= BestDistanceSquared) { continue; } - const float StartRadius = FMath::Abs(ControlRadiusData[SegmentIndex]); - const float EndRadius = FMath::Abs(ControlRadiusData[SegmentIndex + 1]); OutFloorZ = FMath::Lerp( - TunnelFloorZ(A, StartRadius), - TunnelFloorZ(B, EndRadius), - T); + StartFloorZ, + EndFloorZ, + HorizontalT); OutSupportRadius = FMath::Max( FMath::Min(StartRadius, EndRadius) - 0.5f, PlayerRadiusVoxels); @@ -398,6 +453,7 @@ namespace VoxelPassageGeometry *OutSegmentIndex = SegmentIndex; } BestDistanceSquared = DistanceSquared; + BestHorizontalDistanceSquared = HorizontalDistanceSquared; bFoundSegment = true; } @@ -405,7 +461,7 @@ namespace VoxelPassageGeometry && VoxelMath::IsFinite(OutFloorZ) && VoxelMath::IsFinite(OutSupportRadius) && OutSupportRadius > 0.0f - && BestDistanceSquared <= FMath::Square(OutSupportRadius); + && BestHorizontalDistanceSquared <= FMath::Square(OutSupportRadius); } struct FOriginLandingGeometry