feat(opstack): VerticalShafts' connector branch never tested a connector -- test the real capsules
Jahni's call: bank T1.d at 11/40 and take VerticalShafts rather than squeeze the Perlin sup. Right call -- a clearly-scoped defect with no hole-risk maths, on an archetype that was getting nothing. The defect is stated in its own comment. FShaftFieldSource::EffectOverBox ended with a "connectors" branch that never looks at a connector: it returns CarveOnly because a shaft EXISTS within Spacing*1.6 + Pad. At ShaftSpacing 55 and ShaftDensity 0.6 that box spans ~4x4 cells and ~10 shafts, so the condition is true essentially everywhere -- exactly the reported 0 proved of 60. Conservative, never wrong, completely sterile; the same shape as the worm's unconditional CarveOnly one archetype over. It now rebuilds the connectors the way GetCells does and tests the real capsule. Two things had to be right and both were read in the source rather than assumed: - The enumeration is a superset. Eval reads connectors from the 3x3 of ITS query's cell, so any pair visible from a point in the box has both shafts in the 3x3 of some cell the box touches, i.e. in [box cells] +- 1 -- the range swept here. Pairs no query ever sees may be produced: extra CarveOnly, never a hole. - The pair order matches, so the hash matches. VoxelHash::Pair is fed in insertion order and GetCells inserts over (dy, dx), row-major; this sweeps (cy, cx), and row-major order restricted to a sub-grid preserves the relative order of two cells. So the same pair gets the same hash WITHOUT assuming Pair() is symmetric -- which was never verified and now need not be. The capsule test splits the axes because a connector is HORIZONTAL at height Zc: Z is exact, XY is point-to-segment from the box centre minus the XY half diagonal. Tighter than the 3-D half-diagonal the tunnel version had to settle for. Also the same sampler trap, caught before the build this time: tile XY was drawn from +-48 voxels against ShaftSpacing 55 -- under one period of the pattern, identical in kind to the +-32-vs-80 bug that cost the tunnel test three runs. Widened to +-440, and the report prints its own extent in units of ShaftSpacing. The safety net was already there and is untouched: this test brute-forces the full lattice of every proved tile, both hypotheses, and AddErrors on the first violation. If the superset or hash-order argument is wrong, the assertion fails rather than a player falling through the floor. Unbuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -433,7 +433,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. |
|
| `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 5-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. |
|
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-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. |
|
| `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. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). |
|
| `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. **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 rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. |
|
||||||
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
|
|||||||
@@ -3196,3 +3196,73 @@ appetite, not a technical unknown, so it goes to Jahni rather than getting done
|
|||||||
**11 of 40 tiles (27.5 %) proved AllSolid at production defaults, 14641 voxels brute-forced,
|
**11 of 40 tiles (27.5 %) proved AllSolid at production defaults, 14641 voxels brute-forced,
|
||||||
0 violations.** The dense fixture correctly proves nothing. Every verdict is checked voxel by voxel,
|
0 violations.** The dense fixture correctly proves nothing. Every verdict is checked voxel by voxel,
|
||||||
so the risk of the whole feature is bounded by a test that runs on every build.
|
so the risk of the whole feature is bounded by a test that runs on every build.
|
||||||
|
|
||||||
|
## 2026-07-29 — T1.d banked at 11/40. VerticalShafts: the connector branch never tested a connector.
|
||||||
|
|
||||||
|
Jahni's call: bank the tunnel result and take VerticalShafts rather than squeeze the Perlin sup.
|
||||||
|
Right call — the shaft fix is a clearly-scoped defect with no hole-risk maths, on an archetype that
|
||||||
|
was getting nothing.
|
||||||
|
|
||||||
|
### The defect, and it is stated in its own comment
|
||||||
|
|
||||||
|
`FShaftFieldSource::EffectOverBox` ends with a "connectors" branch that never looks at a connector:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad);
|
||||||
|
for (cells in ConnBox)
|
||||||
|
if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent
|
||||||
|
```
|
||||||
|
|
||||||
|
It returns `CarveOnly` because a shaft **exists** somewhere in the neighbourhood. Against the
|
||||||
|
defaults — `ShaftSpacing = 55`, `ShaftDensity = 0.6`, `Pad ≈ 11 + ExtraReach` — that expanded box
|
||||||
|
spans roughly 4×4 cells and therefore ~10 shafts. **The condition is true essentially everywhere**,
|
||||||
|
which is exactly the reported `0 proved of 60`. Conservative, never wrong, and completely sterile —
|
||||||
|
the same shape as the worm's unconditional `CarveOnly`, one archetype over.
|
||||||
|
|
||||||
|
### The fix: rebuild the connectors the way `GetCells` does, and test the real capsule
|
||||||
|
|
||||||
|
Two things had to be right, and both were checked in the source rather than assumed:
|
||||||
|
|
||||||
|
- **The enumeration is a superset.** `Eval` reads connectors from the 3×3 neighbourhood of *its
|
||||||
|
query's* cell. So any pair visible from a point in the box has both shafts inside the 3×3 of some
|
||||||
|
cell the box touches ⇒ both lie in `[box cells] ± 1`, which is precisely the range swept. Pairs
|
||||||
|
that no query ever sees may be produced — extra `CarveOnly`, never a hole.
|
||||||
|
- **The pair order matches, so the hash matches.** `VoxelHash::Pair(A…, B…)` is fed in insertion
|
||||||
|
order, and `GetCells` inserts over `(dy, dx)` — row-major. The sweep here is `(cy, cx)`, the same
|
||||||
|
order, and row-major order restricted to a sub-grid preserves the relative order of any two cells.
|
||||||
|
So the same pair gets the same hash **without assuming `Pair()` is symmetric** — which was never
|
||||||
|
verified and now does not need to be.
|
||||||
|
|
||||||
|
The capsule test splits the axes because a connector is a **horizontal** capsule at height `Zc`:
|
||||||
|
Z is exact (`RMinZ > Zc + Reach || RMaxZ < Zc - Reach` ⇒ skip), XY uses point-to-segment from the box
|
||||||
|
centre minus the XY half-diagonal. Far tighter than a 3-D half-diagonal, which is what the tunnel
|
||||||
|
version had to settle for.
|
||||||
|
|
||||||
|
### And the same sampler trap, caught this time before the build
|
||||||
|
|
||||||
|
The shaft test drew tile XY from `RandRange(-6,6)*8` = **±48 voxels, against `ShaftSpacing = 55`** —
|
||||||
|
less than one period of the pattern. Identical in kind to the ±32-vs-80 bug that cost the tunnel test
|
||||||
|
three runs. Widened to ±440 (8 periods), and the report now prints its own extent in units of
|
||||||
|
`ShaftSpacing`, so it cannot go unnoticed again.
|
||||||
|
|
||||||
|
**The safety net was already there and is untouched:** this test brute-forces the full lattice of
|
||||||
|
every proved tile against `EvalMC`, both hypotheses, and `AddError`s on the first violation. So the
|
||||||
|
fix is checked by construction — if the superset argument or the hash-order argument is wrong, the
|
||||||
|
existing assertion fails rather than a player falling through the floor.
|
||||||
|
|
||||||
|
### Ready to build. Compile-error spots
|
||||||
|
|
||||||
|
1. `VF_DistPointSegment2D` — new helper next to `VF_NearCaveSurface` in the anonymous namespace.
|
||||||
|
2. Both new helpers now use `KINDA_SMALL_NUMBER`, matching the ~6 existing uses in the plugin; they
|
||||||
|
were briefly written with the `UE_`-prefixed spelling, which nothing else here uses.
|
||||||
|
3. The shaft test hoists `SpanCells` / `SpanVoxels` **outside** the tile loop — the report needs
|
||||||
|
them, and `Extent` is loop-local. (That exact scoping slip happened in the tunnel test two rounds
|
||||||
|
ago; caught here before the build rather than after.)
|
||||||
|
4. Format string is 6 specifiers / 6 arguments — counted.
|
||||||
|
|
||||||
|
### What to read
|
||||||
|
|
||||||
|
`Box verdicts over 60 VerticalShafts tiles` — **0 is the number to beat**, and `%d violations` must
|
||||||
|
stay 0. If it is still 0 proved, the warning now says what to check *first*: `ExtraReach` inflates
|
||||||
|
both remaining tests, so its value against `ShaftMaxRadius` is the thing to look at before touching
|
||||||
|
either test — not a re-derivation from scratch.
|
||||||
|
|||||||
@@ -207,14 +207,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
{
|
{
|
||||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||||
FRandomStream Rng(13579);
|
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.
|
||||||
|
const int32 SpanCells = 55;
|
||||||
|
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||||
|
|
||||||
for (int32 t = 0; t < 60; ++t)
|
for (int32 t = 0; t < 60; ++t)
|
||||||
{
|
{
|
||||||
const int32 Step = 1, Cells = 8;
|
const int32 Step = 1, Cells = 8;
|
||||||
const int32 Extent = Step * Cells;
|
const int32 Extent = Step * Cells;
|
||||||
|
// ⚠️ L'ÉTENDUE XY ÉTAIT ±48 VOXELS, POUR UN `ShaftSpacing` DE 55 : moins d'UNE cellule
|
||||||
|
// de puits. C'est le même piège que celui qui a coûté trois runs au test TunnelNetwork —
|
||||||
|
// un échantillonneur qui ne couvre pas une période du motif ne mesure pas le monde, il
|
||||||
|
// mesure un point du motif. ±440 = 8 périodes.
|
||||||
|
// The XY extent was ±48 voxels for a ShaftSpacing of 55 — less than one shaft cell, the
|
||||||
|
// same trap that cost the TunnelNetwork test three runs. ±440 covers 8 periods.
|
||||||
const FIntVector Origin(
|
const FIntVector Origin(
|
||||||
Rng.RandRange(-6, 6) * Extent,
|
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||||
Rng.RandRange(-6, 6) * Extent,
|
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||||
|
|
||||||
const int32 GridDim = Cells + 1;
|
const int32 GridDim = Cells + 1;
|
||||||
@@ -259,10 +269,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
|||||||
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
|
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
|
||||||
|
|
||||||
AddInfo(FString::Printf(
|
AddInfo(FString::Printf(
|
||||||
TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ")
|
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
|
||||||
TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ")
|
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ")
|
||||||
TEXT("prouvable en v1\"."),
|
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
|
||||||
NumProved, NumMixed));
|
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));
|
||||||
|
|
||||||
|
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."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -86,12 +86,26 @@ namespace
|
|||||||
{
|
{
|
||||||
const FVector AB = B - A;
|
const FVector AB = B - A;
|
||||||
const double LenSq = FVector::DotProduct(AB, AB);
|
const double LenSq = FVector::DotProduct(AB, AB);
|
||||||
const double T = (LenSq > UE_KINDA_SMALL_NUMBER)
|
const double T = (LenSq > KINDA_SMALL_NUMBER)
|
||||||
? FMath::Clamp(FVector::DotProduct(P - A, AB) / LenSq, 0.0, 1.0)
|
? FMath::Clamp(FVector::DotProduct(P - A, AB) / LenSq, 0.0, 1.0)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
return (float)FVector::Dist(P, A + AB * T);
|
return (float)FVector::Dist(P, A + AB * T);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** La même chose en 2D, pour les connecteurs de puits : ce sont des capsules HORIZONTALES, donc
|
||||||
|
* Z se teste exactement et seul XY demande une distance point-segment. */
|
||||||
|
FORCEINLINE float VF_DistPointSegment2D(const FVector2D& P, const FVector2D& A, const FVector2D& B)
|
||||||
|
{
|
||||||
|
const FVector2D AB = B - A;
|
||||||
|
const double LenSq = (double)AB.X * AB.X + (double)AB.Y * AB.Y;
|
||||||
|
const double T = (LenSq > KINDA_SMALL_NUMBER)
|
||||||
|
? FMath::Clamp(((double)(P.X - A.X) * AB.X + (double)(P.Y - A.Y) * AB.Y) / LenSq, 0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
const double DX = (double)P.X - ((double)A.X + AB.X * T);
|
||||||
|
const double DY = (double)P.Y - ((double)A.Y + AB.Y * T);
|
||||||
|
return (float)FMath::Sqrt(DX * DX + DY * DY);
|
||||||
|
}
|
||||||
|
|
||||||
FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius)
|
FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius)
|
||||||
{
|
{
|
||||||
// Transcrit tel quel, ordre des comparaisons compris :
|
// Transcrit tel quel, ordre des comparaisons compris :
|
||||||
@@ -1384,20 +1398,88 @@ namespace
|
|||||||
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; }
|
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ Les connecteurs ne sont PAS testés ici, et c'est délibérément conservatif dans le
|
//-----------------------------------------------------------------
|
||||||
// mauvais sens si on n'y prend pas garde : un connecteur ne peut exister qu'entre deux
|
// LES CONNECTEURS — LES VRAIES CAPSULES, PLUS « un puits existe dans le coin »
|
||||||
// puits d'un voisinage, donc si AUCUN puits n'atteint la boîte élargie de `Spacing*1.6`
|
//-----------------------------------------------------------------
|
||||||
// (la portée max d'une paire), aucun connecteur ne peut l'atteindre non plus.
|
// ⚠️ CE BLOC RENDAIT `CarveOnly` DÈS QU'UN PUITS **EXISTAIT** dans la boîte élargie de
|
||||||
const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad);
|
// `Spacing·1.6 + Pad`, sans jamais regarder un connecteur. Avec les défauts
|
||||||
const int32 KX0 = FMath::FloorToInt((float)ConnBox.Min.X / Spacing);
|
// (`ShaftSpacing = 55`, `ShaftDensity = 0.6`) cette boîte élargie couvre ~4×4 cellules,
|
||||||
const int32 KX1 = FMath::FloorToInt((float)ConnBox.Max.X / Spacing);
|
// donc une dizaine de puits : la condition était vraie PARTOUT et l'archétype prouvait
|
||||||
const int32 KY0 = FMath::FloorToInt((float)ConnBox.Min.Y / Spacing);
|
// 0 tuile sur 60. Conservatif, jamais faux — et totalement stérile.
|
||||||
const int32 KY1 = FMath::FloorToInt((float)ConnBox.Max.Y / Spacing);
|
//
|
||||||
for (int32 cy = KY0; cy <= KY1; ++cy)
|
// Ce qu'on fait à la place : reconstruire les connecteurs comme `GetCells` les
|
||||||
for (int32 cx = KX0; cx <= KX1; ++cx)
|
// construit, et tester la capsule réelle.
|
||||||
|
//
|
||||||
|
// ⚠️ POURQUOI L'ÉNUMÉRATION EST UN SUR-ENSEMBLE (donc sûre). `Eval` lit les connecteurs
|
||||||
|
// du voisinage 3×3 de la cellule DE SA REQUÊTE. Une paire visible depuis un point de la
|
||||||
|
// boîte a donc ses deux puits dans un même 3×3 centré sur une cellule que la boîte
|
||||||
|
// touche ⇒ les deux sont dans [cellules de la boîte] ± 1, qui est exactement la plage
|
||||||
|
// balayée ici. 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 connectors 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 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1;
|
||||||
|
const int32 QX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1;
|
||||||
|
const int32 QY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1;
|
||||||
|
const int32 QY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1;
|
||||||
|
|
||||||
|
TArray<FShaft, TInlineAllocator<32>> Near;
|
||||||
|
for (int32 cy = QY0; cy <= QY1; ++cy)
|
||||||
|
for (int32 cx = QX0; cx <= QX1; ++cx)
|
||||||
{
|
{
|
||||||
FShaft Sh;
|
FShaft Sh;
|
||||||
if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent
|
if (RollShaft(cx, cy, Sh)) { Near.Add(Sh); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const float ConnReach = P.ConnectorRadius + ExtraReach;
|
||||||
|
const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness;
|
||||||
|
const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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)));
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; }
|
||||||
|
|
||||||
|
const float Zc = FMath::Lerp(BottomZ, TopZ,
|
||||||
|
VoxelHash::ToFloat01(VoxelHash::Mix(PH)));
|
||||||
|
if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { continue; }
|
||||||
|
|
||||||
|
const float DistXY = VF_DistPointSegment2D(
|
||||||
|
CtrXY, FVector2D(A.X, A.Y), FVector2D(B.X, B.Y));
|
||||||
|
if (DistXY - HalfDiagXY >= ConnReach) { continue; }
|
||||||
|
|
||||||
|
return EVoxelOpEffect::CarveOnly;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return EVoxelOpEffect::Identity;
|
return EVoxelOpEffect::Identity;
|
||||||
|
|||||||
Reference in New Issue
Block a user