diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 4938d1ab..fee8cf6c 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -4512,38 +4512,45 @@ math).
## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
-**Status:** OPEN
+**Status:** FIX SHIPPED — awaiting user visual gate
**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; "also NOW" —
-possibly new since the W=0 clip port `987313a`)
-**Component:** render — doorway aperture edge (seal/punch/OutsideView seam)
+**Filed:** 2026-06-12 (user report, post-#119-close session)
+**Component:** render — doorway-slice scissor box math (AD-17 family)
**Symptom (user):** standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening.
-**Leads (capture first — plausibly a `987313a` regression):**
-1. The W=0 port changed `ProjectToClip` (exact w>=0, no 1e-4 epsilon)
- and DELETED the `EyeInsidePortalOpening` rescue — the OutsideView
- region through a near doorway is computed slightly differently now.
- If the OutsideView's top edge sits ~1 px BELOW the aperture's drawn
- shell edge, terrain/outdoor geometry isn't drawn in that strip while
- the interior seal/punch still cleared it → background color.
- Suspects within the port: `MergeSubPixelVertices` shaving a top
- vertex; the exact-w boundary vs the old epsilon shifting the
- projected edge; the deleted rescue no longer substituting the full
- view for an eye-pressed doorway.
-2. The interior SEAL depth vs the shell top edge (the #118-era
- machinery) — a 1-px mismatch between the seal polygon and the shell
- aperture would show the clear color exactly at an edge.
+**Root cause (pinned headlessly 2026-06-12, `Issue130DoorwayStripTests`
+— 147 eye/gaze combos at the real A9B4 0x0170 exit door):** the
+`BeginDoorwayScissor` NDC→pixel conversion (`Floor(origin) +
+Ceiling(size)`) put the box's far edge at `floor(min)+ceil(max−min)` —
+up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional
+alignments. The scissor brackets the ENTIRE landscape slice (sky,
+terrain, statics, weather), the seal stamps the full aperture at true
+depth, and the shell ends at the aperture edge — so the cut pixel row
+never receives color: a background strip along the top edge that comes
+and goes as the eye moves (alignment shifts). Captured live by the
+harness: top edge y=0.7938 at 1080p → row 968 cut; right edge column
+1296 cut at 1920. This violated AD-17's own doctrine (over-inclusion
+safe, under-inclusion is the bug class).
-**Next:** screenshot + [viewer]/[pv-dump] capture at a doorway showing
-the strip; diff the OutsideView top edge NDC vs the aperture polygon's
-projected top edge for that frame (the CornerFloodReplay harness
-machinery can replay the frame headlessly once the eye/cell are
-captured). If it reproduces at the same doorway with `987313a` reverted
-locally, it's the port's edge math; fix the math, never re-add the
-rescue.
+**Lead 1 REFUTED:** the W=0 clip port `987313a` is exonerated by the
+same harness — the CPU polygon pipeline (ProjectToClip → ClipToRegion
+merges → ClipPlaneSet planes) is sub-pixel exact against the raw
+aperture projection (worst 0.54 px; 0.00 px in the aligned case). For
+an all-in-front doorway polygon the port is bit-identical to the old
+path by construction (the W clip pass only runs when a vertex has
+w < 0).
+
+**Fix:** conservative outer bound `floor(min)/ceil(max)` extracted to
+`NdcScissorRect.ToPixels` (GL-free, unit-tested); `BeginDoorwayScissor`
+delegates. Pins: `NdcScissorRectTests` (containment property + both
+captured alignments) + `Issue130DoorwayStripTests` (scissor never cuts
+plane-admitted fragments; CPU-pipeline exactness canary ≤1.2 px).
+
+**Gate:** stand inside any cottage, look out the door, sweep the gaze —
+no background strip at the top edge at any alignment.
---
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 59f0f83c..d09cf23d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -9954,26 +9954,18 @@ public sealed class GameWindow : IDisposable
// Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in
// framebuffer pixels and enable the scissor test; returns true iff applied (the caller then
- // disables EnableCap.ScissorTest after its draw/clear). Mirrors the terrain Scissor-mode
- // NDC→pixel conversion (one source for the box math). Used to confine the sky/weather particle
- // passes (particle.vert has no gl_ClipDistance) and the conditional doorway depth-only Z-clear
- // to the doorway opening. Returns false (no scissor) when not applied (outdoor / no window).
+ // disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice
+ // (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false
+ // (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer
+ // bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel
+ // off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip.
private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb)
{
if (!apply || _window is null) return false;
var fb = _window.FramebufferSize;
- // NDC [-1,1] → window pixels. Clamp so a doorway opening that extends past a screen edge
- // still yields a valid box (same clamp the terrain Scissor path uses).
- float nx0 = System.Math.Clamp(ndcAabb.X, -1f, 1f);
- float ny0 = System.Math.Clamp(ndcAabb.Y, -1f, 1f);
- float nx1 = System.Math.Clamp(ndcAabb.Z, -1f, 1f);
- float ny1 = System.Math.Clamp(ndcAabb.W, -1f, 1f);
- int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X);
- int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y);
- int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X);
- int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y);
+ var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y);
_gl!.Enable(EnableCap.ScissorTest);
- _gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph));
+ _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}
diff --git a/src/AcDream.App/Rendering/NdcScissorRect.cs b/src/AcDream.App/Rendering/NdcScissorRect.cs
new file mode 100644
index 00000000..f26eb0c6
--- /dev/null
+++ b/src/AcDream.App/Rendering/NdcScissorRect.cs
@@ -0,0 +1,45 @@
+// NdcScissorRect.cs
+//
+// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
+// The scissor that brackets a landscape/doorway slice is a fallback BOUND on
+// the slice's view region (AD-17 in the divergence register): it must CONTAIN
+// every fragment the per-fragment plane clip would keep. Under-inclusion is
+// the bug class — the #130 doorway top-edge background strip was this box
+// computed as Floor(origin) + Ceiling(size), whose far edge
+// floor(min)+ceil(max−min) lands up to one pixel SHORT of the true max edge
+// at unlucky fractional alignments, scissoring away the aperture's top/right
+// pixel row for the whole slice (sky, terrain, statics, weather) while the
+// seal still stamps it — a strip of clear color no later pass can fill.
+//
+// Correct outer bound: floor both mins, ceil both maxes, width = difference.
+// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in
+// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
+// i ≥ X0−0.5 ⇒ i ≥ floor(X0) and i ≤ X1−0.5 ⇒ i < ceil(X1). So
+// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by
+// at most one pixel per edge — safe per AD-17's doctrine (the wall shell /
+// plane clip repaints or kills the surplus).
+using System;
+using System.Numerics;
+
+namespace AcDream.App.Rendering;
+
+public static class NdcScissorRect
+{
+ /// Convert an NDC AABB (minX, minY, maxX, maxY in [-1,1]) to a
+ /// framebuffer-pixel scissor box that CONTAINS it. Inputs are clamped to
+ /// the screen so a region extending past an edge still yields a valid box.
+ /// Width/height are at least 1.
+ public static (int X, int Y, int Width, int Height) ToPixels(
+ Vector4 ndcAabb, int fbWidth, int fbHeight)
+ {
+ float nx0 = Math.Clamp(ndcAabb.X, -1f, 1f);
+ float ny0 = Math.Clamp(ndcAabb.Y, -1f, 1f);
+ float nx1 = Math.Clamp(ndcAabb.Z, -1f, 1f);
+ float ny1 = Math.Clamp(ndcAabb.W, -1f, 1f);
+ int px0 = (int)MathF.Floor((nx0 * 0.5f + 0.5f) * fbWidth);
+ int py0 = (int)MathF.Floor((ny0 * 0.5f + 0.5f) * fbHeight);
+ int px1 = (int)MathF.Ceiling((nx1 * 0.5f + 0.5f) * fbWidth);
+ int py1 = (int)MathF.Ceiling((ny1 * 0.5f + 0.5f) * fbHeight);
+ return (px0, py0, Math.Max(1, px1 - px0), Math.Max(1, py1 - py0));
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs
new file mode 100644
index 00000000..966a4e8d
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// #130 — background-color strip along the TOP outer edge of a doorway when
+/// looking out from inside. Mechanism model (2026-06-12 evidence sweep): for
+/// an interior root the SEAL stamps the FULL raw dat portal polygon at true
+/// depth (PortalDepthMaskRenderer, root-cell slice = full screen), while
+/// terrain/sky COLOR is gated per fragment by the OutsideView region — the
+/// same dat polygon run through ProjectToClip → ClipToRegion (1-px
+/// MergeSubPixelVertices) → ClipPlaneSet.From (0.5° collinear merge) → planes,
+/// with a Floor/Ceil pixel scissor (BeginDoorwayScissor) on the slice AABB on
+/// top. Every one of those passes can only SHRINK the gate, so any shave shows
+/// as a strip of clear color between the gate's top edge and the aperture's
+/// rasterized top edge (the shell wall starts above it; the seal z-kills
+/// everything beyond; nothing re-covers).
+///
+/// This harness measures that gap headlessly at the real Holtburg corner
+/// building exit door (A9B4 0x0170, the HouseExitWalkReplay door): project the
+/// aperture, run the production flood + assembler, then walk sample points
+/// just inside the aperture's top edge downward until the gate admits them.
+/// Plane-gap and scissor-gap are measured separately (mechanism attribution).
+///
+/// VERDICT (2026-06-12, 147 eye/gaze combos): the CPU polygon pipeline is
+/// sub-pixel exact (worst 0.54 px) — the W=0 clip port 987313a and both merge
+/// passes are EXONERATED. The strip was the scissor box: the old
+/// Floor(origin)+Ceiling(size) form cut up to 1 px off the TOP/RIGHT edges at
+/// unlucky fractional alignments (captured live by this harness: top edge
+/// y=0.7938 at 1080p → row 968 cut; right edge x=0.3503 at 1920 → column 1296
+/// cut). Fixed by the conservative NdcScissorRect bound; the assertions below
+/// pin both properties.
+///
+public class Issue130DoorwayStripTests
+{
+ private readonly ITestOutputHelper _out;
+ public Issue130DoorwayStripTests(ITestOutputHelper output) => _out = output;
+
+ private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
+
+ // Production projection convention (CornerFloodReplayTests.ViewProjFor):
+ // FovY 1.2 rad, 1280x720 viewport, near 1, far 5000. The flood clip is
+ // near-independent so near/far exactness is not load-bearing.
+ private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
+ {
+ var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
+ return view * proj;
+ }
+
+ [Fact]
+ public void Diagnostic_ExitDoorTopEdge_GateVsAperture()
+ {
+ 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;
+
+ // Find the exit portal (OtherCellId == 0xFFFF) and its world polygon.
+ 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, "0x0170 has no exit portal polygon");
+
+ var localPoly = root.PortalPolygons[exitIdx];
+ var worldPoly = new Vector3[localPoly.Length];
+ for (int i = 0; i < localPoly.Length; i++)
+ worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform);
+
+ Vector3 centroid = Vector3.Zero;
+ foreach (var w in worldPoly) centroid += w;
+ centroid /= worldPoly.Length;
+
+ // Inward direction: the portal plane normal signed toward the cell
+ // interior (ClipPlanes carries InsideSide from the load).
+ 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);
+
+ _out.WriteLine(FormattableString.Invariant(
+ $"exit portal idx={exitIdx} verts={localPoly.Length} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) inward=({worldNormal.X:F2},{worldNormal.Y:F2},{worldNormal.Z:F2})"));
+ for (int i = 0; i < worldPoly.Length; i++)
+ _out.WriteLine(FormattableString.Invariant(
+ $" poly[{i}] world=({worldPoly[i].X:F3},{worldPoly[i].Y:F3},{worldPoly[i].Z:F3})"));
+
+ float worstPlaneGapPx = 0f, worstScissorGapPx = 0f;
+ string worstDesc = "(none)";
+
+ // Eye sweep: back off the doorway along the inward normal at several
+ // distances/heights/lateral offsets; gaze at the centroid plus raised /
+ // lowered targets (NDC alignment of the top edge varies with gaze).
+ var lateral = Vector3.Normalize(Vector3.Cross(worldNormal, Vector3.UnitZ));
+ float[] dists = { 0.6f, 1.0f, 1.6f, 2.4f, 3.5f };
+ float[] heights = { 0.9f, 1.4f, 1.7f };
+ float[] laterals = { -0.8f, 0f, 0.8f };
+ float[] gazeRaise = { -0.4f, 0f, 0.4f, 0.9f };
+
+ int evaluated = 0;
+ foreach (float d in dists)
+ foreach (float h in heights)
+ foreach (float lat in laterals)
+ foreach (float gz in gazeRaise)
+ {
+ var eye = centroid + worldNormal * d + lateral * lat;
+ eye.Z = centroid.Z - 1.0f + h; // door centroid sits mid-opening; bias to floor-ish
+ var look = centroid + new Vector3(0, 0, gz);
+ var viewProj = ViewProjFor(eye, look);
+
+ // Aperture truth: the seal's footprint = the raw polygon's projection.
+ var clip = new Vector4[worldPoly.Length];
+ float minW = float.MaxValue;
+ for (int i = 0; i < worldPoly.Length; i++)
+ {
+ clip[i] = Vector4.Transform(new Vector4(worldPoly[i], 1f), viewProj);
+ minW = MathF.Min(minW, clip[i].W);
+ }
+ if (minW <= 0.05f) continue; // eye in/behind the door plane — out of #130's scenario
+ 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 pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj);
+ var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
+ if (asm.OutsideViewSlices.Length == 0)
+ {
+ _out.WriteLine(FormattableString.Invariant(
+ $"d={d} h={h} lat={lat} gz={gz}: NO outside slice (outPolys={pv.OutsideView.Polygons.Count})"));
+ continue;
+ }
+ evaluated++;
+
+ (float planeGapPx, float scissorGapPx, float atX) =
+ MeasureTopEdgeGap(aperture, asm.OutsideViewSlices, 1920, 1080);
+
+ if (planeGapPx > worstPlaneGapPx || scissorGapPx > worstScissorGapPx)
+ {
+ worstDesc = FormattableString.Invariant(
+ $"d={d} h={h} lat={lat} gz={gz} minW={minW:F2} atX={atX:F3} slices={asm.OutsideViewSlices.Length} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)} apVerts={aperture.Length}");
+ worstPlaneGapPx = MathF.Max(worstPlaneGapPx, planeGapPx);
+ worstScissorGapPx = MathF.Max(worstScissorGapPx, scissorGapPx);
+ }
+
+ if (planeGapPx > 0.55f || scissorGapPx > 0.55f)
+ {
+ _out.WriteLine(FormattableString.Invariant(
+ $"GAP d={d} h={h} lat={lat} gz={gz}: planeGap={planeGapPx:F2}px scissorGap={scissorGapPx:F2}px atX={atX:F3} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)}"));
+ float apTop = TopBoundaryY(aperture, atX);
+ foreach (var slice in asm.OutsideViewSlices)
+ _out.WriteLine(FormattableString.Invariant(
+ $" slice slot={slice.Slot} planes={slice.Planes.Length} aabb=({slice.NdcAabb.X:F4},{slice.NdcAabb.Y:F4},{slice.NdcAabb.Z:F4},{slice.NdcAabb.W:F4}) apTopAtX={apTop:F4}"));
+ foreach (var poly in pv.OutsideView.Polygons)
+ {
+ var sb = new System.Text.StringBuilder(" outPoly:");
+ foreach (var v in poly.Vertices)
+ sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
+ _out.WriteLine(sb.ToString());
+ }
+ }
+ }
+
+ _out.WriteLine(FormattableString.Invariant(
+ $"evaluated={evaluated} worstPlaneGapPx={worstPlaneGapPx:F2} worstScissorGapPx={worstScissorGapPx:F2} @ {worstDesc}"));
+
+ Assert.True(evaluated > 100, $"sweep degenerated: only {evaluated} eye/gaze combos evaluated");
+ // PIN 1 (#130): the scissor box never cuts a fragment the plane gate
+ // admits — conservative containment (AD-17's over-include doctrine).
+ // One probe step is ~0.11 px; anything beyond it is a real cut row.
+ Assert.True(worstScissorGapPx <= 0.15f, FormattableString.Invariant(
+ $"scissor under-covers the plane-admitted region by {worstScissorGapPx:F2}px @ {worstDesc}"));
+ // PIN 2 (canary): the CPU polygon pipeline (ProjectToClip → ClipToRegion
+ // merges → ClipPlaneSet planes) stays sub-pixel exact against the raw
+ // aperture projection. Observed 0.54 px worst (2026-06-12); the
+ // production vertex-merge floor is ~1 px — beyond 1.2 px means a new
+ // under-inclusion shaver entered the pipeline.
+ Assert.True(worstPlaneGapPx <= 1.2f, FormattableString.Invariant(
+ $"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}"));
+ }
+
+ private static string DescribePolys(CellView view)
+ {
+ var parts = new List();
+ foreach (var p in view.Polygons) parts.Add(p.Vertices.Length.ToString());
+ return $"[{string.Join(",", parts)}]";
+ }
+
+ ///
+ /// For sample x positions across the aperture's projected top edge, find the
+ /// aperture boundary's top y, then walk downward until the gate admits the
+ /// point. Returns the worst gaps in 1080p pixels (plane gate and modeled
+ /// scissor gate measured independently), and the x of the worst plane gap.
+ ///
+ private static (float planeGapPx, float scissorGapPx, float atX) MeasureTopEdgeGap(
+ Vector2[] aperture, ClipViewSlice[] slices, int fbW, int fbH,
+ ITestOutputHelper? debug = null)
+ {
+ const float Inset = 1e-4f; // dodge exact-boundary ambiguity
+ const float StepY = 0.0002f; // ~0.1 px at 1080p
+ const float CapY = 0.02f; // stop searching beyond ~10 px
+
+ float minX = float.MaxValue, maxX = float.MinValue;
+ foreach (var v in aperture) { minX = MathF.Min(minX, v.X); maxX = MathF.Max(maxX, v.X); }
+ float span = maxX - minX;
+ if (span <= 0.01f) return (0, 0, 0);
+
+ float worstPlane = 0, worstScissor = 0, atX = 0;
+ const int Samples = 160;
+ for (int s = 0; s <= Samples; s++)
+ {
+ float x = minX + span * (0.01f + 0.98f * s / Samples);
+ if (MathF.Abs(x) > 0.98f) continue; // off screen — no pixel exists there
+ float topY = TopBoundaryY(aperture, x);
+ if (float.IsNaN(topY) || MathF.Abs(topY) > 0.98f) continue; // off screen / no boundary
+
+ var p = new Vector2(x, topY - Inset);
+
+ float planeGap = GapBelow(p, q => AnySliceAdmitsPlanes(slices, q), StepY, CapY);
+ // The scissor question is "does the box cut pixels the PLANES would
+ // draw" — measure it from the planes-admitted top, not the aperture
+ // top (at slanted corners the aperture top can sit legitimately
+ // outside the gate polygon's column).
+ var pPlanes = new Vector2(p.X, p.Y - planeGap - Inset);
+ float scissorGap = GapBelow(pPlanes, q => AnySliceAdmitsScissor(slices, q, fbW, fbH), StepY, CapY);
+
+ if (debug is not null && scissorGap > 0.005f)
+ debug.WriteLine(FormattableString.Invariant(
+ $" sample x={x:F4} apTop={topY:F4} planeGap={planeGap * fbH / 2f:F2}px pPlanes=({pPlanes.X:F4},{pPlanes.Y:F4}) scissorGap={scissorGap * fbH / 2f:F2}px"));
+
+ if (planeGap > worstPlane) { worstPlane = planeGap; atX = x; }
+ worstScissor = MathF.Max(worstScissor, scissorGap);
+ }
+ // NDC y → pixels at the given framebuffer height.
+ return (worstPlane * fbH / 2f, worstScissor * fbH / 2f, atX);
+ }
+
+ private static float GapBelow(Vector2 start, Func admitted, float step, float cap)
+ {
+ if (admitted(start)) return 0f;
+ for (float dy = step; dy <= cap; dy += step)
+ {
+ if (admitted(new Vector2(start.X, start.Y - dy)))
+ return dy;
+ }
+ return cap;
+ }
+
+ // Production semantics: each OutsideView polygon is one slice; the union of
+ // slices is drawn. A slice with planes gates per fragment via
+ // gl_ClipDistance (dot((nx,ny,0,d),(x,y,z,1)) >= 0 for an NDC point);
+ // a planeless slice (scissor fallback) admits its whole NDC AABB.
+ private static bool AnySliceAdmitsPlanes(ClipViewSlice[] slices, Vector2 p)
+ {
+ foreach (var slice in slices)
+ {
+ if (slice.Planes.Length == 0)
+ {
+ if (p.X >= slice.NdcAabb.X && p.Y >= slice.NdcAabb.Y
+ && p.X <= slice.NdcAabb.Z && p.Y <= slice.NdcAabb.W)
+ return true;
+ continue;
+ }
+ bool inside = true;
+ foreach (var pl in slice.Planes)
+ {
+ if (pl.X * p.X + pl.Y * p.Y + pl.W < 0f) { inside = false; break; }
+ }
+ if (inside) return true;
+ }
+ return false;
+ }
+
+ // Production scissor (BeginDoorwayScissor → NdcScissorRect.ToPixels): a
+ // point is admitted when its pixel falls inside some slice's scissor box.
+ private static bool AnySliceAdmitsScissor(ClipViewSlice[] slices, Vector2 p, int fbW, int fbH)
+ {
+ int pixX = (int)MathF.Floor((p.X * 0.5f + 0.5f) * fbW);
+ int pixY = (int)MathF.Floor((p.Y * 0.5f + 0.5f) * fbH);
+ foreach (var slice in slices)
+ {
+ var box = NdcScissorRect.ToPixels(slice.NdcAabb, fbW, fbH);
+ if (pixX >= box.X && pixX < box.X + box.Width
+ && pixY >= box.Y && pixY < box.Y + box.Height)
+ return true;
+ }
+ return false;
+ }
+
+ /// Highest boundary y of the polygon at vertical line x (NaN when
+ /// the line misses the polygon).
+ private static float TopBoundaryY(Vector2[] poly, float x)
+ {
+ float best = float.NaN;
+ for (int i = 0; i < poly.Length; i++)
+ {
+ var a = poly[i];
+ var b = poly[(i + 1) % poly.Length];
+ if (MathF.Abs(a.X - b.X) < 1e-9f)
+ {
+ if (MathF.Abs(a.X - x) < 1e-6f)
+ {
+ float hi = MathF.Max(a.Y, b.Y);
+ if (float.IsNaN(best) || hi > best) best = hi;
+ }
+ continue;
+ }
+ float t = (x - a.X) / (b.X - a.X);
+ if (t < 0f || t > 1f) continue;
+ float y = a.Y + t * (b.Y - a.Y);
+ if (float.IsNaN(best) || y > best) best = y;
+ }
+ return best;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs b/tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs
new file mode 100644
index 00000000..2dc084ff
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/NdcScissorRectTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Numerics;
+using AcDream.App.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// #130: the doorway-slice scissor must be a CONSERVATIVE outer bound of its
+/// NDC AABB (AD-17: over-inclusion safe, under-inclusion is the bug class).
+/// The old Floor(origin)+Ceiling(size) form put the far edge at
+/// floor(min)+ceil(max−min), up to one pixel short of the true max edge —
+/// the doorway top-edge background strip.
+///
+public class NdcScissorRectTests
+{
+ /// Containment property: every pixel whose CENTER lies inside the
+ /// NDC box is inside the scissor box, across a dense grid of fractional
+ /// alignments at two framebuffer sizes.
+ [Theory]
+ [InlineData(1920, 1080)]
+ [InlineData(2560, 1440)]
+ public void EveryCenterInsidePixel_IsInsideTheBox(int fbW, int fbH)
+ {
+ for (int i = 0; i < 251; i++)
+ {
+ // Sweep fractional alignments of all four edges.
+ float f = i / 251f;
+ float minX = -0.83f + f * 0.0031f;
+ float minY = -0.71f + f * 0.0047f;
+ float maxX = 0.339f + f * 0.0043f;
+ float maxY = 0.7938f + f * 0.0029f;
+ var box = NdcScissorRect.ToPixels(new Vector4(minX, minY, maxX, maxY), fbW, fbH);
+
+ // Pixel-space extremes of center-inside pixels.
+ float x0 = (minX * 0.5f + 0.5f) * fbW, x1 = (maxX * 0.5f + 0.5f) * fbW;
+ float y0 = (minY * 0.5f + 0.5f) * fbH, y1 = (maxY * 0.5f + 0.5f) * fbH;
+ int loX = (int)MathF.Ceiling(x0 - 0.5f), hiX = (int)MathF.Floor(x1 - 0.5f);
+ int loY = (int)MathF.Ceiling(y0 - 0.5f), hiY = (int)MathF.Floor(y1 - 0.5f);
+
+ Assert.True(box.X <= loX, $"left cut: box.X={box.X} > loX={loX} (minX={minX})");
+ Assert.True(box.Y <= loY, $"bottom cut: box.Y={box.Y} > loY={loY} (minY={minY})");
+ Assert.True(box.X + box.Width > hiX, $"right cut: box ends {box.X + box.Width} <= hiX={hiX} (maxX={maxX})");
+ Assert.True(box.Y + box.Height > hiY, $"top cut: box ends {box.Y + box.Height} <= hiY={hiY} (maxY={maxY})");
+ // Over-inclusion stays bounded (≤1 px per edge).
+ Assert.True(box.X >= loX - 1 && box.Y >= loY - 1);
+ Assert.True(box.X + box.Width <= hiX + 2 && box.Y + box.Height <= hiY + 2);
+ }
+ }
+
+ [Fact]
+ public void CapturedRegression_TopEdgeRow968_At1080p()
+ {
+ // Issue130DoorwayStripTests live capture: aperture top y=0.7938 →
+ // pixel row 968 (center 968.5 < 968.65). The old formula ended the box
+ // at row 967 — the visible strip.
+ var box = NdcScissorRect.ToPixels(new Vector4(-0.339f, -0.743f, 0.339f, 0.7938f), 1920, 1080);
+ Assert.True(box.Y + box.Height > 968, $"top row 968 cut: box ends at {box.Y + box.Height}");
+ }
+
+ [Fact]
+ public void CapturedRegression_RightColumn1296_At1920()
+ {
+ // Issue130DoorwayStripTests live capture: gate right edge x=0.3507 →
+ // pixel column 1296 admitted by the plane gate; the old formula ended
+ // the box at column 1295.
+ var box = NdcScissorRect.ToPixels(new Vector4(-0.2845f, -1.0f, 0.3507f, 0.2630f), 1920, 1080);
+ Assert.True(box.X + box.Width > 1296, $"right column 1296 cut: box ends at {box.X + box.Width}");
+ }
+
+ [Fact]
+ public void DegenerateAndOffscreenBoxes_StayValid()
+ {
+ // Past-the-edge regions clamp to the screen and keep min 1 px size.
+ var box = NdcScissorRect.ToPixels(new Vector4(0.999f, 0.999f, 1.5f, 1.5f), 1920, 1080);
+ Assert.True(box.Width >= 1 && box.Height >= 1);
+ var inverted = NdcScissorRect.ToPixels(new Vector4(1f, 1f, -1f, -1f), 1920, 1080);
+ Assert.True(inverted.Width >= 1 && inverted.Height >= 1);
+ }
+}