The live capture pinned it end to end. BuildInteriorEntitiesForStreaming lifts the render-side cell transform +0.02 m Z (shell z-fighting vs terrain - a DRAW concern) and passed that LIFTED transform to BuildLoadedCell, so every plane in the visibility graph sat 2 cm high. The portal side test's in-plane window is +-10 mm: an eye standing ON a floor containing a HORIZONTAL portal (the tower's deck lip 010A->0107, stair landings, cellar mouths) sits 0-10 mm above the TRUE plane = 10-20 mm BELOW the lifted plane -> outside the window -> the cell behind the portal side-culled out of the flood. Captured live at the stair top: root=0xAAB3010A eye z=126.803 vs the portal plane at 126.80, flood=1, 0x0107 (the whole tower interior incl. the staircase) dropped WHILE THE GAZE LOOKED STRAIGHT AT IT - "stairs disappear and you can walk on them", and the roof/edge flap as the gaze swung the marginal admissions. Vertical doorways were immune (the lift slides their planes along themselves) - exactly why this hit stairs/decks/floors and not doors. Chase chain (the apparatus did all the work): [viewer] print-on-change probe with eye@mm -> the user's climb capture -> [viewer-diff] naming the dropped cells per flip -> headless replay of the exact captured (eye,fwd) frame: healthy UNLIFTED, reproduces ONLY with the production lift -> gate-by-gate diagnostic (side test dot=+0.003 unlifted vs -0.017 lifted; clip + rescue exonerated; knife-edge z-sweep all-stable, killing the float-chaos theory). Fix: BuildLoadedCell receives the PHYSICS (unlifted) transform; the drawn shells keep their lift. The seal/punch fans (which read the visibility LoadedCell's WorldTransform) now stamp TRUE depth - MORE consistent with the unlifted terrain they protect. Pins: CapturedTopOfStairs_MainCellStaysInFlood - arm 1 (unlifted = post-fix production) asserts the main cell admitted at the captured frame; arm 2 (lifted) is the mechanism canary asserting the drop, with instructions if it ever starts passing. Plus the gate-by-gate diagnostic + knife-edge sweep as the investigation record. Also this session: Issue127FloodFlipReplayTests (the captured 4 cm outdoor flip pair replays STABLE across fovs/pre-gate arms - the outdoor churn is NOT the flood math; remaining #127 = distant-building admission churn, lower priority now that the tower-cell drops are explained by the lift), and the [viewer-diff] probe (per-flip added/ removed cell naming - keep, it found this). Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
428 lines
21 KiB
C#
428 lines
21 KiB
C#
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>
|
||
/// #119 residual — the tower-ascent harness (the #118 exit-walk pattern,
|
||
/// vertical). User report (post-#120 build): running UP the AAB3 tower
|
||
/// (cell 0xAAB30107, building[1] model 0x01001117) the TOP stairs disappear
|
||
/// visually but stay walkable, and at the top the roof/edges FLAP in and
|
||
/// out. The tower's upper cells are thin landing/roof slabs with EXIT
|
||
/// portals (0x0108 z≈121–123, 0x0109 z≈112–114.5, 0x010A z≈126.8–127.2 —
|
||
/// the roof lip), so the ascent walks the eye across exit-portal planes —
|
||
/// the same decision-stack territory as #118/#120.
|
||
///
|
||
/// Per step of a helix matching the spiral staircase, this drives the
|
||
/// production stack headlessly: viewer-cell resolution (smallest containing
|
||
/// AABB; outdoor when none) → PortalVisibilityBuilder.Build (+ the
|
||
/// per-building exterior floods on outdoor steps, mirroring
|
||
/// RetailPViewRenderer.MergeNearbyBuildingFloods) → ClipFrameAssembler →
|
||
/// ViewconeCuller → the DrawCellObjectLists predicate for the staircase
|
||
/// static (Setup 0x020003F2, ParentCellId 0x0107). The failing steps pin
|
||
/// the vanish; root/visibility instability across adjacent steps pins the
|
||
/// flap.
|
||
/// </summary>
|
||
public class TowerAscentReplayTests
|
||
{
|
||
private readonly ITestOutputHelper _out;
|
||
public TowerAscentReplayTests(ITestOutputHelper output) => _out = output;
|
||
|
||
private const uint Landblock = 0xAAB30000u;
|
||
private const uint MainCell = Landblock | 0x0107u;
|
||
|
||
// Tower geometry (Issue119TowerDumpTests dat facts): origin (108,60,112);
|
||
// the staircase Setup spans local x,y ∈ [-4,4], z ∈ [0,15.5]. Production
|
||
// EntitySphere = AABB center + half-diagonal.
|
||
private static readonly Vector3 TowerCenter = new(108f, 60f, 112f);
|
||
private static readonly Vector3 StairSphereCenter = new(108f, 60f, 119.75f);
|
||
private const float StairSphereRadius = 9.45f;
|
||
|
||
private sealed record AscentStep(
|
||
int Index,
|
||
Vector3 Eye,
|
||
uint RootCellId, // 0 = outdoor
|
||
bool FloodHasMainCell,
|
||
bool StairsConeVisible,
|
||
int OutsidePolys,
|
||
int FloodCells);
|
||
|
||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||
{
|
||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||
return view * proj;
|
||
}
|
||
|
||
private static uint? ResolveInteriorCellByAabb(
|
||
Dictionary<uint, LoadedCell> cells, Vector3 worldPoint, float margin = 0.05f)
|
||
{
|
||
uint? best = null;
|
||
float bestVolume = float.MaxValue;
|
||
foreach (var (id, cell) in cells)
|
||
{
|
||
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
|
||
var min = cell.LocalBoundsMin - new Vector3(margin);
|
||
var max = cell.LocalBoundsMax + new Vector3(margin);
|
||
if (local.X < min.X || local.Y < min.Y || local.Z < min.Z
|
||
|| local.X > max.X || local.Y > max.Y || local.Z > max.Z)
|
||
continue;
|
||
var ext = cell.LocalBoundsMax - cell.LocalBoundsMin;
|
||
float volume = MathF.Max(ext.X, 1e-3f) * MathF.Max(ext.Y, 1e-3f) * MathF.Max(ext.Z, 1e-3f);
|
||
if (volume < bestVolume) { bestVolume = volume; best = id; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// Mirror of RetailPViewRenderer.MergeBuildingFrame (union semantics).
|
||
private static void MergeFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
|
||
{
|
||
foreach (uint cellId in src.OrderedVisibleCells)
|
||
{
|
||
if (!src.CellViews.TryGetValue(cellId, out var srcView)) continue;
|
||
if (target.CellViews.TryGetValue(cellId, out var existing))
|
||
{
|
||
foreach (var p in srcView.Polygons) existing.Add(p);
|
||
continue;
|
||
}
|
||
target.CellViews[cellId] = srcView;
|
||
target.OrderedVisibleCells.Add(cellId);
|
||
}
|
||
}
|
||
|
||
private List<AscentStep>? RunAscent()
|
||
{
|
||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
|
||
|
||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||
Assert.True(cells.ContainsKey(MainCell), "tower main cell not loaded");
|
||
|
||
// The tower building's cell list (Issue119TowerDumpTests: building[1]
|
||
// stabs = 0x0107..0x010A) — the per-building flood candidates on
|
||
// outdoor-root steps, mirroring GameWindow's gather.
|
||
var towerCells = new List<LoadedCell>();
|
||
for (uint low = 0x0107u; low <= 0x010Au; low++)
|
||
if (cells.TryGetValue(Landblock | low, out var c))
|
||
towerCells.Add(c);
|
||
|
||
// Eye helix matching the spiral: two turns, radius 2.5 m, z from head
|
||
// height at the base to above the roof lip (cell 0x010A tops at 127.2).
|
||
const int Steps = 220;
|
||
const float ZStart = 113.6f, ZEnd = 128.6f;
|
||
var clipFrame = ClipFrame.NoClip();
|
||
var steps = new List<AscentStep>(Steps + 1);
|
||
|
||
for (int i = 0; i <= Steps; i++)
|
||
{
|
||
float t = i / (float)Steps;
|
||
float theta = t * 4f * MathF.PI;
|
||
float z = ZStart + t * (ZEnd - ZStart);
|
||
var eye = new Vector3(
|
||
TowerCenter.X + 2.5f * MathF.Cos(theta),
|
||
TowerCenter.Y + 2.5f * MathF.Sin(theta),
|
||
z);
|
||
// look AT the staircase (the user's framing while climbing: the
|
||
// camera tracks the player on the stairs). An unambiguous pin:
|
||
// if the staircase cone-culls while looked at directly, the
|
||
// visibility stack is wrong — no gaze excuse.
|
||
var gaze = StairSphereCenter - eye;
|
||
if (gaze.LengthSquared() < 1e-4f) gaze = new Vector3(0f, 0f, -1f);
|
||
var viewProj = ViewProjFor(eye, eye + Vector3.Normalize(gaze) * 3f);
|
||
|
||
uint rootCellId = 0u;
|
||
PortalVisibilityFrame pv;
|
||
var contained = ResolveInteriorCellByAabb(cells, eye);
|
||
if (contained.HasValue)
|
||
{
|
||
rootCellId = contained.Value;
|
||
pv = PortalVisibilityBuilder.Build(cells[rootCellId], eye, lookup, viewProj);
|
||
}
|
||
else
|
||
{
|
||
// outdoor root: full-screen outside view + the per-building
|
||
// exterior floods (RetailPViewRenderer.MergeNearbyBuildingFloods)
|
||
var outdoor = OutdoorCellNode.Build(Landblock | 0x0001u);
|
||
pv = PortalVisibilityBuilder.Build(outdoor, eye, lookup, viewProj);
|
||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(
|
||
towerCells, eye, lookup, viewProj);
|
||
MergeFrame(pv, bf);
|
||
}
|
||
|
||
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
|
||
var cone = ViewconeCuller.Build(asm, viewProj);
|
||
|
||
bool floodHasMain = pv.CellViews.ContainsKey(MainCell);
|
||
// DrawCellObjectLists predicate for a 0x0107 static: sphere vs the
|
||
// cell's views (RetailPViewRenderer.cs DrawCellObjectLists / T3).
|
||
bool stairsVisible = cone.SphereVisibleInCell(MainCell, StairSphereCenter, StairSphereRadius);
|
||
|
||
steps.Add(new AscentStep(
|
||
i, eye, rootCellId, floodHasMain, stairsVisible,
|
||
pv.OutsideView.Polygons.Count, pv.OrderedVisibleCells.Count));
|
||
}
|
||
|
||
return steps;
|
||
}
|
||
|
||
private void DumpSteps(IEnumerable<AscentStep> steps, Func<AscentStep, bool>? filter = null)
|
||
{
|
||
foreach (var st in steps)
|
||
{
|
||
if (filter is not null && !filter(st)) continue;
|
||
string root = st.RootCellId == 0 ? "OUTDOOR " : FormattableString.Invariant($"0x{st.RootCellId:X8}");
|
||
string stairs = st.StairsConeVisible ? "VIS " : "CULL";
|
||
_out.WriteLine(FormattableString.Invariant(
|
||
$"step={st.Index,3} eye=({st.Eye.X:F2},{st.Eye.Y:F2},{st.Eye.Z:F2}) root={root} floodMain={st.FloodHasMainCell} stairs={stairs} outPolys={st.OutsidePolys} flood={st.FloodCells}"));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The vanish pin: the staircase static must stay cone-visible on every
|
||
/// ascent step (the gaze looks STRAIGHT AT it — no gaze excuse).
|
||
///
|
||
/// PINNED RED 2026-06-11 (skip carries the defect; un-skip with the fix):
|
||
/// steps 195–201 (eye z 126.9–127.3, the roof-lip band between the main
|
||
/// cell's ceiling at 126.8 and the roof aperture plane at ~127.2) resolve
|
||
/// OUTDOOR and the per-building exterior flood admits NOTHING (flood=1 =
|
||
/// the outdoor node alone) — the eye is above every side aperture's
|
||
/// useful view and ON/INSIDE the roof aperture's plane, so
|
||
/// BuildFromExterior's seed side-test/in-plane reject refuses every exit
|
||
/// portal. The tower interior never floods → the staircase (a 0x0107
|
||
/// static) has no views → culled while walkable, and the roof-lip cell
|
||
/// geometry flaps as the live eye bobs across the band's edges. Fix
|
||
/// needs the retail oracle: does retail keep the viewer cell INTERIOR
|
||
/// through this band (curr_cell keep-curr above open-top cells), or can
|
||
/// ConstructView(CBldPortal) seed with an in-plane eye?
|
||
/// </summary>
|
||
[Fact(Skip = "#119-residual: pins the roof-lip flood gap (steps 195-201) — un-skip with the fix; see the doc comment")]
|
||
public void TowerAscent_StaircaseStaysConeVisible_EveryStep()
|
||
{
|
||
var steps = RunAscent();
|
||
if (steps is null) return;
|
||
|
||
var failures = steps.FindAll(s => !s.StairsConeVisible);
|
||
if (failures.Count > 0)
|
||
{
|
||
_out.WriteLine($"--- {failures.Count} stairs-CULLED steps ---");
|
||
DumpSteps(failures);
|
||
}
|
||
Assert.True(failures.Count == 0,
|
||
$"{failures.Count}/{steps.Count} ascent steps cone-cull the staircase (first at step {(failures.Count > 0 ? failures[0].Index : -1)}) — see output");
|
||
}
|
||
|
||
/// <summary>
|
||
/// The flap pin: along a smooth monotone ascent, the resolved root may
|
||
/// transition between cells/outdoor but must not PING-PONG (A→B→A within
|
||
/// three consecutive steps = a knife-edge decision the live camera jitter
|
||
/// turns into per-frame flapping).
|
||
/// </summary>
|
||
[Fact]
|
||
public void TowerAscent_RootDoesNotPingPong()
|
||
{
|
||
var steps = RunAscent();
|
||
if (steps is null) return;
|
||
|
||
var flips = new List<int>();
|
||
for (int i = 2; i < steps.Count; i++)
|
||
{
|
||
uint a = steps[i - 2].RootCellId, b = steps[i - 1].RootCellId, c = steps[i].RootCellId;
|
||
if (a != b && b != c && a == c)
|
||
flips.Add(i - 1);
|
||
}
|
||
if (flips.Count > 0)
|
||
{
|
||
_out.WriteLine($"--- {flips.Count} root ping-pong steps ---");
|
||
DumpSteps(steps, s => flips.Contains(s.Index) || flips.Contains(s.Index - 1) || flips.Contains(s.Index + 1));
|
||
}
|
||
Assert.True(flips.Count == 0,
|
||
$"{flips.Count} root ping-pongs along a monotone ascent — knife-edge root decisions (see output)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// THE captured top-of-stairs vanish (flap-diff-capture.log, live user
|
||
/// pan): root=0xAAB3010A (the roof-lip cell), eye 3 mm above its floor
|
||
/// portal plane, gazing DOWN at the staircase — and the flood admitted
|
||
/// ONLY {010A}: 0x0107 (the whole tower interior, staircase included)
|
||
/// dropped while being looked straight at. Exact production inputs:
|
||
/// eye=(301.448,-128.448,126.803) fwd=(-0.9361,-0.2459,-0.2514)
|
||
/// eye=(301.371,-128.401,126.807) fwd=(-0.8938,-0.3596,-0.2678)
|
||
/// The 010A→0107 portal is HORIZONTAL at z≈126.8 — the eye rides within
|
||
/// millimetres of its plane at the stair top, knife-edge projection
|
||
/// territory (the #120 family's geometry, admission-side).
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(301.448f, -128.448f, 126.803f, -0.9361f, -0.2459f, -0.2514f)]
|
||
[InlineData(301.371f, -128.401f, 126.807f, -0.8938f, -0.3596f, -0.2678f)]
|
||
public void CapturedTopOfStairs_MainCellStaysInFlood(
|
||
float ex, float ey, float ez, float fx, float fy, float fz)
|
||
{
|
||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||
|
||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||
|
||
// The capture is A9B4-anchored world; the loaded cells are AAB3-local
|
||
// (LoadCell without offset). AAB3 = A9B4 + (1,-1) blocks → local =
|
||
// world - (192,-192,0).
|
||
var eye = new Vector3(ex - 192f, ey + 192f, ez);
|
||
var fwd = Vector3.Normalize(new Vector3(fx, fy, fz));
|
||
|
||
// ROOT CAUSE (found via this capture): production fed BuildLoadedCell
|
||
// the +0.02 m RENDER-LIFTED transform ("keep the small render lift out
|
||
// of physics", GameWindow:5567) — the visibility graph's HORIZONTAL
|
||
// portal planes all sat 2 cm high, so an eye standing on the deck
|
||
// (3 mm above the TRUE plane) was 17 mm BELOW the lifted plane —
|
||
// outside the side test's ±10 mm in-plane window → 010A→0107
|
||
// side-culled → the staircase vanished while walkable. FIXED: the
|
||
// visibility LoadedCell now gets the PHYSICS (unlifted) transform.
|
||
//
|
||
// Arm 1 (unlifted = post-fix production): the main cell must be
|
||
// admitted. Arm 2 (lifted = the old bug, kept as the mechanism
|
||
// canary): the main cell is dropped — if this arm ever starts
|
||
// PASSING, the in-plane window has changed enough to absorb a 2 cm
|
||
// lift; re-evaluate both this canary and the no-lift rule.
|
||
foreach (bool lift in new[] { false, true })
|
||
{
|
||
var armCells = lift ? new Dictionary<uint, LoadedCell>() : cells;
|
||
if (lift)
|
||
{
|
||
var datDir2 = CornerFloodReplayTests.ResolveDatDir()!;
|
||
using var dats2 = new DatCollection(datDir2, DatAccessType.Read);
|
||
var lbi = dats2.Get<DatReaderWriter.DBObjs.LandBlockInfo>(Landblock | 0xFFFEu)!;
|
||
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
|
||
{
|
||
try { armCells[Landblock | low] = CornerFloodReplayTests.LoadCell(dats2, Landblock | low, new Vector3(0f, 0f, 0.02f)); }
|
||
catch (InvalidOperationException) { }
|
||
}
|
||
}
|
||
Func<uint, LoadedCell?> armLookup = id => armCells.TryGetValue(id, out var c) ? c : null;
|
||
var root = armCells[Landblock | 0x010Au];
|
||
|
||
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f })
|
||
{
|
||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fov, 1280f / 720f, 0.1f, 5000f);
|
||
var pv = PortalVisibilityBuilder.Build(root, eye, armLookup, view * proj);
|
||
bool hasMain = pv.CellViews.ContainsKey(MainCell);
|
||
_out.WriteLine(FormattableString.Invariant(
|
||
$"lift={lift} fov={fov:F2}: flood={pv.OrderedVisibleCells.Count} hasMain={hasMain}"));
|
||
if (!lift)
|
||
Assert.True(hasMain,
|
||
$"fov={fov:F2}: the tower main cell 0x{MainCell:X8} is NOT in the flood from the roof-lip root (unlifted = post-fix production)");
|
||
else
|
||
Assert.False(hasMain,
|
||
$"fov={fov:F2}: the LIFTED canary unexpectedly admits the main cell — the in-plane window now absorbs a 2 cm lift; re-evaluate the canary + the no-lift rule");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gate-by-gate diagnosis of the captured drop: for the 010A→0107 portal
|
||
/// at the exact captured eye/gaze, print each admission gate's verdict —
|
||
/// side test (CameraOnInteriorSide replica), homogeneous clip vs the
|
||
/// full-screen view (ProjectToClip + ClipToRegion, the forward-hop
|
||
/// pipeline), and the eye-in-opening rescue inputs (perp distance +
|
||
/// footprint containment).
|
||
/// </summary>
|
||
[Fact]
|
||
public void Diagnostic_TopOfStairs_GateByGate()
|
||
{
|
||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||
|
||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||
var root = cells[Landblock | 0x010Au];
|
||
|
||
// capture eye converted to the cells' AAB3-local frame (world - (192,-192,0))
|
||
var eye = new Vector3(301.448f - 192f, -128.448f + 192f, 126.803f);
|
||
var fwd = Vector3.Normalize(new Vector3(-0.9361f, -0.2459f, -0.2514f));
|
||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1280f / 720f, 0.1f, 5000f);
|
||
var viewProj = view * proj;
|
||
|
||
for (int i = 0; i < root.Portals.Count; i++)
|
||
{
|
||
var portal = root.Portals[i];
|
||
var poly = i < root.PortalPolygons.Count ? root.PortalPolygons[i] : null;
|
||
_out.WriteLine($"portal[{i}] -> 0x{portal.OtherCellId:X4} polyLen={poly?.Length ?? -1}");
|
||
if (poly is null || poly.Length < 3) continue;
|
||
|
||
// gate 1: side test (CameraOnInteriorSide replica, ε=0.01 in-plane-allowed)
|
||
string side = "no-plane(allow)";
|
||
if (i < root.ClipPlanes.Count && root.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
|
||
{
|
||
var plane = root.ClipPlanes[i];
|
||
var localCam = Vector3.Transform(eye, root.InverseWorldTransform);
|
||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||
bool pass = plane.InsideSide == 0 ? dot >= -0.01f : dot <= 0.01f;
|
||
side = FormattableString.Invariant($"dot={dot:F4} insideSide={plane.InsideSide} -> {(pass ? "TRAVERSE" : "CULL")}");
|
||
}
|
||
_out.WriteLine($" side: {side}");
|
||
|
||
// gate 2: homogeneous clip vs the full-screen view
|
||
var clip = PortalProjection.ProjectToClip(poly, root.WorldTransform, viewProj);
|
||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||
var region = clip.Length >= 3 ? PortalProjection.ClipToRegion(clip, fullScreen) : Array.Empty<Vector2>();
|
||
_out.WriteLine($" clip: projVerts={clip.Length} regionVerts={region.Length}");
|
||
|
||
// gate 3: rescue inputs — perp distance to the portal plane + the
|
||
// perpendicular foot inside the opening's footprint
|
||
var centroid = Vector3.Zero;
|
||
foreach (var v in poly) centroid += Vector3.Transform(v, root.WorldTransform);
|
||
centroid /= poly.Length;
|
||
var n0 = Vector3.Transform(poly[0], root.WorldTransform);
|
||
var n1 = Vector3.Transform(poly[1], root.WorldTransform);
|
||
var n2 = Vector3.Transform(poly[2], root.WorldTransform);
|
||
var planeN = Vector3.Normalize(Vector3.Cross(n1 - n0, n2 - n0));
|
||
float perp = MathF.Abs(Vector3.Dot(planeN, eye - centroid));
|
||
_out.WriteLine(FormattableString.Invariant(
|
||
$" rescue: perpDist={perp:F4} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) planeN=({planeN.X:F2},{planeN.Y:F2},{planeN.Z:F2})"));
|
||
}
|
||
|
||
// knife-edge sweep: the probe rounds the eye to mm — sweep z within the
|
||
// rounding envelope and count clip flips for the 010A→0107 portal. A
|
||
// chaotic 6↔0 region inside ±2 mm proves the live drop is the same
|
||
// frame at sub-mm precision.
|
||
{
|
||
var poly = root.PortalPolygons[0];
|
||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||
int flips = 0;
|
||
bool? prev = null;
|
||
var results = new System.Text.StringBuilder();
|
||
for (int i = -20; i <= 20; i++)
|
||
{
|
||
var e2 = eye with { Z = eye.Z + i * 0.0002f };
|
||
var v2 = Matrix4x4.CreateLookAt(e2, e2 + fwd * 3f, Vector3.UnitZ);
|
||
var vp2 = v2 * proj;
|
||
var clip2 = PortalProjection.ProjectToClip(poly, root.WorldTransform, vp2);
|
||
var region2 = clip2.Length >= 3 ? PortalProjection.ClipToRegion(clip2, fullScreen) : Array.Empty<Vector2>();
|
||
bool ok = region2.Length >= 3;
|
||
if (prev.HasValue && ok != prev.Value) flips++;
|
||
prev = ok;
|
||
results.Append(ok ? '1' : '0');
|
||
}
|
||
_out.WriteLine($" knife-edge sweep (z ±4 mm, 0.2 mm steps): {results} flips={flips}");
|
||
}
|
||
}
|
||
|
||
/// <summary>Full per-step table for the investigation record.</summary>
|
||
[Fact]
|
||
public void Diagnostic_TowerAscent_PerStepTable()
|
||
{
|
||
var steps = RunAscent();
|
||
if (steps is null) return;
|
||
DumpSteps(steps);
|
||
}
|
||
}
|