fix #130 (the real strip): drawn-shell lift vs draw-space portal consumers

The user's re-gate refuted the scissor fix as THE strip (6c4b6d6 was a
real but sub-pixel under-coverage): the strip survived, screenshot at a
doorway, full width of the opening, top edge only, "very subtle".

Root cause (pinned by Issue130DoorwayStripTests.UnliftedGate_*): the
+0.02 m shell render lift. Cell shells DRAW 2 cm above the dat origin
(z-fight vs coplanar terrain); f35cb8b (the #119-residual fix,
2026-06-11) deliberately reverted the VISIBILITY graph to the physics
(unlifted) transform - but the OutsideView color gate (terrain/sky/
scissor through the doorway) and the seal/punch depth fans are
DRAW-space consumers and kept projecting the unlifted polygons. The
drawn lintel therefore sits one lift-projection above the gate's top
edge - measured 6.7 px at a 2.4 m doorway - and that band never
receives terrain/sky color while the seal also stamps 2 cm low.
A regression from f35cb8b, NOT from the W=0 clip port (987313a stays
exonerated). Vertical aperture edges are immune (the lift slides them
along themselves) - top edge only, exactly as reported; explains the
"also NOW" timing precisely.

Fix - draw space draws lifted, visibility stays physics (the f35cb8b
invariant, now symmetric):
- PortalVisibilityBuilder.Build gains drawLiftZ: the exit-portal branch
  projects the OutsideView region with the lifted transform; flood
  admission, side tests, and CellViews are untouched (default 0 keeps
  every existing visibility test bit-identical).
- The seal/punch fans (DrawRetailPViewPortalDepthWrite) lift their
  world verts to the drawn shell's space.
- One shared constant PortalVisibilityBuilder.ShellDrawLiftZ feeds the
  shell registration (GameWindow:5604), the gate, and the fans.

Register: AP-32 ADDED - the +0.02 lift had NO row (a pre-register
deviation the 2026-06-12 sweep missed). The row records the split
invariant both ways: a draw-space consumer that forgets the lift
re-opens the #130 strip; a visibility consumer that picks the lifted
transform re-opens the #119-residual side-cull.

Pins: the lifted gate covers the drawn (lifted) aperture to 0.00 px
across the 147-combo sweep; the unlifted gate shows the 6.7 px strip
(sensitivity proof - if the lift is ever removed, this test says the
drawLiftZ plumbing can go too).

Suites: App 257+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user re-gate at a doorway with the lintel on screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 14:28:16 +02:00
parent 4ba714835d
commit 5135066733
6 changed files with 191 additions and 38 deletions

View file

