acdream/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
Erik fde169970f diag(render): Phase U.4c — flap probe logs projected NDC coords + clip result
The [flap] line now reports, per root portal, the actual projected NDC vertices and
the Intersect-against-FullScreen result count (clip=N), so a portal that PROJECTS
(proj>=3) but still fails to ADD its neighbour (vis stays low) shows WHY: clip=0 with
ndc inside [-1,1] = winding/self-intersection degeneracy; clip=0 with ndc outside
[-1,1] = genuinely off-screen; the ndc coords expose a near-plane bowtie. Pins the
exact clip-region failure before the root-cause fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:21:46 +02:00

432 lines
26 KiB
C#

// PortalVisibilityBuilder.cs
//
// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail
// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals
// (433446). Walks the portal graph from the camera cell, accumulating a per-cell
// screen-space CellView; exit portals union their clipped region into OutsideView.
// GL-free; unit-tested without a GPU context.
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame
{
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; } = new();
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new();
/// <summary>Visible interior cells in the exact order they were first dequeued from the
/// distance-priority work list — closest-first (Phase U.2a). Mirrors retail's
/// PView::cell_draw_list, appended in PView::ConstructView (decomp:433783) as each cell pops
/// off the nearest-vertex-sorted cell_todo_list (InsCellTodoList 433183). Deduplicated: a cell
/// appears exactly once, on its first dequeue. The camera cell is always first.</summary>
public List<uint> OrderedVisibleCells { get; } = new();
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
}
public static class PortalVisibilityBuilder
{
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
private static readonly bool s_pvDump =
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
/// <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
/// 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>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null)
{
var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame;
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
// building-boundary crossings are handled separately via the buildingMembership escape hatch.
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
// always pops first.
var todo = new CellTodoList();
todo.Insert(cameraCell, 0f);
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
// (current view_count) decision in AddViewToPortals (433446): a cell is INSERTED into the
// todo list exactly once — on first discovery (retail's ecx_5==0 branch calls
// InsCellTodoList; the ecx_5!=eax_2 growth branch calls AddToCell IN PLACE and never
// re-enqueues). Later growth into an already-discovered cell is unioned into its CellView but
// does NOT re-enqueue it — the `cell_view_done` guarantee (ConstructView sets it at 433784
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
var seen = new HashSet<uint> { cameraCell.CellId };
bool pvDump = false;
if (s_pvDump)
{
lock (s_pvDumpCount)
{
s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc);
if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; }
}
if (pvDump)
{
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
// a portal the loop silently `continue`s past is still visible here. An
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
// polygon from BuildLoadedCell), interiorSide=false (camera back-facing the
// portal — a legitimately-empty result, not a bug), or (if both OK) a
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
{
int plen = ci < cameraCell.PortalPolygons.Count
? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2;
bool hasPlane = ci < cameraCell.ClipPlanes.Count;
bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos);
var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero;
Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})");
}
}
}
while (todo.Count > 0)
{
var cell = todo.PopNearest();
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
continue;
// `seen` guarantees each cell is inserted into the todo list exactly once, so this single
// pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per
// pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
frame.OrderedVisibleCells.Add(cell.CellId);
for (int i = 0; i < cell.Portals.Count; i++)
{
if (i >= cell.PortalPolygons.Count) continue;
var poly = cell.PortalPolygons[i];
if (poly == null || poly.Length < 3) continue;
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
// Portal-side test: only traverse a portal the camera is on the interior side of
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing
// portals so we never feed a degenerate/wrong-facing projection downstream.
if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos))
{
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
continue;
}
// Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]");
if (portalNdc.Length < 3) continue;
EnsureCcw(portalNdc);
// Intersect the portal opening with every polygon of the current cell's view.
var clippedRegion = new List<ViewPolygon>();
foreach (var vp in currentView.Polygons)
{
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
}
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
if (clippedRegion.Count == 0) continue; // portal not visible through this chain
var portal = cell.Portals[i];
if (portal.OtherCellId == 0xFFFF)
{
if (pvDump)
{
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}");
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]");
foreach (var cp in clippedRegion)
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.
foreach (var cp in clippedRegion) frame.OutsideView.Add(cp);
continue;
}
uint neighbourId = lbMask | portal.OtherCellId;
// Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS.
// (Cross-building entry is retail's CBldPortal/AddToCell channel, not OtherPortalClip;
// the reciprocal clip below is interior-cell-to-cell only, matching the OtherPortalClip
// call inside ConstructView at decomp:433692.)
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
foreach (var cp in clippedRegion) xview.Add(cp);
continue;
}
var neighbour = lookup(neighbourId);
if (neighbour == null) continue;
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
// SAME opening seen from the neighbour (skewed/oblique apertures), so retail
// re-clips the already-near-side-clipped region against the neighbour's matching
// (reciprocal) portal polygon — the propagated region is the intersection of the
// opening "seen from A" AND "seen from B". This can only TIGHTEN, never widen, and
// degrades to the prior near-side-only region when the reciprocal is unresolvable
// (over-include is the safe default). The reciprocal is the portal at index
// `portal.OtherPortalId` in the NEIGHBOUR's portal list — retail's direct back-link
// (arg2->other_portal_id, 433557), NOT a scan for the first OtherCellId match. The
// direct index is what lets a cell with TWO portals to the same neighbour clip each
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
// in place before the union below.
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
foreach (var cp in clippedRegion) nview.Add(cp);
// Insert the neighbour into the distance-priority list — but ONLY on first discovery
// (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an
// already-seen cell is handled in place, never by re-enqueue). `seen` is the
// enqueue-once / `cell_view_done` gate: a neighbour already discovered is never
// re-enqueued, which is what bounds cyclic & hub graphs. Distance = camera→nearest
// portal-opening vertex in world space (retail InitCell min-vertex distance,
// 432988-433004); derived from the portal geometry, so it works even when the cell's
// WorldPosition was never populated.
if (seen.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
}
}
}
if (pvDump)
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
// Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
return frame;
}
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
// `localEye` is the eye in root-local space — its component along an interior portal
// plane reveals when the eye has crossed past that plane (the stale-root region that
// makes the side test cull a still-needed portal). Read-only recompute; no effect on
// the returned frame. Throwaway apparatus — strip with the probe.
private static void EmitFlapProbe(
LoadedCell cameraCell, Vector3 cameraPos, Matrix4x4 viewProj, PortalVisibilityFrame frame)
{
var localEye = Vector3.Transform(cameraPos, cameraCell.InverseWorldTransform);
var sb = new System.Text.StringBuilder(220);
sb.Append("[flap] root=0x").Append(cameraCell.CellId.ToString("X8"));
sb.Append(" eye=(").Append(cameraPos.X.ToString("F2")).Append(',')
.Append(cameraPos.Y.ToString("F2")).Append(',').Append(cameraPos.Z.ToString("F2")).Append(')');
sb.Append(" localEye=(").Append(localEye.X.ToString("F2")).Append(',')
.Append(localEye.Y.ToString("F2")).Append(',').Append(localEye.Z.ToString("F2")).Append(')');
for (int i = 0; i < cameraCell.Portals.Count; i++)
{
var portal = cameraCell.Portals[i];
float d = float.NaN;
bool side = true;
if (i < cameraCell.ClipPlanes.Count && cameraCell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
{
var pl = cameraCell.ClipPlanes[i];
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
}
// Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a
// portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY:
// clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with
// ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie.
int projN = -1, clipN = -1;
string ndcText = "";
if (i < cameraCell.PortalPolygons.Count)
{
var poly = cameraCell.PortalPolygons[i];
if (poly != null && poly.Length >= 3)
{
var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj);
projN = ndc.Length;
if (ndc.Length >= 3)
{
EnsureCcw(ndc);
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length;
var ns = new System.Text.StringBuilder(48);
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
ndcText = ns.ToString();
}
}
}
sb.Append(" | p").Append(i).Append("->0x").Append(portal.OtherCellId.ToString("X4"));
sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2"));
sb.Append(side ? " TRV" : " CULL");
sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN);
if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText);
}
sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count);
sb.Append(" vis=").Append(frame.OrderedVisibleCells.Count);
Console.WriteLine(sb.ToString());
}
// Mirrors CellVisibility's portal-side test (InsideSide convention).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
}
// Reverse vertex order in place if the polygon is wound clockwise (signed area < 0).
private static void EnsureCcw(Vector2[] poly)
{
float area2 = 0f;
for (int i = 0; i < poly.Length; i++)
{
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
area2 += p.X * q.Y - q.X * p.Y;
}
if (area2 < 0f) Array.Reverse(poly);
}
// Phase U.2b — reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524).
// Resolves the neighbour's reciprocal back-portal by DIRECT INDEX (`otherPortalId`), projects
// that reciprocal polygon through the NEIGHBOUR's world transform to NDC, and intersects it into
// every polygon of `clippedRegion` (already clipped against the near-side opening + current
// view). The net region is "opening seen from the near cell" ∩ "opening seen from the
// neighbour" — a strict tightening that prevents over-inclusion through skewed apertures.
//
// `otherPortalId` is the near-side portal's reciprocal back-link, straight from the dat's
// CellPortal.OtherPortalId. Retail indexes the neighbour's portal array with it directly —
// `portals->portal[arg2->other_portal_id ...]` at 005a54b2/005a54f6 — rather than scanning for
// the first OtherCellId match. A scan picks the FIRST back-portal for EVERY near-side portal to
// the same neighbour, so a cell with two openings into one neighbour clips both against the same
// (first) reciprocal — hiding the second opening when the apertures are disjoint (under-inclusion
// bug #102 M-4). The direct index gives each opening its own reciprocal.
//
// GUARDS — degrade to over-include (leave `clippedRegion` untouched), NEVER clip against a
// guessed polygon: the index is out of range, OR the indexed polygon is missing/degenerate
// (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default;
// mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with
// Portals, so index `otherPortalId` selects the reciprocal polygon. NEVER throws.
private static void ApplyReciprocalClip(
List<ViewPolygon> clippedRegion, ushort otherPortalId, LoadedCell neighbour, Matrix4x4 viewProj)
{
if (clippedRegion.Count == 0) return;
// Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include.
if (otherPortalId >= neighbour.PortalPolygons.Count) return;
Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId];
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
// &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper.
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
EnsureCcw(reciprocalNdc);
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
for (int k = clippedRegion.Count - 1; k >= 0; k--)
{
var tightened = ScreenPolygonClip.Intersect(reciprocalNdc, clippedRegion[k].Vertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k);
}
}
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
{
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
return v;
}
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
// origin) is both retail-faithful and robust to cells whose WorldPosition was never populated.
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
{
float best = float.MaxValue;
for (int i = 0; i < localPoly.Length; i++)
{
var world = Vector3.Transform(localPoly[i], worldTransform);
float d2 = Vector3.DistanceSquared(world, cameraPos);
if (d2 < best) best = d2;
}
return best == float.MaxValue ? 0f : MathF.Sqrt(best);
}
/// <summary>
/// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list +
/// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at
/// the tail; <see cref="PopNearest"/> removes the tail — giving closest-first traversal exactly
/// as ConstructView's pop-from-(cell_todo_num-1) does (433767-433769). The insertion only shifts
/// entries strictly farther than the newcomer (retail's flag test breaks on the first
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
/// </summary>
private sealed class CellTodoList
{
private readonly List<(LoadedCell Cell, float Distance)> _items = new();
public int Count => _items.Count;
public void Insert(LoadedCell cell, float distance)
{
// Find the slot: scan from the tail (nearest) toward the head while existing entries are
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is
// farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance
// newcomer inserts at the tail and pops first).
int idx = _items.Count;
while (idx > 0 && _items[idx - 1].Distance < distance)
idx--;
_items.Insert(idx, (cell, distance));
}
public LoadedCell PopNearest()
{
int last = _items.Count - 1;
var cell = _items[last].Cell;
_items.RemoveAt(last);
return cell;
}
}
}