fix(shafts): the primordial law holds -- 0 trusted negatives across 16 seeds

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-04 03:57:30 +02:00
1 parent 9d2324879d
commit 4f11ce81a4
10 files changed
+1811 -172

No files matched your search

+28 -19
View File
@@ -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
+17 -10
View File
@@ -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)
@@ -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<int32> Impure{ 0 };
@@ -209,7 +209,7 @@ 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
// 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;
@@ -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<double>(NumRebuildSamples),
File diff suppressed because it is too large. Load diff
@@ -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);
}
+251 -54
View File
@@ -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<float>(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<FShaft, TInlineAllocator<10>> Shafts;
TArray<FConn, TInlineAllocator<24>> Conns;
TArray<FConn, TInlineAllocator<32>> 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<FShaft, TInlineAllocator<64>> TreeCandidates;
TArray<FShaft, TInlineAllocator<32>> TreeEmit;
for (int32 cy = EmitY0 - CandidateRadius; cy <= EmitY1 + CandidateRadius; ++cy)
for (int32 cx = EmitX0 - CandidateRadius; cx <= EmitX1 + CandidateRadius; ++cx)
TArray<FShaft, TInlineAllocator<225>> TreeCandidates;
TArray<FShaft, TInlineAllocator<81>> 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<FShaft, TInlineAllocator<32>> 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,25 +1954,59 @@ 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<FShaft, TInlineAllocator<49>> 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<FShaft, TInlineAllocator<225>> ShaftGrid;
TArray<uint8, TInlineAllocator<225>> ShaftPresent;
TArray<FShaft, TInlineAllocator<81>> 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)
@@ -1884,9 +2014,20 @@ namespace
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<float>(CX) * Spacing;
const float CellMaxX = static_cast<float>(CX + 1) * Spacing;
const float CellMinY = static_cast<float>(CY) * Spacing;
const float CellMaxY = static_cast<float>(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);
+189 -44
View File
@@ -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<float>(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<FLocalShaft, TInlineAllocator<10>> Shafts;
thread_local TArray<FLocalConn, TInlineAllocator<24>> Conns;
thread_local TArray<FLocalConn, TInlineAllocator<32>> Conns;
thread_local TArray<FLocalShaft, TInlineAllocator<81>> 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<FLocalShaft, TInlineAllocator<49>> 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<FLocalShaft, TInlineAllocator<225>> ShaftGrid;
TArray<uint8, TInlineAllocator<225>> 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,12 +3858,30 @@ 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
@@ -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<float>(CX) * Spacing;
const float CellMaxX = static_cast<float>(CX + 1) * Spacing;
const float CellMinY = static_cast<float>(CY) * Spacing;
const float CellMaxY = static_cast<float>(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);
+102 -20
View File
@@ -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<double>(Settings.CoverMarginVoxels);
MinX64 = FMath::Min(static_cast<double>(PointA.X), static_cast<double>(PointB.X)) - Margin;
MaxX64 = FMath::Max(static_cast<double>(PointA.X), static_cast<double>(PointB.X)) + Margin;
MinY64 = FMath::Min(static_cast<double>(PointA.Y), static_cast<double>(PointB.Y)) - Margin;
MaxY64 = FMath::Max(static_cast<double>(PointA.Y), static_cast<double>(PointB.Y)) + Margin;
WindowCenter = FVector2D(
static_cast<float>(0.5 * (MinX64 + MaxX64)),
static_cast<float>(0.5 * (MinY64 + MaxY64)));
}
else
{
MinX64 = static_cast<double>(Settings.CenterXY.X)
- static_cast<double>(Settings.RadiusInVoxels);
MaxX64 = static_cast<double>(Settings.CenterXY.X)
+ static_cast<double>(Settings.RadiusInVoxels);
MinY64 = static_cast<double>(Settings.CenterXY.Y)
- static_cast<double>(Settings.RadiusInVoxels);
MaxY64 = static_cast<double>(Settings.CenterXY.Y)
+ static_cast<double>(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<float>(CHUNK_SIZE)),
FMath::FloorToInt(Settings.CenterXY.Y / static_cast<float>(CHUNK_SIZE)),
FMath::FloorToInt(WindowCenter.X / static_cast<float>(CHUNK_SIZE)),
FMath::FloorToInt(WindowCenter.Y / static_cast<float>(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<int64>(Settings.RadiusInVoxels) * 2;
const int64 Step = static_cast<int64>(Settings.SampleStep);
const int64 NumX64 = CeilDivPositive(XYExtent, Step);
const int64 NumY64 = NumX64;
const double NumXReal = XYExtentX / static_cast<double>(Step);
const double NumYReal = XYExtentY / static_cast<double>(Step);
if (!FMath::IsFinite(NumXReal) || !FMath::IsFinite(NumYReal)
|| NumXReal <= 0.0 || NumYReal <= 0.0
|| NumXReal > static_cast<double>(INT32_MAX)
|| NumYReal > static_cast<double>(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<int64>(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<double>(Settings.CenterXY.X)
- static_cast<double>(Settings.RadiusInVoxels);
const double MaxX64 = static_cast<double>(Settings.CenterXY.X)
+ static_cast<double>(Settings.RadiusInVoxels);
const double MinY64 = static_cast<double>(Settings.CenterXY.Y)
- static_cast<double>(Settings.RadiusInVoxels);
const double MaxY64 = static_cast<double>(Settings.CenterXY.Y)
+ static_cast<double>(Settings.RadiusInVoxels);
const float MinX = static_cast<float>(MinX64);
const float MaxX = static_cast<float>(MaxX64);
const float MinY = static_cast<float>(MinY64);
@@ -301,7 +363,8 @@ namespace VoxelStrateMeasurePrivate
int64 NumAir,
int64& OutLargestComponentCells,
int32& OutLargestComponentLowestCell,
int32& OutNumComponentsAtLeast1Pct)
int32& OutNumComponentsAtLeast1Pct,
TArray<int64>* 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<int64>(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<int64> 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<float>(
static_cast<double>(NumAir) / static_cast<double>(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)
@@ -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,
@@ -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<FVector2D> CoverPointA;
TOptional<FVector2D> 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<int64> AirComponentCells;
};
/**