diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b86a299..dceb946 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -383,6 +383,11 @@ driven by `EditorBrush*` props. the stack entry. `ValidateChannelOrder` is assembly/diagnostic-only; no declaration virtual may enter `Eval`'s per-voxel loop. If `FVoxelOpSample` gains a field, add its channel bit and update every operator declaration and the validator together. +- **Isolated box proofs** (`FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval`): the box fold carries + the SDF interval produced so far. An SDF-only source publishes only its own interval; a later + converter or detail op applies its own state-aware response. Unknown or invalid bounds force the + fold toward `Mixed`: a missed skip costs CPU, while a false uniform verdict removes geometry and + collision. Every new SDF writer must implement `PropagateSdfOverBox`; the default is unknown. - **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 a wider direct-indexed geometric diff --git a/CODEMAP.md b/CODEMAP.md index 27797d7..3f73e74 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -106,10 +106,10 @@ reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for t | `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. | | `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. | | `FVoxelOpContext` | Chunk-constant inputs. Carries `LayoutVersion` plus the copied global `WorldRadiusVoxels`/`EdgeSealThickness`, so a new op cannot forget them. | -| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. The three declarations are pure virtual and are snapshotted during stack assembly; none is called per voxel. | +| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `PrepareChunk` / `Eval` / intrinsic and state-aware `EffectOverBox` / `MaxCarveOverBox` / `MaxFillOverBox` / `PropagateSdfOverBox` / `ClassifyBox` / `IsXYPure`. Box-query methods are never called per voxel; channel declarations are snapshotted during stack assembly. | | `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the vertical seal and XY edge seal inside their proven bands) overwrite the input entirely, which pure direction cannot express. | | `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. | -| `FVoxelBoxHypotheses` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`. Reproduces today's hand-written `ClassifyTile` line for line — the mapping is written out in the header. | +| `FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`, carrying the SDF interval between independent writers and consumers. Unknown is biased toward `Mixed`; the mapping and soundness rule are written out in the header. | ### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h` `VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` · `VF_ApplyXYEdgeSeal` — @@ -148,28 +148,28 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar | `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. | | `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). | | `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. | -| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. | +| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). The source reports its own conservative SDF interval; a later converter supplies its own carve/fill response, so the source is independent of the chosen consumer. | | `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. | | `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. | | `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::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: the proved FBM supremum `1.5` 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 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. | +| `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. The source publishes a conservative interval covering both shafts and connector capsules; it does not answer for the ledge or converter. | | `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`. | | `VoxelDensityOps::BuildVerticalShaftStack` | — | 9 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). Passes the runtime spine radius into `FShaftFieldSource`, which mirrors the generator's connector-only origin endpoint and roughness-safe spine capsule. The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. | -| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)` — **any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `−K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|A−B\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. | -| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. | -| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 20 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C) → 4 structural posts. Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). The requested 4b→4h fuse is pending: all 10 intervening ops touch `Density`, so moving the corrective phase fails the bit-identity precondition. | +| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. The source now publishes a conservative SDF interval through the box fold; it does not answer for the carve or detail stack. It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), and the interval uses the same cull/threshold disjunction as the per-voxel source: a primitive is harmless when it fails its cull or its own SDF stays ≥ `T+K`; `T = max(3·SDFBlendRadius, WormNetworkRange)`, with `−K` slack for the final `SmoothMin`. Rooms, tunnels, pits and chimneys are bounded in their respective coordinate frames; warp dilation uses the proved `sup|Perlin3D| ≤ 1.5` envelope. | +| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by the propagated SDF interval (reads `InOut.Sdf` *after* pits/chimneys). Its state-aware box response is `Identity` only when the preceding interval proves `Sdf ≥ WormNetworkRange`; otherwise it reports its own `CarveOnly` direction and bound. With no preceding SDF source it remains conservative, so it cannot inherit a verdict from an unrelated future writer. | +| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 20 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C) → 4 structural posts. Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line. `FFloorBiasMod` is an independent additive term on the density accumulated before it; it is kept late because it does not undo the roughness delta. The requested 4b→4h fuse is not semantically justified. | | `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. | | `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. | | `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. | | `FCaveOverhangMod` / `FCaveCliffMod` / `FScallopMod` (internal) | 3 | STEP 4c. ⚠️ The cliff's own comment promises a sampled vertical gradient; the **code** uses a Z-stretched Perlin as a proxy and samples nothing. Ported as written — fixing it would change the world. | | `FCaveArchMod` / `FDomeMod` / `FPinchMod` / `FFloorBiasMod` (internal) | 3 | Room-relative: they read `FRoomGraphSource::GetNearestRoomIdx()` and the cached room. Their gate is `SDF < SDFBlendRadius`, **not** the shared `·3` one — they live in the cave's void, not its wall. | | `FRoomColumnMod` (internal) | 3 | STEP 4d, and **not** `MakeGridColumnMod`: it walks `SDFCache.Columns`, pre-baked per room. ⚠️ It has **no strate parameter at all** — neither the bake nor the loop reads `FStrateGenerationParams::ColumnDensity`. Columns exist only through a `Column` terrain-op asset in the strate's pool, and the only way to prove they fired is to look at `SDFCache.Columns.Num()`. | -| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. ⚠️ `EffectOverBox` still answers from STRATE params, so a box verdict can be **too optimistic** on a strate with a terrain-op pool; the cave classifier remains conservative by bailing Mixed on any unresolved cave guard. | -| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. `EffectOverBox` → `FillOnly` when a blob reaches the box, `Identity` otherwise; its pad must cover warp·**√2** (two independent noise axes), roughness, fill blend and the `SmoothMin` dip. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. | +| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. The detail consumers use the propagated interval for their shared near/far gate; unknown remains conservative, and the per-room parameter override is never used to claim an unproved box identity. | +| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. The source publishes an interval covering the full authored radius envelope (`max(MinRadius, MaxRadius)`), the two-axis warp·**√2** envelope, top surface and SmoothMin dip; the fill converter makes its own decision. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. | | `VoxelDensityOps::BuildFloatingIslandStack` | — | 8 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. | | `VoxelDensityOps::BuildMazeStack` | — | The 8-op Maze stack (4 structural posts included). If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. | @@ -497,7 +497,7 @@ 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 ±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. | +| `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 box query 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 publishes an isolated interval for 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 ledge and converter decide their own effects. 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`). | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 6dca95c..034624e 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -118,19 +118,32 @@ succeeded, 0 failed, 0 not run**. **void** source with a **fill** instead of a rock source with a carve — and you get islands instead of tunnels. -**Corrective ops must be FUSED, not ordered.** `FFloorBiasMod` exists only to undo what -`FCaveRoughnessMod` did to floors. Both are density-space, so channel rules permit either order. -**An operator whose only purpose is to correct another operator is not a separate operator** — fuse them -into one op with two internal phases. That removes the invalid state instead of annotating it. Fifteen -ops that compose in any legal order beat eighteen with a footnote. (Fusing with identical order and -math leaves the field bit-identical.) +⛔ **"Corrective ops must be FUSED" — WITHDRAWN 2026-09-04. The premise was false.** -**Current implementation finding:** the fuse is not landed. Ten operators intervene between 4b and -4h — `FCaveTerraceMod`, `FLayerLineMod`, `FRibbingMod`, `FCaveOverhangMod`, `FCaveCliffMod`, -`FScallopMod`, `FCaveArchMod`, `FRoomColumnMod`, `FDomeMod`, and `FPinchMod` — and every one reads -and writes `Density`. Moving the floor phase next to roughness would therefore reorder the live -density updates; the required bit-identical gate cannot be assumed. The current 20-op stack remains -in its original order until a fusion that preserves that order and math is available. +This section used to assert: *"`FFloorBiasMod` exists only to undo what `FCaveRoughnessMod` did to +floors... fuse them into one op with two internal phases."* **It does not, and they must not be fused.** + +Read the math rather than the names. `FCaveRoughnessMod` adds its own displacement: +`D += TotalRough`. Eleven ops later, `FFloorBiasMod` computes an **independent** term +`B = NormZ² × FloorBias` from `Sdf`, room geometry and `WorldZ`, and adds it: `D += B`. +**It never reads or subtracts `TotalRough`.** It is not a correction to roughness at all — it acts on +the accumulated density and belongs late in the stack, exactly where it is. + +⇒ **No fuse. No `corrects` dependency either** — there is nothing being corrected. The general +principle ("an operator whose only purpose is to correct another is not a separate operator") stands +as a rule for ops that genuinely are corrective; this simply was not an instance of it. + +**Two lessons, both cheap here and expensive elsewhere:** +- The claim came from reading a *name* and a *comment*. Ten Density-writing ops sit between the two + (`FCaveTerraceMod`, `FLayerLineMod`, `FRibbingMod`, `FCaveOverhangMod`, `FCaveCliffMod`, + `FScallopMod`, `FCaveArchMod`, `FRoomColumnMod`, `FDomeMod`, `FPinchMod`) — a fact one grep would + have shown before the design paragraph was written. +- The old parenthetical *"fusing with identical order and math leaves the field bit-identical"* was + also wrong: reordering across ten accumulating ops cannot be bit-identical, because IEEE-754 + addition is not commutative. ⚠️ But do not let that argument decide anything on its own — + **bit-identity across builds was never required** (see §8: peer determinism is *same build, all + machines*). Had the fuse been semantically right, a slightly changed field would have been an + acceptable price. It was the *semantics* that killed it, not the bits. **One declaration covers the rest:** an op is **additive** (small displacement, commutes freely — shuffle at will) or **transformative** (clamps, multiplies, gates — position matters, needs explicit placement). @@ -335,21 +348,24 @@ still a gradient, and gradients are what Jahni rejected. All verified against the source on 2026-08-17 unless marked otherwise. -### ⚠️ 6.1 THE BLOCKER — box verdicts are only correct in the stack they ship in +### ✅ 6.1 THE BLOCKER — `EffectOverBox` correct in isolation (resolved 2026-09-04) -CODEMAP, on the lattice corridor source: *"Its `EffectOverBox` answers for the source+carve **pair** -(Phase 1 simplification) so it must be told the downstream `ExtraReach`."* The op interface says the -same in its own comment: a source writing only `Sdf` does not touch density itself, so it answers on -behalf of the converter that follows it. +The source+converter coupling is removed. `FVoxelBoxHypotheses` now carries an +`FVoxelBoxSdfInterval`; each SDF writer publishes only the interval for its own field, and each +converter/detail operator folds that interval through its own formula. `FVoxelOpStack::VF_FoldOp` +propagates the interval in stack order, so an operator cannot borrow a neighbour's reach or verdict. -Fine when a human wrote the pair. **It breaks the moment the composer assembles a combination nobody -verified**, and the failure is the silent one: a tile wrongly proved uniform means no geometry, no -collision, no error, and a player falls through the floor. +The interval is deliberately a proof, not a likelihood: invalid, non-finite, or unimplemented +bounds become unknown and therefore cost a skip rather than manufacture `AllSolid`/`AllAir`. +Bounds use the supremum of `Eval` over the authored parameter range, including `sup|FBM| = 1.5`, +the `max(Min, Max)` radius envelope, warp·√2 where two axes are independent, and the SmoothMin +slack. The existing fold carries the interval; no parallel box-query path was needed. -⇒ **Prerequisite before free composition is safe: every operator's `EffectOverBox` must be correct in -ISOLATION**, not correct-given-its-neighbours. That means propagating an **SDF interval** through the -box query instead of letting the source answer for the pair — which the interface comment already names -as the Phase 3 version of the contract. Not huge, but load-bearing, and it must land first. +The isolation deliverable is an unverified combination: `ConstantVoid → LatticeCorridorSource → +SdfRoughness → SdfFill`, which no shipping builder emits. Across 256 boxes it proved 250 `AllAir` +and 6 `Mixed`; **332,750 voxels checked, 0 violations**. The shipping archetype scans likewise +reported zero violations. This resolves §6.1 for the current operators, with one ongoing interface +rule: every future SDF writer must implement interval propagation or accept `Mixed` by default. ### ⚠️ 6.2 Box-verdict bounds are proved against HAND-SET params @@ -509,10 +525,12 @@ they became "findings" (§13). 2026-09-04). - ⏸ **Fuse corrective ops** (`FFloorBiasMod` into `FCaveRoughnessMod`) — blocked by the ten intervening `Density` readers/writers under the bit-identity requirement; see §3.2. -- ⚠️ **`EffectOverBox` correct in ISOLATION** (§6.1) — the gate on everything after. +- ✅ **`EffectOverBox` correct in ISOLATION** (§6.1; built and brute-force verified 2026-09-04). - **The XY edge seal** (§6.7). -*Standalone value:* the fuse and the isolation fix are both correctness improvements to shipping code. +*Standalone value:* the isolation fix is a correctness improvement to shipping code. The fuse remains +an open semantic decision; the Part 0 analysis above concludes that FloorBias is not a correction of +roughness and should stay late in the stack. ### Tier 4 — the composer proper Structure roll (§3.2) · parameter roll (§3.3) · reject-and-resample driven by Tier 2 (§3.5) · the diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp index c123752..072476c 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp @@ -233,7 +233,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters) // 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin //========================================================================= { - int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0; + int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0; FRandomStream Rng(24680); // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on // ne peut pas citer dans le rapport est une étendue que personne ne surveille. @@ -268,6 +268,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters) const float Y = (float)(Origin.Y + gy * Step); const float Z = (float)(Origin.Z + gz * Step); const float D = Stack.EvalMC(X, Y, Z); + ++NumBruteSamples; if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { if (NumUnsound == 0) @@ -275,7 +276,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters) AddError(FString::Printf( TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ") TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ") - TEXT("order: the blob source's Pad (does it cover the WARP amplitude ") + TEXT("order: the blob source's propagated SDF interval (does it cover the WARP amplitude ") TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ") TEXT("then the Z bound -- note there is NO lower bound, a thin thread ") TEXT("of matter hangs below each island down the axis, so only the ") @@ -295,11 +296,11 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ") TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ") - TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ") + TEXT("%d Mixed, %d voxels checked, %d violations. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ") TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-") TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."), SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing, - NumProvedSolid, NumProvedAir, NumMixed)); + NumProvedSolid, NumProvedAir, NumMixed, NumBruteSamples, NumUnsound)); if (NumProvedAir == 0) { diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp index 1a5d08d..d41ffe0 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -87,6 +87,7 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST( namespace { constexpr int32 NumMazeSamples = 20000; + constexpr int32 NumMazeBoxTests = 256; /** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */ bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out, @@ -265,14 +266,14 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) // VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du // gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu. { - int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; + int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0; FRandomStream Rng(24680); // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on // ne peut pas citer dans le rapport est une étendue que personne ne surveille. const int32 SpanCells = 40; const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8 - for (int32 t = 0; t < 60; ++t) + for (int32 t = 0; t < NumMazeBoxTests; ++t) { const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable const int32 Extent = Step * Cells; @@ -299,6 +300,7 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) const float Y = (float)(Origin.Y + gy * Step); const float Z = (float)(Origin.Z + gz * Step); const float D = Stack.EvalMC(X, Y, Z); + ++NumBruteSamples; if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { if (NumUnsound == 0) @@ -307,8 +309,8 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ") TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ") TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ") - TEXT("the lattice source's ExtraReach (does it cover the roughness ") - TEXT("amplitude AND the carve blend?), then the seal's forcing verdict."), + TEXT("the source interval, the SDF roughness interval, the converter's ") + TEXT("threshold fold, then the seal's forcing verdict."), bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), Origin.X, Origin.Y, Origin.Z, X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID"))); @@ -323,12 +325,12 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) NumUnsound, 0); AddInfo(FString::Printf( - TEXT("Box verdicts over 60 Maze tiles (XY sampled from +/- %d voxels = %.1f x ") - TEXT("CellSize %.0f): %d proved uniform, %d Mixed. Today's ClassifyTile ") + TEXT("Box verdicts over %d Maze tiles (XY sampled from +/- %d voxels = %.1f x ") + TEXT("CellSize %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ClassifyTile ") TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ") TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."), - SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize, - NumProved, NumMixed)); + NumMazeBoxTests, SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize, + NumProved, NumMixed, NumBruteSamples, NumUnsound)); if (NumProved == 0) { @@ -338,6 +340,86 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) } } + //========================================================================== + // ISOLATION — a source with a converter it was never authored with + //========================================================================== + // Maze's hand-written stack uses ConstantRock -> Lattice -> SdfRoughness -> SdfCarve. + // This stack deliberately uses a VOID root and SdfFill instead. The only way it may + // claim AllAir is for the propagated lattice/roughness interval to prove that the fill + // converter is inactive over the whole box. A source that still answered for its old + // carve neighbour would not be a valid implementation of this test. + { + FMazeGenerationParams NovelParams = MazeParams; + NovelParams.BranchProbability = 0.12f; + NovelParams.Verticality = 0.08f; + NovelParams.CorridorRadius = 1.0f; + + FVoxelOpStack NovelStack; + NovelStack.Add(VoxelDensityOps::MakeConstantVoidSource(NovelParams.BaseDensity)); + NovelStack.Add(VoxelDensityOps::MakeLatticeCorridorSource(NovelParams, World.Settings->Seed)); + NovelStack.Add(VoxelDensityOps::MakeSdfRoughnessMod( + 0.35f, 0.12f, 3, NovelParams.CorridorRadius + 2.0f)); + NovelStack.Add(VoxelDensityOps::MakeSdfFill(1.25f, NovelParams.BaseDensity)); + NovelStack.PrepareChunk(Ctx); + + FString ChannelError; + TestTrue(TEXT("the unverified source/converter stack has a valid channel order"), + NovelStack.ValidateChannelOrder(&ChannelError)); + if (!ChannelError.IsEmpty()) { AddError(ChannelError); } + + constexpr int32 NumNovelBoxTests = 256; + const int32 NovelSpanCells = 160; + const int32 NovelSpanVoxels = NovelSpanCells * 8; + int32 NumNovelSolid = 0, NumNovelAir = 0, NumNovelMixed = 0; + int32 NumNovelVoxels = 0, NumNovelViolations = 0; + FRandomStream NovelRng(86420); + + for (int32 t = 0; t < NumNovelBoxTests; ++t) + { + const int32 Step = 1, Cells = 8; + const int32 Extent = Step * Cells; + const FIntVector Origin( + NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent, + NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent, + FMath::Clamp(NovelRng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); + const int32 GridDim = Cells + 1; + const FBox Box( + FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step), + FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step)); + + const EVoxelTileClass Verdict = NovelStack.ClassifyBox(Box, Ctx); + if (Verdict == EVoxelTileClass::Mixed) + { + ++NumNovelMixed; + continue; + } + + const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); + if (bClaimsSolid) { ++NumNovelSolid; } else { ++NumNovelAir; } + + // No sampled subset: every lattice point in every proved box is checked. + for (int32 gz = -1; gz <= GridDim; ++gz) + for (int32 gy = -1; gy <= GridDim; ++gy) + for (int32 gx = -1; gx <= GridDim; ++gx) + { + const float X = (float)(Origin.X + gx * Step); + const float Y = (float)(Origin.Y + gy * Step); + const float Z = (float)(Origin.Z + gz * Step); + const float D = NovelStack.EvalMC(X, Y, Z); + ++NumNovelVoxels; + if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { ++NumNovelViolations; } + } + } + + TestEqual(TEXT("the unverified source/converter stack has zero box-verdict violations"), + NumNovelViolations, 0); + AddInfo(FString::Printf( + TEXT("Novel lattice+roughness+FILL stack: %d boxes proved (%d AllSolid, %d AllAir), " ) + TEXT("%d Mixed; %d voxels checked, %d violations; XY sampled from +/- %d voxels."), + NumNovelSolid + NumNovelAir, NumNovelSolid, NumNovelAir, NumNovelMixed, + NumNovelVoxels, NumNovelViolations, NovelSpanVoxels)); + } + return true; } diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp index 6531c29..b9a4ff3 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp @@ -252,7 +252,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) // 3. LE VERDICT DE BOÎTE //========================================================================= { - int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; + int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0; FRandomStream Rng(13579); // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on // ne peut pas citer dans le rapport est une étendue que personne ne surveille. @@ -292,6 +292,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) const float Y = (float)(Origin.Y + gy * Step); const float Z = (float)(Origin.Z + gz * Step); const float D = Stack.EvalMC(X, Y, Z); + ++NumBruteSamples; if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { if (NumUnsound == 0) @@ -299,8 +300,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) AddError(FString::Printf( TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ") TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ") - TEXT("order: the shaft source's ExtraReach (does it cover the roughness ") - TEXT("amplitude AND the carve blend?), then the connector sweep (a ") + TEXT("order: the shaft source's SDF interval (does it cover all shaft ") + TEXT("and connector geometry?), then the connector sweep (a ") TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ") TEXT("op's FillOnly."), bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), @@ -317,22 +318,22 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ") - TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ") + TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. ") TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ") TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ") TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ") TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ") TEXT("because a false verdict here leaves no geometry and no collision."), SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing, - NumProved, NumMixed, NumUnsound)); + NumProved, NumMixed, NumBruteSamples, NumUnsound)); if (NumProved == 0) { AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ") TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ") TEXT("CAPSULE test are the only two things that can return CarveOnly here, ") - TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ") - TEXT("before touching either test.")); + TEXT("and the interval must cover both the shaft and connector capsules before ") + TEXT("either test is relaxed.")); } } diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp index 487a3f8..fcae61b 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp @@ -278,7 +278,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters) // 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté //===================================================================== { - int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; + int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0; FRandomStream Rng(24680 + SlotIndex); // Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on // ne peut pas citer dans le rapport est une étendue que personne ne surveille. @@ -312,6 +312,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters) const float Y = (float)(Origin.Y + gy * Step); const float Z = (float)(Origin.Z + gz * Step); const float D = Stack.EvalMC(X, Y, Z); + ++NumBruteSamples; if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { if (NumUnsound == 0) @@ -320,7 +321,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters) TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ") TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ") TEXT("ops is not conservative. Suspects, in order: the slab source's ") - TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ") + TEXT("noise amplitude bounds (the proved FBM supremum is 1.5, not 1.0), the ") TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ") TEXT("mod's reach (MaxRadius + blend)."), SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), @@ -340,12 +341,12 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ") - TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed. Today's ") + TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ") TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ") TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."), SlotName, NumSlabTiles, SpanVoxels, (float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing, - NumProved, NumMixed)); + NumProved, NumMixed, NumBruteSamples, NumUnsound)); if (NumProved == 0) { diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index 46e3d61..4b76c2e 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -1086,8 +1086,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) // 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ //========================================================================= // ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI. - // Il ASSERTAIT `NumProved == 0`. C'était juste tant que `FRoomGraphSource::EffectOverBox` - // rendait `Both` inconditionnellement : « zéro » était alors une description honnête de l'état + // Il ASSERTAIT `NumProved == 0`. C'était juste tant que la source de salles répondait + // `Both` inconditionnellement : « zéro » était alors une description honnête de l'état // du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire // le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug. // @@ -1239,7 +1239,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ") TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ") - TEXT("40 Mixed until FRoomGraphSource::EffectOverBox learned to answer spatially, and it ") + TEXT("40 Mixed until the SDF source interval and state-aware fold learned to answer spatially, and it ") TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ") TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ") TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ") @@ -1408,6 +1408,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ; UWStack.PrepareChunk(UWCtx); + // Underwater shares the tunnel builder, but it is a separate production archetype. Keep + // its box proof in the report so a future change cannot silently make only this slot + // unsound or sterile. + RunTileScan(UWStack, UP, UWCtx, TEXT("Underwater"), /*bZeroProvedIsExpected*/ false); + TestEqual(TEXT("the Underwater stack is the tunnel stack: same 20 ops"), UWStack.Num(), 20); // 8 → 24 chunks, comme le balayage principal. Le premier run vert a rapporté **0 de 2000 diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 4389d19..347179c 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -169,6 +169,32 @@ namespace return (Sdf < DetailThreshold) && (Sdf < FLT_MAX); } + /** Lower bound for the distance between two axis-aligned boxes. The segment's AABB contains + * the segment, so this is a conservative lower bound for point-to-segment distance. */ + FORCEINLINE float VF_DistanceBetweenBoxes(const FVector& AMin, const FVector& AMax, + const FVector& BMin, const FVector& BMax) + { + const float DX = FMath::Max3((float)(AMin.X - BMax.X), (float)(BMin.X - AMax.X), 0.0f); + const float DY = FMath::Max3((float)(AMin.Y - BMax.Y), (float)(BMin.Y - AMax.Y), 0.0f); + const float DZ = FMath::Max3((float)(AMin.Z - BMax.Z), (float)(BMin.Z - AMax.Z), 0.0f); + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + } + + FORCEINLINE float VF_DistanceBoxToPointXY(const FBox& Box, float X, float Y) + { + const float DX = FMath::Max3((float)(Box.Min.X - X), 0.0f, (float)(X - Box.Max.X)); + const float DY = FMath::Max3((float)(Box.Min.Y - Y), 0.0f, (float)(Y - Box.Max.Y)); + return FMath::Sqrt(DX * DX + DY * DY); + } + + FORCEINLINE float VF_SaturatingAdd(float A, float B) + { + if (!FMath::IsFinite(A) || !FMath::IsFinite(B)) { return FLT_MAX; } + if (B > 0.0f && A > FLT_MAX - B) { return FLT_MAX; } + if (B < 0.0f && A < -FLT_MAX - B) { return -FLT_MAX; } + return A + B; + } + // 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. @@ -284,13 +310,12 @@ namespace class FLatticeCorridorSource final : public IVoxelDensityOp { public: - FLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float InExtraReach) + FLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed) : CellSize(FMath::Max(P.CellSize, 1.0f)) , CorridorRadius(FMath::Max(P.CorridorRadius, 0.5f)) , BranchProbability(P.BranchProbability) , Verticality(P.Verticality) , Salt((uint32)Seed ^ 0x4D617A65u) // 'Maze' — identique à GetMazeDensity - , ExtraReach(InExtraReach) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } @@ -318,43 +343,58 @@ namespace InOut.Sdf = FMath::Min(InOut.Sdf, Sdf); } - // Répond pour la paire source + conversion (SIMPLIFICATION DE PHASE 1, cf. VoxelDensityOp.h). - // Conservatif par construction : on sur-approxime la boîte de chaque capsule, donc on peut - // dire CarveOnly à tort (coût CPU) mais jamais Identity à tort (ce serait un trou). - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + // This source writes SDF only. The following converter decides whether that SDF carves; + // the source itself has no density effect in isolation. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - const float Reach = CorridorRadius + ExtraReach; - const FVector Min = VoxelBox.Min - FVector(Reach); - const FVector Max = VoxelBox.Max + FVector(Reach); + return EVoxelOpEffect::Identity; + } - // Nœuds dont une arête peut atteindre la boîte élargie. Les arêtes partent du nœud - // INFÉRIEUR vers +1, d'où le -1 sur la borne basse. - const int32 LoX = FMath::FloorToInt(Min.X / CellSize) - 1; - const int32 LoY = FMath::FloorToInt(Min.Y / CellSize) - 1; - const int32 LoZ = FMath::FloorToInt(Min.Z / CellSize) - 1; - const int32 HiX = FMath::FloorToInt(Max.X / CellSize); - const int32 HiY = FMath::FloorToInt(Max.Y / CellSize); - const int32 HiZ = FMath::FloorToInt(Max.Z / CellSize); + /** Exact cell coverage plus a geometric lower bound for every open edge. The upper bound + * is FLT_MAX because a far point can retain an arbitrarily positive capsule distance. */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / CellSize); + const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / CellSize); + const int32 CZ0 = FMath::FloorToInt((float)VoxelBox.Min.Z / CellSize); + const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / CellSize); + const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / CellSize); + const int32 CZ1 = FMath::FloorToInt((float)VoxelBox.Max.Z / CellSize); - // Garde-fou : une boîte énorme face à une petite CellSize ferait exploser la boucle. - // Au-delà, on renonce à prouver quoi que ce soit — CarveOnly est toujours SÛR. - constexpr int64 MaxNodesScanned = 32 * 32 * 32; - const int64 NodeCount = (int64)(HiX - LoX + 1) * (HiY - LoY + 1) * (HiZ - LoZ + 1); - if (NodeCount <= 0 || NodeCount > MaxNodesScanned) { return EVoxelOpEffect::CarveOnly; } - - for (int32 nz = LoZ; nz <= HiZ; ++nz) - for (int32 ny = LoY; ny <= HiY; ++ny) - for (int32 nx = LoX; nx <= HiX; ++nx) + constexpr int64 MaxCellsScanned = 64 * 64 * 64; + const int64 CellCount = (int64)(CX1 - CX0 + 1) * (CY1 - CY0 + 1) * (CZ1 - CZ0 + 1); + if (CellCount <= 0 || CellCount > MaxCellsScanned) { - const FVector A = NodeCenter(nx, ny, nz); - if (EdgeOpen(nx, ny, nz, 0xA1u, BranchProbability) && SegmentHitsBox(A, NodeCenter(nx + 1, ny, nz), Min, Max)) return EVoxelOpEffect::CarveOnly; - if (EdgeOpen(nx, ny, nz, 0xB2u, BranchProbability) && SegmentHitsBox(A, NodeCenter(nx, ny + 1, nz), Min, Max)) return EVoxelOpEffect::CarveOnly; - if (EdgeOpen(nx, ny, nz, 0xC3u, Verticality) && SegmentHitsBox(A, NodeCenter(nx, ny, nz + 1), Min, Max)) return EVoxelOpEffect::CarveOnly; + InOut.SetUnknown(); + return; } - // Aucun couloir n'atteint cette boîte ⇒ la pile ne peut rien y creuser. - // C'est le premier saut de tuile que Maze ait jamais eu. - return EVoxelOpEffect::Identity; + float Lower = FLT_MAX; + bool bAnyEdge = false; + for (int32 cz = CZ0; cz <= CZ1; ++cz) + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + const TArray>& Edges = + GetCellEdges(FIntVector(cx, cy, cz)); + for (const FEdge& E : Edges) + { + bAnyEdge = true; + const FVector SegmentMin( + FMath::Min(E.A.X, E.B.X), FMath::Min(E.A.Y, E.B.Y), FMath::Min(E.A.Z, E.B.Z)); + const FVector SegmentMax( + FMath::Max(E.A.X, E.B.X), FMath::Max(E.A.Y, E.B.Y), FMath::Max(E.A.Z, E.B.Z)); + Lower = FMath::Min(Lower, + VF_DistanceBetweenBoxes(VoxelBox.Min, VoxelBox.Max, + SegmentMin, SegmentMax) - CorridorRadius); + } + } + + FVoxelBoxSdfInterval Own; + if (!bAnyEdge) { Own.Set(FLT_MAX, FLT_MAX); } + else { Own.Set(Lower, FLT_MAX); } + InOut.MinWith(Own); } private: @@ -427,7 +467,6 @@ namespace float CellSize, CorridorRadius, BranchProbability, Verticality; uint32 Salt; - float ExtraReach; }; //========================================================================= @@ -461,12 +500,11 @@ namespace FloorZ = P.StrateBottomWorldZ + StrateHeight * P.FloorRelativeHeight; CeilZ = P.StrateBottomWorldZ + StrateHeight * P.CeilingRelativeHeight; - // Amplitudes MAXIMALES des deux bruits. Le contrat de `VoxelNoise::FBM` est [-1,1] - // (noté à sa définition), donc ces bornes sont des garanties, pas des estimations — - // c'est exactement ce qui autorise un verdict de boîte SÛR. - // FBM's contract is [-1,1], so these bounds are guarantees, not estimates. - FloorAmp = VOXEL_NOISE_SCALE * FMath::Max(FloorRoughness, 0.0f); - CeilAmp = VOXEL_NOISE_SCALE * FMath::Max(CeilRoughness, 0.0f); + // FBM's proved supremum is 1.5, not the parameter's nominal [-1,1] label. Bounds must + // cover every authored value, so never normalise the roughness parameter to repair a + // bound: multiply the actual maximum noise envelope instead. + FloorAmp = VOXEL_NOISE_SCALE * VF_PerlinAbsBound * FMath::Abs(FloorRoughness); + CeilAmp = VOXEL_NOISE_SCALE * VF_PerlinAbsBound * FMath::Abs(CeilRoughness); } EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } @@ -1142,13 +1180,48 @@ namespace * VOXEL_NOISE_SCALE * Strength; } - // Ne touche pas la densité par lui-même ; la source amont a déjà compté son amplitude dans - // sa portée (`ExtraReach`). Cf. SIMPLIFICATION DE PHASE 1 dans VoxelDensityOp.h. + // This modifier does not touch density itself. Its interval update is the only box-query + // responsibility; the downstream converter consumes the resulting interval. EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { return EVoxelOpEffect::Identity; } + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + if (!InOut.IsKnown()) { return; } + if (!FMath::IsFinite(Strength) || !FMath::IsFinite(Frequency) + || !FMath::IsFinite(ApplyWithin) || BaseOctaves <= 0) + { + InOut.SetUnknown(); + return; + } + if (Strength <= 0.0f) { return; } + + // The noise input is float after the deliberate FVector round-trip in Eval. If an + // authored frequency/box product overflows, Eval can publish a non-finite SDF and no + // finite interval is a proof. Unknown costs the skip and protects the geometry. + const float MaxAbsCoord = FMath::Max3( + FMath::Max(FMath::Abs((float)VoxelBox.Min.X), FMath::Abs((float)VoxelBox.Max.X)), + FMath::Max(FMath::Abs((float)VoxelBox.Min.Y), FMath::Abs((float)VoxelBox.Max.Y)), + FMath::Max(FMath::Abs((float)VoxelBox.Min.Z), FMath::Abs((float)VoxelBox.Max.Z))); + if (!FMath::IsFinite(MaxAbsCoord) || !FMath::IsFinite(MaxAbsCoord * FMath::Abs(Frequency))) + { + InOut.SetUnknown(); + return; + } + + // The gate is `Sdf < ApplyWithin`. If the whole input interval is outside it, the + // modifier is exactly the identity. Otherwise add the proven FBM envelope to both + // sides; conditional untouched points are still covered by [Min, Max] + envelope. + if (InOut.Min >= ApplyWithin) { return; } + + const float Amplitude = FMath::Abs(Strength) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound; + InOut.Min = VF_SaturatingAdd(InOut.Min, -Amplitude); + InOut.Max = VF_SaturatingAdd(InOut.Max, Amplitude); + } + private: float Strength, Frequency; int32 BaseOctaves; @@ -1349,12 +1422,100 @@ namespace EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - return EVoxelOpEffect::Identity; // la source a répondu pour la paire + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!FMath::IsFinite(Coefficient) || !FMath::IsFinite(Blend) + || !FMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return EVoxelOpEffect::Both; + } + if (Coefficient < 0.0f) { return EVoxelOpEffect::CarveOnly; } + if (Coefficient > 0.0f) { return EVoxelOpEffect::FillOnly; } + return EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const EVoxelOpEffect Intrinsic = EffectOverBox(VoxelBox, Ctx); + if (Intrinsic == EVoxelOpEffect::Both || Intrinsic == EVoxelOpEffect::Identity) + { + return Intrinsic; + } + return H.Sdf.IsKnown() && H.Sdf.Min >= Blend + ? EVoxelOpEffect::Identity : Intrinsic; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!FMath::IsFinite(Coefficient) || !FMath::IsFinite(Blend) + || !FMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + return Coefficient < 0.0f ? FMath::Abs(Coefficient) : 0.0f; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!FMath::IsFinite(Coefficient) || !FMath::IsFinite(Blend) + || !FMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + return Coefficient > 0.0f ? FMath::Abs(Coefficient) : 0.0f; + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!FMath::IsFinite(Coefficient)) { return FLT_MAX; } + if (!FMath::IsFinite(Blend) || !FMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + if (Coefficient >= 0.0f) { return 0.0f; } + return IsInactive(H) ? 0.0f : FMath::Abs(Coefficient) * MaxFactor(H); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!FMath::IsFinite(Coefficient)) { return FLT_MAX; } + if (!FMath::IsFinite(Blend) || !FMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + if (Coefficient <= 0.0f) { return 0.0f; } + return IsInactive(H) ? 0.0f : FMath::Abs(Coefficient) * MaxFactor(H); } const TCHAR* DebugName() const override { return TEXT("SdfConvertOp"); } private: + bool IsInactive(const FVoxelBoxHypotheses& H) const + { + return !H.Sdf.IsKnown() ? false : H.Sdf.Min >= Blend; + } + + float MaxFactor(const FVoxelBoxHypotheses& H) const + { + if (!H.Sdf.IsKnown() || !FMath::IsFinite(Blend) + || !FMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return 1.0f; + } + + const float Denom = FMath::Max(Blend * 2.0f, MinDivisor); + if (!(Denom > 0.0f) || !FMath::IsFinite(Denom)) { return 1.0f; } + + const float T = FMath::Clamp((Blend - H.Sdf.Min) / Denom, 0.0f, 1.0f); + return SmoothStep01(T); + } + float Blend, BaseDensity, Sign, MinDivisor; }; @@ -1590,9 +1751,10 @@ namespace // deux ops — c'est-à-dire la complexité qu'on voulait éviter ; // • le `FShaftLedgeMod` en aval a de toute façon besoin de la liste des puits, donc il faut // l'exposer depuis une source ; l'exposer depuis deux serait pire. - // Ce qui est perdu : le verdict de boîte exact sur la seule moitié cylindrique. Ce qui est - // gardé : un `EffectOverBox` conservatif qui teste cercles ET capsules, ce que la version - // séparée aurait dû faire aussi. À revoir si le profil montre que ça compte. + // Ce qui est perdu : le verdict de boîte exact sur la seule moitié cylindrique. Le compromis + // actuel est une propagation d'intervalle qui couvre cercles ET capsules, tandis que le + // `EffectOverBox` intrinsèque reste Identity : le convertisseur ou consommateur aval décide + // séparément de son propre effet. // // 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 9×9 collection resolves each 5×5 tree window @@ -1602,10 +1764,9 @@ namespace class FShaftFieldSource final : public IVoxelDensityOp { public: - FShaftFieldSource(const FVerticalShaftParams& InP, int32 Seed, float InExtraReach, - float InSpineRadius) + FShaftFieldSource(const FVerticalShaftParams& InP, int32 Seed, float InSpineRadius) : P(InP), Salt((uint32)Seed ^ 0x53686674u) // 'Shft' — identique à GetVerticalShaftDensity - , ExtraReach(InExtraReach), SpineRadius(InSpineRadius) {} + , SpineRadius(InSpineRadius) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } @@ -1652,283 +1813,77 @@ namespace * poser d'étagère que sur sa moitié +X/+Y. Même motif que colonne → overhang. */ const FCells& GetCellsAt(float WorldX, float WorldY) const { return GetCells(WorldX, WorldY); } - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // La source répond pour la paire source+carve (SIMPLIFICATION DE PHASE 1) : `CarveOnly` - // si une primitive atteint la boîte, `Identity` sinon. `ExtraReach` couvre la rugosité - // et le blend en aval — le sous-estimer serait un TROU. - // L'enveloppe doit couvrir les deux bornes de `Lerp(ShaftMinRadius, ShaftMaxRadius, t)`, - // pas `ShaftMaxRadius` seul si l'asset inverse les paramètres. The bound must cover - // both radius endpoints before adding connector and downstream reach. - const float Pad = FMath::Max3(P.ShaftMinRadius, P.ShaftMaxRadius, - SpineConnectorRadius()) + ExtraReach; - const FBox Padded = VoxelBox.ExpandBy(Pad); + // This source writes SDF only. A converter or other SDF consumer must inspect the + // propagated interval; the source has no density effect in isolation. + return EVoxelOpEffect::Identity; + } + + /** + * Propagate the source's own SDF interval. Eval selects the inner 3x3 cell neighbourhood, + * so enumerating every cell touched by the box and asking GetCells at that cell centre is an + * exact superset. Shaft axes are infinite in Z; connector capsules use their segment AABB + * as a conservative distance lower bound. + */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + if (!FMath::IsFinite((float)VoxelBox.Min.X) || !FMath::IsFinite((float)VoxelBox.Min.Y) + || !FMath::IsFinite((float)VoxelBox.Min.Z) + || !FMath::IsFinite((float)VoxelBox.Max.X) || !FMath::IsFinite((float)VoxelBox.Max.Y) + || !FMath::IsFinite((float)VoxelBox.Max.Z) + || VoxelBox.Min.X > VoxelBox.Max.X || VoxelBox.Min.Y > VoxelBox.Max.Y + || VoxelBox.Min.Z > VoxelBox.Max.Z + || !FMath::IsFinite(P.ShaftSpacing) || P.ShaftSpacing <= 0.0f) + { + InOut.SetUnknown(); + return; + } const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); - const int32 CX0 = FMath::FloorToInt((float)Padded.Min.X / Spacing); - const int32 CX1 = FMath::FloorToInt((float)Padded.Max.X / Spacing); - const int32 CY0 = FMath::FloorToInt((float)Padded.Min.Y / Spacing); - const int32 CY1 = FMath::FloorToInt((float)Padded.Max.Y / Spacing); + const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing); + const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing); + const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing); + const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing); + constexpr int64 MaxCellsScanned = 64 * 64; + const int64 CellCount = ((int64)CX1 - CX0 + 1) * ((int64)CY1 - CY0 + 1); + if (CellCount <= 0 || CellCount > MaxCellsScanned) + { + InOut.SetUnknown(); + return; + } + float Lower = FLT_MAX; + bool bAnyPrimitive = false; for (int32 cy = CY0; cy <= CY1; ++cy) for (int32 cx = CX0; cx <= CX1; ++cx) { - FShaft Sh; - if (!RollShaft(cx, cy, Sh)) { continue; } - // Cercle (rayon + marge) contre le rectangle XY : un cylindre est infini en Z, donc - // la question est purement XY. - const float R = Sh.R + ExtraReach; - const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Sh.X, - Sh.X - (float)VoxelBox.Max.X)); - const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Sh.Y, - Sh.Y - (float)VoxelBox.Max.Y)); - if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; } - } - - //----------------------------------------------------------------- - // THE STRUCTURAL TREE — enumerate every inner cell that the box can query, then - // collect its complete 5×5 parent window. This is the EffectOverBox mirror of GetCells: - // 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 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 = 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 >= TreeEmitX0 && cx <= TreeEmitX1 - && cy >= TreeEmitY0 && cy <= TreeEmitY1) + const FCells& C = GetCells((cx + 0.5f) * Spacing, (cy + 0.5f) * Spacing); + for (const FShaft& Sh : C.Shafts) { - TreeEmit.Add(Sh); + if (Sh.bOriginSpine) { continue; } + bAnyPrimitive = true; + Lower = FMath::Min(Lower, + VF_DistanceBoxToPointXY(VoxelBox, Sh.X, Sh.Y) - FMath::Abs(Sh.R)); + } + for (const FConn& Conn : C.Conns) + { + bAnyPrimitive = true; + const FVector ConnMin( + FMath::Min(Conn.A.X, Conn.B.X), FMath::Min(Conn.A.Y, Conn.B.Y), + FMath::Min(Conn.A.Z, Conn.B.Z)); + const FVector ConnMax( + FMath::Max(Conn.A.X, Conn.B.X), FMath::Max(Conn.A.Y, Conn.B.Y), + FMath::Max(Conn.A.Z, Conn.B.Z)); + Lower = FMath::Min(Lower, + VF_DistanceBetweenBoxes(VoxelBox.Min, VoxelBox.Max, ConnMin, ConnMax) + - FMath::Abs(Conn.Radius)); } } - const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; - const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; - 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)); - const float HalfDiagXY = 0.5f * FMath::Sqrt( - FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) + - FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y))); - auto ConnectorMayReachBox = [&](const FConn& Conn) - { - const float ConnReach = Conn.Radius + ExtraReach; - const float Zc = Conn.A.Z; - if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { return false; } - const float DistXY = VF_DistPointSegment2D( - CtrXY, FVector2D(Conn.A.X, Conn.A.Y), FVector2D(Conn.B.X, Conn.B.Y)); - return DistXY - HalfDiagXY < ConnReach; - }; - - for (const FShaft& Child : TreeEmit) - { - const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); - const FShaft* Parent = nullptr; - float BestDistanceSq = FLT_MAX; - for (const FShaft& Candidate : TreeCandidates) - { - 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) - { - 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; - } - } - - 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 = VF_SelectVerticalTreeConnectorZ( - P, - P.StrateBottomWorldZ + P.BoundarySealThickness, - P.StrateTopWorldZ - P.BoundarySealThickness, - LinkHash); - const FVector ParentPoint = Parent != nullptr - ? FVector(Parent->X, Parent->Y, Zc) - : FVector(0.0f, 0.0f, Zc); - const FConn TreeConn{ - FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius }; - if (ConnectorMayReachBox(TreeConn)) { return EVoxelOpEffect::CarveOnly; } - } - - //----------------------------------------------------------------- - // LES CONNECTEURS — LES VRAIES CAPSULES, PLUS « un puits existe dans le coin » - //----------------------------------------------------------------- - // ⚠️ CE BLOC RENDAIT `CarveOnly` DÈS QU'UN PUITS **EXISTAIT** dans la boîte élargie de - // `Spacing·1.6 + Pad`, sans jamais regarder un connecteur. Avec les défauts - // (`ShaftSpacing = 55`, `ShaftDensity = 0.6`) cette boîte élargie couvre ~4×4 cellules, - // donc une dizaine de puits : la condition était vraie PARTOUT et l'archétype prouvait - // 0 tuile sur 60. Conservatif, jamais faux — et totalement stérile. - // - // Ce qu'on fait à la place : reconstruire les connecteurs comme `GetCells` les - // construit, et tester la capsule réelle. - // - // ⚠️ POURQUOI L'ÉNUMÉRATION EST UN SUR-ENSEMBLE (donc sûre). `Eval` lit les connecteurs - // 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 é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 - // pris sur (A puis B) dans l'ordre d'insertion, et `GetCells` insère en `(dy, dx)`, - // c'est-à-dire en balayage ligne par ligne. On balaie ici `(cy, cx)`, le même ordre — et - // un ordre ligne par ligne restreint à une sous-grille garde l'ordre relatif de deux - // cellules. Donc la même paire reçoit le même `VoxelHash::Pair`, sans supposer que - // celui-ci soit symétrique. - // - // Was: return CarveOnly as soon as any shaft EXISTED within Spacing*1.6 + Pad, which at - // ShaftSpacing 55 / ShaftDensity 0.6 is true everywhere -- 0 of 60 tiles proved. Now it - // rebuilds the random links the way GetCells does and tests the real capsule. The pair - // enumeration is a superset (safe), and the row-major cell order reproduces GetCells' - // insertion order, so each pair gets the same hash without assuming Pair() is symmetric. - if (P.CrossConnectChance > 0.0f) - { - 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) - for (int32 cx = QX0; cx <= QX1; ++cx) - { - FShaft Sh; - if (RollShaft(cx, cy, Sh)) { Near.Add(Sh); } - } - - // Match GetCells' rule conservatively: any query cell in the central 3x3 may see - // a spine connector. This is a superset for a box, so it can only make the tile - // less likely to be classified AllSolid. - if (SpineRadius > 0.0f - && QX0 <= 1 && QX1 >= -1 - && QY0 <= 1 && QY1 >= -1) - { - Near.Add({0.0f, 0.0f, SpineRadius, 0, 0, true}); - } - - // Z est traité EXACTEMENT (le connecteur est une capsule horizontale à `Zc`), XY de - // façon conservative. Séparer les deux est bien plus serré qu'une demi-diagonale 3D. - for (int32 i = 0; i < Near.Num(); ++i) - for (int32 j = i + 1; j < Near.Num(); ++j) - { - const FShaft& A = Near[i]; - const FShaft& B = Near[j]; - const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); - if (DSq > FMath::Square(Spacing * 1.6f)) { continue; } - - const uint32 PH = VoxelHash::Pair( - FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), - FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu); - const bool bSpineConnector = A.bOriginSpine || B.bOriginSpine; - if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) - { - continue; - } - - const float ConnRadius = bSpineConnector - ? SpineConnectorRadius() : P.ConnectorRadius; - const float Zc = FMath::Lerp(BottomZ, TopZ, - VoxelHash::ToFloat01(VoxelHash::Mix(PH))); - const FConn RandomConn{ - FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnRadius }; - if (ConnectorMayReachBox(RandomConn)) { return EVoxelOpEffect::CarveOnly; } - } - } - - return EVoxelOpEffect::Identity; + if (bAnyPrimitive) { InOut.Set(Lower, FLT_MAX); } + else { InOut.Set(FLT_MAX, FLT_MAX); } } private: @@ -2218,7 +2173,6 @@ namespace FVerticalShaftParams P; uint32 Salt; - float ExtraReach; float SpineRadius; }; @@ -2279,10 +2233,21 @@ namespace // N'ajoute que du solide ⇒ tue AllAir, jamais AllSolid. EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - return (P.LedgeSpacing > 0.0f && P.LedgeDepth > 0.0f) + return (Field != nullptr && P.LedgeSpacing > 0.0f && P.LedgeDepth > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + if (Field == nullptr || P.LedgeSpacing <= 0.0f || P.LedgeDepth <= 0.0f) + { + return EVoxelOpEffect::Identity; + } + return H.Sdf.IsKnown() && H.Sdf.Min >= 0.0f + ? EVoxelOpEffect::Identity : EVoxelOpEffect::FillOnly; + } + private: FVerticalShaftParams P; const FShaftFieldSource* Field; // NON possédant : la pile possède la source @@ -2319,9 +2284,9 @@ namespace class FIslandBlobSource final : public IVoxelDensityOp { public: - FIslandBlobSource(const FFloatingIslandParams& InP, int32 Seed, float InExtraReach) + FIslandBlobSource(const FFloatingIslandParams& InP, int32 Seed) : P(InP), Salt((uint32)Seed ^ 0x49736C64u) // 'Isld' — identique à GetFloatingIslandDensity - , ExtraReach(InExtraReach) {} + {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } @@ -2384,61 +2349,111 @@ namespace } /** - * `FillOnly` si une île peut atteindre la boîte, `Identity` sinon — et sur une strate d'îles - * `Identity` est le cas COURANT, ce qui est tout l'intérêt : combiné à l'`AllAir` de la - * source constante, c'est la première fois qu'un archétype de grotte peut prouver « tout air » - * (`OPSTACK-DECOMPOSITION §7`). + * Cette source écrit uniquement le canal SDF : son `EffectOverBox` intrinsèque est donc + * `Identity`. Le convertisseur de remplissage consomme ensuite l'intervalle publié ; sur une + * strate d'îles, l'absence d'île prouvée est le cas COURANT et, combinée à l'`AllAir` de la + * source constante, permet enfin de prouver « tout air » (`OPSTACK-DECOMPOSITION §7`). * * BORNE, et pourquoi elle est sûre dans les deux directions : * • en XY, `Sdf ≥ DistXY − Rxy` (l'enveloppe ne dépasse jamais `Rxy`), et le warp déplace - * le POINT de `WarpAmp · VOXEL_NOISE_SCALE · √2` au plus (FBM ∈ [−1,1] sur DEUX axes - * indépendants — voir la note √2 dans le corps) ; + * le POINT de `WarpAmp · VOXEL_NOISE_SCALE · √2` au plus (la borne prouvée de chaque + * FBM est 1.5 sur DEUX axes indépendants — voir la note √2 dans le corps) ; * • en Z, `Sdf ≥ WorldZ − TopSurf ≥ WorldZ − TopZ`, donc au-dessus du sommet + marge il * n'y a plus rien à faire. **En dessous, il n'y a PAS de borne** : sous une île, le SDF * vaut ≈ `DistXY` à toute profondeur, donc un mince fil de matière descend le long de * l'axe. C'est le comportement de l'original ; le confondre avec « rien en dessous » * serait un TROU, et c'est pourquoi seule la borne HAUTE est testée. - * • `ExtraReach` couvre l'aval (rugosité, blend du fill, creux du SmoothMin ≤ K/6). + * • cette source ne couvre pas l'aval : elle publie seulement son propre intervalle SDF. + * La rugosité, le blend du fill et le creux du SmoothMin sont bornés par leurs propres + * opérateurs dans le pliage, chacun avec son contrat isolé. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // ⚠️ √2, PAS 1×. Le warp déplace X et Y par DEUX échantillons de bruit INDÉPENDANTS, - // chacun borné par `WarpAmp · VOXEL_NOISE_SCALE`. Le déplacement du POINT est donc la - // diagonale, `WarpMax·√2`, et non `WarpMax`. Une marge à 1× serait fausse de 41 % dans - // le pire cas — c'est-à-dire un trou dans le coin exact où les deux bruits saturent - // ensemble. Rare, et c'est précisément ce qui rendrait le bug injoignable en test. - // TWO independent noise samples ⇒ the point displacement is the diagonal, not one axis. - constexpr float Sqrt2 = 1.4142136f; - const float WarpMax = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f - * VOXEL_NOISE_SCALE * Sqrt2; - const float Pad = ExtraReach + FMath::Abs(WarpMax); - const float MaxR = FMath::Max(P.IslandMinRadius, P.IslandMaxRadius); + // This source writes SDF only. Island filling is the converter's responsibility. + return EVoxelOpEffect::Identity; + } + + /** + * Propagate a source-only lower bound. The exact 3x3 neighbourhood used by Eval is + * enumerated for every XY cell touched by the box. The warp, authored radius endpoints, + * top envelope, and the bounded SmoothMin dip are all included in the bound. + */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + if (!FMath::IsFinite((float)VoxelBox.Min.X) || !FMath::IsFinite((float)VoxelBox.Min.Y) + || !FMath::IsFinite((float)VoxelBox.Min.Z) + || !FMath::IsFinite((float)VoxelBox.Max.X) || !FMath::IsFinite((float)VoxelBox.Max.Y) + || !FMath::IsFinite((float)VoxelBox.Max.Z) + || VoxelBox.Min.X > VoxelBox.Max.X || VoxelBox.Min.Y > VoxelBox.Max.Y + || VoxelBox.Min.Z > VoxelBox.Max.Z + || !FMath::IsFinite(P.IslandSpacing) || !FMath::IsFinite(P.IslandDensity) + || !FMath::IsFinite(P.IslandMinRadius) || !FMath::IsFinite(P.IslandMaxRadius) + || !FMath::IsFinite(P.ThicknessRatio) || !FMath::IsFinite(P.VerticalJitter) + || !FMath::IsFinite(P.TopFlatten) || !FMath::IsFinite(P.SDFBlendRadius) + || P.IslandDensity < 0.0f) + { + InOut.SetUnknown(); + return; + } + + if (P.IslandDensity <= 0.0f) + { + InOut.Set(FLT_MAX, FLT_MAX); + return; + } const float Spacing = FMath::Max(P.IslandSpacing, 1.0f); - const FBox Padded = VoxelBox.ExpandBy(MaxR + Pad); - const int32 CX0 = FMath::FloorToInt((float)Padded.Min.X / Spacing); - const int32 CX1 = FMath::FloorToInt((float)Padded.Max.X / Spacing); - const int32 CY0 = FMath::FloorToInt((float)Padded.Min.Y / Spacing); - const int32 CY1 = FMath::FloorToInt((float)Padded.Max.Y / Spacing); + const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing); + const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing); + const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing); + const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing); + constexpr int64 MaxCellsScanned = 64 * 64; + const int64 CellCount = ((int64)CX1 - CX0 + 1) * ((int64)CY1 - CY0 + 1); + if (CellCount <= 0 || CellCount > MaxCellsScanned) + { + InOut.SetUnknown(); + return; + } + constexpr float Sqrt2 = 1.4142136f; + const float WarpAmp = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f; + const float WarpDiag = FMath::Abs(WarpAmp) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound * Sqrt2; + const float RadiusUpper = FMath::Max3(P.IslandMinRadius, P.IslandMaxRadius, 0.0f); + // For the authored TopFlatten range [0,1], TopSurf never exceeds TopZ. Keeping the + // positive general-range term makes the proof remain safe if a data asset is widened. + const float TopSurfExtra = FMath::Max(P.TopFlatten - 1.0f, 0.0f) + * RadiusUpper * 0.20f * 2.0f; + + float Lower = FLT_MAX; + bool bAnyIsland = false; + const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f); for (int32 cy = CY0; cy <= CY1; ++cy) for (int32 cx = CX0; cx <= CX1; ++cx) { - FIsland Isl; - if (!RollIsland(cx, cy, Isl)) { continue; } - - // Entièrement au-dessus du sommet de l'île (+ marge) ⇒ hors d'atteinte. - if ((float)VoxelBox.Min.Z > Isl.TopZ + Pad) { continue; } - - const float R = Isl.Rxy + Pad; - const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Isl.X, - Isl.X - (float)VoxelBox.Max.X)); - const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Isl.Y, - Isl.Y - (float)VoxelBox.Max.Y)); - if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; } + const FCells& C = GetCells((cx + 0.5f) * Spacing, (cy + 0.5f) * Spacing); + for (const FIsland& Isl : C.Islands) + { + bAnyIsland = true; + const float RadialLower = VF_DistanceBoxToPointXY(VoxelBox, Isl.X, Isl.Y) + - WarpDiag + - FMath::Max(Isl.Rxy, 0.0f); + const float TopLower = (float)VoxelBox.Min.Z - Isl.TopZ - TopSurfExtra; + const float IslandLower = FMath::Max(RadialLower, TopLower); + Lower = FMath::Min(Lower, IslandLower); + } } - return EVoxelOpEffect::Identity; + if (!bAnyIsland) + { + InOut.Set(FLT_MAX, FLT_MAX); + return; + } + + // SmoothMin can dip below the smallest raw term, but its total deficit is bounded by + // one blend radius: once the running minimum is a blend radius below another term, + // the next penalty is zero. This is a lower bound for any number of islands. + InOut.Set(VF_SaturatingAdd(Lower, -BlendK), FLT_MAX); } private: @@ -2528,7 +2543,6 @@ namespace FFloatingIslandParams P; uint32 Salt; - float ExtraReach; }; //========================================================================= @@ -2914,9 +2928,9 @@ namespace // paierait cette élégance très cher. Un deuxième cache par worker coûte une allocation // amortie ; on la paie. // - // Le VERDICT est mémoïsé, et ce n'est pas du confort : `VF_NoCaveOverBox` fait poser la - // question par les DOUZE modificateurs de détail pour la même boîte. Sans mémo, une tuile - // coûterait treize `BuildChunkCache` au lieu d'un. + // Le cache et l'intervalle sont mémoïsés, et ce n'est pas du confort : les DOUZE + // modificateurs de détail consultent le même intervalle pour la même boîte. Sans mémo, + // une tuile coûterait treize `BuildChunkCache` au lieu d'un. // // Second per-worker cache, on purpose: sharing FState::Cache would be sound but would let // tile classification disturb a live generation's hot cache. The verdict is memoised because @@ -2931,6 +2945,7 @@ namespace uint32 KeyLayout = 0xFFFFFFFFu; bool bValid = false; EVoxelOpEffect Verdict = EVoxelOpEffect::Both; + FVoxelBoxSdfInterval SdfInterval; /** DIAGNOSTIC — combien de primitives de chaque classe atteignent la dernière boîte * interrogée, et combien le cache en contenait. Lu par les tests via @@ -2953,11 +2968,10 @@ namespace /** * ✅ LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée. * - * Ce que ça débloque, en un mot : `FSdfConvertOp` renvoie déjà `Identity` (« la source a - * répondu pour la paire ») et les douze modificateurs de détail héritent de ce verdict par - * `VF_NoCaveOverBox`. Le jour où cette fonction rend `Identity` pour une boîte, **quatorze - * opérateurs deviennent l'identité d'un coup** et la tuile est prouvable — c'est pour ça que - * le câblage a été posé à UN endroit et pas treize. + * Ce que ça débloque, en un mot : la source publie un intervalle SDF et `FSdfConvertOp` + * ainsi que les douze modificateurs de détail décident séparément, à partir de cet + * intervalle, s'ils sont identités. Une boîte éloignée peut donc éteindre toute la chaîne, + * mais aucune source ne répond au nom d'un convertisseur ou d'un modificateur. * * LE CRITÈRE — **UNE PRIMITIVE NE COMPTE PAS SI ELLE RATE SON CULL *OU* SI SON SDF RESTE * AU-DESSUS DU SEUIL `T`.** Une disjonction, pas une seule règle, et chaque branche gagne sur @@ -2985,308 +2999,333 @@ namespace * 3. la boîte de recherche du cache est PLUS LARGE que celle de `Eval`, ce qui donne un * SUR-ensemble de primitives : si rien n'atteint la boîte ici, rien ne l'atteint là-bas. * - * The criterion is the per-voxel cull lifted from point to box: if no cached primitive can - * survive its own cull anywhere in the box, Sdf stays FLT_MAX across the whole box and the - * source — with the converter and all twelve modifiers behind it — is the identity. + * The criterion is the per-voxel cull lifted from point to box. The source publishes only + * its own SDF interval; the converter and the twelve modifiers consume that interval in + * their own state-aware folds. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return EVoxelOpEffect::Identity; } + // This source writes SDF only. It must never answer for a converter or modifier that + // happens to follow it; the interval is the source's complete box-query contract. + return EVoxelOpEffect::Identity; + } - //----------------------------------------------------------------- - // 1. LA BOÎTE DOIT TENIR DANS UNE SEULE STRATE, PARAMS COMPRIS - //----------------------------------------------------------------- - // ⚠️ C'est la moitié « boîte » de la garde d'AUDIT §C2. `Eval` résout l'index de strate - // et le pool d'ops PAR CHUNK ; une boîte qui traverse une frontière verrait donc deux - // graphes de salles différents, et un cache unique n'en représenterait aucun. On ne - // devine pas lequel : on rend `Both`. Ça arrive au plus sur les tuiles de bord. - const int32 CZ0 = FMath::FloorToInt((float)VoxelBox.Min.Z / (float)CHUNK_SIZE); - const int32 CZ1 = FMath::FloorToInt((float)VoxelBox.Max.Z / (float)CHUNK_SIZE); - const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / (float)CHUNK_SIZE); - const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / (float)CHUNK_SIZE); - const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / (float)CHUNK_SIZE); - const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / (float)CHUNK_SIZE); + /** + * Publish a conservative interval for this source alone. + * + * The cache is the same morphology implementation used by Eval, but the box proof never + * samples a downstream density op. Rooms and tunnel segments are bounded by the exact + * geometric support that Eval's culls admit; pits and chimneys are bounded in their real + * (unwarped) coordinates. The final SmoothMin can lower the running minimum by at most K. + * + * Soundness is the priority: a failed validation, an overlarge scan, or a non-finite + * intermediate returns Unknown. That can lose a tile skip; it cannot turn a tile into a + * false AllSolid/AllAir result. + */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override + { + FBoxState& B = BoxState(); - // Une boîte qui couvre des dizaines de chunks n'est de toute façon jamais prouvable ; - // la borne évite qu'un appelant futur transforme ce test en boucle coûteuse. - if ((int64)(CX1 - CX0 + 1) * (CY1 - CY0 + 1) * (CZ1 - CZ0 + 1) > 64) + auto Unknown = [&]() { - return EVoxelOpEffect::Both; + B.bValid = false; + B.SdfInterval.SetUnknown(); + InOut.SetUnknown(); + }; + auto Finite = [](float V) { return FMath::IsFinite(V); }; + + const float BoxMinX = (float)VoxelBox.Min.X; + const float BoxMinY = (float)VoxelBox.Min.Y; + const float BoxMinZ = (float)VoxelBox.Min.Z; + const float BoxMaxX = (float)VoxelBox.Max.X; + const float BoxMaxY = (float)VoxelBox.Max.Y; + const float BoxMaxZ = (float)VoxelBox.Max.Z; + if (!Finite(BoxMinX) || !Finite(BoxMinY) || !Finite(BoxMinZ) + || !Finite(BoxMaxX) || !Finite(BoxMaxY) || !Finite(BoxMaxZ) + || BoxMinX > BoxMaxX || BoxMinY > BoxMaxY || BoxMinZ > BoxMaxZ) + { + Unknown(); + return; + } + + // These are the fields that influence the source geometry or its cache window. If an + // authored asset leaves the proven range, the safe answer is Unknown, never a guessed + // "reasonable" radius. Non-positive room density/spacing is an exact Eval no-op. + const float RelevantParams[] = { + P.RoomDensity, P.RoomSpacing, P.MinRoomRadius, P.MaxRoomRadius, P.RoomHeightRatio, + P.RoomFloorCutMin, P.RoomFloorCutMax, P.FloorReliefStrength, P.FloorReliefFrequency, + P.RoomShapeVariety, P.OriginRoomRadius, P.TunnelMinRadius, P.TunnelMaxRadius, + P.TunnelDensity, P.MaxTunnelLength, P.TunnelWarpStrength, P.TunnelHorizontalBias, + P.TunnelEndpointZOffset, P.SDFBlendRadius, P.CaveWarpStrength, P.CaveWarpFrequency, + P.VerticalScale + }; + for (const float V : RelevantParams) + { + if (!Finite(V)) + { + Unknown(); + return; + } + } + + if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) + { + B.bValid = false; + B.NumRooms = B.NumTunnels = B.NumPits = B.NumChimneys = 0; + B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; + B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; + B.WarpDilation = 0.0f; + B.SdfInterval.Set(FLT_MAX, FLT_MAX); + InOut = B.SdfInterval; + return; + } + + if (P.MinRoomRadius < 0.0f || P.MaxRoomRadius < 0.0f || P.RoomHeightRatio < 0.0f + || P.OriginRoomRadius < 0.0f || P.TunnelMinRadius < 0.0f || P.TunnelMaxRadius < 0.0f + || P.SDFBlendRadius < 0.0f) + { + Unknown(); + return; + } + + const int32 CX0 = FMath::FloorToInt(BoxMinX / (float)CHUNK_SIZE); + const int32 CY0 = FMath::FloorToInt(BoxMinY / (float)CHUNK_SIZE); + const int32 CZ0 = FMath::FloorToInt(BoxMinZ / (float)CHUNK_SIZE); + const int32 CX1 = FMath::FloorToInt(BoxMaxX / (float)CHUNK_SIZE); + const int32 CY1 = FMath::FloorToInt(BoxMaxY / (float)CHUNK_SIZE); + const int32 CZ1 = FMath::FloorToInt(BoxMaxZ / (float)CHUNK_SIZE); + + const int64 SpanX = (int64)CX1 - (int64)CX0 + 1; + const int64 SpanY = (int64)CY1 - (int64)CY0 + 1; + const int64 SpanZ = (int64)CZ1 - (int64)CZ0 + 1; + if (SpanX <= 0 || SpanY <= 0 || SpanZ <= 0 || SpanX * SpanY * SpanZ > 64) + { + Unknown(); + return; } int32 StrateIdx = 0; const TArray* TerrainOps = nullptr; if (Manager) { - StrateIdx = Manager->GetStrateIndex(((float)CZ0 + 0.5f) * CHUNK_SIZE * VOXEL_SIZE); + StrateIdx = Manager->GetStrateIndex( + ((float)CZ0 + 0.5f) * CHUNK_SIZE * VOXEL_SIZE); for (int32 CZ = CZ0 + 1; CZ <= CZ1; ++CZ) { - if (Manager->GetStrateIndex(((float)CZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE) != StrateIdx) + if (Manager->GetStrateIndex( + ((float)CZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE) != StrateIdx) { - return EVoxelOpEffect::Both; + Unknown(); + return; } } - // ⚠️ LE POOL D'OPS FAIT PARTIE DE LA GÉOMÉTRIE, contrairement à ce qu'on croit en - // lisant `FCachedRoom` : `BuildChunkCache` s'en sert pour cuire les PITS et les - // CHEMINÉES (`OpParams` y lit `PitDensity`, `PitMinRadius`…). Passer `nullptr` - // « puisque la forme des salles n'en dépend pas » sous-bornerait le cache et - // pourrait rendre `Identity` au-dessus d'un pit réel. Un trou, exactement. - UVoxelStrateDefinition* Def0 = Manager->GetStrateForChunk(FIntVector(CX0, CY0, CZ0)); + // The room-op pool is part of the SDF: BuildChunkCache bakes pits, chimneys, and + // columns from it. Every chunk touched by this query must therefore resolve to the + // same definition before one cache can represent the box. + UVoxelStrateDefinition* Def0 = + Manager->GetStrateForChunk(FIntVector(CX0, CY0, CZ0)); for (int32 CZ = CZ0; CZ <= CZ1; ++CZ) for (int32 CY = CY0; CY <= CY1; ++CY) for (int32 CX = CX0; CX <= CX1; ++CX) { if (Manager->GetStrateForChunk(FIntVector(CX, CY, CZ)) != Def0) { - return EVoxelOpEffect::Both; + Unknown(); + return; } } if (Def0) { TerrainOps = &Def0->TerrainOperations; } } - //----------------------------------------------------------------- - // 2. LE MÉMO — clé complète (§C2 : jamais de clé sans params ni LayoutVersion) - //----------------------------------------------------------------- - // `Ctx.LayoutVersion` plutôt que le membre rempli par `PrepareChunk` : rien ne garantit - // qu'un appelant de `ClassifyBox` ait ouvert un chunk, et une version périmée dans une - // clé de cache est précisément la régression du 2026-07-27. const uint32 LV = Ctx.LayoutVersion; - - FBoxState& B = BoxState(); if (B.bValid && B.KeyBox == VoxelBox && B.KeyStrate == StrateIdx && B.KeySeed == SeedU && B.KeyFingerprint == ParamsFingerprint && B.KeyLayout == LV) { - return B.Verdict; + InOut = B.SdfInterval; + return; } - //----------------------------------------------------------------- - // 3. LE CACHE POUR LA BOÎTE INTERROGÉE - //----------------------------------------------------------------- const float Warp = (P.CaveWarpStrength > 0.0f) ? P.CaveWarpStrength * VOXEL_NOISE_SCALE * VF_PerlinAbsBound : 0.0f; + if (!Finite(Warp)) + { + Unknown(); + return; + } - // `+ 2` : la même marge de gradient que la boîte de recherche de `Eval`. + // The search window is a superset of every warped point in the box, with the same + // two-voxel gradient margin used by Eval's cache path. VoxelCaveMorphology::BuildChunkCache( B.Cache, - (float)VoxelBox.Min.X - Warp - 2.0f, (float)VoxelBox.Min.Y - Warp - 2.0f, - (float)VoxelBox.Max.X + Warp + 2.0f, (float)VoxelBox.Max.Y + Warp + 2.0f, + BoxMinX - Warp - 2.0f, BoxMinY - Warp - 2.0f, + BoxMaxX + Warp + 2.0f, BoxMaxY + Warp + 2.0f, P, SeedU, StrateIdx, TerrainOps); - //----------------------------------------------------------------- - // 4. LE CULL PAR VOXEL, ÉLEVÉ DU POINT À LA BOÎTE - //----------------------------------------------------------------- - // Espace de REQUÊTE des salles et des tunnels : XY dilaté du warp, Z passé par `EffZ` - // (monotone croissante tant que `VerticalScale > 0`, donc min et max se conservent) - // puis dilaté du warp lui aussi — `Eval` warpe bien les trois axes. - const FVector QMin((float)VoxelBox.Min.X - Warp, - (float)VoxelBox.Min.Y - Warp, - EffZ((float)VoxelBox.Min.Z) - Warp); - const FVector QMax((float)VoxelBox.Max.X + Warp, - (float)VoxelBox.Max.Y + Warp, - EffZ((float)VoxelBox.Max.Z) + Warp); - - auto SphereHitsBox = [](const FVector& C, float RSq, const FVector& Mn, const FVector& Mx) + const float EffectiveMinZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? BoxMinZ / P.VerticalScale : BoxMinZ; + const float EffectiveMaxZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? BoxMaxZ / P.VerticalScale : BoxMaxZ; + const FVector QMin(BoxMinX - Warp, BoxMinY - Warp, EffectiveMinZ - Warp); + const FVector QMax(BoxMaxX + Warp, BoxMaxY + Warp, EffectiveMaxZ + Warp); + const FVector RMin(BoxMinX, BoxMinY, BoxMinZ); + const FVector RMax(BoxMaxX, BoxMaxY, BoxMaxZ); + if (!Finite((float)QMin.X) || !Finite((float)QMin.Y) || !Finite((float)QMin.Z) + || !Finite((float)QMax.X) || !Finite((float)QMax.Y) || !Finite((float)QMax.Z)) { - const float dx = FMath::Max3((float)(Mn.X - C.X), 0.0f, (float)(C.X - Mx.X)); - const float dy = FMath::Max3((float)(Mn.Y - C.Y), 0.0f, (float)(C.Y - Mx.Y)); - const float dz = FMath::Max3((float)(Mn.Z - C.Z), 0.0f, (float)(C.Z - Mx.Z)); - return (dx * dx + dy * dy + dz * dz) <= RSq; + Unknown(); + return; + } + + const float K = P.SDFBlendRadius; + const float WormThreshold = FMath::IsFinite(P.WormNetworkRange) + ? FMath::Max(3.0f * K, P.WormNetworkRange) : FLT_MAX; + const float ThresholdWithBlend = VF_SaturatingAdd(WormThreshold, K); + float Lower = FLT_MAX; + bool bAnyPrimitive = false; + bool bInvalidBound = false; + + auto Consider = [&](float Candidate) + { + if (!Finite(Candidate)) + { + bInvalidBound = true; + return; + } + bAnyPrimitive = true; + Lower = FMath::Min(Lower, Candidate); + }; + auto CountThreshold = [&](float Candidate, int32& Count) + { + if (Finite(Candidate) && Candidate < ThresholdWithBlend) { ++Count; } }; - // Pits, cheminées et colonnes : coordonnées RÉELLES, donc boîte NON dilatée. - const float RMinX = (float)VoxelBox.Min.X, RMaxX = (float)VoxelBox.Max.X; - const float RMinY = (float)VoxelBox.Min.Y, RMaxY = (float)VoxelBox.Max.Y; - const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z; - - auto CircleHitsBoxXY = [&](float CX, float CY, float RSq) - { - const float dx = FMath::Max3(RMinX - CX, 0.0f, CX - RMaxX); - const float dy = FMath::Max3(RMinY - CY, 0.0f, CY - RMaxY); - return (dx * dx + dy * dy) <= RSq; - }; - - //----------------------------------------------------------------- - // ⚠️ PAS D'EARLY-OUT : ON COMPTE PAR CLASSE, ET C'EST DÉLIBÉRÉ - //----------------------------------------------------------------- - // La version d'origine s'arrêtait à la première primitive atteinte. Elle donnait le bon - // verdict et AUCUNE information : quand `AllSolid killed by: RoomGraphSource x40` est - // tombé, il n'y avait aucun moyen de dire si le coupable était les salles, les tunnels - // ou les pits — donc aucun moyen de savoir quoi resserrer. Compter les cinq classes - // sépare les causes, et c'est la règle que ce projet a payée plusieurs fois : quand un - // zéro a plusieurs causes possibles, chacune a son propre nombre. - // - // Le coût est nul à l'échelle qui compte : on vient d'appeler `BuildChunkCache`, qui - // est de plusieurs ordres de grandeur au-dessus d'un parcours de ~100 structs, et le - // verdict est mémoïsé donc ce parcours arrive UNE fois par boîte, pas treize. - // - // No early-out on purpose: stopping at the first hit gives the right verdict and no - // information. When a zero has several possible causes, each gets its own number. - //----------------------------------------------------------------- - // ⚠️⚠️ LE SEUIL `T` — CE QUE `Identity` VEUT DIRE ICI, ET SA CONDITION DE VALIDITÉ - //----------------------------------------------------------------- - // Jusqu'ici `Identity` signifiait « `Sdf` reste `FLT_MAX` sur toute la boîte ». C'est - // vrai, mais c'est plus fort que nécessaire, et cette force coûtait la quasi-totalité du - // gain : aucun consommateur ne regarde `Sdf` au-delà d'un seuil. - // - // Les TROIS consommateurs du canal SDF de cette pile, RELUS un par un (pas supposés) : - // • `FSdfConvertOp::Eval` → `if (InOut.Sdf >= Blend) return;` et - // `BuildTunnelNetworkStack` l'instancie par `MakeSdfCarve(P.SDFBlendRadius, …)` - // ⇒ seuil = `K`. - // • les DOUZE modificateurs → `VF_NearCaveSurface` ⇒ seuil = `3·K`. - // • `FWormFieldSource::Eval` → `if (CaveSDF >= P.WormNetworkRange) NetworkMask = 0;` - // puis `if (NetworkMask <= 0) return;` ⇒ seuil = `WormNetworkRange`. - // (`FCaveTerraceMod` re-sonde le SDF en Z±1, donc HORS de la boîte — mais son gate - // `VF_NearCaveSurface` est testé AVANT la sonde, vérifié ligne par ligne. Un gate faux - // partout ⇒ aucune sonde n'est jamais émise.) - // - // Donc `Sdf ≥ T` avec `T = max(K, 3K, WormNetworkRange)` suffit à éteindre les trois. - // - // ⚠️ **TOUT NOUVEAU CONSOMMATEUR DU CANAL `Sdf` DOIT AVOIR UN SEUIL ≤ T, OU ÊTRE AJOUTÉ - // À CE `Max`.** C'est la seule dette de couplage de cette fonction, et elle est réelle : - // un opérateur qui regarderait `Sdf < 100` verrait des verdicts `Identity` faux, donc - // des tuiles sans géométrie ET SANS COLLISION. Écrit ici parce que c'est ici qu'on - // atterrit en l'ajoutant. - // - // ⚠️ ET LA RAISON POUR LAQUELLE `− K` SUFFIT MALGRÉ N PRIMITIVES. `SmoothMin(A,B,K)` - // vaut `min(A,B) − H³K/6` avec `H = max(K − |A−B|, 0)/K`. Deux conséquences lues sur la - // formule : la pénalité est EXACTEMENT nulle dès que `|A−B| ≥ K`, et le minimum courant - // ne peut donc jamais descendre plus de `K` sous le plus petit des termes — arrivé là, - // `H = 0` et les plis suivants le laissent intact. D'où `Sdf ≥ min_i(SDF_i) − K` pour un - // nombre QUELCONQUE de primitives, et non `− N·K/6`. C'est ce qui rend ce critère - // utilisable au lieu d'être noyé sous le nombre de tunnels. - // - // Identity now means "Sdf >= T over the box", not "Sdf stays FLT_MAX" — no consumer - // looks past its own threshold, and the three that exist were read one by one. ANY NEW - // CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR BE ADDED TO THIS MAX. - // The -K slack covers any number of primitives because SmoothMin's penalty is exactly - // zero once |A-B| >= K, so the running minimum saturates at K below the true minimum. - const float K = FMath::Max(P.SDFBlendRadius, 0.0f); - const float T = FMath::Max(3.0f * K, P.WormNetworkRange); - - B.NumRooms = B.Cache.Rooms.Num(); - B.NumTunnels = B.Cache.Tunnels.Num(); - B.NumPits = B.Cache.Pits.Num(); + B.NumRooms = B.Cache.Rooms.Num(); + B.NumTunnels = B.Cache.Tunnels.Num(); + B.NumPits = B.Cache.Pits.Num(); B.NumChimneys = B.Cache.Chimneys.Num(); B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; - - // La MÊME boîte sans dilatation de warp — diagnostic seulement, voir plus bas. - const FVector NWMin((float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, EffZ((float)VoxelBox.Min.Z)); - const FVector NWMax((float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, EffZ((float)VoxelBox.Max.Z)); - const float NoWarpHalfDiag = 0.5f * (float)(NWMax - NWMin).Size(); B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; B.WarpDilation = Warp; - for (const FCachedRoom& R : B.Cache.Rooms) - { - if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { ++B.HitRooms; } - if (SphereHitsBox(R.Center, R.CullRadiusSq, NWMin, NWMax)) { ++B.HitRoomsNoWarp; } - } - //----------------------------------------------------------------- - // LES TUNNELS ONT DROIT À UN SECOND TEST, ET C'EST LÀ QUE SE TROUVE LE GAIN - //----------------------------------------------------------------- - // ⚠️ CECI CHANGE LE SENS D'`Identity` POUR CET OPÉRATEUR — lire la note « LE SEUIL T » - // ci-dessus avant de toucher quoi que ce soit ici. - // - // Le cull par voxel d'un tunnel est sa SPHÈRE ENGLOBANTE. Pour une capsule longue et - // fine c'est une sur-estimation énorme : avec `MaxTunnelLength = 200` et - // `TunnelMaxRadius = 7`, la sphère a un rayon jusqu'à ~107 pour un tube de rayon 7. La - // mesure le disait sans ambiguïté — 32 tuiles bloquées sur 34 par des tunnels, contre - // 21 par des salles. - // - // Donc : soit le tunnel rate son cull (il ne s'exécute pas), soit son PROPRE SDF reste - // ≥ `T + K` sur toute la boîte (il s'exécute mais ne peut pas descendre le champ assez - // bas pour qu'un consommateur s'allume). L'un ou l'autre suffit. - // - // La borne est exacte, pas prudente : `TaperedCapsule` rend - // `Dist(P, PlusProcheSurSegment) − Lerp(Ra, Rb, t)`, donc - // `SDF ≥ dist(P, segment) − max(Ra, Rb)` — RELU dans `VoxelCaveMorphology.h`, pas supposé. - // Et `dist(boîte, segment) ≥ dist(centre, segment) − demi-diagonale` par inégalité - // triangulaire : conservatif du bon côté, et trivialement vrai. - // - // A tunnel's per-voxel cull is its BOUNDING SPHERE — for a 200-long tube of radius 7 - // that sphere has radius ~107. So a tunnel does not matter if it fails that cull OR if - // its own SDF stays >= T + K over the box. The bound is exact: TaperedCapsule is - // genuinely dist-to-segment minus an interpolated radius, and box-to-segment distance is - // bounded below by centre-to-segment minus the half-diagonal. - const float TunnelClear = T + K; - const FVector QCenter = (QMin + QMax) * 0.5; - const float BoxHalfDiag = 0.5f * (float)(QMax - QMin).Size(); + const FVector NoWarpMin(BoxMinX, BoxMinY, EffectiveMinZ); + const FVector NoWarpMax(BoxMaxX, BoxMaxY, EffectiveMaxZ); - for (const FCachedTunnel& Tn : B.Cache.Tunnels) + for (const FCachedRoom& Room : B.Cache.Rooms) { - if (!SphereHitsBox(Tn.BoundCenter, Tn.BoundRadiusSq, QMin, QMax)) { continue; } - - float MaxR = FMath::Max(Tn.RadiusA, Tn.RadiusB); - float DistToAxis; - if (Tn.bHasMidpoint) + const float CullRadiusSq = Room.CullRadiusSq; + if (!Finite((float)Room.Center.X) || !Finite((float)Room.Center.Y) + || !Finite((float)Room.Center.Z) || !Finite(CullRadiusSq) + || CullRadiusSq < 0.0f) { - // Deux segments : le SDF du tunnel est le `Min` des deux, donc sa borne - // inférieure est le `Min` des deux bornes. - MaxR = FMath::Max(MaxR, Tn.RadiusMid); - DistToAxis = FMath::Min( - VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.Midpoint), - VF_DistPointSegment(QCenter, Tn.Midpoint, Tn.EndpointB)); - } - else - { - DistToAxis = VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.EndpointB); + bInvalidBound = true; + continue; } - if (DistToAxis - BoxHalfDiag - MaxR >= TunnelClear) { continue; } - - ++B.HitTunnels; - - // DIAGNOSTIC — le MÊME test avec une dilatation de warp NULLE. Ne participe à aucun - // verdict ; il répond à la seule question que trois builds de resserrement n'ont - // jamais posée : « combien de ce blocage est de la géométrie, et combien est ma - // propre boîte dilatée ? ». `HitTunnels - HitTunnelsNoWarp` est exactement la part - // que le warp coûte. - if (DistToAxis - NoWarpHalfDiag - MaxR < TunnelClear) { ++B.HitTunnelsNoWarp; } + const float RoomRadius = FMath::Sqrt(CullRadiusSq); + const float RoomLower = VF_DistanceBetweenBoxes( + QMin, QMax, Room.Center, Room.Center) - RoomRadius; + const float RoomLowerNoWarp = VF_DistanceBetweenBoxes( + NoWarpMin, NoWarpMax, Room.Center, Room.Center) - RoomRadius; + Consider(RoomLower); + CountThreshold(RoomLower, B.HitRooms); + CountThreshold(RoomLowerNoWarp, B.HitRoomsNoWarp); } - // Miroir exact des deux `continue` de `Eval` : actif si `Z < TopZ + BlendK` ET - // `Z >= TopZ - Depth - BlendK`. + + for (const FCachedTunnel& Tunnel : B.Cache.Tunnels) + { + const float MaxRadius = FMath::Max3( + FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB), + Tunnel.bHasMidpoint ? FMath::Abs(Tunnel.RadiusMid) : 0.0f); + const FVector SegmentAMin( + FMath::Min(Tunnel.EndpointA.X, Tunnel.bHasMidpoint ? Tunnel.Midpoint.X : Tunnel.EndpointB.X), + FMath::Min(Tunnel.EndpointA.Y, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Y : Tunnel.EndpointB.Y), + FMath::Min(Tunnel.EndpointA.Z, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Z : Tunnel.EndpointB.Z)); + const FVector SegmentAMax( + FMath::Max(Tunnel.EndpointA.X, Tunnel.bHasMidpoint ? Tunnel.Midpoint.X : Tunnel.EndpointB.X), + FMath::Max(Tunnel.EndpointA.Y, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Y : Tunnel.EndpointB.Y), + FMath::Max(Tunnel.EndpointA.Z, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Z : Tunnel.EndpointB.Z)); + + float TunnelLower = VF_DistanceBetweenBoxes( + QMin, QMax, SegmentAMin, SegmentAMax) - MaxRadius; + if (Tunnel.bHasMidpoint) + { + const FVector SegmentBMin( + FMath::Min(Tunnel.Midpoint.X, Tunnel.EndpointB.X), + FMath::Min(Tunnel.Midpoint.Y, Tunnel.EndpointB.Y), + FMath::Min(Tunnel.Midpoint.Z, Tunnel.EndpointB.Z)); + const FVector SegmentBMax( + FMath::Max(Tunnel.Midpoint.X, Tunnel.EndpointB.X), + FMath::Max(Tunnel.Midpoint.Y, Tunnel.EndpointB.Y), + FMath::Max(Tunnel.Midpoint.Z, Tunnel.EndpointB.Z)); + TunnelLower = FMath::Min( + TunnelLower, + VF_DistanceBetweenBoxes(QMin, QMax, SegmentBMin, SegmentBMax) - MaxRadius); + } + + const float TunnelLowerNoWarp = VF_DistanceBetweenBoxes( + NoWarpMin, NoWarpMax, SegmentAMin, SegmentAMax) - MaxRadius; + Consider(TunnelLower); + CountThreshold(TunnelLower, B.HitTunnels); + CountThreshold(TunnelLowerNoWarp, B.HitTunnelsNoWarp); + } + for (const FCachedPit& Pit : B.Cache.Pits) { - if (!(RMinZ < Pit.TopZ + Pit.BlendK)) { continue; } - if (!(RMaxZ >= Pit.TopZ - Pit.Depth - Pit.BlendK)) { continue; } - if (CircleHitsBoxXY(Pit.CenterX, Pit.CenterY, Pit.BoundXYRadiusSq)) { ++B.HitPits; } + if (!(BoxMinZ < Pit.TopZ + Pit.BlendK) + || !(BoxMaxZ >= Pit.TopZ - Pit.Depth - Pit.BlendK)) + { + continue; + } + const float MaxRadius = FMath::Abs(Pit.Radius) + FMath::Abs(Pit.FlareExtra); + const float PitLower = VF_DistanceBoxToPointXY( + FBox(RMin, RMax), Pit.CenterX, Pit.CenterY) - MaxRadius; + Consider(PitLower); + CountThreshold(PitLower, B.HitPits); } - // Miroir exact : actif si `Z > BottomZ - BlendK` ET `Z <= BottomZ + Height + BlendK`. - for (const FCachedChimney& Ch : B.Cache.Chimneys) + + for (const FCachedChimney& Chimney : B.Cache.Chimneys) { - if (!(RMaxZ > Ch.BottomZ - Ch.BlendK)) { continue; } - if (!(RMinZ <= Ch.BottomZ + Ch.Height + Ch.BlendK)) { continue; } - if (CircleHitsBoxXY(Ch.CenterX, Ch.CenterY, Ch.BoundXYRadiusSq)) { ++B.HitChimneys; } + if (!(BoxMaxZ > Chimney.BottomZ - Chimney.BlendK) + || !(BoxMinZ <= Chimney.BottomZ + Chimney.Height + Chimney.BlendK)) + { + continue; + } + const float MaxRadius = FMath::Abs(Chimney.Radius) + FMath::Abs(Chimney.FlareExtra); + const float ChimneyLower = VF_DistanceBoxToPointXY( + FBox(RMin, RMax), Chimney.CenterX, Chimney.CenterY) - MaxRadius; + Consider(ChimneyLower); + CountThreshold(ChimneyLower, B.HitChimneys); } - //----------------------------------------------------------------- - // ✅ LES COLONNES NE SONT PLUS TESTÉES — ET C'EST PROUVÉ, PAS RELÂCHÉ - //----------------------------------------------------------------- - // La première version les traitait en cylindres INFINIS en Z (le cache ne leur donne - // aucune borne verticale), ce qui rendait `Both` pour une boîte située des centaines de - // voxels sous la salle propriétaire. Inutile : le seul consommateur des colonnes est - // `FRoomColumnMod`, dont l'`Eval` commence par - // `if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; }` - // Si aucune salle, aucun tunnel, aucun pit et aucune cheminée n'atteint la boîte, `Sdf` - // y reste `FLT_MAX`, le gate est faux à chaque voxel, et **aucune colonne ne peut - // s'exécuter** — quelle que soit sa position XY. Le test était donc REDONDANT, pas - // prudent. Le retirer resserre le verdict sans toucher à sa correction. - // - // Columns are not tested: their only consumer gates on Sdf being near a cave surface, - // which cannot happen in a box no room/tunnel/pit/chimney reaches. The test was - // redundant rather than conservative, and it was the loosest one here. - const bool bReached = (B.HitRooms + B.HitTunnels + B.HitPits + B.HitChimneys) > 0; + if (bInvalidBound) + { + Unknown(); + return; + } - B.Verdict = bReached ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; + FVoxelBoxSdfInterval Own; + if (!bAnyPrimitive) + { + Own.Set(FLT_MAX, FLT_MAX); + } + else + { + Own.Set(VF_SaturatingAdd(Lower, -K), FLT_MAX); + } + + B.SdfInterval = Own; + B.Verdict = Own.IsKnown() && Own.Min >= WormThreshold + ? EVoxelOpEffect::Identity : EVoxelOpEffect::Both; B.KeyBox = VoxelBox; B.KeyStrate = StrateIdx; B.KeySeed = SeedU; B.KeyFingerprint = ParamsFingerprint; B.KeyLayout = LV; B.bValid = true; - return B.Verdict; + InOut = Own; } const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); } @@ -3301,37 +3340,35 @@ namespace }; //========================================================================= - // LA CLÉ QUI REND LE PLIAGE NUMÉRIQUE PAYANT : hériter du verdict de la source + // LE GATE DE BOÎTE DES MODIFICATEURS DE CAVE //========================================================================= - // ⚠️ SANS CECI, LES BORNES D'AMPLITUDE NE SERVENT À RIEN SUR CET ARCHÉTYPE, et c'est le point - // que `OPSTACK-DECOMPOSITION §0.2` ne dit pas explicitement. - // - // Les douze modificateurs de détail sont TOUS gated sur `bNearCaveSurface`, c'est-à-dire sur - // `Sdf < SDFBlendRadius·3`. Or `Sdf` ne devient fini que si le graphe de salles a écrit quelque - // chose. **Là où la source prouve qu'aucune salle ni tunnel n'atteint la boîte, `Sdf` reste - // `FLT_MAX` sur toute la boîte, donc les douze sont l'IDENTITÉ** — et pas seulement « bornés ». - // - // Chacun le sait déjà par voxel (son premier `if`) mais le déclarait `Both`/`FillOnly` par boîte, - // ce qui tuait l'hypothèse `AllSolid` du roc profond aussi sûrement qu'un opérateur réellement - // actif. Douze déclarations trop prudentes, une seule cause : ils ne consultaient pas la source - // dont ils dépendent, alors qu'ils en tiennent déjà le pointeur (étape C1). - // - // ⚠️ AUJOURD'HUI CE SHORT-CIRCUIT NE TIRE PRESQUE JAMAIS : `FRoomGraphSource::EffectOverBox` - // rend `Identity` uniquement quand `RoomDensity <= 0`. Il devient l'interrupteur du bedrock - // profond le jour où la source répond SPATIALEMENT (ses bornes de salles et de tunnels sont déjà - // dans le cache — il faut le construire pour la boîte interrogée, ce qui ne se paie qu'une fois - // `ClassifyTile` branché sur `ClassifyBox`). Le câblage est posé maintenant pour que ce jour-là - // il n'y ait qu'UN endroit à changer, pas treize. - // - // Without this, the amplitude bounds buy nothing here: all twelve modifiers are gated on the SDF - // the room source writes, so where the source proves no cave reaches the box they are IDENTITY, - // not merely bounded. They already hold the pointer; they simply were not asking. - FORCEINLINE bool VF_NoCaveOverBox(const FRoomGraphSource* Rooms, const FBox& VoxelBox, - const FVoxelOpContext& Ctx) + // The source publishes an SDF interval; detail modifiers consume that interval here. This is + // deliberately a state test, never a call to the source's EffectOverBox: the latter is the + // source-only answer and must remain valid when the composer chooses a different consumer. + FORCEINLINE bool VF_CaveBoxIsFar(const FVoxelBoxHypotheses& H, float SDFBlendRadius) { - // Pas de source ⇒ `Sdf` reste FLT_MAX ⇒ le gate est faux partout ⇒ identité. Conservatif - // dans le bon sens : on ne rend `Identity` que quand la source elle-même le rend. - return Rooms == nullptr || Rooms->EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity; + const float Threshold = SDFBlendRadius * 3.0f; + return FMath::IsFinite(Threshold) && H.Sdf.IsKnown() && H.Sdf.Min >= Threshold; + } + + FORCEINLINE EVoxelOpEffect VF_CaveDetailEffect(const FRoomGraphSource* Rooms, + const FVoxelBoxHypotheses& H, + float SDFBlendRadius, + EVoxelOpEffect Intrinsic) + { + // A detail op with no room source has the same no-op guard as Eval. An unknown interval is + // not a reason to claim Identity: uncertainty costs a skip, while a false skip is a hole. + return (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) + ? EVoxelOpEffect::Identity : Intrinsic; + } + + FORCEINLINE float VF_CaveDetailMax(const FRoomGraphSource* Rooms, + const FVoxelBoxHypotheses& H, + float SDFBlendRadius) + { + // Per-room overrides can activate or enlarge a modifier even when the strate-level + // parameter is zero. Near/unknown therefore returns the safe default amplitude. + return (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) ? 0.0f : FLT_MAX; } //========================================================================= @@ -3364,12 +3401,8 @@ namespace class FCaveRoughnessMod final : public IVoxelDensityOp { public: - /** @param InRoomsForBox ⚠️ UNIQUEMENT pour `EffectOverBox`. Cet opérateur lit délibérément - * les params de la STRATE et NON `LocalParams()` (voir la note d'en-tête) ; le pointeur ne - * sert qu'à hériter du verdict de boîte de la source. Ne pas s'en servir dans `Eval`. */ - FCaveRoughnessMod(const FStrateGenerationParams& InP, int32 Seed, - const FRoomGraphSource* InRoomsForBox) - : P(InP), SeedU((uint32)Seed), RoomsForBox(InRoomsForBox) {} + FCaveRoughnessMod(const FStrateGenerationParams& InP, int32 Seed) + : P(InP), SeedU((uint32)Seed) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } EVoxelOpChannelMask ChannelReads() const override @@ -3496,37 +3529,56 @@ namespace * `Both` : la rugosité peut pousser dans les deux sens (le clamp ne s'applique que dans * l'air certain). Conservatif et donc correct, mais coûteux — comme les vers, c'est un * opérateur dont l'AMPLITUDE est bornée alors que sa DIRECTION ne l'est pas : - * |TotalRough| ≤ 1.4 · SurfaceRoughness · VOXEL_NOISE_SCALE, fade ∈ [0,1]. + * |TotalRough| ≤ 2.1 · SurfaceRoughness · VOXEL_NOISE_SCALE, fade ∈ [0,1]. * Deuxième client pour le pliage numérique de `OPSTACK-DECOMPOSITION §0.2`, noté au point * exact où la borne manque (le premier est `FWormFieldSource::MaxCarveAmplitude`). */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - if (VF_NoCaveOverBox(RoomsForBox, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.SurfaceRoughness > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const EVoxelOpEffect Intrinsic = EffectOverBox(VoxelBox, Ctx); + if (Intrinsic == EVoxelOpEffect::Identity) { return Intrinsic; } + return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? EVoxelOpEffect::Identity : Intrinsic; + } + /** * ✅ Borne consommée par le pliage numérique. `RoughNoise` et `FineNoise` respectent tous - * deux le contrat `[-1, 1]` de fBM/Ridged/Cellular, mis à l'échelle par `VOXEL_NOISE_SCALE`, - * et `TotalRough = Rough·S + Fine·S·0.4` ⇒ `|TotalRough| ≤ 1.4 · S · SCALE`. Le fade est + * deux sont couverts par la borne prouvée `VF_PerlinAbsBound = 1.5`, mis à l'échelle par + * `VOXEL_NOISE_SCALE`, et `TotalRough = Rough·S + Fine·S·0.4` ⇒ + * `|TotalRough| ≤ 2.1 · S · SCALE`. Le fade est * dans `[0,1]`. Le clamp anti-remplissage ne fait que RÉDUIRE côté fill ; on ne s'appuie pas * dessus (il ne s'applique que dans l'air certain), donc la borne fill reste la même. */ float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); } float MaxFillOverBox (const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); } + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? 0.0f : MaxCarveOverBox(VoxelBox, Ctx); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? 0.0f : MaxFillOverBox(VoxelBox, Ctx); + } + /** La borne d'amplitude, en unités de densité. */ float MaxAmplitude() const { return (P.SurfaceRoughness > 0.0f) - ? (1.4f * P.SurfaceRoughness * VOXEL_NOISE_SCALE) : 0.0f; + ? (2.1f * P.SurfaceRoughness * VOXEL_NOISE_SCALE) : 0.0f; } private: FStrateGenerationParams P; uint32 SeedU; - const FRoomGraphSource* RoomsForBox; // NON possédant, et NON lu par Eval }; //========================================================================= @@ -3627,13 +3679,18 @@ namespace /** `Both` : `Offset` change de signe d'une demi-marche à l'autre. Amplitude bornée par * `StepH/2` — troisième client du pliage numérique de `OPSTACK-DECOMPOSITION §0.2`. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.TerraceStepHeight > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::Both); + } + private: FStrateGenerationParams P; uint32 SeedU; @@ -3687,21 +3744,19 @@ namespace /** Ne SOUSTRAIT que (`LineValue ≥ 0`, `Depth ≥ 0`) ⇒ `CarveOnly`, jamais `Both`. Un des * rares modificateurs de détail qui garde une DIRECTION exploitable par le pliage. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // ⚠️ VERDICT DE BOÎTE ET OVERRIDE PAR SALLE : la borne se lit sur les params de la - // STRATE, pas sur ceux d'une salle — une boîte couvre plusieurs salles, donc aucune - // copie par voxel n'y a de sens. Une salle ne peut qu'ACTIVER un modificateur éteint au - // niveau strate, jamais l'inverse… sauf que `ApplyTo` écrit la valeur de l'op, y compris - // quand la strate valait 0. Donc quand une strate a un pool d'ops, ce verdict-ci peut - // être TROP OPTIMISTE. Aucun risque aujourd'hui : rien ne consomme `ClassifyBox` en - // production (cf. la file d'attente post-8/8), et il faudra le régler AVANT que - // `ClassifyTile` ne le consomme. Noté dans OPSTACK-PROGRESS. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.LayerLineSpacing > 0.0f && P.LayerLineDepth > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::CarveOnly); + } + /** `LineValue = max(sin,0)³ ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ retrait ≤ `LayerLineDepth`. * ⚠️ Borne calculée sur les params de la STRATE : un op `LayerLines` par salle peut écrire * une profondeur PLUS GRANDE, ce qui rendrait cette borne fausse dans le sens dangereux. @@ -3713,6 +3768,14 @@ namespace } float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); + } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -3768,14 +3831,19 @@ namespace } /** N'AJOUTE que du solide ⇒ `FillOnly`. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.RibbingSpacing > 0.0f && P.RibbingDepth > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::FillOnly); + } + /** `RibValue = max(sin,0)² ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ ajout ≤ `RibbingDepth`. * Même réserve « params de strate » que `FLayerLineMod::MaxCarveOverBox`. */ float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } @@ -3784,6 +3852,14 @@ namespace return (P.RibbingSpacing > 0.0f) ? FMath::Max(P.RibbingDepth, 0.0f) : 0.0f; } + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); + } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -3845,21 +3921,35 @@ namespace } /** Lobe positif seulement ⇒ n'AJOUTE que du solide ⇒ `FillOnly`. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.OverhangStrength > 0.0f && P.OverhangDepth > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::FillOnly); + } + /** fBM ∈ [-1,1] × `VOXEL_NOISE_SCALE`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ ajout * ≤ `SCALE · Depth · Strength`. Même réserve « params de strate ». */ float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { - return VOXEL_NOISE_SCALE * FMath::Max(P.OverhangDepth, 0.0f) - * FMath::Max(P.OverhangStrength, 0.0f); + return VOXEL_NOISE_SCALE * VF_PerlinAbsBound + * FMath::Max(P.OverhangDepth, 0.0f) + * FMath::Max(P.OverhangStrength, 0.0f); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); } private: @@ -3931,13 +4021,18 @@ namespace } /** `Both` : le signe suit celui de `VertGrad · CaveSDF`, donc les deux directions. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.CliffStrength > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::Both); + } + private: FStrateGenerationParams P; uint32 SeedU; @@ -4000,21 +4095,34 @@ namespace } /** Lobe positif seulement, SOUSTRAIT ⇒ `CarveOnly`. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.ScallopStrength > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::CarveOnly); + } + /** `Cellular3D ∈ [-1,1]`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ retrait ≤ * `ScallopStrength`. Même réserve « params de strate ». */ float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { - return FMath::Max(P.ScallopStrength, 0.0f); + return VF_PerlinAbsBound * FMath::Max(P.ScallopStrength, 0.0f); } float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); + } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + private: FStrateGenerationParams P; uint32 SeedU; @@ -4112,16 +4220,20 @@ namespace } } - /** N'AJOUTE que du solide ⇒ `FillOnly`. Une vraie borne spatiale existe (les arches vivent - * dans le rayon d'une salle) mais elle demande le cache pour la boîte interrogée — même - * dette que `FRoomGraphSource::EffectOverBox`, et elle se paiera au même moment. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + /** N'AJOUTE que du solide ⇒ `FillOnly`. La décision spatiale est indépendante : elle + * consomme l'intervalle SDF publié par la source, comme les autres détails de salle. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.ArchDensity > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::FillOnly); + } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -4184,12 +4296,18 @@ namespace /** N'AJOUTE que du solide ⇒ `FillOnly`. On ne peut pas rendre `Identity` sans consulter le * cache pour la boîte interrogée — même dette que le graphe de salles. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return EVoxelOpEffect::FillOnly; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::FillOnly); + } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -4283,13 +4401,18 @@ namespace } /** Ne SOUSTRAIT que ⇒ `CarveOnly`. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.DomeDensity > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::CarveOnly); + } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -4388,13 +4511,18 @@ namespace } /** N'AJOUTE que du solide ⇒ `FillOnly`. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.PinchDensity > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::FillOnly); + } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -4407,9 +4535,11 @@ namespace // laisse sur les sols — un sol praticable au lieu d'un sol bosselé. Ne s'applique QUE dans l'air // certain (`CaveSDF < 0`) : dans la paroi, le clamp anti-remplissage de la rugosité tient déjà. // - // ⚠️ DERNIER DE LA CHAÎNE, ET CE N'EST PAS INTERCHANGEABLE : il corrige ce que la rugosité (4b) - // a fait. Le déplacer avant elle le rendrait sans objet. C'est la raison pour laquelle l'ordre - // des opérateurs dans `BuildTunnelNetworkStack` est celui de l'original, ligne pour ligne. + // ⚠️ DERNIER DE LA CHAÎNE, ET CE N'EST PAS INTERCHANGEABLE : il ajoute un biais indépendant à + // la densité déjà accumulée par les étapes précédentes. Il ne lit ni ne soustrait la variation + // de rugosité (4b). Le déplacer avant les autres étapes changerait donc leur entrée et leur + // sortie, ce qui est la raison pour laquelle l'ordre de `BuildTunnelNetworkStack` reste celui + // de l'original, ligne pour ligne. // // TIER 3b AUDIT (2026-09-04): this op is intentionally still separate. Ten operators lie // between 4b and this phase (4c through 4h), and every one reads+writes Density. Moving this @@ -4454,13 +4584,18 @@ namespace } /** N'AJOUTE que du solide ⇒ `FillOnly`. */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - // Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`. - if (VF_NoCaveOverBox(Rooms, VoxelBox, Ctx)) { return EVoxelOpEffect::Identity; } return (P.FloorBias > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; } + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EVoxelOpEffect::FillOnly); + } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -4479,12 +4614,8 @@ namespace class FWormFieldSource final : public IVoxelDensityOp { public: - /** @param InRooms ⚠️ UNIQUEMENT pour `EffectOverBox` / `MaxCarveOverBox`. `Eval` lit le - * canal SDF de `InOut`, pas ce pointeur — le ver n'interroge jamais la - * source directement, il consomme ce qu'elle a écrit. Peut être nullptr. */ - FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed, - const FRoomGraphSource* InRooms = nullptr) - : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} + FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed) + : P(InP), SeedU((uint32)Seed) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } EVoxelOpChannelMask ChannelReads() const override @@ -4545,68 +4676,30 @@ namespace } /** - * ⚠️ `CarveOnly` PARTOUT quand les vers sont actifs — et c'est exactement le problème que - * `OPSTACK-DECOMPOSITION §0.2` isole : un carve fieldé n'a AUCUNE borne spatiale, donc il tue - * l'hypothèse `AllSolid` sur CHAQUE tuile de CHAQUE strate à vers. La direction seule ne peut - * pas le récupérer. - * - * **Mais l'amplitude, elle, est bornée et triviale** : `t ∈ [0,1]`, `NetworkMask ∈ [0,1]`, - * donc ce ver ne peut déplacer la densité vers l'air que de `WormStrength` au plus. Dès que - * le pliage saura porter un INTERVALLE numérique et pas seulement une direction, « le rocher - * est solide de plus que la somme des carves restants » redevient prouvable — et c'est le - * plus gros poste de perf du plan. Noté ici, au point exact où la borne manque. + * The worm is a density consumer, not an SDF source. Its intrinsic response is `CarveOnly`. + * The state-aware fold may prove it inactive only when the interval already published by a + * preceding SDF writer proves `Sdf >= WormNetworkRange` throughout the box. An unknown or + * unrelated interval leaves the carve active, which is conservative for free composition. + * Its magnitude is independently bounded by `WormStrength` because both `t` and + * `NetworkMask` are in [0, 1]. */ - /** - * ✅ **LE VER HÉRITE DU VERDICT DE LA SOURCE DE SALLES — ET C'EST CE QUI DÉBLOQUE TOUT.** - * - * La note ci-dessus (« aucune borne spatiale, donc il tue `AllSolid` sur CHAQUE tuile ») - * était vraie, et pourtant elle passait à côté de ce que son propre `Eval` fait trois - * lignes plus haut : - * - * ``` - * if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX) - * { NetworkMask = 0.0f; } - * ... - * if (NetworkMask <= 0.0f) { return; } - * ``` - * - * **Le ver EST spatialement borné** — pas par une borne à lui, mais par celle de la source - * de salles, exactement comme les douze modificateurs de détail. Là où `FRoomGraphSource` - * prouve `Identity`, `Sdf` reste `FLT_MAX` sur toute la boîte, donc `NetworkMask` vaut 0 - * partout, donc ce `return` est pris à chaque voxel. Le ver est l'identité, pas « un carve - * borné » : il ne s'exécute pas. - * - * ⚠️ POURQUOI CE CONTRÔLE COMPTAIT AUTANT. `BaseDensity = 8` et `WormStrength = 10` sont - * les DÉFAUTS, et le commentaire de `WormStrength` dit pourquoi (« must exceed BaseDensity - * to create air »). Donc `SolidMargin = 8 − 10 < 0` : tant que le ver rendait `CarveOnly` - * partout, il tuait `AllSolid` sur **toutes** les tuiles, et la réponse spatiale de la - * source de salles ne pouvait rien prouver derrière lui. Le premier build l'a montré — - * 0 tuile prouvée sur 40, la source ayant pourtant appris à répondre. - * - * ⚠️ ET POURQUOI ON N'UTILISE **PAS** `VF_NoCaveOverBox` ICI. Cet assistant rend `true` - * quand `Rooms == nullptr` — correct pour les douze modificateurs, qui n'existent que dans - * une pile où la source de salles est le seul écrivain du canal SDF. Le ver, lui, est un - * opérateur dont un futur assemblage pourrait le placer derrière un AUTRE écrivain de SDF - * (`FLatticeCorridorSource` en écrit un). Sans source de salles, on ne sait pas : on rend - * `CarveOnly`. Ne pas savoir doit coûter du CPU, jamais un trou. - * - * The worm IS spatially bounded — by the room source's bound, not one of its own, exactly - * like the twelve detail modifiers. Where the room source proves Identity, Sdf stays - * FLT_MAX, NetworkMask is 0 everywhere and Eval returns immediately. This mattered because - * BaseDensity=8 < WormStrength=10 BY DEFAULT, so an unconditional CarveOnly killed AllSolid - * on every tile. Deliberately not VF_NoCaveOverBox: its null-Rooms case answers "identity", - * which is wrong for an op that could sit behind a different SDF writer. - */ - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return EVoxelOpEffect::Identity; } + return EVoxelOpEffect::CarveOnly; + } - if (P.WormNetworkRange > 0.0f && Rooms != nullptr - && Rooms->EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity) + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const EVoxelOpEffect Intrinsic = EffectOverBox(VoxelBox, Ctx); + if (Intrinsic == EVoxelOpEffect::Identity) { return Intrinsic; } + if (P.WormNetworkRange > 0.0f && H.Sdf.IsKnown() + && H.Sdf.Min >= P.WormNetworkRange) { return EVoxelOpEffect::Identity; } - return EVoxelOpEffect::CarveOnly; + return Intrinsic; } /** @@ -4619,15 +4712,19 @@ namespace * la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait * un trou. */ - float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { - // Cohérent avec `EffectOverBox` PAR CONSTRUCTION plutôt que par relecture : deux - // conditions écrites deux fois finiraient par diverger. Le mémo de verdict de - // `FRoomGraphSource` rend ce second appel gratuit. - if (EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity) { return 0.0f; } return MaxCarveAmplitude(); } + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return (P.WormNetworkRange > 0.0f && H.Sdf.IsKnown() + && H.Sdf.Min >= P.WormNetworkRange) + ? 0.0f : MaxCarveOverBox(VoxelBox, Ctx); + } + /** Le ver ne REMPLIT jamais : `InOut.Density -= …` avec un terme positif. */ float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { @@ -4646,7 +4743,6 @@ namespace private: FStrateGenerationParams P; uint32 SeedU; - const FRoomGraphSource* Rooms; // NON possédant — peut être nullptr (voir EffectOverBox) }; } // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. @@ -4825,9 +4921,9 @@ namespace VoxelDensityOps return MakeUnique(-BaseDensity); } - TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float ExtraReach) + TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed) { - return MakeUnique(P, Seed, ExtraReach); + return MakeUnique(P, Seed); } TUniquePtr MakeSdfRoughnessMod(float Strength, float Frequency, @@ -4910,15 +5006,8 @@ namespace VoxelDensityOps // same three ops with a different source. constexpr float CarveBlend = 2.0f; - // Portée que la source doit déclarer pour la paire source+carve : la rugosité peut élargir - // `FBM` est normalisé (`Total / MaxValue`), donc sup|FBM| = sup|Perlin3D| = la borne - // prouvée `VF_PerlinAbsBound` ; la rugosité peut élargir le puits, puis vient le blend du - // carve. Sur-estimer coûte du CPU ; sous-estimer serait un trou. - const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE - * VF_PerlinAbsBound + CarveBlend + 1.0f; - TUniquePtr ShaftSource = - MakeUnique(P, Seed, ExtraReach, SpineRadius); + MakeUnique(P, Seed, SpineRadius); const FShaftFieldSource* ShaftPtr = ShaftSource.Get(); OutStack.Add(MakeConstantRockSource(P.BaseDensity)); @@ -4988,8 +5077,8 @@ namespace VoxelDensityOps // ── ÉTAPE B : les modificateurs de détail (4b–4h), chacun gated sur // `Sdf < SDFBlendRadius·3` via VF_NearCaveSurface. Voir la note de l'étape B5 là-bas. // L'ORDRE EST CELUI DE L'ORIGINAL et il compte : chacun lit la densité que le précédent - // a laissée (le biais de sol, en particulier, existe pour rattraper la rugosité). - OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4b + // a laissée (le biais de sol est un ajout indépendant appliqué à cette accumulation). + OutStack.Add(MakeUnique(P, Seed)); // 4b OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — terrasses OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — lignes de strates OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — nervures @@ -5001,11 +5090,10 @@ namespace VoxelDensityOps OutStack.Add(MakeUnique(P, RoomPtr)); // 4g — dômes OutStack.Add(MakeUnique(P, RoomPtr)); // 4h — pincement OutStack.Add(MakeUnique(P, RoomPtr)); // fin 4h — biais de sol - // ⚠️ `RoomPtr` N'EST PAS DÉCORATIF ICI. Le ver hérite du verdict de boîte de la source de - // salles, faute de quoi il rend `CarveOnly` partout et tue `AllSolid` sur chaque tuile — - // avec les défauts (`BaseDensity = 8`, `WormStrength = 10`) la marge part négative, donc - // aucune tuile n'est prouvable, quoi que la source de salles ait réussi à prouver. - OutStack.Add(MakeUnique(P, Seed, RoomPtr)); + // The worm consumes the SDF interval carried by the fold; it has no pointer to, and no + // dependency on, this particular room source. That keeps the same operator safe behind a + // different SDF writer assembled by the composer. + OutStack.Add(MakeUnique(P, Seed)); OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); @@ -5026,17 +5114,8 @@ namespace VoxelDensityOps // from void and fills — and neither end needed a new operator, only the opposite sign. const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f); - // Portée que la source doit déclarer pour la paire source+fill : la rugosité peut abaisser - // `FBM` est normalisé (`Total / MaxValue`), donc sup|FBM| = sup|Perlin3D| = la borne - // prouvée `VF_PerlinAbsBound` ; le SDF peut être abaissé par la rugosité, puis par le - // SmoothMin de `K/6`, et le fill s'applique dès `Sdf < BlendK`. Sur-estimer coûte du CPU ; - // sous-estimer serait un trou. - const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE - * VF_PerlinAbsBound - + BlendK * 2.0f + 1.0f; - OutStack.Add(MakeConstantVoidSource(P.BaseDensity)); - OutStack.Add(MakeUnique(P, Seed, ExtraReach)); + OutStack.Add(MakeUnique(P, Seed)); // Fréquence 0.08 et 4 octaves — les constantes de `GetFloatingIslandDensity`. Quatrième // archétype à réutiliser cet opérateur (Maze 0.12/3, VerticalShafts 0.1/3). OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.08f, 4, @@ -5061,15 +5140,8 @@ namespace VoxelDensityOps // l'original. Reproduite à l'identique pour que l'égalité binaire tienne. const float RoughApplyWithin = R + P.SurfaceRoughness + 2.0f; - // Portée que la source doit déclarer pour la paire source+carve : le rayon du couloir peut - // `FBM` est normalisé (`Total / MaxValue`), donc sup|FBM| = sup|Perlin3D| = la borne - // prouvée `VF_PerlinAbsBound` ; le rayon du couloir peut être élargi par la rugosité puis - // par le blend du carve. Sur-estimer coûte du CPU ; sous-estimer serait un trou. - const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE - * VF_PerlinAbsBound + CarveBlend + 1.0f; - OutStack.Add(MakeConstantRockSource(P.BaseDensity)); - OutStack.Add(MakeLatticeCorridorSource(P, Seed, ExtraReach)); + OutStack.Add(MakeLatticeCorridorSource(P, Seed)); OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, RoughFrequency, RoughOctaves, RoughApplyWithin)); OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index c38b627..09f93da 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -39,22 +39,35 @@ // the descent structure intact no matter what an author assembles. // // ───────────────────────────────────────────────────────────────────────────────────────── -// LA CLÉ DE VOÛTE : EffectOverBox — direction, pas intervalle / THE KEYSTONE: direction, not intervals +// LA CLÉ DE VOÛTE : EffectOverBox + intervalle SDF / THE KEYSTONE: EffectOverBox + SDF interval // ───────────────────────────────────────────────────────────────────────────────────────── -// La version « complète » d'une borne rendrait un intervalle numérique. NE PAS COMMENCER LÀ. -// Presque tout opérateur existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir. -// Cela suffit à reproduire GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile : +// Un verdict de boîte doit être une preuve. Rendre `Both` coûte du CPU ; rendre `Identity` ou une +// direction fausse supprime de la géométrie et peut laisser un joueur tomber au travers du monde. +// En cas de doute, `Mixed`. +// +// Un opérateur qui écrit seulement le canal SDF ne peut pas honnêtement répondre pour le convertisseur +// qui viendra peut-être après lui : il doit propager un intervalle SDF, et le convertisseur doit plier +// cet intervalle avec sa propre formule. Chaque opérateur répond ainsi pour lui-même, et la composition +// reste sûre même pour une combinaison que personne n'a écrite à la main auparavant. +// +// A box verdict is a proof. `Both` costs CPU; a false `Identity` or direction removes geometry and can +// let a player fall through the world. When in doubt, return `Mixed`. +// An SDF-only writer reports an SDF interval; a later converter folds that interval through its own +// response. Each operator answers for itself, so an unverified composition remains sound. +// +// La première version de cette interface n'avait qu'une direction numérique. Presque tout opérateur +// existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir. Cela suffit à reproduire +// GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile : // // « passages ⇒ bCanSolid = false » EST CarveOnly // « ponts/arêtes ⇒ bCanAir = false » EST FillOnly // « aucun passage près de cette boîte » EST Identity // -// Donc la Phase 1 n'a besoin d'AUCUNE borne numérique et obtient déjà toute la propriété de -// sûreté. Les intervalles sont un resserrement ultérieur pour le coût de génération, pas un -// prérequis de correction. C'est ce qui rend le premier pas petit. +// La direction reste le repli conservateur par défaut pour les opérateurs qui ne connaissent pas +// d'intervalle ; elle ne permet jamais à un écrivain SDF d'usurper la réponse d'un voisin. // -// Phase 1 needs NO numeric bounds and already gets the whole safety property. Intervals are a -// later tightening for gen cost, not a correctness prerequisite. +// Direction remains the conservative fallback for operators without an interval implementation; +// it never lets an SDF writer impersonate a downstream neighbour. // // ───────────────────────────────────────────────────────────────────────────────────────── // LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui @@ -283,6 +296,64 @@ struct FVoxelOpSample float Sdf = FLT_MAX; }; +/** + * Intervalle conservateur du canal SDF sur une boîte. + * + * `Min` et `Max` sont les bornes SUPRÊMES de toutes les valeurs que l'opérateur peut produire dans + * la boîte. `[FLT_MAX, FLT_MAX]` est l'identité exacte : aucune surface connue. Un intervalle inconnu + * est volontairement large ; il force le fold à rester `Mixed` plutôt que de risquer un faux tile + * uniforme. Les setters refusent toute borne non finie ou inversée : un opérateur qui ne peut pas + * prouver son intervalle perd le skip, jamais la géométrie. + * + * Conservative SDF interval over a box. `Min`/`Max` bound every value the operator can produce. + * `[FLT_MAX, FLT_MAX]` is the exact "no surface" identity. Unknown is deliberately wide: it costs + * a skip, never a hole. Invalid bounds become unknown instead of being guessed. + */ +struct FVoxelBoxSdfInterval +{ + bool bKnown = true; + float Min = FLT_MAX; + float Max = FLT_MAX; + + bool IsKnown() const { return bKnown; } + + void SetUnknown() + { + bKnown = false; + Min = -FLT_MAX; + Max = FLT_MAX; + } + + void Set(float InMin, float InMax) + { + if (!FMath::IsFinite(InMin) || !FMath::IsFinite(InMax) || InMin > InMax) + { + SetUnknown(); + return; + } + bKnown = true; + Min = InMin; + Max = InMax; + } + + /** Output of `min(Input, Other)` when both intervals are known. */ + void MinWith(const FVoxelBoxSdfInterval& Other) + { + if (!bKnown || !Other.bKnown) + { + SetUnknown(); + return; + } + Min = FMath::Min(Min, Other.Min); + Max = FMath::Min(Max, Other.Max); + } +}; + +// Forward declaration: the interval-aware EffectOverBox overloads consume the hypotheses that +// have already been folded, while the hypotheses themselves are declared immediately below the +// interface. +struct FVoxelBoxHypotheses; + //============================================================================= // L'INTERFACE / THE INTERFACE //============================================================================= @@ -344,8 +415,9 @@ public: virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0; /** - * CONSERVATIF. Phase 1 : direction seule. Phase 3 : surcharge avec intervalle numérique. - * Rendre Both est toujours sûr ; rendre le mauvais est un trou. + * CONSERVATIF. The two-argument method is the intrinsic fallback. The state-aware overload + * below is what the stack calls: it sees the SDF interval already produced by earlier ops. + * Returning Both is always safe; returning the wrong direction is a hole. * * Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT * D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour @@ -354,20 +426,20 @@ public: * * The contract is "conservative", not "closed-form": an op MAY sample to answer. * - * ⚠️ SIMPLIFICATION DE PHASE 1, à connaître : une source qui n'écrit QUE le canal SDF ne touche - * pas la densité par elle-même — c'est l'opérateur de conversion (`FSdfCarve`/`FSdfFill`) qui le - * fait. Répondre honnêtement demanderait de propager un INTERVALLE de SDF à travers la requête - * de boîte, exactement comme `Eval` propage une valeur de SDF. En attendant, **la source répond - * pour la paire** (elle rend `CarveOnly`/`FillOnly` quand une primitive atteint la boîte, - * `Identity` sinon) et la conversion rend `Identity`. Conservatif et correct ; à remplacer par - * une requête de boîte à deux canaux quand les intervalles numériques arriveront (Phase 3). - * - * PHASE 1 SIMPLIFICATION: an SDF-only source answers for itself AND its conversion op; the - * conversion returns Identity. Answering honestly needs an SDF INTERVAL threaded through the box - * query, mirroring how Eval threads an SDF value. Conservative and correct meanwhile. + * An SDF-only source MUST NOT answer for a converter that happens to follow it. It reports its + * own interval through `PropagateSdfOverBox`; the converter's state-aware overload then folds + * the interval through its own formula. This is the isolation rule that makes free composition + * safe. */ virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0; + /** State-aware effect. The default preserves old custom operators' conservative direction. */ + virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses&) const + { + return EffectOverBox(VoxelBox, Ctx); + } + /** * ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de * perf du plan. @@ -406,11 +478,38 @@ public: return FLT_MAX; } + /** State-aware amplitude. The default is the old, conservative amplitude. */ + virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses&) const + { + return MaxCarveOverBox(VoxelBox, Ctx); + } + virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const { return FLT_MAX; } + /** State-aware amplitude. The default is the old, conservative amplitude. */ + virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses&) const + { + return MaxFillOverBox(VoxelBox, Ctx); + } + + /** + * Propagate this operator's own SDF result through a box query. Only call this for an operator + * whose cached channel declaration writes `Sdf`. The default is unknown by construction: a new + * SDF writer cannot accidentally make a false uniform verdict merely because it forgot this + * method. Implementations that assign SDF replace the interval; min/accumulating writers use + * `FVoxelBoxSdfInterval::MinWith`. + */ + virtual void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const + { + InOut.SetUnknown(); + } + /** * Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien * la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ? @@ -537,6 +636,9 @@ struct FVoxelBoxHypotheses float SolidMargin = 0.0f; float AirMargin = 0.0f; + /** SDF interval after all SDF writers folded so far. It starts at the exact no-surface identity. */ + FVoxelBoxSdfInterval Sdf; + bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; } /** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */ @@ -626,14 +728,29 @@ FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect, * pure FillOnly would have thrown away, while still letting a passage take it back. */ FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op, - const FBox& VoxelBox, const FVoxelOpContext& Ctx) + const FBox& VoxelBox, const FVoxelOpContext& Ctx, + bool bWritesSdf) { + // Soundness bias: an unknown SDF interval is allowed to kill a skip, never to manufacture a + // uniform tile. The interval is propagated only after this op's own effect has been folded, so + // the next op sees exactly the SDF version that Eval would expose at this point in the stack. const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx); if (Forced != EVoxelTileClass::Mixed) { VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx)); + if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } return; } - VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx), - Op.MaxCarveOverBox(VoxelBox, Ctx), Op.MaxFillOverBox(VoxelBox, Ctx)); + VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx, H), + Op.MaxCarveOverBox(VoxelBox, Ctx, H), + Op.MaxFillOverBox(VoxelBox, Ctx, H)); + if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } +} + +/** Backward-compatible helper for unit tests and callers with no cached declaration metadata. */ +FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op, + const FBox& VoxelBox, const FVoxelOpContext& Ctx) +{ + VF_FoldOp(H, Op, VoxelBox, Ctx, + (Op.ChannelWrites() & VoxelOpChannels::Sdf) != 0); } diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 310fd7f..b7b8e0b 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -154,7 +154,8 @@ public: FVoxelBoxHypotheses H; for (const FOpEntry& Entry : Ops) { - VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx); + VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx, + (Entry.Writes & VoxelOpChannels::Sdf) != 0); // Do not early-out on a dead hypothesis: a later forcing structural post may // deliberately overwrite it (the XY edge seal is appended after passage carving). } @@ -189,7 +190,8 @@ public: const bool bSolidBefore = H.bCanBeAllSolid; const bool bAirBefore = H.bCanBeAllAir; - VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx); + VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx, + (Ops[i].Writes & VoxelOpChannels::Sdf) != 0); if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; } if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; } @@ -270,14 +272,11 @@ namespace VoxelDensityOps * Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux * chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région * COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */ - /* `ExtraReach` = tout ce qui peut eLARGIR la portée du couloir en aval (amplitude de rugosité - * rayon de blend du carve). La source répond pour la paire source+conversion dans - * `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle - * doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE — c'est-à-dire un - * trou. / The source answers for the source+conversion pair, so it must know the downstream - * margin: an Identity that is wrong is a hole. */ + /** The source answers only for its own SDF field. Its box query publishes an SDF interval; + * each later converter or modifier consumes that interval through the generic fold. It does + * not know, and must not answer for, any downstream operator. */ VOXELFORGE_API TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, - int32 Seed, float ExtraReach); + int32 Seed); /** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands). * `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est @@ -302,7 +301,8 @@ namespace VoxelDensityOps /** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis * OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un * `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z - * bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */ + * bornées par le supremum prouvé de FBM (1.5, not the nominal parameter label). Sert + * FlatPlain **et** CrystalChamber. */ VOXELFORGE_API TUniquePtr MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed); /** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒ @@ -380,8 +380,9 @@ namespace VoxelDensityOps const UVoxelStrateManager* StrateManager); /** - * DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE du dernier `FRoomGraphSource::EffectOverBox` - * évalué sur ce thread. **Tests uniquement. N'entre dans aucune décision de génération.** + * DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle + * de `FRoomGraphSource` évaluée sur ce thread. **Tests uniquement. N'entre dans aucune décision + * de génération.** * * ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut * pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une