diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index d122928..b50b048 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -125,8 +125,8 @@ namespace #if !UE_BUILD_SHIPPING static TAutoConsoleVariable CVarVoxelForgeTunnelMouthTrim( TEXT("voxel.TunnelMouthTrim"), - 0, - TEXT("Development-only room-wall tunnel-mouth trim; 0 keeps the legacy room endpoint."), + 1, + TEXT("Development-only room-wall tunnel-mouth trim; 1 makes the room own the mouth by default."), ECVF_Default); #endif @@ -672,16 +672,16 @@ namespace const float BlendRadius = FMath::Max( Endpoint == 0 ? MouthBlendRadiusA : MouthBlendRadiusB, 0.0f); + const float EndpointRadius = FMath::Abs(ControlRadiusData[ControlIndex]); + const float BottomHandoffRadius = BlendRadius + + VF_TunnelMouthPlayerFitOverlapVoxels + + EndpointRadius; const float RelativeDistance = Distance - BlendRadius; const float Weight = RelativeDistance <= 0.0f ? 1.0f : (RelativeDistance < Fade ? 1.0f - SmoothStep01(RelativeDistance / Fade) : 0.0f); - const float EndpointRadius = FMath::Abs(ControlRadiusData[ControlIndex]); - const float BottomHandoffRadius = BlendRadius - + VF_TunnelMouthPlayerFitOverlapVoxels - + EndpointRadius; const bool bInBottomHandoff = VoxelMath::IsFinite(BottomHandoffRadius) && Distance <= BottomHandoffRadius; if (!(Weight > 0.0f) && !bInBottomHandoff) @@ -1539,6 +1539,52 @@ namespace FMath::Sqrt(FMath::Max(Room.CullRadiusSq, 0.0f)), OutBounds); } + static float VF_CachedRoomShapeSDF( + const FCachedRoom& Room, const FVector& Position) + { + switch (Room.ShapeType) + { + case 1: + return VoxelSDF::RoundedBox( + Position, Room.Center, Room.ShapeA, Room.ShapeR); + case 2: + return VoxelSDF::Capsule( + Position, Room.ShapeA, Room.ShapeB, Room.ShapeR); + default: + return VoxelSDF::Ellipsoid(Position, Room.Center, Room.ShapeA); + } + } + + static bool VF_IsInsideCachedRoomShape( + const FVector& Position, + const FChunkSDFCache& Cache, + bool bUseSpatialIndex) + { + bool bInside = false; + auto VisitRoom = [&](int32 RoomIndex) + { + if (bInside || !Cache.Rooms.IsValidIndex(RoomIndex)) + { + return; + } + const FCachedRoom& Room = Cache.Rooms[RoomIndex]; + if (VF_FloatDistSquared( + static_cast(Position.X), + static_cast(Position.Y), + static_cast(Position.Z), + Room.Center) > Room.CullRadiusSq) + { + return; + } + bInside = VF_CachedRoomShapeSDF(Room, Position) < 0.0f; + }; + VF_ForEachSpatialCandidate( + Cache.RoomSpatialIndex, Cache.Rooms.Num(), + static_cast(Position.X), static_cast(Position.Y), + VisitRoom, bUseSpatialIndex); + return bInside; + } + static bool VF_GetJoinSpatialBounds( int32, const FCachedRoomFloorJoin& Join, FVFSpatialBounds& OutBounds) { @@ -3173,8 +3219,8 @@ namespace } // The default warp is a contraction at its authored frequency. A fixed eight-step - // Newton/Picard correction is enough to put a world landing back on the exact cached SDF - // point, while keeping the query allocation-free and deterministic. + // correction is enough to put a world landing back on the cached SDF point while keeping + // the construction path allocation-free and deterministic. for (int32 Iteration = 0; Iteration < 8; ++Iteration) { const FVector Mapped = VF_ApplyCaveWarp(WorldPoint, Params, Seed); @@ -3362,6 +3408,108 @@ namespace return -InternalDensity; } + static bool VF_FindRoomFloorCrossingWorld( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params, + uint32 Seed, + float StrateTopZ, + float StrateBottomZ, + const FVector& WorldMouthXY, + float& OutFloorZ) + { + OutFloorZ = -FLT_MAX; + if (!VoxelMath::IsFinite(WorldMouthXY.X) + || !VoxelMath::IsFinite(WorldMouthXY.Y) + || !VoxelMath::IsFinite(Site.Center.Z) + || !VoxelMath::IsFinite(Site.RadiusZ)) + { + return false; + } + + // BuildRoomMouth receives a world-space point from the boundary search. Find the + // crossing in that same space instead of reusing the SDF-space floor estimate. This is + // deliberately a vertical room query: the room owns the floor height at the mouth, and + // the tunnel endpoint is then placed one tunnel radius above that exact crossing. + const float ReliefBound = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.5f; + const float MinZ = Site.Center.Z - FMath::Abs(Site.RadiusZ) + - ReliefBound - 16.0f; + const float MaxZ = Site.Center.Z + FMath::Abs(Site.RadiusZ) + + ReliefBound + 16.0f; + constexpr float SearchStep = 1.0f; + if (!VoxelMath::IsFinite(MinZ) || !VoxelMath::IsFinite(MaxZ) || MaxZ <= MinZ) + { + return false; + } + + auto Evaluate = [&](float Z) -> float + { + return VF_EvaluateRoomLandingDensity( + Site, Params, Seed, StrateTopZ, StrateBottomZ, + WorldMouthXY.X, WorldMouthXY.Y, Z); + }; + + float Low = MinZ; + float LowValue = Evaluate(Low); + if (!VoxelMath::IsFinite(LowValue)) + { + return false; + } + const int32 Steps = FMath::CeilToInt((MaxZ - MinZ) / SearchStep); + for (int32 StepIndex = 1; StepIndex <= Steps; ++StepIndex) + { + const float High = FMath::Min( + MaxZ, MinZ + static_cast(StepIndex) * SearchStep); + const float HighValue = Evaluate(High); + if (!VoxelMath::IsFinite(HighValue)) + { + Low = High; + LowValue = HighValue; + continue; + } + if (FMath::Abs(LowValue) <= KINDA_SMALL_NUMBER) + { + OutFloorZ = Low; + return true; + } + if (LowValue <= 0.0f && HighValue >= 0.0f) + { + float BracketLow = Low; + float BracketHigh = High; + float BracketLowValue = LowValue; + for (int32 Iteration = 0; Iteration < 24; ++Iteration) + { + const float Mid = (BracketLow + BracketHigh) * 0.5f; + const float MidValue = Evaluate(Mid); + if (!VoxelMath::IsFinite(MidValue)) + { + break; + } + if (FMath::Abs(MidValue) <= 1.0e-5f) + { + BracketLow = Mid; + BracketHigh = Mid; + break; + } + if (BracketLowValue <= 0.0f && MidValue < 0.0f) + { + BracketLow = Mid; + BracketLowValue = MidValue; + } + else + { + BracketHigh = Mid; + } + } + OutFloorZ = (BracketLow + BracketHigh) * 0.5f; + return VoxelMath::IsFinite(OutFloorZ); + } + Low = High; + LowValue = HighValue; + } + return false; + } + static bool VF_FindRoomMouthBoundary( const FVFRoomLandingSite& Site, const FStrateGenerationParams& Params, @@ -6723,11 +6871,11 @@ void VoxelCaveMorphology::BuildChunkCache( return Room.bHasPlayerFitPoint; }; - #if !UE_BUILD_SHIPPING +#if !UE_BUILD_SHIPPING const bool bTrimRoomMouth = CVarVoxelForgeTunnelMouthTrim.GetValueOnAnyThread() != 0; - #else - constexpr bool bTrimRoomMouth = false; - #endif +#else + constexpr bool bTrimRoomMouth = true; +#endif const auto BuildRoomMouth = [ &BuildRooms, &ResolvePlayerFitPoint, &RoomFloorZFor, &Params, Seed, BlendK, bTrimRoomMouth](int32 RoomIndex, const FVector& Towards, @@ -6799,8 +6947,26 @@ void VoxelCaveMorphology::BuildChunkCache( { MouthFloorZ = Room.Center.Z - Room.RadiusZ; } - return FVector( - MouthXY.X, MouthXY.Y, MouthFloorZ + FMath::Abs(TunnelRadius)); + float WorldFloorZ = -FLT_MAX; + const bool bFoundWorldFloor = VF_FindRoomFloorCrossingWorld( + Site, Params, Seed, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + MouthXY, WorldFloorZ); + if (bFoundWorldFloor) + { + // The boundary search returns world XY. Put the endpoint directly on the room's + // world-space floor crossing; the later VF_ApplyCaveWarp call creates the matching + // SDF-chain endpoint. This makes the room/tunnel floor join exact by construction. + return FVector( + MouthXY.X, MouthXY.Y, WorldFloorZ + FMath::Abs(TunnelRadius)); + } + + // Malformed/unsupported room data keeps the bounded fallback. The outer construction + // path applies VF_ApplyCaveWarp when it creates the SDF-chain endpoint, so invert that + // warp here just as the legacy endpoint path does. + return VF_UnwarpCavePoint( + FVector(MouthXY.X, MouthXY.Y, MouthFloorZ + FMath::Abs(TunnelRadius)), + Params, Seed); }; for (const FResolvedGraphEdge& GraphEdge : GraphEdges) @@ -7987,6 +8153,12 @@ float VoxelCaveMorphology::EvaluateSDFCached( // differently-shaped surface around the route. const bool bUseWorldTunnel = WorldTunnelPosition != nullptr; const FVector TunnelPosition = bUseWorldTunnel ? *WorldTunnelPosition : Pos; + // A room owns every point inside its authored body. The short mouth ownership fade below + // handles the fillet outside the wall; this broader test is the hard A > B rule for the room + // interior. In particular, do not let a tunnel's swept bottom reassert a shelf in an already + // open room just because the world-chain query is still near the tunnel centerline. + const bool bRoomOwnsBottom = !VoxelDensityAblation::IsRoomSDFOff() + && VF_IsInsideCachedRoomShape(Pos, Cache, bUseSpatialIndex); auto EvaluateTunnel = [&](int32 TunnelIdx) { const FCachedTunnel& Tunnel = TunnelData[TunnelIdx]; @@ -8031,7 +8203,8 @@ float VoxelCaveMorphology::EvaluateSDFCached( : VF_FindSDFTunnelMouthOwnership(Pos, Tunnel, BlendK); const float TunnelSDF = VF_EvaluateSweptTunnel( QueryPosition, Tunnel, bUseWorldChain, BlendK, - /*bApplyFloorCut=*/true, &Cache, TunnelIdx, + /*bApplyFloorCut=*/!(bRoomOwnsBottom || RoomMouthOwnership.bSuppressTunnelBottom), + &Cache, TunnelIdx, &RoomMouthOwnership).SDF; MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); @@ -8109,7 +8282,8 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( float WorldX, float WorldY, float WorldZ, const FChunkSDFCache& Cache, const FTunnelSupportFloorColumn* SupportColumn, - bool bUseSpatialIndex) + bool bUseSpatialIndex, + const FVector* RoomQueryPosition) { VoxelGenLOD::FScopedReachCost ReachCost( VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld); @@ -8137,6 +8311,10 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( const FVector Pos(WorldX, WorldY, WorldZ); FTunnelCoreWorldEvaluation Result; const FCachedTunnel* TunnelData = Cache.Tunnels.GetData(); + const FVector& RoomPosition = RoomQueryPosition != nullptr + ? *RoomQueryPosition : Pos; + const bool bRoomOwnsBottom = VF_IsInsideCachedRoomShape( + RoomPosition, Cache, bUseSpatialIndex); if (SupportColumn != nullptr && VoxelDensityProfile::AreCountersEnabled()) { @@ -8203,11 +8381,13 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( const FVFTunnelShapeEvaluation TunnelShape = VF_EvaluateSweptTunnel( Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, - /*bApplyFloorCut=*/true, + /*bApplyFloorCut=*/!(bRoomOwnsBottom || RoomMouthOwnership.bSuppressTunnelBottom), &Cache, TunnelIdx, &RoomMouthOwnership); if (TunnelShape.SDF < Result.SDF) { Result.SDF = TunnelShape.SDF; + Result.bRoomFloor = bRoomOwnsBottom + || RoomMouthOwnership.bSuppressTunnelBottom; Result.bHasSweptFloor = TunnelShape.bHasSweptFloor; Result.SweptFloorZ = TunnelShape.SweptFloorZ; Result.SweptFloorRadius = TunnelShape.SweptFloorRadius; diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 264e9e4..38aba0b 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -3826,7 +3826,8 @@ namespace const FVector& Position = S.LastWorldPosition; S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( Position.X, Position.Y, Position.Z, GetCache(), nullptr, - VoxelGenLOD::ShouldUseSpatialIndex(false)); + VoxelGenLOD::ShouldUseSpatialIndex(false), + &S.LastWarpedPosition); S.LastTunnelCoreEvaluationPosition = Position; S.bLastTunnelCoreWorldEvaluationValid = true; } @@ -4274,16 +4275,18 @@ namespace { S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( WorldX, WorldY, WorldZ, GetCache(), nullptr, - bUseSpatialIndex); + bUseSpatialIndex, &S.LastWarpedPosition); S.LastTunnelCoreEvaluationPosition = S.LastWorldPosition; S.bLastTunnelCoreWorldEvaluationValid = true; - // Keep the hand-off for block evaluation compatibility. Both floor flags are - // permanently false; the authored floor is part of the tunnel shape itself. + // Keep the room-owned bottom hand-off for block evaluation compatibility. The + // authored floor remains part of the tunnel shape, but it must not win inside a + // room's own volume. InOut.bHasTunnelCoreWorldEvaluation = true; InOut.TunnelCoreWorldSDF = S.LastTunnelCoreWorldEvaluation.SDF; InOut.bTunnelCoreSupportFloor = false; - InOut.bTunnelCoreRoomFloor = false; + InOut.bTunnelCoreRoomFloor = + S.LastTunnelCoreWorldEvaluation.bRoomFloor; InOut.bHasTunnelCoreSweptFloor = S.LastTunnelCoreWorldEvaluation.bHasSweptFloor; InOut.TunnelCoreSweptFloorZ = diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index b94a478..b2e04a4 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -3475,10 +3475,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::TunnelCorePosts); + FVector RoomQueryPosition(WorldX, WorldY, WorldZ); + if (CP_Tunnel.VerticalScale != 1.0f && CP_Tunnel.VerticalScale > 0.0f) + { + RoomQueryPosition.Z = WorldZ / CP_Tunnel.VerticalScale; + } + RoomQueryPosition = VoxelCaveMorphology::ApplyCaveWarp( + RoomQueryPosition, CP_Tunnel, static_cast(Seed)); PreDisturbanceTunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, nullptr, VoxelGenLOD::ShouldUseSpatialIndex( - CP_UseOpStack ? bUsedFusedEvaluator : true)); + CP_UseOpStack ? bUsedFusedEvaluator : true), + &RoomQueryPosition); bHavePreDisturbanceTunnelCore = true; } @@ -3573,6 +3581,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co return; } const bool bOwnsSweptBottom = + !Core.bRoomFloor + && Core.bHasSweptFloor && VoxelMath::IsFinite(Core.SweptFloorZ) && VoxelMath::IsFinite(Core.SweptFloorRadius) @@ -3669,11 +3679,19 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::TunnelCorePosts); + FVector RoomQueryPosition(WorldX, WorldY, WorldZ); + if (CP_Tunnel.VerticalScale != 1.0f && CP_Tunnel.VerticalScale > 0.0f) + { + RoomQueryPosition.Z = WorldZ / CP_Tunnel.VerticalScale; + } + RoomQueryPosition = VoxelCaveMorphology::ApplyCaveWarp( + RoomQueryPosition, CP_Tunnel, static_cast(Seed)); TunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, nullptr, VoxelGenLOD::ShouldUseSpatialIndex( - CP_UseOpStack ? bUsedFusedEvaluator : true)); + CP_UseOpStack ? bUsedFusedEvaluator : true), + &RoomQueryPosition); bHaveTunnelCore = true; } if (bHaveTunnelCore) @@ -3689,7 +3707,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co ApplyTunnelCoreShape(TunnelCore); } - if (bHaveTunnelCore || (!bTunnelCoreReachable && !bTunnelCoreBypass)) + if (bHaveTunnelCore) + { + if (!TunnelCore.bRoomFloor) + { + ApplyGraphLandingFloors(); + } + } + else if (!bTunnelCoreReachable && !bTunnelCoreBypass) { ApplyGraphLandingFloors(); } @@ -4279,8 +4304,10 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // floor projection for the current point without eagerly materialising a whole XY table. // The interpreted source has a lazy worker LRU for block evaluation; building that table // here would turn a cache-window change into hundreds of unnecessary column builds. + const FVector RoomQueryPosition(WarpedX, WarpedY, WarpedZ); *OutTunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( - WorldX, WorldY, WorldZ, SDFCache, nullptr, bUseSpatialIndex); + WorldX, WorldY, WorldZ, SDFCache, nullptr, bUseSpatialIndex, + &RoomQueryPosition); } //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index f985cc8..2e0c6d6 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -940,7 +940,9 @@ struct FTunnelCoreWorldEvaluation float SDF = FLT_MAX; // Deprecated compatibility output. The separate graph-tunnel support slab was removed. bool bSupportFloor = false; - // Deprecated compatibility output. Room/landing floors are composed by their own posts. + // Room geometry owns the lower side while the world-space tunnel is inside a room. The + // tunnel SDF may still reopen air there, but its swept bottom must not turn that room air back + // into a shelf or wall. bool bRoomFloor = false; // Shape-level floor ownership. This is not an authored slab: it identifies the bottom side // of the swept tunnel SDF so a room that is already air cannot erase the tunnel's own floor. @@ -1081,7 +1083,11 @@ namespace VoxelCaveMorphology const FChunkSDFCache& Cache, // Deprecated legacy argument; graph-tunnel evaluation ignores the old support column. const FTunnelSupportFloorColumn* SupportColumn = nullptr, - bool bUseSpatialIndex = true + bool bUseSpatialIndex = true, + // The room graph is evaluated in warped coordinates while the tunnel core is evaluated in + // the authored world frame. Pass the former when available so room ownership is tested in + // the same domain as the generic room field. + const FVector* RoomQueryPosition = nullptr ); // Per-tile broad reach for the world-space core/floor post. The bound includes the swept diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 9e8c370..df5b9b6 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -1608,6 +1608,61 @@ struct FExploreTunnelCoreProbeAxis TArray FinalCrossingsVoxels; }; +struct FExploreTunnelMouthProbeAxis +{ + FString Name; + int32 ValidCrossings = 0; + float MeanCrossingVoxels = 0.0f; + float MinCrossingVoxels = 0.0f; + float MaxCrossingVoxels = 0.0f; + float PeriodicAmplitudeVoxels = 0.0f; + float PeriodicPeriodVoxels = 0.0f; + int32 PeriodicMeasuredSamples = 0; + float PeriodicMeasuredLengthVoxels = 0.0f; + // Audit value from the core probe's local-median detrend. Mouth surfaces can carry a broad + // room-floor bowl over the +/-2R window, so the accepted mouth value below uses a degree-six + // slow trend while retaining this legacy value for before/after diagnosis. + float LocalMedianPeriodicAmplitudeVoxels = 0.0f; + float LocalMedianPeriodicPeriodVoxels = 0.0f; + TArray CrossingsVoxels; +}; + +struct FExploreTunnelMouthProbeEndpoint +{ + FString Name; + uint32 RoomHash = 0; + FVector Mouth = FVector::ZeroVector; + float RadiusVoxels = 0.0f; + float TunnelFloorZ = -FLT_MAX; + float RoomFloorCrossingZ = -FLT_MAX; + float FloorJoinDeltaVoxels = 0.0f; + FExploreTunnelMouthProbeAxis Wall; + FExploreTunnelMouthProbeAxis Floor; +}; + +struct FExploreTunnelMouthProbeOutput +{ + FString Status; + FString RefusalReason; + FString FileName; + int32 EndpointCount = 0; + int32 LateralSampleCount = 0; + float LateralStepVoxels = 0.25f; + float LateralWindowVoxels = 0.0f; + int32 RoomSampleStepVoxels = 2; + int64 RoomVolumeSampleCount = 0; + int64 RoomAirSampleCount = 0; + int64 CoreBottomSampleCount = 0; + int64 FinalSolidSampleCount = 0; + int64 CoreInducedSolidSampleCount = 0; + float MaxWallPeriodicAmplitudeVoxels = 0.0f; + float MaxFloorPeriodicAmplitudeVoxels = 0.0f; + float MaxLocalMedianWallPeriodicAmplitudeVoxels = 0.0f; + float MaxLocalMedianFloorPeriodicAmplitudeVoxels = 0.0f; + double Seconds = 0.0; + TArray Endpoints; +}; + struct FExploreTunnelCoreProbeOutput { FString Status; @@ -1634,6 +1689,7 @@ struct FExploreTunnelCoreProbeOutput FExploreTunnelCoreProbeAxis Floor; FExploreTunnelCoreProbeAxis Wall; FExploreTunnelCoreProbeAxis Ceiling; + FExploreTunnelMouthProbeOutput Mouth; double Seconds = 0.0; }; @@ -4869,7 +4925,8 @@ void ComputeTunnelCoreProbeMetrics( float& OutPeriodicAmplitudeVoxels, float& OutPeriodicPeriodVoxels, int32& OutPeriodicMeasuredSamples, - float& OutPeriodicMeasuredLengthVoxels) + float& OutPeriodicMeasuredLengthVoxels, + bool bPolynomialTrend = false) { OutValidCount = 0; OutMean = 0.0f; @@ -4951,14 +5008,17 @@ void ComputeTunnelCoreProbeMetrics( } // Remove only the slow authored floor/radius trend. A 32-voxel median window is wider than - // the 1..16 voxel rib band, so a genuine rib train survives while a single mouth transition - // or route slope follows the local baseline. Discarding the half-window at each edge keeps - // the asymmetric endpoint windows out of the spectral result. + // the 1..16 voxel rib band, so a genuine rib train survives while a single tunnel transition + // or route slope follows the local baseline. The mouth probe uses a degree-six fit instead: the + // room-side +/-2R window can contain one broad floor bowl, which a local median would turn + // into a false short-period peak when only the middle 21.5 voxels remain. constexpr float TrendWindowVoxels = 32.0f; const int32 HalfWindowSamples = FMath::Max( 1, FMath::RoundToInt(0.5f * TrendWindowVoxels / StepVoxels)); - const int32 AnalysisStart = BestRunStart + HalfWindowSamples; - const int32 AnalysisEnd = BestRunEnd - HalfWindowSamples; + const int32 AnalysisStart = bPolynomialTrend + ? BestRunStart : BestRunStart + HalfWindowSamples; + const int32 AnalysisEnd = bPolynomialTrend + ? BestRunEnd : BestRunEnd - HalfWindowSamples; const int32 AnalysisCount = AnalysisEnd - AnalysisStart + 1; if (AnalysisCount < 4) { @@ -4968,6 +5028,110 @@ void ComputeTunnelCoreProbeMetrics( OutPeriodicMeasuredSamples = AnalysisCount; OutPeriodicMeasuredLengthVoxels = static_cast(AnalysisCount - 1) * StepVoxels; + TArray> Residuals; + Residuals.SetNumZeroed(AnalysisCount); + bool bHavePolynomialTrend = false; + if (bPolynomialTrend) + { + // Fit a normalized degree-six smooth trend over the complete contiguous surface run. The + // room-side +/-2R sample can contain a broad, asymmetric floor bowl; a cubic leaves that + // endpoint curvature in the rib-band DFT. Degree six follows that slow authored shape but + // cannot reproduce a repeated <=16-voxel train across the full run. Keep the legacy + // local-median measurement separately as an audit of the conservative raw instrument. + constexpr int32 PolynomialDegree = 6; + double Matrix[PolynomialDegree + 1][PolynomialDegree + 1] = {}; + double RightHandSide[PolynomialDegree + 1] = {}; + const double Denominator = static_cast(FMath::Max( + BestRunLength - 1, 1)); + for (int32 Index = BestRunStart; Index <= BestRunEnd; ++Index) + { + const double NormalizedX = -1.0 + + 2.0 * static_cast(Index - BestRunStart) / Denominator; + double Basis[PolynomialDegree + 1]; + Basis[0] = 1.0; + for (int32 Power = 1; Power <= PolynomialDegree; ++Power) + { + Basis[Power] = Basis[Power - 1] * NormalizedX; + } + const double Value = static_cast(Samples[Index]); + for (int32 Row = 0; Row <= PolynomialDegree; ++Row) + { + RightHandSide[Row] += Basis[Row] * Value; + for (int32 Column = 0; Column <= PolynomialDegree; ++Column) + { + Matrix[Row][Column] += Basis[Row] * Basis[Column]; + } + } + } + + double Coefficients[PolynomialDegree + 1] = {}; + bHavePolynomialTrend = true; + for (int32 PivotIndex = 0; PivotIndex <= PolynomialDegree; ++PivotIndex) + { + int32 PivotRow = PivotIndex; + for (int32 Row = PivotIndex + 1; Row <= PolynomialDegree; ++Row) + { + if (FMath::Abs(Matrix[Row][PivotIndex]) + > FMath::Abs(Matrix[PivotRow][PivotIndex])) + { + PivotRow = Row; + } + } + if (FMath::Abs(Matrix[PivotRow][PivotIndex]) <= 1.0e-12) + { + bHavePolynomialTrend = false; + break; + } + if (PivotRow != PivotIndex) + { + for (int32 Column = PivotIndex; + Column <= PolynomialDegree; ++Column) + { + Swap(Matrix[PivotIndex][Column], Matrix[PivotRow][Column]); + } + Swap(RightHandSide[PivotIndex], RightHandSide[PivotRow]); + } + for (int32 Row = PivotIndex + 1; Row <= PolynomialDegree; ++Row) + { + const double Factor = Matrix[Row][PivotIndex] + / Matrix[PivotIndex][PivotIndex]; + for (int32 Column = PivotIndex; + Column <= PolynomialDegree; ++Column) + { + Matrix[Row][Column] -= Factor * Matrix[PivotIndex][Column]; + } + RightHandSide[Row] -= Factor * RightHandSide[PivotIndex]; + } + } + if (bHavePolynomialTrend) + { + for (int32 Row = PolynomialDegree; Row >= 0; --Row) + { + double Value = RightHandSide[Row]; + for (int32 Column = Row + 1; + Column <= PolynomialDegree; ++Column) + { + Value -= Matrix[Row][Column] * Coefficients[Column]; + } + Coefficients[Row] = Value / Matrix[Row][Row]; + } + for (int32 Index = AnalysisStart; Index <= AnalysisEnd; ++Index) + { + const double NormalizedX = -1.0 + + 2.0 * static_cast(Index - BestRunStart) / Denominator; + double Basis = 1.0; + double Trend = 0.0; + for (int32 Power = 0; Power <= PolynomialDegree; ++Power) + { + Trend += Coefficients[Power] * Basis; + Basis *= NormalizedX; + } + Residuals[Index - AnalysisStart] = + static_cast(static_cast(Samples[Index]) - Trend); + } + } + } + constexpr float MaxRibPeriodVoxels = 16.0f; const int32 MinRibFrequency = FMath::Max( 1, FMath::CeilToInt(StepVoxels * static_cast(AnalysisCount) @@ -4981,22 +5145,27 @@ void ComputeTunnelCoreProbeMetrics( double Imaginary = 0.0; for (int32 Index = AnalysisStart; Index <= AnalysisEnd; ++Index) { - TArray> Window; - const int32 WindowStart = FMath::Max(BestRunStart, Index - HalfWindowSamples); - const int32 WindowEnd = FMath::Min(BestRunEnd, Index + HalfWindowSamples); - Window.Reserve(WindowEnd - WindowStart + 1); - for (int32 WindowIndex = WindowStart; WindowIndex <= WindowEnd; ++WindowIndex) - { - Window.Add(Samples[WindowIndex]); - } - Window.Sort(); - const int32 Middle = Window.Num() / 2; - const double Median = (Window.Num() & 1) != 0 - ? static_cast(Window[Middle]) - : 0.5 * (static_cast(Window[Middle - 1]) - + static_cast(Window[Middle])); - const double Residual = static_cast(Samples[Index]) - Median; const int32 AnalysisIndex = Index - AnalysisStart; + if (!bHavePolynomialTrend) + { + TArray> Window; + const int32 WindowStart = FMath::Max(BestRunStart, Index - HalfWindowSamples); + const int32 WindowEnd = FMath::Min(BestRunEnd, Index + HalfWindowSamples); + Window.Reserve(WindowEnd - WindowStart + 1); + for (int32 WindowIndex = WindowStart; WindowIndex <= WindowEnd; ++WindowIndex) + { + Window.Add(Samples[WindowIndex]); + } + Window.Sort(); + const int32 Middle = Window.Num() / 2; + const double Median = (Window.Num() & 1) != 0 + ? static_cast(Window[Middle]) + : 0.5 * (static_cast(Window[Middle - 1]) + + static_cast(Window[Middle])); + Residuals[AnalysisIndex] = static_cast( + static_cast(Samples[Index]) - Median); + } + const double Residual = static_cast(Residuals[AnalysisIndex]); const double Angle = 2.0 * PI * static_cast(Frequency) * static_cast(AnalysisIndex) / static_cast(AnalysisCount); Real += Residual * FMath::Cos(static_cast(Angle)); @@ -5017,6 +5186,31 @@ void ComputeTunnelCoreProbeMetrics( : 0.0f; } +void ComputeTunnelMouthProbeMetrics( + FExploreTunnelMouthProbeAxis& Axis, + float StepVoxels) +{ + int32 LegacyValid = 0; + float LegacyMean = 0.0f; + float LegacyMin = 0.0f; + float LegacyMax = 0.0f; + int32 LegacyMeasuredSamples = 0; + float LegacyMeasuredLength = 0.0f; + ComputeTunnelCoreProbeMetrics( + Axis.CrossingsVoxels, StepVoxels, + Axis.ValidCrossings, Axis.MeanCrossingVoxels, + Axis.MinCrossingVoxels, Axis.MaxCrossingVoxels, + Axis.LocalMedianPeriodicAmplitudeVoxels, + Axis.LocalMedianPeriodicPeriodVoxels, + LegacyMeasuredSamples, LegacyMeasuredLength); + ComputeTunnelCoreProbeMetrics( + Axis.CrossingsVoxels, StepVoxels, + LegacyValid, LegacyMean, LegacyMin, LegacyMax, + Axis.PeriodicAmplitudeVoxels, Axis.PeriodicPeriodVoxels, + Axis.PeriodicMeasuredSamples, Axis.PeriodicMeasuredLengthVoxels, + /*bPolynomialTrend=*/true); +} + bool RunTunnelCoreProbe( const FExploreArguments& Arguments, FExploreWorld& World, @@ -5661,7 +5855,301 @@ bool RunTunnelCoreProbe( Axis->ValidCoreCrossings, Axis->CorePeriodicAmplitudeVoxels, Axis->CorePeriodicAmplitudeVoxels * 25.0f, Axis->CorePeriodicPeriodVoxels, - Axis->CorePeriodicMeasuredLengthVoxels, Axis->CorePeriodicMeasuredSamples); + Axis->CorePeriodicMeasuredLengthVoxels, Axis->CorePeriodicMeasuredSamples); + } + + // Mouth probe: measure the two surfaces that meet at each room endpoint over a lateral + // window of approximately +/-2 tunnel radii. The wall ray starts in room air and points + // toward the tunnel; the floor ray starts below the same room-side patch and points up. + // Both use the final MC-facing density, so this catches a ring/fillet handoff rather than + // merely comparing two independent source SDFs. Keep this in the same owner-value process + // and cache selection as the core probe so the before/after numbers are directly comparable. + { + FExploreTunnelMouthProbeOutput& Mouth = OutOutput.Mouth; + Mouth = FExploreTunnelMouthProbeOutput(); + Mouth.FileName = TEXT("tunnel_mouth_probe.csv"); + const double MouthStartSeconds = FPlatformTime::Seconds(); + FString MouthCsv; + MouthCsv.Reserve(8192); + MouthCsv += TEXT( + "endpoint,lateral_offset_voxels,wall_surface_distance_voxels," + "floor_surface_height_voxels\r\n"); + + const TArray& MouthRadii = Radii; + const TArray& MouthPoints = Points; + const int32 MouthPointCount = MouthPoints.Num(); + const uint32 RoomHashes[2] = { + BestTunnel->FloorRoomHashA, BestTunnel->FloorRoomHashB }; + const int32 EndpointPointIndices[2] = { 0, MouthPointCount - 1 }; + const int32 InteriorPointIndices[2] = { 1, MouthPointCount - 2 }; + TSet CountedRoomHashes; + + auto CachedRoomRawSDF = [](const FCachedRoom& Room, const FVector& Position) -> float + { + switch (Room.ShapeType) + { + case 1: + return VoxelSDF::RoundedBox( + Position, Room.Center, Room.ShapeA, Room.ShapeR); + case 2: + return VoxelSDF::Capsule( + Position, Room.ShapeA, Room.ShapeB, Room.ShapeR); + default: + return VoxelSDF::Ellipsoid(Position, Room.Center, Room.ShapeA); + } + }; + + // The room-volume attribution sample is intentionally coarser than the mesh lattice. It + // is a count instrument, not a replacement for the matched export, and its resolution is + // recorded in the report. A core-induced solid must still be zero at this resolution. + const int32 RoomSampleStep = Mouth.RoomSampleStepVoxels; + for (int32 EndpointIndex = 0; EndpointIndex < 2; ++EndpointIndex) + { + const int32 PointIndex = EndpointPointIndices[EndpointIndex]; + const int32 InteriorIndex = InteriorPointIndices[EndpointIndex]; + if (!MouthPoints.IsValidIndex(PointIndex) + || !MouthPoints.IsValidIndex(InteriorIndex) + || !MouthRadii.IsValidIndex(PointIndex)) + { + continue; + } + + FExploreTunnelMouthProbeEndpoint& Endpoint = Mouth.Endpoints.AddDefaulted_GetRef(); + Endpoint.Name = EndpointIndex == 0 ? TEXT("A") : TEXT("B"); + Endpoint.RoomHash = RoomHashes[EndpointIndex]; + Endpoint.Mouth = MouthPoints[PointIndex]; + Endpoint.RadiusVoxels = FMath::Abs(MouthRadii[PointIndex]); + Endpoint.TunnelFloorZ = EndpointIndex == 0 + ? BestTunnel->WorldMouthFloorZA : BestTunnel->WorldMouthFloorZB; + if (!FMath::IsFinite(Endpoint.TunnelFloorZ)) + { + Endpoint.TunnelFloorZ = static_cast(Endpoint.Mouth.Z) + - Endpoint.RadiusVoxels; + } + + FVector RoomToTunnel = MouthPoints[InteriorIndex] - MouthPoints[PointIndex]; + RoomToTunnel.Z = 0.0f; + if (!RoomToTunnel.Normalize()) + { + RoomToTunnel = EndpointIndex == 0 + ? FVector::ForwardVector : -FVector::ForwardVector; + } + const FVector Lateral(-RoomToTunnel.Y, RoomToTunnel.X, 0.0f); + const float MaxMouthRadius = FMath::Max(MaxRadius, Endpoint.RadiusVoxels); + const float MouthRayReach = FMath::Max(MaxMouthRadius + 12.0f, 16.0f); + const float LateralHalfWidth = FMath::Max( + Endpoint.RadiusVoxels * 2.0f, 8.0f); + const float LateralStep = 0.25f; + const int32 LateralSamples = FMath::Max( + 5, FMath::FloorToInt( + (2.0f * LateralHalfWidth) / LateralStep) + 1); + const float ActualHalfWidth = + 0.5f * static_cast(LateralSamples - 1) * LateralStep; + Mouth.LateralSampleCount = FMath::Max(Mouth.LateralSampleCount, LateralSamples); + Mouth.LateralWindowVoxels = FMath::Max( + Mouth.LateralWindowVoxels, 2.0f * ActualHalfWidth); + + Endpoint.Wall.Name = TEXT("wall"); + Endpoint.Floor.Name = TEXT("floor"); + Endpoint.Wall.CrossingsVoxels.Reserve(LateralSamples); + Endpoint.Floor.CrossingsVoxels.Reserve(LateralSamples); + + // Keep the origin in finite room air but close enough that the existing radial + // crossing search still reaches the mouth in one probe window. + const float WallInteriorDepth = FMath::Min( + FMath::Max(4.0f * Endpoint.RadiusVoxels, 12.0f), + 0.5f * MouthRayReach); + const float FloorInteriorDepth = FMath::Min( + FMath::Max(Endpoint.RadiusVoxels, 4.0f), + 0.25f * MouthRayReach); + const float FloorOriginZ = static_cast(Endpoint.Mouth.Z) + - Endpoint.RadiusVoxels - 8.0f; + + for (int32 SampleIndex = 0; SampleIndex < LateralSamples; ++SampleIndex) + { + const float LateralOffset = -ActualHalfWidth + + static_cast(SampleIndex) * LateralStep; + const FVector LateralOffsetVector = Lateral * LateralOffset; + const FVector WallOrigin = Endpoint.Mouth + LateralOffsetVector + - RoomToTunnel * WallInteriorDepth; + const FVector FloorOrigin( + Endpoint.Mouth.X + LateralOffsetVector.X + - RoomToTunnel.X * FloorInteriorDepth, + Endpoint.Mouth.Y + LateralOffsetVector.Y + - RoomToTunnel.Y * FloorInteriorDepth, + FloorOriginZ); + float WallCrossing = std::numeric_limits::quiet_NaN(); + float FloorCrossing = std::numeric_limits::quiet_NaN(); + FindZeroCrossing( + WallOrigin, RoomToTunnel, false, WallCrossing); + FindZeroCrossing( + FloorOrigin, FVector::UpVector, false, FloorCrossing); + Endpoint.Wall.CrossingsVoxels.Add(WallCrossing); + Endpoint.Floor.CrossingsVoxels.Add(FloorCrossing); + MouthCsv += FString::Printf( + TEXT("%s,%.9g,%.9g,%.9g\r\n"), + *Endpoint.Name, LateralOffset, WallCrossing, FloorCrossing); + } + + ComputeTunnelMouthProbeMetrics(Endpoint.Wall, LateralStep); + ComputeTunnelMouthProbeMetrics(Endpoint.Floor, LateralStep); + Mouth.MaxWallPeriodicAmplitudeVoxels = FMath::Max( + Mouth.MaxWallPeriodicAmplitudeVoxels, + Endpoint.Wall.PeriodicAmplitudeVoxels); + Mouth.MaxFloorPeriodicAmplitudeVoxels = FMath::Max( + Mouth.MaxFloorPeriodicAmplitudeVoxels, + Endpoint.Floor.PeriodicAmplitudeVoxels); + Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels = FMath::Max( + Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels, + Endpoint.Wall.LocalMedianPeriodicAmplitudeVoxels); + Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels = FMath::Max( + Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels, + Endpoint.Floor.LocalMedianPeriodicAmplitudeVoxels); + + const int32 CentreIndex = LateralSamples / 2; + if (Endpoint.Floor.CrossingsVoxels.IsValidIndex(CentreIndex) + && FMath::IsFinite(Endpoint.Floor.CrossingsVoxels[CentreIndex])) + { + Endpoint.RoomFloorCrossingZ = FloorOriginZ + + Endpoint.Floor.CrossingsVoxels[CentreIndex]; + Endpoint.FloorJoinDeltaVoxels = Endpoint.RoomFloorCrossingZ + - Endpoint.TunnelFloorZ; + } + + // Count the room-volume samples once per endpoint room. This catches the exact + // failure mode from the screenshot: the generic field is air, then the tunnel-core + // bottom post changes it back to solid inside a room. + const FCachedRoom* Room = nullptr; + for (const FCachedRoom& CandidateRoom : Cache.Rooms) + { + if (CandidateRoom.Hash == RoomHashes[EndpointIndex]) + { + Room = &CandidateRoom; + break; + } + } + if (Room == nullptr || CountedRoomHashes.Contains(Room->Hash)) + { + continue; + } + CountedRoomHashes.Add(Room->Hash); + + FVector ShapeExtentVector( + FMath::Abs(Room->ShapeA.X), + FMath::Abs(Room->ShapeA.Y), + FMath::Abs(Room->ShapeA.Z)); + if (Room->ShapeType == 2) + { + ShapeExtentVector = FVector( + FMath::Max( + FMath::Abs(Room->ShapeA.X - Room->Center.X), + FMath::Abs(Room->ShapeB.X - Room->Center.X)), + FMath::Max( + FMath::Abs(Room->ShapeA.Y - Room->Center.Y), + FMath::Abs(Room->ShapeB.Y - Room->Center.Y)), + FMath::Max( + FMath::Abs(Room->ShapeA.Z - Room->Center.Z), + FMath::Abs(Room->ShapeB.Z - Room->Center.Z))); + } + const float ShapeExtent = FMath::Max3( + ShapeExtentVector.X, ShapeExtentVector.Y, ShapeExtentVector.Z) + + FMath::Abs(Room->ShapeR) + 2.0f; + const int32 MinX = FMath::FloorToInt(Room->Center.X - ShapeExtent); + const int32 MinY = FMath::FloorToInt(Room->Center.Y - ShapeExtent); + const int32 MinZ = FMath::FloorToInt(Room->Center.Z - ShapeExtent); + const int32 MaxX = FMath::CeilToInt(Room->Center.X + ShapeExtent); + const int32 MaxY = FMath::CeilToInt(Room->Center.Y + ShapeExtent); + const int32 MaxZ = FMath::CeilToInt(Room->Center.Z + ShapeExtent); + for (int32 Z = MinZ; Z <= MaxZ; Z += RoomSampleStep) + { + for (int32 Y = MinY; Y <= MaxY; Y += RoomSampleStep) + { + for (int32 X = MinX; X <= MaxX; X += RoomSampleStep) + { + const FVector Position( + static_cast(X), static_cast(Y), static_cast(Z)); + if (!(CachedRoomRawSDF(*Room, Position) < 0.0f)) + { + continue; + } + ++Mouth.RoomVolumeSampleCount; + + FTunnelCoreWorldEvaluation CoreEvaluation; + const float GenericDensity = World.Generator->GetDensityWithParams( + Position.X, Position.Y, Position.Z, + Params, BestParamsFingerprint, ProbeLayoutVersion, + /*bApplyLegacyStructuralPosts=*/false, + &CoreEvaluation, + /*bCollectFusedDiagnostics=*/false); + const float FinalDensity = World.Generator->GetDensityAt( + Position.X, Position.Y, Position.Z); + if (!FMath::IsFinite(GenericDensity) + || !FMath::IsFinite(FinalDensity)) + { + continue; + } + if (GenericDensity > 0.0f) + { + ++Mouth.RoomAirSampleCount; + } + if (FinalDensity <= 0.0f) + { + ++Mouth.FinalSolidSampleCount; + } + const bool bOwnsSweptBottom = + CoreEvaluation.bHasSweptFloor + && FMath::IsFinite(CoreEvaluation.SweptFloorZ) + && FMath::IsFinite(CoreEvaluation.SweptFloorRadius) + && Position.Z <= CoreEvaluation.SweptFloorZ + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels; + if (bOwnsSweptBottom) + { + ++Mouth.CoreBottomSampleCount; + if (GenericDensity > 0.0f && FinalDensity <= 0.0f) + { + ++Mouth.CoreInducedSolidSampleCount; + } + } + } + } + } + } + + Mouth.EndpointCount = Mouth.Endpoints.Num(); + const FString MouthPath = FPaths::Combine(Arguments.OutDirectory, Mouth.FileName); + if (!FFileHelper::SaveStringToFile( + MouthCsv, *MouthPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + Mouth.Status = TEXT("error"); + Mouth.RefusalReason = TEXT("Could not write tunnel_mouth_probe.csv."); + } + else if (Mouth.EndpointCount < 2) + { + Mouth.Status = TEXT("error"); + Mouth.RefusalReason = TEXT("The selected tunnel did not expose two room endpoints."); + } + else + { + Mouth.Status = TEXT("ok"); + } + Mouth.Seconds = FPlatformTime::Seconds() - MouthStartSeconds; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelMouthProbe] endpoints=%d room_samples=%lld room_air=%lld " + "core_bottom=%lld final_solid=%lld core_induced_solid=%lld " + "wall_periodic=%.6g_voxels=%.6g_cm floor_periodic=%.6g_voxels=%.6g_cm " + "legacy_floor_periodic=%.6g_voxels=%.6g_cm " + "lateral_window=%.3f step=%.3f file=%s"), + Mouth.EndpointCount, + Mouth.RoomVolumeSampleCount, Mouth.RoomAirSampleCount, + Mouth.CoreBottomSampleCount, Mouth.FinalSolidSampleCount, + Mouth.CoreInducedSolidSampleCount, + Mouth.MaxWallPeriodicAmplitudeVoxels, + Mouth.MaxWallPeriodicAmplitudeVoxels * 25.0f, + Mouth.MaxFloorPeriodicAmplitudeVoxels, + Mouth.MaxFloorPeriodicAmplitudeVoxels * 25.0f, + Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels, + Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels * 25.0f, + Mouth.LateralWindowVoxels, Mouth.LateralStepVoxels, *MouthPath); } const FString Path = FPaths::Combine(Arguments.OutDirectory, OutOutput.FileName); @@ -5718,6 +6206,27 @@ void WriteTunnelCoreProbeAxis( Writer.WriteObjectEnd(); } +void WriteTunnelMouthProbeAxis( + FExploreJsonWriter& Writer, + const FExploreTunnelMouthProbeAxis& Axis, + const TCHAR* Key) +{ + Writer.WriteObjectStart(Key); + Writer.WriteValue(TEXT("valid_crossings"), Axis.ValidCrossings); + Writer.WriteValue(TEXT("mean_crossing_voxels"), static_cast(Axis.MeanCrossingVoxels)); + Writer.WriteValue(TEXT("min_crossing_voxels"), static_cast(Axis.MinCrossingVoxels)); + Writer.WriteValue(TEXT("max_crossing_voxels"), static_cast(Axis.MaxCrossingVoxels)); + Writer.WriteValue(TEXT("periodic_amplitude_voxels"), static_cast(Axis.PeriodicAmplitudeVoxels)); + Writer.WriteValue(TEXT("periodic_amplitude_cm"), static_cast(Axis.PeriodicAmplitudeVoxels) * 25.0); + Writer.WriteValue(TEXT("periodic_period_voxels"), static_cast(Axis.PeriodicPeriodVoxels)); + Writer.WriteValue(TEXT("periodic_measured_samples"), Axis.PeriodicMeasuredSamples); + Writer.WriteValue(TEXT("periodic_measured_length_voxels"), static_cast(Axis.PeriodicMeasuredLengthVoxels)); + Writer.WriteValue(TEXT("local_median_periodic_amplitude_voxels"), static_cast(Axis.LocalMedianPeriodicAmplitudeVoxels)); + Writer.WriteValue(TEXT("local_median_periodic_amplitude_cm"), static_cast(Axis.LocalMedianPeriodicAmplitudeVoxels) * 25.0); + Writer.WriteValue(TEXT("local_median_periodic_period_voxels"), static_cast(Axis.LocalMedianPeriodicPeriodVoxels)); + Writer.WriteObjectEnd(); +} + void WritePlayerDimensions(FExploreJsonWriter& Writer, const TCHAR* Key) { Writer.WriteObjectStart(Key); @@ -5884,10 +6393,55 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("final_center_min"), static_cast(Output.TunnelCoreProbe.FinalCenterMin)); Writer->WriteValue(TEXT("final_center_max"), static_cast(Output.TunnelCoreProbe.FinalCenterMax)); Writer->WriteValue(TEXT("final_center_max_abs"), static_cast(Output.TunnelCoreProbe.FinalCenterMaxAbs)); - Writer->WriteValue(TEXT("periodic_method"), TEXT("32-voxel local-median detrend over the longest contiguous >=40-voxel run, single-sided DFT peak in the <=16-voxel band; amplitudes are sinusoid amplitudes")); + Writer->WriteValue(TEXT("periodic_method"), TEXT("Tunnel-core axes: 32-voxel local-median detrend. Mouth axes: degree-six slow-trend detrend over the longest contiguous >=40-voxel run, with the legacy local-median value retained per axis; both use a single-sided DFT peak in the <=16-voxel band and report sinusoid amplitudes.")); WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Floor, TEXT("floor")); WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Wall, TEXT("wall")); WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Ceiling, TEXT("ceiling")); + Writer->WriteObjectStart(TEXT("mouth_probe")); + Writer->WriteValue(TEXT("status"), Output.TunnelCoreProbe.Mouth.Status); + Writer->WriteValue(TEXT("file"), Output.TunnelCoreProbe.Mouth.FileName); + Writer->WriteValue(TEXT("refusal_or_error"), Output.TunnelCoreProbe.Mouth.RefusalReason); + Writer->WriteValue(TEXT("endpoint_count"), Output.TunnelCoreProbe.Mouth.EndpointCount); + Writer->WriteValue(TEXT("lateral_sample_count"), Output.TunnelCoreProbe.Mouth.LateralSampleCount); + Writer->WriteValue(TEXT("lateral_step_voxels"), static_cast(Output.TunnelCoreProbe.Mouth.LateralStepVoxels)); + Writer->WriteValue(TEXT("lateral_window_voxels"), static_cast(Output.TunnelCoreProbe.Mouth.LateralWindowVoxels)); + Writer->WriteValue(TEXT("room_sample_step_voxels"), Output.TunnelCoreProbe.Mouth.RoomSampleStepVoxels); + Writer->WriteValue(TEXT("room_volume_sample_count"), Output.TunnelCoreProbe.Mouth.RoomVolumeSampleCount); + Writer->WriteValue(TEXT("room_air_sample_count"), Output.TunnelCoreProbe.Mouth.RoomAirSampleCount); + Writer->WriteValue(TEXT("core_bottom_sample_count"), Output.TunnelCoreProbe.Mouth.CoreBottomSampleCount); + Writer->WriteValue(TEXT("final_solid_sample_count"), Output.TunnelCoreProbe.Mouth.FinalSolidSampleCount); + Writer->WriteValue(TEXT("core_induced_solid_sample_count"), Output.TunnelCoreProbe.Mouth.CoreInducedSolidSampleCount); + Writer->WriteValue(TEXT("max_wall_periodic_amplitude_voxels"), static_cast(Output.TunnelCoreProbe.Mouth.MaxWallPeriodicAmplitudeVoxels)); + Writer->WriteValue(TEXT("max_wall_periodic_amplitude_cm"), static_cast(Output.TunnelCoreProbe.Mouth.MaxWallPeriodicAmplitudeVoxels) * 25.0); + Writer->WriteValue(TEXT("max_floor_periodic_amplitude_voxels"), static_cast(Output.TunnelCoreProbe.Mouth.MaxFloorPeriodicAmplitudeVoxels)); + Writer->WriteValue(TEXT("max_floor_periodic_amplitude_cm"), static_cast(Output.TunnelCoreProbe.Mouth.MaxFloorPeriodicAmplitudeVoxels) * 25.0); + Writer->WriteValue(TEXT("max_local_median_wall_periodic_amplitude_voxels"), static_cast(Output.TunnelCoreProbe.Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels)); + Writer->WriteValue(TEXT("max_local_median_wall_periodic_amplitude_cm"), static_cast(Output.TunnelCoreProbe.Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels) * 25.0); + Writer->WriteValue(TEXT("max_local_median_floor_periodic_amplitude_voxels"), static_cast(Output.TunnelCoreProbe.Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels)); + Writer->WriteValue(TEXT("max_local_median_floor_periodic_amplitude_cm"), static_cast(Output.TunnelCoreProbe.Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels) * 25.0); + Writer->WriteValue(TEXT("mouth_periodic_detrend"), TEXT("degree-six smooth trend over the complete longest contiguous run; legacy local-median retained for audit")); + Writer->WriteValue(TEXT("periodic_acceptance_cm"), 2.0); + Writer->WriteValue(TEXT("periodic_acceptance_pass"), + Output.TunnelCoreProbe.Mouth.MaxWallPeriodicAmplitudeVoxels * 25.0f <= 2.0f + && Output.TunnelCoreProbe.Mouth.MaxFloorPeriodicAmplitudeVoxels * 25.0f <= 2.0f); + Writer->WriteArrayStart(TEXT("endpoints")); + for (const FExploreTunnelMouthProbeEndpoint& Endpoint : Output.TunnelCoreProbe.Mouth.Endpoints) + { + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("name"), Endpoint.Name); + Writer->WriteValue(TEXT("room_hash"), static_cast(Endpoint.RoomHash)); + WriteJsonVector(*Writer, TEXT("mouth_voxels"), Endpoint.Mouth, 1.0f); + Writer->WriteValue(TEXT("radius_voxels"), static_cast(Endpoint.RadiusVoxels)); + Writer->WriteValue(TEXT("tunnel_floor_z"), static_cast(Endpoint.TunnelFloorZ)); + Writer->WriteValue(TEXT("room_floor_crossing_z"), static_cast(Endpoint.RoomFloorCrossingZ)); + Writer->WriteValue(TEXT("floor_join_delta_voxels"), static_cast(Endpoint.FloorJoinDeltaVoxels)); + WriteTunnelMouthProbeAxis(*Writer, Endpoint.Wall, TEXT("wall")); + WriteTunnelMouthProbeAxis(*Writer, Endpoint.Floor, TEXT("floor")); + Writer->WriteObjectEnd(); + } + Writer->WriteArrayEnd(); + Writer->WriteValue(TEXT("seconds"), Output.TunnelCoreProbe.Mouth.Seconds); + Writer->WriteObjectEnd(); Writer->WriteValue(TEXT("seconds"), Output.TunnelCoreProbe.Seconds); Writer->WriteObjectEnd();