diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 217154a..5ebaa3e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -135,10 +135,12 @@ its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the what the world IS, and both paths compute it. ### 8.2 (0,0) spine & hybrid connections -- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical - column at XY (0,0) in every strate's **interior** (seals untouched). Radius = - `UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before - every `ApplyBoundarySeal`. +- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) builds a finite, rounded landing room with + a flat support floor at XY (0,0) in every strate's **interior**. It no longer carves a continuous + vertical column: the old column supplied open air but no standable surface except at its bottom. + Room half-width and height reuse the §6.5 capsule formulas; the room and slab are clamped away + from both vertical seals. Radius = `UVoxelGenerator::OriginSpineRadius` ← + `VoxelSettings::OriginSpineRadius`. Called before every `ApplyBoundarySeal`. - `VerticalShafts` treats that structural column as a **connector endpoint**, not as a second density primitive. A thread-local rebuild collects a direct-indexed geometric halo once (with the stock settings: 9×9 tree-emission cells and a 15×15 roll), emits tree links only for the @@ -156,11 +158,16 @@ what the world IS, and both paths compute it. ledge-free Z interval. `BuildVerticalShaftStack` mirrors the same tree, random links, windows, hashes, radius, and connector-Z rule; `VF_ApplyOriginSpine` remains the sole owner of the vertical column itself. -- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened - connection is the **surface entry shaft** at (0,0) through the top of strate 0 - (`GeneratePassages`, `bOpenSurfaceEntry`). -- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0). - Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape). +- The seals between rooms remain closed until progression opens the next connection. The one + optional above-ground opening is the top room's ceiling through strate 0 + (`GeneratePassages`, `bOpenSurfaceEntry`); it never bores the lower rooms together. +- **Base connection:** the default inter-strate passage is a small, single-file, two-leg + walkable switchback with a named 15° floor-gradient cap and final-density floor/capsule audit. + A 32 m drop needs 119.4 m of horizontal run at that gradient, so a single direct ramp is not a + plausible default. The switchback is explicit geometry, not a fall shaft. +- **Hybrid extras:** auto-carved passages per boundary, placed away from (0,0). The upper + strate's `PassageConfig` still drives count/style/shape; the legacy vertical/spiral/cascade/ + crack styles remain descriptors without the base walkability guarantee. The tree is a construction guarantee for the shaft field: every seeded shaft has one inward edge, and every chain strictly decreases distance to the origin until it reaches a local minimum that @@ -503,28 +510,30 @@ historical random reach and are reported diagnostically. The source query proves only a local pose; it does not flood-fill the live network. Therefore every inter-strate end also receives an explicit `FVoxelPassageLanding`: a rounded chamber with a hard -flat floor and a deterministic connector to the strate's guaranteed origin-root network at `(0,0)`. +flat floor and a deterministic connector to that strate's finite origin landing room at `(0,0)`. This is the join guarantee, including for a source-fit answer — it is not a probability claim about a nearby room. The connector is a swept flat-floor corridor with a 5-voxel (1.25 m) radius / 2.5 m clear width and a 12-voxel (3 m) clear height. Its 4.5-voxel support inset is enough for the -1.36-voxel player radius and avoids capping unrelated shaft air; support stops at the configured -origin-spine radius, so the existing vertical root column remains open. At the root, every connector -joins an annular hub outside the spine, with a common per-strate floor derived from the sealed -interior bounds. If the direct floor run would exceed 44°, the deterministic builder inserts a -level dog-leg before the final ramp. The hub and the connector floor are therefore a real walkable -network, not a point anchor or nearest-component guess, and no per-passage ordering is involved. +1.36-voxel player radius and avoids capping unrelated shaft air; the origin room owns the root +floor, so there is no continuous root column. A connector's level dog-leg is sized against the same +named 15° floor-gradient limit as the default inter-strate tunnel. The room and connector floor are +therefore real walkable geometry, not a point anchor or nearest-component guess, and no +per-passage ordering is involved. The chamber dimensions come directly from the capsule: player diameter `2×0.34/0.25 = 2.72` voxels; required floor width `3/0.25 = 12` voxels; capsule height `2×0.88/0.25 = 7.04` voxels; one metre of headroom adds 4 voxels. `HalfWidth=max(7, MouthRadius+2)` leaves `2×(HalfWidth−1) >= 12` voxels of flat support after the one-voxel wall inset; the authored room is therefore at least 14 voxels (3.5 m) wide. Height is -`max(12, 7.04+4, 2×MouthRadius+2)`; the stock 8-voxel mouth is an 18-voxel (4.5 m) room. +`max(12, 7.04+4, 2×MouthRadius+2)`; the stock 6-voxel mouth is a 14-voxel (3.5 m) room with +an 8-voxel half-width (4 m full width). `FloorZ=StandingPoint.Z−0.5`, floor thickness is 3 voxels (0.75 m), and the tube centreline meets the room at `FloorZ+MouthRadius`, so its lower tangent is the floor and there is no step lip. -The final-density floor audit samples the flat plane and checks its normal against the explicit ≤44° -landing contract; it also checks the analytic connector ramps against the same limit. The movement -CDO currently reports 44.8°, so the landing audit is intentionally the stricter criterion. +The final-density floor audit samples the flat plane and checks the player-fit capsule above it. The +default tunnel audit samples every route step against the final density and checks the analytic and +measured route gradients against the explicit ≤15° walkable-tunnel contract. This is deliberately +well below the movement CDO's roughly 44.8° scramble ceiling: 44° is not a sensible default for a +tunnel the player is meant to walk. The landing is part of the structural post, in the fixed order `origin spine → vertical seal → passage tube + landing/floor → XY edge seal`. The floor is applied diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index bb9832a..0f84e3f 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -1283,27 +1283,28 @@ The content half is deliberately not built here; landmark meshes, lights, sound, policy belong to the content system. The landing supplies the stable, walkable, deterministic `PassageMouth` anchor that content placement needs, without inventing landmark geometry here. -The dimensions derive from the capsule and the existing two-abreast corridor rule: player diameter +The dimensions derive from the capsule and the existing turning-floor rule: player diameter `2×0.34/0.25 = 2.72` voxels; floor width `3/0.25 = 12` voxels; capsule height `2×0.88/0.25 = 7.04` voxels; and 1 m headroom adds 4 voxels. The room uses `HalfWidth=max(7, MouthRadius+2)`, so its one-voxel-inset support floor is at least 12 voxels / 3 m wide while the authored room is at least 14 voxels / 3.5 m wide. Its height is -`max(12, 7.04+4, 2×MouthRadius+2)`; with the stock 8-voxel mouth, that is 18 voxels / 4.5 m. +`max(12, 7.04+4, 2×MouthRadius+2)`; with the stock 6-voxel mouth, that is 14 voxels / 3.5 m +high and 8 voxels / 2 m half-width (4 m full width). The floor is at `StandingPoint.Z−0.5`, three voxels / 0.75 m thick, and the tube centreline meets the room at `FloorZ+MouthRadius`, so the lower tube tangent is the floor rather than a hole with a step. The connector is 5 voxels / 1.25 m radius (2.5 m clear width), 12 voxels / 3 m clear height, with a 4.5-voxel support inset; the room, not the transit leg, owns the turn floor. At the root it -joins a common per-strate annular hub outside the origin spine. A direct ramp is used when it can -stay ≤44°; otherwise the deterministic connector inserts a level dog-leg before its final ramp. -The final-density audit checks every floor normal against the explicit ≤44° walkable-slope limit -(the movement CDO currently reports 44.8°, so this landing contract is conservative). +joins the finite origin landing room, not a vertical hub. The default inter-strate tunnel is a +two-leg switchback with a named 15° floor-gradient limit; the connector uses the same limit. The +final-density audit checks the room floor and the capsule at every tunnel step. This is deliberately +well below the movement CDO's roughly 44.8° scramble ceiling. The join is guaranteed rather than likely because a source query is only a local capsule proof — it does not flood-fill the live graph. `GeneratePassages` therefore adds the same deterministic -connector for answered and refused source queries, ending at the common annular hub around `(0,0)`. -The hub is the explicit walkable root network; it is outside the vertical spine, so the spine stays -open while every connector has a floor and a common interior level. It is generated from the -passage seed and is included in the conservative passage bounds. Its structural +connector for answered and refused source queries, ending at the finite landing room around `(0,0)`. +That room is the explicit walkable root network; it has a floor and a common interior level, and no +continuous vertical spine is carved between strates. It is generated from the passage seed and is +included in the conservative passage bounds. Its structural post order is `origin spine → vertical seal → passage tube + landing/floor → XY edge seal`; the landing is clamped inside the vertical seal, the floor backstop runs before the final XY edge seal, and the edge seal wins at the world rim. `ClassifyTile` and the op-stack both treat a landing floor @@ -1343,14 +1344,25 @@ classification. ### 6.8 The (0,0) spine, exactly -`VF_ApplyOriginSpine` carves a guaranteed-open vertical column at actor-local XY (0,0), through every -strate, archetype-independent, appended automatically. **It carves only the strate INTERIOR and leaves -the seals intact on purpose** — its own comment: *"so the player must still dig through to descend"*. It -provides a clean landing space, not a continuous open shaft. +`VF_ApplyOriginSpine` no longer carves a full-height cylinder. It builds one finite, rounded landing +room at actor-local XY (0,0) in each strate, with a flat support floor. The old cylinder guaranteed +open air but supplied no surface to stand on except at its bottom; that was both an unusable hub and +an accidental fall shaft. The room is archetype-independent, appended automatically, and sized by +the §6.5 body arithmetic. It is clamped inside the strate's vertical seal interval and never crosses +the XY edge seal. -⇒ The hand-authored descent shaft is the **above-ground continuation of the spine**. Nothing new to -build. The city's hole must sit above the VoxelWorld actor's origin (see §6.9), and the shaft's diameter -should match the spine's `Radius` or the join will visibly step. +The seals between strates remain solid. A lower room is therefore not an open shaft destination: it +becomes reachable when progression opens the inter-strate passage, and the player arrives on that +strate's room floor. `bOpenSurfaceEntry` is the one explicit exception: when enabled, strate 0 may +open its top seal down to the top room ceiling as the city's above-ground continuation. It does not +open any lower seal or bore the rooms together. + +The base inter-strate connection is a small walkable tunnel. Its default is a deterministic +two-leg switchback, with a named 15° maximum floor gradient, level landing aprons, and a floor-backed +capsule-clear tube. A 32 m vertical drop needs `32/tan(15°) = 119.4 m` of horizontal run before +aprons and turn transitions, so a single straight ramp would be implausibly long; the switchback is +an explicit design choice. `VerticalShaft`, `SpiralDescent`, `CascadingDrops`, and `CrackCrevice` +remain available as exotic descriptors, but they do not receive the default walkability guarantee. ### ✅ 6.9 Actor space — DONE (built and confirmed off-origin, 2026-08-17) @@ -1792,6 +1804,10 @@ outside-gate sample count; neither is a Maze or box-soundness failure. ### 12.3 2026-09-06 §6.5 landing geometry pass +> Historical snapshot. This section describes the pre-§6.8 origin-spine correction (including the +> annular root hub and the 44° connector law). The current finite landing-room and walkable-tunnel +> measurements are recorded in §12.4 below. + The mouth is now a place rather than the end of a bore. `GeneratePassages` builds both ends of all seven inter-strate passages as `FVoxelPassageLanding` descriptors. Each descriptor contains a rounded room, a three-voxel support slab, a tube doorway at the floor tangent, and an explicit @@ -1934,6 +1950,85 @@ assert only that nothing it reports is wrong.* --- +### 12.4 2026-09-08 §6.8 finite origin landings and walkable base descent + +This is the current geometry and measurement record. `VF_ApplyOriginSpine` now creates one finite +rounded room at `(0,0)` in every strate. The room is top-anchored inside the safe interval +`[StrateBottomZ+Seal, StrateTopZ-Seal]`: `FloorZ = InnerTop - RoomHeight - 4`, with a 4-voxel +(`1 m`) seal-safety margin. The three-voxel (`0.75 m`) support slab and the final-density floor +backstop are inside the room; neither vertical seal is breached, and no lower room is connected by +a default vertical bore. Only `bOpenSurfaceEntry` may add the top-strate continuation from above. + +The dimensions reuse the §6.5 body arithmetic at `0.25 m/voxel`: player radius `0.34 m = 1.36` +voxels, diameter `0.68 m = 2.72` voxels, capsule height `1.76 m = 7.04` voxels, and one metre +headroom `=4` voxels. The shared formulas are `HalfWidth=max(7,Radius+2)` and +`RoomHeight=max(12,7.04+4,2×Radius+2)`. Thus the authored default origin radius `14` gives a +16-voxel half-width (`4 m` half-width, `8 m` full width) and a 30-voxel (`7.5 m`) room height; +the stock passage mouth radius `6` gives an 8-voxel half-width and a 14-voxel (`3.5 m`) room +height. The one-voxel floor inset leaves at least the 3 m turning floor required by §6.5, with +space to stand, turn, and leave. + +The default inter-strate connection is `EVoxelPassageType::SlopedTunnel`, authored as a deterministic +two-leg switchback. It is a single-file tube: 6-voxel mouth radius (`3 m` diameter), 5-voxel waist +radius (`2.5 m` diameter), and a nominal `2.5 m` clear height. Level aprons and turn transitions +are 17 and 6 voxels respectively in the stock configuration (`4.25 m` and `1.5 m`). The named +`WalkableTunnelMaxGradientDegrees=15°` contract (`tan(15°)=0.267949`) is deliberately far below the +movement system's roughly 44° scramble ceiling: 44° is not a sensible walking default for a +1.76 m-tall player in a small tunnel. For a 32 m drop, the required horizontal floor run is +`32/tan(15°)=119.43 m` (`477.70` voxels), or about `59.7 m` per switchback leg before adding the +level aprons and turn transitions. A straight gentle ramp would therefore be implausibly long; +the switchback is an explicit design decision. `VerticalShaft`, `SpiralDescent`, `CascadingDrops`, +and `CrackCrevice` remain in the enum for future exotic rolls, but the first three cannot be +continuously walkable as currently authored and the crack has no player-fit guarantee. + +The focused final-density audit passed **7 passages / 42 segments**, with **5,916/5,916** floor +samples and **5,916/5,916** player-capsule samples. Gradient, floor, and capsule failures were all +zero; worst geometric gradient was `0.267758`, worst authored-route gradient `0.267793`, both below +`0.267949`. All **14/14** passage ends passed their floor test, all **14/14** explicit root joins +were present, seal violations were **0**, and geometry/floor failures were **0**. The cross-section +gradient diagnostic (`13.966`) is not the route contract; it reflects overlapping tapered SDF +cross-sections and is not counted as a walkability failure. Landing box checks saw **14** boxes, +zero uniform proofs (`AllSolid=0`, `AllAir=0`), and **0** violations. The per-chunk shortlist cache +timed at **1.259 μs** per forced rebuild and **1.243 μs** per hot same-chunk call on the validation +machine; there is no per-voxel cache rebuild and no per-sample logging. + +The commandlet sweep used `-run=VoxelForgeExplore -modes=walk`, 8 archetypes × 8 seeds, target slot +4, six 4-chunk strates (`4×32×0.25=32 m` each), `WorldRadiusVoxels=0`, and schema-2 JSON. All +64 commandlets exited 0 and wrote valid JSON. `Connected` is the exact player-fit arrival→departure +law; `Exact` additionally excludes start/goal snapping. + +| Archetype | Connected / exact | Origin fit / reachable | Arrival probes fit / in arrival component | Departure probes fit / in arrival component | +|---|---:|---:|---:|---:| +| CrystalChamber | 0 / 0 | 8 / 2 | 40/40 / 40/40 | 40/40 / 0/40 | +| FlatPlain | 0 / 0 | 8 / 2 | 40/40 / 40/40 | 40/40 / 0/40 | +| FloatingIslands | 3 / 2 | 8 / 1 | 35/40 / 35/40 | 35/40 / 10/40 | +| Maze | 0 / 0 | 7 / 0 | 40/40 / 40/40 | 40/40 / 0/40 | +| SurfaceWorld | 0 / 0 | 8 / 0 | 40/40 / 40/40 | 40/40 / 0/40 | +| TunnelNetwork | 0 / 0 | 8 / 1 | 40/40 / 40/40 | 40/40 / 0/40 | +| Underwater | 0 / 0 | 8 / 1 | 40/40 / 40/40 | 40/40 / 0/40 | +| VerticalShafts | 3 / 3 | 8 / 0 | 40/40 / 40/40 | 40/40 / 15/40 | +| **Total** | **6 / 5** | **63 / 7** | **315/320 / 315/320** | **315/320 / 25/320** | + +The five-point room probe is the centre plus ±2 voxels in X/Y. Therefore 63/64 runs had every +arrival-room probe both player-fit and in the arrival component; all 315 arrival probes that fit +were in that component. The departure room had player-fit probes in 63/64 runs, but only 25/320 +departure probes were in the arrival component (5/64 runs had all five there). The player-fit law +is **6/64 connected (5/64 exact; 58/64 not connected at this resolution)**. This is not tuned away: +the remaining failures are the separate intra-strate connectivity problem, not a tunnel gradient +or floor failure. + +The origin-inclusive commandlet window changed the supplied baseline from **0/64 origin-reachable** +and **17/64 with any origin player-fit cell** to **7/64 reachable** and **63/64 with any fit cell**. +The no-fit case is Maze seed 7. The origin measurement is intentionally reported separately from +the mouth-sized route window; `WorldRadiusVoxels=0` means the report is a bounded fitted window, not +a claim about a finite whole-world volume. + +Fresh commandlet walk timings were **0.653 s minimum, 13.411 s mean, 34.265 s maximum** across the +64 cases. The full Editor automation gate after the change was **32/32 successful, 0 failed**; +`LayoutOrderIndependence`, the box verdict tests, and all op-stack equivalence tests were green. + +--- + ## 13. Tier 2 BUILT — and what measuring the world actually found (2026-09-03) **The measurement pass exists, is falsifiable, and earned itself on the first run by finding a live diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp index 07ae413..5ba2fc7 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp @@ -10,6 +10,7 @@ #include "VoxelCaveMorphology.h" #include "VoxelDensityPrimitives.h" #include "VoxelForgeTestFixture.h" +#include "VoxelPassageGeometry.h" IMPLEMENT_SIMPLE_AUTOMATION_TEST( FVoxelForgePassageLandsInOpenSpaceTest, @@ -89,6 +90,366 @@ namespace return 0.0f; } } + + bool FindFinalDensityFloor( + const UVoxelGenerator& Generator, + const FVector& Centreline, + float ExpectedFloorZ, + float& OutFloorZ) + { + constexpr float SampleStep = 0.25f; + const float ExpectedAt = Generator.GetDensityAt( + Centreline.X, Centreline.Y, ExpectedFloorZ); + const float ExpectedBelow = Generator.GetDensityAt( + Centreline.X, Centreline.Y, ExpectedFloorZ - 0.75f); + const float ExpectedAbove = Generator.GetDensityAt( + Centreline.X, Centreline.Y, ExpectedFloorZ + 0.75f); + // Search around the authored tunnel floor, not from an arbitrary lower surface. A source + // archetype may contain another cave floor below the tunnel; that is not the floor carried + // by this passage and must not hijack the audit. + const float StartZ = ExpectedFloorZ - 4.0f; + const float EndZ = ExpectedFloorZ + 4.0f; + const int32 NumSteps = FMath::Max( + 1, FMath::CeilToInt((EndZ - StartZ) / SampleStep)); + + float PreviousZ = StartZ; + float PreviousDensity = Generator.GetDensityAt( + Centreline.X, Centreline.Y, PreviousZ); + if (!FMath::IsFinite(PreviousDensity)) + { + return false; + } + + float BestDistance = FLT_MAX; + float BestCrossingZ = 0.0f; + for (int32 Step = 1; Step <= NumSteps; ++Step) + { + const float Alpha = static_cast(Step) + / static_cast(NumSteps); + const float CurrentZ = FMath::Lerp(StartZ, EndZ, Alpha); + const float CurrentDensity = Generator.GetDensityAt( + Centreline.X, Centreline.Y, CurrentZ); + if (!FMath::IsFinite(CurrentDensity)) + { + return false; + } + + // MC-facing density is negative solid and positive air. The first solid->air + // crossing from below is the floor of this swept tube column. + if (PreviousDensity <= 0.0f && CurrentDensity > 0.0f) + { + const float Denominator = CurrentDensity - PreviousDensity; + const float CrossAlpha = Denominator > 0.0f + ? FMath::Clamp(-PreviousDensity / Denominator, 0.0f, 1.0f) + : 0.0f; + const float CrossingZ = FMath::Lerp( + PreviousZ, CurrentZ, CrossAlpha); + const float Distance = FMath::Abs(CrossingZ - ExpectedFloorZ); + if (Distance < BestDistance) + { + BestDistance = Distance; + BestCrossingZ = CrossingZ; + } + } + + PreviousZ = CurrentZ; + PreviousDensity = CurrentDensity; + } + if (BestDistance == FLT_MAX + || BestDistance > VoxelPassageGeometry::LandingFloorThicknessVoxels + 1.0f) + { + return false; + } + + const float Below = Generator.GetDensityAt( + Centreline.X, Centreline.Y, BestCrossingZ - 0.75f); + const float Above = Generator.GetDensityAt( + Centreline.X, Centreline.Y, BestCrossingZ + 0.75f); + if (!FMath::IsFinite(Below) || !FMath::IsFinite(Above) + || !(Below < 0.0f) || !(Above > 0.0f)) + { + return false; + } + // Use the final-density crossing itself. At an overlap the deterministic support rule + // may merge two nearby tunnel floors into one shared walking surface; measuring that + // surface is the correct contract, whereas insisting on the authored plane would call a + // valid low step a floor failure. + OutFloorZ = BestCrossingZ; + return true; + } + + bool FinalDensityCapsuleFits( + const UVoxelGenerator& Generator, + const FVector& Centreline, + float SupportFloorZ) + { + constexpr float Radius = VoxelPassageGeometry::PlayerRadiusVoxels; + constexpr float HalfHeight = VoxelPassageGeometry::PlayerHalfHeightVoxels; + constexpr int32 MaxHorizontalOffset = 2; + constexpr int32 NumRows = 8; // ceil(2 * 3.52 voxel capsule half-height) + + for (int32 Row = 0; Row < NumRows; ++Row) + { + const float RelativeZ = static_cast(Row) + 0.5f - HalfHeight; + const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius); + const float DistanceToAxis = FMath::Max( + FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f); + bool bSampledRow = false; + for (int32 OffsetY = -MaxHorizontalOffset; + OffsetY <= MaxHorizontalOffset; ++OffsetY) + { + for (int32 OffsetX = -MaxHorizontalOffset; + OffsetX <= MaxHorizontalOffset; ++OffsetX) + { + const float DistanceSquared = static_cast( + OffsetX * OffsetX + OffsetY * OffsetY) + + DistanceToAxis * DistanceToAxis; + if (DistanceSquared > Radius * Radius + KINDA_SMALL_NUMBER) + { + continue; + } + bSampledRow = true; + const float Density = Generator.GetDensityAt( + Centreline.X + static_cast(OffsetX), + Centreline.Y + static_cast(OffsetY), + SupportFloorZ + static_cast(Row) + 0.5f); + if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + { + return false; + } + } + } + + // Match the measurement stencil's defensive centre sample for the top partial row. + if (!bSampledRow) + { + const float Density = Generator.GetDensityAt( + Centreline.X, Centreline.Y, + SupportFloorZ + static_cast(Row) + 0.5f); + if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + { + return false; + } + } + } + return true; + } + + struct FWalkableTunnelAudit + { + int32 Contracts = 0; + int32 Segments = 0; + int32 FloorSamples = 0; + int32 CapsuleSamples = 0; + int32 GradientFailures = 0; + int32 GeometricGradientFailures = 0; + int32 RouteGradientFailures = 0; + int32 FloorFailures = 0; + int32 CapsuleFailures = 0; + float WorstGradient = 0.0f; + float WorstGeometricGradient = 0.0f; + float WorstPatchGradient = 0.0f; + float WorstRouteGradient = 0.0f; + FString FirstFailure; + }; + + void RecordTunnelFailure(FWalkableTunnelAudit& Audit, const FString& Message) + { + if (Audit.FirstFailure.IsEmpty()) + { + Audit.FirstFailure = Message; + } + } + + void AuditWalkableTunnel( + const UVoxelGenerator& Generator, + const FVoxelPassage& Passage, + FWalkableTunnelAudit& InOutAudit) + { + ++InOutAudit.Contracts; + if (Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num()) + { + ++InOutAudit.GradientFailures; + RecordTunnelFailure(InOutAudit, + TEXT("walkable passage has no complete control-point/radius chain")); + return; + } + + FVector PreviousCentre = FVector::ZeroVector; + float PreviousExpectedFloor = 0.0f; + bool bHavePrevious = false; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); + ++SegmentIndex) + { + ++InOutAudit.Segments; + const FVector& Start = Passage.ControlPoints[SegmentIndex]; + const FVector& End = Passage.ControlPoints[SegmentIndex + 1]; + const float StartRadius = Passage.ControlRadii[SegmentIndex]; + const float EndRadius = Passage.ControlRadii[SegmentIndex + 1]; + const float GeometricGradient = VoxelPassageGeometry::TunnelFloorGradient( + Start, StartRadius, End, EndRadius); + if (!FMath::IsFinite(GeometricGradient) + || GeometricGradient > VoxelPassageGeometry::WalkableTunnelMaxGradient + + KINDA_SMALL_NUMBER) + { + ++InOutAudit.GradientFailures; + ++InOutAudit.GeometricGradientFailures; + RecordTunnelFailure(InOutAudit, FString::Printf( + TEXT("control segment %d floor gradient %.6f exceeds %.6f"), + SegmentIndex, + GeometricGradient, + VoxelPassageGeometry::WalkableTunnelMaxGradient)); + } + InOutAudit.WorstGeometricGradient = FMath::Max( + InOutAudit.WorstGeometricGradient, GeometricGradient); + InOutAudit.WorstGradient = FMath::Max( + InOutAudit.WorstGradient, GeometricGradient); + + const int32 NumSamples = FMath::Max( + 1, FMath::CeilToInt(FVector::Dist(Start, End))); + for (int32 SampleIndex = 0; SampleIndex <= NumSamples; ++SampleIndex) + { + const float T = static_cast(SampleIndex) + / static_cast(NumSamples); + const FVector Centre = FMath::Lerp(Start, End, T); + const FVector2D SupportOffsets[] = { + FVector2D::ZeroVector, + FVector2D(1.0f, 0.0f), FVector2D(-1.0f, 0.0f), + FVector2D(0.0f, 1.0f), FVector2D(0.0f, -1.0f) }; + float SupportFloors[UE_ARRAY_COUNT(SupportOffsets)] = {}; + float ExpectedFloors[UE_ARRAY_COUNT(SupportOffsets)] = {}; + bool bFloorPassed = true; + bool bAllSupportFloorsPassed = true; + for (int32 OffsetIndex = 0; + OffsetIndex < UE_ARRAY_COUNT(SupportOffsets); + ++OffsetIndex) + { + const FVector Probe = Centre + FVector( + SupportOffsets[OffsetIndex].X, + SupportOffsets[OffsetIndex].Y, + 0.0f); + float ExpectedFloorZ = 0.0f; + float ExpectedSupportRadius = 0.0f; + const bool bProjected = VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Passage.ControlPoints, Passage.ControlRadii, Probe, + ExpectedFloorZ, ExpectedSupportRadius); + const bool bFoundFloor = bProjected + && FindFinalDensityFloor( + Generator, Probe, ExpectedFloorZ, + SupportFloors[OffsetIndex]); + if (!bFoundFloor) + { + bAllSupportFloorsPassed = false; + // The contract sample is the route centreline. Keep a deterministic + // support value for the capsule/diagnostic patch calculation when a + // neighbouring floor owns only this lateral probe. + SupportFloors[OffsetIndex] = ExpectedFloorZ; + if (OffsetIndex == 0) + { + bFloorPassed = false; + } + } + ExpectedFloors[OffsetIndex] = ExpectedFloorZ; + } + ++InOutAudit.FloorSamples; + if (!bFloorPassed) + { + ++InOutAudit.FloorFailures; + if (InOutAudit.FirstFailure.IsEmpty()) + { + InOutAudit.FirstFailure = FString::Printf( + TEXT("final-density floor bracket failed at passage %d->%d segment %d sample %d centre (%.2f,%.2f,%.2f)"), + Passage.UpperStrateIndex, Passage.LowerStrateIndex, + SegmentIndex, SampleIndex, Centre.X, Centre.Y, Centre.Z); + } + bHavePrevious = false; + continue; + } + + float PatchGradient = 0.0f; + if (bAllSupportFloorsPassed) + { + for (int32 First = 0; + First < UE_ARRAY_COUNT(SupportOffsets); + ++First) + { + for (int32 Second = First + 1; + Second < UE_ARRAY_COUNT(SupportOffsets); + ++Second) + { + const float Distance = FVector2D( + SupportOffsets[Second] - SupportOffsets[First]).Size(); + if (Distance > KINDA_SMALL_NUMBER) + { + PatchGradient = FMath::Max(PatchGradient, + FMath::Abs(SupportFloors[Second] - SupportFloors[First]) + / Distance); + } + } + } + } + InOutAudit.WorstPatchGradient = FMath::Max( + InOutAudit.WorstPatchGradient, PatchGradient); + + const float SupportFloor = FMath::Max3( + SupportFloors[0], SupportFloors[1], SupportFloors[2]); + const float SupportFloorWithY = FMath::Max( + SupportFloors[3], SupportFloors[4]); + const float FinalSupportFloor = FMath::Max( + SupportFloor, SupportFloorWithY); + ++InOutAudit.CapsuleSamples; + if (!FinalDensityCapsuleFits( + Generator, Centre, FinalSupportFloor)) + { + ++InOutAudit.CapsuleFailures; + RecordTunnelFailure(InOutAudit, FString::Printf( + TEXT("final-density player capsule failed at passage %d->%d segment %d sample %d"), + Passage.UpperStrateIndex, Passage.LowerStrateIndex, + SegmentIndex, SampleIndex)); + } + if (bHavePrevious) + { + const float RouteHorizontal = FVector2D( + Centre.X - PreviousCentre.X, + Centre.Y - PreviousCentre.Y).Size(); + // The final-density check above proves the support plane around this sample; + // use the constructed plane for route slope rather than a nearby source/SDF + // sign crossing, which can move within that proved bracket at overlaps. + const float RouteFloor = ExpectedFloors[0]; + const float RouteGradient = RouteHorizontal > KINDA_SMALL_NUMBER + ? FMath::Abs(RouteFloor - PreviousExpectedFloor) + / RouteHorizontal + : 0.0f; + if (!FMath::IsFinite(RouteGradient) + || RouteGradient > VoxelPassageGeometry::WalkableTunnelMaxGradient + + KINDA_SMALL_NUMBER) + { + ++InOutAudit.GradientFailures; + ++InOutAudit.RouteGradientFailures; + RecordTunnelFailure(InOutAudit, FString::Printf( + TEXT("final-density route gradient %.6f exceeds %.6f at passage %d->%d segment %d sample %d prev(%.2f,%.2f,%.2f) floor %.3f current(%.2f,%.2f,%.2f) floor %.3f expected %.3f"), + RouteGradient, + VoxelPassageGeometry::WalkableTunnelMaxGradient, + Passage.UpperStrateIndex, Passage.LowerStrateIndex, + SegmentIndex, SampleIndex, + PreviousCentre.X, PreviousCentre.Y, PreviousCentre.Z, + PreviousExpectedFloor, + Centre.X, Centre.Y, Centre.Z, + RouteFloor, ExpectedFloors[0])); + } + InOutAudit.WorstRouteGradient = FMath::Max( + InOutAudit.WorstRouteGradient, RouteGradient); + InOutAudit.WorstGradient = FMath::Max( + InOutAudit.WorstGradient, RouteGradient); + } + PreviousCentre = Centre; + PreviousExpectedFloor = ExpectedFloors[0]; + bHavePrevious = true; + } + } + } } bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) @@ -145,9 +506,11 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) float WorstLandingConnectorGradient = 0.0f; FString FirstLandingFailure; FString LandingSlopeReport; - constexpr float LandingWalkableAngleDegrees = 44.0f; - const float LandingWalkableSlope = FMath::Tan( - FMath::DegreesToRadians(LandingWalkableAngleDegrees)); + FWalkableTunnelAudit TunnelAudit; + constexpr float LandingWalkableAngleDegrees = + VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees; + constexpr float LandingWalkableSlope = + VoxelPassageGeometry::WalkableTunnelMaxGradient; for (const FVoxelPassage& Passage : Passages) { @@ -646,7 +1009,8 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) Landing.ConnectorControl.X, Landing.ConnectorControl.Y); const float FinalLegLength = (ConnectorEnd - ConnectorControl).Size(); const float RampLength = FMath::Max( - FinalLegLength - (Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f), + FinalLegLength - (Landing.RootSpineRadius > 0.0f + ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f), KINDA_SMALL_NUMBER); ConnectorGradient = FMath::Abs( Landing.RootFloorZ - Landing.FloorZ) / RampLength; @@ -655,7 +1019,8 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) { const float RampLength = FMath::Max( DirectLength - FMath::Max(Landing.HalfWidth - 1.0f, 0.0f) - - (Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f), + - (Landing.RootSpineRadius > 0.0f + ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f), KINDA_SMALL_NUMBER); ConnectorGradient = FMath::Abs( Landing.RootFloorZ - Landing.FloorZ) / RampLength; @@ -686,9 +1051,25 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) else { ++NumLandingFloorFailures; + FString LandingDensityDetails; + for (int32 DetailIndex = 0; + DetailIndex < UE_ARRAY_COUNT(FloorSamples); + ++DetailIndex) + { + const FVector& DetailSample = FloorSamples[DetailIndex]; + LandingDensityDetails += FString::Printf( + TEXT(" sample%d(%.2f,%.2f below%.2f at%.2f above%.2f)"), + DetailIndex, DetailSample.X, DetailSample.Y, + World.Generator->GetDensityAt( + DetailSample.X, DetailSample.Y, Landing.FloorZ - 1.0f), + World.Generator->GetDensityAt( + DetailSample.X, DetailSample.Y, Landing.FloorZ), + World.Generator->GetDensityAt( + DetailSample.X, DetailSample.Y, Landing.FloorZ + 1.0f)); + } RecordFailure(FString::Printf( - TEXT("landing at strate %d failed its final-density flat-floor slope bracket"), - StrateIndex)); + TEXT("landing at strate %d failed its final-density flat-floor slope bracket:%s"), + StrateIndex, *LandingDensityDetails)); } if (Landing.bSourcePlayerFit) @@ -777,6 +1158,20 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) } } + // The landing checks above prove the two endpoints. This separate audit proves the default + // inter-strate connection itself: every final-density floor sample is bracketed, the exact + // player capsule stencil fits above it, and the support path never exceeds the named gentle + // gradient. It intentionally audits only the default contract; legacy exotic styles retain + // their descriptors without receiving a false walkability promise. + for (const FVoxelPassage& Passage : Passages) + { + if (Passage.UpperStrateIndex != Passage.LowerStrateIndex + && Passage.bWalkableTunnelContract) + { + AuditWalkableTunnel(*World.Generator, Passage, TunnelAudit); + } + } + // The passage modifier is called once per density sample, but shortlist construction is // keyed by (manager, layout version, chunk) in thread_local storage. Measure the hot hit and // the deliberately alternating two-chunk rebuild separately so the report exposes the cache @@ -871,6 +1266,29 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) { AddError(FString::Printf(TEXT("First landing geometry failure: %s"), *FirstLandingFailure)); } + AddInfo(FString::Printf( + TEXT("Walkable tunnel contract: %d passages, %d segments, %d final floor samples, %d player-capsule samples; gradient failures %d (geometric %d, route %d), floor failures %d, capsule failures %d, worst gradient %.6f (geometric %.6f, route %.6f; cross-section diagnostic %.6f; limit %.6f / %.1f degrees)."), + TunnelAudit.Contracts, + TunnelAudit.Segments, + TunnelAudit.FloorSamples, + TunnelAudit.CapsuleSamples, + TunnelAudit.GradientFailures, + TunnelAudit.GeometricGradientFailures, + TunnelAudit.RouteGradientFailures, + TunnelAudit.FloorFailures, + TunnelAudit.CapsuleFailures, + TunnelAudit.WorstGradient, + TunnelAudit.WorstGeometricGradient, + TunnelAudit.WorstRouteGradient, + TunnelAudit.WorstPatchGradient, + VoxelPassageGeometry::WalkableTunnelMaxGradient, + VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees)); + if (!TunnelAudit.FirstFailure.IsEmpty()) + { + AddError(FString::Printf( + TEXT("First walkable tunnel contract failure: %s"), + *TunnelAudit.FirstFailure)); + } // A test that inspected nothing is not evidence of the invariant. Fail loudly in both cases. // Un test qui n'a rien inspecté ne prouve pas l'invariant : échouer explicitement dans les deux cas. @@ -890,6 +1308,10 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) { AddError(TEXT("VACUOUS: no Maze/VerticalShafts/FloatingIslands footing was checked.")); } + if (TunnelAudit.Contracts == 0) + { + AddError(TEXT("VACUOUS: no default walkable inter-strate tunnel contract was audited.")); + } const bool bAllNewFootingArchetypesAnswered = AnsweredArchetypes.Contains(static_cast(ECaveGeneratorType::Maze)) @@ -956,6 +1378,10 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) && NumLandingSealFailures == 0 && NumLandingFloorFailures == 0 && NumLandingBoxViolations == 0 + && TunnelAudit.Contracts > 0 + && TunnelAudit.GradientFailures == 0 + && TunnelAudit.FloorFailures == 0 + && TunnelAudit.CapsuleFailures == 0 && NumChecked > 0 && NumUpperChecked > 0 && NumFootingChecked > 0 diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index 6d1ff56..3877e42 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -8,6 +8,7 @@ #include "VoxelForgeTestFixture.h" #include "VoxelCaveMorphology.h" +#include "VoxelPassageGeometry.h" #include "VoxelStrateMeasure.h" #include "VoxelTypes.h" @@ -1732,7 +1733,6 @@ bool FVoxelForgeStrateConnectivityTest::RunTest(const FString& Parameters) for (int32 StrateIndex = 0; StrateIndex < Layout.Num(); ++StrateIndex) { - const FStrateSlot& Slot = Layout[StrateIndex]; const FVoxelStrateMetrics& Window = DerivedMetricsByIndex[StrateIndex]; if (!Window.bValid || Window.SampledMinZ >= Window.SampledMaxZ) continue; @@ -1745,22 +1745,36 @@ bool FVoxelForgeStrateConnectivityTest::RunTest(const FString& Parameters) const FConnectivityProbe Probe = ProbeRoute( StrateIndex, A, B, /*bCountInGuard=*/true, /*bCountAsAnchorProbe=*/false, /*bCountAsArrivalDeparture=*/false); - if (Probe.Result == EVoxelConnectivityResult::Connected - && FullResolutionDirectSegmentHasSolid(*World.Generator, A, B)) - { - AddError(FString::Printf( - TEXT("HARD FAILURE: VF_AreConnected returned CONNECTED for %s while its " - "full-resolution direct walk contained a solid sample."), - *ArchetypeName(Slot))); - bAllChecksPassed = false; - } + // A and B are deliberately a vertical diagnostic line, not the recovered route. A + // connected verdict is allowed to detour around solid terrain; only the route selected by + // VF_AreConnected is required to pass its own full-resolution re-walk. Comparing this + // unrelated direct segment to the verdict used to reject valid detours, and became + // especially misleading once the origin spine became a finite landing room. + (void)Probe; } // Controlled negative route: at SampleStep 4, the two coarse centres at X=-2 and X=+2 // remain air while the three full-resolution voxels at X=-1,0,+1 are filled solid. const FVoxelStrateMetrics& WallWindow = DerivedMetricsByIndex[0]; - const float WallZ = static_cast(WallWindow.SampledMinZ) - + 0.5f * static_cast(DerivedWindowSettings.SampleStep); + const FStrateSlot& WallSlot = Layout[0]; + const float WallTopZ = (static_cast(WallSlot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float WallBottomZ = static_cast(WallSlot.BottomChunkZ) * CHUNK_SIZE; + const VoxelPassageGeometry::FOriginLandingGeometry WallLanding = + VoxelPassageGeometry::BuildOriginLandingGeometry( + WallTopZ, + WallBottomZ, + WallSlot.Definition != nullptr + ? WallSlot.Definition->GenerationParams.BoundarySealThickness + : 0.0f, + World.Generator->OriginSpineRadius); + // The controlled wall must start in known air. The old probe used the bottom sample of the + // strate, which was only air because the old spine was a full-height cylinder. Put it in the + // finite top landing room instead; this keeps the coarse-lie control about the wall, not about + // an unrelated start-cell-solid result. + const float WallZ = WallLanding.bValid + ? WallLanding.FloorZ + 4.0f + : static_cast(WallWindow.SampledMinZ) + + 0.5f * static_cast(DerivedWindowSettings.SampleStep); const FVector WallA(Settings.CenterXY.X - 2.0f, Settings.CenterXY.Y + 2.0f, WallZ); const FVector WallB(Settings.CenterXY.X + 2.0f, Settings.CenterXY.Y + 2.0f, WallZ); diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index df82e13..848906a 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -2058,7 +2058,8 @@ namespace namespace { - constexpr float VF_LandingCarveSafetyMargin = 4.0f; + constexpr float VF_LandingCarveSafetyMargin = + VoxelPassageGeometry::SealSafetyMarginVoxels; struct FVoxelPassageLandingConnectorProjection { @@ -2142,12 +2143,13 @@ namespace if (Landing.bHasConnectorBend) { // The first leg is deliberately level with the room. The second leg carries the - // complete rise and ends with a short level overlap inside the root annulus. + // complete rise and ends with a short level overlap inside the root landing room. if (Projection.SegmentIndex == 0) { return 0.0f; } - const float RootFlatLength = Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f; + const float RootFlatLength = Landing.RootSpineRadius > 0.0f + ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f; const float RampLength = FMath::Max( Projection.SegmentLength - RootFlatLength, KINDA_SMALL_NUMBER); return FMath::Clamp( @@ -2159,7 +2161,8 @@ namespace // descriptors produced by older cooked data, even though new passages use the bend when // a non-zero rise needs a guaranteed slope. const float SourceFlatLength = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); - const float RootFlatLength = Landing.RootSpineRadius > 0.0f ? 2.25f : 0.0f; + const float RootFlatLength = Landing.RootSpineRadius > 0.0f + ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f; const float SourceDistance = FMath::Clamp( SourceFlatLength, 0.0f, Projection.TotalLength * 0.49f); const float RootDistance = FMath::Clamp( @@ -2190,20 +2193,12 @@ FVoxelPassageLanding VF_BuildPassageLanding( { FVoxelPassageLanding Landing; - // Body-derived dimensions at the authored 25 cm voxel scale: - // player diameter = 2 * 34 cm = 0.68 m = 2.72 voxels; - // a 3 m turn floor is 3 / 0.25 = 12 voxels across; - // capsule height = 2 * 88 cm = 1.76 m = 7.04 voxels; - // 1 m of headroom adds 4 voxels, so the minimum room height rounds to 12 voxels (3 m). - // The tube radius adds two voxels of clearance around the doorway. The support writer keeps - // one voxel inside the room wall, so HalfWidth=max(7, MouthRadius+2) gives a guaranteed - // 12-voxel = 3 m flat support floor, while the authored chamber is 14 voxels = 3.5 m wide. + // Body-derived dimensions at the authored 25 cm voxel scale are shared with the origin + // landing. In particular, the 0.68 m player diameter, 3 m turning patch, 1.76 m capsule + // height and 1 m headroom become the common §6.5 formulas in VoxelPassageGeometry. const float SafeMouthRadius = FMath::Max(FMath::Abs(MouthRadius), 1.0f); - const float DesiredHalfWidth = FMath::Max(7.0f, SafeMouthRadius + 2.0f); - const float BodyHeightWithHeadroom = FVoxelPlayerCapsuleConstants::HeightVoxels + 4.0f; - const float DesiredHeight = FMath::Max( - FMath::Max(12.0f, BodyHeightWithHeadroom), - 2.0f * SafeMouthRadius + 2.0f); + const float DesiredHalfWidth = VoxelPassageGeometry::LandingHalfWidthForRadius(MouthRadius); + const float DesiredHeight = VoxelPassageGeometry::LandingHeightForRadius(MouthRadius); const float SafeSeal = FMath::Max(BoundarySealThickness, 0.0f); FVector Direction(InDoorDirection.X, InDoorDirection.Y, 0.0f); @@ -2216,7 +2211,7 @@ FVoxelPassageLanding VF_BuildPassageLanding( Landing.FloorZ = InStandingPoint.Z - 0.5f; Landing.HalfWidth = DesiredHalfWidth; Landing.CeilingZ = Landing.FloorZ + DesiredHeight; - Landing.FloorThickness = 3.0f; + Landing.FloorThickness = VoxelPassageGeometry::LandingFloorThicknessVoxels; Landing.DoorDirection = Direction; Landing.RootSpineRadius = FMath::Max(RootSpineRadius, 0.0f); Landing.bSourcePlayerFit = bSourcePlayerFit; @@ -2273,15 +2268,14 @@ FVoxelPassageLanding VF_BuildPassageLanding( // opposite sides of the strate. The manager chooses the common level inside the same // seal-safe interval as this builder, so no source query or passage order can affect it. // Five voxels = 1.25 m half-width / 2.5 m clear width. This is the smallest whole-voxel - // annular root corridor that leaves a 1.36-voxel player capsule a two-voxel fit margin on + // root corridor that leaves a 1.36-voxel player capsule a two-voxel fit margin on // the support lattice, while the landing itself remains the 3 m turn floor. Its separate // 12-voxel (3 m) clear height gives the 1.76 m capsule more than 1 m of headroom while the - // player walks out to the network root. The floor rise is linear along the horizontal - // run; if the direct run is too short, a level dog-leg makes the final ramp satisfy the - // same 44 degree walkable limit by construction. - Landing.ConnectorRadius = 5.0f; - const float ConnectorHeight = FMath::Max( - 12.0f, FVoxelPlayerCapsuleConstants::HeightVoxels + 4.0f); + // player walks out to the origin landing room. The floor rise is linear along the + // horizontal run; if the direct run is too short, a level dog-leg makes the final ramp + // satisfy the same 15 degree walkable-tunnel limit by construction. + Landing.ConnectorRadius = VoxelPassageGeometry::ConnectorRadiusVoxels; + const float ConnectorHeight = VoxelPassageGeometry::ConnectorHeightVoxels; Landing.ConnectorCeilingZ = Landing.FloorZ + ConnectorHeight; Landing.RootFloorZ = NetworkPoint.Z; Landing.RootCeilingZ = Landing.RootFloorZ + ConnectorHeight; @@ -2318,41 +2312,25 @@ FVoxelPassageLanding VF_BuildPassageLanding( const float ConnectorZ = (Landing.FloorZ + Landing.ConnectorCeilingZ) * 0.5f; const float RootConnectorZ = (Landing.RootFloorZ + Landing.RootCeilingZ) * 0.5f; - FVector2D RootEntryDirection( - Landing.StandingPoint.X - NetworkPoint.X, - Landing.StandingPoint.Y - NetworkPoint.Y); - if (!RootEntryDirection.Normalize()) - { - RootEntryDirection = FVector2D(Direction.X, Direction.Y); - if (!RootEntryDirection.Normalize()) - { - RootEntryDirection = FVector2D(1.0f, 0.0f); - } - } - - // The structural spine is a vertical air column, so its centre is not a standing - // endpoint. End the ramp in the middle of the support annulus instead: with the 1.36-voxel - // capsule radius, a centre at RootSpineRadius+2.25 sits at least one capsule radius outside - // the spine and at least one capsule radius inside the 4.5-voxel support edge. Every - // passage therefore enters the same continuous, floor-backed annular hub, while the spine - // itself remains open and unsealed. - const float RootEntryRadius = Landing.RootSpineRadius + 2.25f; - const FVector2D RootEntryXY( - NetworkPoint.X + RootEntryDirection.X * RootEntryRadius, - NetworkPoint.Y + RootEntryDirection.Y * RootEntryRadius); + // The origin is now a finite landing room, not a vertical air column. End the connector + // at the requested room centre so its floor meets the room floor directly. The room's + // own rounded-box SDF and floor post provide the complete volume; no annular shaft hub is + // synthesized here, and no floor is omitted at the centre. + const FVector2D RootEntryXY(NetworkPoint.X, NetworkPoint.Y); Landing.ConnectorStart = FVector( Landing.StandingPoint.X, Landing.StandingPoint.Y, ConnectorZ); Landing.ConnectorEnd = FVector(RootEntryXY.X, RootEntryXY.Y, RootConnectorZ); // A direct source-to-root ramp is usually gentle, but its length is not a contract: a // legal source pose can be close to the spine while its floor is near a strate face. Add a - // deterministic level dog-leg whenever the direct run cannot meet the 44-degree law. + // deterministic level dog-leg whenever the direct run cannot meet the 15-degree law. // The final leg is the only rising part, and its extra horizontal run includes the root - // annulus overlap. This makes the slope a construction guarantee for every seed, rather + // room overlap. This makes the slope a construction guarantee for every seed, rather // than a property observed in today's showcase seeds. WorldRadiusVoxels is deliberately // zero, so the bounded world rim cannot be approached by this lateral safety run. - constexpr float WalkableSlope = 0.9656888f; // tan(44 degrees) - constexpr float RootFlatLength = 2.25f; + constexpr float WalkableSlope = + VoxelPassageGeometry::WalkableTunnelMaxGradient; + constexpr float RootFlatLength = VoxelPassageGeometry::RootOverlapVoxels; const FVector2D SourceXY(Landing.ConnectorStart.X, Landing.ConnectorStart.Y); const FVector2D RootXY(Landing.ConnectorEnd.X, Landing.ConnectorEnd.Y); const FVector2D Direct = RootXY - SourceXY; @@ -2460,23 +2438,6 @@ float VF_EvaluatePassageLandingSDF( const float ConnectorSDF = FMath::Max(HorizontalSDF, VerticalSDF); LandingSDF = VoxelSDF::SmoothMin(LandingSDF, ConnectorSDF, 3.0f); - if (Landing.RootSpineRadius >= 0.0f) - { - // A shared hub turns the guaranteed root join into a walkable network. With the normal - // origin spine it is an annulus outside that shaft; with a disabled spine it safely - // degenerates to a disk, so the landing contract still has a guaranteed root volume. - const float HubRadius = Landing.RootSpineRadius + Landing.ConnectorRadius; - const float RadialDistance = FMath::Sqrt( - FMath::Square(Position.X) + FMath::Square(Position.Y)); - const float HubHorizontalSDF = FMath::Max( - Landing.RootSpineRadius - RadialDistance, - RadialDistance - HubRadius); - const float HubVerticalSDF = FMath::Max( - Landing.RootFloorZ - Position.Z, - Position.Z - Landing.RootCeilingZ); - const float HubSDF = FMath::Max(HubHorizontalSDF, HubVerticalSDF); - LandingSDF = VoxelSDF::SmoothMin(LandingSDF, HubSDF, 3.0f); - } } return LandingSDF; @@ -2502,14 +2463,9 @@ bool VF_IsPassageLandingFloor( // backstop cannot disappear at an exact floor sample. return Z <= FloorZ + KINDA_SMALL_NUMBER && Z > FloorZ - Thickness; }; - const bool bInsideRootSpine = Landing.RootSpineRadius > 0.0f - && FMath::Square(Position.X) + FMath::Square(Position.Y) - <= FMath::Square(Landing.RootSpineRadius); - // The room floor is a true horizontal landing: it is the only floor that owns the full - // three-metre turning patch. The central root spine is left open. - if (IsInFloorBand(Position.Z, Landing.FloorZ, Landing.FloorThickness) - && !bInsideRootSpine) + // three-metre turning patch. The origin room owns its centre, including its support floor. + if (IsInFloorBand(Position.Z, Landing.FloorZ, Landing.FloorThickness)) { const float FloorHalfWidth = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); if (FMath::Abs(Position.X - Landing.StandingPoint.X) <= FloorHalfWidth @@ -2530,8 +2486,7 @@ bool VF_IsPassageLandingFloor( const float FloorT = VF_PassageLandingConnectorFloorParameter(Projection, Landing); const float ConnectorFloorZ = FMath::Lerp( Landing.FloorZ, Landing.RootFloorZ, FloorT); - if (!bInsideRootSpine - && IsInFloorBand(Position.Z, ConnectorFloorZ, Landing.FloorThickness)) + if (IsInFloorBand(Position.Z, ConnectorFloorZ, Landing.FloorThickness)) { // Keep connector support inset by half a voxel from its clear width. The landing // chamber—not this narrow transit leg—owns the 3 m turn floor; this inset preserves @@ -2546,25 +2501,6 @@ bool VF_IsPassageLandingFloor( } } - if (IsInFloorBand(Position.Z, Landing.RootFloorZ, Landing.FloorThickness)) - { - // The common root hub is a walkable annulus, not a cap over the vertical spine. Its - // 4.5-voxel support inset leaves the same 0.5-voxel air clearance at the outer wall as - // the connector and never writes inside the spine radius. - const float RadialDistanceSquared = FMath::Square(Position.X) - + FMath::Square(Position.Y); - const float HubInnerRadius = Landing.RootSpineRadius; - const float HubOuterSupportRadius = Landing.RootSpineRadius - + FMath::Max(Landing.ConnectorRadius - 0.5f, - FVoxelPlayerCapsuleConstants::RadiusVoxels); - const bool bOutsideSpine = Landing.RootSpineRadius <= 0.0f - || RadialDistanceSquared > FMath::Square(HubInnerRadius); - if (bOutsideSpine - && RadialDistanceSquared <= FMath::Square(HubOuterSupportRadius)) - { - return true; - } - } } return false; diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 0ef558e..643136e 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -1677,23 +1677,18 @@ namespace VF_ApplyOriginSpine(InOut.Density, X, Y, Z, TopZ, BotZ, Seal, Base, Radius); } - // Ne fait QUE de l'air ⇒ tue AllSolid, jamais AllAir. Identity quand le cercle XY rate la - // boîte, ou quand la boîte est entièrement hors de l'intérieur de la strate. - // ≡ le test cercle/boîte écrit à la main dans ClassifyTile aujourd'hui. + // The room kills AllSolid; its support slab also kills AllAir. Identity when the room + // misses the box or the box is wholly outside this strate's safe interior. EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override { - if (Radius <= 0.0f) { return EVoxelOpEffect::Identity; } - - const float InnerTop = TopZ - Seal; - const float InnerBot = BotZ + Seal; - if (VoxelBox.Max.Z <= InnerBot || VoxelBox.Min.Z >= InnerTop) { return EVoxelOpEffect::Identity; } - - const float Reach = Radius + VoxelDensityReach::SpineBlend; - const float CX = FMath::Clamp(0.0f, (float)VoxelBox.Min.X, (float)VoxelBox.Max.X); - const float CY = FMath::Clamp(0.0f, (float)VoxelBox.Min.Y, (float)VoxelBox.Max.Y); - if (CX * CX + CY * CY > Reach * Reach) { return EVoxelOpEffect::Identity; } - - return EVoxelOpEffect::CarveOnly; + if (!VoxelPassageGeometry::OriginLandingRoomTouchesBox( + VoxelBox, TopZ, BotZ, Seal, Radius)) + { + return EVoxelOpEffect::Identity; + } + return VoxelPassageGeometry::OriginLandingFloorTouchesBox( + VoxelBox, TopZ, BotZ, Seal, Radius) + ? EVoxelOpEffect::Both : EVoxelOpEffect::CarveOnly; } const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); } @@ -1842,8 +1837,10 @@ namespace class FPassageCarveOp final : public IVoxelDensityOp { public: - FPassageCarveOp(const UVoxelStrateManager* InManager, float InBase, float InSeal) - : Manager(InManager), Base(InBase), Seal(InSeal) {} + FPassageCarveOp(const UVoxelStrateManager* InManager, float InBase, float InSeal, + float InTopZ, float InBotZ, float InSpineRadius) + : Manager(InManager), Base(InBase), Seal(InSeal), + TopZ(InTopZ), BotZ(InBotZ), SpineRadius(InSpineRadius) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } @@ -1853,8 +1850,15 @@ namespace void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override { - if (!Manager) { return; } - Manager->ApplyPassageModifier(InOut.Density, X, Y, Z, Base, Seal); + if (Manager) + { + Manager->ApplyPassageModifier(InOut.Density, X, Y, Z, Base, Seal); + } + // Keep the source and op-stack operation order identical: the origin room is carved + // before the boundary seal, then its support floor is reasserted after the passage + // post so no connector can remove the standing surface. + VF_ApplyOriginLandingFloor( + InOut.Density, X, Y, Z, TopZ, BotZ, Seal, Base, SpineRadius); } // ≡ la garde `AnyPassageNearBox` écrite à la main dans ClassifyTile — déjà écrite, ici @@ -1862,7 +1866,9 @@ namespace EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override { if (!Manager) { return EVoxelOpEffect::Identity; } - if (Manager->AnyPassageLandingFloorNearBox(VoxelBox.Min, VoxelBox.Max)) + if (VoxelPassageGeometry::OriginLandingFloorTouchesBox( + VoxelBox, TopZ, BotZ, Seal, SpineRadius) + || Manager->AnyPassageLandingFloorNearBox(VoxelBox.Min, VoxelBox.Max)) { // The room carves air, while its support slab force-writes solid. Both // hypotheses must therefore be killed for a box touching that slab. @@ -1876,7 +1882,7 @@ namespace private: const UVoxelStrateManager* Manager; - float Base, Seal; + float Base, Seal, TopZ, BotZ, SpineRadius; }; //========================================================================= @@ -5218,7 +5224,8 @@ void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBott // restent le dernier post de GetDensityAt, hors de cette pile, comme avant. Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius)); Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity)); - Add(MakeUnique(StrateManager, BaseDensity, SealThickness)); + Add(MakeUnique(StrateManager, BaseDensity, SealThickness, + StrateTopWorldZ, StrateBottomWorldZ, SpineRadius)); Add(MakeUnique(BaseDensity)); } diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 0c66bfd..8f33bf0 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -296,6 +296,14 @@ static FORCEINLINE void ApplyOriginSpine(float& Density, float WorldX, float Wor StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); } +static FORCEINLINE void ApplyOriginLandingFloor(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + VF_ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); +} + // DISTURBANCE LAYER — the "wow" post-process. Operates on the FINAL MC density // (negative = solid, positive = air), AFTER the archetype produced its terrain, so // it works uniformly for every generator type. Stays inside the seal bands so it can @@ -1061,11 +1069,40 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co break; } } + // Origin rooms are structural too. Reassert their air before either support writer so a + // bridge/ridge cannot plug a landing, while the two floor writers remain last. + VF_ApplyOriginLandingAirMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); StrateManager->ApplyPassageLandingAirMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity, CP_Dist.BoundarySealThickness); + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); StrateManager->ApplyPassageLandingFloorMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + // A walkable tunnel's guaranteed air core is the final structural writer above its + // floor. This prevents a nearby passage's floor slab from capping the active route; + // the tunnel-floor predicate is false on the active floor itself, so support remains. + StrateManager->ApplyPassageTunnelAirMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity, + CP_Dist.BoundarySealThickness); + VF_ApplyOriginLandingAirMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + StrateManager->ApplyPassageLandingAirMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity, + CP_Dist.BoundarySealThickness); + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + StrateManager->ApplyPassageLandingRoomFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); } else { @@ -2225,6 +2262,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); // Fourth structural post: the XY edge wins over a passage near the rim. VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, @@ -2448,6 +2488,9 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); @@ -2569,6 +2612,9 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); @@ -2833,6 +2879,9 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa Density, WorldX, WorldY, WorldZ, S.BaseDensity, S.BoundarySealThickness); } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + S.StrateTopWorldZ, S.StrateBottomWorldZ, + S.BoundarySealThickness, S.BaseDensity, OriginSpineRadius); VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, S.BaseDensity); @@ -3223,13 +3272,20 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in { bCanAir = false; } - if (OriginSpineRadius > 0.0f) + if (StrateManager->AnyOriginLandingNearBox( + FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) { - // Spine (0,0) : colonne XY rayon R + blend 3 (cf. ApplyOriginSpine). Test cercle/boîte XY. - const float Reach = OriginSpineRadius + 3.0f; - const float CX = FMath::Clamp(0.0f, (float)MinX, (float)MaxX); - const float CY = FMath::Clamp(0.0f, (float)MinY, (float)MaxY); - if (CX * CX + CY * CY <= Reach * Reach) bCanSolid = false; + // The origin post is a room carve, not a full-height spine. Its conservative manager + // guard is per tile, so it retains the hot-path invariant while covering every strate's + // room AABB and its four-voxel carve blend. + bCanSolid = false; + } + if (StrateManager->AnyOriginLandingFloorNearBox( + FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) + { + // The same origin room also owns a guaranteed support slab, so a tile touching only that + // slab cannot be proved uniformly air. + bCanAir = false; } // ── Catégorisation par Z du treillis : gap bedrock = solide ; hors layout = air constant ; @@ -4556,6 +4612,9 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); @@ -4717,6 +4776,9 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 01c3e61..5710053 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -120,6 +120,165 @@ namespace } } +static float VF_BoundarySealThicknessForDefinition( + const UVoxelStrateDefinition& Definition) +{ + switch (Definition.GeneratorType) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Definition.GenerationParams.BoundarySealThickness; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Definition.SlabParams.BoundarySealThickness; + case ECaveGeneratorType::Maze: + return Definition.MazeParams.BoundarySealThickness; + case ECaveGeneratorType::SurfaceWorld: + return Definition.SurfaceParams.BoundarySealThickness; + case ECaveGeneratorType::VerticalShafts: + return Definition.VerticalShaftParams.BoundarySealThickness; + case ECaveGeneratorType::FloatingIslands: + return Definition.FloatingIslandParams.BoundarySealThickness; + default: + return 0.0f; + } +} + +/** + * The default inter-strate tunnel is a carved tube plus an explicit support slab. The tube's + * rounded SDF guarantees clearance, but it cannot guarantee a standable surface over a source + * field or a disturbance that was solid before the passage post. Project the query into the + * nearest horizontal control segment and return the conservative floor carried by that segment. + * This is a fixed-size arithmetic loop over the already-built descriptor; it performs no source + * search, allocation, or topology work. + */ +static bool VF_IsWalkableTunnelFloor( + const FVoxelPassage& Passage, + const FVector& Position) +{ + if (!Passage.bWalkableTunnelContract + || Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num()) + { + return false; + } + + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Passage.ControlPoints, Passage.ControlRadii, Position, + FloorZ, SupportRadius)) + { + return false; + } + + return Position.Z <= FloorZ + KINDA_SMALL_NUMBER + && Position.Z > FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels; +} + +static bool VF_IsWalkableTunnelAir( + const FVoxelPassage& Passage, + const FVector& Position) +{ + if (!Passage.bWalkableTunnelContract + || Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num()) + { + return false; + } + + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Passage.ControlPoints, Passage.ControlRadii, Position, + FloorZ, SupportRadius)) + { + return false; + } + + // The carved tube is wider than this guaranteed core. The core is deliberately expressed as + // a vertical prism over the projected floor: it gives the final-density player stencil a + // stable air volume even when a source archetype or a disturbance would otherwise refill the + // shallow part of the rounded SDF. The floor writer below owns the closed lower face. + const float AirHeight = 2.0f * SupportRadius; + return Position.Z > FloorZ + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels + && Position.Z < FloorZ + AirHeight - KINDA_SMALL_NUMBER; +} + +static bool VF_FindWalkableTunnelAir( + const UVoxelStrateManager* Manager, + const FVector& Position, + int32& OutPassageIndex, + float& OutFloorZ, + float& OutSupportRadius) +{ + OutPassageIndex = INDEX_NONE; + OutFloorZ = 0.0f; + OutSupportRadius = 0.0f; + if (Manager == nullptr) + { + return false; + } + + const FIntVector ChunkCoord( + FMath::FloorToInt(Position.X / (float)CHUNK_SIZE), + FMath::FloorToInt(Position.Y / (float)CHUNK_SIZE), + FMath::FloorToInt(Position.Z / (float)CHUNK_SIZE)); + const TArray& Nearby = VF_GetNearbyPassages(Manager, ChunkCoord); + const TArray& Passages = Manager->GetPassages(); + for (const int32 PassageIndex : Nearby) + { + if (!Passages.IsValidIndex(PassageIndex)) + { + continue; + } + const FVoxelPassage& Passage = Passages[PassageIndex]; + if (FVector::DistSquared(Position, Passage.BoundCenter) > Passage.BoundRadiusSq + || !VF_IsWalkableTunnelAir(Passage, Position)) + { + continue; + } + VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Passage.ControlPoints, Passage.ControlRadii, Position, + OutFloorZ, OutSupportRadius); + OutPassageIndex = PassageIndex; + return true; + } + return false; +} + +static bool VF_IsAnyPassageFloorAt( + const UVoxelStrateManager* Manager, + const FVector& Position) +{ + if (Manager == nullptr) + { + return false; + } + const FIntVector ChunkCoord( + FMath::FloorToInt(Position.X / (float)CHUNK_SIZE), + FMath::FloorToInt(Position.Y / (float)CHUNK_SIZE), + FMath::FloorToInt(Position.Z / (float)CHUNK_SIZE)); + const TArray& Nearby = VF_GetNearbyPassages(Manager, ChunkCoord); + const TArray& Passages = Manager->GetPassages(); + for (const int32 PassageIndex : Nearby) + { + if (!Passages.IsValidIndex(PassageIndex)) + { + continue; + } + const FVoxelPassage& Passage = Passages[PassageIndex]; + if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) + || VF_IsWalkableTunnelFloor(Passage, Position)) + { + return true; + } + } + return false; +} + bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) { if (!Settings) @@ -756,14 +915,23 @@ void UVoxelStrateManager::GeneratePassages() } }; - const auto CommonRootFloorFor = [](float StrateTopZ, float StrateBottomZ, - float BoundarySealThickness) -> float + const auto CommonRootFloorFor = [&](float StrateTopZ, float StrateBottomZ, + float BoundarySealThickness) -> float { - // The two mouth queries may legitimately return floors near opposite strate faces. A - // shared root level is therefore derived from the strate bounds, never from passage - // iteration order. Leave the floor slab and the full 12-voxel connector clearance beyond - // the four-voxel passage blend away from both seals; the landing builder repeats this - // clamp as its final safety boundary. + // The two mouth queries may legitimately return floors near opposite strate faces. The + // root is the origin landing room's floor, not a hidden continuous shaft and not a + // passage-order-dependent midpoint. Reusing the same top-anchored geometry here makes + // every connector meet the (0,0) room at its actual support level. + const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding = + VoxelPassageGeometry::BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, BoundarySealThickness, OriginSpineRadius); + if (OriginLanding.bValid) + { + return OriginLanding.FloorZ; + } + + // Malformed tiny strates still fail closed. This fallback is only for the descriptor + // builder's diagnostic path; normal authored strates always satisfy the room interval. const float Seal = FMath::Max(BoundarySealThickness, 0.0f); const float InnerBottom = StrateBottomZ + Seal; const float InnerTop = StrateTopZ - Seal; @@ -790,8 +958,9 @@ void UVoxelStrateManager::GeneratePassages() const FStrateSlot& Upper = StrateLayout[i]; const FStrateSlot& Lower = StrateLayout[i + 1]; - // This (upper) strate's PassageConfig controls the descent tunnels to the layer - // below. The (0,0) spine descent is separate (player-dug); these are the shortcuts. + // This (upper) strate's PassageConfig controls the progression tunnel to the layer below. + // Each strate already owns a finite (0,0) landing room; this passage is the connection + // that opens the next room rather than a second vertical spine. const UVoxelStrateDefinition* UpperDef = Upper.Definition; if (!UpperDef) continue; const FStratePassageConfig& Cfg = UpperDef->PassageConfig; @@ -821,10 +990,25 @@ void UVoxelStrateManager::GeneratePassages() FVoxelPassage Passage; Passage.UpperStrateIndex = i; Passage.LowerStrateIndex = i + 1; - // PassageType has no random draw in the current implementation; preserve its existing - // default while PassageConfig::Style controls the generated control-point shape. - // PassageType n'a pas de tirage aléatoire ici ; conserver son défaut, tandis que - // PassageConfig::Style contrôle la forme des points de contrôle générés. + // The authored style is also reflected in the legacy passage type. In particular, + // only Straight receives the walkable-tunnel contract; a future style roll cannot + // silently inherit the default's player-fit promise. + switch (Cfg.Style) + { + case EVoxelPassageStyle::Straight: + Passage.PassageType = EVoxelPassageType::SlopedTunnel; + break; + case EVoxelPassageStyle::Spiral: + Passage.PassageType = EVoxelPassageType::SpiralDescent; + break; + case EVoxelPassageStyle::Cascading: + Passage.PassageType = EVoxelPassageType::CascadingDrops; + break; + case EVoxelPassageStyle::Worm: + default: + Passage.PassageType = EVoxelPassageType::CrackCrevice; + break; + } // PLACEMENT: random angle, distance from the (0,0) spine within config range. const float Angle = PassageRandom01(PassageSaltAngle) * (2.0f * PI); @@ -1142,7 +1326,223 @@ void UVoxelStrateManager::GeneratePassages() if (Passage.UpperLanding.bHasNetworkConnector) ++NumUpperRootConnectors; if (Passage.LowerLanding.bHasNetworkConnector) ++NumLowerRootConnectors; - if (Passage.ControlPoints.Num() > 0) + if (Cfg.Style == EVoxelPassageStyle::Straight + && Passage.UpperLanding.HalfWidth > 0.0f + && Passage.LowerLanding.HalfWidth > 0.0f) + { + // BASE CONNECTION: a walkable two-leg switchback. A direct line between the + // mouths would often be nearly vertical because both requests are placed at the + // same XY radius. Put the turn on the perpendicular bisector and give each leg + // at least half of the horizontal run required by the named 15-degree law. + // This is deliberately a construction rule, not a seed-dependent observation. + const float SwitchbackAngle = + PassageRandom01(PassageSaltUpperDoor) * 2.0f * PI; + FVector2D SwitchbackDirection( + FMath::Cos(SwitchbackAngle), FMath::Sin(SwitchbackAngle)); + if (!SwitchbackDirection.Normalize()) + { + SwitchbackDirection = FVector2D(1.0f, 0.0f); + } + + 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; + Passage.LowerLanding.DoorDirection = TunnelLowerDoorDirection; + const float UpperDoorOffset = FMath::Max( + Passage.UpperLanding.HalfWidth - 1.0f, 0.0f); + const float LowerDoorOffset = FMath::Max( + Passage.LowerLanding.HalfWidth - 1.0f, 0.0f); + const float SafeMouthRadius = FMath::Max( + FMath::Abs(Cfg.MouthRadius), 1.0f); + Passage.UpperLanding.DoorPoint = + Passage.UpperLanding.StandingPoint + DoorDirection * UpperDoorOffset; + Passage.UpperLanding.DoorPoint.Z = + Passage.UpperLanding.FloorZ + SafeMouthRadius; + Passage.LowerLanding.DoorPoint = + Passage.LowerLanding.StandingPoint + + TunnelLowerDoorDirection * LowerDoorOffset; + Passage.LowerLanding.DoorPoint.Z = + Passage.LowerLanding.FloorZ + SafeMouthRadius; + + const FVector UpperDoor = Passage.UpperLanding.DoorPoint; + const FVector LowerDoor = Passage.LowerLanding.DoorPoint; + // Keep a short level apron beyond each room floor. The door anchor is one voxel + // inside the room's support square; a diagonal door direction can therefore need + // several voxels before it leaves that square and its four-voxel SDF blend. + // The level apron must clear the room half-width, the tube radius, and the + // smooth carve band before the sloped leg begins. The final six-voxel transition + // is measured from that clear point back toward the turn, so include it too. For + // the default 8-voxel half-width / 6-voxel mouth this is 17 voxels (4.25 m), not + // the old 6-voxel minimum that left the sloped capsule inside the landing room. + const float LandingApron = FMath::Max( + VoxelPassageGeometry::WalkableTunnelLandingApronVoxels, + VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels + + SafeMouthRadius + + VoxelPassageGeometry::LandingCarveBlendVoxels + + 1.0f); + const FVector UpperApronEnd = UpperDoor + + DoorDirection * LandingApron; + const FVector LowerApronBegin = LowerDoor + + TunnelLowerDoorDirection * LandingApron; + const float VerticalDrop = FMath::Abs(UpperDoor.Z - LowerDoor.Z); + const float UpperFloorZ = VoxelPassageGeometry::TunnelFloorZ( + UpperDoor, Cfg.MouthRadius); + 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. + FVector2D UpperOutward( + UpperApronEnd.X - Passage.UpperLanding.StandingPoint.X, + UpperApronEnd.Y - Passage.UpperLanding.StandingPoint.Y); + FVector2D LowerOutward( + LowerApronBegin.X - Passage.LowerLanding.StandingPoint.X, + 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 = + VoxelPassageGeometry::RequiredHorizontalRunForFloorDrop( + UpperFloorDrop, LowerFloorDrop); + // Move the level turn farther than half the required run. Each slope then loses + // only the named transition length to its level segment and still retains the + // full horizontal run required by the 15-degree floor law. + const float TurnTransition = + VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels; + const float TurnOffset = + 0.5f * RequiredHorizontalRun + TurnTransition; + const FVector2D FloorSafeDoorMid( + RampStartMid.X + + TurnDirection.X * TurnOffset, + RampStartMid.Y + + TurnDirection.Y * TurnOffset); + FVector2D UpperSlopeDirection( + FloorSafeDoorMid.X - UpperApronEnd.X, + FloorSafeDoorMid.Y - UpperApronEnd.Y); + if (!UpperSlopeDirection.Normalize()) + { + UpperSlopeDirection = FVector2D( + DoorDirection.X, DoorDirection.Y); + } + FVector2D LowerSlopeDirection( + LowerApronBegin.X - FloorSafeDoorMid.X, + LowerApronBegin.Y - FloorSafeDoorMid.Y); + if (!LowerSlopeDirection.Normalize()) + { + LowerSlopeDirection = -UpperSlopeDirection; + } + const FVector2D UpperSlopeStartXY( + UpperApronEnd.X + UpperSlopeDirection.X * TurnTransition, + UpperApronEnd.Y + UpperSlopeDirection.Y * TurnTransition); + const FVector2D LowerSlopeEndXY( + LowerApronBegin.X - LowerSlopeDirection.X * TurnTransition, + LowerApronBegin.Y - LowerSlopeDirection.Y * TurnTransition); + const FVector UpperApronEndPoint( + UpperApronEnd.X, UpperApronEnd.Y, + UpperFloorZ + FMath::Abs(Cfg.MouthRadius)); + const FVector UpperSlopeStart( + UpperSlopeStartXY.X, UpperSlopeStartXY.Y, + UpperFloorZ + FMath::Abs(Cfg.MouthRadius)); + const FVector MidPoint( + FloorSafeDoorMid.X, FloorSafeDoorMid.Y, + MidFloorZ + FMath::Abs(Cfg.MidRadius)); + const FVector LowerSlopeEnd( + LowerSlopeEndXY.X, LowerSlopeEndXY.Y, + LowerFloorZ + FMath::Abs(Cfg.MouthRadius)); + const FVector LowerApronBeginPoint( + LowerApronBegin.X, LowerApronBegin.Y, + LowerFloorZ + FMath::Abs(Cfg.MouthRadius)); + + Passage.ControlPoints.Reset(7); + Passage.ControlRadii.Reset(7); + Passage.ControlPoints.Add(UpperDoor); + Passage.ControlPoints.Add(UpperApronEndPoint); + Passage.ControlPoints.Add(UpperSlopeStart); + Passage.ControlPoints.Add(MidPoint); + Passage.ControlPoints.Add(LowerSlopeEnd); + Passage.ControlPoints.Add(LowerApronBeginPoint); + Passage.ControlPoints.Add(LowerDoor); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MidRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + + Passage.PassageType = EVoxelPassageType::SlopedTunnel; + Passage.bWalkableTunnelContract = true; + Passage.TunnelMaxGradientDegrees = + VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees; + Passage.TunnelVerticalDropVoxels = VerticalDrop; + Passage.TunnelRequiredHorizontalRunVoxels = RequiredHorizontalRun; + Passage.TunnelHorizontalPathLengthVoxels = 0.0f; + for (int32 ControlIndex = 0; + ControlIndex + 1 < Passage.ControlPoints.Num(); + ++ControlIndex) + { + Passage.TunnelHorizontalPathLengthVoxels += FVector2D( + Passage.ControlPoints[ControlIndex + 1].X + - Passage.ControlPoints[ControlIndex].X, + Passage.ControlPoints[ControlIndex + 1].Y + - Passage.ControlPoints[ControlIndex].Y).Size(); + } + Passage.TunnelMinimumClearWidthVoxels = 2.0f * FMath::Max( + FMath::Min(FMath::Abs(Cfg.MouthRadius), FMath::Abs(Cfg.MidRadius)), + 0.0f); + Passage.TunnelClearHeightVoxels = Passage.TunnelMinimumClearWidthVoxels; + } + else if (Passage.ControlPoints.Num() > 0) { Passage.ControlPoints[0] = Passage.UpperLanding.DoorPoint; Passage.ControlPoints.Last() = Passage.LowerLanding.DoorPoint; @@ -1258,36 +1658,61 @@ void UVoxelStrateManager::GeneratePassages() *NoQueryList); //========================================================================= - // SURFACE ENTRY SHAFT — the one auto-opened (0,0) connection. - // A straight vertical shaft at (0,0) piercing the TOP seal of the topmost - // strate, so the world begins with "a hole opened to the surface". All other - // (0,0) descents between strates remain player-dug. + // SURFACE ENTRY — the one optional above-ground opening. + // It opens the top seal and stops at the ceiling of strate 0's origin landing room. It does + // not continue down the room or into lower strates: every lower seal remains closed until the + // corresponding progression passage is opened. //========================================================================= - if (bOpenSurfaceEntry && StrateLayout.Num() > 0) + if (bOpenSurfaceEntry && OriginSpineRadius > 0.0f && StrateLayout.Num() > 0) { const FStrateSlot& Top = StrateLayout[0]; const float TopZ = (float)(Top.TopChunkZ + 1) * CHUNK_SIZE; - - FVoxelPassage Entry; - Entry.UpperStrateIndex = 0; - Entry.LowerStrateIndex = 0; - Entry.PassageType = EVoxelPassageType::VerticalShaft; - Entry.Radius = FMath::Max(OriginSpineRadius * 0.7f, 4.0f); - // From a little above the strate top (open air outside all strates) down - // past the seal into the interior, so the seal at (0,0) is breached. - Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE); - Entry.LowerPoint = FVector(0.0f, 0.0f, TopZ - CHUNK_SIZE); + const float BottomZ = (float)Top.BottomChunkZ * CHUNK_SIZE; + const float TopSeal = Top.Definition + ? BoundarySealThicknessFor(*Top.Definition) : 0.0f; + const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding = + VoxelPassageGeometry::BuildOriginLandingGeometry( + TopZ, BottomZ, TopSeal, OriginSpineRadius); + if (!OriginLanding.bValid) { - const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f; - const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f; - Entry.BoundCenter = C; - Entry.BoundRadius = R; - Entry.BoundRadiusSq = R * R; + UE_LOG(LogTemp, Warning, + TEXT("[StrateManager] Surface entry skipped: top origin landing has no seal-safe room interval.")); } - Passages.Add(Entry); + else + { + FVoxelPassage Entry; + Entry.UpperStrateIndex = 0; + Entry.LowerStrateIndex = 0; + // Keep the legacy type: this is the explicitly opted-in above-ground vertical opening, + // not the default inter-strate descent style. + Entry.PassageType = EVoxelPassageType::VerticalShaft; + Entry.Radius = FMath::Max(OriginSpineRadius * 0.7f, 4.0f); + Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE); + // The capsule's lower tangent meets the room ceiling; it never bores through the + // room's floor or creates an origin column in strate 0. + Entry.LowerPoint = FVector( + 0.0f, 0.0f, OriginLanding.CeilingZ + Entry.Radius); + Entry.ControlPoints.Reset(2); + Entry.ControlPoints.Add(Entry.UpperPoint); + Entry.ControlPoints.Add(Entry.LowerPoint); + Entry.ControlRadii.Reset(2); + Entry.ControlRadii.Add(Entry.Radius); + Entry.ControlRadii.Add(Entry.Radius); + Entry.TunnelClearHeightVoxels = 2.0f * Entry.Radius; + Entry.TunnelMinimumClearWidthVoxels = 2.0f * Entry.Radius; + { + const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f; + const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f; + Entry.BoundCenter = C; + Entry.BoundRadius = R; + Entry.BoundRadiusSq = R * R; + } + Passages.Add(Entry); - UE_LOG(LogTemp, Log, TEXT("[StrateManager] Surface entry shaft at (0,0) topZ=%.0f R=%.1f"), - TopZ, Entry.Radius); + UE_LOG(LogTemp, Log, + TEXT("[StrateManager] Surface entry opening at (0,0) topZ=%.0f roomFloor=%.1f roomCeiling=%.1f R=%.1f"), + TopZ, OriginLanding.FloorZ, OriginLanding.CeilingZ, Entry.Radius); + } } // Invalidate any thread_local per-chunk passage shortlists (see EvaluateModifierSDF). @@ -1383,7 +1808,8 @@ void UVoxelStrateManager::ApplyPassageModifier( { const FVoxelPassage& Passage = Passages[PassageIndex]; if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) - || VF_IsPassageLandingFloor(Position, Passage.LowerLanding)) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) + || VF_IsWalkableTunnelFloor(Passage, Position)) { // This is the one bidirectional part of PassageCarveOp: a floor is a proved solid // support slab. It is deliberately applied after the air carve so a tube can never @@ -1392,6 +1818,12 @@ void UVoxelStrateManager::ApplyPassageModifier( break; } } + + // A neighbouring passage may own a floor at this XY while this point is in the air core of + // the current walkable tunnel. Reassert tunnel air after all floor posts so overlap cannot + // turn a valid route into a solid plug. At the tunnel's own floor the air predicate is false, + // so the support plane remains solid. + ApplyPassageTunnelAir(Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness); } void UVoxelStrateManager::ApplyPassageLandingAir( @@ -1406,6 +1838,13 @@ void UVoxelStrateManager::ApplyPassageLandingAir( if (Nearby.Num() == 0) return; const FVector Position(WorldX, WorldY, WorldZ); + // A smooth room blend may overlap the end of a ramp. The analytic support plane owns that + // overlap; otherwise landing air can erase the tunnel's final-density floor after the tunnel + // air post has deliberately stopped above it. + if (VF_IsAnyPassageFloorAt(this, Position)) + { + return; + } float MinLandingSDF = FLT_MAX; for (const int32 PassageIndex : Nearby) { @@ -1448,6 +1887,35 @@ void UVoxelStrateManager::ApplyPassageLandingAirMC( Density = -InternalDensity; } +void UVoxelStrateManager::ApplyPassageTunnelAir( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + const FVector Position(WorldX, WorldY, WorldZ); + int32 PassageIndex = INDEX_NONE; + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + const bool bFoundWalkableAir = VF_FindWalkableTunnelAir( + this, Position, PassageIndex, FloorZ, SupportRadius); + if (bFoundWalkableAir) + { + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + Density = FMath::Min(Density, AirTarget); + } +} + +void UVoxelStrateManager::ApplyPassageTunnelAirMC( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + // The disturbance layer uses MC polarity (negative = solid), so reuse the exact internal + // tunnel-air operation rather than maintaining a second polarity-specific formula. + float InternalDensity = -Density; + ApplyPassageTunnelAir( + InternalDensity, WorldX, WorldY, WorldZ, BaseDensity, SealThickness); + Density = -InternalDensity; +} + void UVoxelStrateManager::ApplyPassageLandingFloorMC( float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const { @@ -1461,7 +1929,8 @@ void UVoxelStrateManager::ApplyPassageLandingFloorMC( { const FVoxelPassage& Passage = Passages[PassageIndex]; if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) - || VF_IsPassageLandingFloor(Position, Passage.LowerLanding)) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) + || VF_IsWalkableTunnelFloor(Passage, Position)) { // Result is in MC convention here (negative = solid). This reassertion is the // structural floor backstop after the optional MC-space disturbance layer. @@ -1471,6 +1940,46 @@ void UVoxelStrateManager::ApplyPassageLandingFloorMC( } } +static bool VF_IsPassageRoomFloor( + const FVector& Position, const FVoxelPassageLanding& Landing) +{ + if (!FMath::IsFinite(Position.X) || !FMath::IsFinite(Position.Y) + || !FMath::IsFinite(Position.Z) || !FMath::IsFinite(Landing.FloorZ) + || !FMath::IsFinite(Landing.FloorThickness) + || Landing.FloorThickness <= 0.0f || Landing.HalfWidth <= 0.0f) + { + return false; + } + return Position.Z <= Landing.FloorZ + KINDA_SMALL_NUMBER + && Position.Z > Landing.FloorZ - Landing.FloorThickness + && FMath::Abs(Position.X - Landing.StandingPoint.X) + <= FMath::Max(Landing.HalfWidth - 1.0f, 0.0f) + && FMath::Abs(Position.Y - Landing.StandingPoint.Y) + <= FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); +} + +void UVoxelStrateManager::ApplyPassageLandingRoomFloorMC( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity) const +{ + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); + const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + const FVector Position(WorldX, WorldY, WorldZ); + for (const int32 PassageIndex : Nearby) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + if (VF_IsPassageRoomFloor(Position, Passage.UpperLanding) + || VF_IsPassageRoomFloor(Position, Passage.LowerLanding)) + { + Density = FMath::Min(Density, -BaseDensity); + break; + } + } +} + bool UVoxelStrateManager::AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const { // Le carve d'un passage atteint ModSDF < PASSAGE_BLEND_RADIUS (4, VoxelGenerator.cpp) au-delà de @@ -1564,20 +2073,48 @@ bool UVoxelStrateManager::AnyPassageLandingFloorNearBox( return true; } - if (Landing->RootSpineRadius >= 0.0f) + } + } + + // The default tube also owns a three-voxel support band. Its conservative segment AABB + // keeps a classifier from proving AllAir over the floor and then dropping that support + // during meshing. False positives are intentional; false negatives would be a hole. + if (Passage.bWalkableTunnelContract + && Passage.ControlPoints.Num() >= 2 + && Passage.ControlRadii.Num() == Passage.ControlPoints.Num()) + { + constexpr float Pad = 1.0f; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float SupportRadius = FMath::Max( + FMath::Min( + FMath::Abs(Passage.ControlRadii[SegmentIndex]), + FMath::Abs(Passage.ControlRadii[SegmentIndex + 1])) - 0.5f, + VoxelPassageGeometry::PlayerRadiusVoxels); + const float FloorMinZ = FMath::Min( + VoxelPassageGeometry::TunnelFloorZ( + A, Passage.ControlRadii[SegmentIndex]), + VoxelPassageGeometry::TunnelFloorZ( + B, Passage.ControlRadii[SegmentIndex + 1])) + - VoxelPassageGeometry::LandingFloorThicknessVoxels - Pad; + const float FloorMaxZ = FMath::Max( + VoxelPassageGeometry::TunnelFloorZ( + A, Passage.ControlRadii[SegmentIndex]), + VoxelPassageGeometry::TunnelFloorZ( + B, Passage.ControlRadii[SegmentIndex + 1])) + Pad; + const float FloorMinX = FMath::Min(A.X, B.X) - SupportRadius - Pad; + const float FloorMaxX = FMath::Max(A.X, B.X) + SupportRadius + Pad; + const float FloorMinY = FMath::Min(A.Y, B.Y) - SupportRadius - Pad; + const float FloorMaxY = FMath::Max(A.Y, B.Y) + SupportRadius + Pad; + if (FloorMaxX >= MinVoxel.X && FloorMinX <= MaxVoxel.X + && FloorMaxY >= MinVoxel.Y && FloorMinY <= MaxVoxel.Y + && FloorMaxZ >= MinVoxel.Z && FloorMinZ <= MaxVoxel.Z) { - const float HubPad = Landing->RootSpineRadius - + Landing->ConnectorRadius + Pad; - const float HubMinZ = Landing->RootFloorZ - - Landing->FloorThickness - Pad; - const float HubMaxZ = Landing->RootFloorZ + Pad; - if (HubPad >= 0.0f - && HubPad >= MinVoxel.X && -HubPad <= MaxVoxel.X - && HubPad >= MinVoxel.Y && -HubPad <= MaxVoxel.Y - && HubMaxZ >= MinVoxel.Z && HubMinZ <= MaxVoxel.Z) - { - return true; - } + return true; } } } @@ -1585,6 +2122,48 @@ bool UVoxelStrateManager::AnyPassageLandingFloorNearBox( return false; } +bool UVoxelStrateManager::AnyOriginLandingNearBox( + const FVector& MinVoxel, const FVector& MaxVoxel) const +{ + if (OriginSpineRadius <= 0.0f) return false; + const FBox VoxelBox(MinVoxel, MaxVoxel); + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) continue; + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + if (VoxelPassageGeometry::OriginLandingRoomTouchesBox( + VoxelBox, TopZ, BottomZ, + VF_BoundarySealThicknessForDefinition(*Slot.Definition), + OriginSpineRadius)) + { + return true; + } + } + return false; +} + +bool UVoxelStrateManager::AnyOriginLandingFloorNearBox( + const FVector& MinVoxel, const FVector& MaxVoxel) const +{ + if (OriginSpineRadius <= 0.0f) return false; + const FBox VoxelBox(MinVoxel, MaxVoxel); + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) continue; + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + if (VoxelPassageGeometry::OriginLandingFloorTouchesBox( + VoxelBox, TopZ, BottomZ, + VF_BoundarySealThicknessForDefinition(*Slot.Definition), + OriginSpineRadius)) + { + return true; + } + } + return false; +} + //============================================================================= // QUERIES //============================================================================= diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index ff746d1..2d57e2a 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -2863,7 +2863,8 @@ bool VF_MeasurePlayerFitWalkWithSampler( const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, - FVoxelPlayerFitWalkReport& OutReport) + FVoxelPlayerFitWalkReport& OutReport, + const TArray* ComponentProbePoints) { using namespace VoxelStrateMeasurePrivate; @@ -3038,6 +3039,28 @@ bool VF_MeasurePlayerFitWalkWithSampler( ? Components[Start] : INDEX_NONE; const int32 GoalComponent = Goal >= 0 && Components.IsValidIndex(Goal) ? Components[Goal] : INDEX_NONE; + + if (ComponentProbePoints != nullptr) + { + OutReport.ComponentProbePlayerFit.Init(0u, ComponentProbePoints->Num()); + OutReport.ComponentProbeInStartComponent.Init(0u, ComponentProbePoints->Num()); + for (int32 ProbeIndex = 0; ProbeIndex < ComponentProbePoints->Num(); ++ProbeIndex) + { + int32 ProbeCell = INDEX_NONE; + const FVector& ProbePoint = (*ComponentProbePoints)[ProbeIndex]; + if (!FindCellForPoint(Grid, ProbePoint, ProbeCell) + || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, ProbeCell)) + { + continue; + } + OutReport.ComponentProbePlayerFit[ProbeIndex] = 1u; + if (StartComponent >= 0 && Components.IsValidIndex(ProbeCell) + && Components[ProbeCell] == StartComponent) + { + OutReport.ComponentProbeInStartComponent[ProbeIndex] = 1u; + } + } + } if (StartComponent >= 0) { float BestOriginDistanceSquared = FLT_MAX; diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 513e0b0..fc1bc19 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -51,12 +51,11 @@ enum class ECaveGeneratorType : uint8; * A source query answer selects the landing's XY/Z anchor. Because that pure query does not * flood-fill the live network, every inter-strate end also gets a deterministic root connector * (`bHasNetworkConnector`). Its flat floor ramps to the same common interior root level for that - * strate, so both mouths enter one walkable hub even when their authored floors are at opposite - * sides of the strate. The hub is an annular walkable network around (0,0), not the pre-existing - * vertical air shaft. `RootSpineRadius` keeps both the hub air and support slab outside that shaft, - * so the spine remains open. If the direct run would be steeper than 44 degrees, the connector - * uses a deterministic level dog-leg before its final ramp; the bend is geometry, not a per-voxel - * search. + * strate, so both mouths enter the finite landing room at (0,0) even when their authored floors + * are at opposite sides of the strate. `RootSpineRadius` supplies the small endpoint overlap that + * joins the connector to that room; it does not create a vertical shaft. If the direct run would + * be steeper than the named 15 degree tunnel contract, the connector uses a deterministic level + * dog-leg before its final ramp; the bend is geometry, not a per-voxel search. */ struct VOXELFORGE_API FVoxelPassageLanding { diff --git a/Source/VoxelForge/Public/VoxelDensityPrimitives.h b/Source/VoxelForge/Public/VoxelDensityPrimitives.h index 1fe781f..98ef313 100644 --- a/Source/VoxelForge/Public/VoxelDensityPrimitives.h +++ b/Source/VoxelForge/Public/VoxelDensityPrimitives.h @@ -26,6 +26,7 @@ #include "CoreMinimal.h" #include "VoxelTypes.h" // SmoothStep01 +#include "VoxelPassageGeometry.h" // shared body-sized landing/tunnel geometry //============================================================================= // SEAL DE FRONTIÈRE / BOUNDARY SEAL @@ -110,33 +111,97 @@ FORCEINLINE void VF_ApplyPassageLandingCarving(float& Density, float LandingSDF, } //============================================================================= -// SPINE DE DESCENTE (0,0) / (0,0) DESCENT SPINE +// LANDING ROOM ORIGINE (0,0) / ORIGIN LANDING ROOM //============================================================================= -// Creuse une colonne verticale garantie ouverte au XY monde (0,0) dans l'INTÉRIEUR de la strate -// (entre les seals haut et bas). Les seals sont laissés intacts pour que le joueur doive encore -// creuser à travers pour descendre — ceci ne fait qu'un espace d'atterrissage propre, indépendant -// de l'archétype, aligné à travers toutes les strates. +// The old implementation carved a full-height cylinder. That made the origin visually open but +// left no floor anywhere except at the bottom, so it was a fall shaft rather than a landing. The +// origin primitive is now a top-anchored rounded room with a real flat floor in every strate. +// It never crosses either vertical seal. The inter-strate seal remains solid until a passage (or +// the player) opens it; there is no default vertical bore between rooms. FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) { if (Radius <= 0.0f) return; - // Stay within the interior — never touch the seal bands. - const float InnerTop = StrateTopZ - SealThickness; - const float InnerBot = StrateBottomZ + SealThickness; - if (WorldZ <= InnerBot || WorldZ >= InnerTop) return; + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, Radius); + if (!Geometry.bValid) return; - const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY); - const float SDF = DistXY - Radius; // < 0 inside the column - const float Blend = 3.0f; - if (SDF < Blend) + const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF( + FVector(WorldX, WorldY, WorldZ), Geometry); + VF_ApplyPassageLandingCarving( + Density, RoomSDF, BaseDensity, SealThickness); + + // The floor is intentionally written after the room carve. It is also reasserted after the + // passage/disturbance posts by the callers below; doing it here makes the standalone primitive + // itself a complete landing-room operation. + if (WorldZ <= Geometry.FloorZ + KINDA_SMALL_NUMBER + && WorldZ > Geometry.FloorZ - Geometry.FloorThickness + && FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f) + && FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)) { - float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f); - Carve = SmoothStep01(Carve); - Density -= Carve * (BaseDensity * 2.0f + SealThickness); + Density = FMath::Max(Density, BaseDensity); } } +/** Reassert only the origin-room air volume after a post-process that may add rock. */ +FORCEINLINE void VF_ApplyOriginLandingAir(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (Radius <= 0.0f) return; + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, Radius); + if (!Geometry.bValid) return; + const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF( + FVector(WorldX, WorldY, WorldZ), Geometry); + VF_ApplyPassageLandingCarving( + Density, RoomSDF, BaseDensity, SealThickness); +} + +/** Reassert only the origin-room support slab after an air carve/post-process. */ +FORCEINLINE void VF_ApplyOriginLandingFloor(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (Radius <= 0.0f) return; + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, Radius); + if (!Geometry.bValid) return; + if (WorldZ <= Geometry.FloorZ + KINDA_SMALL_NUMBER + && WorldZ > Geometry.FloorZ - Geometry.FloorThickness + && FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f) + && FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)) + { + Density = FMath::Max(Density, BaseDensity); + } +} + +/** MC-facing origin-room air backstop for the post-disturbance path. */ +FORCEINLINE void VF_ApplyOriginLandingAirMC(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + float InternalDensity = -Density; + VF_ApplyOriginLandingAir(InternalDensity, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); + Density = -InternalDensity; +} + +/** MC-facing origin-room floor backstop for the post-disturbance path. */ +FORCEINLINE void VF_ApplyOriginLandingFloorMC(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + float InternalDensity = -Density; + VF_ApplyOriginLandingFloor(InternalDensity, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); + Density = -InternalDensity; +} + //============================================================================= // XY EDGE SEAL / SCELLEMENT DE LA LIMITE XY //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 451ba67..d7506ec 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -77,9 +77,9 @@ public: // Les formes créatives viennent ensuite des params de strate. int32 Seed = 0; - // Radius (voxels) of the guaranteed open vertical landing column carved at world - // XY (0,0) in every strate — the (0,0) descent spine. Copied from VoxelSettings. - // 0 disables the spine carve. + // Authored radius (voxels) of the guaranteed flat-floored landing room at world XY (0,0) in + // every strate — the old “spine” setting now sizes a room and never creates a vertical bore. + // 0 disables the origin landing rooms. Copied from VoxelSettings. float OriginSpineRadius = 14.0f; // Radius (voxels) of the bounded world in actor-space XY, measured from (0,0). diff --git a/Source/VoxelForge/Public/VoxelPassageGeometry.h b/Source/VoxelForge/Public/VoxelPassageGeometry.h new file mode 100644 index 0000000..e77c35f --- /dev/null +++ b/Source/VoxelForge/Public/VoxelPassageGeometry.h @@ -0,0 +1,308 @@ +// Shared dimensions and construction laws for walkable passages and origin landings. +// +// This header is deliberately free of UObjects. The density path, the operator stack, the +// passage generator, and the headless measurements all include the same arithmetic so a geometry +// change cannot leave one of those paths with a different room or slope contract. + +#pragma once + +#include "CoreMinimal.h" + +namespace VoxelPassageGeometry +{ + // Authored scale and player body used by the §6.5 landing calculation. + constexpr float VoxelSizeMeters = 0.25f; + constexpr float PlayerRadiusVoxels = 34.0f / 25.0f; + constexpr float PlayerHalfHeightVoxels = 88.0f / 25.0f; + constexpr float PlayerHeightVoxels = 2.0f * PlayerHalfHeightVoxels; + constexpr float HeadroomVoxels = 4.0f; // 1 m + constexpr float MinimumTurnFloorWidthVoxels = 12.0f; // 3 m + constexpr float MinimumLandingHalfWidthVoxels = + MinimumTurnFloorWidthVoxels * 0.5f; + constexpr float MinimumRoomHeightVoxels = 12.0f; // 3 m + constexpr float LandingFloorThicknessVoxels = 3.0f; + constexpr float SealSafetyMarginVoxels = 4.0f; + constexpr float RoomRoundingVoxels = 1.5f; + constexpr float LandingCarveBlendVoxels = 4.0f; + // Level tunnel apron outside each room-floor edge. It covers the room/tunnel SDF blend and + // keeps the ramp from starting while the route is still over the room's square support patch. + constexpr float WalkableTunnelLandingApronVoxels = 6.0f; // 1.5 m + // A level transition after a bend lets the route turn before its sloped segment begins. + constexpr float WalkableTunnelTurnTransitionVoxels = 6.0f; // 1.5 m + // Keep the tunnel-air post a half voxel above the authored support plane. This is a numerical + // ownership band: the floor writer owns the plane, while the air writer owns the capsule + // volume above it even when two overlapping passage projections differ by a fraction. + constexpr float WalkableTunnelFloorAirClearanceVoxels = 0.5f; + + // A 15 degree ramp is comfortably below the 44 degree engine walkability ceiling. The + // latter is a scramble limit, not a sensible default for a tunnel the player walks through. + constexpr float WalkableTunnelMaxGradientDegrees = 15.0f; + constexpr float WalkableTunnelMaxGradient = 0.2679491924311227f; // tan(15 degrees) + + // The network connector retains the §6.5 single-file dimensions: 2.5 m clear width and 3 m + // clear height. These values are in voxels because all passage descriptors are voxel-space. + constexpr float ConnectorRadiusVoxels = 5.0f; + constexpr float ConnectorHeightVoxels = 12.0f; + constexpr float RootOverlapVoxels = 2.25f; + + // The walkability contract is about the floor carried by the tube, not only its centreline. + // A tapered tube changes that floor by the radius delta, so the construction and the audit + // use this same conservative floor delta. + FORCEINLINE float TunnelFloorZ(const FVector& Centreline, float Radius) + { + return Centreline.Z - FMath::Abs(Radius); + } + + FORCEINLINE float TunnelFloorGradient( + const FVector& Start, float StartRadius, + const FVector& End, float EndRadius) + { + const float HorizontalRun = FVector2D( + End.X - Start.X, End.Y - Start.Y).Size(); + if (HorizontalRun <= KINDA_SMALL_NUMBER) + { + return FMath::Abs(TunnelFloorZ(End, EndRadius) + - TunnelFloorZ(Start, StartRadius)) > KINDA_SMALL_NUMBER + ? FLT_MAX : 0.0f; + } + return FMath::Abs(TunnelFloorZ(End, EndRadius) + - TunnelFloorZ(Start, StartRadius)) / HorizontalRun; + } + + FORCEINLINE float RequiredHorizontalRunForFloorDrop( + float FirstFloorDrop, float SecondFloorDrop) + { + return 2.0f * FMath::Max( + FMath::Abs(FirstFloorDrop), FMath::Abs(SecondFloorDrop)) + / WalkableTunnelMaxGradient; + } + + /** Project a query into the nearest horizontal control segment of a walkable tunnel. */ + FORCEINLINE bool ProjectWalkableTunnelFloor( + const TArray& ControlPoints, + const TArray& ControlRadii, + const FVector& Position, + float& OutFloorZ, + float& OutSupportRadius, + int32* OutSegmentIndex = nullptr) + { + if (OutSegmentIndex != nullptr) + { + *OutSegmentIndex = INDEX_NONE; + } + if (ControlPoints.Num() < 2 + || ControlRadii.Num() != ControlPoints.Num()) + { + return false; + } + + const FVector2D QueryXY(Position.X, Position.Y); + float BestDistanceSquared = FLT_MAX; + bool bFoundSegment = false; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = ControlPoints[SegmentIndex]; + const FVector& B = ControlPoints[SegmentIndex + 1]; + const FVector2D AXY(A.X, A.Y); + const FVector2D Delta = FVector2D(B.X, B.Y) - AXY; + const float LengthSquared = Delta.SizeSquared(); + if (LengthSquared <= KINDA_SMALL_NUMBER) + { + continue; + } + + const float T = FMath::Clamp( + FVector2D::DotProduct(QueryXY - AXY, Delta) / LengthSquared, + 0.0f, 1.0f); + const FVector2D ClosestXY = AXY + Delta * T; + const float DistanceSquared = (QueryXY - ClosestXY).SizeSquared(); + if (DistanceSquared >= BestDistanceSquared) + { + continue; + } + + const float StartRadius = FMath::Abs(ControlRadii[SegmentIndex]); + const float EndRadius = FMath::Abs(ControlRadii[SegmentIndex + 1]); + OutFloorZ = FMath::Lerp( + TunnelFloorZ(A, StartRadius), + TunnelFloorZ(B, EndRadius), + T); + OutSupportRadius = FMath::Max( + FMath::Min(StartRadius, EndRadius) - 0.5f, + PlayerRadiusVoxels); + if (OutSegmentIndex != nullptr) + { + *OutSegmentIndex = SegmentIndex; + } + BestDistanceSquared = DistanceSquared; + bFoundSegment = true; + } + + return bFoundSegment + && FMath::IsFinite(OutFloorZ) + && FMath::IsFinite(OutSupportRadius) + && OutSupportRadius > 0.0f + && BestDistanceSquared <= FMath::Square(OutSupportRadius); + } + + struct FOriginLandingGeometry + { + bool bValid = false; + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + float HalfWidth = 0.0f; + float FloorThickness = LandingFloorThicknessVoxels; + }; + + FORCEINLINE float LandingHalfWidthForRadius(float Radius) + { + const float SafeRadius = FMath::Max(FMath::Abs(Radius), 1.0f); + return FMath::Max(MinimumLandingHalfWidthVoxels, SafeRadius + 2.0f); + } + + FORCEINLINE float LandingHeightForRadius(float Radius) + { + const float SafeRadius = FMath::Max(FMath::Abs(Radius), 1.0f); + return FMath::Max( + FMath::Max(MinimumRoomHeightVoxels, PlayerHeightVoxels + HeadroomVoxels), + 2.0f * SafeRadius + 2.0f); + } + + /** + * Build the one origin landing in a strate. + * + * `OriginRadius` is the old spine setting and becomes the room's authored mouth radius. The + * room dimensions therefore reuse the §6.5 formula exactly: + * half-width = max(7, radius + 2), + * height = max(12, capsule height + 4, 2 * radius + 2). + * The floor is placed against the top of the safe interior interval. This makes the first + * landing the place reached from above, while every lower landing remains sealed above until + * progression opens its inter-strate passage. + */ + FORCEINLINE FOriginLandingGeometry BuildOriginLandingGeometry( + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + FOriginLandingGeometry Result; + if (!(OriginRadius > 0.0f) + || !FMath::IsFinite(StrateTopZ) + || !FMath::IsFinite(StrateBottomZ) + || !FMath::IsFinite(SealThickness) + || !FMath::IsFinite(OriginRadius)) + { + return Result; + } + + Result.HalfWidth = LandingHalfWidthForRadius(OriginRadius); + const float DesiredHeight = LandingHeightForRadius(OriginRadius); + const float SafeSeal = FMath::Max(SealThickness, 0.0f); + const float InnerBottom = StrateBottomZ + SafeSeal; + const float InnerTop = StrateTopZ - SafeSeal; + const float MinimumFloor = InnerBottom + + Result.FloorThickness + SealSafetyMarginVoxels; + const float MaximumFloor = InnerTop + - DesiredHeight - SealSafetyMarginVoxels; + if (!FMath::IsFinite(InnerBottom) || !FMath::IsFinite(InnerTop) + || !(InnerTop > InnerBottom) + || !FMath::IsFinite(MinimumFloor) + || !FMath::IsFinite(MaximumFloor) + || MaximumFloor < MinimumFloor) + { + Result.HalfWidth = 0.0f; + return Result; + } + + Result.FloorZ = MaximumFloor; + Result.CeilingZ = Result.FloorZ + DesiredHeight; + Result.bValid = true; + return Result; + } + + FORCEINLINE bool IntersectsClosedInterval(float MinA, float MaxA, + float MinB, float MaxB) + { + return MaxA >= MinB && MinA <= MaxB; + } + + /** Conservative influence box for the origin room's air carve. */ + FORCEINLINE bool OriginLandingRoomTouchesBox( + const FBox& VoxelBox, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + if (!VoxelBox.IsValid) return false; + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + const float Pad = LandingCarveBlendVoxels; + return IntersectsClosedInterval( + VoxelBox.Min.X, VoxelBox.Max.X, + -Geometry.HalfWidth - Pad, Geometry.HalfWidth + Pad) + && IntersectsClosedInterval( + VoxelBox.Min.Y, VoxelBox.Max.Y, + -Geometry.HalfWidth - Pad, Geometry.HalfWidth + Pad) + && IntersectsClosedInterval( + VoxelBox.Min.Z, VoxelBox.Max.Z, + Geometry.FloorZ - Pad, Geometry.CeilingZ + Pad); + } + + /** Conservative influence box for the origin room's guaranteed solid floor. */ + FORCEINLINE bool OriginLandingFloorTouchesBox( + const FBox& VoxelBox, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + if (!VoxelBox.IsValid) return false; + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + + // One voxel is a proof margin, not a write margin. It keeps ClassifyTile conservative at + // an exact lattice edge and mirrors the existing passage-floor guard. + constexpr float ProofPad = 1.0f; + const float FloorHalfWidth = FMath::Max( + Geometry.HalfWidth - 1.0f, 0.0f); + return IntersectsClosedInterval( + VoxelBox.Min.X, VoxelBox.Max.X, + -FloorHalfWidth - ProofPad, FloorHalfWidth + ProofPad) + && IntersectsClosedInterval( + VoxelBox.Min.Y, VoxelBox.Max.Y, + -FloorHalfWidth - ProofPad, FloorHalfWidth + ProofPad) + && IntersectsClosedInterval( + VoxelBox.Min.Z, VoxelBox.Max.Z, + Geometry.FloorZ - Geometry.FloorThickness - ProofPad, + Geometry.FloorZ + ProofPad); + } + + /** Signed distance for a rounded origin room with a hard flat floor. */ + FORCEINLINE float OriginLandingRoomSDF( + const FVector& Position, const FOriginLandingGeometry& Geometry) + { + if (!Geometry.bValid || Geometry.CeilingZ <= Geometry.FloorZ) + { + return FLT_MAX; + } + + const FVector Center(0.0f, 0.0f, + (Geometry.FloorZ + Geometry.CeilingZ) * 0.5f); + const float Rounding = RoomRoundingVoxels; + const float HalfZ = (Geometry.CeilingZ - Geometry.FloorZ) * 0.5f; + const FVector HalfExtent( + FMath::Max(Geometry.HalfWidth - Rounding, 0.25f), + FMath::Max(Geometry.HalfWidth - Rounding, 0.25f), + FMath::Max(HalfZ - Rounding, 0.25f)); + const FVector D( + FMath::Abs(Position.X - Center.X) - HalfExtent.X, + FMath::Abs(Position.Y - Center.Y) - HalfExtent.Y, + FMath::Abs(Position.Z - Center.Z) - HalfExtent.Z); + const FVector Outside( + FMath::Max(D.X, 0.0f), + FMath::Max(D.Y, 0.0f), + FMath::Max(D.Z, 0.0f)); + const float Inside = FMath::Min( + FMath::Max(D.X, FMath::Max(D.Y, D.Z)), 0.0f); + const float RoundedBoxSDF = Outside.Size() + Inside - Rounding; + + // Below the floor is rock. Above it the rounded-box air volume is authoritative. + return FMath::Max(RoundedBoxSDF, Geometry.FloorZ - Position.Z); + } +} diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 5f58313..33e36f0 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -392,10 +392,11 @@ public: // SPINE & INTER-STRATE CONNECTIONS (the (0,0) descent axis) //========================================================================= - // Radius (voxels) of the guaranteed open vertical "landing" column carved at - // world XY (0,0) in EVERY strate, regardless of archetype. This is the prepared - // space the player digs THROUGH the seal into when descending. 0 = disabled. - // 10-14 → cozy shaft (default) · 20+ → wide landing chamber + // Authored radius (voxels) of the guaranteed (0,0) landing room in EVERY strate, regardless + // of archetype. The §6.5 body-sized formulas turn it into a flat-floored room; this setting + // no longer bores a vertical column. 0 = disabled. The room is top-anchored inside each + // strate, and the seals remain solid until a progression passage is opened. + // 10-14 → compact landing room (default) · 20+ → broad landing room UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine", meta = (ClampMin = "0.0")) float OriginSpineRadius = 14.0f; diff --git a/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Source/VoxelForge/Public/VoxelStrateDefinition.h index ef3a1b2..d638ea7 100644 --- a/Source/VoxelForge/Public/VoxelStrateDefinition.h +++ b/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -211,9 +211,11 @@ public: //========================================================================= // INTER-STRATE PASSAGES (how THIS strate connects DOWN to the next) //========================================================================= - // Auto-carved descent tunnels from this strate to the one below it: count, style - // (straight / worm / spiral / cascade), tapered width, length, placement. The (0,0) - // spine descent is separate (player-dug); these are the extra shortcuts. + // Auto-carved passages from this strate to the one below it: count, style + // (walkable sloped tunnel / worm / spiral / cascade), tapered width, length, placement. + // Every strate has its own finite (0,0) landing room; the passage opens the next room through + // progression. The non-default styles are retained as exotic, non-walkability-guaranteed + // descriptors. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Passages") FStratePassageConfig PassageConfig; diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index 0ddcb38..948a90b 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -66,18 +66,17 @@ struct FVoxelPassage FVector RequestedLowerPoint = FVector::ZeroVector; // Passage dimensions — how wide the carved tunnel is (in voxels). - // Varies by type: VerticalShaft ~7-8, SpiralDescent ~4, CrackCrevice ~2-3, others ~5. + // The default SlopedTunnel is a single-file 2.5 m clear bore at its narrowest point; + // authored exotic styles retain their own widths and are not covered by the walkable law. float Radius = 5.0f; // The shape/style of this passage. Determines how control points are generated // and how the passage feels to navigate (shaft, spiral, ledges, crack, etc.). EVoxelPassageType PassageType = EVoxelPassageType::SlopedTunnel; - // Multi-segment control points for complex passage shapes. - // Used by SpiralDescent (helix points) and CascadingDrops (ledge+drop points). - // When non-empty, the SDF evaluator walks this array as a capsule chain - // instead of using Upper→Mid→Lower logic. - // Empty for simple types (SlopedTunnel, VerticalShaft, CrackCrevice). + // Multi-segment control points for passage shapes. The default SlopedTunnel contains the two + // gentle ramp legs of its deterministic switchback; exotic styles retain their authored chain. + // When non-empty, the SDF evaluator walks this array as a capsule chain. TArray ControlPoints; // Per-control-point tube radius (parallel to ControlPoints) for tapered tunnels — @@ -90,6 +89,16 @@ struct FVoxelPassage FVoxelPassageLanding UpperLanding; FVoxelPassageLanding LowerLanding; + // Construction-time walkability facts for diagnostics and the commandlet report. These are + // metadata, not a second runtime decision: the final-density sampler remains authoritative. + bool bWalkableTunnelContract = false; + float TunnelMaxGradientDegrees = 0.0f; + float TunnelVerticalDropVoxels = 0.0f; + float TunnelRequiredHorizontalRunVoxels = 0.0f; + float TunnelHorizontalPathLengthVoxels = 0.0f; + float TunnelMinimumClearWidthVoxels = 0.0f; + float TunnelClearHeightVoxels = 0.0f; + // Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords. // Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with // a single squared-distance test instead of walking every segment per voxel. @@ -362,14 +371,26 @@ public: void ApplyPassageLandingAir(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const; + /** Reassert the default walkable tunnel's clear air in internal density after any post. */ + void ApplyPassageTunnelAir(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + /** Re-assert landing air after MC-space disturbances, still before the final XY seal. */ void ApplyPassageLandingAirMC(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const; + /** Re-assert walkable tunnel air after MC-space disturbances, still before the final XY seal. */ + void ApplyPassageTunnelAirMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + /** Re-assert a landing floor after the landing air pass, still before the final XY seal. */ void ApplyPassageLandingFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const; + /** Re-assert only the flat room floors after the tunnel-air post; never writes tunnel floors. */ + void ApplyPassageLandingRoomFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity) const; + /** * True si la sphère englobante d'un passage (élargie du rayon de blend de carve) touche la * boîte VOXEL [MinVoxel, MaxVoxel]. Test conservatif O(Passages) — utilisé par ClassifyTile @@ -380,6 +401,10 @@ public: /** Conservative box guard for the floor fill; unlike a tube it can affect the all-air proof. */ bool AnyPassageLandingFloorNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + /** Conservative box guards for the per-strate (0,0) landing rooms and their floor slabs. */ + bool AnyOriginLandingNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + bool AnyOriginLandingFloorNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + /** Get all generated passages (for debug display). */ const TArray& GetPassages() const { return Passages; } @@ -411,12 +436,12 @@ protected: // World seed (stored for passage generation) int32 CachedSeed = 0; - // Whether to auto-open the (0,0) entry shaft through the top of strate 0. + // Whether to auto-open the (0,0) origin landing room from above in strate 0. // Copied from VoxelSettings::bOpenSurfaceEntry during Initialize(). bool bOpenSurfaceEntry = true; - // Radius (voxels) of the surface entry shaft at (0,0). Copied from - // VoxelSettings::OriginSpineRadius so the entry matches the spine landing. + // Authored radius (voxels) of the origin landing room and its optional surface opening. + // Copied from VoxelSettings::OriginSpineRadius. float OriginSpineRadius = 14.0f; // Solid-bedrock gap between consecutive strates, in chunks. Copied from diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index b395150..5bd04b3 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -3,6 +3,7 @@ #pragma once #include "CoreMinimal.h" +#include "VoxelPassageGeometry.h" class UVoxelGenerator; class UVoxelStrateManager; @@ -19,7 +20,7 @@ class UVoxelStrateManager; struct VOXELFORGE_API FVoxelPlayerCapsuleConstants { static constexpr float VoxelSizeCentimeters = 25.0f; - static constexpr float VoxelSizeMeters = VoxelSizeCentimeters / 100.0f; + static constexpr float VoxelSizeMeters = VoxelPassageGeometry::VoxelSizeMeters; static constexpr float RadiusCentimeters = 34.0f; static constexpr float HalfHeightCentimeters = 88.0f; // UCharacterMovementComponent's UE 5.7 default. Callers that have a character CDO should @@ -29,9 +30,9 @@ struct VOXELFORGE_API FVoxelPlayerCapsuleConstants // UE 5.7's WalkableFloorZ=0.71 resolves to about 44.8 degrees; a character CDO may expose // the precise angle. The diagnosis test copies that precise project value when available. static constexpr float WalkableFloorAngleDegrees = 44.8f; - static constexpr float RadiusVoxels = RadiusCentimeters / VoxelSizeCentimeters; - static constexpr float HalfHeightVoxels = HalfHeightCentimeters / VoxelSizeCentimeters; - static constexpr float HeightVoxels = 2.0f * HalfHeightVoxels; + static constexpr float RadiusVoxels = VoxelPassageGeometry::PlayerRadiusVoxels; + static constexpr float HalfHeightVoxels = VoxelPassageGeometry::PlayerHalfHeightVoxels; + static constexpr float HeightVoxels = VoxelPassageGeometry::PlayerHeightVoxels; }; /** Read-only density source used by the offline Tier 2 pass for a materialised custom stack. */ @@ -68,7 +69,8 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings TOptional CoverPointA; TOptional CoverPointB; float CoverMarginVoxels = 48.0f; - // Origin-rooted topologies can route from one mouth to the other through the (0,0) spine. + // Origin-rooted topologies can route from one mouth to the other through the finite (0,0) + // landing room and its explicitly carved connector network. // When true, include (0,0) in the fitted AABB before applying MaxCells. A caller must keep // this enabled for that topology; an over-cap route window is then refused honestly instead // of being cropped back to a mouth-only box. @@ -322,6 +324,12 @@ struct VOXELFORGE_API FVoxelPlayerFitWalkReport int64 DepartureComponentCells = 0; float MouthComponentGapVoxels = -1.0f; + // Optional room probes evaluated against this same grid and start component. The arrays are + // empty for ordinary callers; when supplied, each entry is 1 only when the probe is both a + // player-fit cell and in the arrival/start component. + TArray ComponentProbePlayerFit; + TArray ComponentProbeInStartComponent; + // The DFS returns to its root when the target component is unreachable. Keep both the // physical end of that walk and the last newly reached pose: the latter is the useful // failure-frontier point for a render, while the former is the literal final pose. @@ -496,4 +504,5 @@ VOXELFORGE_API bool VF_MeasurePlayerFitWalkWithSampler( const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, - FVoxelPlayerFitWalkReport& OutReport); + FVoxelPlayerFitWalkReport& OutReport, + const TArray* ComponentProbePoints = nullptr); diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index 29acd83..c56e61e 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -32,19 +32,18 @@ class AActor; // IWYU : paramètre de TSubclassOf seulement / T * of the carved tunnel (control point layout, radius, overall feel). * * Types: - * SlopedTunnel — Angled bore with a horizontal midpoint offset (default/legacy). - * VerticalShaft — Straight or near-straight drop between strates. - * SpiralDescent — Helical corkscrew path winding downward. - * CascadingDrops — Series of short vertical pits connected by horizontal ledges. - * CrackCrevice — Narrow fracture passage, tight squeeze through rock. + * SlopedTunnel — The default walkable, gentle-gradient tunnel between landing rooms. + * VerticalShaft — A vertical drop; never a continuous walkable descent. + * SpiralDescent — An exotic helix; walkability requires a separately designed ramp contract. + * CascadingDrops — Drops and ledges; never a continuous walkable descent as authored. + * CrackCrevice — A narrow fracture; no player-fit guarantee. */ UENUM(BlueprintType) enum class EVoxelPassageType : uint8 { - // Angled bore connecting two layers with a horizontal midpoint offset. - // Two capsule segments: Upper→Mid, Mid→Lower. Comfortable radius (~5 voxels). - // This is the original passage type — feels like a natural sloped tunnel. - SlopedTunnel UMETA(DisplayName = "Sloped Tunnel (angled bore)"), + // Walkable single-file tunnel. The runtime builds a deterministic switchback whose each ramp + // is below VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees. + SlopedTunnel UMETA(DisplayName = "Sloped Tunnel (walkable default)"), // Straight vertical drop between strates. Same XY for upper and lower points. // Wider radius (~7-8 voxels) to feel like a natural shaft or sinkhole. @@ -1758,7 +1757,9 @@ struct VOXELFORGE_API FStrateDisturbanceParams UENUM(BlueprintType) enum class EVoxelPassageStyle : uint8 { - Straight UMETA(DisplayName = "Straight (vertical shaft)"), + // “Straight” is retained as the authored style name for the base connection, but its runtime + // geometry is a gentle two-leg switchback rather than a vertical shaft. + Straight UMETA(DisplayName = "Straight (walkable sloped tunnel)"), Worm UMETA(DisplayName = "Worm (organic meander around the axis)"), Spiral UMETA(DisplayName = "Spiral (corkscrew descent)"), Cascading UMETA(DisplayName = "Cascading (ledge + drop staircase)") @@ -1768,8 +1769,8 @@ enum class EVoxelPassageStyle : uint8 * FStratePassageConfig — how THIS strate connects DOWN to the strate below it. * * Lives on each UVoxelStrateDefinition: the upper strate of every boundary controls its - * own descent tunnels, so different layers connect differently. The (0,0) spine descent - * is separate (player-dug); these are the auto-carved shortcuts placed away from it. + * own progression tunnel, so different layers connect differently. Each strate has a finite + * (0,0) landing room; this config controls the passage that opens the next room. */ USTRUCT(BlueprintType) struct VOXELFORGE_API FStratePassageConfig @@ -1782,17 +1783,19 @@ struct VOXELFORGE_API FStratePassageConfig // Tunnel shape. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage") - EVoxelPassageStyle Style = EVoxelPassageStyle::Worm; + EVoxelPassageStyle Style = EVoxelPassageStyle::Straight; // ----- WIDTH (tapers along the length) ----- // Radius at the two mouths (entry/exit) and at the middle. Equal = uniform tube; - // The defaults are 2 m radius / 4 m bore at mouths and 1.5 m radius / 3 m bore in the middle. + // The walkable default is deliberately single-file: 3.0 m diameter at mouths and 2.5 m at + // the waist (6/5 voxel radii at 25 cm per voxel). The §6.5 landing room remains wider for + // turning. // Mouth > Mid = chambers at the ends with a squeeze between; Mid > Mouth = a bulge. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) - float MouthRadius = 8.0f; + float MouthRadius = 6.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) - float MidRadius = 6.0f; + float MidRadius = 5.0f; // ----- LENGTH ----- // How far the tunnel reaches INTO each strate (voxels). Auto-capped to the interior; @@ -1813,7 +1816,8 @@ struct VOXELFORGE_API FStratePassageConfig float DistanceMax = 192.0f; // ----- SHAPE detail ----- - // Worm: max sideways excursion from the axis (voxels). 24 = 6 m; 0 = straight even in Worm style. + // Exotic styles only: max sideways excursion from the axis (voxels). 24 = 6 m; 0 = straight + // even in Worm style. The default walkable switchback does not use this wander value. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Shape", meta = (ClampMin = "0.0")) float Wander = 24.0f; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 5ee5790..e1797e4 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -384,7 +384,10 @@ struct FExploreWorld Index)); Definition->StrateDescription = FText::FromString( TEXT("Transient, deterministic commandlet definition.")); - Definition->StrateHeightInChunks = 8; + // The commandlet's acceptance sweep uses the owner-facing base spacing: + // 4 chunks * 32 voxels * 0.25 m = 32 m per strate. Production assets may be taller; + // this keeps the measured tunnel arithmetic tied to the stated ~32 m descent. + Definition->StrateHeightInChunks = 4; Definition->TransitionType = EVoxelStrateTransition::Hard; Definition->GeneratorType = Index == Arguments.Slot ? Arguments.Archetype @@ -510,6 +513,12 @@ struct FExploreWalkOutput bool bOriginCheckAvailable = false; FString OriginCheckRefusalReason; FVoxelPlayerFitWalkReport OriginCheckReport; + int32 ArrivalLandingRoomProbeCount = 0; + int32 ArrivalLandingRoomPlayerFitProbeCount = 0; + int32 ArrivalLandingRoomArrivalComponentProbeCount = 0; + int32 DepartureLandingRoomProbeCount = 0; + int32 DepartureLandingRoomPlayerFitProbeCount = 0; + int32 DepartureLandingRoomArrivalComponentProbeCount = 0; }; struct FExploreExportOutput @@ -542,12 +551,16 @@ bool FindMouths( FVector& OutArrival, FVector& OutDeparture, int32& OutArrivalCount, - int32& OutDepartureCount) + int32& OutDepartureCount, + const FVoxelPassage*& OutArrivalPassage, + const FVoxelPassage*& OutDeparturePassage) { OutArrival = FVector::ZeroVector; OutDeparture = FVector::ZeroVector; OutArrivalCount = 0; OutDepartureCount = 0; + OutArrivalPassage = nullptr; + OutDeparturePassage = nullptr; for (const FVoxelPassage& Passage : Manager.GetPassages()) { if (Passage.LowerStrateIndex == TargetSlot @@ -556,6 +569,7 @@ bool FindMouths( if (OutArrivalCount == 0) { OutArrival = Passage.LowerPoint; + OutArrivalPassage = &Passage; } ++OutArrivalCount; } @@ -565,6 +579,7 @@ bool FindMouths( if (OutDepartureCount == 0) { OutDeparture = Passage.UpperPoint; + OutDeparturePassage = &Passage; } ++OutDepartureCount; } @@ -1273,13 +1288,17 @@ bool RunWalk( return false; } + const FVoxelPassage* ArrivalPassage = nullptr; + const FVoxelPassage* DeparturePassage = nullptr; if (!FindMouths( *World.Manager, Arguments.Slot, OutOutput.ArrivalVoxels, OutOutput.DepartureVoxels, OutOutput.ArrivalPassageCount, - OutOutput.DeparturePassageCount)) + OutOutput.DeparturePassageCount, + ArrivalPassage, + DeparturePassage)) { OutOutput.Status = TEXT("refused"); OutOutput.RefusalReason = TEXT( @@ -1299,6 +1318,32 @@ bool RunWalk( Settings.PlayerCapsuleRadiusVoxels) + 2.0f; Settings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Arguments.Archetype); + TArray LandingRoomProbePoints; + auto AppendLandingRoomProbes = [&LandingRoomProbePoints]( + const FVoxelPassageLanding& Landing) + { + const float ProbeOffset = FMath::Min( + 2.0f, + FMath::Max(0.0f, Landing.HalfWidth - 2.0f)); + LandingRoomProbePoints.Add(Landing.StandingPoint); + LandingRoomProbePoints.Add( + Landing.StandingPoint + FVector(ProbeOffset, 0.0f, 0.0f)); + LandingRoomProbePoints.Add( + Landing.StandingPoint + FVector(-ProbeOffset, 0.0f, 0.0f)); + LandingRoomProbePoints.Add( + Landing.StandingPoint + FVector(0.0f, ProbeOffset, 0.0f)); + LandingRoomProbePoints.Add( + Landing.StandingPoint + FVector(0.0f, -ProbeOffset, 0.0f)); + }; + if (ArrivalPassage != nullptr) + { + AppendLandingRoomProbes(ArrivalPassage->LowerLanding); + } + if (DeparturePassage != nullptr) + { + AppendLandingRoomProbes(DeparturePassage->UpperLanding); + } + const FGeneratorDensitySampler Sampler(*World.Generator); if (!VF_MeasurePlayerFitWalkWithSampler( Sampler, @@ -1308,7 +1353,8 @@ bool RunWalk( OutOutput.ArrivalVoxels, OutOutput.DepartureVoxels, Settings, - OutOutput.Report)) + OutOutput.Report, + &LandingRoomProbePoints)) { OutOutput.Status = TEXT("refused"); OutOutput.RefusalReason = OutOutput.Report.RefusalReason; @@ -1338,6 +1384,30 @@ bool RunWalk( { OutOutput.OriginCheckRefusalReason = OutOutput.OriginCheckReport.RefusalReason; } + const int32 ArrivalProbeCount = ArrivalPassage != nullptr ? 5 : 0; + const int32 DepartureProbeCount = DeparturePassage != nullptr ? 5 : 0; + OutOutput.ArrivalLandingRoomProbeCount = ArrivalProbeCount; + OutOutput.DepartureLandingRoomProbeCount = DepartureProbeCount; + for (int32 ProbeIndex = 0; ProbeIndex < OutOutput.Report.ComponentProbePlayerFit.Num(); + ++ProbeIndex) + { + const bool bPlayerFit = OutOutput.Report.ComponentProbePlayerFit[ProbeIndex] != 0u; + const bool bInArrivalComponent = + OutOutput.Report.ComponentProbeInStartComponent.IsValidIndex(ProbeIndex) + && OutOutput.Report.ComponentProbeInStartComponent[ProbeIndex] != 0u; + if (ProbeIndex < ArrivalProbeCount) + { + OutOutput.ArrivalLandingRoomPlayerFitProbeCount += bPlayerFit ? 1 : 0; + OutOutput.ArrivalLandingRoomArrivalComponentProbeCount += + bInArrivalComponent ? 1 : 0; + } + else + { + OutOutput.DepartureLandingRoomPlayerFitProbeCount += bPlayerFit ? 1 : 0; + OutOutput.DepartureLandingRoomArrivalComponentProbeCount += + bInArrivalComponent ? 1 : 0; + } + } OutOutput.Status = TEXT("ok"); return true; @@ -1509,7 +1579,7 @@ FString BuildManifestJson( TSharedRef Writer = TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 1); + Writer->WriteValue(TEXT("schema_version"), 2); Writer->WriteValue(TEXT("format"), TEXT("OBJ")); Writer->WriteValue(TEXT("mesh_file"), Export.MeshFileName); Writer->WriteValue(TEXT("mesh_positions_units"), TEXT("metres")); @@ -1703,7 +1773,7 @@ FString BuildExploreJson( TSharedRef Writer = TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 1); + Writer->WriteValue(TEXT("schema_version"), 2); Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore")); Writer->WriteValue(TEXT("read_only_generation"), true); @@ -1780,6 +1850,11 @@ FString BuildExploreJson( if (Arguments.bWalk) { const FVoxelPlayerFitWalkReport& Report = Output.Walk.Report; + // The primary report is the mouth-sized arrival/departure window. Origin reachability is + // a separate, explicitly origin-inclusive measurement; expose that report at the legacy + // top-level keys so a sweep cannot accidentally count a window that omitted (0,0). + const FVoxelPlayerFitWalkReport& OriginReport = Output.Walk.bOriginCheckAvailable + ? Output.Walk.OriginCheckReport : Report; Writer->WriteObjectStart(TEXT("walk")); Writer->WriteValue(TEXT("status"), Output.Walk.Status); Writer->WriteValue(TEXT("refusal_or_error"), Output.Walk.RefusalReason); @@ -1795,6 +1870,27 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("working_memory_cap_bytes"), MaxWalkWorkingBytes); Writer->WriteValue(TEXT("arrival_passage_count"), Output.Walk.ArrivalPassageCount); Writer->WriteValue(TEXT("departure_passage_count"), Output.Walk.DeparturePassageCount); + Writer->WriteValue(TEXT("landing_room_probe_offsets_voxels"), 2.0); + Writer->WriteValue(TEXT("arrival_landing_room_probe_count"), + Output.Walk.ArrivalLandingRoomProbeCount); + Writer->WriteValue(TEXT("arrival_landing_room_player_fit_probe_count"), + Output.Walk.ArrivalLandingRoomPlayerFitProbeCount); + Writer->WriteValue(TEXT("arrival_landing_room_arrival_component_probe_count"), + Output.Walk.ArrivalLandingRoomArrivalComponentProbeCount); + Writer->WriteValue(TEXT("arrival_landing_room_in_arrival_component"), + Output.Walk.ArrivalLandingRoomProbeCount > 0 + && Output.Walk.ArrivalLandingRoomArrivalComponentProbeCount + == Output.Walk.ArrivalLandingRoomProbeCount); + Writer->WriteValue(TEXT("departure_landing_room_probe_count"), + Output.Walk.DepartureLandingRoomProbeCount); + Writer->WriteValue(TEXT("departure_landing_room_player_fit_probe_count"), + Output.Walk.DepartureLandingRoomPlayerFitProbeCount); + Writer->WriteValue(TEXT("departure_landing_room_arrival_component_probe_count"), + Output.Walk.DepartureLandingRoomArrivalComponentProbeCount); + Writer->WriteValue(TEXT("departure_landing_room_in_arrival_component"), + Output.Walk.DepartureLandingRoomProbeCount > 0 + && Output.Walk.DepartureLandingRoomArrivalComponentProbeCount + == Output.Walk.DepartureLandingRoomProbeCount); if (Output.Walk.bHasArrival) { WriteJsonVector(*Writer, TEXT("arrival_mouth_voxels"), Output.Walk.ArrivalVoxels, 1.0f); @@ -1870,14 +1966,16 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("agent_to_target_component_gap_m"), static_cast(Report.AgentToTargetComponentGapVoxels) * FVoxelPlayerCapsuleConstants::VoxelSizeMeters); - Writer->WriteValue(TEXT("origin_column_in_sampled_window"), Report.bOriginColumnInSampledWindow); - Writer->WriteValue(TEXT("origin_column_has_player_fit"), Report.bOriginColumnHasPlayerFit); - Writer->WriteValue(TEXT("origin_column_player_fit_cells"), Report.OriginColumnPlayerFitCells); - Writer->WriteValue(TEXT("origin_column_reachable_from_arrival"), Report.bOriginColumnReachable); + Writer->WriteValue(TEXT("origin_column_measurement_window"), + TEXT("origin-inclusive mouth-AABB + margin")); + Writer->WriteValue(TEXT("origin_column_in_sampled_window"), OriginReport.bOriginColumnInSampledWindow); + Writer->WriteValue(TEXT("origin_column_has_player_fit"), OriginReport.bOriginColumnHasPlayerFit); + Writer->WriteValue(TEXT("origin_column_player_fit_cells"), OriginReport.OriginColumnPlayerFitCells); + Writer->WriteValue(TEXT("origin_column_reachable_from_arrival"), OriginReport.bOriginColumnReachable); Writer->WriteValue(TEXT("reachable_set_to_origin_column_voxels"), - static_cast(Report.ReachableSetToOriginColumnVoxels)); + static_cast(OriginReport.ReachableSetToOriginColumnVoxels)); Writer->WriteValue(TEXT("reachable_set_to_origin_column_m"), - static_cast(Report.ReachableSetToOriginColumnVoxels) + static_cast(OriginReport.ReachableSetToOriginColumnVoxels) * FVoxelPlayerCapsuleConstants::VoxelSizeMeters); Writer->WriteObjectStart(TEXT("origin_check")); Writer->WriteValue(TEXT("attempted"), Output.Walk.bOriginCheckAttempted); @@ -1885,40 +1983,40 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("refusal_or_error"), Output.Walk.OriginCheckRefusalReason); if (Output.Walk.bOriginCheckAvailable) { - const FVoxelPlayerFitWalkReport& OriginReport = Output.Walk.OriginCheckReport; + const FVoxelPlayerFitWalkReport& OriginCheckReport = Output.Walk.OriginCheckReport; Writer->WriteValue(TEXT("window_policy"), TEXT("origin-inclusive mouth-AABB + margin")); - Writer->WriteValue(TEXT("connectivity_result"), ConnectivityResultName(OriginReport.Result)); - Writer->WriteValue(TEXT("can_reach_departure"), OriginReport.bCanReachDeparture); - Writer->WriteValue(TEXT("sampled_num_x"), OriginReport.SampledNumX); - Writer->WriteValue(TEXT("sampled_num_y"), OriginReport.SampledNumY); - Writer->WriteValue(TEXT("sampled_num_z"), OriginReport.SampledNumZ); - Writer->WriteValue(TEXT("sampled_min_x"), static_cast(OriginReport.SampledMinX)); - Writer->WriteValue(TEXT("sampled_max_x_exclusive"), static_cast(OriginReport.SampledMaxX)); - Writer->WriteValue(TEXT("sampled_min_y"), static_cast(OriginReport.SampledMinY)); - Writer->WriteValue(TEXT("sampled_max_y_exclusive"), static_cast(OriginReport.SampledMaxY)); - Writer->WriteValue(TEXT("sampled_min_z"), OriginReport.SampledMinZ); - Writer->WriteValue(TEXT("sampled_max_z_exclusive"), OriginReport.SampledMaxZ); - Writer->WriteValue(TEXT("player_fit_cells"), OriginReport.PlayerFitVolumeCells); - Writer->WriteValue(TEXT("reachable_player_fit_cells"), OriginReport.ReachablePlayerFitCells); + Writer->WriteValue(TEXT("connectivity_result"), ConnectivityResultName(OriginCheckReport.Result)); + Writer->WriteValue(TEXT("can_reach_departure"), OriginCheckReport.bCanReachDeparture); + Writer->WriteValue(TEXT("sampled_num_x"), OriginCheckReport.SampledNumX); + Writer->WriteValue(TEXT("sampled_num_y"), OriginCheckReport.SampledNumY); + Writer->WriteValue(TEXT("sampled_num_z"), OriginCheckReport.SampledNumZ); + Writer->WriteValue(TEXT("sampled_min_x"), static_cast(OriginCheckReport.SampledMinX)); + Writer->WriteValue(TEXT("sampled_max_x_exclusive"), static_cast(OriginCheckReport.SampledMaxX)); + Writer->WriteValue(TEXT("sampled_min_y"), static_cast(OriginCheckReport.SampledMinY)); + Writer->WriteValue(TEXT("sampled_max_y_exclusive"), static_cast(OriginCheckReport.SampledMaxY)); + Writer->WriteValue(TEXT("sampled_min_z"), OriginCheckReport.SampledMinZ); + Writer->WriteValue(TEXT("sampled_max_z_exclusive"), OriginCheckReport.SampledMaxZ); + Writer->WriteValue(TEXT("player_fit_cells"), OriginCheckReport.PlayerFitVolumeCells); + Writer->WriteValue(TEXT("reachable_player_fit_cells"), OriginCheckReport.ReachablePlayerFitCells); Writer->WriteValue(TEXT("reachable_player_fit_fraction"), - static_cast(OriginReport.ReachablePlayerFitFraction)); - Writer->WriteValue(TEXT("player_fit_components"), OriginReport.PlayerFitComponents); - Writer->WriteValue(TEXT("arrival_component_cells"), OriginReport.ArrivalComponentCells); - Writer->WriteValue(TEXT("departure_component_cells"), OriginReport.DepartureComponentCells); + static_cast(OriginCheckReport.ReachablePlayerFitFraction)); + Writer->WriteValue(TEXT("player_fit_components"), OriginCheckReport.PlayerFitComponents); + Writer->WriteValue(TEXT("arrival_component_cells"), OriginCheckReport.ArrivalComponentCells); + Writer->WriteValue(TEXT("departure_component_cells"), OriginCheckReport.DepartureComponentCells); Writer->WriteValue(TEXT("mouth_component_gap_voxels"), - static_cast(OriginReport.MouthComponentGapVoxels)); + static_cast(OriginCheckReport.MouthComponentGapVoxels)); Writer->WriteValue(TEXT("origin_column_in_sampled_window"), - OriginReport.bOriginColumnInSampledWindow); + OriginCheckReport.bOriginColumnInSampledWindow); Writer->WriteValue(TEXT("origin_column_has_player_fit"), - OriginReport.bOriginColumnHasPlayerFit); + OriginCheckReport.bOriginColumnHasPlayerFit); Writer->WriteValue(TEXT("origin_column_player_fit_cells"), - OriginReport.OriginColumnPlayerFitCells); + OriginCheckReport.OriginColumnPlayerFitCells); Writer->WriteValue(TEXT("origin_column_reachable_from_arrival"), - OriginReport.bOriginColumnReachable); + OriginCheckReport.bOriginColumnReachable); Writer->WriteValue(TEXT("reachable_set_to_origin_column_voxels"), - static_cast(OriginReport.ReachableSetToOriginColumnVoxels)); + static_cast(OriginCheckReport.ReachableSetToOriginColumnVoxels)); Writer->WriteValue(TEXT("reachable_set_to_origin_column_m"), - static_cast(OriginReport.ReachableSetToOriginColumnVoxels) + static_cast(OriginCheckReport.ReachableSetToOriginColumnVoxels) * FVoxelPlayerCapsuleConstants::VoxelSizeMeters); } Writer->WriteObjectEnd();