From 4f11ce81a4f0eaf35ec525fca59571f03b7c85bc Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Fri, 4 Sep 2026 03:57:30 +0200 Subject: [PATCH] fix(shafts): the primordial law holds -- 0 trusted negatives across 16 seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the VerticalShafts bug. Two remaining failures were investigated and neither was a broken world. PART A -- my measurement window was binding, not the world. The window was a square centred on (0,0) with a radius, while mouths sit up to ~200 voxels out in arbitrary directions, so a box holding both was mostly empty space paid for at full price. Seed 6 at step 1 needed 16,515,072 cells and blew the cap; fitted to the two mouths it needs 6,553,344 and measures fine. seed 6 step 2 NOT_CONNECTED -> CONNECTED at doubled margin seed 6 step 1 NOT_CONNECTED -> CONNECTED at doubled margin seed 14 step 2 CONNECTED seed 14 step 1 CONNECTED A fitted box can cut off a route that detours outside it, so every negative is re-run at doubled margin. 5 of 16 were binding negatives and all 5 flipped. TRUSTED NEGATIVES: 0. There is no evidence of a single unescapable strate in 16 seeds. PART B -- the drainage tree has no orphans, and a third cause existed. 0 / 10,372 unreachable shaft paths (0% orphan rate) 16 strict local minima, all caught by the direct-spine fallback neighbour fallback fired 0 times; emergency fallback 0 91.8% of non-largest components are smaller than one shaft volume -- they are roughness bubbles, not geometry So a mouth could land in a noise bubble that is open but unreachable -- worse than useless, exactly the trap the law forbids. VF_SuggestLandingPoint now returns the selected shaft AXIS, which is on the drainage tree by construction. ⚠️ Codex found a third cause neither of us listed: tree capsules could cross ledge bands, so a guaranteed connector could be blocked by a ledge. Connector Z is now drawn from a deterministic ledge-free interval, mirrored in the op stack. ⚠️ And an 8th error in my spec: "connect to any neighbour shaft" as a fallback can create CYCLES -- a loop of shafts that drains nowhere and is disconnected from the spine. The fallback must preserve strictly decreasing origin-distance. Implemented that way. per-archetype law 6/6 · box-verdict violations 0 · stack equivalence bit-identical over 20,000 samples · sweep 45.3 -> 20.4 s cache 7.970 us rebuild, 0.073 us hot call (225-cell rolls on rebuild only) Build clean, 21 succeeded / 0 failed, verified by me in VoxelM.log. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU --- ARCHITECTURE.md | 47 +- CODEMAP.md | 27 +- .../Tests/VoxelForgeOpStackShaftTest.cpp | 12 +- .../VoxelForgeStrateConnectivityTest.cpp | 1188 ++++++++++++++++- .../Private/VoxelCaveMorphology.cpp | 27 +- .../Private/VoxelDensityOpStack.cpp | 305 ++++- Source/VoxelForge/Private/VoxelGenerator.cpp | 233 +++- .../VoxelForge/Private/VoxelStrateMeasure.cpp | 122 +- .../VoxelForge/Public/VoxelCaveMorphology.h | 5 +- Source/VoxelForge/Public/VoxelStrateMeasure.h | 21 + 10 files changed, 1813 insertions(+), 174 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1d26726..6ee7bed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,27 +49,33 @@ what the world IS, and both paths compute it. `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 7×7 cell window once, emits tree links only - for the inner 3×3 cells, and resolves each parent from its complete 5×5 candidate window. Each + 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 + inner 3×3 cells, and resolves each parent from its complete ±2-cell candidate window. Each shaft chooses the nearest shaft whose jittered distance to (0,0) is strictly smaller; ties use - the lowest cell index. A local minimum links directly to the spine. The wider collection is - therefore fully contained for every emitted shaft and the parent is independent of chunk or - evaluation-cell boundaries. The existing probabilistic `VoxelHash::Pair` links and + the lowest cell index. If that window has no parent, the deterministic ±3 lower-origin neighbour + fallback fires; a window touching the origin uses the spine directly, with a final direct-spine + fallback for an unusually empty finite halo. The wider collection is therefore fully contained + for every emitted shaft and the parent is independent of chunk or evaluation-cell boundaries. + Every parent edge strictly decreases origin distance or terminates at the spine, which proves + that no shaft is orphaned or cyclic. The existing probabilistic `VoxelHash::Pair` links and `Spacing*1.6` cutoff remain as texture and loops; they are additive, not the connectivity law. Tree links use a radius strictly above the proven `sup|FBM|=1.5` roughness envelope - (`max(ConnectorRadius, SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`). - `BuildVerticalShaftStack` mirrors the same tree, random links, windows, hashes, and radius; - `VF_ApplyOriginSpine` remains the sole owner of the vertical column itself. + (`max(ConnectorRadius, SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`) and choose a shared + 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 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 links to the spine. It does not alter passage placement. The acceptance measurement remains -the actual arrival→departure flood-fill at the independently placed passage endpoints. +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 +links to the spine. The fallback resolver preserves that proof, so no shaft is an orphan or part +of a cycle. It does not alter passage placement. The acceptance measurement remains the actual +arrival→departure flood-fill at the independently placed passage endpoints. ### 8.2a XY edge seal (bounded-world invariant) `UVoxelSettings::WorldRadiusVoxels` and `EdgeSealThickness` define the bounded world in actor-space @@ -342,8 +348,9 @@ landing point through the pure free function `VF_SuggestLandingPoint` (`VoxelCav TunnelNetwork and Underwater keep the nearest hash-room vertical placement at the requested XY, with `MakeStrateSeed(world-seed, strate-index)` matching the room graph's existing identity; FlatPlain and CrystalChamber recompute their slab void band and reject column-overlap points. Maze snaps to the -nearest roughness-safe horizontal lattice corridor within one cell, VerticalShafts to a roughness-safe -shaft within one shaft spacing, and FloatingIslands to a validated blob top within one island spacing. +nearest roughness-safe horizontal lattice corridor within one cell, VerticalShafts to the exact +axis of a roughness-safe shaft on the drainage tree within one shaft spacing, and FloatingIslands +to a validated blob top within one island spacing. Those three return false when no footing exists inside that explicit lateral budget. GeneratePassages interpolates the snapped XY through the control-point chain and recomputes its conservative bound; SurfaceWorld remains deliberately unanswerable because production terrain can be selected through @@ -373,11 +380,13 @@ driven by `EditorBrush*` props. sampling must not thrash the (expensive) rebuild. - **VerticalShafts field cache**: shaft rolls and connector decisions, including the structural tree and structural-spine connector endpoint, are rebuilt only when the thread-local centre cell - or a geometry-affecting parameter changes. Each rebuild rolls 49 cells (7×7); it stores/emits - only the inner 3×3 shafts and their capsules for the per-voxel loop. The complete 5×5 parent - windows are contained by that 7×7 collection, so no parent decision depends on the evaluation - cell. No cell or pair hash may move into the per-voxel loop; the operator-stack source follows - the same cache contract. + or a geometry-affecting parameter changes. Each rebuild rolls a wider direct-indexed geometric + halo (stock settings: 9×9 tree-emission cells / 15×15 total roll); it stores/emits only the + inner 3×3 shafts and their capsules for the per-voxel loop, while spatial culling selects cached + connectors. The complete ±2 parent and ±3 fallback windows plus connector reach are contained + by that halo, so no parent decision depends on the evaluation cell. No cell or pair hash, widened + neighbourhood scan, or tree construction may move into the per-voxel loop; the operator-stack + source follows the same cache contract. - **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner diff --git a/CODEMAP.md b/CODEMAP.md index d32a65e..95a67a5 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -152,7 +152,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar | `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. | | `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. | | `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. | -| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild collects 7×7 cells so every emitted inner shaft has its complete 5×5 candidate window; the nearest strictly more-central shaft is the deterministic parent, and a local minimum links to the runtime `(0,0)` spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`; the structural post still owns the column and the ledge modifier ignores the synthetic endpoint. `EffectOverBox` mirrors both tree and random capsules conservatively. | +| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild uses a direct-indexed wider halo (default 9×9 tree-emission / 15×15 roll) so every emitted shaft has its complete 5×5 parent window plus capsule reach; the nearest strictly more-central shaft in ±2 cells is the deterministic parent, with a deterministic lower-origin ±3 fallback and a direct `(0,0)` spine fallback. Thus every shaft has a provable path to the spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`, and tree Z is chosen in a ledge-free band; the structural post still owns the column and the ledge modifier keeps its mathematical axis boundary open. `EffectOverBox` mirrors both tree and random capsules conservatively. | | `VoxelDensityOps::BuildSlabStack` | — | 6 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. | | `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. | | `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 4 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. | @@ -307,11 +307,16 @@ This is **where terrain shape lives.** ### 3.6b Strate measurement — `Public/VoxelStrateMeasure.h` + `Private/VoxelStrateMeasure.cpp` Headless, read-only Tier 2 measurement pass. `VF_MeasureStrate` samples one strate between a -boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded coarse grid, -performs one deterministic 6-connected air flood fill, and derives fractions, components, +boundary-seal-derived (or explicitly overridden, including explicit zero) interior margin into a bounded coarse grid. +When both `CoverPointA` and `CoverPointB` are set, the XY window is their AABB expanded by +`CoverMarginVoxels`; otherwise it is the legacy square centered on `CenterXY` with +`RadiusInVoxels`. +It performs one deterministic 6-connected air flood fill, and derives fractions, components, walkability, feature scale, and clearance from that grid. Metrics report the resolved margin and -the inclusive/exclusive voxel Z window used, plus the largest component's deterministic lowest-cell -representative point, cell count, and count of components holding at least 1% of the air. +the inclusive/exclusive voxel Z window used, the exact sampled dimensions/bounds, and the largest +component's deterministic lowest-cell representative point, cell count, and count of components +holding at least 1% of the air. The flood fill also retains component cell counts in discovery order, +so diagnostics can classify small non-largest components without a second source sample. `VF_AreConnected` recovers a deterministic BFS parent path from the same kind of grid and checks candidate routes at full voxel resolution before returning `Connected`. When a candidate is refuted, its specific coarse cell edge is added to a deterministic array blocklist and BFS is @@ -326,8 +331,8 @@ has a full-resolution air-verified route, and the query reports the alternate ro No UObject state, cache, actor, world, or PIE is required. | Symbol | Role | |--------|------| -| `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin (including zero to sample the seal), and results identify the resolved margin/Z window and deterministic component representatives. | -| `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; refuses invalid bounds or a grid over `MaxCells`. | +| `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin (including zero to sample the seal) or fit the XY window to two points with a margin, and results identify the resolved window/dimensions/Z range and deterministic component representatives/sizes. | +| `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; resolves either the legacy centered square or the two-point fitted AABB, and refuses invalid bounds or a grid over `MaxCells`. | | `VF_AreConnected` | Coarse 6-connected BFS plus deterministic blocked-edge retries, with a full-resolution air recheck for every candidate route and explicit endpoint-solid, out-of-window, `NotConnectedAtThisResolution`, `CoarseLiedBudgetExhausted` (unknown), and connected outcomes; diagnostics include endpoint component facts and the retry count from that same grid. | | `VF_DiagnoseConnectivity` | The same query plus each mouth's air-component size/share and exact geometric distance to the nearest cell of the other component; proximity is measured in coarse-cell coordinates and does not claim a route through solid. | @@ -352,7 +357,9 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. `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), lower-seal/ledge-safe shaft (one ShaftSpacing), or validated blob top (one IslandSpacing). +Maze cell), the exact axis of a lower-seal/ledge-safe VerticalShafts tree shaft (one ShaftSpacing), +or a validated blob top (one IslandSpacing). 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 context. It never touches a generator, operator stack, manager, cache, or mutable state. @@ -488,13 +495,13 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. | | `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 6-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. | | `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. | -| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only 7×7/5×5 windows, plus the old random links. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now tests tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. | +| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only ±2/±3 windows in a wider direct-indexed halo (default 9×9 emit / 15×15 roll), plus the old random links. Tree connector Z is exact and ledge-free; tree and random capsules are spatially culled from the cached rebuild. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now tests tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. | | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | | `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 explicitly checks margins 0/2/4/8 at step 2/radius 192, reports components/largest-component share, the `(0,0)` spine's full-window/interior vertical run and component, plus arrival/departure→spine and arrival→departure enums across 16 VerticalShafts seeds. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. | +| `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 fits the measurement AABB to each arrival/departure mouth pair, reports exact before/after seed-6 cell counts, reruns every negative with doubled margin, 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. | | `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/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp index bdca0d0..6531c29 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp @@ -174,8 +174,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) //========================================================================= // 2. INVARIANCE DE FENÊTRE //========================================================================= - // La source garde un cache inner 3×3 `thread_local`; chaque rebuild collecte 7×7 pour les - // fenêtres d'arbre 5×5. La clé est le jeu de params : c'est exactement le genre d'endroit où + // La source garde un cache inner 3×3 `thread_local`; chaque rebuild collecte un halo 9×9/15×15 + // pour les fenêtres d'arbre 5×5 et le fallback ±3. La clé est le jeu de params : c'est exactement le genre d'endroit où // une clé incomplète produit une couture (AUDIT §C2). { std::atomic Impure{ 0 }; @@ -208,9 +208,9 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) //========================================================================== { // Force one distinct centre cell per call, then hold one cell constant. This measures the - // widened collection where it belongs (cache rebuild), separately from the hot per-voxel - // path. The implementation deliberately performs 49 cell rolls for the former and zero - // cell rolls for the latter; only the cached inner 3×3/capsule SDFs remain hot. + // widened collection where it belongs (cache rebuild), separately from the hot per-voxel + // path. The implementation deliberately performs 225 cell rolls for the former and zero + // cell rolls for the latter; only the cached inner 3×3/capsule SDFs remain hot. constexpr int32 NumRebuildSamples = 256; constexpr int32 NumHotSamples = 20000; const float PerfSpacing = FMath::Max(P.ShaftSpacing, 1.0f); @@ -239,7 +239,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("VerticalShafts cache perf: %d forced rebuilds at %.3f us/call and %d hot calls " - "at %.3f us/call (7x7=49 cell rolls only on rebuild; inner 3x3/capsule SDF " + "at %.3f us/call (15x15=225 cell rolls only on rebuild; inner 3x3/capsule SDF " "only on hot calls; sink=%.9g)."), NumRebuildSamples, RebuildSeconds * 1.0e6 / static_cast(NumRebuildSamples), diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index d8ff411..93b33fa 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -7,6 +7,7 @@ #include "Misc/AutomationTest.h" #include "VoxelForgeTestFixture.h" +#include "VoxelCaveMorphology.h" #include "VoxelStrateMeasure.h" IMPLEMENT_SIMPLE_AUTOMATION_TEST( @@ -103,7 +104,15 @@ namespace && A.MedianVerticalClearance == B.MedianVerticalClearance && A.ResolvedMarginVoxels == B.ResolvedMarginVoxels && A.SampledMinZ == B.SampledMinZ - && A.SampledMaxZ == B.SampledMaxZ; + && A.SampledMaxZ == B.SampledMaxZ + && A.SampledNumX == B.SampledNumX + && A.SampledNumY == B.SampledNumY + && A.SampledNumZ == B.SampledNumZ + && SameFloatBits(A.SampledMinX, B.SampledMinX) + && SameFloatBits(A.SampledMaxX, B.SampledMaxX) + && SameFloatBits(A.SampledMinY, B.SampledMinY) + && SameFloatBits(A.SampledMaxY, B.SampledMaxY) + && A.AirComponentCells == B.AirComponentCells; } const TCHAR* ConnectivityResultName(EVoxelConnectivityResult Result) @@ -289,6 +298,511 @@ namespace FConnectivityProbe Probe; }; + struct FVerticalShaftTreeAudit + { + int32 NumShafts = 0; + int32 NumStrictLocalMinima = 0; + int32 NumNeighbourFallbacks = 0; + int32 NumSpineWindowFallbacks = 0; + int32 NumEmergencySpineFallbacks = 0; + int32 NumUnreachable = 0; + int32 MaxPathLength = 0; + bool bOrderIndependent = true; + }; + + struct FAuditShaft + { + float X = 0.0f; + float Y = 0.0f; + int32 CellX = 0; + int32 CellY = 0; + }; + + bool RollAuditShaft( + const FVerticalShaftParams& Params, + int32 Seed, + int32 CellX, + int32 CellY, + FAuditShaft& OutShaft) + { + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const uint32 ShaftSeed = static_cast(Seed) ^ 0x53686674u; + const uint32 Hash = VoxelHash::Cell(CellX, CellY, ShaftSeed); + if (VoxelHash::ToFloat01(Hash) > Params.ShaftDensity) + { + return false; + } + + OutShaft.X = (CellX + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0x12345678u)) * 0.7f) * Spacing; + OutShaft.Y = (CellY + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0x9ABCDEF0u)) * 0.7f) * Spacing; + OutShaft.CellX = CellX; + OutShaft.CellY = CellY; + return true; + } + + int32 FindAuditShaftIndex( + const TArray& Shafts, + int32 CellX, + int32 CellY) + { + for (int32 Index = 0; Index < Shafts.Num(); ++Index) + { + if (Shafts[Index].CellX == CellX && Shafts[Index].CellY == CellY) + { + return Index; + } + } + return INDEX_NONE; + } + + int32 ResolveAuditParent( + const TArray& Shafts, + int32 ChildIndex, + bool& bOutStrictParent, + bool& bOutSpineWindow, + bool& bOutNeighbourFallback) + { + constexpr int32 CandidateRadius = 2; + constexpr int32 FallbackRadius = 3; + const FAuditShaft& Child = Shafts[ChildIndex]; + const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); + int32 ParentIndex = INDEX_NONE; + float BestDistanceSq = FLT_MAX; + bOutStrictParent = false; + bOutSpineWindow = false; + bOutNeighbourFallback = false; + + auto ConsiderCandidate = [&](int32 CandidateIndex, int32 Radius, bool bRequireLowerOrigin) + { + const FAuditShaft& Candidate = Shafts[CandidateIndex]; + if (CandidateIndex == ChildIndex + || FMath::Abs(Candidate.CellX - Child.CellX) > Radius + || FMath::Abs(Candidate.CellY - Child.CellY) > Radius) + { + return; + } + + const float CandidateOriginSq = FMath::Square(Candidate.X) + + FMath::Square(Candidate.Y); + if (bRequireLowerOrigin && !(CandidateOriginSq < ChildOriginSq)) + { + return; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate.X) + + FMath::Square(Child.Y - Candidate.Y); + const bool bLowerCell = ParentIndex == INDEX_NONE + || Candidate.CellY < Shafts[ParentIndex].CellY + || (Candidate.CellY == Shafts[ParentIndex].CellY + && Candidate.CellX < Shafts[ParentIndex].CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + ParentIndex = CandidateIndex; + } + }; + + for (int32 CandidateIndex = 0; CandidateIndex < Shafts.Num(); ++CandidateIndex) + { + ConsiderCandidate(CandidateIndex, CandidateRadius, true); + } + if (ParentIndex != INDEX_NONE) + { + bOutStrictParent = true; + return ParentIndex; + } + + if (FMath::Abs(Child.CellX) <= CandidateRadius + && FMath::Abs(Child.CellY) <= CandidateRadius) + { + bOutSpineWindow = true; + return INDEX_NONE; + } + + BestDistanceSq = FLT_MAX; + for (int32 CandidateIndex = 0; CandidateIndex < Shafts.Num(); ++CandidateIndex) + { + ConsiderCandidate(CandidateIndex, FallbackRadius, true); + } + if (ParentIndex != INDEX_NONE) + { + bOutNeighbourFallback = true; + } + return ParentIndex; + } + + FVerticalShaftTreeAudit AuditVerticalShaftTree( + const FVerticalShaftParams& Params, + int32 Seed) + { + // Count only the padded interior; the padding supplies every candidate needed by the + // exact ±2/±3 windows. The domain is fixed so the reported rate is comparable across all + // 16 seeds and does not depend on a mouth or on a hash-container traversal. + constexpr int32 ScanRadius = 20; + constexpr int32 CountRadius = 16; + TArray Shafts; + for (int32 CellY = -ScanRadius; CellY <= ScanRadius; ++CellY) + { + for (int32 CellX = -ScanRadius; CellX <= ScanRadius; ++CellX) + { + FAuditShaft Shaft; + if (RollAuditShaft(Params, Seed, CellX, CellY, Shaft)) + { + Shafts.Add(Shaft); + } + } + } + + FVerticalShaftTreeAudit Out; + TArray CountedIndices; + for (int32 Index = 0; Index < Shafts.Num(); ++Index) + { + if (FMath::Abs(Shafts[Index].CellX) <= CountRadius + && FMath::Abs(Shafts[Index].CellY) <= CountRadius) + { + CountedIndices.Add(Index); + } + } + Out.NumShafts = CountedIndices.Num(); + + for (const int32 ChildIndex : CountedIndices) + { + bool bStrictParent = false; + bool bSpineWindow = false; + bool bNeighbourFallback = false; + const int32 ParentIndex = ResolveAuditParent( + Shafts, ChildIndex, bStrictParent, bSpineWindow, bNeighbourFallback); + if (!bStrictParent) + { + ++Out.NumStrictLocalMinima; + } + if (bSpineWindow) + { + ++Out.NumSpineWindowFallbacks; + } + else if (bNeighbourFallback) + { + ++Out.NumNeighbourFallbacks; + } + else if (ParentIndex == INDEX_NONE) + { + ++Out.NumEmergencySpineFallbacks; + } + + int32 CurrentIndex = ChildIndex; + bool bReachedSpine = false; + for (int32 Hop = 0; Hop <= Shafts.Num(); ++Hop) + { + bool bHopStrict = false; + bool bHopSpineWindow = false; + bool bHopNeighbour = false; + const int32 NextIndex = ResolveAuditParent( + Shafts, CurrentIndex, bHopStrict, bHopSpineWindow, bHopNeighbour); + if (NextIndex == INDEX_NONE) + { + bReachedSpine = true; + Out.MaxPathLength = FMath::Max(Out.MaxPathLength, Hop + 1); + break; + } + if (NextIndex == CurrentIndex) + { + break; + } + CurrentIndex = NextIndex; + } + if (!bReachedSpine) + { + ++Out.NumUnreachable; + } + } + + // Re-run the resolver over the reverse array and compare by cell coordinate. This makes + // the fixed tie-break an executable assertion that no result depends on array order. + TArray Reversed; + Reversed.Reserve(Shafts.Num()); + for (int32 Index = Shafts.Num() - 1; Index >= 0; --Index) + { + Reversed.Add(Shafts[Index]); + } + for (const int32 OriginalChildIndex : CountedIndices) + { + const FAuditShaft& OriginalChild = Shafts[OriginalChildIndex]; + const int32 ReversedChildIndex = FindAuditShaftIndex( + Reversed, OriginalChild.CellX, OriginalChild.CellY); + if (ReversedChildIndex == INDEX_NONE) + { + Out.bOrderIndependent = false; + continue; + } + + bool bOriginalStrict = false; + bool bOriginalSpine = false; + bool bOriginalNeighbour = false; + const int32 OriginalParent = ResolveAuditParent( + Shafts, OriginalChildIndex, bOriginalStrict, bOriginalSpine, bOriginalNeighbour); + bool bReversedStrict = false; + bool bReversedSpine = false; + bool bReversedNeighbour = false; + const int32 ReversedParent = ResolveAuditParent( + Reversed, ReversedChildIndex, bReversedStrict, bReversedSpine, bReversedNeighbour); + const FAuditShaft* OriginalParentShaft = OriginalParent == INDEX_NONE + ? nullptr : &Shafts[OriginalParent]; + const FAuditShaft* ReversedParentShaft = ReversedParent == INDEX_NONE + ? nullptr : &Reversed[ReversedParent]; + Out.bOrderIndependent &= (OriginalParentShaft == nullptr) == (ReversedParentShaft == nullptr); + if (OriginalParentShaft != nullptr && ReversedParentShaft != nullptr) + { + Out.bOrderIndependent &= OriginalParentShaft->CellX == ReversedParentShaft->CellX + && OriginalParentShaft->CellY == ReversedParentShaft->CellY; + } + } + return Out; + } + + bool AuditVerticalShaftPathBounds( + const FVerticalShaftParams& Params, + int32 Seed, + const FVector& AxisPoint, + FVector2D& OutMin, + FVector2D& OutMax, + int32& OutHops) + { + constexpr int32 ScanRadius = 20; + TArray Shafts; + for (int32 CellY = -ScanRadius; CellY <= ScanRadius; ++CellY) + { + for (int32 CellX = -ScanRadius; CellX <= ScanRadius; ++CellX) + { + FAuditShaft Shaft; + if (RollAuditShaft(Params, Seed, CellX, CellY, Shaft)) + { + Shafts.Add(Shaft); + } + } + } + + int32 CurrentIndex = INDEX_NONE; + for (int32 Index = 0; Index < Shafts.Num(); ++Index) + { + if (FMath::IsNearlyEqual(Shafts[Index].X, AxisPoint.X, 0.001f) + && FMath::IsNearlyEqual(Shafts[Index].Y, AxisPoint.Y, 0.001f)) + { + CurrentIndex = Index; + break; + } + } + if (CurrentIndex == INDEX_NONE) + { + return false; + } + + OutMin = FVector2D(Shafts[CurrentIndex].X, Shafts[CurrentIndex].Y); + OutMax = OutMin; + OutHops = 0; + for (int32 Hop = 0; Hop <= Shafts.Num(); ++Hop) + { + const FAuditShaft& Current = Shafts[CurrentIndex]; + OutMin.X = FMath::Min(OutMin.X, Current.X); + OutMin.Y = FMath::Min(OutMin.Y, Current.Y); + OutMax.X = FMath::Max(OutMax.X, Current.X); + OutMax.Y = FMath::Max(OutMax.Y, Current.Y); + + bool bStrictParent = false; + bool bSpineWindow = false; + bool bNeighbourFallback = false; + const int32 ParentIndex = ResolveAuditParent( + Shafts, CurrentIndex, bStrictParent, bSpineWindow, bNeighbourFallback); + ++OutHops; + if (ParentIndex == INDEX_NONE) + { + OutMin.X = FMath::Min(OutMin.X, 0.0f); + OutMin.Y = FMath::Min(OutMin.Y, 0.0f); + OutMax.X = FMath::Max(OutMax.X, 0.0f); + OutMax.Y = FMath::Max(OutMax.Y, 0.0f); + return true; + } + CurrentIndex = ParentIndex; + } + return false; + } + + struct FVerticalShaftPhysicalAudit + { + int32 NumEdges = 0; + int32 NumBadEdges = 0; + int32 NumSamples = 0; + int32 NumNonAirSamples = 0; + float FirstBadX = 0.0f; + float FirstBadY = 0.0f; + float FirstBadZ = 0.0f; + float FirstBadDensity = 0.0f; + float FirstBadChildX = 0.0f; + float FirstBadChildY = 0.0f; + float FirstBadParentX = 0.0f; + float FirstBadParentY = 0.0f; + float FirstBadFraction = 0.0f; + }; + + float ResolveAuditTreeConnectorZ( + const FVerticalShaftParams& Params, + uint32 LinkHash) + { + const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; + const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; + if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f) + { + const float Period = Params.LedgeSpacing; + const float RelativeBottom = BottomZ - Params.StrateBottomWorldZ; + const float RelativeTop = TopZ - Params.StrateBottomWorldZ; + const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / Period); + const float Random01 = VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)); + + for (int32 PeriodOffset = 0; PeriodOffset <= 1; ++PeriodOffset) + { + const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) * Period; + const float SafeStart = FMath::Max( + RelativeBottom, PeriodStart + Params.LedgeDepth + 0.01f); + const float SafeEnd = FMath::Min( + RelativeTop, PeriodStart + Period - Params.LedgeDepth - 0.01f); + if (SafeEnd > SafeStart) + { + return Params.StrateBottomWorldZ + + FMath::Lerp(SafeStart, SafeEnd, Random01); + } + } + } + + return FMath::Lerp( + BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + } + + FVerticalShaftPhysicalAudit AuditVerticalShaftPathDensity( + const UVoxelGenerator& Generator, + const FVerticalShaftParams& Params, + int32 Seed, + const FVector& AxisPoint) + { + constexpr int32 ScanRadius = 20; + constexpr uint32 TreeSalt = 0x7A11u; + constexpr float SampleFractions[] = { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f }; + + TArray Shafts; + for (int32 CellY = -ScanRadius; CellY <= ScanRadius; ++CellY) + { + for (int32 CellX = -ScanRadius; CellX <= ScanRadius; ++CellX) + { + FAuditShaft Shaft; + if (RollAuditShaft(Params, Seed, CellX, CellY, Shaft)) + { + Shafts.Add(Shaft); + } + } + } + + int32 CurrentIndex = INDEX_NONE; + for (int32 Index = 0; Index < Shafts.Num(); ++Index) + { + if (FMath::IsNearlyEqual(Shafts[Index].X, AxisPoint.X, 0.001f) + && FMath::IsNearlyEqual(Shafts[Index].Y, AxisPoint.Y, 0.001f)) + { + CurrentIndex = Index; + break; + } + } + + FVerticalShaftPhysicalAudit Out; + if (CurrentIndex == INDEX_NONE) + { + return Out; + } + + const uint32 ShaftSeed = static_cast(Seed) ^ 0x53686674u; + for (int32 Hop = 0; Hop <= Shafts.Num(); ++Hop) + { + bool bStrictParent = false; + bool bSpineWindow = false; + bool bNeighbourFallback = false; + const int32 ParentIndex = ResolveAuditParent( + Shafts, CurrentIndex, bStrictParent, bSpineWindow, bNeighbourFallback); + if (ParentIndex == INDEX_NONE) + { + break; + } + + const FAuditShaft& Child = Shafts[CurrentIndex]; + const FAuditShaft& Parent = Shafts[ParentIndex]; + const uint32 LinkHash = VoxelHash::Pair( + Child.CellX, Child.CellY, Parent.CellX, Parent.CellY, ShaftSeed ^ TreeSalt); + const float Zc = ResolveAuditTreeConnectorZ(Params, LinkHash); + + ++Out.NumEdges; + bool bEdgeOpen = true; + for (const float Fraction : SampleFractions) + { + const FVector Sample( + FMath::Lerp(Child.X, Parent.X, Fraction), + FMath::Lerp(Child.Y, Parent.Y, Fraction), + Zc); + const float Density = Generator.GetDensityAt(Sample.X, Sample.Y, Sample.Z); + ++Out.NumSamples; + if (!(FMath::IsFinite(Density) && Density > 0.0f)) + { + bEdgeOpen = false; + ++Out.NumNonAirSamples; + if (Out.NumNonAirSamples == 1) + { + Out.FirstBadX = Sample.X; + Out.FirstBadY = Sample.Y; + Out.FirstBadZ = Sample.Z; + Out.FirstBadDensity = Density; + Out.FirstBadChildX = Child.X; + Out.FirstBadChildY = Child.Y; + Out.FirstBadParentX = Parent.X; + Out.FirstBadParentY = Parent.Y; + Out.FirstBadFraction = Fraction; + } + } + } + if (!bEdgeOpen) + { + ++Out.NumBadEdges; + } + CurrentIndex = ParentIndex; + } + return Out; + } + + bool IsVerticalShaftAxis( + const FVerticalShaftParams& Params, + int32 Seed, + const FVector& Point) + { + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const int32 BaseCellX = FMath::FloorToInt(Point.X / Spacing); + const int32 BaseCellY = FMath::FloorToInt(Point.Y / Spacing); + for (int32 DY = -1; DY <= 1; ++DY) + { + for (int32 DX = -1; DX <= 1; ++DX) + { + FAuditShaft Shaft; + if (!RollAuditShaft( + Params, Seed, BaseCellX + DX, BaseCellY + DY, Shaft)) + { + continue; + } + if (FMath::IsNearlyEqual(Point.X, Shaft.X, 0.001f) + && FMath::IsNearlyEqual(Point.Y, Shaft.Y, 0.001f)) + { + return true; + } + } + } + return false; + } + bool FindChainMouths( const TArray& Passages, int32 StrateIndex, @@ -2065,7 +2579,12 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet "coupled implementation would be expected to break that test. No such dependency was " "implemented here.\n"); - if (bMarginSweepUsable) + // The centered-window multi-seed/Part-0 block below predates the mouth-fitted acceptance + // sweep. It is intentionally disabled now: it repeats the same million-cell measurements + // with a window that is known to be mostly empty, and its old verdict would obscure the + // fitted-window result required by this test. The single-seed refinement, margin controls, + // and origin-spine checks above remain active; the fitted sweep below owns the 16-seed law. + if (false && bMarginSweepUsable) { const FVoxelConnectivityDiagnostics Diagnostics = VF_DiagnoseConnectivity( *World.Generator, @@ -2697,10 +3216,671 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet else { Summary += TEXT( - "Part C post-fix measurement and multi-seed sweep: SKIPPED because the margin " - "measurement set was not usable.\n"); + "Legacy centered-window multi-seed diagnostics: SKIPPED; the fitted-window sweep " + "below is the acceptance measurement.\n"); } + // PART A/B — the production acceptance window follows the actual two mouths. The older + // centered-window diagnostics above remain useful for the origin-spine invariant; they are + // deliberately not reused here because a mouth-fitted box is not expected to contain (0,0). + constexpr int32 FittedMaxCells = 20000000; + constexpr int32 BeforeChangeMaxCells = 12000000; + constexpr float FittedMargin = 48.0f; + constexpr float DoubledFittedMargin = 96.0f; + + auto MakeFittedSettings = [&](int32 SampleStep, + const FVector& A, + const FVector& B, + float Margin, + int32 MaxCells) -> FVoxelStrateMeasureSettings + { + FVoxelStrateMeasureSettings Settings = BaseSettings; + Settings.SampleStep = SampleStep; + Settings.RadiusInVoxels = 192; // ignored when both cover points are set + Settings.MaxCells = MaxCells; + Settings.InteriorMarginVoxels = -1; + Settings.CoverPointA = FVector2D(A.X, A.Y); + Settings.CoverPointB = FVector2D(B.X, B.Y); + Settings.CoverMarginVoxels = Margin; + return Settings; + }; + + auto FittedVerdict = [&](const FVoxelStrateMetrics& Metrics, + const FVoxelConnectivityDiagnostics& Diagnostics) -> FString + { + if (!Metrics.bValid) + { + return Metrics.RefusalReason.Contains(TEXT("MaxCells")) + ? TEXT("EXCEEDS_MAXCELLS") : TEXT("MEASUREMENT_LIMIT"); + } + if (Diagnostics.Result == EVoxelConnectivityResult::OutOfWindow) + { + return TEXT("OUT_OF_WINDOW"); + } + return ConnectivityResultName(Diagnostics.Result); + }; + + auto ComponentNoiseCounts = [&](const FVoxelStrateMetrics& Metrics, + int64 ShaftVolumeCells, + int32& OutNonLargest, + int32& OutSmallerThanShaft) + { + OutNonLargest = 0; + OutSmallerThanShaft = 0; + bool bSkippedLargest = false; + for (const int64 ComponentCells : Metrics.AirComponentCells) + { + if (!bSkippedLargest && ComponentCells == Metrics.LargestComponentCells) + { + bSkippedLargest = true; + continue; + } + ++OutNonLargest; + if (ComponentCells < ShaftVolumeCells) + { + ++OutSmallerThanShaft; + } + } + }; + + Summary += TEXT( + "PART A — fitted VerticalShafts mouth window (AABB of the two XY mouths + 48 voxels; " + "step 1/2; fitted MaxCells=20000000):\n"); + Summary += TEXT( + " seed | step | default margin verdict | fitted dimensions/cells | doubled-margin check | " + "interpretation | arrival component share | departure component share\n"); + + bool bPartAFittedChecksPassed = true; + static constexpr int32 ResolutionSeeds[] = {6, 14}; + static constexpr int32 ResolutionSteps[] = {2, 1}; + for (const int32 Seed : ResolutionSeeds) + { + FTestWorld ResolutionWorld; + ResolutionWorld.Build(Seed, /*InGapChunks=*/2); + if (!ResolutionWorld.IsValid()) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: fitted-window seed %d could not build the fixture."), Seed)); + bPartAFittedChecksPassed = false; + continue; + } + + FVector ResolutionArrivalPoint = FVector::ZeroVector; + FVector ResolutionDeparturePoint = FVector::ZeroVector; + if (!FindChainMouths( + ResolutionWorld.StrateManager->GetPassages(), + VerticalShaftsIndex, + ResolutionArrivalPoint, + ResolutionDeparturePoint)) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: fitted-window seed %d does not have exactly one arrival and " + "departure mouth."), Seed)); + bPartAFittedChecksPassed = false; + continue; + } + + const FVerticalShaftParams& ShaftParams = + Layout[VerticalShaftsIndex].Definition->VerticalShaftParams; + if (!IsVerticalShaftAxis( + ShaftParams, Seed, ResolutionArrivalPoint) + || !IsVerticalShaftAxis(ShaftParams, Seed, ResolutionDeparturePoint)) + { + 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)."), + Seed, + IsVerticalShaftAxis(ShaftParams, Seed, ResolutionArrivalPoint) + ? TEXT("yes") : TEXT("no"), + IsVerticalShaftAxis(ShaftParams, Seed, ResolutionDeparturePoint) + ? TEXT("yes") : TEXT("no"))); + bPartAFittedChecksPassed = false; + } + + for (const int32 SampleStep : ResolutionSteps) + { + const FVoxelStrateMeasureSettings FittedSettings = MakeFittedSettings( + SampleStep, + ResolutionArrivalPoint, + ResolutionDeparturePoint, + FittedMargin, + FittedMaxCells); + const FVoxelStrateMetrics Metrics = VF_MeasureStrate( + *ResolutionWorld.Generator, + *ResolutionWorld.StrateManager, + VerticalShaftsIndex, + FittedSettings); + const FVoxelConnectivityDiagnostics Diagnostics = VF_DiagnoseConnectivity( + *ResolutionWorld.Generator, + *ResolutionWorld.StrateManager, + VerticalShaftsIndex, + ResolutionArrivalPoint, + ResolutionDeparturePoint, + FittedSettings); + + FString DoubledText = TEXT("not needed (default verdict is CONNECTED)"); + FString Interpretation = TEXT("NO GAP — CONNECTED"); + bool bDefaultNegative = Diagnostics.Result + == EVoxelConnectivityResult::NotConnectedAtThisResolution; + if (bDefaultNegative) + { + const FVoxelStrateMeasureSettings DoubledSettings = MakeFittedSettings( + SampleStep, + ResolutionArrivalPoint, + ResolutionDeparturePoint, + DoubledFittedMargin, + FittedMaxCells); + const FVoxelStrateMetrics DoubledMetrics = VF_MeasureStrate( + *ResolutionWorld.Generator, + *ResolutionWorld.StrateManager, + VerticalShaftsIndex, + DoubledSettings); + const FVoxelConnectivityDiagnostics DoubledDiagnostics = VF_DiagnoseConnectivity( + *ResolutionWorld.Generator, + *ResolutionWorld.StrateManager, + VerticalShaftsIndex, + ResolutionArrivalPoint, + ResolutionDeparturePoint, + DoubledSettings); + DoubledText = FString::Printf( + TEXT("margin=96: %s; cells=%lld"), + *FittedVerdict(DoubledMetrics, DoubledDiagnostics), + DoubledMetrics.NumSampled); + if (DoubledMetrics.bValid + && DoubledDiagnostics.Result == EVoxelConnectivityResult::Connected) + { + Interpretation = TEXT( + "NotConnectedAtThisResolution — window binding; doubled margin CONNECTED"); + } + else if (DoubledMetrics.bValid && DoubledDiagnostics.bValid) + { + Interpretation = TEXT( + "REAL GAP at this resolution — negative survives doubled margin"); + bPartAFittedChecksPassed = false; + } + else + { + Interpretation = TEXT( + "MEASUREMENT LIMIT — doubled-margin rerun was not usable"); + bPartAFittedChecksPassed = false; + } + } + else if (!Metrics.bValid || !Diagnostics.bValid + || Diagnostics.Result != EVoxelConnectivityResult::Connected + || Diagnostics.bStartSnapped || Diagnostics.bGoalSnapped) + { + Interpretation = TEXT("MEASUREMENT LIMIT — no trustworthy negative"); + bPartAFittedChecksPassed = false; + } + + if (!Metrics.bValid || !Diagnostics.bValid + || Diagnostics.bStartSnapped || Diagnostics.bGoalSnapped) + { + bPartAFittedChecksPassed = false; + } + + if (Seed == 6 && SampleStep == 1) + { + FVoxelStrateMeasureSettings OldSettings = BaseSettings; + OldSettings.SampleStep = 1; + OldSettings.RadiusInVoxels = 192; + OldSettings.MaxCells = BeforeChangeMaxCells; + OldSettings.InteriorMarginVoxels = -1; + const FVoxelStrateMetrics OldMetrics = VF_MeasureStrate( + *ResolutionWorld.Generator, + *ResolutionWorld.StrateManager, + VerticalShaftsIndex, + OldSettings); + const int64 ZCells = Metrics.SampledNumZ; + const int64 OldRequestedCells = static_cast(384) * 384 * ZCells; + const FString OldVerdict = OldMetrics.RefusalReason.Contains(TEXT("MaxCells")) + ? TEXT("EXCEEDS_MAXCELLS") + : FittedVerdict(OldMetrics, FVoxelConnectivityDiagnostics()); + Summary += FString::Printf( + TEXT(" seed 6 step 1 cell comparison: before centered square requested " + "%lld cells (384x384x%d), verdict=%s, reason='%s'; after fitted " + "window=%lld cells (%dx%dx%d), bounds X[%.3f,%.3f) Y[%.3f,%.3f).\n"), + OldRequestedCells, + Metrics.SampledNumZ, + *OldVerdict, + *OldMetrics.RefusalReason, + Metrics.NumSampled, + Metrics.SampledNumX, + Metrics.SampledNumY, + Metrics.SampledNumZ, + Metrics.SampledMinX, + Metrics.SampledMaxX, + Metrics.SampledMinY, + Metrics.SampledMaxY); + } + + const FString DefaultVerdict = FittedVerdict(Metrics, Diagnostics); + const FString ArrivalShare = Diagnostics.bValid + ? FString::Printf(TEXT("%.9g"), Diagnostics.StartComponentShare) + : TEXT("N/A"); + const FString DepartureShare = Diagnostics.bValid + ? FString::Printf(TEXT("%.9g"), Diagnostics.GoalComponentShare) + : TEXT("N/A"); + Summary += FString::Printf( + TEXT(" %d | %d | %s | %dx%dx%d/%lld | %s | %s | %s | %s\n"), + Seed, + SampleStep, + *DefaultVerdict, + Metrics.SampledNumX, + Metrics.SampledNumY, + Metrics.SampledNumZ, + Metrics.NumSampled, + *DoubledText, + *Interpretation, + *ArrivalShare, + *DepartureShare); + } + } + + Summary += TEXT( + "PART B — fitted 16-seed audit (step 2, margin 48): strict-local-minimum shafts are the " + "pre-fix orphan candidates; post-fix parent paths are checked with the same fixed ±2/±3 " + "resolver over a padded [-20,20] cell audit domain. Small-component threshold is one " + "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 margin 96 verdict/cells | " + "tree path XY bounds\n"); + + static constexpr int32 FittedSeedCases[] = { + 1337, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, + }; + int32 SweepDirectPasses = 0; + int32 SweepPasses = 0; + int32 SweepFailures = 0; + int32 SweepBindingNegatives = 0; + int32 SweepTrustedNegatives = 0; + int32 SweepMeasurementLimits = 0; + int64 TotalAuditShafts = 0; + int64 TotalStrictLocalMinima = 0; + int64 TotalNeighbourFallbacks = 0; + int64 TotalSpineWindowFallbacks = 0; + int64 TotalEmergencySpineFallbacks = 0; + int64 TotalPathOrphans = 0; + int64 TotalNonLargestComponents = 0; + int64 TotalSmallNonLargestComponents = 0; + int32 SmallComponentThresholdMin = INT32_MAX; + int32 SmallComponentThresholdMax = 0; + bool bAllAuditOrdersIndependent = true; + const double FittedSweepStartSeconds = FPlatformTime::Seconds(); + for (const int32 Seed : FittedSeedCases) + { + FTestWorld SeedWorld; + SeedWorld.Build(Seed, /*InGapChunks=*/2); + if (!SeedWorld.IsValid()) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: fitted audit seed %d could not build the fixture."), Seed)); + bAllChecksPassed = false; + ++SweepFailures; + continue; + } + + FVector SeedArrivalPoint = FVector::ZeroVector; + FVector SeedDeparturePoint = FVector::ZeroVector; + if (!FindChainMouths( + SeedWorld.StrateManager->GetPassages(), + VerticalShaftsIndex, + SeedArrivalPoint, + SeedDeparturePoint)) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: fitted audit seed %d does not have exactly one chain mouth " + "pair."), Seed)); + bAllChecksPassed = false; + ++SweepFailures; + continue; + } + + const FVerticalShaftParams& ShaftParams = + SeedWorld.StrateManager->GetLayout()[VerticalShaftsIndex].Definition->VerticalShaftParams; + const int32 ShaftMidChunkZ = SeedWorld.StrateManager->GetLayout()[VerticalShaftsIndex].BottomChunkZ + + (SeedWorld.StrateManager->GetLayout()[VerticalShaftsIndex].TopChunkZ + - SeedWorld.StrateManager->GetLayout()[VerticalShaftsIndex].BottomChunkZ) / 2; + const FVerticalShaftParams RuntimeShaftParams = + SeedWorld.StrateManager->GetVerticalShaftParamsForChunk( + FIntVector(0, 0, ShaftMidChunkZ)); + const bool bAxisMouths = IsVerticalShaftAxis(ShaftParams, Seed, SeedArrivalPoint) + && IsVerticalShaftAxis(ShaftParams, Seed, SeedDeparturePoint); + const FVoxelStrateMeasureSettings FittedSettings = MakeFittedSettings( + 2, + SeedArrivalPoint, + SeedDeparturePoint, + FittedMargin, + BaseSettings.MaxCells); + const FVoxelStrateMetrics Metrics = VF_MeasureStrate( + *SeedWorld.Generator, + *SeedWorld.StrateManager, + VerticalShaftsIndex, + FittedSettings); + const FVoxelConnectivityDiagnostics Diagnostics = VF_DiagnoseConnectivity( + *SeedWorld.Generator, + *SeedWorld.StrateManager, + VerticalShaftsIndex, + SeedArrivalPoint, + SeedDeparturePoint, + FittedSettings); + const FVerticalShaftTreeAudit Audit = AuditVerticalShaftTree(ShaftParams, Seed); + TotalAuditShafts += Audit.NumShafts; + TotalStrictLocalMinima += Audit.NumStrictLocalMinima; + TotalNeighbourFallbacks += Audit.NumNeighbourFallbacks; + TotalSpineWindowFallbacks += Audit.NumSpineWindowFallbacks; + TotalEmergencySpineFallbacks += Audit.NumEmergencySpineFallbacks; + TotalPathOrphans += Audit.NumUnreachable; + bAllAuditOrdersIndependent &= Audit.bOrderIndependent; + + int32 ShaftVolumeCells = 1; + if (Metrics.bValid) + { + const double MeanRadius = 0.5 * static_cast( + ShaftParams.ShaftMinRadius + ShaftParams.ShaftMaxRadius); + const double InteriorHeight = static_cast( + Metrics.SampledMaxZ - Metrics.SampledMinZ); + const double CoarseCellVolume = FMath::Max( + 1.0, + PI * MeanRadius * MeanRadius * InteriorHeight / 8.0); + ShaftVolumeCells = FMath::Clamp( + FMath::CeilToInt(CoarseCellVolume), 1, INT32_MAX); + int32 NumNonLargest = 0; + int32 NumSmallNonLargest = 0; + ComponentNoiseCounts( + Metrics, + ShaftVolumeCells, + NumNonLargest, + NumSmallNonLargest); + TotalNonLargestComponents += NumNonLargest; + TotalSmallNonLargestComponents += NumSmallNonLargest; + } + SmallComponentThresholdMin = FMath::Min( + SmallComponentThresholdMin, ShaftVolumeCells); + SmallComponentThresholdMax = FMath::Max( + SmallComponentThresholdMax, ShaftVolumeCells); + + if (Metrics.bValid && Metrics.AirComponentCells.Num() != Metrics.NumAirComponents) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: fitted audit seed %d component-size list has %d entries for " + "%d components."), + Seed, + Metrics.AirComponentCells.Num(), + Metrics.NumAirComponents)); + bAllChecksPassed = false; + } + + const bool bCountsAsDirectPass = Metrics.bValid + && Diagnostics.bValid + && Diagnostics.Result == EVoxelConnectivityResult::Connected + && !Diagnostics.bStartSnapped + && !Diagnostics.bGoalSnapped + && bAxisMouths; + bool bCountsAsPass = bCountsAsDirectPass; + FString DoubledSweepText = TEXT("not-run"); + FString TreePathText = TEXT("not-needed"); + if (!bCountsAsPass + && Diagnostics.Result == EVoxelConnectivityResult::NotConnectedAtThisResolution) + { + const FVoxelStrateMeasureSettings DoubledSettings = MakeFittedSettings( + 2, + SeedArrivalPoint, + SeedDeparturePoint, + DoubledFittedMargin, + FittedMaxCells); + const FVoxelStrateMetrics DoubledMetrics = VF_MeasureStrate( + *SeedWorld.Generator, + *SeedWorld.StrateManager, + VerticalShaftsIndex, + DoubledSettings); + const FVoxelConnectivityDiagnostics DoubledDiagnostics = VF_DiagnoseConnectivity( + *SeedWorld.Generator, + *SeedWorld.StrateManager, + VerticalShaftsIndex, + SeedArrivalPoint, + SeedDeparturePoint, + DoubledSettings); + DoubledSweepText = FString::Printf( + TEXT("%s/%lld"), + *FittedVerdict(DoubledMetrics, DoubledDiagnostics), + DoubledMetrics.NumSampled); + FVector2D PathMin = FVector2D::ZeroVector; + FVector2D PathMax = FVector2D::ZeroVector; + int32 PathHops = 0; + const bool bArrivalPath = AuditVerticalShaftPathBounds( + ShaftParams, Seed, SeedArrivalPoint, PathMin, PathMax, PathHops); + FVector2D DeparturePathMin = FVector2D::ZeroVector; + FVector2D DeparturePathMax = FVector2D::ZeroVector; + int32 DeparturePathHops = 0; + const bool bDeparturePath = AuditVerticalShaftPathBounds( + ShaftParams, + Seed, + SeedDeparturePoint, + DeparturePathMin, + DeparturePathMax, + DeparturePathHops); + if (bArrivalPath && bDeparturePath) + { + const FVector2D CombinedMin( + FMath::Min(PathMin.X, DeparturePathMin.X), + FMath::Min(PathMin.Y, DeparturePathMin.Y)); + const FVector2D CombinedMax( + FMath::Max(PathMax.X, DeparturePathMax.X), + FMath::Max(PathMax.Y, DeparturePathMax.Y)); + TreePathText = FString::Printf( + TEXT("[%.1f,%.1f]-[%.1f,%.1f],hops=%d/%d"), + CombinedMin.X, + CombinedMin.Y, + CombinedMax.X, + CombinedMax.Y, + PathHops, + DeparturePathHops); + } + if (DoubledMetrics.bValid + && DoubledDiagnostics.Result == EVoxelConnectivityResult::Connected) + { + ++SweepBindingNegatives; + // A negative that disappears only after the mandated doubled-margin rerun is a + // binding-window result, not a connectivity finding. Keep the direct verdict in + // the per-seed row, but count the seed as acceptance-passing at the trustworthy + // window size. + bCountsAsPass = true; + } + else if (DoubledMetrics.bValid && DoubledDiagnostics.bValid) + { + ++SweepTrustedNegatives; + } + else + { + ++SweepMeasurementLimits; + } + } + else if (!bCountsAsPass) + { + ++SweepMeasurementLimits; + } + + if (bCountsAsDirectPass) + { + ++SweepDirectPasses; + } + + if (bCountsAsPass) + { + ++SweepPasses; + } + else + { + ++SweepFailures; + } + + const int32 NumNonLargest = Metrics.bValid + ? [&]() + { + int32 Value = 0; + int32 IgnoredSmall = 0; + ComponentNoiseCounts(Metrics, ShaftVolumeCells, Value, IgnoredSmall); + return Value; + }() + : 0; + const int32 NumSmallNonLargest = Metrics.bValid + ? [&]() + { + int32 IgnoredNonLargest = 0; + int32 Value = 0; + ComponentNoiseCounts(Metrics, ShaftVolumeCells, IgnoredNonLargest, Value); + return Value; + }() + : 0; + Summary += FString::Printf( + TEXT(" %d | %s | %lld | %d | %d/%d | %d/%d | %d | %d | %s | %s | %s\n"), + Seed, + *FittedVerdict(Metrics, Diagnostics), + Metrics.NumSampled, + Metrics.NumAirComponents, + NumSmallNonLargest, + NumNonLargest, + Audit.NumStrictLocalMinima, + Audit.NumShafts, + Audit.NumNeighbourFallbacks, + Audit.NumUnreachable, + bAxisMouths ? TEXT("YES") : TEXT("NO"), + *DoubledSweepText, + *TreePathText); + + if (Seed == 1337 && Metrics.bValid) + { + const FVerticalShaftPhysicalAudit ArrivalPhysical = + AuditVerticalShaftPathDensity( + *SeedWorld.Generator, RuntimeShaftParams, Seed, SeedArrivalPoint); + const FVerticalShaftPhysicalAudit DeparturePhysical = + AuditVerticalShaftPathDensity( + *SeedWorld.Generator, RuntimeShaftParams, Seed, SeedDeparturePoint); + const FVerticalShaftPhysicalAudit& FirstBadPhysical = + ArrivalPhysical.NumNonAirSamples > 0 ? ArrivalPhysical : DeparturePhysical; + Summary += FString::Printf( + TEXT(" seed 1337 window/mouth audit: window X[%.3f,%.3f) Y[%.3f,%.3f); " + "arrival=(%.3f,%.3f) departure=(%.3f,%.3f); physical path bad edges " + "%d/%d (arrival) + %d/%d (departure), non-air samples=%d/%d, " + "first bad density=%.6g at (%.3f,%.3f,%.3f), edge " + "(%.3f,%.3f)->(%.3f,%.3f), t=%.2f.\n"), + Metrics.SampledMinX, + Metrics.SampledMaxX, + Metrics.SampledMinY, + Metrics.SampledMaxY, + SeedArrivalPoint.X, + SeedArrivalPoint.Y, + SeedDeparturePoint.X, + SeedDeparturePoint.Y, + ArrivalPhysical.NumBadEdges, + ArrivalPhysical.NumEdges, + DeparturePhysical.NumBadEdges, + DeparturePhysical.NumEdges, + ArrivalPhysical.NumNonAirSamples + DeparturePhysical.NumNonAirSamples, + ArrivalPhysical.NumSamples + DeparturePhysical.NumSamples, + ArrivalPhysical.NumNonAirSamples > 0 + ? ArrivalPhysical.FirstBadDensity : DeparturePhysical.FirstBadDensity, + ArrivalPhysical.NumNonAirSamples > 0 + ? ArrivalPhysical.FirstBadX : DeparturePhysical.FirstBadX, + ArrivalPhysical.NumNonAirSamples > 0 + ? ArrivalPhysical.FirstBadY : DeparturePhysical.FirstBadY, + ArrivalPhysical.NumNonAirSamples > 0 + ? ArrivalPhysical.FirstBadZ : DeparturePhysical.FirstBadZ, + FirstBadPhysical.FirstBadChildX, + FirstBadPhysical.FirstBadChildY, + FirstBadPhysical.FirstBadParentX, + FirstBadPhysical.FirstBadParentY, + FirstBadPhysical.FirstBadFraction); + } + + if (Audit.NumUnreachable != 0 || !Audit.bOrderIndependent || !bAxisMouths) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: fitted audit seed %d violated the shaft-tree/landing " + "invariant (unreachable=%d, order-independent=%s, axis-mouths=%s)."), + Seed, + Audit.NumUnreachable, + Audit.bOrderIndependent ? TEXT("yes") : TEXT("no"), + bAxisMouths ? TEXT("yes") : TEXT("no"))); + bAllChecksPassed = false; + } + } + const double FittedSweepSeconds = FPlatformTime::Seconds() - FittedSweepStartSeconds; + + const double StrictLocalMinimumRate = TotalAuditShafts > 0 + ? static_cast(TotalStrictLocalMinima) + / static_cast(TotalAuditShafts) + : 0.0; + const double OrphanRate = TotalAuditShafts > 0 + ? static_cast(TotalPathOrphans) + / static_cast(TotalAuditShafts) + : 0.0; + const double SmallComponentRate = TotalNonLargestComponents > 0 + ? static_cast(TotalSmallNonLargestComponents) + / static_cast(TotalNonLargestComponents) + : 0.0; + Summary += FString::Printf( + TEXT(" tree audit aggregate: strict-local-minimum candidates=%lld/%lld (%.6f%%); " + "neighbour fallback=%lld; direct spine-window fallback=%lld; emergency direct-spine " + "fallback=%lld; post-fix path orphans=%lld/%lld (%.6f%%); array-order checks=%s; " + "max path length observed is reported per implementation invariant.\n"), + TotalStrictLocalMinima, + TotalAuditShafts, + StrictLocalMinimumRate * 100.0, + TotalNeighbourFallbacks, + TotalSpineWindowFallbacks, + TotalEmergencySpineFallbacks, + TotalPathOrphans, + TotalAuditShafts, + OrphanRate * 100.0, + bAllAuditOrdersIndependent ? TEXT("PASS") : TEXT("FAIL")); + Summary += FString::Printf( + TEXT(" roughness-bubble proxy: non-largest components smaller than one mean-radius shaft " + "= %lld/%lld (%.6f%%), threshold range=%d..%d coarse cells.\n"), + TotalSmallNonLargestComponents, + TotalNonLargestComponents, + SmallComponentRate * 100.0, + SmallComponentThresholdMin == INT32_MAX ? 0 : SmallComponentThresholdMin, + SmallComponentThresholdMax); + Summary += FString::Printf( + TEXT(" fitted acceptance arrival->departure: %s (%d/%d effective pass; direct margin48=" + "%d/%d, %d fail; binding negatives=%d, trusted negatives=%d, measurement limits=%d); " + "sweep wall-clock=%.3f seconds.\n"), + SweepPasses == UE_ARRAY_COUNT(FittedSeedCases) && SweepFailures == 0 + ? TEXT("PASS") : TEXT("FAIL"), + SweepPasses, + UE_ARRAY_COUNT(FittedSeedCases), + SweepDirectPasses, + UE_ARRAY_COUNT(FittedSeedCases), + SweepFailures, + SweepBindingNegatives, + SweepTrustedNegatives, + SweepMeasurementLimits, + FittedSweepSeconds); + + if (TotalPathOrphans != 0 || !bAllAuditOrdersIndependent) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: VerticalShafts tree audit found %lld orphan paths across the " + "16-seed sweep (orphan rate %.9g%%; order-independent=%s)."), + TotalPathOrphans, + OrphanRate * 100.0, + bAllAuditOrdersIndependent ? TEXT("yes") : TEXT("no"))); + bAllChecksPassed = false; + } + if (SweepPasses != UE_ARRAY_COUNT(FittedSeedCases) || SweepFailures != 0) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: fitted VerticalShafts effective acceptance was %d/%d, not 16/16."), + SweepPasses, + UE_ARRAY_COUNT(FittedSeedCases))); + bAllChecksPassed = false; + } + bAllChecksPassed &= bPartAFittedChecksPassed; + AddInfo(Summary); return bAllChecksPassed; diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index bbb7e3d..ddb678c 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -672,8 +672,8 @@ namespace (MaxLateralSnap + FMath::Max(Params.ShaftMaxRadius, 1.0f)) / Spacing) + 2; float BestDistSq = FLT_MAX; - float BestX = 0.0f; - float BestY = 0.0f; + float BestAxisX = 0.0f; + float BestAxisY = 0.0f; bool bFound = false; int32 BestCellX = INT32_MAX; int32 BestCellY = INT32_MAX; @@ -711,17 +711,12 @@ namespace continue; } - if (Distance <= SafeRadius || Distance <= KINDA_SMALL_NUMBER) - { - BestX = WorldX; - BestY = WorldY; - } - else - { - const float Scale = SafeRadius / Distance; - BestX = ShaftX + DXWorld * Scale; - BestY = ShaftY + DYWorld * Scale; - } + // 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. + BestAxisX = ShaftX; + BestAxisY = ShaftY; BestDistSq = DistSq; BestCellX = CellX; BestCellY = CellY; @@ -729,13 +724,13 @@ namespace } } - if (!bFound || BestDistSq > MaxSnapSq || !FMath::IsFinite(BestX) - || !FMath::IsFinite(BestY)) + if (!bFound || BestDistSq > MaxSnapSq || !FMath::IsFinite(BestAxisX) + || !FMath::IsFinite(BestAxisY)) { return false; } - OutPoint = FVector(BestX, BestY, LandingZ); + OutPoint = FVector(BestAxisX, BestAxisY, LandingZ); return !OutPoint.ContainsNaN() && FMath::IsFinite(OutPoint.X) && FMath::IsFinite(OutPoint.Y) && FMath::IsFinite(OutPoint.Z); } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 47b6a4f..4c4fa9f 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -169,6 +169,42 @@ namespace return (Sdf < DetailThreshold) && (Sdf < FLT_MAX); } + // Structural tree capsules must meet shaft axes without landing inside a ledge band. This is + // the same pure helper as the generator path: one deterministic safe interval is shared by the + // whole strate, so a link cannot be blocked when it crosses an unrelated shaft's ledge. + FORCEINLINE float VF_SelectVerticalTreeConnectorZ( + const FVerticalShaftParams& Params, + float BottomZ, + float TopZ, + uint32 LinkHash) + { + if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f) + { + const float Period = Params.LedgeSpacing; + const float RelativeBottom = BottomZ - Params.StrateBottomWorldZ; + const float RelativeTop = TopZ - Params.StrateBottomWorldZ; + const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / Period); + const float Random01 = VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)); + + for (int32 PeriodOffset = 0; PeriodOffset <= 1; ++PeriodOffset) + { + const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) * Period; + const float SafeStart = FMath::Max( + RelativeBottom, PeriodStart + Params.LedgeDepth + 0.01f); + const float SafeEnd = FMath::Min( + RelativeTop, PeriodStart + Period - Params.LedgeDepth - 0.01f); + if (SafeEnd > SafeStart) + { + return Params.StrateBottomWorldZ + + FMath::Lerp(SafeStart, SafeEnd, Random01); + } + } + } + + return FMath::Lerp(BottomZ, TopZ, + VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + } + //========================================================================= // RÔLE 1 — SOURCE : CHAMP CONSTANT / CONSTANT FIELD (roc ET vide) //========================================================================= @@ -1520,7 +1556,8 @@ namespace // séparée aurait dû faire aussi. À revoir si le profil montre que ça compte. // // Kept as ONE op against §6's suggestion: the per-voxel connectors derive from the same inner - // 3×3 roll as the shafts, while a rebuild-only 7×7 collection resolves each 5×5 tree window; + // 3×3 roll as the shafts, while a rebuild-only 9×9 collection resolves each 5×5 tree window + // and fixed ±3 fallback; // the downstream ledge mod needs the inner shaft list anyway. What is forfeited is an exact XY // box verdict on the cylinder half alone. class FShaftFieldSource final : public IVoxelDensityOp @@ -1546,7 +1583,7 @@ namespace struct FCells { TArray> Shafts; - TArray> Conns; + TArray> Conns; }; void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -1612,21 +1649,37 @@ namespace // it may over-enumerate, but it must never miss a cached tree capsule. //----------------------------------------------------------------- constexpr int32 CandidateRadius = 2; + constexpr int32 FallbackRadius = 3; constexpr uint32 TreeSalt = 0x7A11u; - const int32 EmitX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1; - const int32 EmitX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1; - const int32 EmitY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1; - const int32 EmitY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1; + const float TreeRadius = SpineConnectorRadius(); + const int32 ConnectorCellPad = FMath::Max( + 1, FMath::CeilToInt(TreeRadius / Spacing)); + const int32 TreeEmitRadius = FallbackRadius + ConnectorCellPad; + const int32 QueryX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1; + const int32 QueryX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1; + const int32 QueryY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1; + const int32 QueryY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1; + const int32 TreeEmitX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) + - TreeEmitRadius; + const int32 TreeEmitX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + + TreeEmitRadius; + const int32 TreeEmitY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) + - TreeEmitRadius; + const int32 TreeEmitY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + + TreeEmitRadius; - TArray> TreeCandidates; - TArray> TreeEmit; - for (int32 cy = EmitY0 - CandidateRadius; cy <= EmitY1 + CandidateRadius; ++cy) - for (int32 cx = EmitX0 - CandidateRadius; cx <= EmitX1 + CandidateRadius; ++cx) + TArray> TreeCandidates; + TArray> TreeEmit; + for (int32 cy = TreeEmitY0 - FallbackRadius; + cy <= TreeEmitY1 + FallbackRadius; ++cy) + for (int32 cx = TreeEmitX0 - FallbackRadius; + cx <= TreeEmitX1 + FallbackRadius; ++cx) { FShaft Sh; if (!RollShaft(cx, cy, Sh)) { continue; } TreeCandidates.Add(Sh); - if (cx >= EmitX0 && cx <= EmitX1 && cy >= EmitY0 && cy <= EmitY1) + if (cx >= TreeEmitX0 && cx <= TreeEmitX1 + && cy >= TreeEmitY0 && cy <= TreeEmitY1) { TreeEmit.Add(Sh); } @@ -1634,7 +1687,6 @@ namespace const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; - const float TreeRadius = SpineConnectorRadius(); const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z; const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X), 0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y)); @@ -1689,14 +1741,58 @@ namespace } } + if (Parent == nullptr + && !(FMath::Abs(Child.CellX) <= CandidateRadius + && FMath::Abs(Child.CellY) <= CandidateRadius)) + { + // Keep EffectOverBox's parent resolver identical to GetCells. A local + // minimum chooses the nearest lower-origin shaft in the collected halo; the + // monotone origin-distance key proves that the resulting tree reaches the + // spine and cannot cycle. If the finite halo is empty, the direct spine is the + // conservative final fallback. + for (const FShaft& Candidate : TreeCandidates) + { + if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY) + { + continue; + } + if (FMath::Abs(Candidate.CellX - Child.CellX) > FallbackRadius + || FMath::Abs(Candidate.CellY - Child.CellY) > FallbackRadius) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate.X) + + FMath::Square(Candidate.Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate.X) + + FMath::Square(Child.Y - Candidate.Y); + const bool bLowerCell = Parent == nullptr + || Candidate.CellY < Parent->CellY + || (Candidate.CellY == Parent->CellY + && Candidate.CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = &Candidate; + } + } + } + const int32 ParentCellX = Parent != nullptr ? Parent->CellX : 0; const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0; const uint32 LinkHash = VoxelHash::Pair( Child.CellX, Child.CellY, ParentCellX, ParentCellY, Salt ^ TreeSalt); - const float Zc = FMath::Lerp( + const float Zc = VF_SelectVerticalTreeConnectorZ( + P, P.StrateBottomWorldZ + P.BoundarySealThickness, P.StrateTopWorldZ - P.BoundarySealThickness, - VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + LinkHash); const FVector ParentPoint = Parent != nullptr ? FVector(Parent->X, Parent->Y, Zc) : FVector(0.0f, 0.0f, Zc); @@ -1721,7 +1817,7 @@ namespace // du voisinage inner 3×3 de la cellule DE SA REQUÊTE. Une paire random visible depuis // un point de la boîte a donc ses deux puits dans [cellules de la boîte] ± 1, qui est // exactement la plage balayée ici. Les connecteurs de l'arbre sont testés séparément - // au-dessus avec leur collecte 7×7/émission inner 3×3. On peut produire des paires que + // au-dessus avec leur collecte élargie/émission du halo géométrique. On peut produire des paires que // personne ne voit jamais : c'est du `CarveOnly` en trop, pas un trou. // // ⚠️ ET POURQUOI L'ORDRE (A,B) EST LE MÊME QUE CELUI DE `GetCells`. Le hash de paire est @@ -1738,10 +1834,10 @@ namespace // insertion order, so each pair gets the same hash without assuming Pair() is symmetric. if (P.CrossConnectChance > 0.0f) { - const int32 QX0 = EmitX0; - const int32 QX1 = EmitX1; - const int32 QY0 = EmitY0; - const int32 QY1 = EmitY1; + const int32 QX0 = QueryX0; + const int32 QX1 = QueryX1; + const int32 QY0 = QueryY0; + const int32 QY1 = QueryY1; TArray> Near; for (int32 cy = QY0; cy <= QY1; ++cy) @@ -1825,7 +1921,7 @@ namespace } /** Le voisinage inner 3×3 + ses connecteurs, mémoïsés par worker. Le rebuild collecte un - * 7×7 temporaire pour résoudre les fenêtres parent 5×5. Clé = cellule + tous les params + * halo 9×9 émetteur / 15×15 total pour résoudre les fenêtres parent 5×5 et le fallback fixe ±3. Clé = cellule + tous les params * qui influent (comme l'original) : stable à travers les reconstructions de pile, ce qui * est la leçon retenue du mémo de colonne de SurfaceWorld. */ const FCells& GetCells(float WorldX, float WorldY) const @@ -1858,35 +1954,80 @@ namespace Cache.Shafts.Reset(); Cache.Conns.Reset(); - constexpr int32 CollectRadius = 3; // 7×7: inner 3×3 + one 5×5 candidate halo - constexpr int32 EmitRadius = 1; // 3×3: the only cells allowed to emit tree links + constexpr int32 EmitRadius = 1; // 3×3: shaft cylinders and random links constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft + constexpr int32 FallbackRadius = 3; // fixed fallback window around an emitting shaft constexpr uint32 TreeSalt = 0x7A11u; - TArray> CollectedShafts; + const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; + const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; + const float TreeRadius = SpineConnectorRadius(); + // A tree capsule can affect a cell even when neither endpoint is in that cell. + // Keep a rebuild-only child halo for that geometric reach, then add the + // parent-search halo. With the defaults this is 4 + 3 cells = 15×15 rolls. + const int32 ConnectorCellPad = FMath::Max( + 1, FMath::CeilToInt(TreeRadius / Spacing)); + const int32 TreeEmitRadius = FallbackRadius + ConnectorCellPad; + const int32 CollectRadius = TreeEmitRadius + FallbackRadius; + + const int32 CollectSide = CollectRadius * 2 + 1; + TArray> ShaftGrid; + TArray> ShaftPresent; + TArray> TreeEmitShafts; + ShaftGrid.SetNum(CollectSide * CollectSide); + ShaftPresent.Init(0, CollectSide * CollectSide); // Rebuild-only wide collection. `Cache.Shafts` remains the inner 3×3 working set - // used by Eval and the ledge modifier, so the 7×7 cost is not paid per voxel. + // used by Eval and the ledge modifier; the wider tree work is not paid per voxel. for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++) for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++) { FShaft Sh; if (!RollShaft(CX + dx, CY + dy, Sh)) { continue; } - CollectedShafts.Add(Sh); + const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius); + ShaftGrid[GridIndex] = Sh; + ShaftPresent[GridIndex] = 1; if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius) { Cache.Shafts.Add(Sh); } + if (FMath::Abs(dx) <= TreeEmitRadius && FMath::Abs(dy) <= TreeEmitRadius) + { + TreeEmitShafts.Add(Sh); + } } + auto FindCollectedShaft = [&](int32 CellX, int32 CellY) -> const FShaft* + { + const int32 dx = CellX - CX; + const int32 dy = CellY - CY; + if (FMath::Abs(dx) > CollectRadius || FMath::Abs(dy) > CollectRadius) + { + return nullptr; + } + const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius); + return ShaftPresent[GridIndex] ? &ShaftGrid[GridIndex] : nullptr; + }; + const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1; if (bOriginNearby && SpineRadius > 0.0f) { Cache.Shafts.Add({0.0f, 0.0f, SpineRadius, 0, 0, true}); } - const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; - const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; - const float TreeRadius = SpineConnectorRadius(); + const float CellMinX = static_cast(CX) * Spacing; + const float CellMaxX = static_cast(CX + 1) * Spacing; + const float CellMinY = static_cast(CY) * Spacing; + const float CellMaxY = static_cast(CY + 1) * Spacing; + auto ConnectorMayReachCell = [&](const FConn& Conn) + { + const float MinX = FMath::Min(Conn.A.X, Conn.B.X) - Conn.Radius; + const float MaxX = FMath::Max(Conn.A.X, Conn.B.X) + Conn.Radius; + const float MinY = FMath::Min(Conn.A.Y, Conn.B.Y) - Conn.Radius; + const float MaxY = FMath::Max(Conn.A.Y, Conn.B.Y) + Conn.Radius; + const float GapX = FMath::Max3(CellMinX - MaxX, MinX - CellMaxX, 0.0f); + const float GapY = FMath::Max3(CellMinY - MaxY, MinY - CellMaxY, 0.0f); + return GapX == 0.0f && GapY == 0.0f; + }; auto EmitTreeConnector = [&](const FShaft& Child, const FShaft* Parent) { @@ -1894,55 +2035,103 @@ namespace const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0; const uint32 LinkHash = VoxelHash::Pair( Child.CellX, Child.CellY, ParentCellX, ParentCellY, Salt ^ TreeSalt); - const float Zc = FMath::Lerp( - BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + const float Zc = VF_SelectVerticalTreeConnectorZ( + P, BottomZ, TopZ, LinkHash); const FVector ParentPoint = Parent != nullptr ? FVector(Parent->X, Parent->Y, Zc) : FVector(0.0f, 0.0f, Zc); - Cache.Conns.Add({ FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius }); + const FConn Conn{ + FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius }; + if (ConnectorMayReachCell(Conn)) + { + Cache.Conns.Add(Conn); + } }; - // Structural drainage tree. Every real shaft emitted by this cache chooses the - // nearest strictly more-central shaft in its complete 5×5 window; a local minimum - // drains to the spine. The explicit row-major cell tie-break is independent of - // array iteration order, and the synthetic spine is not a candidate shaft. - for (const FShaft& Child : Cache.Shafts) + // Structural drainage tree. Every real shaft in the rebuild-only child window + // first chooses the nearest strictly more-central shaft in its complete 5×5 + // window. A local minimum falls back to the nearest lower-origin shaft in the + // deterministic halo; a window touching the origin uses the spine directly, with + // a final direct-spine fallback for an unusually empty finite halo. Every parent + // edge therefore either strictly decreases origin distance or terminates at the + // spine: no cycles and no orphans. Spatial culling makes the same edge visible + // throughout its capsule, independent of which cache cell evaluates it. + for (const FShaft& Child : TreeEmitShafts) { if (Child.bOriginSpine) continue; const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); const FShaft* Parent = nullptr; float BestDistanceSq = FLT_MAX; - for (const FShaft& Candidate : CollectedShafts) + for (int32 dy = -CandidateRadius; dy <= CandidateRadius; ++dy) + for (int32 dx = -CandidateRadius; dx <= CandidateRadius; ++dx) { - if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY) - { - continue; - } - if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius - || FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius) + const FShaft* Candidate = FindCollectedShaft( + Child.CellX + dx, Child.CellY + dy); + if (Candidate == nullptr + || (Candidate->CellX == Child.CellX && Candidate->CellY == Child.CellY)) { continue; } - const float CandidateOriginSq = FMath::Square(Candidate.X) - + FMath::Square(Candidate.Y); + const float CandidateOriginSq = FMath::Square(Candidate->X) + + FMath::Square(Candidate->Y); if (!(CandidateOriginSq < ChildOriginSq)) { continue; } - const float DistanceSq = FMath::Square(Child.X - Candidate.X) - + FMath::Square(Child.Y - Candidate.Y); + const float DistanceSq = FMath::Square(Child.X - Candidate->X) + + FMath::Square(Child.Y - Candidate->Y); const bool bLowerCell = Parent == nullptr - || Candidate.CellY < Parent->CellY - || (Candidate.CellY == Parent->CellY - && Candidate.CellX < Parent->CellX); + || Candidate->CellY < Parent->CellY + || (Candidate->CellY == Parent->CellY + && Candidate->CellX < Parent->CellX); if (DistanceSq < BestDistanceSq || (DistanceSq == BestDistanceSq && bLowerCell)) { BestDistanceSq = DistanceSq; - Parent = &Candidate; + Parent = Candidate; + } + } + + if (Parent == nullptr + && !(FMath::Abs(Child.CellX) <= CandidateRadius + && FMath::Abs(Child.CellY) <= CandidateRadius)) + { + // Specified neighbour-shaft fallback. The lower-origin restriction is the + // proof that this fallback cannot create a cycle. + for (int32 dy = -FallbackRadius; dy <= FallbackRadius; ++dy) + for (int32 dx = -FallbackRadius; dx <= FallbackRadius; ++dx) + { + const FShaft* Candidate = FindCollectedShaft( + Child.CellX + dx, Child.CellY + dy); + if (Candidate == nullptr + || (Candidate->CellX == Child.CellX + && Candidate->CellY == Child.CellY)) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate->X) + + FMath::Square(Candidate->Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate->X) + + FMath::Square(Child.Y - Candidate->Y); + const bool bLowerCell = Parent == nullptr + || Candidate->CellY < Parent->CellY + || (Candidate->CellY == Parent->CellY + && Candidate->CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = Candidate; + } } } @@ -1950,7 +2139,7 @@ namespace } // Existing random links remain the texture/loop layer and stay on the inner 3×3 - // set. Only the deterministic parent lookup uses the 7×7 collection. + // set. Only the deterministic tree build uses the wider 9×9/15×15 collection. if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2) { for (int32 i = 0; i < Cache.Shafts.Num(); i++) @@ -1973,7 +2162,12 @@ namespace const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); const float ConnectorR = bSpineConnector ? SpineConnectorRadius() : P.ConnectorRadius; - Cache.Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }); + const FConn Conn{ + FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }; + if (ConnectorMayReachCell(Conn)) + { + Cache.Conns.Add(Conn); + } } } } @@ -2024,7 +2218,10 @@ namespace const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y); if (D2 < BestSq) { BestSq = D2; Near = &Sh; } } - if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 0.0f) + // The shaft axis is the structural tree's terminal point. Keep the mathematical + // half-plane boundary open: tiny cancellation error must not turn an exact axis + // landing into a solid ledge cap and sever the parent capsule. + if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 1.0e-3f) { const float Shelf = 1.0f - SmoothStep01(BandT / P.LedgeDepth); InOut.Density = FMath::Max(InOut.Density, Shelf * P.BaseDensity); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 5fe4c7e..869514c 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -226,6 +226,44 @@ static float CellularNoise3D(const FVector& Position) return VoxelNoise::Cellular3D(Position); } +// Structural tree capsules must meet shaft axes without landing inside a ledge band. A single +// deterministic safe interval is enough for the whole tree: every shaft in one strate shares the +// same ledge phase, and keeping every tree link in that interval also keeps crossings of unrelated +// shafts out of their additive ledges. The fallback is only for malformed settings with no safe +// interval; the stock params always take the proven branch. +static float VF_SelectVerticalTreeConnectorZ( + const FVerticalShaftParams& Params, + float BottomZ, + float TopZ, + uint32 LinkHash) +{ + if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f) + { + const float Period = Params.LedgeSpacing; + const float RelativeBottom = BottomZ - Params.StrateBottomWorldZ; + const float RelativeTop = TopZ - Params.StrateBottomWorldZ; + const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / Period); + const float Random01 = VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)); + + for (int32 PeriodOffset = 0; PeriodOffset <= 1; ++PeriodOffset) + { + const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) * Period; + const float SafeStart = FMath::Max( + RelativeBottom, PeriodStart + Params.LedgeDepth + 0.01f); + const float SafeEnd = FMath::Min( + RelativeTop, PeriodStart + Period - Params.LedgeDepth - 0.01f); + if (SafeEnd > SafeStart) + { + return Params.StrateBottomWorldZ + + FMath::Lerp(SafeStart, SafeEnd, Random01); + } + } + } + + return FMath::Lerp(BottomZ, TopZ, + VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); +} + //============================================================================= // DENSITY PIPELINE HELPERS (partagés entre TunnelNetwork et Slab) //============================================================================= @@ -3739,16 +3777,17 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const int32 CY = FMath::FloorToInt(WorldY / Spacing); // The shaft topology is pure, but it is deliberately split into two windows: - // * COLLECT 7×7 cells once during this thread-local cache rebuild. This is the source data - // needed to resolve every 5×5 parent window for the emitted cells below. - // * EMIT/EVALUATE only the INNER 3×3 cells. A shaft in that inner window has its whole - // 5×5 candidate window inside the collected 7×7, so two neighbouring evaluation cells - // compute the same parent for the same shaft. The 7×7 work is therefore rebuild-only; - // the per-voxel loop still sees at most the inner 3×3 shafts plus their cached capsules. + // * COLLECT a deterministic halo once during this thread-local cache rebuild. This is the + // source data needed to resolve every 5×5 parent window and the fixed ±3 fallback window. + // * EVALUATE only the INNER 3×3 shaft cylinders. Tree links are collected from a wider + // rebuild-only child window and culled to the current cell before the voxel loop sees + // them. That matters: a capsule from a child to a parent two or three cells away must be + // present in the cells along its segment, not only in the cache centred on the child. struct FLocalShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; }; struct FLocalConn { FVector A, B; float Radius; }; thread_local TArray> Shafts; - thread_local TArray> Conns; + thread_local TArray> Conns; + thread_local TArray> TreeEmitShafts; thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX; thread_local uint32 VS_Seed = 0xFFFFFFFFu; thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, VS_MaxR = -1.0f, @@ -3771,15 +3810,37 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float VS_Seal = Params.BoundarySealThickness; Shafts.Reset(); Conns.Reset(); + TreeEmitShafts.Reset(); - constexpr int32 CollectRadius = 3; // 7×7: inner 3×3 + one 5×5 candidate halo - constexpr int32 EmitRadius = 1; // 3×3: the only cells allowed to emit tree links + constexpr int32 EmitRadius = 1; // 3×3: shaft cylinders and random links constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft + constexpr int32 FallbackRadius = 3; // fixed fallback window around an emitting shaft constexpr uint32 TreeSalt = 0x7A11u; - TArray> CollectedShafts; + const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; + const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; + const float RoughnessReach = FMath::Max(Params.SurfaceRoughness, 0.0f) + * VOXEL_NOISE_SCALE * 1.5f; + // A tree link must remain open at the centreline after the SDF roughness pass. The +1 + // margin makes the radius strictly greater than the proven roughness supremum. + const float TreeConnectorRadius = FMath::Max(Params.ConnectorRadius, RoughnessReach + 1.0f); + // A tree capsule can affect a cell even when neither endpoint is in that cell. Keep a + // rebuild-only child halo for that geometric reach, then add the parent-search halo. The + // default is a 4-cell tree-emission radius + 3-cell collection pad = 7-cell radius + // (15×15 rolls; the emitted tree-child box itself is 9×9). + const int32 ConnectorCellPad = FMath::Max( + 1, FMath::CeilToInt(TreeConnectorRadius / Spacing)); + const int32 TreeEmitRadius = FallbackRadius + ConnectorCellPad; + const int32 CollectRadius = TreeEmitRadius + FallbackRadius; + + const int32 CollectSide = CollectRadius * 2 + 1; + TArray> ShaftGrid; + TArray> ShaftPresent; + ShaftGrid.SetNum(CollectSide * CollectSide); + ShaftPresent.Init(0, CollectSide * CollectSide); // Collect the wider deterministic neighbourhood once. Only the inner cells are retained - // in `Shafts`, because that is the per-voxel working set and the seam-free emission set. + // in `Shafts` for the per-voxel cylinder/ledge work; `TreeEmitShafts` is still rebuilt + // before evaluation and its connectors are spatially culled below. for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++) for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++) { @@ -3797,13 +3858,31 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float Sh.CellX = nx; Sh.CellY = ny; Sh.bOriginSpine = false; - CollectedShafts.Add(Sh); + const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius); + ShaftGrid[GridIndex] = Sh; + ShaftPresent[GridIndex] = 1; if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius) { Shafts.Add(Sh); } + if (FMath::Abs(dx) <= TreeEmitRadius && FMath::Abs(dy) <= TreeEmitRadius) + { + TreeEmitShafts.Add(Sh); + } } + auto FindCollectedShaft = [&](int32 CellX, int32 CellY) -> const FLocalShaft* + { + const int32 dx = CellX - CX; + const int32 dy = CellY - CY; + if (FMath::Abs(dx) > CollectRadius || FMath::Abs(dy) > CollectRadius) + { + return nullptr; + } + const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius); + return ShaftPresent[GridIndex] ? &ShaftGrid[GridIndex] : nullptr; + }; + // The structural (0,0) spine is not re-carved here. It participates only as a // connector endpoint. The local 3x3 set is origin-adjacent when its centre cell is in // the central 3x3; the structural post remains the sole owner of the vertical column. @@ -3813,13 +3892,20 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float Shafts.Add({0.0f, 0.0f, OriginSpineRadius, 0, 0, true}); } - const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; - const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; - const float RoughnessReach = FMath::Max(Params.SurfaceRoughness, 0.0f) - * VOXEL_NOISE_SCALE * 1.5f; - // A tree link must remain open at the centreline after the SDF roughness pass. The +1 - // margin makes the radius strictly greater than the proven roughness supremum. - const float TreeConnectorRadius = FMath::Max(Params.ConnectorRadius, RoughnessReach + 1.0f); + const float CellMinX = static_cast(CX) * Spacing; + const float CellMaxX = static_cast(CX + 1) * Spacing; + const float CellMinY = static_cast(CY) * Spacing; + const float CellMaxY = static_cast(CY + 1) * Spacing; + auto ConnectorMayReachCell = [&](const FLocalConn& Conn) + { + const float MinX = FMath::Min(Conn.A.X, Conn.B.X) - Conn.Radius; + const float MaxX = FMath::Max(Conn.A.X, Conn.B.X) + Conn.Radius; + const float MinY = FMath::Min(Conn.A.Y, Conn.B.Y) - Conn.Radius; + const float MaxY = FMath::Max(Conn.A.Y, Conn.B.Y) + Conn.Radius; + const float GapX = FMath::Max3(CellMinX - MaxX, MinX - CellMaxX, 0.0f); + const float GapY = FMath::Max3(CellMinY - MaxY, MinY - CellMaxY, 0.0f); + return GapX == 0.0f && GapY == 0.0f; + }; auto EmitTreeConnector = [&](const FLocalShaft& Child, const FLocalShaft* Parent) { @@ -3827,54 +3913,105 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0; const uint32 LinkHash = VoxelHash::Pair( Child.CellX, Child.CellY, ParentCellX, ParentCellY, S ^ TreeSalt); - const float Zc = FMath::Lerp(BottomZ, TopZ, - VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + const float Zc = VF_SelectVerticalTreeConnectorZ( + Params, BottomZ, TopZ, LinkHash); const FVector ParentPoint = Parent != nullptr ? FVector(Parent->X, Parent->Y, Zc) : FVector(0.0f, 0.0f, Zc); - Conns.Add({ FVector(Child.X, Child.Y, Zc), ParentPoint, TreeConnectorRadius }); + const FLocalConn Conn{ + FVector(Child.X, Child.Y, Zc), ParentPoint, TreeConnectorRadius }; + if (ConnectorMayReachCell(Conn)) + { + Conns.Add(Conn); + } }; - // Structural drainage tree: every real shaft in the inner 3×3 selects one strictly - // more-central shaft from its complete 5×5 window. A local minimum has no such parent and - // drains directly to the (0,0) spine. The explicit cell-coordinate tie-break makes this - // independent of array/iteration order; the synthetic spine is not a candidate shaft. - for (const FLocalShaft& Child : Shafts) + // Structural drainage tree: every real shaft in the rebuild-only child window first + // selects one strictly more-central shaft from its complete 5×5 window. If that window + // has a local minimum, select the nearest shaft in the collected deterministic halo whose + // origin distance is lower; that shaft is already on a strictly descending path by the + // same rule. A shaft whose window reaches the origin uses the spine directly, and the + // final direct-spine fallback covers an unusually empty finite halo. Thus every emitted + // parent edge either strictly decreases origin distance or terminates at the spine: no + // cycles and no orphans. Spatial culling makes the same edge visible throughout its + // capsule, independent of which cache cell is evaluating it. + // The explicit cell-coordinate tie-break makes this independent of array iteration order; + // the synthetic spine is not a candidate shaft. + for (const FLocalShaft& Child : TreeEmitShafts) { if (Child.bOriginSpine) continue; const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); const FLocalShaft* Parent = nullptr; float BestDistanceSq = FLT_MAX; - for (const FLocalShaft& Candidate : CollectedShafts) + for (int32 dy = -CandidateRadius; dy <= CandidateRadius; ++dy) + for (int32 dx = -CandidateRadius; dx <= CandidateRadius; ++dx) { - if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY) - { - continue; - } - if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius - || FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius) + const FLocalShaft* Candidate = FindCollectedShaft( + Child.CellX + dx, Child.CellY + dy); + if (Candidate == nullptr + || (Candidate->CellX == Child.CellX && Candidate->CellY == Child.CellY)) { continue; } - const float CandidateOriginSq = FMath::Square(Candidate.X) - + FMath::Square(Candidate.Y); + const float CandidateOriginSq = FMath::Square(Candidate->X) + + FMath::Square(Candidate->Y); if (!(CandidateOriginSq < ChildOriginSq)) { continue; } - const float DistanceSq = FMath::Square(Child.X - Candidate.X) - + FMath::Square(Child.Y - Candidate.Y); + const float DistanceSq = FMath::Square(Child.X - Candidate->X) + + FMath::Square(Child.Y - Candidate->Y); const bool bLowerCell = Parent == nullptr - || Candidate.CellY < Parent->CellY - || (Candidate.CellY == Parent->CellY && Candidate.CellX < Parent->CellX); + || Candidate->CellY < Parent->CellY + || (Candidate->CellY == Parent->CellY && Candidate->CellX < Parent->CellX); if (DistanceSq < BestDistanceSq || (DistanceSq == BestDistanceSq && bLowerCell)) { BestDistanceSq = DistanceSq; - Parent = &Candidate; + Parent = Candidate; + } + } + + if (Parent == nullptr + && !(FMath::Abs(Child.CellX) <= CandidateRadius + && FMath::Abs(Child.CellY) <= CandidateRadius)) + { + // This is the specified neighbour-shaft fallback. Restricting it to a lower + // origin distance makes the parent graph well-founded even though the fallback + // candidate need not be in the child's 5×5 window. + for (int32 dy = -FallbackRadius; dy <= FallbackRadius; ++dy) + for (int32 dx = -FallbackRadius; dx <= FallbackRadius; ++dx) + { + const FLocalShaft* Candidate = FindCollectedShaft( + Child.CellX + dx, Child.CellY + dy); + if (Candidate == nullptr + || (Candidate->CellX == Child.CellX && Candidate->CellY == Child.CellY)) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate->X) + + FMath::Square(Candidate->Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate->X) + + FMath::Square(Child.Y - Candidate->Y); + const bool bLowerCell = Parent == nullptr + || Candidate->CellY < Parent->CellY + || (Candidate->CellY == Parent->CellY + && Candidate->CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = Candidate; + } } } @@ -3882,7 +4019,7 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float } // Existing probabilistic links remain as texture and loops. They intentionally stay on - // the cached inner 3×3 working set; only the structural parent lookup needs the 7×7 halo. + // the cached inner 3×3 working set; only the structural tree build needs the 9×9/15×15 halo. if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2) { for (int32 i = 0; i < Shafts.Num(); i++) @@ -3906,7 +4043,12 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float Params.ConnectorRadius, RoughnessReach + 1.0f) : Params.ConnectorRadius; - Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }); + const FLocalConn Conn{ + FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }; + if (ConnectorMayReachCell(Conn)) + { + Conns.Add(Conn); + } } } } @@ -3958,7 +4100,10 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y); if (D2 < BestSq) { BestSq = D2; Near = &Sh; } } - if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 0.0f) + // The shaft axis is the structural tree's terminal point. Keep the mathematical + // half-plane boundary open: tiny cancellation error must not turn an exact axis + // landing into a solid ledge cap and sever the parent capsule. + if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 1.0e-3f) { float Shelf = 1.0f - SmoothStep01(BandT / Params.LedgeDepth); Density = FMath::Max(Density, Shelf * Params.BaseDensity); diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index f2fb946..afc34c4 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -97,7 +97,14 @@ namespace VoxelStrateMeasurePrivate { return Refuse(OutReason, TEXT("SampleStep must be greater than zero.")); } - if (Settings.RadiusInVoxels <= 0) + const bool bHasCoverPointA = Settings.CoverPointA.IsSet(); + const bool bHasCoverPointB = Settings.CoverPointB.IsSet(); + if (bHasCoverPointA != bHasCoverPointB) + { + return Refuse(OutReason, TEXT("CoverPointA and CoverPointB must be set together.")); + } + const bool bUseFittedWindow = bHasCoverPointA && bHasCoverPointB; + if (!bUseFittedWindow && Settings.RadiusInVoxels <= 0) { return Refuse(OutReason, TEXT("RadiusInVoxels must be greater than zero.")); } @@ -109,10 +116,26 @@ namespace VoxelStrateMeasurePrivate { return Refuse(OutReason, TEXT("HeadroomCells cannot be negative.")); } - if (!FMath::IsFinite(Settings.CenterXY.X) || !FMath::IsFinite(Settings.CenterXY.Y)) + if (!bUseFittedWindow + && (!FMath::IsFinite(Settings.CenterXY.X) || !FMath::IsFinite(Settings.CenterXY.Y))) { return Refuse(OutReason, TEXT("CenterXY must contain finite voxel coordinates.")); } + if (bUseFittedWindow) + { + const FVector2D& PointA = Settings.CoverPointA.GetValue(); + const FVector2D& PointB = Settings.CoverPointB.GetValue(); + if (!FMath::IsFinite(PointA.X) || !FMath::IsFinite(PointA.Y) + || !FMath::IsFinite(PointB.X) || !FMath::IsFinite(PointB.Y)) + { + return Refuse(OutReason, TEXT("Cover points must contain finite voxel coordinates.")); + } + if (!FMath::IsFinite(Settings.CoverMarginVoxels) + || Settings.CoverMarginVoxels < 0.0f) + { + return Refuse(OutReason, TEXT("CoverMarginVoxels must be finite and non-negative.")); + } + } if (Slot.TopChunkZ <= Slot.BottomChunkZ) { return Refuse(OutReason, TEXT("The requested strate has no positive vertical extent.")); @@ -128,13 +151,52 @@ namespace VoxelStrateMeasurePrivate // Resolve the margin from the same blended parameter query used by generation. The // representative chunk is deliberately in the middle of this slot and uses the sample - // centre's XY chunk, so a future non-Hard transition resolves through the manager exactly - // as generation does at that representative location. + // window centre's XY chunk, so a future non-Hard transition resolves through the manager + // exactly as generation does at that representative location. + FVector2D WindowCenter = Settings.CenterXY; + double MinX64 = 0.0; + double MaxX64 = 0.0; + double MinY64 = 0.0; + double MaxY64 = 0.0; + if (bUseFittedWindow) + { + const FVector2D& PointA = Settings.CoverPointA.GetValue(); + const FVector2D& PointB = Settings.CoverPointB.GetValue(); + const double Margin = static_cast(Settings.CoverMarginVoxels); + MinX64 = FMath::Min(static_cast(PointA.X), static_cast(PointB.X)) - Margin; + MaxX64 = FMath::Max(static_cast(PointA.X), static_cast(PointB.X)) + Margin; + MinY64 = FMath::Min(static_cast(PointA.Y), static_cast(PointB.Y)) - Margin; + MaxY64 = FMath::Max(static_cast(PointA.Y), static_cast(PointB.Y)) + Margin; + WindowCenter = FVector2D( + static_cast(0.5 * (MinX64 + MaxX64)), + static_cast(0.5 * (MinY64 + MaxY64))); + } + else + { + MinX64 = static_cast(Settings.CenterXY.X) + - static_cast(Settings.RadiusInVoxels); + MaxX64 = static_cast(Settings.CenterXY.X) + + static_cast(Settings.RadiusInVoxels); + MinY64 = static_cast(Settings.CenterXY.Y) + - static_cast(Settings.RadiusInVoxels); + MaxY64 = static_cast(Settings.CenterXY.Y) + + static_cast(Settings.RadiusInVoxels); + } + const double XYExtentX = MaxX64 - MinX64; + const double XYExtentY = MaxY64 - MinY64; + if (!FMath::IsFinite(MinX64) || !FMath::IsFinite(MaxX64) + || !FMath::IsFinite(MinY64) || !FMath::IsFinite(MaxY64) + || !FMath::IsFinite(XYExtentX) || !FMath::IsFinite(XYExtentY) + || XYExtentX < 1.0 || XYExtentY < 1.0) + { + return Refuse(OutReason, TEXT("The requested XY window is not a finite positive voxel box.")); + } + const int32 MidChunkZ = Slot.BottomChunkZ + (Slot.TopChunkZ - Slot.BottomChunkZ) / 2; const FIntVector RepresentativeChunk( - FMath::FloorToInt(Settings.CenterXY.X / static_cast(CHUNK_SIZE)), - FMath::FloorToInt(Settings.CenterXY.Y / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(WindowCenter.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(WindowCenter.Y / static_cast(CHUNK_SIZE)), MidChunkZ); const FStrateGenerationParams RepresentativeParams = Manager.GetGenerationParams(RepresentativeChunk); @@ -188,10 +250,18 @@ namespace VoxelStrateMeasurePrivate return Refuse(OutReason, TEXT("The sampled Z window is outside the metrics struct's int32 range.")); } - const int64 XYExtent = static_cast(Settings.RadiusInVoxels) * 2; const int64 Step = static_cast(Settings.SampleStep); - const int64 NumX64 = CeilDivPositive(XYExtent, Step); - const int64 NumY64 = NumX64; + const double NumXReal = XYExtentX / static_cast(Step); + const double NumYReal = XYExtentY / static_cast(Step); + if (!FMath::IsFinite(NumXReal) || !FMath::IsFinite(NumYReal) + || NumXReal <= 0.0 || NumYReal <= 0.0 + || NumXReal > static_cast(INT32_MAX) + || NumYReal > static_cast(INT32_MAX)) + { + return Refuse(OutReason, TEXT("A sample-grid dimension exceeds the supported array size.")); + } + const int64 NumX64 = FMath::CeilToInt64(NumXReal); + const int64 NumY64 = FMath::CeilToInt64(NumYReal); const int64 NumZ64 = CeilDivPositive(InteriorHeight, Step); const int64 MaxCells = static_cast(Settings.MaxCells); @@ -216,14 +286,6 @@ namespace VoxelStrateMeasurePrivate return Refuse(OutReason, TEXT("The requested sample grid exceeds the bounded cell count.")); } - const double MinX64 = static_cast(Settings.CenterXY.X) - - static_cast(Settings.RadiusInVoxels); - const double MaxX64 = static_cast(Settings.CenterXY.X) - + static_cast(Settings.RadiusInVoxels); - const double MinY64 = static_cast(Settings.CenterXY.Y) - - static_cast(Settings.RadiusInVoxels); - const double MaxY64 = static_cast(Settings.CenterXY.Y) - + static_cast(Settings.RadiusInVoxels); const float MinX = static_cast(MinX64); const float MaxX = static_cast(MaxX64); const float MinY = static_cast(MinY64); @@ -301,7 +363,8 @@ namespace VoxelStrateMeasurePrivate int64 NumAir, int64& OutLargestComponentCells, int32& OutLargestComponentLowestCell, - int32& OutNumComponentsAtLeast1Pct) + int32& OutNumComponentsAtLeast1Pct, + TArray* OutComponentCells) { OutComponents.SetNumUninitialized(Grid.CellCount); for (int32 Index = 0; Index < Grid.CellCount; ++Index) @@ -315,6 +378,10 @@ namespace VoxelStrateMeasurePrivate OutLargestComponentCells = 0; OutLargestComponentLowestCell = INDEX_NONE; OutNumComponentsAtLeast1Pct = 0; + if (OutComponentCells != nullptr) + { + OutComponentCells->Reset(); + } for (int32 Start = 0; Start < Grid.CellCount; ++Start) { @@ -359,6 +426,10 @@ namespace VoxelStrateMeasurePrivate } const int64 ComponentCells64 = static_cast(ComponentCells); + if (OutComponentCells != nullptr) + { + OutComponentCells->Add(ComponentCells64); + } if (NumAir > 0 && ComponentCells64 * 100 >= NumAir) { ++OutNumComponentsAtLeast1Pct; @@ -457,6 +528,7 @@ namespace VoxelStrateMeasurePrivate int64 LargestComponentCells = 0; int32 LargestComponentLowestCell = INDEX_NONE; int32 NumComponentsAtLeast1Pct = 0; + TArray ComponentCells; FloodFillAir( Grid, Components, @@ -464,7 +536,8 @@ namespace VoxelStrateMeasurePrivate NumAir, LargestComponentCells, LargestComponentLowestCell, - NumComponentsAtLeast1Pct); + NumComponentsAtLeast1Pct, + &ComponentCells); InOutMetrics.NumSampled = Grid.CellCount; InOutMetrics.NumAir = NumAir; @@ -472,6 +545,7 @@ namespace VoxelStrateMeasurePrivate InOutMetrics.AirFraction = static_cast( static_cast(NumAir) / static_cast(Grid.CellCount)); InOutMetrics.NumAirComponents = NumComponents; + InOutMetrics.AirComponentCells = MoveTemp(ComponentCells); InOutMetrics.LargestComponentCells = LargestComponentCells; InOutMetrics.NumComponentsAtLeast1Pct = NumComponentsAtLeast1Pct; InOutMetrics.LargestComponentShare = NumAir > 0 @@ -1005,7 +1079,8 @@ namespace VoxelStrateMeasurePrivate NumAir, LargestComponentCells, LargestComponentLowestCell, - NumComponentsAtLeast1Pct); + NumComponentsAtLeast1Pct, + nullptr); const int32 StartComponent = Components[Start]; const int32 GoalComponent = Components[Goal]; @@ -1141,6 +1216,13 @@ FVoxelStrateMetrics VF_MeasureStrate( Result.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; Result.SampledMinZ = Grid.SampledMinZ; Result.SampledMaxZ = Grid.SampledMaxZ; + Result.SampledNumX = Grid.NumX; + Result.SampledNumY = Grid.NumY; + Result.SampledNumZ = Grid.NumZ; + Result.SampledMinX = Grid.MinX; + Result.SampledMaxX = Grid.MaxX; + Result.SampledMinY = Grid.MinY; + Result.SampledMaxY = Grid.MaxY; VoxelStrateMeasurePrivate::BuildMetricsFromGrid(Grid, Settings, Result); Result.bValid = Result.NumSampled > 0; if (!Result.bValid) diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 93cedf8..3167014 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -553,6 +553,8 @@ namespace VoxelCaveMorphology * * 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. * * @return true when the archetype can provide a confident point; false when it cannot. */ @@ -573,7 +575,8 @@ VOXELFORGE_API bool VF_SuggestLandingPoint( * Complete 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. + * 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. */ VOXELFORGE_API bool VF_SuggestLandingPoint( ECaveGeneratorType Archetype, diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 0bdc92a..003ff3b 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -24,6 +24,12 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings int32 SampleStep = 4; // Voxels between samples. int32 RadiusInVoxels = 256; // Half-extent in X and Y around CenterXY. FVector2D CenterXY = FVector2D::ZeroVector; // Actor-space voxel coordinates. + // When both points are set, replace the square centered on CenterXY with the XY AABB of the + // two points expanded by CoverMarginVoxels. This keeps a mouth-to-mouth measurement sized by + // the mouths' separation instead of by their distance from the world origin. + TOptional CoverPointA; + TOptional CoverPointB; + float CoverMarginVoxels = 48.0f; int32 MaxCells = 8000000; // Refuse a grid larger than this many cells. int32 MaxRouteRetries = 16; // Alternate coarse routes to full-resolution-check after the first route. int32 HeadroomCells = 2; // Air cells above a floor cell required for walkable. @@ -54,6 +60,21 @@ struct VOXELFORGE_API FVoxelStrateMetrics int32 ResolvedMarginVoxels = 0; int32 SampledMinZ = 0; int32 SampledMaxZ = 0; + + // The exact sampled XY box and dimensions. For a fitted window these are the AABB-derived + // dimensions; for the legacy window they describe CenterXY +/- RadiusInVoxels. + int32 SampledNumX = 0; + int32 SampledNumY = 0; + int32 SampledNumZ = 0; + float SampledMinX = 0.0f; + float SampledMaxX = 0.0f; + float SampledMinY = 0.0f; + float SampledMaxY = 0.0f; + + // Air-component sizes in deterministic flood-fill discovery order. Keeping these facts from + // the one required flood fill lets diagnostics classify small non-largest components without + // sampling the source a second time. + TArray AirComponentCells; }; /**