@ -4514,45 +4514,50 @@ cap constant (0.5 m) is the tuning knob — see AD-18.
## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside ## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
**Status:** FIX SHIPPED — awaiting user visual gate **Status:** FIX 2 SHIPPED — awaiting user visual re-gate
**Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game) **Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game)
**Filed:** 2026-06-12 (user report, post-#119-close session) **Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — doorway-slice scissor box math (AD-17 family) **Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32)
**Symptom (user):** standing inside looking out through a doorway, a **Symptom (user):** standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening. of the TOP of the doorway opening. Survived the scissor fix (`6c4b6d6`)
— user screenshot 2026-06-12 evening, "very subtle".
**Root cause (pinned headlessly 2026-06-12, `Issue130DoorwayStripTests` **Root cause (the REAL strip, pinned by
— 147 eye/gaze combos at the real A9B4 0x0170 exit door):** the `Issue130DoorwayStripTests.UnliftedGate_LeavesTheStripAtTheDrawnTopEdge`):
`BeginDoorwayScissor` NDC→pixel conversion (`Floor(origin) + the +0.02 m shell render lift.** Cell shells DRAW 2 cm above the dat
Ceiling(size)`) put the box's far edge at `floor(min)+ceil(maxmin)` — origin (z-fight vs terrain, AP-32); since `f35cb8b` (the #119-residual
up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional fix) the visibility graph deliberately uses the PHYSICS (unlifted)
alignments. The scissor brackets the ENTIRE landscape slice (sky, transform — but the OutsideView color gate and the seal fans, which are
terrain, statics, weather), the seal stamps the full aperture at true DRAW-space consumers, kept the unlifted polygons. The drawn lintel
depth, and the shell ends at the aperture edge — so the cut pixel row therefore sits one lift-projection ABOVE the gate's top edge —
never receives color: a background strip along the top edge that comes **6.7 px at a 2.4 m doorway** (measured) — and that band gets no
and goes as the eye moves (alignment shifts). Captured live by the terrain/sky color while the seal also stamps 2 cm low. Regression from
harness: top edge y=0.7938 at 1080p → row 968 cut; right edge column `f35cb8b` (2026-06-11), NOT from the W=0 clip port. Vertical edges are
1296 cut at 1920. This violated AD-17's own doctrine (over-inclusion immune (the lift slides them along themselves) — top edge only, exactly
safe, under-inclusion is the bug class). as reported.
**Lead 1 REFUTED:** the W=0 clip port `987313a` is exonerated by the **Fix 2:** draw-space consumers re-apply the lift —
same harness — the CPU polygon pipeline (ProjectToClip → ClipToRegion `PortalVisibilityBuilder.Build(drawLiftZ:)` projects the exit-portal
merges → ClipPlaneSet planes) is sub-pixel exact against the raw OutsideView region with the lifted transform (flood admission, side
aperture projection (worst 0.54 px; 0.00 px in the aligned case). For tests, CellViews stay physics-space per f35cb8b), and the seal/punch
an all-in-front doorway polygon the port is bit-identical to the old fans lift their world verts. One shared constant
path by construction (the W clip pass only runs when a vertex has `PortalVisibilityBuilder.ShellDrawLiftZ` now feeds the shell
w < 0). registration, the gate, and the fans. AP-32 register row added (the
lift had no row). Pins: the lifted gate covers the drawn aperture to
0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px
strip (sensitivity).
**Fix:** conservative outer bound `floor(min)/ceil(max)` extracted to **Fix 1 (also real, sub-pixel): `6c4b6d6`** — the doorway-slice scissor
`NdcScissorRect.ToPixels` (GL-free, unit-tested); `BeginDoorwayScissor` `Floor(origin)+Ceiling(size)` cut up to 1 px off the top/right edges;
delegates. Pins: `NdcScissorRectTests` (containment property + both now a conservative outer bound (`NdcScissorRect`, AD-17 doctrine).
captured alignments) + `Issue130DoorwayStripTests` (scissor never cuts The W=0 clip port `987313a` is exonerated (CPU pipeline sub-pixel exact
plane-admitted fragments; CPU-pipeline exactness canary ≤1.2 px). in like-for-like space).
**Gate:** stand inside any cottage, look out the door, sweep the gaze — **Gate:** stand inside, look out the door with the lintel on screen,
no background strip at the top edge at any alignment. sweep the gaze — no background strip at the top edge at any alignment
or distance.
--- ---

View file

@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 31 rows ## 3. Documented approximation (AP) — 32 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -127,6 +127,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` | | AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
--- ---

View file

@ -5599,9 +5599,13 @@ public sealed class GameWindow : IDisposable
_pendingCellMeshes[envCellId] = cellSubMeshes; _pendingCellMeshes[envCellId] = cellSubMeshes;
// Keep the small render lift out of physics; retail BSP // Keep the small render lift out of physics; retail BSP
// contact planes use the EnvCell origin verbatim. // contact planes use the EnvCell origin verbatim. The lift
// constant is shared with every draw-space consumer of
// portal polygons (OutsideView gate, seal/punch fans) —
// see PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset; var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f); var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform = var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
@ -9699,9 +9703,16 @@ public sealed class GameWindow : IDisposable
if (localVerts.Length < 3) if (localVerts.Length < 3)
continue; continue;
// cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b);
// the shell that rasterizes this aperture draws +ShellDrawLiftZ
// higher. The seal/punch is a DRAW — stamp depth in the same lifted
// space or the stamp sits 2 cm below the drawn hole (#130 family).
int n = System.Math.Min(localVerts.Length, world.Length); int n = System.Math.Min(localVerts.Length, world.Length);
for (int v = 0; v < n; v++) for (int v = 0; v < n; v++)
{
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform); world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
}
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ); _portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
} }

