From 4378385b37a713459b144e490296b97fcbcc403c Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sun, 6 Sep 2026 08:08:38 +0200 Subject: [PATCH] fix(passages): land mouths where a BODY fits -- and the cause is now cornered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VF_SuggestLandingPoint aimed at open AIR. Air is not where a body fits: a mouth could sit in a perfectly open pocket no capsule can stand in. It now requires a legal capsule pose -- clearance (r 1.36, half-height 3.52), support within a 1.8-voxel / 45 cm step height, a walkable support PATCH (4 of 5 columns, centre required, slope <= 44.8 deg), positive density throughout the capsule, strict 2x seal interior, bounded lateral snap. It stays a PURE free function, which was the hard constraint -- GeneratePassages runs inside Initialize while the layout is half-built. So it cannot flood-fill, and Codex correctly did not pretend otherwise: it aims at deterministic guaranteed features instead (room centre/floor, slab floor, horizontal maze edge, shaft feature-core ledge pose, island top). Both mouths query their own strate independently; no passage chaining, so LayoutOrderIndependence stays green. Placement measurably improved: Maze comps 309 -> 82, largest 176 -> 1217, gap 206 -> 122 VerticalShafts comps 112 -> 36, largest 96 -> 619 CrystalChamber gap 47.5 -> 19.1, departure mouth now in a 717-cell comp Underwater comps 86 -> 13 ⚠️ AND THE LAW IS STILL 1/8. Reported honestly rather than tuned. Mouths now land on legal body-sized poses; the poses just are not connected to each other. ⇒ BY ELIMINATION, THE CAUSE IS GENERATION. Four candidates, three dead: size DEAD -- Jahni walked it; bore measures 3.62 m vs 3.677 m spec the stencil FIXED -- fragmentation was 3x overstated by an exact-Z-1 floor placement FIXED -- mouths now sit on legal poses, several in large comps GENERATION STANDS -- body-sized space exists in big components that do not reach each other ⭐ AND THE MAZE TIES TWO OF JAHNI'S COMPLAINTS TOGETHER. Its arrival mouth lands in a 30-cell pocket while the main network holds 1217. Edge selection is INDEPENDENT HASH GATING per +X/+Y/+Z edge (VoxelDensityOpStack.cpp:582/584/586) -- so the corridor graph is a random subgraph with no connectivity guarantee, exactly the percolation failure VerticalShafts had before its drainage tree. That is the same root cause as "mostly a cube full of straight pathway along the edges": a spanning tree would make it BOTH feel like a maze (dead ends, uneven runs) AND be connected by construction. One fix, two complaints. Codex found a real error in my spec: an infinite shaft cylinder has no support surface AT its axis, so the old exact-axis assertion was unsatisfiable. It now requires a deterministic feature core and audits tree connectivity from the resolved axis. Build clean, 29 succeeded / 0 failed / 2 known deliberate warnings. Box audits 0 violations. Shaft refinement 16/16. Order-independence green across 10 slots, 31 passages, 3 pool orders. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV --- CODEMAP.md | 19 +- COMPOSER-NOTES.md | 90 +- .../Tests/VoxelForgePassageOpenSpaceTest.cpp | 84 +- .../VoxelForgeStrateConnectivityTest.cpp | 111 +- .../Private/VoxelCaveMorphology.cpp | 1144 +++++++++++++++-- .../VoxelForge/Private/VoxelStrateManager.cpp | 57 +- .../VoxelForge/Public/VoxelCaveMorphology.h | 25 +- 7 files changed, 1365 insertions(+), 165 deletions(-) diff --git a/CODEMAP.md b/CODEMAP.md index da92525..b7d9cc1 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -406,11 +406,14 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. `MakeStrateSeed` (h:~477) is the shared pure world-seed/strate-index salt used by both `BuildChunkCache` and the TunnelNetwork/Underwater destination landing queries. -`VF_SuggestLandingPoint` (h:~550, implemented in `VoxelCaveMorphology.cpp`) is the pure landing-site -query: nearest hash-room height at the requested XY for TunnelNetwork/Underwater, slab void midpoint -for FlatPlain/CrystalChamber, and bounded lateral searches for a horizontal lattice corridor (one -Maze cell), the exact axis of a lower-seal/ledge-safe VerticalShafts tree shaft (up to two local -shaft spacings at the 0.6 site density), or a validated blob top (one IslandSpacing). The +`VF_SuggestLandingPoint` (h:~550, implemented in `VoxelCaveMorphology.cpp`) is the pure player-fit +landing-site query: it evaluates the local archetype source through the same capsule/support +contract as the measurement pass — support within step height, walkable slope, and full capsule +clearance. Room queries use a room-floor core, slabs keep their requested XY, and the sparse + sources use bounded feature cores: a horizontal lattice corridor (one Maze cell), a selected + VerticalShafts tree shaft plus deterministic ledge-side offsets (up to two local shaft spacings at the 0.6 +site density), or a validated blob top (one IslandSpacing). It is intentionally not a flood fill: +the pure query can prove a local pose, not a large component. The VerticalShafts result is therefore on the drainage tree, not merely in an open roughness disk. It returns `false` when those feature-bearing conditions cannot be proven inside the supplied budget; SurfaceWorld remains refused because its production height can depend on manager-owned biome/per-column @@ -452,7 +455,7 @@ Maps depth→strate at runtime; owns passages. | Method | .cpp line | Role | |--------|-----------|------| | `Initialize` | 10 | Keeps the existing sequencing through `GeneratePassages`, but selects one slot source: a validated cooked season (exact ordered bounds/vector/recipe) or the unchanged authored fixed+sorted/shuffled pool. Assigned-invalid seasons fail closed. Authored source definitions are duplicated as content bags; generated slots use deterministic C++ defaults and zero auto-passages because schema 2 does not store passage configuration. | -| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. After XY placement, the destination's pure `VF_SuggestLandingPoint` may move the lower mouth within an archetype-specific lattice budget, then the result is clamped outside the destination seal bands and interpolated through the control points; unsupported/no-footing answers warn and preserve the old random reach. | +| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. Each mouth independently queries its own strate's pure player-fit `VF_SuggestLandingPoint` with its contract seed (cave room salt, otherwise world seed), may move within its archetype-specific budget, then is clamped outside the destination seal bands and interpolated through the control points; unsupported/no-fit answers preserve the old random reach. | | `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. | | `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. | @@ -666,8 +669,8 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeComposerSeasonTest.cpp` | `VoxelForge.Composer.Season` | Existing compose/review/393,216-sample round trip plus schema hash tamper rejection, two independent runtime manager/generator instances, season-authoritative seed/spine/radius, unset-season regression, and brute-force verification of every sampled non-Mixed recipe tile verdict (up to 64). | | `VoxelForgeComposerCorpusFreeTest.cpp` | `VoxelForge.Composer.CorpusFree` | Shares each structure recipe across today's corpus blend, naive independent uniform rolls, and constraint-sampled rolls. The completed equal-arm world-scale run uses **16 candidates per arm**, step 4 / radius 256 / `MaxCells=8,000,000`, fixed passage-law mouths, and 40 box probes per candidate. Result: **10/16, 9/16, 8/16** survival; survivor feature-scale ranges are **8..268**, **8..60**, **8..92** voxels. Box checks: **185/246,235**, **260/346,060**, **299/397,969** proved/voxels, **0 violations** in every arm; constraint parameter and box-stop violations were also 0. | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | -| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | -| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts axis landings, checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. | +| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check both generated mouths against independent source queries. Answered mouths must match the pure player-fit point within the float envelope; the lower mouth also keeps the 16-point ring and source-footing assertions outside the carve/blend band. This is a local-fit/open-space proxy, not a flood-fill proof. Reports answered/refused mouths and archetypes; fails if either side is vacuous. | +| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. | | `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. | ## 4. The density pipeline (most-edited hot path) diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index cb5c663..796f55a 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -1549,19 +1549,27 @@ law has a design-level fix. --- -## 11. Tier 1 status — passages aim at real open space (bounded full-point query; build not run) +## 11. Tier 1 status — passages aim at player-fit source space (pure local stencil) -**Updated 2026-08-29.** `VF_SuggestLandingPoint` (in `VoxelCaveMorphology`) is a **pure free function** +**Updated 2026-09-06.** `VF_SuggestLandingPoint` (in `VoxelCaveMorphology`) is a **pure free function** of (archetype, params, seed, desired XY, lateral budget) — it touches no operator stack, no `UVoxelStrateManager`, no cache. That matters: `GeneratePassages` runs inside `Initialize`, when the layout is half-built and `LayoutVersion` is mid-flight, so constructing anything holding a manager pointer there is a re-entrancy trap. -`GeneratePassages` now asks the **destination** strate for a full open/footing point near the chosen XY. -Maze, VerticalShafts, and FloatingIslands may snap to a nearby lattice site, shaft, or island top; -the lower mouth uses the returned X/Y/Z, clamped strictly inside the interior (never into a seal band). -Where the archetype cannot prove footing inside its bound, **today's random reach and original XY are -preserved unchanged**. +`GeneratePassages` now asks **each mouth's own strate**, independently, for a player-fit pose near +the chosen XY. The pure query reconstructs the exact default capsule stencil used by +`VoxelStrateMeasure`: support coverage within max step height, a walkable support patch, and a clear +capsule volume, with positive density meaning air and non-positive density meaning solid. It samples +only a stateless source evaluator — no manager pointer, live layout, operator stack, cache, or +measurement flood fill enters the query. + +The candidate is deliberately a feature core rather than arbitrary air: a room floor core, +requested XY in a slab, a horizontal Maze edge, a VerticalShafts shaft core with a deterministic +ledge-side offset above its lower seal, or a FloatingIslands top surface. The local stencil proves a legal pose, not that the pose belongs to a +large connected component; that question remains in the measurement pass. When no legal local pose +exists inside the bounded source neighbourhood, **the random reach and original XY are preserved +unchanged**. ### ⚠️ Coverage is source-dependent and the number is the point The failing pre-fix measurement was: @@ -1569,11 +1577,15 @@ The failing pre-fix measurement was: > *7 inter-strate passages, 3 checked, 0 footing checks, 38/48 room/slab ring samples air, 4 > query-false (4 unique archetypes; 1 unsupported, 3 supported-but-no-point).* -The Tier 1 query now searches laterally for `Maze`, `VerticalShafts`, and `FloatingIslands` landing -sites within one configured placement period. `SurfaceWorld` remains intentionally refused. +The Tier 1 query now searches laterally for `Maze`, `VerticalShafts`, and `FloatingIslands` feature +cores within one configured placement period, and for cave rooms within one `RoomSpacing`. +`SurfaceWorld` remains intentionally refused. The extended test uses vertical source footing brackets for those three instead of applying the -room/slab circumference ring to thin or void-dominated geometry. The post-fix measurement is deliberately -not claimed here because this change does not build or run the Unreal automation test. +room/slab circumference ring to thin or void-dominated geometry. The post-fix measurement is not +claimed as a connectivity proof: the focused fixture now reports 7 passages, 3 upper and 3 lower +player-fit queries answered, 8/14 mouths falling back, 0/0 room/slab ring samples air, and all 3 +answered thin-source footing checks valid. The eight-card step-1 measurement is recorded in §12.1; +it is the source of truth for the remaining component gaps. ### Seed contracts, do not mix them - **TunnelNetwork / Underwater** → `VoxelCaveMorphology::MakeStrateSeed` (the strate's own room seed). @@ -1593,7 +1605,9 @@ construction*. It passed whether the aiming worked or not. Replaced with a **16- destinations. A mouth in a real room has open space around it; a mouth in bedrock has only the tube it dug itself. Maze, VerticalShafts, and FloatingIslands use a manager-free source density bracket: air at the landing and solid below it. A large void ring is not evidence of island footing. -⚠️ **It is a proxy for connectivity, not a proof.** The proof is a flood fill — Tier 2. +⚠️ **It is a proxy for connectivity, not a proof.** The pure query itself is even narrower: it can +prove a local capsule pose but cannot prove a large component without reading generated layout +state. The proof is a flood fill — Tier 2. ### ⚠️ A forked placement envelope, guarded by that test `VF_FindNearestHashRoomLandingPoint` re-derives the room vertical placement envelope that `BuildChunkCache` also @@ -1603,9 +1617,57 @@ is the only thing standing between a placement change and silent bedrock passage --- -## 12. Tier 1 FINAL — and the 92.8% defect a green suite hid +## 12. Tier 1 checkpoint — and the 92.8% defect a green suite hid -**Built clean, suite green: 17 succeeded, 0 warnings, 0 failed (2026-08-30).** +**Historical checkpoint: built clean, suite green: 17 succeeded, 0 warnings, 0 failed +(2026-08-30).** The room-centre correction below remains useful history, but it did not prove a +player-sized landing or a large connected component. + +### 12.1 2026-09-06 player-fit landing pass + +The next correction keeps the pure/re-entrant boundary intact and replaces the query's open-air +claim with a local player-pose claim. Both the upper and lower mouths query their own source +independently. The query mirrors the measurement stencil (capsule volume, support patch, step +height, and walkable normal) against a stateless archetype source evaluator. It does not run a +flood fill, so it cannot honestly promise that the two mouths share a large fit component. + +The final eight-archetype before/after table below records the measured arrival/departure component +sizes and voxel gaps. A remaining gap is a generation-connectivity finding, not a reason to widen +the landing budget. + +| Archetype | Before: fit / components / largest / arrival / departure / gap / law | After: fit / components / largest / arrival / departure / gap / law | +|---|---|---| +| CrystalChamber | 655 / 197 / 24 / 1 / 1 / 47.529 / NotConnectedAtThisResolution | 1,360 / 62 / 717 / 270 / 717 / 19.131 / NotConnectedAtThisResolution | +| FlatPlain | 1,116 / 45 / 259 / 1 / 1 / 48.104 / NotConnectedAtThisResolution | 2,136 / 2 / 2,135 / 2,135 / 2,135 / 0.000 / Connected | +| FloatingIslands | 4 / 2 / 3 / 1 / 1 / 0.000 / Connected | 0 / 0 / 0 / 0 / 0 / -1.000 / StartCellNotPlayerFit | +| Maze | 2,801 / 309 / 176 / 43 / 81 / 206.630 / NotConnectedAtThisResolution | 5,759 / 82 / 1,217 / 30 / 1,217 / 121.598 / NotConnectedAtThisResolution | +| SurfaceWorld | 713 / 520 / 6 / 1 / 1 / 110.648 / NotConnectedAtThisResolution | 355 / 223 / 9 / 3 / 1 / 96.073 / NotConnectedAtThisResolution | +| TunnelNetwork | 2,748 / 467 / 1,292 / 2 / 1 / 164.405 / NotConnectedAtThisResolution | 100 / 1 / 100 / 100 / 100 / 0.000 / CoarseLiedBudgetExhausted | +| Underwater | 337 / 86 / 35 / 3 / 11 / 142.499 / NotConnectedAtThisResolution | 342 / 13 / 194 / 95 / 194 / 115.265 / NotConnectedAtThisResolution | +| VerticalShafts | 1,156 / 112 / 96 / 3 / 36 / 94.000 / NotConnectedAtThisResolution | 4,669 / 36 / 619 / 14 / 81 / 104.890 / NotConnectedAtThisResolution | + +The restricted law tally is **1/8 connected before and 1/8 after**; no after result is a connected +component of four cells or fewer. The FloatingIslands `StartCellNotPlayerFit` result is the live +world after the mouth carve: the pure source query found a top pose, but the structural passage +carve removes the support at that exact endpoint. That is a generation/carve integration issue, +not evidence that the query should accept open air. TunnelNetwork's single 100-cell component is +the coarse retry budget being exhausted, not a connected-law pass. + +The query-answer fixture still intentionally refuses `SurfaceWorld`. In this seed, the sparse room +and slab sources can also decline when no room/core passes the local stencil inside their lateral +budget; that preserves the original placement rather than inventing a point. The pure query has +no component-size oracle, so its honest large-space substitute is a guaranteed feature core: +room centre, slab floor at requested XY, horizontal Maze edge, shaft ledge-side pose, or island +top interior. A generation-side connection/room-mouth guarantee is the smallest alternative if +the measured law must become connected. + +The old VerticalShafts refinement assertion that every mouth sit on the exact shaft axis was too +strong for this task: an infinite open cylinder has no support surface at its axis. It is now an +executable **feature-core** invariant (with the drainage-tree path still audited from the resolved +axis), while the landing query proves the actual ledge-side capsule pose. The required UE 5.7 build +passed; the full `VoxelForge` namespace finished with **29 successes, 2 known warnings, 0 failures, +0 not-run** in 2,388.35 s. The warnings remain the gated lateral-region 9/16 law and the known +TunnelNetwork op-stack outside-gate sample count; box violations stayed zero. ### ⚠️ Correct the record: commit `1ab8c0c` overstated what worked It reported *"3 checked, 48/48 ring samples air"* and read as Tier 1 working. A million-seed sweep later diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp index 392d208..2c40122 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp @@ -54,10 +54,13 @@ namespace case ECaveGeneratorType::VerticalShafts: // A 0.6 shaft density does not guarantee an occupied immediate cell. The production // query therefore permits a bounded two-cell local search and still returns the - // selected site's exact axis. + // selected site's deterministic feature core/ledge-side pose. return FMath::Max(2.0f * Definition.VerticalShaftParams.ShaftSpacing, 1.0f); case ECaveGeneratorType::FloatingIslands: return FMath::Max(Definition.FloatingIslandParams.IslandSpacing, 1.0f); + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return FMath::Max(Definition.GenerationParams.RoomSpacing, 1.0f); default: return 0.0f; } @@ -85,6 +88,8 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) int32 NumInterStratePassages = 0; int32 NumChecked = 0; + int32 NumUpperChecked = 0; + int32 NumUpperQueryFalse = 0; int32 NumQueryFalse = 0; int32 NumUnsupported = 0; int32 NumSupportedWithoutPoint = 0; @@ -144,6 +149,74 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) continue; } + // The upper mouth has its own source query too. This is deliberately evaluated from the + // upper strate's definition, seed, bounds, and requested XY; it must never inherit the + // lower mouth's answer or become an implicit passage chain. + const float SourceTopZ = (float)(Source.TopChunkZ + 1) * CHUNK_SIZE; + const float SourceBottomZ = (float)Source.BottomChunkZ * CHUNK_SIZE; + const int32 SourceQuerySeed = OpenPointSeedFor( + *SourceDefinition, Source, WorldSeed); + const float SourceMaxLateralSnap = MaxLateralSnapFor(*SourceDefinition); + FVector SuggestedUpperPoint = FVector::ZeroVector; + const bool bCanAnswerUpper = VF_SuggestLandingPoint( + SourceDefinition->GeneratorType, + SourceDefinition->GenerationParams, + SourceDefinition->SlabParams, + SourceDefinition->MazeParams, + SourceDefinition->VerticalShaftParams, + SourceDefinition->FloatingIslandParams, + SourceQuerySeed, + SourceTopZ, + SourceBottomZ, + Passage.RequestedUpperPoint.X, + Passage.RequestedUpperPoint.Y, + SourceMaxLateralSnap, + SuggestedUpperPoint); + if (!bCanAnswerUpper) + { + ++NumUpperQueryFalse; + FalseArchetypes.Add(static_cast(SourceDefinition->GeneratorType)); + } + else + { + ++NumUpperChecked; + const float UpperSnapDX = Passage.UpperPoint.X + - Passage.RequestedUpperPoint.X; + const float UpperSnapDY = Passage.UpperPoint.Y + - Passage.RequestedUpperPoint.Y; + const float UpperSnapDistance = FMath::Sqrt( + UpperSnapDX * UpperSnapDX + UpperSnapDY * UpperSnapDY); + if (!FMath::IsNearlyEqual(Passage.UpperPoint.X, SuggestedUpperPoint.X, 0.01f) + || !FMath::IsNearlyEqual(Passage.UpperPoint.Y, SuggestedUpperPoint.Y, 0.01f) + || !FMath::IsNearlyEqual(Passage.UpperPoint.Z, SuggestedUpperPoint.Z, 0.01f)) + { + bAllAnswerableEndpointsMatchQuery = false; + AddError(FString::Printf( + TEXT("Passage from strate %d (%s) missed its upper player-fit landing point: endpoint (%.9g, %.9g, %.9g), query (%.9g, %.9g, %.9g)."), + Source.StrateIndex, + *ArchetypeName(SourceDefinition->GeneratorType), + Passage.UpperPoint.X, + Passage.UpperPoint.Y, + Passage.UpperPoint.Z, + SuggestedUpperPoint.X, + SuggestedUpperPoint.Y, + SuggestedUpperPoint.Z)); + } + if (!FMath::IsFinite(UpperSnapDistance) + || UpperSnapDistance > SourceMaxLateralSnap + 0.01f) + { + bAllAnswerableEndpointsMatchQuery = false; + AddError(FString::Printf( + TEXT("Passage from strate %d (%s) exceeded its upper lateral snap bound: %.6f > %.6f from requested (%.3f, %.3f)."), + Source.StrateIndex, + *ArchetypeName(SourceDefinition->GeneratorType), + UpperSnapDistance, + SourceMaxLateralSnap, + Passage.RequestedUpperPoint.X, + Passage.RequestedUpperPoint.Y)); + } + } + const FStratePassageConfig& PassageConfig = SourceDefinition->PassageConfig; if (!FMath::IsFinite(PassageConfig.MouthRadius) || PassageConfig.MouthRadius <= 0.0f) { @@ -377,8 +450,10 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) } AddInfo(FString::Printf( - TEXT("Passage open-space check: %d inter-strate passages, %d checked, %d footing checks, %d/%d room/slab ring samples air, %d query-false (%d unique archetypes; %d unsupported, %d supported-but-no-point)."), + TEXT("Passage player-fit check: %d inter-strate passages, upper %d checked (%d false), lower %d checked (%d footing checks), %d/%d room/slab ring samples air, lower %d query-false (%d unique archetypes; %d unsupported, %d supported-but-no-point)."), NumInterStratePassages, + NumUpperChecked, + NumUpperQueryFalse, NumChecked, NumFootingChecked, NumRingAirSamples, @@ -398,6 +473,10 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) { AddError(TEXT("VACUOUS: zero answerable passage mouths were checked; the passage invariant was not exercised.")); } + if (NumUpperChecked == 0) + { + AddError(TEXT("VACUOUS: zero answerable upper passage mouths were checked; independent upper-mouth aiming was not exercised.")); + } if (NumFootingChecked == 0) { AddError(TEXT("VACUOUS: no Maze/VerticalShafts/FloatingIslands footing was checked.")); @@ -461,6 +540,7 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) return NumInterStratePassages > 0 && NumChecked > 0 + && NumUpperChecked > 0 && NumFootingChecked > 0 && bAllNewFootingArchetypesAnswered // (fixture coverage of the room/slab archetypes is reported, not asserted — see above) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index e8dae96..6d1ff56 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -9,6 +9,7 @@ #include "VoxelForgeTestFixture.h" #include "VoxelCaveMorphology.h" #include "VoxelStrateMeasure.h" +#include "VoxelTypes.h" IMPLEMENT_SIMPLE_AUTOMATION_TEST( FVoxelForgeStrateConnectivityTest, @@ -327,6 +328,7 @@ namespace { float X = 0.0f; float Y = 0.0f; + float Radius = 0.0f; int32 CellX = 0; int32 CellY = 0; }; @@ -350,6 +352,10 @@ namespace + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0x12345678u)) * 0.7f) * Spacing; OutShaft.Y = (CellY + 0.15f + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0x9ABCDEF0u)) * 0.7f) * Spacing; + OutShaft.Radius = FMath::Lerp( + Params.ShaftMinRadius, + Params.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0xBEEFu))); OutShaft.CellX = CellX; OutShaft.CellY = CellY; return true; @@ -788,14 +794,27 @@ namespace return Out; } - bool IsVerticalShaftAxis( + bool FindVerticalShaftFeatureCore( const FVerticalShaftParams& Params, int32 Seed, - const FVector& Point) + const FVector& Point, + FVector& OutAxisPoint) { const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const float InteriorMargin = FMath::Max(Params.SurfaceRoughness, 0.0f) + * VOXEL_NOISE_SCALE + 0.25f; + if (!FMath::IsFinite(InteriorMargin) || InteriorMargin < 0.0f) + { + return false; + } + const int32 BaseCellX = FMath::FloorToInt(Point.X / Spacing); const int32 BaseCellY = FMath::FloorToInt(Point.Y / Spacing); + float BestDistanceSq = FLT_MAX; + int32 BestCellX = INT32_MAX; + int32 BestCellY = INT32_MAX; + FAuditShaft BestShaft; + bool bFound = false; for (int32 DY = -1; DY <= 1; ++DY) { for (int32 DX = -1; DX <= 1; ++DX) @@ -806,14 +825,50 @@ namespace { continue; } - if (FMath::IsNearlyEqual(Point.X, Shaft.X, 0.001f) - && FMath::IsNearlyEqual(Point.Y, Shaft.Y, 0.001f)) + const float SafeRadius = Shaft.Radius - InteriorMargin; + if (!FMath::IsFinite(SafeRadius) || SafeRadius <= 0.0f) { - return true; + continue; + } + + const float DistanceSq = FMath::Square(Point.X - Shaft.X) + + FMath::Square(Point.Y - Shaft.Y); + if (DistanceSq > FMath::Square(SafeRadius) + 1.0e-4f) + { + continue; + } + + const bool bCloser = DistanceSq < BestDistanceSq; + const bool bTie = DistanceSq == BestDistanceSq + && (Shaft.CellX < BestCellX + || (Shaft.CellX == BestCellX && Shaft.CellY < BestCellY)); + if (bCloser || bTie) + { + BestDistanceSq = DistanceSq; + BestCellX = Shaft.CellX; + BestCellY = Shaft.CellY; + BestShaft = Shaft; + bFound = true; } } } - return false; + + if (!bFound) + { + return false; + } + + OutAxisPoint = FVector(BestShaft.X, BestShaft.Y, Point.Z); + return true; + } + + bool IsVerticalShaftFeatureCore( + const FVerticalShaftParams& Params, + int32 Seed, + const FVector& Point) + { + FVector AxisPoint = FVector::ZeroVector; + return FindVerticalShaftFeatureCore(Params, Seed, Point, AxisPoint); } bool FindChainMouths( @@ -3344,18 +3399,20 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet const FVerticalShaftParams& ShaftParams = Layout[VerticalShaftsIndex].Definition->VerticalShaftParams; - if (!IsVerticalShaftAxis( - ShaftParams, Seed, ResolutionArrivalPoint) - || !IsVerticalShaftAxis(ShaftParams, Seed, ResolutionDeparturePoint)) + const bool bArrivalFeatureCore = IsVerticalShaftFeatureCore( + ShaftParams, Seed, ResolutionArrivalPoint); + const bool bDepartureFeatureCore = IsVerticalShaftFeatureCore( + ShaftParams, Seed, ResolutionDeparturePoint); + if (!bArrivalFeatureCore || !bDepartureFeatureCore) { AddError(FString::Printf( - TEXT("HARD FAILURE: fitted-window seed %d has a VerticalShafts mouth not on a " - "shaft axis (arrival axis=%s, departure axis=%s; arrival=(%.3f,%.3f,%.3f); " + TEXT("HARD FAILURE: fitted-window seed %d has a VerticalShafts mouth outside " + "the shaft feature core (arrival core=%s, departure core=%s; arrival=(%.3f,%.3f,%.3f); " "departure=(%.3f,%.3f,%.3f); spacing=%.3f; density=%.3f)."), Seed, - IsVerticalShaftAxis(ShaftParams, Seed, ResolutionArrivalPoint) + bArrivalFeatureCore ? TEXT("yes") : TEXT("no"), - IsVerticalShaftAxis(ShaftParams, Seed, ResolutionDeparturePoint) + bDepartureFeatureCore ? TEXT("yes") : TEXT("no"), ResolutionArrivalPoint.X, ResolutionArrivalPoint.Y, @@ -3515,7 +3572,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet "mean-radius shaft volume.\n"); Summary += TEXT( " seed | fitted verdict | cells | components | small non-largest/total | strict minima/shafts " - "| neighbour fallback | path orphans | axis mouths | doubled fitted-margin verdict/cells | " + "| neighbour fallback | path orphans | shaft feature-core mouths | doubled fitted-margin verdict/cells | " "tree path XY bounds\n"); static constexpr int32 FittedSeedCases[] = { @@ -3577,8 +3634,12 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet const FVerticalShaftParams RuntimeShaftParams = SeedWorld.StrateManager->GetVerticalShaftParamsForChunk( FIntVector(0, 0, ShaftMidChunkZ)); - const bool bAxisMouths = IsVerticalShaftAxis(ShaftParams, Seed, SeedArrivalPoint) - && IsVerticalShaftAxis(ShaftParams, Seed, SeedDeparturePoint); + FVector ArrivalShaftAxis = SeedArrivalPoint; + FVector DepartureShaftAxis = SeedDeparturePoint; + const bool bShaftFeatureMouths = FindVerticalShaftFeatureCore( + ShaftParams, Seed, SeedArrivalPoint, ArrivalShaftAxis) + && FindVerticalShaftFeatureCore( + ShaftParams, Seed, SeedDeparturePoint, DepartureShaftAxis); const FVoxelStrateMeasureSettings FittedSettings = MakeFittedSettings( 2, SeedArrivalPoint, @@ -3649,7 +3710,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet && Diagnostics.Result == EVoxelConnectivityResult::Connected && !Diagnostics.bStartSnapped && !Diagnostics.bGoalSnapped - && bAxisMouths; + && bShaftFeatureMouths; bool bCountsAsPass = bCountsAsDirectPass; FString DoubledSweepText = TEXT("not-run"); FString TreePathText = TEXT("not-needed"); @@ -3682,14 +3743,14 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet FVector2D PathMax = FVector2D::ZeroVector; int32 PathHops = 0; const bool bArrivalPath = AuditVerticalShaftPathBounds( - ShaftParams, Seed, SeedArrivalPoint, PathMin, PathMax, PathHops); + ShaftParams, Seed, ArrivalShaftAxis, PathMin, PathMax, PathHops); FVector2D DeparturePathMin = FVector2D::ZeroVector; FVector2D DeparturePathMax = FVector2D::ZeroVector; int32 DeparturePathHops = 0; const bool bDeparturePath = AuditVerticalShaftPathBounds( ShaftParams, Seed, - SeedDeparturePoint, + DepartureShaftAxis, DeparturePathMin, DeparturePathMax, DeparturePathHops); @@ -3778,7 +3839,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet Audit.NumShafts, Audit.NumNeighbourFallbacks, Audit.NumUnreachable, - bAxisMouths ? TEXT("YES") : TEXT("NO"), + bShaftFeatureMouths ? TEXT("YES") : TEXT("NO"), *DoubledSweepText, *TreePathText); @@ -3786,10 +3847,10 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet { const FVerticalShaftPhysicalAudit ArrivalPhysical = AuditVerticalShaftPathDensity( - *SeedWorld.Generator, RuntimeShaftParams, Seed, SeedArrivalPoint); + *SeedWorld.Generator, RuntimeShaftParams, Seed, ArrivalShaftAxis); const FVerticalShaftPhysicalAudit DeparturePhysical = AuditVerticalShaftPathDensity( - *SeedWorld.Generator, RuntimeShaftParams, Seed, SeedDeparturePoint); + *SeedWorld.Generator, RuntimeShaftParams, Seed, DepartureShaftAxis); const FVerticalShaftPhysicalAudit& FirstBadPhysical = ArrivalPhysical.NumNonAirSamples > 0 ? ArrivalPhysical : DeparturePhysical; Summary += FString::Printf( @@ -3827,15 +3888,15 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet FirstBadPhysical.FirstBadFraction); } - if (Audit.NumUnreachable != 0 || !Audit.bOrderIndependent || !bAxisMouths) + if (Audit.NumUnreachable != 0 || !Audit.bOrderIndependent || !bShaftFeatureMouths) { AddError(FString::Printf( TEXT("HARD FAILURE: fitted audit seed %d violated the shaft-tree/landing " - "invariant (unreachable=%d, order-independent=%s, axis-mouths=%s)."), + "invariant (unreachable=%d, order-independent=%s, feature-core-mouths=%s)."), Seed, Audit.NumUnreachable, Audit.bOrderIndependent ? TEXT("yes") : TEXT("no"), - bAxisMouths ? TEXT("yes") : TEXT("no"))); + bShaftFeatureMouths ? TEXT("yes") : TEXT("no"))); bAllChecksPassed = false; } } diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index f1cc5f9..940638b 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -28,6 +28,8 @@ // this chunk are kept for the per-voxel loop, so hot-path cost stays low. #include "VoxelCaveMorphology.h" +#include "VoxelDensityPrimitives.h" +#include "VoxelStrateMeasure.h" #include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 #include "VoxelStrateTypes.h" #include "VoxelNoise.h" // Pure FBM used by the slab landing query @@ -90,9 +92,776 @@ static void BakeRoomFeature( namespace { - // Find the nearest hash room's vertical centre without constructing a cache. The search is + // Runtime landing queries use the same pinned capsule dimensions and support rules as the + // player-fit measurement, but they cannot call the editor-only flood fill. Keep this local + // evaluator deliberately source-only: the callback is pure density math supplied by the + // archetype query, never a generator, manager, cache, or layout lookup. + struct FVFPlayerCapsuleStencilRow + { + int32 RelativeZ = 0; + TArray HorizontalOffsets; + }; + + bool VF_IsPointInsidePlayerCapsule( + float RelativeZ, + int32 OffsetX, + int32 OffsetY, + float Radius, + float HalfHeight) + { + const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius); + const float DistanceToAxis = FMath::Max( + FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f); + const float DistanceSquared = static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + + DistanceToAxis * DistanceToAxis; + return DistanceSquared <= Radius * Radius + KINDA_SMALL_NUMBER; + } + + bool VF_BuildPlayerFitStencil( + const FVoxelStrateMeasureSettings& Settings, + TArray& OutRows, + TArray& OutSupportOffsets, + int32& OutCentreSupportIndex, + int32& OutRequiredSupportCount) + { + OutRows.Reset(); + OutSupportOffsets.Reset(); + OutCentreSupportIndex = INDEX_NONE; + OutRequiredSupportCount = 0; + + if (!FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels) + || Settings.PlayerCapsuleRadiusVoxels <= 0.0f + || !FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels) + || Settings.PlayerCapsuleHalfHeightVoxels <= 0.0f + || !FMath::IsFinite(Settings.PlayerMaxStepHeightMeters) + || Settings.PlayerMaxStepHeightMeters < 0.0f + || !FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees) + || Settings.PlayerWalkableFloorAngleDegrees < 0.0f + || Settings.PlayerWalkableFloorAngleDegrees > 90.0f + || !FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction) + || Settings.PlayerSupportPatchMinCoverageFraction <= 0.0f + || Settings.PlayerSupportPatchMinCoverageFraction > 1.0f) + { + return false; + } + + const int32 HeightCells = FMath::CeilToInt( + 2.0f * Settings.PlayerCapsuleHalfHeightVoxels); + const int32 MaxHorizontalOffset = FMath::CeilToInt( + Settings.PlayerCapsuleRadiusVoxels); + if (HeightCells <= 0 || HeightCells > 4096 + || MaxHorizontalOffset < 0 || MaxHorizontalOffset > 4096) + { + return false; + } + + int64 NumStencilOffsets = 0; + for (int32 RelativeZ = 0; RelativeZ < HeightCells; ++RelativeZ) + { + FVFPlayerCapsuleStencilRow& Row = OutRows.AddDefaulted_GetRef(); + Row.RelativeZ = RelativeZ; + const float CapsuleRelativeZ = static_cast(RelativeZ) + 0.5f + - Settings.PlayerCapsuleHalfHeightVoxels; + for (int32 OffsetY = -MaxHorizontalOffset; + OffsetY <= MaxHorizontalOffset; + ++OffsetY) + { + for (int32 OffsetX = -MaxHorizontalOffset; + OffsetX <= MaxHorizontalOffset; + ++OffsetX) + { + if (!VF_IsPointInsidePlayerCapsule( + CapsuleRelativeZ, OffsetX, OffsetY, + Settings.PlayerCapsuleRadiusVoxels, + Settings.PlayerCapsuleHalfHeightVoxels)) + { + continue; + } + if (NumStencilOffsets >= (1ll << 20)) + { + return false; + } + Row.HorizontalOffsets.Add(FIntPoint(OffsetX, OffsetY)); + ++NumStencilOffsets; + } + } + + // Preserve the top partial row exactly as the measurement does. A row with no + // rounded-cap offsets still needs a centre sample for full-height clearance. + if (Row.HorizontalOffsets.IsEmpty()) + { + Row.HorizontalOffsets.Add(FIntPoint::ZeroValue); + } + } + + const int32 MaxSupportOffset = FMath::CeilToInt( + Settings.PlayerCapsuleRadiusVoxels); + for (int32 OffsetY = -MaxSupportOffset; + OffsetY <= MaxSupportOffset; + ++OffsetY) + { + for (int32 OffsetX = -MaxSupportOffset; + OffsetX <= MaxSupportOffset; + ++OffsetX) + { + if (static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + > FMath::Square(Settings.PlayerCapsuleRadiusVoxels) + + KINDA_SMALL_NUMBER) + { + continue; + } + if (OffsetX == 0 && OffsetY == 0) + { + OutCentreSupportIndex = OutSupportOffsets.Num(); + } + OutSupportOffsets.Add(FIntPoint(OffsetX, OffsetY)); + } + } + + if (OutRows.IsEmpty() || OutSupportOffsets.IsEmpty() + || OutCentreSupportIndex == INDEX_NONE) + { + return false; + } + OutRequiredSupportCount = FMath::Clamp( + FMath::CeilToInt( + Settings.PlayerSupportPatchMinCoverageFraction + * static_cast(OutSupportOffsets.Num())), + 1, + OutSupportOffsets.Num()); + return true; + } + + using FVFPlayerDensitySampler = TFunctionRef; + + bool VF_GetPlayerFitInteriorBounds( + const FVoxelStrateMeasureSettings& Settings, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + float& OutInnerTop, + float& OutInnerBottom) + { + if (!FMath::IsFinite(StrateTopZ) + || !FMath::IsFinite(StrateBottomZ) + || !FMath::IsFinite(BoundarySealThickness) + || BoundarySealThickness < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + int32 InteriorMarginVoxels = Settings.InteriorMarginVoxels; + if (InteriorMarginVoxels < 0) + { + const double DerivedMargin = 2.0 + * static_cast(BoundarySealThickness); + if (!FMath::IsFinite(DerivedMargin) + || DerivedMargin > static_cast(INT32_MAX)) + { + return false; + } + InteriorMarginVoxels = FMath::CeilToInt(static_cast(DerivedMargin)); + } + if (InteriorMarginVoxels < 0 || InteriorMarginVoxels > 4096) + { + return false; + } + + OutInnerBottom = StrateBottomZ + static_cast(InteriorMarginVoxels); + OutInnerTop = StrateTopZ - static_cast(InteriorMarginVoxels); + return FMath::IsFinite(OutInnerTop) + && FMath::IsFinite(OutInnerBottom) + && OutInnerTop > OutInnerBottom; + } + + bool VF_ValidatePlayerFitPose( + const FVoxelStrateMeasureSettings& Settings, + const FVector& CandidateFeetPoint, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + FVFPlayerDensitySampler SampleDensity, + FVector& OutPoint) + { + OutPoint = FVector::ZeroVector; + if (!FMath::IsFinite(CandidateFeetPoint.X) + || !FMath::IsFinite(CandidateFeetPoint.Y) + || !FMath::IsFinite(CandidateFeetPoint.Z) + || !FMath::IsFinite(StrateTopZ) + || !FMath::IsFinite(StrateBottomZ) + || !FMath::IsFinite(BoundarySealThickness) + || BoundarySealThickness < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + TArray StencilRows; + TArray SupportOffsets; + int32 CentreSupportIndex = INDEX_NONE; + int32 RequiredSupportCount = 0; + if (!VF_BuildPlayerFitStencil( + Settings, StencilRows, SupportOffsets, + CentreSupportIndex, RequiredSupportCount)) + { + return false; + } + + const double MaxStepHeightVoxelsReal = + static_cast(Settings.PlayerMaxStepHeightMeters) + / static_cast(FVoxelPlayerCapsuleConstants::VoxelSizeMeters); + if (!FMath::IsFinite(MaxStepHeightVoxelsReal) + || MaxStepHeightVoxelsReal < 0.0 + || MaxStepHeightVoxelsReal > 4096.0) + { + return false; + } + const int32 MaxDownwardSearchCells = FMath::CeilToInt( + static_cast(MaxStepHeightVoxelsReal)); + const float MaxStepHeightVoxels = static_cast(MaxStepHeightVoxelsReal); + const float MinimumWalkableNormalZ = FMath::Cos(FMath::DegreesToRadians( + Settings.PlayerWalkableFloorAngleDegrees)); + + TArray SupportHeights; + SupportHeights.SetNumUninitialized(SupportOffsets.Num()); + TArray bSupported; + bSupported.Init(0u, SupportOffsets.Num()); + int32 NumSupported = 0; + for (int32 SupportIndex = 0; SupportIndex < SupportOffsets.Num(); ++SupportIndex) + { + const FIntPoint& Offset = SupportOffsets[SupportIndex]; + for (int32 DownwardCell = 0; + DownwardCell <= MaxDownwardSearchCells; + ++DownwardCell) + { + const float UpperZ = CandidateFeetPoint.Z + - static_cast(DownwardCell); + const float LowerZ = UpperZ - 1.0f; + const float LowerDensity = SampleDensity( + CandidateFeetPoint.X + static_cast(Offset.X), + CandidateFeetPoint.Y + static_cast(Offset.Y), + LowerZ); + const float UpperDensity = SampleDensity( + CandidateFeetPoint.X + static_cast(Offset.X), + CandidateFeetPoint.Y + static_cast(Offset.Y), + UpperZ); + if (!FMath::IsFinite(LowerDensity) + || !FMath::IsFinite(UpperDensity) + || !(LowerDensity <= 0.0f) + || !(UpperDensity > 0.0f)) + { + continue; + } + + const float Denominator = UpperDensity - LowerDensity; + if (!FMath::IsFinite(Denominator) || Denominator <= 0.0f) + { + continue; + } + const float Fraction = FMath::Clamp( + -LowerDensity / Denominator, 0.0f, 1.0f); + const float SurfaceHeight = LowerZ + Fraction; + const float CandidateBottom = CandidateFeetPoint.Z - 0.5f; + if (!FMath::IsFinite(SurfaceHeight) + || SurfaceHeight > CandidateBottom + KINDA_SMALL_NUMBER + || SurfaceHeight < CandidateBottom - MaxStepHeightVoxels + - KINDA_SMALL_NUMBER) + { + continue; + } + + bSupported[SupportIndex] = 1u; + SupportHeights[SupportIndex] = SurfaceHeight; + ++NumSupported; + break; + } + } + + if (NumSupported < RequiredSupportCount + || bSupported[CentreSupportIndex] == 0u) + { + return false; + } + + float SupportHeight = -FLT_MAX; + int32 SupportCount = 0; + for (int32 Index = 0; Index < SupportOffsets.Num(); ++Index) + { + if (bSupported[Index] == 0u) continue; + ++SupportCount; + SupportHeight = FMath::Max(SupportHeight, SupportHeights[Index]); + } + if (SupportCount < RequiredSupportCount || !FMath::IsFinite(SupportHeight)) + { + return false; + } + + float MaximumGradient = 0.0f; + for (int32 First = 0; First < SupportOffsets.Num(); ++First) + { + if (bSupported[First] == 0u) continue; + for (int32 Second = First + 1; Second < SupportOffsets.Num(); ++Second) + { + if (bSupported[Second] == 0u) continue; + const float DX = static_cast( + SupportOffsets[Second].X - SupportOffsets[First].X); + const float DY = static_cast( + SupportOffsets[Second].Y - SupportOffsets[First].Y); + const float HorizontalDistance = FMath::Sqrt(DX * DX + DY * DY); + if (HorizontalDistance <= KINDA_SMALL_NUMBER) continue; + MaximumGradient = FMath::Max( + MaximumGradient, + FMath::Abs(SupportHeights[Second] - SupportHeights[First]) + / HorizontalDistance); + } + } + const float SupportNormalZ = 1.0f / FMath::Sqrt( + 1.0f + MaximumGradient * MaximumGradient); + if (!FMath::IsFinite(SupportNormalZ) + || SupportNormalZ + KINDA_SMALL_NUMBER < MinimumWalkableNormalZ) + { + return false; + } + + // Re-evaluate every occupied stencil sample at the chosen support, so an overhang cannot + // masquerade as a floor. The returned point is the free centre immediately above that + // resolved support, which is the stable standing/footing point used by passage mouths. + for (const FVFPlayerCapsuleStencilRow& Row : StencilRows) + { + const float SampleZ = SupportHeight + + static_cast(Row.RelativeZ) + 0.5f; + for (const FIntPoint& Offset : Row.HorizontalOffsets) + { + const float Density = SampleDensity( + CandidateFeetPoint.X + static_cast(Offset.X), + CandidateFeetPoint.Y + static_cast(Offset.Y), + SampleZ); + if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + { + return false; + } + } + } + + float InnerTop = 0.0f; + float InnerBottom = 0.0f; + if (!VF_GetPlayerFitInteriorBounds( + Settings, StrateTopZ, StrateBottomZ, BoundarySealThickness, + InnerTop, InnerBottom)) + { + return false; + } + const float OutputZ = SupportHeight + 0.5f; + const float OccupiedTop = SupportHeight + + 2.0f * Settings.PlayerCapsuleHalfHeightVoxels; + if (!FMath::IsFinite(InnerBottom) || !FMath::IsFinite(InnerTop) + || !FMath::IsFinite(OutputZ) || !FMath::IsFinite(OccupiedTop) + || OutputZ <= InnerBottom + || OccupiedTop >= InnerTop) + { + return false; + } + + OutPoint = FVector(CandidateFeetPoint.X, CandidateFeetPoint.Y, OutputZ); + return !OutPoint.ContainsNaN() + && FMath::IsFinite(OutPoint.X) + && FMath::IsFinite(OutPoint.Y) + && FMath::IsFinite(OutPoint.Z); + } + + struct FVFRoomLandingSite + { + FVector Center = FVector::ZeroVector; + float RadiusXY = 0.0f; + float RadiusZ = 0.0f; + uint32 Hash = 0; + bool bOrigin = false; + }; + + float VF_RoomFloorZ( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params) + { + if (Params.RoomFloorCutMin >= 1.0f && Params.RoomFloorCutMax >= 1.0f) + { + return Site.Center.Z - Site.RadiusZ; + } + const float Roll = VoxelHash::ToFloat01(VoxelHash::Mix(Site.Hash ^ 0xF100F2u)); + const float FloorCut = FMath::Lerp( + FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + Roll); + return Site.Center.Z - Site.RadiusZ * FloorCut; + } + + float VF_EvaluateRoomLandingDensity( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params, + uint32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float WorldZ) + { + float EffectiveZ = WorldZ; + if (Params.VerticalScale > 0.0f && Params.VerticalScale != 1.0f) + { + EffectiveZ = WorldZ / Params.VerticalScale; + } + + FVector Position(WorldX, WorldY, EffectiveZ); + if (Params.CaveWarpStrength > 0.0f) + { + const float Frequency = Params.CaveWarpFrequency; + const float Strength = Params.CaveWarpStrength; + Position.X += VoxelNoise::Perlin3D( + WorldX * Frequency + VoxelHash::SeedOffset(Seed, 0.37f), + WorldY * Frequency + 1.3f, + EffectiveZ * Frequency + 5.7f) + * VOXEL_NOISE_SCALE * Strength; + Position.Y += VoxelNoise::Perlin3D( + WorldX * Frequency + 7.1f, + WorldY * Frequency + VoxelHash::SeedOffset(Seed, 0.59f), + EffectiveZ * Frequency + 2.3f) + * VOXEL_NOISE_SCALE * Strength; + Position.Z += VoxelNoise::Perlin3D( + WorldX * Frequency + 11.3f, + WorldY * Frequency + 9.7f, + EffectiveZ * Frequency + VoxelHash::SeedOffset(Seed, 0.41f)) + * VOXEL_NOISE_SCALE * Strength; + } + + const uint32 ShapeHash = VoxelHash::Mix(Site.Hash ^ 0xDEADBEEFu); + const float ShapeRoll = Site.bOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); + const float BoxThreshold = 1.0f - Params.RoomShapeVariety * 0.5f; + const float CapsuleThreshold = 1.0f - Params.RoomShapeVariety * 0.2f; + float RoomSDF = 0.0f; + if (ShapeRoll >= BoxThreshold && ShapeRoll < CapsuleThreshold) + { + RoomSDF = VoxelSDF::RoundedBox( + Position, + Site.Center, + FVector(Site.RadiusXY * 0.8f, Site.RadiusXY * 0.8f, Site.RadiusZ * 0.8f), + Site.RadiusXY * 0.25f); + } + else if (ShapeRoll >= CapsuleThreshold) + { + const float DirectionAngle = VoxelHash::ToFloat01( + VoxelHash::Mix(Site.Hash ^ 0xCAFEBABEu)) * 2.0f * PI; + const float StretchDistance = Site.RadiusXY * 0.7f; + const FVector Direction(FMath::Cos(DirectionAngle), FMath::Sin(DirectionAngle), 0.0f); + RoomSDF = VoxelSDF::Capsule( + Position, + Site.Center + Direction * StretchDistance, + Site.Center - Direction * StretchDistance, + FMath::Min(Site.RadiusXY * 0.6f, Site.RadiusZ)); + } + else + { + RoomSDF = VoxelSDF::Ellipsoid( + Position, Site.Center, + FVector(Site.RadiusXY, Site.RadiusXY, Site.RadiusZ)); + } + + const float FloorCut = FMath::Lerp( + FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + VoxelHash::ToFloat01(VoxelHash::Mix(Site.Hash ^ 0xF100F2u))); + if (FloorCut < 1.0f) + { + float FloorZ = Site.Center.Z - Site.RadiusZ * FloorCut; + if (Params.FloorReliefStrength > 0.0f) + { + const float Frequency = Params.FloorReliefFrequency; + const float FloorSeed = static_cast( + VoxelHash::Mix(Site.Hash ^ 0xF100F1u)) * 0.00001f; + float Noise = FMath::PerlinNoise2D(FVector2D( + Position.X * Frequency + FloorSeed, + Position.Y * Frequency + FloorSeed * 1.7f)) * 0.65f; + Noise += FMath::PerlinNoise2D(FVector2D( + Position.X * Frequency * 2.3f + FloorSeed * 3.1f, + Position.Y * Frequency * 2.3f + FloorSeed * 5.3f)) * 0.35f; + FloorZ += Noise * VOXEL_NOISE_SCALE * Params.FloorReliefStrength; + } + RoomSDF = VoxelSDF::SmoothMax( + RoomSDF, FloorZ - Position.Z, Params.SDFBlendRadius * 0.35f); + } + + float InternalDensity = RoomSDF < 0.0f ? -1.0f : 1.0f; + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, StrateTopZ, StrateBottomZ, + Params.BoundarySealThickness, 1.0f); + // Query-facing density uses the same MC polarity as the measurement: positive is air. + return -InternalDensity; + } + + float VF_EvaluateSlabLandingDensity( + const FSlabGenerationParams& Params, + uint32 Seed, + float WorldX, + float WorldY, + float WorldZ) + { + const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + if (!(StrateHeight > 0.0f)) return -1.0f; + + const uint32 SeedU = Seed; + const float FloorZ = Params.StrateBottomWorldZ + + StrateHeight * Params.FloorRelativeHeight; + float FloorNoise = 0.0f; + if (Params.FloorRoughness > 0.0f) + { + FloorNoise = VoxelNoise::FBM( + WorldX * Params.FloorRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * Params.FloorRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 11.1f), + 0.0f, 3) * VOXEL_NOISE_SCALE * Params.FloorRoughness; + } + const float FloorSurface = FloorZ + FloorNoise; + + const float CeilZ = Params.StrateBottomWorldZ + + StrateHeight * Params.CeilingRelativeHeight; + float CeilNoise = 0.0f; + if (Params.CeilingRoughness > 0.0f) + { + const float RawNoise = VoxelNoise::FBM( + WorldX * Params.CeilingRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + WorldY * Params.CeilingRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, + 3000.0f, 3) * VOXEL_NOISE_SCALE; + CeilNoise = FMath::Abs(RawNoise) * Params.CeilingRoughness; + } + const float CeilSurface = FMath::Max( + CeilZ - CeilNoise, FloorSurface + 2.0f); + float InternalDensity = -FMath::Min( + WorldZ - FloorSurface, CeilSurface - WorldZ); + + if (Params.ColumnDensity > 0.0f && Params.ColumnSpacing > 0.0f) + { + float ColumnSDF = FLT_MAX; + const float Spacing = Params.ColumnSpacing; + const int32 CellX = FMath::FloorToInt(WorldX / Spacing); + const int32 CellY = FMath::FloorToInt(WorldY / Spacing); + for (int32 DY = -1; DY <= 1; ++DY) + { + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 ColumnX = CellX + DX; + const int32 ColumnY = CellY + DY; + const uint32 H = VoxelHash::Cell( + ColumnX, ColumnY, SeedU ^ 0xC01C01u); + if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue; + const float JX = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + const float CentreX = (ColumnX + 0.15f + JX * 0.7f) * Spacing; + const float CentreY = (ColumnY + 0.15f + JY * 0.7f) * Spacing; + const float Radius = FMath::Lerp( + Params.ColumnMinRadius, Params.ColumnMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + ColumnSDF = FMath::Min( + ColumnSDF, + FMath::Sqrt(FMath::Square(WorldX - CentreX) + + FMath::Square(WorldY - CentreY)) - Radius); + } + } + if (ColumnSDF < 2.0f && ColumnSDF < FLT_MAX) + { + float Fill = FMath::Clamp( + (2.0f - ColumnSDF) / 4.0f, 0.0f, 1.0f); + InternalDensity += SmoothStep01(Fill) + * Params.BaseDensity * 1.5f; + } + } + + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, Params.StrateTopWorldZ, + Params.StrateBottomWorldZ, Params.BoundarySealThickness, + Params.BaseDensity); + return -InternalDensity; + } + + float VF_EvaluateMazeLandingDensity( + const FMazeGenerationParams& Params, + uint32 Seed, + float WorldX, + float WorldY, + float WorldZ) + { + const float CellSize = FMath::Max(Params.CellSize, 1.0f); + const FVector Position(WorldX, WorldY, WorldZ); + const uint32 SeedU = Seed ^ 0x4D617A65u; + const int32 CellX = FMath::FloorToInt(WorldX / CellSize); + const int32 CellY = FMath::FloorToInt(WorldY / CellSize); + const int32 CellZ = FMath::FloorToInt(WorldZ / CellSize); + auto NodeCenter = [CellSize](int32 X, int32 Y, int32 Z) + { + return FVector( + (X + 0.5f) * CellSize, + (Y + 0.5f) * CellSize, + (Z + 0.5f) * CellSize); + }; + auto EdgeOpen = [SeedU](int32 X, int32 Y, int32 Z, + uint32 AxisSalt, float Threshold) + { + uint32 H = VoxelHash::Cell(X, Y, SeedU ^ AxisSalt); + H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); + return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; + }; + + const float Radius = FMath::Max(Params.CorridorRadius, 0.5f); + float MazeSDF = FLT_MAX; + for (int32 DZ = -1; DZ <= 0; ++DZ) + { + for (int32 DY = -1; DY <= 0; ++DY) + { + for (int32 DX = -1; DX <= 0; ++DX) + { + const int32 X = CellX + DX; + const int32 Y = CellY + DY; + const int32 Z = CellZ + DZ; + const FVector A = NodeCenter(X, Y, Z); + if (EdgeOpen(X, Y, Z, 0xA1u, Params.BranchProbability)) + { + MazeSDF = FMath::Min( + MazeSDF, + VoxelSDF::Capsule( + Position, A, NodeCenter(X + 1, Y, Z), Radius)); + } + if (EdgeOpen(X, Y, Z, 0xB2u, Params.BranchProbability)) + { + MazeSDF = FMath::Min( + MazeSDF, + VoxelSDF::Capsule( + Position, A, NodeCenter(X, Y + 1, Z), Radius)); + } + if (EdgeOpen(X, Y, Z, 0xC3u, Params.Verticality)) + { + MazeSDF = FMath::Min( + MazeSDF, + VoxelSDF::Capsule( + Position, A, NodeCenter(X, Y, Z + 1), Radius)); + } + } + } + } + + if (Params.SurfaceRoughness > 0.0f + && MazeSDF < Radius + Params.SurfaceRoughness + 2.0f) + { + MazeSDF += VoxelNoise::FBM( + WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f, 3) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + + float InternalDensity = Params.BaseDensity; + if (MazeSDF < 2.0f) + { + float Carve = FMath::Clamp( + (2.0f - MazeSDF) / 4.0f, 0.0f, 1.0f); + InternalDensity -= SmoothStep01(Carve) + * Params.BaseDensity * 2.0f; + } + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, Params.StrateTopWorldZ, + Params.StrateBottomWorldZ, Params.BoundarySealThickness, + Params.BaseDensity); + return -InternalDensity; + } + + float VF_EvaluateShaftLandingDensity( + const FVerticalShaftParams& Params, + uint32 Seed, + float WorldX, + float WorldY, + float WorldZ) + { + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const int32 BaseCellX = FMath::FloorToInt(WorldX / Spacing); + const int32 BaseCellY = FMath::FloorToInt(WorldY / Spacing); + const uint32 SeedU = Seed ^ 0x53686674u; + float CaveSDF = FLT_MAX; + FVector NearestCentre = FVector::ZeroVector; + float NearestDistanceSq = FLT_MAX; + float NearestRadius = 0.0f; + for (int32 DY = -1; DY <= 1; ++DY) + { + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 CellX = BaseCellX + DX; + const int32 CellY = BaseCellY + DY; + const uint32 H = VoxelHash::Cell(CellX, CellY, SeedU); + if (VoxelHash::ToFloat01(H) > Params.ShaftDensity) continue; + const float JX = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + const float CentreX = (CellX + 0.15f + JX * 0.7f) * Spacing; + const float CentreY = (CellY + 0.15f + JY * 0.7f) * Spacing; + const float Radius = FMath::Lerp( + Params.ShaftMinRadius, Params.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + const float DistanceSq = FMath::Square(WorldX - CentreX) + + FMath::Square(WorldY - CentreY); + if (DistanceSq < NearestDistanceSq) + { + NearestDistanceSq = DistanceSq; + NearestCentre = FVector(CentreX, CentreY, 0.0f); + NearestRadius = Radius; + } + CaveSDF = FMath::Min( + CaveSDF, + FMath::Sqrt(DistanceSq) - Radius); + } + } + + float InternalDensity = Params.BaseDensity; + if (Params.SurfaceRoughness > 0.0f + && CaveSDF < Params.SurfaceRoughness + 4.0f) + { + CaveSDF += VoxelNoise::FBM( + WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f, 3) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + if (CaveSDF < 2.0f) + { + float Carve = FMath::Clamp( + (2.0f - CaveSDF) / 4.0f, 0.0f, 1.0f); + InternalDensity -= SmoothStep01(Carve) + * Params.BaseDensity * 2.0f; + } + + if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f + && CaveSDF < 0.0f && NearestDistanceSq < FLT_MAX) + { + const float Phase = FMath::Frac( + (WorldZ - Params.StrateBottomWorldZ) / Params.LedgeSpacing); + const float BandT = FMath::Min(Phase, 1.0f - Phase) + * Params.LedgeSpacing; + if (BandT < Params.LedgeDepth + && (WorldX - NearestCentre.X) + (WorldY - NearestCentre.Y) > 1.0e-3f) + { + const float Shelf = 1.0f - SmoothStep01( + BandT / Params.LedgeDepth); + InternalDensity = FMath::Max( + InternalDensity, Shelf * Params.BaseDensity); + } + } + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, Params.StrateTopWorldZ, + Params.StrateBottomWorldZ, Params.BoundarySealThickness, + Params.BaseDensity); + return -InternalDensity; + } + + // Find the nearest hash room's feature core without constructing a cache. The search is // deliberately bounded: an empty neighbourhood is an honest "no answer", not a guessed point. - // Rooms are dense enough that the requested XY remains the landing XY; only Z is selected. + // The returned XY is the selected room centre; only a room core is a trustworthy sparse target. // Cherche le centre vertical de la salle hachée la plus proche sans construire de cache. La // recherche est bornée : un voisinage vide signifie "pas de réponse", jamais un point inventé. bool VF_FindNearestHashRoomLandingPoint( @@ -161,6 +930,9 @@ namespace float BestZ = 0.0f; float BestRoomX = 0.0f; float BestRoomY = 0.0f; + float BestRadiusXY = 0.0f; + float BestRadiusZ = 0.0f; + uint32 BestHash = 0u; int32 BestCellX = 0; int32 BestCellY = 0; bool bFound = false; @@ -191,6 +963,15 @@ namespace BestZ = RoomZ; BestRoomX = RoomX; BestRoomY = RoomY; + BestRadiusXY = RadiusEnvelope; + const float SizeFactor = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + BestRadiusXY = FMath::Lerp( + FMath::Min(Params.MinRoomRadius, Params.MaxRoomRadius), + FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius), + SizeFactor); + BestRadiusZ = BestRadiusXY * Params.RoomHeightRatio; + BestHash = CellHash; BestCellX = CellX; BestCellY = CellY; bFound = true; @@ -279,9 +1060,33 @@ namespace return false; } - OutPoint = FVector(BestRoomX, BestRoomY, BestZ); - return OutPoint.ContainsNaN() == false && FMath::IsFinite(OutPoint.X) - && FMath::IsFinite(OutPoint.Y) && FMath::IsFinite(OutPoint.Z); + const FVFRoomLandingSite Site{ + FVector(BestRoomX, BestRoomY, BestZ), + BestRadiusXY, + BestRadiusZ, + BestHash, + false }; + const float FloorZ = VF_RoomFloorZ(Site, Params); + FVoxelStrateMeasureSettings FitSettings; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const FVector CandidateFeet( + Site.Center.X, Site.Center.Y, FloorZ + MaxStepHeightVoxels + 0.5f); + const auto SampleRoom = [&](float X, float Y, float Z) + { + return VF_EvaluateRoomLandingDensity( + Site, Params, static_cast(Seed), + StrateTopZ, StrateBottomZ, X, Y, Z); + }; + return VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleRoom, + OutPoint); } // The slab source is an XY height band. Evaluate the same two pure height fields as @@ -397,11 +1202,31 @@ namespace } } - // Slab void is a height band at every usable XY. Keep the requested XY exactly as before; - // its caller supplies zero lateral snap so this dense source cannot wander from the spine. - OutPoint = FVector(WorldX, WorldY, (OpenBottom + OpenTop) * 0.5f); - return !OutPoint.ContainsNaN() && FMath::IsFinite(OutPoint.X) - && FMath::IsFinite(OutPoint.Y) && FMath::IsFinite(OutPoint.Z); + // The old query returned the centre of the void band. That is open air, but it is not a + // player pose: the body must stand on the floor. Re-run the pure slab source through the + // same support/capsule stencil used by the measurement and return the resolved feet row. + FSlabGenerationParams SourceParams = Params; + SourceParams.StrateTopWorldZ = StrateTopZ; + SourceParams.StrateBottomWorldZ = StrateBottomZ; + FVoxelStrateMeasureSettings FitSettings; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const FVector CandidateFeet( + WorldX, WorldY, OpenBottom + MaxStepHeightVoxels + 0.5f); + const auto SampleSlab = [&](float X, float Y, float Z) + { + return VF_EvaluateSlabLandingDensity( + SourceParams, static_cast(Seed), X, Y, Z); + }; + return VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + SourceParams.BoundarySealThickness, + SampleSlab, + OutPoint); } // Maze corridors are thin 3D lattice edges. Only a horizontal edge is a useful landing @@ -565,9 +1390,61 @@ namespace return false; } - OutPoint = FVector(BestX, BestY, BestZ); - return !OutPoint.ContainsNaN() && FMath::IsFinite(OutPoint.X) - && FMath::IsFinite(OutPoint.Y) && FMath::IsFinite(OutPoint.Z); + FMazeGenerationParams SourceParams = Params; + SourceParams.StrateTopWorldZ = StrateTopZ; + SourceParams.StrateBottomWorldZ = StrateBottomZ; + FVoxelStrateMeasureSettings FitSettings; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const auto SampleMaze = [&](float X, float Y, float Z) + { + return VF_EvaluateMazeLandingDensity( + SourceParams, static_cast(Seed), X, Y, Z); + }; + + // The nearest point on a lattice edge can be its spherical node cap, where the support + // patch is narrower than the same edge's middle. Try a few deterministic points along + // the selected horizontal edge, then a small vertical anchor band. Every trial remains + // inside the original lateral budget and is checked by the exact player stencil. + const float NodeX = (BestCellX + 0.5f) * CellSize; + const float NodeY = (BestCellY + 0.5f) * CellSize; + constexpr int32 EdgeSamples = 9; + const int32 VerticalOffsets[] = { 0, 1, -1, 2, -2, 3, -3, 4, 5, 6 }; + for (int32 EdgeSample = 0; EdgeSample < EdgeSamples; ++EdgeSample) + { + const float T = static_cast(EdgeSample) / (EdgeSamples - 1); + const float CandidateX = BestAxis == 0 ? NodeX + T * CellSize : NodeX; + const float CandidateY = BestAxis == 1 ? NodeY + T * CellSize : NodeY; + const float LateralDX = CandidateX - WorldX; + const float LateralDY = CandidateY - WorldY; + if (LateralDX * LateralDX + LateralDY * LateralDY > MaxSnapSq) + { + continue; + } + + for (const int32 VerticalOffset : VerticalOffsets) + { + const FVector CandidateFeet( + CandidateX, + CandidateY, + BestZ - Radius + RoughnessBound + + MaxStepHeightVoxels + 0.5f + + static_cast(VerticalOffset)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + SourceParams.BoundarySealThickness, + SampleMaze, + OutPoint)) + { + return true; + } + } + } + return false; } // VerticalShafts has a guaranteed floor only when its lower boundary seal exists. Select a @@ -617,58 +1494,8 @@ namespace return false; } - const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness; - const float InnerTop = StrateTopZ - Params.BoundarySealThickness; - if (!FMath::IsFinite(InnerBottom) || !FMath::IsFinite(InnerTop) - || InnerBottom >= InnerTop) - { - return false; - } - const float RoughnessBound = Params.SurfaceRoughness * VOXEL_NOISE_SCALE; const float ShaftInteriorMargin = RoughnessBound + 0.25f; - const float FloorClearance = FMath::Max(1.0f, RoughnessBound + 1.0f); - - // Pick the first interval above the bottom seal that is not occupied by a periodic ledge. - // Leaving a full roughness-sized margin makes the result a source-level air claim rather - // than a point on the smoothed ledge edge. - float LandingFromBottom = FMath::Max( - Params.BoundarySealThickness, Params.LedgeDepth) + FloorClearance; - // The measurement API derives its default interior margin as two seal thicknesses. - // Keep a source-level landing strictly inside that same measured window; otherwise a - // lower-roughness default can produce a perfectly valid shaft landing just below the - // diagnostic grid's lower edge and turn a real mouth query into OUT_OF_WINDOW. - LandingFromBottom = FMath::Max( - LandingFromBottom, - 2.0f * Params.BoundarySealThickness + 0.01f); - if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f) - { - const float SafeBand = Params.LedgeDepth + FloorClearance; - if (SafeBand * 2.0f >= Params.LedgeSpacing) - { - return false; - } - - const float Period = FMath::FloorToFloat(LandingFromBottom / Params.LedgeSpacing); - const float PeriodStart = Period * Params.LedgeSpacing; - const float InPeriod = LandingFromBottom - PeriodStart; - if (InPeriod < SafeBand) - { - LandingFromBottom = PeriodStart + SafeBand; - } - else if (InPeriod > Params.LedgeSpacing - SafeBand) - { - LandingFromBottom = (Period + 1.0f) * Params.LedgeSpacing + SafeBand; - } - } - - const float LandingZ = StrateBottomZ + LandingFromBottom; - if (!FMath::IsFinite(LandingZ) - || LandingZ <= InnerBottom || LandingZ >= InnerTop) - { - return false; - } - const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); const int32 BaseCellX = FMath::FloorToInt(WorldX / Spacing); const int32 BaseCellY = FMath::FloorToInt(WorldY / Spacing); @@ -681,6 +1508,7 @@ namespace float BestDistSq = FLT_MAX; float BestAxisX = 0.0f; float BestAxisY = 0.0f; + float BestSafeRadius = 0.0f; bool bFound = false; int32 BestCellX = INT32_MAX; int32 BestCellY = INT32_MAX; @@ -720,10 +1548,13 @@ namespace // A VerticalShafts landing must identify the topology, not merely an open point // inside its radius. The safe-radius distance still chooses the nearest confident - // site under the existing snap budget, but the returned XY is the selected shaft's - // exact axis so it is on the deterministic drainage tree by construction. + // site under the existing snap budget. The candidate offsets below stay inside + // this same feature core while allowing the generated ledge to provide a real + // walkable support patch; the exact shaft axis is an open cylinder and has no + // support surface by itself. BestAxisX = ShaftX; BestAxisY = ShaftY; + BestSafeRadius = SafeRadius; BestDistSq = DistSq; BestCellX = CellX; BestCellY = CellY; @@ -737,9 +1568,91 @@ namespace return false; } - OutPoint = FVector(BestAxisX, BestAxisY, LandingZ); - return !OutPoint.ContainsNaN() && FMath::IsFinite(OutPoint.X) - && FMath::IsFinite(OutPoint.Y) && FMath::IsFinite(OutPoint.Z); + FVerticalShaftParams SourceParams = Params; + SourceParams.StrateTopWorldZ = StrateTopZ; + SourceParams.StrateBottomWorldZ = StrateBottomZ; + + FVoxelStrateMeasureSettings FitSettings; + float InnerTop = 0.0f; + float InnerBottom = 0.0f; + if (!VF_GetPlayerFitInteriorBounds( + FitSettings, StrateTopZ, StrateBottomZ, + Params.BoundarySealThickness, InnerTop, InnerBottom)) + { + return false; + } + const auto SampleShaft = [&](float X, float Y, float Z) + { + return VF_EvaluateShaftLandingDensity( + SourceParams, static_cast(Seed), X, Y, Z); + }; + + // The query's default interior margin is the same 2x-seal margin used by the exact + // player-fit measurement. This prevents a lower boundary seal from being reported as a + // standing floor that the measured window intentionally excludes. + const float FirstCandidateZ = InnerBottom + 0.5f; + const float LastCandidateZ = InnerTop + - 2.0f * FitSettings.PlayerCapsuleHalfHeightVoxels - 0.5f; + if (!FMath::IsFinite(FirstCandidateZ) || !FMath::IsFinite(LastCandidateZ) + || LastCandidateZ < FirstCandidateZ) + { + return false; + } + + const int32 NumCandidateSteps = FMath::Min( + 4096, + FMath::Max(0, FMath::CeilToInt(LastCandidateZ - FirstCandidateZ))); + + const FVector2D CandidateOffsets[] = { + FVector2D(0.0f, 0.0f), + FVector2D(0.20f, 0.20f), + FVector2D(0.35f, 0.35f), + FVector2D(0.50f, 0.15f), + FVector2D(0.15f, 0.50f), + FVector2D(0.55f, 0.35f), + FVector2D(0.35f, 0.55f), + }; + for (const FVector2D& NormalizedOffset : CandidateOffsets) + { + const float CandidateX = BestAxisX + + NormalizedOffset.X * BestSafeRadius; + const float CandidateY = BestAxisY + + NormalizedOffset.Y * BestSafeRadius; + const float OffsetSq = FMath::Square(CandidateX - BestAxisX) + + FMath::Square(CandidateY - BestAxisY); + const float LateralDX = CandidateX - WorldX; + const float LateralDY = CandidateY - WorldY; + const float LateralDistanceSq = FMath::Square(LateralDX) + + FMath::Square(LateralDY); + if (!FMath::IsFinite(CandidateX) || !FMath::IsFinite(CandidateY) + || !FMath::IsFinite(LateralDistanceSq) + || OffsetSq > FMath::Square(BestSafeRadius) + || LateralDistanceSq > MaxSnapSq + KINDA_SMALL_NUMBER) + { + continue; + } + + for (int32 CandidateStep = 0; + CandidateStep <= NumCandidateSteps; + ++CandidateStep) + { + const FVector CandidateFeet( + CandidateX, CandidateY, + FirstCandidateZ + static_cast(CandidateStep)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleShaft, + OutPoint)) + { + return true; + } + } + } + return false; } struct FVFIslandSite @@ -955,6 +1868,7 @@ namespace float BestDistSq = FLT_MAX; FVector BestPoint = FVector::ZeroVector; int32 BestIslandIndex = INDEX_NONE; + FVoxelStrateMeasureSettings FitSettings; auto FindLandingZ = [&](float CandidateX, float CandidateY, float& OutLandingZ) -> bool { @@ -1016,6 +1930,28 @@ namespace return false; }; + const auto SampleIsland = [&](float X, float Y, float Z) + { + // Island density uses the opposite convention internally (positive = solid). + return -VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), X, Y, Z); + }; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const FIntPoint CoreOffsets[] = { + FIntPoint(0, 0), + FIntPoint(1, 0), FIntPoint(-1, 0), + FIntPoint(0, 1), FIntPoint(0, -1), + FIntPoint(2, 0), FIntPoint(-2, 0), + FIntPoint(0, 2), FIntPoint(0, -2), + FIntPoint(1, 1), FIntPoint(1, -1), + FIntPoint(-1, 1), FIntPoint(-1, -1), + FIntPoint(3, 0), FIntPoint(-3, 0), + FIntPoint(0, 3), FIntPoint(0, -3) + }; + const float FeetOffsets[] = { 0.0f, -1.0f, 1.0f, -2.0f, 2.0f, 3.0f }; + for (int32 IslandIndex = 0; IslandIndex < Islands.Num(); ++IslandIndex) { const FVFIslandSite& Island = Islands[IslandIndex]; @@ -1034,23 +1970,77 @@ namespace float CandidateX = WorldX; float CandidateY = WorldY; - if (Distance > SafeRadius && Distance > KINDA_SMALL_NUMBER) + // Prefer the feature core whenever it fits the lateral budget. A point near the + // nominal blob rim can be open air yet fail the support patch; the deterministic + // centre is the island's large, stable landing feature. + if (Distance <= MaxLateralSnap) + { + CandidateX = Island.X; + CandidateY = Island.Y; + } + else if (Distance > SafeRadius && Distance > KINDA_SMALL_NUMBER) { const float Scale = SafeRadius / Distance; CandidateX = Island.X + DX * Scale; CandidateY = Island.Y + DY * Scale; } - float CandidateZ = 0.0f; - if (!FindLandingZ(CandidateX, CandidateY, CandidateZ)) continue; + const float CandidateDX = CandidateX - WorldX; + const float CandidateDY = CandidateY - WorldY; + const float CandidateDistSq = + CandidateDX * CandidateDX + CandidateDY * CandidateDY; + if (CandidateDistSq > MaxSnapSq) + { + continue; + } - const bool bCloser = DistSq < BestDistSq; - const bool bTie = DistSq == BestDistSq && IslandIndex < BestIslandIndex; - if (!bCloser && !bTie) continue; + // A top surface is only a landing site if the complete player stencil can occupy the + // air above it and the support patch is walkable. The core offsets are a bounded, + // deterministic local search for a broad patch; they are not a component query. + for (const FIntPoint& CoreOffset : CoreOffsets) + { + const float TrialX = CandidateX + static_cast(CoreOffset.X); + const float TrialY = CandidateY + static_cast(CoreOffset.Y); + const float TrialDX = TrialX - Island.X; + const float TrialDY = TrialY - Island.Y; + if (TrialDX * TrialDX + TrialDY * TrialDY + > FMath::Square(SafeRadius) + KINDA_SMALL_NUMBER) + { + continue; + } + const float LateralDX = TrialX - WorldX; + const float LateralDY = TrialY - WorldY; + const float TrialDistSq = LateralDX * LateralDX + LateralDY * LateralDY; + if (TrialDistSq > MaxSnapSq) continue; - BestDistSq = DistSq; - BestPoint = FVector(CandidateX, CandidateY, CandidateZ); - BestIslandIndex = IslandIndex; + float CandidateZ = 0.0f; + if (!FindLandingZ(TrialX, TrialY, CandidateZ)) continue; + for (const float FeetOffset : FeetOffsets) + { + FVector FitPoint = FVector::ZeroVector; + if (!VF_ValidatePlayerFitPose( + FitSettings, + FVector(TrialX, TrialY, + CandidateZ + FeetOffset + MaxStepHeightVoxels), + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleIsland, + FitPoint)) + { + continue; + } + + const bool bCloser = TrialDistSq < BestDistSq; + const bool bTie = TrialDistSq == BestDistSq + && IslandIndex < BestIslandIndex; + if (!bCloser && !bTie) continue; + + BestDistSq = TrialDistSq; + BestPoint = FitPoint; + BestIslandIndex = IslandIndex; + } + } } if (BestIslandIndex == INDEX_NONE || BestDistSq > MaxSnapSq || BestPoint.ContainsNaN()) diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 069c14a..efb7b2c 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -603,8 +603,8 @@ void UVoxelStrateManager::GeneratePassages() constexpr uint32 PassageSaltBendFreq = 0xA110000Au; int32 TotalPassages = 0; - int32 NumAimedAtUpperOpenPoint = 0; - int32 NumAimedAtLowerOpenPoint = 0; + int32 NumAimedAtUpperPlayerFit = 0; + int32 NumAimedAtLowerPlayerFit = 0; TSet NoQueryArchetypes; const auto ArchetypeName = [](ECaveGeneratorType Archetype) @@ -629,8 +629,9 @@ void UVoxelStrateManager::GeneratePassages() case ECaveGeneratorType::VerticalShafts: // A density of 0.6 does not guarantee an occupied cell in the immediate grid // neighbourhood. Allow the nearest occupied shaft to be two grid cells away while - // remaining a bounded local query; the landing query still returns that shaft's - // exact axis, so this cannot create a non-topological mouth. + // remaining a bounded local query; the landing query still returns a pose inside + // that shaft's deterministic feature core, so this cannot create a non-topological + // mouth. return FMath::Max(2.0f * Definition.VerticalShaftParams.ShaftSpacing, 1.0f); case ECaveGeneratorType::FloatingIslands: @@ -749,20 +750,18 @@ void UVoxelStrateManager::GeneratePassages() // their source. The two queries are deliberately independent: a passage never reads // another passage's endpoint or any live layout state. FVector SuggestedUpperPoint = FVector::ZeroVector; - bool bAimedUpperAtOpenPoint = false; + bool bAimedUpperAtPlayerFitPoint = false; if (UpperDef) { const bool bUsesRoomSeed = UpperDef->GeneratorType == ECaveGeneratorType::TunnelNetwork || UpperDef->GeneratorType == ECaveGeneratorType::Underwater; - const int32 UpperQuerySeed = SeasonStrates.IsValidIndex(i) - ? SeasonStrates[i].Seed - : (bUsesRoomSeed - ? static_cast(VoxelCaveMorphology::MakeStrateSeed( - static_cast(CachedSeed), Upper.StrateIndex)) - : CachedSeed); + const int32 UpperQuerySeed = bUsesRoomSeed + ? static_cast(VoxelCaveMorphology::MakeStrateSeed( + static_cast(CachedSeed), Upper.StrateIndex)) + : CachedSeed; - bAimedUpperAtOpenPoint = VF_SuggestLandingPoint( + bAimedUpperAtPlayerFitPoint = VF_SuggestLandingPoint( UpperDef->GeneratorType, UpperDef->GenerationParams, UpperDef->SlabParams, @@ -777,9 +776,9 @@ void UVoxelStrateManager::GeneratePassages() MaxLateralSnapFor(*UpperDef), SuggestedUpperPoint); - if (bAimedUpperAtOpenPoint) + if (bAimedUpperAtPlayerFitPoint) { - ++NumAimedAtUpperOpenPoint; + ++NumAimedAtUpperPlayerFit; } else { @@ -788,22 +787,20 @@ void UVoxelStrateManager::GeneratePassages() } FVector SuggestedLowerPoint = FVector::ZeroVector; - bool bAimedLowerAtOpenPoint = false; + bool bAimedLowerAtPlayerFitPoint = false; const UVoxelStrateDefinition* LowerDef = Lower.Definition; if (LowerDef) { const bool bUsesRoomSeed = LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork || LowerDef->GeneratorType == ECaveGeneratorType::Underwater; - const int32 LowerQuerySeed = SeasonStrates.IsValidIndex(i + 1) - ? SeasonStrates[i + 1].Seed - : (bUsesRoomSeed - ? static_cast(VoxelCaveMorphology::MakeStrateSeed( - static_cast(CachedSeed), Lower.StrateIndex)) - : CachedSeed); + const int32 LowerQuerySeed = bUsesRoomSeed + ? static_cast(VoxelCaveMorphology::MakeStrateSeed( + static_cast(CachedSeed), Lower.StrateIndex)) + : CachedSeed; const float MaxLateralSnap = MaxLateralSnapFor(*LowerDef); - bAimedLowerAtOpenPoint = VF_SuggestLandingPoint( + bAimedLowerAtPlayerFitPoint = VF_SuggestLandingPoint( LowerDef->GeneratorType, LowerDef->GenerationParams, LowerDef->SlabParams, @@ -818,9 +815,9 @@ void UVoxelStrateManager::GeneratePassages() MaxLateralSnap, SuggestedLowerPoint); - if (bAimedLowerAtOpenPoint) + if (bAimedLowerAtPlayerFitPoint) { - ++NumAimedAtLowerOpenPoint; + ++NumAimedAtLowerPlayerFit; } else { @@ -842,7 +839,7 @@ void UVoxelStrateManager::GeneratePassages() Passage.RequestedUpperPoint = FVector(PX, PY, TopZ); Passage.RequestedLowerPoint = FVector(PX, PY, BottomZ); - if (bAimedUpperAtOpenPoint) + if (bAimedUpperAtPlayerFitPoint) { const float UpperSealThickness = BoundarySealThicknessFor(*UpperDef); const float InnerBottomZ = UpperBottomZ + UpperSealThickness; @@ -868,7 +865,7 @@ void UVoxelStrateManager::GeneratePassages() } } - if (bAimedLowerAtOpenPoint) + if (bAimedLowerAtPlayerFitPoint) { const float LowerSealThickness = BoundarySealThicknessFor(*LowerDef); const float LowerBottomZ = (float)(Lower.BottomChunkZ) * CHUNK_SIZE; @@ -1031,12 +1028,12 @@ void UVoxelStrateManager::GeneratePassages() } UE_LOG(LogTemp, Log, - TEXT("[StrateManager] Passage landing sites: upper %d/%d and lower %d/%d aimed at generator-confirmed open space, %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."), - NumAimedAtUpperOpenPoint, + TEXT("[StrateManager] Passage landing sites: upper %d/%d and lower %d/%d aimed at player-fit source space, %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."), + NumAimedAtUpperPlayerFit, TotalPassages, - NumAimedAtLowerOpenPoint, + NumAimedAtLowerPlayerFit, TotalPassages, - (TotalPassages * 2) - NumAimedAtUpperOpenPoint - NumAimedAtLowerOpenPoint, + (TotalPassages * 2) - NumAimedAtUpperPlayerFit - NumAimedAtLowerPlayerFit, TotalPassages * 2, *NoQueryList); diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 3167014..552ad6c 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -539,22 +539,27 @@ namespace VoxelCaveMorphology } /** - * Suggest a deterministic full landing point for an inter-strate passage mouth. + * Suggest a deterministic player-fit landing point for an inter-strate passage mouth. * * This is deliberately a pure source query: it does not touch a generator, an operator stack, * a strate manager, a cache, or mutable state. DesiredX/DesiredY is the passage's deliberate * placement around the (0,0) spine. Sparse sources may move laterally, but never farther than - * MaxLateralSnap; false means no confident landing site exists inside that bound. + * MaxLateralSnap; false means no confident player-fit pose exists inside that bound. A successful + * point has a locally supported floor within player step height, a walkable support patch, and a + * clear capsule volume. The local source stencil deliberately does not claim flood-fill + * connectivity or a measured component size; it selects a topology-backed feature core instead. * For TunnelNetwork/Underwater, Seed must be the strate-specific room seed returned by * VoxelCaveMorphology::MakeStrateSeed; for slab, maze, shaft, and island archetypes, Seed is the * generator world seed consumed by their source. SurfaceWorld deliberately has no answer here: * its production height can be selected by manager-owned biome context and per-chunk state, * which this re-entrant query cannot inspect. * - * Propose un point d'atterrissage déterministe complet pour la bouche d'un passage inter-strates. - * Cette requête reste pure et le déplacement latéral est strictement borné par MaxLateralSnap. - * Pour VerticalShafts, la réponse XY est exactement l'axe d'un puits sélectionné; elle n'est pas - * un point ouvert arbitraire à l'intérieur de son disque. + * Propose un point d'atterrissage déterministe où la capsule du joueur tient réellement pour la + * bouche d'un passage inter-strates. Cette requête reste pure et le déplacement latéral est + * strictement borné par MaxLateralSnap; elle ne prétend pas prouver une composante connectée. + * Pour VerticalShafts, la réponse s'appuie sur l'axe d'un puits sélectionné et peut se déplacer + * vers une banquette déterministe (+X/+Y) pour obtenir un support; ce n'est pas un point ouvert + * arbitraire à l'intérieur de son disque. * * @return true when the archetype can provide a confident point; false when it cannot. */ @@ -572,11 +577,13 @@ VOXELFORGE_API bool VF_SuggestLandingPoint( ); /** - * Complete source-query overload for archetypes whose placement parameters are not part of + * Complete player-fit source-query overload for archetypes whose placement parameters are not part of * FStrateGenerationParams/FSlabGenerationParams. The extra structs are read-only inputs only; * the function remains pure and SurfaceWorld still returns false for the reason above. The - * returned point is either at the requested XY or within MaxLateralSnap of it. VerticalShafts - * returns the selected shaft axis so the answer belongs to the connected shaft tree. + * returned point is either at the requested XY or within MaxLateralSnap of it, and is accepted + * only after the local player capsule/support stencil passes. VerticalShafts uses a selected + * shaft's deterministic feature core and ledge-side pose; the axis identifies the topology but + * is not itself claimed to be a standable point. */ VOXELFORGE_API bool VF_SuggestLandingPoint( ECaveGeneratorType Archetype,