diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 9dda041..fe1e1c8 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -2762,3 +2762,91 @@ verbatim. **Measure, then tighten what the numbers name.** to look at; pits or chimneys leading would be a surprise worth stopping on. 2. Whether removing the columns test alone moved `proved` off zero. If it did, that number is the first real T1.d saving in the plugin. + +## 2026-07-28 — the breakdown refuted MY hypothesis, and found the sampler was the bug + +``` +rooms 40, tunnels 40, pits 14, chimneys 0 +Averages per killed tile: 4.9 of 7.2 rooms reach, 69.2 of 80.4 tunnels reach +``` + +I predicted "tunnels ≫ rooms ⇒ capsule bounding spheres". **Wrong, or rather insufficient:** tunnels +*are* wildly over-counted (69 of 80), but **rooms hit all 40 tiles too**, so fixing tunnels alone +would have moved the number by exactly zero. Fourth hypothesis this refactor has reversed on contact +with a measurement. The instrument paid for itself on its first run. + +### The real finding: the tile sampler never left the (0,0) spine + +```cpp +const int32 Extent = Step * Cells; // 1 * 8 = 8 +Rng.RandRange(-4, 4) * Extent // XY ∈ [-32, +32] voxels +``` + +Against the defaults: + +| | | +|---|---| +| `RoomSpacing` | **80** — ±32 does not cover half of one room cell | +| `OriginRoomRadius` | **20**, guaranteed at (0,0); cull radius `max(20·1.5, 8) + 3·4` = **42** | +| the (0,0) spine | descends exactly there | + +So all 40 tiles sat inside the origin room's cull sphere, in the most cave-riddled cubic +metre of the entire world. `4.9 of 7.2 rooms reach` was not measuring the world's cave density — it +was measuring the spine hub. **Every conclusion about "is deep rock provable" drawn from that sample +was answering a different question.** + +Widened to ±320 voxels (4 × `RoomSpacing`), and the report now **prints its own sampling extent** plus +how many tiles landed clear of the spine — because a sampler whose extent you cannot quote is one +nobody is watching. The brute-force soundness assertion is untouched: this changes what the +measurement *looks at*, never what it *demands*. + +### Why rooms can't be tightened and tunnels can — the arithmetic, before writing any code + +With `SDFBlendRadius K = 4`, `WormNetworkRange = 24`, mods gating at `3K = 12`, the strongest useful +threshold is `T = max(Blend, 3K, WormNetworkRange) = 24`. + +`SmoothMin(A,B,K) = min(A,B) − H³K/6`, `H = max(K−|A−B|,0)/K`. Two consequences worth writing down: +the penalty is **exactly zero** once `|A−B| ≥ K`, and the running minimum therefore **saturates at +`K` below the true minimum** — so `Sdf ≥ min_i(SDF_i) − K` for **any** number of primitives. That is +the bound that makes an "`Sdf ≥ T`" criterion possible at all. + +Now compare the two possible criteria per primitive: + +- **rooms** — cull rejects at `dist > Rmax + 3K` (= 57 worst case). The `Sdf ≥ T+K` criterion rejects + only at `dist ≥ Rmax + T + K` (= 73). Since `T + K = 28 > 3K = 12`, **the cull is strictly the + better test for rooms.** Nothing to gain; leave them alone. +- **tunnels** — the cull is the capsule's **bounding sphere**, and with `MaxTunnelLength = 200` that + sphere has radius up to ~107 for a tube of radius 7. The `Sdf ≥ T+K` criterion uses the real + distance to the segment, so it rejects at `dist(box, segment) ≥ 7 + 28 = 35`. **Order of magnitude + tighter**, and sound because `TaperedCapsule` is a genuine distance function + (`Dist(P, ClosestOnSegment) − Lerp(Ra,Rb,T)`), verified rather than assumed. + +So the eventual fix is a **per-primitive disjunction** — a primitive cannot matter if it fails its +cull *or* its own SDF stays ≥ `T + K` over the box — applied **only to tunnels**, where the SDF is +exact. Mixing is sound: primitives failing the cull contribute nothing, the rest are all ≥ `T+K`, so +the fold is ≥ `T`, and every consumer (converter at `Blend`, twelve mods at `3K`, worm at +`WormNetworkRange`) is identity at `Sdf ≥ T`. + +### NOT done in this build, deliberately + +The tunnel disjunction changes what `Identity` *means* here — from "`Sdf` stays `FLT_MAX`" to +"`Sdf ≥ T`" — and that is only sound if **every** consumer's threshold is ≤ `T`. Three premises still +need reading rather than assuming: `VF_NearCaveSurface`'s exact constant, the `Blend` actually passed +to `FSdfConvertOp` by `BuildTunnelNetworkStack`, and whether any modifier re-probes the SDF *outside* +the box (`FCaveTerraceMod` samples Z±1) before its gate. Any one of those wrong is a hole with no +collision behind it, and this session has already produced four confident chains that reversed on +checking. **Fix the measurement first; it costs nothing and it is wrong today.** + +### The prediction, stated so the next run can refute it + +With the widened sampler: **rooms should stop hitting every tile** (cull spheres of mean radius ~42 +on an 80-lattice cover roughly 60 % of space, so ~40 % of tiles should be clear of all rooms), and +**tunnels should now be the binding constraint on nearly all of them.** If instead tunnels stay at +~100 % *and* rooms drop, the tunnel disjunction above is the whole remaining job. If rooms also stay +at 100 %, my model of the room lattice is wrong and that is the thing to look at next — not the +tunnels. + +### Ready to build. Compile-error spots + +`SpanCells` / `SpanVoxelsReported` / `NumTilesAwayFromSpine` are new locals hoisted **outside** the +tile loop (the report line needs them); `P.OriginRoomRadius` and `P.RoomSpacing` are read in check 4. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index 3e0a4ca..d4c03aa 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -1107,7 +1107,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) int32 NumBruteSamples = 0, NumViolations = 0; float WorstViolation = 0.0f; TMap SolidKillerCounts; - int32 NumRoomKilled = 0; + int32 NumRoomKilled = 0, NumTilesAwayFromSpine = 0; + // ±320 voxels = 4 x RoomSpacing. Hors de la boucle : la ligne de rapport en a besoin, et + // une étendue d'échantillonnage qu'on ne peut pas citer est une étendue qu'on ne surveille pas. + const int32 SpanCells = 40; + const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8 int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0; int32 SumHitRooms = 0, SumNumRooms = 0, SumHitTunnels = 0, SumNumTunnels = 0; @@ -1116,10 +1120,39 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) { const int32 Step = 1, Cells = 8; const int32 Extent = Step * Cells; + + // ⚠️ L'ÉTENDUE XY ÉTAIT ±32 VOXELS, ET C'EST CE QUI RENDAIT CE BLOC INEXPLOITABLE. + // `RandRange(-4, 4) * 8` échantillonnait 40 tuiles dans un cube de ±32 voxels autour de + // (0,0) — c'est-à-dire l'endroit le PLUS creusé du monde entier, et de loin : + // • `RoomSpacing = 80`, donc ±32 ne couvre même pas la moitié d'UNE cellule de salle ; + // • `OriginRoomRadius = 20` garantit une grosse salle exactement à (0,0), de rayon de + // cull `max(20·1.5, 8) + 3·4 = 42` — qui avale la quasi-totalité de la fenêtre ; + // • la spine (0,0) descend précisément là. + // La mesure « 4.9 salles sur 7.2 atteignent la boîte » ne décrivait donc pas la densité + // de grottes du monde, elle décrivait le hub de la spine. Aucune conclusion sur la + // prouvabilité du roc profond ne pouvait sortir de cet échantillon. + // + // ⚠️ CE N'EST PAS « ÉLARGIR JUSQU'À CE QUE ÇA PASSE ». Le verdict de chaque tuile reste + // brute-forcé voxel par voxel juste en dessous : un échantillonneur plus large qui + // produirait un verdict FAUX échoue exactement comme avant. On corrige ce que la mesure + // REGARDE, pas ce qu'elle exige. + // + // The XY extent was ±32 voxels around (0,0) -- with RoomSpacing = 80 and a guaranteed + // OriginRoomRadius = 20 room at the origin, that samples the single most cave-dense spot + // in the world and says nothing about deep rock. Widening changes what the measurement + // LOOKS AT, not what it demands: every verdict is still brute-forced below. const FIntVector Origin( - Rng.RandRange(-4, 4) * Extent, - Rng.RandRange(-4, 4) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, + Rng.RandRange(-SpanCells, SpanCells) * Extent, FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); + + // Combien de tuiles échappent vraiment au hub de la spine : sans ce compte, un futur + // resserrement de l'étendue redeviendrait invisible. + if (FMath::Square((float)Origin.X) + FMath::Square((float)Origin.Y) + > FMath::Square(3.0f * P.OriginRoomRadius)) + { + ++NumTilesAwayFromSpine; + } const int32 GridDim = Cells + 1; const FBox Box( FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step), @@ -1181,6 +1214,16 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) } } + AddInfo(FString::Printf( + TEXT("Tile sampler: 40 tiles of 10 voxels, XY drawn from +/-%d voxels (= %.1f x ") + TEXT("RoomSpacing %.0f), Z across the strate; %d of 40 landed further than 3 x ") + TEXT("OriginRoomRadius from the (0,0) spine. THIS LINE EXISTS BECAUSE THE SAMPLER WAS ") + TEXT("THE BUG ONCE: it drew XY from +/-32 voxels, i.e. entirely inside the origin room's ") + TEXT("cull sphere, so every number below described the spine hub rather than the world. ") + TEXT("If the last count is low, nothing below says anything about deep rock."), + SpanVoxelsReported, (float)SpanVoxelsReported / FMath::Max(P.RoomSpacing, 1.0f), + P.RoomSpacing, NumTilesAwayFromSpine)); + AddInfo(FString::Printf( TEXT("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 / ")