View file

@ -97,16 +97,31 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}"); Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
} }
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs
/// terrain; applied in GameWindow's cell registration). The visibility
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b).
/// Draw-space consumers of portal polygons (the OutsideView color gate
/// here, the seal/punch depth fans in GameWindow) must apply this lift so
/// they meet the drawn shell's aperture edge — the unlifted gate left a
/// 2 cm background strip under the drawn lintel (#130).</summary>
public const float ShellDrawLiftZ = 0.02f;
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param> /// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell /// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of /// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param> /// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
public static PortalVisibilityFrame Build( public static PortalVisibilityFrame Build(
LoadedCell cameraCell, LoadedCell cameraCell,
Vector3 cameraPos, Vector3 cameraPos,
Func<uint, LoadedCell?> lookup, Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj, Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null) Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f)
{ {
var frame = new PortalVisibilityFrame(); var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame; if (cameraCell == null) return frame;
@ -318,8 +333,22 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]"); Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
} }
// Exit portal -> outdoors visible through this (clipped) opening. // Exit portal -> outdoors visible through this (clipped) opening.
AddRegion(frame.OutsideView, clippedRegion); // OutsideView gates DRAWN color (terrain/sky/scissor), and the
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}"); // shell that rasterizes this aperture draws +drawLiftZ above
// the physics transform — project the region in the SAME
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
var outsideRegion = drawLiftZ == 0f
? clippedRegion
: ClipPortalAgainstView(
poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj,
activeViewPolygons,
out _);
AddRegion(frame.OutsideView, outsideRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
continue; continue;
} }

View file

@ -54,7 +54,9 @@ public sealed class RetailPViewRenderer
ctx.RootCell, ctx.RootCell,
ctx.ViewerEyePos, ctx.ViewerEyePos,
ctx.CellLookup, ctx.CellLookup,
ctx.ViewProjection); ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via // the small (~2-cell) per-building views into the frame. Retail reaches building interiors via

View file

