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
+ }
+}