This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
504 lines
21 KiB
C#
504 lines
21 KiB
C#
// 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.Buffers;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Frame-owned exact-length storage for projected portal polygons. A polygon's
|
|
/// vertex array remains immutable for the lifetime of its visibility frame, then
|
|
/// becomes reusable when that frame is reset. Exact lengths preserve the existing
|
|
/// <see cref="ViewPolygon.Vertices"/> contract and all clipping semantics.
|
|
/// </summary>
|
|
internal sealed class PortalPolygonVertexStore
|
|
{
|
|
private const int MaxRetainedArrays = 4_096;
|
|
private const int MaxRetainedVertices = 65_536;
|
|
private const int MaxRetainedPolygonVertices = 256;
|
|
|
|
private sealed class Bucket
|
|
{
|
|
public readonly List<Vector2[]> Buffers = new(4);
|
|
public int Used;
|
|
}
|
|
|
|
private readonly Dictionary<int, Bucket> _buckets = new();
|
|
private int _retainedArrays;
|
|
private int _retainedVertices;
|
|
|
|
internal int AllocationCount { get; private set; }
|
|
internal int RetainedArrayCount => _retainedArrays;
|
|
|
|
internal Vector2[] Rent(int vertexCount)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(vertexCount, 1);
|
|
|
|
if (_buckets.TryGetValue(vertexCount, out Bucket? bucket)
|
|
&& bucket.Used < bucket.Buffers.Count)
|
|
{
|
|
return bucket.Buffers[bucket.Used++];
|
|
}
|
|
|
|
Vector2[] result = GC.AllocateUninitializedArray<Vector2>(vertexCount);
|
|
AllocationCount++;
|
|
|
|
bool retain = vertexCount <= MaxRetainedPolygonVertices
|
|
&& _retainedArrays < MaxRetainedArrays
|
|
&& _retainedVertices + vertexCount <= MaxRetainedVertices;
|
|
if (!retain)
|
|
return result;
|
|
|
|
if (bucket is null)
|
|
{
|
|
bucket = new Bucket();
|
|
_buckets.Add(vertexCount, bucket);
|
|
}
|
|
bucket.Buffers.Add(result);
|
|
bucket.Used++;
|
|
_retainedArrays++;
|
|
_retainedVertices += vertexCount;
|
|
return result;
|
|
}
|
|
|
|
internal void ResetUsage()
|
|
{
|
|
foreach (Bucket bucket in _buckets.Values)
|
|
bucket.Used = 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
|
|
public sealed class CellView
|
|
{
|
|
// ViewPolygon exposes its vertex array for the renderer, so this seed must
|
|
// be owned by the CellView rather than shared globally. Pooling the
|
|
// CellView then reuses the four vertices without allowing one caller to
|
|
// corrupt every future full-screen seed.
|
|
private readonly ViewPolygon _fullScreenPolygon = new(new[]
|
|
{
|
|
new Vector2(-1f, -1f),
|
|
new Vector2(1f, -1f),
|
|
new Vector2(1f, 1f),
|
|
new Vector2(-1f, 1f),
|
|
});
|
|
|
|
public readonly List<ViewPolygon> Polygons = new();
|
|
|
|
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
|
|
// dedup in <see cref="Add"/>. Hash-bucket membership is the dedup; a stored key owns only its
|
|
// snapped integer coordinates while duplicate probes use stack or ArrayPool scratch.
|
|
// Hash -> head index in _polygonKeyStorage. Collision chains are integer
|
|
// links rather than one List allocation per accepted hash. Storage and
|
|
// coordinate arrays stay with this pooled CellView and are reused across
|
|
// frames; _activePolygonKeyCount marks the live prefix.
|
|
private readonly Dictionary<int, int> _polygonKeyHeads = new();
|
|
private readonly List<PolygonKey> _polygonKeyStorage = new();
|
|
private int _activePolygonKeyCount;
|
|
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;
|
|
|
|
internal bool IsRetainable
|
|
{
|
|
get
|
|
{
|
|
if (Polygons.Capacity > 256
|
|
|| _polygonKeyHeads.EnsureCapacity(0) > 512
|
|
|| _polygonKeyStorage.Count > 512)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int retainedCoordinateInts = 0;
|
|
for (int i = 0; i < _polygonKeyStorage.Count; i++)
|
|
{
|
|
int length = _polygonKeyStorage[i].Coordinates.Length;
|
|
if (length > 256)
|
|
return false;
|
|
retainedCoordinateInts += length;
|
|
if (retainedCoordinateInts > 8192)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
internal void Reset()
|
|
{
|
|
Polygons.Clear();
|
|
_polygonKeyHeads.Clear();
|
|
_activePolygonKeyCount = 0;
|
|
MinX = float.MaxValue;
|
|
MinY = float.MaxValue;
|
|
MaxX = float.MinValue;
|
|
MaxY = float.MinValue;
|
|
}
|
|
|
|
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
|
|
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
|
|
public static CellView FullScreen()
|
|
{
|
|
var v = new CellView();
|
|
v.Add(v._fullScreenPolygon);
|
|
return v;
|
|
}
|
|
|
|
internal void SetFullScreen()
|
|
{
|
|
Reset();
|
|
Add(_fullScreenPolygon);
|
|
}
|
|
|
|
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.
|
|
CanonicalKeyResult keyResult = TryAddCanonicalKey(p.Vertices);
|
|
if (keyResult == CanonicalKeyResult.Degenerate) return false;
|
|
if (keyResult == CanonicalKeyResult.Duplicate) return false;
|
|
|
|
// #120 convergence (2026-06-11): reject a polygon CONTAINED in one already
|
|
// stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a
|
|
// portal plane → BOTH side tests pass → views lap A→B→A…) re-emits, each
|
|
// lap, a region that is — in exact arithmetic — a SUBSET of the polygon
|
|
// that originated it; near-edge-on apertures make the re-clip wobble by
|
|
// more than the 1e-3 key grid, so every lap keyed as "new" and the
|
|
// in-place growth recursed to the depth-128 tripwire (chain dumps:
|
|
// 0xA9B4015C↔0x0162, 0xA9B30103↔0x010F; Issue120ReciprocalPingPongTests
|
|
// reproduces deterministically). Containment rejection makes growth
|
|
// strictly area-increasing — no new visible area, no propagation. The
|
|
// key stays recorded so the exact emission also short-circuits later.
|
|
// Bonus: back-emission into a full-screen view (the root cell) is now
|
|
// always rejected outright.
|
|
if (ContainedInExisting(p)) return false;
|
|
|
|
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;
|
|
}
|
|
|
|
// #120: is polygon p entirely inside ONE stored polygon (with DedupGridNdc
|
|
// slack)? Single-polygon containment is sufficient for the ping-pong class —
|
|
// a round-trip re-emission descends from exactly one originator. Stored
|
|
// polygons are convex (Sutherland-Hodgman / full-screen seed outputs); the
|
|
// edge test adapts to either winding via the polygon's signed area.
|
|
private bool ContainedInExisting(in ViewPolygon p)
|
|
{
|
|
const float eps = DedupGridNdc;
|
|
for (int i = 0; i < Polygons.Count; i++)
|
|
{
|
|
var e = Polygons[i];
|
|
// bounding-rect quick reject (with slack)
|
|
if (p.MinX < e.MinX - eps || p.MaxX > e.MaxX + eps
|
|
|| p.MinY < e.MinY - eps || p.MaxY > e.MaxY + eps)
|
|
continue;
|
|
if (ContainsAllVertices(e.Vertices, p.Vertices, eps))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static bool ContainsAllVertices(Vector2[] convex, Vector2[] pts, float eps)
|
|
{
|
|
if (convex.Length < 3) return false;
|
|
|
|
// signed area → winding (CCW positive); inside = left of every CCW edge.
|
|
float area2 = 0f;
|
|
for (int i = 0; i < convex.Length; i++)
|
|
{
|
|
var a = convex[i];
|
|
var b = convex[(i + 1) % convex.Length];
|
|
area2 += a.X * b.Y - b.X * a.Y;
|
|
}
|
|
float sign = area2 >= 0f ? 1f : -1f;
|
|
|
|
for (int i = 0; i < convex.Length; i++)
|
|
{
|
|
var a = convex[i];
|
|
var b = convex[(i + 1) % convex.Length];
|
|
var ab = b - a;
|
|
float len = ab.Length();
|
|
if (len < 1e-9f) continue; // degenerate edge — no constraint
|
|
foreach (var pt in pts)
|
|
{
|
|
// signed perpendicular distance of pt from edge a→b (positive = inside for CCW)
|
|
float cross = sign * (ab.X * (pt.Y - a.Y) - ab.Y * (pt.X - a.X));
|
|
if (cross < -eps * len)
|
|
return false; // a vertex lies outside this edge by more than eps
|
|
}
|
|
}
|
|
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), COLLINEAR points removed (exact integer cross-products on the
|
|
// snapped grid), then rotated to start at the lexicographically smallest vertex so a rotated
|
|
// emission of the same cycle yields the same key. Winding is already CCW for every
|
|
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
|
|
//
|
|
// W=0 port (2026-06-11): an ALL-COLLINEAR polygon (zero area) keys as its snapped segment
|
|
// ("L:" + extreme points) instead of null. A portal whose plane contains the eye projects to
|
|
// exactly this — and retail PROPAGATES it: PView::ClipPortals (decomp:433651-433711) forwards
|
|
// any GetClip output with count != 0 to copy_view/OtherPortalClip with no area gate anywhere,
|
|
// so the neighbour cell stays in the draw list (cells draw whole; onward floods die naturally
|
|
// against the zero-area region). Rejecting these views dropped the whole chain behind an
|
|
// exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral
|
|
// landings). The segment key space is finite like the area-key space, so dedup + the strict
|
|
// growth convergence invariant are unchanged. Degenerate is returned only when fewer than 2
|
|
// distinct snapped points survive (a true sub-grid point — not a real region OR segment).
|
|
//
|
|
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
|
|
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
|
|
// legitimately inserts intersection vertices ON a subject edge when a region edge grazes it, so
|
|
// BFS re-clip rounds re-emit the SAME geometric region with 1-2 extra collinear edge vertices.
|
|
// Without collinear canonicalization those re-emissions key as distinct, defeating the dedup and
|
|
// accumulating duplicate polygons (the pre-2026-06-06 unbounded-growth hang in miniature, and the
|
|
// exact reason the reciprocal clip was previously parked on the unstable divide-first path).
|
|
// Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so
|
|
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
|
|
private CanonicalKeyResult TryAddCanonicalKey(Vector2[]? verts)
|
|
{
|
|
if (verts is null || verts.Length < 3)
|
|
return CanonicalKeyResult.Degenerate;
|
|
|
|
SnappedPoint[]? rented = null;
|
|
Span<SnappedPoint> points = verts.Length <= 32
|
|
? stackalloc SnappedPoint[verts.Length]
|
|
: (rented = ArrayPool<SnappedPoint>.Shared.Rent(verts.Length)).AsSpan(0, verts.Length);
|
|
|
|
try
|
|
{
|
|
int count = 0;
|
|
foreach (Vector2 vertex in verts)
|
|
{
|
|
var point = new SnappedPoint(
|
|
(int)MathF.Round(vertex.X / DedupGridNdc),
|
|
(int)MathF.Round(vertex.Y / DedupGridNdc));
|
|
if (count == 0 || points[count - 1] != point)
|
|
points[count++] = point;
|
|
}
|
|
if (count >= 2 && points[count - 1] == points[0])
|
|
count--;
|
|
if (count < 2)
|
|
return CanonicalKeyResult.Degenerate;
|
|
|
|
SnappedPoint lo = points[0];
|
|
SnappedPoint hi = points[0];
|
|
for (int i = 1; i < count; i++)
|
|
{
|
|
SnappedPoint point = points[i];
|
|
if (point.X < lo.X || (point.X == lo.X && point.Y < lo.Y)) lo = point;
|
|
if (point.X > hi.X || (point.X == hi.X && point.Y > hi.Y)) hi = point;
|
|
}
|
|
|
|
bool removed = true;
|
|
while (removed && count >= 3)
|
|
{
|
|
removed = false;
|
|
for (int i = 0; i < count && count >= 3; i++)
|
|
{
|
|
SnappedPoint previous = points[(i + count - 1) % count];
|
|
SnappedPoint current = points[i];
|
|
SnappedPoint next = points[(i + 1) % count];
|
|
long cross = (long)(current.X - previous.X) * (next.Y - current.Y)
|
|
- (long)(current.Y - previous.Y) * (next.X - current.X);
|
|
if (cross != 0)
|
|
continue;
|
|
|
|
points.Slice(i + 1, count - i - 1).CopyTo(points.Slice(i));
|
|
count--;
|
|
removed = true;
|
|
i--;
|
|
}
|
|
}
|
|
|
|
if (count < 3)
|
|
{
|
|
if (lo == hi)
|
|
return CanonicalKeyResult.Degenerate;
|
|
Span<SnappedPoint> segment = stackalloc SnappedPoint[2] { lo, hi };
|
|
return AddCanonicalKey(PolygonKeyKind.Line, segment, start: 0);
|
|
}
|
|
|
|
int best = 0;
|
|
for (int start = 1; start < count; start++)
|
|
if (RotationLess(points, start, best, count)) best = start;
|
|
|
|
return AddCanonicalKey(PolygonKeyKind.Polygon, points[..count], best);
|
|
}
|
|
finally
|
|
{
|
|
if (rented is not null)
|
|
ArrayPool<SnappedPoint>.Shared.Return(rented);
|
|
}
|
|
}
|
|
|
|
private CanonicalKeyResult AddCanonicalKey(
|
|
PolygonKeyKind kind,
|
|
ReadOnlySpan<SnappedPoint> points,
|
|
int start)
|
|
{
|
|
int hash = ComputeHash(kind, points, start);
|
|
if (_polygonKeyHeads.TryGetValue(hash, out int keyIndex))
|
|
{
|
|
while (keyIndex >= 0)
|
|
{
|
|
PolygonKey existing = _polygonKeyStorage[keyIndex];
|
|
if (existing.Equals(kind, points, start))
|
|
return CanonicalKeyResult.Duplicate;
|
|
keyIndex = existing.Next;
|
|
}
|
|
}
|
|
|
|
int coordinateCount = points.Length * 2;
|
|
int storageIndex = FindOrCreateCoordinateStorage(coordinateCount);
|
|
int[] coordinates = _polygonKeyStorage[storageIndex].Coordinates;
|
|
for (int i = 0; i < points.Length; i++)
|
|
{
|
|
SnappedPoint point = points[(start + i) % points.Length];
|
|
coordinates[i * 2] = point.X;
|
|
coordinates[i * 2 + 1] = point.Y;
|
|
}
|
|
int next = _polygonKeyHeads.GetValueOrDefault(hash, -1);
|
|
_polygonKeyStorage[storageIndex] = new PolygonKey(kind, coordinates, next);
|
|
_polygonKeyHeads[hash] = storageIndex;
|
|
_activePolygonKeyCount++;
|
|
return CanonicalKeyResult.Added;
|
|
}
|
|
|
|
private int FindOrCreateCoordinateStorage(int coordinateCount)
|
|
{
|
|
int storageIndex = _activePolygonKeyCount;
|
|
for (int i = storageIndex; i < _polygonKeyStorage.Count; i++)
|
|
{
|
|
if (_polygonKeyStorage[i].Coordinates.Length != coordinateCount)
|
|
continue;
|
|
if (i != storageIndex)
|
|
(_polygonKeyStorage[storageIndex], _polygonKeyStorage[i]) =
|
|
(_polygonKeyStorage[i], _polygonKeyStorage[storageIndex]);
|
|
return storageIndex;
|
|
}
|
|
|
|
_polygonKeyStorage.Add(new PolygonKey(
|
|
PolygonKeyKind.Polygon,
|
|
new int[coordinateCount],
|
|
-1));
|
|
int addedIndex = _polygonKeyStorage.Count - 1;
|
|
if (addedIndex != storageIndex)
|
|
(_polygonKeyStorage[storageIndex], _polygonKeyStorage[addedIndex]) =
|
|
(_polygonKeyStorage[addedIndex], _polygonKeyStorage[storageIndex]);
|
|
return storageIndex;
|
|
}
|
|
|
|
private static int ComputeHash(
|
|
PolygonKeyKind kind,
|
|
ReadOnlySpan<SnappedPoint> points,
|
|
int start)
|
|
{
|
|
unchecked
|
|
{
|
|
uint hash = 2166136261u;
|
|
hash = (hash ^ (byte)kind) * 16777619u;
|
|
hash = (hash ^ (uint)points.Length) * 16777619u;
|
|
for (int i = 0; i < points.Length; i++)
|
|
{
|
|
SnappedPoint point = points[(start + i) % points.Length];
|
|
hash = (hash ^ (uint)point.X) * 16777619u;
|
|
hash = (hash ^ (uint)point.Y) * 16777619u;
|
|
}
|
|
return (int)hash;
|
|
}
|
|
}
|
|
|
|
private static bool RotationLess(
|
|
ReadOnlySpan<SnappedPoint> points,
|
|
int a,
|
|
int b,
|
|
int count)
|
|
{
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
SnappedPoint left = points[(a + i) % count];
|
|
SnappedPoint right = points[(b + i) % count];
|
|
if (left.X != right.X) return left.X < right.X;
|
|
if (left.Y != right.Y) return left.Y < right.Y;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private readonly record struct SnappedPoint(int X, int Y);
|
|
|
|
private readonly record struct PolygonKey(PolygonKeyKind Kind, int[] Coordinates, int Next)
|
|
{
|
|
public bool Equals(PolygonKeyKind kind, ReadOnlySpan<SnappedPoint> points, int start)
|
|
{
|
|
if (Kind != kind || Coordinates.Length != points.Length * 2)
|
|
return false;
|
|
for (int i = 0; i < points.Length; i++)
|
|
{
|
|
SnappedPoint point = points[(start + i) % points.Length];
|
|
if (Coordinates[i * 2] != point.X || Coordinates[i * 2 + 1] != point.Y)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private enum PolygonKeyKind : byte { Polygon, Line }
|
|
private enum CanonicalKeyResult : byte { Degenerate, Duplicate, Added }
|
|
}
|