// PortalView.cs // // Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model. // Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338): // a cell's clip region is a SET of convex polygons in normalized device coords. using System.Collections.Generic; using System.Numerics; using System.Text; namespace AcDream.App.Rendering; /// One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect. public readonly struct ViewPolygon { public readonly Vector2[] Vertices; public readonly float MinX, MinY, MaxX, MaxY; public ViewPolygon(Vector2[] vertices) { Vertices = vertices; if (vertices is null || vertices.Length < 3) { MinX = MinY = MaxX = MaxY = 0f; return; } float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue; foreach (var v in vertices) { if (v.X < minX) minX = v.X; if (v.X > maxX) maxX = v.X; if (v.Y < minY) minY = v.Y; if (v.Y > maxY) maxY = v.Y; } MinX = minX; MinY = minY; MaxX = maxX; MaxY = maxY; } public bool IsEmpty => Vertices is null || Vertices.Length < 3; } /// A cell's accumulated clip region: a set of convex view polygons + the union bounding rect. public sealed class CellView { public readonly List Polygons = new(); // Canonical (snapped) keys of the polygons in , backing the drift-tolerant // dedup in . One entry per stored polygon; HashSet membership IS the dedup. private readonly HashSet _polygonKeys = new(); public float MinX { get; private set; } = float.MaxValue; public float MinY { get; private set; } = float.MaxValue; public float MaxX { get; private set; } = float.MinValue; public float MaxY { get; private set; } = float.MinValue; public bool IsEmpty => Polygons.Count == 0; /// A region covering the entire NDC viewport — the camera cell's seed region /// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814). public static CellView FullScreen() { var v = new CellView(); v.Add(new ViewPolygon(new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), })); return v; } public bool Add(ViewPolygon p) { if (p.IsEmpty) return false; // Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build // re-queues a cell every time its CellView GROWS, so the flood only terminates when Add // recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns // float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman + // EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region // grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its // vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a // canonical start. The snapped key space is finite, so a monotonically-growing CellView is // bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only // the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub- // pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge. string? key = CanonicalKey(p.Vertices); if (key is null) return false; // degenerate after snap (< 3 distinct vertices) if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant) Polygons.Add(p); if (p.MinX < MinX) MinX = p.MinX; if (p.MinY < MinY) MinY = p.MinY; if (p.MaxX > MaxX) MaxX = p.MaxX; if (p.MaxY > MaxY) MaxY = p.MaxY; return true; } // NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings // (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped // region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth. private const float DedupGridNdc = 1e-3f; // Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates // removed (including wrap-around), then rotated to start at the lexicographically smallest vertex so // a rotated emission of the same cycle yields the same key. Returns null when fewer than 3 distinct // snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every // builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step. private static string? CanonicalKey(Vector2[]? verts) { if (verts is null || verts.Length < 3) return null; var pts = new List<(int X, int Y)>(verts.Length); foreach (var v in verts) { var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc)); if (pts.Count == 0 || pts[^1] != q) pts.Add(q); } if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1); if (pts.Count < 3) return null; int n = pts.Count; int best = 0; for (int s = 1; s < n; s++) if (RotationLess(pts, s, best, n)) best = s; var sb = new StringBuilder(n * 10); for (int i = 0; i < n; i++) { var q = pts[(best + i) % n]; sb.Append(q.X).Append(',').Append(q.Y).Append(';'); } return sb.ToString(); } // True when the rotation of `pts` starting at index a is lexicographically less than the rotation // starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical // start even when two vertices share the minimum snapped coordinate. private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n) { for (int i = 0; i < n; i++) { var pa = pts[(a + i) % n]; var pb = pts[(b + i) % n]; if (pa.X != pb.X) return pa.X < pb.X; if (pa.Y != pb.Y) return pa.Y < pb.Y; } return false; } }