diff --git a/src/AcDream.App/Rendering/ScreenPolygonClip.cs b/src/AcDream.App/Rendering/ScreenPolygonClip.cs new file mode 100644 index 0000000..666b5ff --- /dev/null +++ b/src/AcDream.App/Rendering/ScreenPolygonClip.cs @@ -0,0 +1,77 @@ +// ScreenPolygonClip.cs +// +// Phase A8.F: 2D convex-polygon intersection (Sutherland-Hodgman). +// Ports the BEHAVIOR of retail ACRender::polyClipFinish (the screen-space +// portal clipper invoked by PView::GetClip, decomp:432344). Both inputs are +// convex (a cell's accumulated view polygon and a projected portal polygon), +// so the result is convex. We match retail's clipping relationship, not its +// exact float output. +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +public static class ScreenPolygonClip +{ + private const float Eps = 1e-7f; + + /// Intersect two convex polygons given CCW. Returns the clipped + /// vertices (CCW) or an array with <3 verts when the intersection is empty. + public static Vector2[] Intersect(IReadOnlyList subject, IReadOnlyList clip) + { + if (subject == null || clip == null || subject.Count < 3 || clip.Count < 3) + return System.Array.Empty(); + + var output = new List(subject); + + for (int i = 0; i < clip.Count; i++) + { + if (output.Count < 3) return System.Array.Empty(); + + Vector2 a = clip[i]; + Vector2 b = clip[(i + 1) % clip.Count]; + output = ClipByEdge(output, a, b); + } + + return output.Count >= 3 ? output.ToArray() : System.Array.Empty(); + } + + // Keep the part of `poly` on the left of directed edge a->b (CCW inside). + private static List ClipByEdge(List poly, Vector2 a, Vector2 b) + { + var result = new List(poly.Count + 1); + Vector2 edge = b - a; + + for (int i = 0; i < poly.Count; i++) + { + Vector2 cur = poly[i]; + Vector2 prev = poly[(i + poly.Count - 1) % poly.Count]; + + float curSide = Cross(edge, cur - a); // > 0 = left (inside) + float prevSide = Cross(edge, prev - a); + + bool curIn = curSide >= -Eps; + bool prevIn = prevSide >= -Eps; + + if (curIn) + { + if (!prevIn) + result.Add(Intersection(prev, cur, prevSide, curSide)); + result.Add(cur); + } + else if (prevIn) + { + result.Add(Intersection(prev, cur, prevSide, curSide)); + } + } + return result; + } + + private static float Cross(Vector2 u, Vector2 v) => u.X * v.Y - u.Y * v.X; + + private static Vector2 Intersection(Vector2 p, Vector2 q, float dp, float dq) + { + float t = dp / (dp - dq); // dp, dq are signed distances (cross products); opposite signs here + return p + t * (q - p); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs b/tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs new file mode 100644 index 0000000..723c194 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs @@ -0,0 +1,67 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class ScreenPolygonClipTests +{ + // CCW square [min,max]^2. + private static Vector2[] Square(float min, float max) => new[] + { + new Vector2(min, min), new Vector2(max, min), new Vector2(max, max), new Vector2(min, max), + }; + + private static float Area(Vector2[] poly) + { + if (poly.Length < 3) return 0f; + float a = 0f; + for (int i = 0; i < poly.Length; i++) + { + var p = poly[i]; var q = poly[(i + 1) % poly.Length]; + a += p.X * q.Y - q.X * p.Y; + } + return System.MathF.Abs(a) * 0.5f; + } + + [Fact] + public void Intersect_FullyContained_ReturnsSubject() + { + var outer = Square(-1f, 1f); + var inner = Square(-0.5f, 0.5f); + var r = ScreenPolygonClip.Intersect(inner, outer); + Assert.Equal(1.0f, Area(r), 3); // inner area = 1x1 + } + + [Fact] + public void Intersect_PartialOverlap_ReturnsOverlapArea() + { + var a = Square(0f, 2f); + var b = Square(1f, 3f); + var r = ScreenPolygonClip.Intersect(a, b); + Assert.Equal(1.0f, Area(r), 3); // overlap [1,2]^2 = 1 + } + + [Fact] + public void Intersect_Disjoint_ReturnsEmpty() + { + var a = Square(0f, 1f); + var b = Square(5f, 6f); + var r = ScreenPolygonClip.Intersect(a, b); + Assert.True(r.Length < 3); // empty + } + + [Fact] + public void Intersect_Sliver_PreservesNarrowOverlap() + { + // A tall thin clip (the "stairwell") intersected with a wide subject (the "window"). + var window = Square(-1f, 1f); + var stairwell = new[] + { + new Vector2(-0.1f, -1f), new Vector2(0.1f, -1f), new Vector2(0.1f, 1f), new Vector2(-0.1f, 1f), + }; + var r = ScreenPolygonClip.Intersect(window, stairwell); + Assert.Equal(0.4f, Area(r), 3); // 0.2 wide x 2 tall + Assert.True(System.MathF.Abs(r[0].X) <= 0.1001f); // clipped to stairwell width + } +}