fix #130: doorway-slice scissor cut the aperture's top/right pixel row

The user's "thin strip of background color along the TOP outer edge of a
doorway, looking out from inside" is the landscape-slice scissor box, not
the W=0 clip port.

Mechanism (pinned headlessly, Issue130DoorwayStripTests, 147 eye/gaze
combos at the real Holtburg A9B4 0x0170 exit door):
- BeginDoorwayScissor converted the slice NDC AABB to pixels as
  Floor(origin) + Ceiling(size). The far edge floor(min)+ceil(max-min)
  lands up to ONE PIXEL SHORT of the true top/right edge at unlucky
  fractional alignments (captured: top edge y=0.7938 @1080p -> row 968
  cut; right edge column 1296 @1920 cut).
- The scissor brackets the ENTIRE landscape slice (sky, terrain, outdoor
  statics, weather). The exit-portal SEAL stamps the full raw aperture at
  true depth and the shell wall ends at the aperture edge, so the cut row
  never receives any color write -> clear color, flickering with eye
  movement as the fractional alignment shifts.
- This violated AD-17's own invariant (over-inclusion is safe,
  UNDER-inclusion is the bug class). No register change: the fix restores
  the row's documented doctrine.

Lead 1 (987313a W=0 clip port regression) REFUTED 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 aligned). For an all-in-front doorway
polygon the port is bit-identical to the old 1e-4 path by construction.
The EyeInsidePortalOpening rescue stays deleted.

Fix: conservative outer bound floor(min)/ceil(max) extracted to
NdcScissorRect.ToPixels (GL-free; containment property proven in the
header comment); BeginDoorwayScissor delegates.

Pins:
- NdcScissorRectTests: center-inside containment across 251 fractional
  alignments x 2 framebuffer sizes + both captured regression cases.
- Issue130DoorwayStripTests: production flood + assembler at the real
  exit door; asserts the scissor never cuts a plane-admitted fragment
  (worstScissorGap 0.00 px post-fix, was 10.8 px capped) and the CPU
  pipeline stays sub-pixel exact (canary 1.2 px).

Suites: App 252+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user visual gate at a cottage doorway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 13:31:43 +02:00
parent 0cb97aa594
commit 6c4b6d64d9
5 changed files with 494 additions and 40 deletions

View file

@ -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(maxmin)` —
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.
---

View file

@ -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;
}

View file

@ -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(maxmin) 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 ≥ X00.5 ⇒ i ≥ floor(X0) and i ≤ X10.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
{
/// <summary>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.</summary>
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));
}
}

View file

@ -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;
/// <summary>
/// #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.
/// </summary>
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<string>();
foreach (var p in view.Polygons) parts.Add(p.Vertices.Length.ToString());
return $"[{string.Join(",", parts)}]";
}
/// <summary>
/// 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.
/// </summary>
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<Vector2, bool> 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;
}
/// <summary>Highest boundary y of the polygon at vertical line x (NaN when
/// the line misses the polygon).</summary>
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;
}
}

View file

@ -0,0 +1,80 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #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(maxmin), up to one pixel short of the true max edge —
/// the doorway top-edge background strip.
/// </summary>
public class NdcScissorRectTests
{
/// <summary>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.</summary>
[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);
}
}