@ -77,9 +77,16 @@ public class Issue130DoorwayStripTests
Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon"); Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon");
var localPoly = root.PortalPolygons[exitIdx]; var localPoly = root.PortalPolygons[exitIdx];
// DRAWN space: the shell that rasterizes the aperture (and the seal fan)
// draws +ShellDrawLiftZ above the physics transform — the gate must be
// compared against the drawn hole, not the physics polygon (#130: the
// unlifted gate left a 2 cm background strip under the drawn lintel).
var worldPoly = new Vector3[localPoly.Length]; var worldPoly = new Vector3[localPoly.Length];
for (int i = 0; i < localPoly.Length; i++) for (int i = 0; i < localPoly.Length; i++)
{
worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform); worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform);
worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ;
}
Vector3 centroid = Vector3.Zero; Vector3 centroid = Vector3.Zero;
foreach (var w in worldPoly) centroid += w; foreach (var w in worldPoly) centroid += w;
@ -137,7 +144,8 @@ public class Issue130DoorwayStripTests
for (int i = 0; i < clip.Length; i++) for (int i = 0; i < clip.Length; i++)
aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W); aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W);
var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj); var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj,
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
if (asm.OutsideViewSlices.Length == 0) if (asm.OutsideViewSlices.Length == 0)
{ {
@ -194,6 +202,103 @@ public class Issue130DoorwayStripTests
$"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}")); $"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}"));
} }
/// <summary>Sensitivity proof + regression documentation: a gate built in
/// PHYSICS space (drawLiftZ 0) against the DRAWN (lifted) aperture shows a
/// multi-pixel strip at a close doorway — the user-visible #130 strip
/// (f35cb8b split the lift out of the visibility transform; the OutsideView
/// kept gating drawn color in unlifted space). If this stops failing-by-gap,
/// the lift is gone and the production drawLiftZ plumbing can go too.</summary>
[Fact]
public void UnliftedGate_LeavesTheStripAtTheDrawnTopEdge()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = CornerFloodReplayTests.LoadBuilding(dats);
var root = cells[ExitCellId];
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
int exitIdx = -1;
for (int i = 0; i < root.Portals.Count; i++)
{
if (root.Portals[i].OtherCellId == 0xFFFF && i < root.PortalPolygons.Count
&& root.PortalPolygons[i].Length >= 3)
{ exitIdx = i; break; }
}
Assert.True(exitIdx >= 0);
var localPoly = root.PortalPolygons[exitIdx];
var worldPoly = new Vector3[localPoly.Length];
Vector3 centroid = Vector3.Zero;
for (int i = 0; i < localPoly.Length; i++)
{
worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform);
worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ; // drawn space
centroid += worldPoly[i];
}
centroid /= worldPoly.Length;
var plane = root.ClipPlanes[exitIdx];
var worldNormal = Vector3.TransformNormal(plane.Normal, root.WorldTransform);
var cellCenterWorld = Vector3.Transform(
(root.LocalBoundsMin + root.LocalBoundsMax) * 0.5f, root.WorldTransform);
if (Vector3.Dot(worldNormal, cellCenterWorld - centroid) < 0)
worldNormal = -worldNormal;
worldNormal = Vector3.Normalize(worldNormal);
// d=2.4 m, eye low (0.9 m above the opening's base), gaze at the
// centroid — the main sweep's clean case, where the aperture top edge
// projects ON SCREEN (y≈0.79; a closer/higher eye pushes the lintel
// past the screen top and the seam becomes unmeasurable).
var eye = centroid + worldNormal * 2.4f;
eye.Z = centroid.Z - 1.0f + 0.9f;
var viewProj = ViewProjFor(eye, centroid);
var clip = new Vector4[worldPoly.Length];
for (int i = 0; i < worldPoly.Length; i++)
clip[i] = Vector4.Transform(new Vector4(worldPoly[i], 1f), viewProj);
var aperture = new Vector2[clip.Length];
for (int i = 0; i < clip.Length; i++)
aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W);
var pvUnlifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj); // drawLiftZ 0
var asmUnlifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvUnlifted);
Assert.True(asmUnlifted.OutsideViewSlices.Length > 0);
(float unliftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmUnlifted.OutsideViewSlices, 1920, 1080);
var pvLifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj,
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var asmLifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvLifted);
Assert.True(asmLifted.OutsideViewSlices.Length > 0);
(float liftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmLifted.OutsideViewSlices, 1920, 1080);
_out.WriteLine(FormattableString.Invariant(
$"top-edge gap vs the DRAWN aperture at d=2.4 m: unliftedGate={unliftedGapPx:F2}px liftedGate={liftedGapPx:F2}px"));
var dbg = new System.Text.StringBuilder(" aperture(LIFTED):");
foreach (var v in aperture) dbg.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
_out.WriteLine(dbg.ToString());
foreach (var poly in pvUnlifted.OutsideView.Polygons)
{
var sb = new System.Text.StringBuilder(" unliftedGatePoly:");
foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
_out.WriteLine(sb.ToString());
}
foreach (var poly in pvLifted.OutsideView.Polygons)
{
var sb = new System.Text.StringBuilder(" liftedGatePoly:");
foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
_out.WriteLine(sb.ToString());
}
// The strip the user saw: physics-space gate vs drawn hole, several px.
Assert.True(unliftedGapPx > 2.0f, FormattableString.Invariant(
$"expected the unlifted gate to show the strip (>2px), got {unliftedGapPx:F2}px"));
// The fix: a gate in drawn space covers the drawn hole.
Assert.True(liftedGapPx <= 1.2f, FormattableString.Invariant(
$"lifted gate still under-covers by {liftedGapPx:F2}px"));
}
private static string DescribePolys(CellView view) private static string DescribePolys(CellView view)
{ {
var parts = new List<string>(); var parts = new List<string>();