diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 072b7159..c14b98bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Update Wurst run: grill install wurstscript @@ -21,4 +21,4 @@ jobs: run: grill install - name: Test - run: grill test + run: grill test --quiet diff --git a/wurst/math/Polygon.wurst b/wurst/math/Polygon.wurst new file mode 100644 index 00000000..158b53ac --- /dev/null +++ b/wurst/math/Polygon.wurst @@ -0,0 +1,200 @@ +package Polygon +import NoWurst +import ArrayList +import Vectors +import Rect +import ErrorHandling +import Maths +import Lightning +import ClosureTimers +import Colors + +public enum PolygonPointRelation + OUTSIDE + BOUNDARY + INSIDE + +/** + An immutable closed 2D path after seal(). Polygon owns its copied vertex + storage and cached bounds rect. Call destroy when finished: it releases both, + and invalidates every rect previously borrowed from bounds(). +*/ +public class Polygon + private ArrayList vertices + private rect boundaryRect = null + private bool sealed = false + + /** Builder form. expectedVertexCount is a capacity hint. Call seal() before querying. */ + construct(int expectedVertexCount) + vertices = new ArrayList(expectedVertexCount) + + /** Convenience form. Copies the supplied vertices and seals immediately. */ + construct(vararg vec2 initialVertices) + vertices = new ArrayList() + for vertex in initialVertices + vertices.add(vertex) + seal() + + /** Appends a copied vertex before seal(). Adding after seal() is a programmer error. */ + function addVertex(vec2 vertex) returns thistype + if sealed + error("Polygon: cannot add vertices after seal") + return this + vertices.add(vertex) + return this + + /** + Finalizes immutable query state and creates Polygon's owned bounds rect. + Repeated calls preserve the same rect handle. Polygon removes that rect + when it is destroyed; callers must not remove its borrowed bounds(). + */ + function seal() returns thistype + if sealed + return this + if vertices.size() == 0 + boundaryRect = Rect(0, 0, 0, 0) + else + let firstVertex = vertices.get(0) + var minX = firstVertex.x + var minY = firstVertex.y + var maxX = minX + var maxY = minY + for i = 1 to vertices.size() - 1 + let vertex = vertices.get(i) + minX = min(minX, vertex.x) + minY = min(minY, vertex.y) + maxX = max(maxX, vertex.x) + maxY = max(maxY, vertex.y) + boundaryRect = Rect(minX, minY, maxX, maxY) + sealed = true + return this + + private function requireSealed() returns bool + if not sealed + error("Polygon: must be sealed before querying") + return false + return true + + function isSealed() returns bool + return sealed + + function vertexCount() returns int + if not requireSealed() + return 0 + return vertices.size() + + function vertexAt(int index) returns vec2 + if not requireSealed() + return ZERO2 + if index < 0 or index >= vertices.size() + error("Polygon: vertex index out of bounds") + return ZERO2 + return vertices.get(index) + + /** + Returns Polygon's borrowed cached rect. Polygon retains ownership: callers + must not remove or destroy it, and must not use it after Polygon is + destroyed. + */ + function bounds() returns rect + if not requireSealed() + return null + return boundaryRect + + function boundsCenter() returns vec2 + if not requireSealed() + return ZERO2 + return boundaryRect.getCenter() + + private function isOnSegment(vec2 point, vec2 start, vec2 finish, real cross) returns bool + if cross != 0 + return false + return (point.x - start.x) * (point.x - finish.x) <= 0 and + (point.y - start.y) * (point.y - finish.y) <= 0 + + function classify(vec2 point) returns PolygonPointRelation + if not requireSealed() + return PolygonPointRelation.OUTSIDE + if vertices.size() == 0 + return PolygonPointRelation.OUTSIDE + if point.x < boundaryRect.getMinX() or point.x > boundaryRect.getMaxX() or + point.y < boundaryRect.getMinY() or point.y > boundaryRect.getMaxY() + return PolygonPointRelation.OUTSIDE + var inside = false + var previous = vertices.get(vertices.size() - 1) + for i = 0 to vertices.size() - 1 + let current = vertices.get(i) + let deltaY = current.y - previous.y + let cross = (point.x - previous.x) * deltaY - + (point.y - previous.y) * (current.x - previous.x) + if isOnSegment(point, previous, current, cross) + return PolygonPointRelation.BOUNDARY + if (previous.y > point.y) != (current.y > point.y) + if (deltaY > 0 and cross < 0) or (deltaY < 0 and cross > 0) + inside = not inside + previous = current + return inside ? PolygonPointRelation.INSIDE : PolygonPointRelation.OUTSIDE + + function contains(vec2 point) returns bool + return classify(point) != PolygonPointRelation.OUTSIDE + + function containsStrict(vec2 point) returns bool + return classify(point) == PolygonPointRelation.INSIDE + + /** + Draws every edge in standard green. Ownership transfers to the caller: + destroy every returned lightning handle, then destroy the returned list. + */ + function debugRender() returns ArrayList + return this.debugRender(PLAYER_COLOR_GREEN.toColor()) + + /** + Draws every edge in the requested color. Ownership transfers to the caller: + destroy every returned lightning handle, then destroy the returned list. + */ + function debugRender(color col) returns ArrayList + let result = new ArrayList(max(1, vertices.size())) + if not requireSealed() + return result + if vertices.size() < 2 + return result + var previous = vertices.get(vertices.size() - 1) + for i = 0 to vertices.size() - 1 + let current = vertices.get(i) + let border = addLightning(LIGHTNING_MAGIC_LEASH, false, previous, current) + border.setColor(col) + result.add(border) + previous = current + return result + + /** + Draws in standard green. Polygon retains ownership of all created lightning + handles and its internal list, and destroys both after duration. duration + must be positive. + */ + function debugRenderTimed(real duration) + this.debugRenderTimed(duration, PLAYER_COLOR_GREEN.toColor()) + + /** + Draws in the requested color. Polygon retains ownership of all created + lightning handles and its internal list, and destroys both after duration. + duration must be positive. + */ + function debugRenderTimed(real duration, color col) + if duration <= 0 + error("Polygon: timed debug duration must be positive") + return + if not requireSealed() + return + let borders = this.debugRender(col) + doAfter(duration) -> + for i = 0 to borders.size() - 1 + borders.get(i).destr() + destroy borders + + ondestroy + if boundaryRect != null + boundaryRect.remove() + boundaryRect = null + destroy vertices + vertices = null diff --git a/wurst/math/PolygonTests.wurst b/wurst/math/PolygonTests.wurst new file mode 100644 index 00000000..1981e720 --- /dev/null +++ b/wurst/math/PolygonTests.wurst @@ -0,0 +1,226 @@ +package PolygonTests +import Polygon +import ArrayList +import ErrorHandling + +class RenderSpyPolygon extends Polygon + int debugRenderCalls = 0 + + construct() + super(0) + seal() + + override function debugRender(color col) returns ArrayList + debugRenderCalls++ + return new ArrayList(1) + +@Test function builderSealsAndCachesBounds() + let polygon = new Polygon(4) + ..addVertex(vec2(-2, 3)) + ..addVertex(vec2(8, 1)) + ..addVertex(vec2(10, 7)) + ..addVertex(vec2(4, 9)) + ..seal() + polygon.isSealed().assertTrue() + polygon.vertexCount().assertEquals(4) + polygon.vertexAt(0).assertEquals(vec2(-2, 3)) + polygon.vertexAt(3).assertEquals(vec2(4, 9)) + polygon.bounds().getMinX().assertEquals(-2.) + polygon.bounds().getMinY().assertEquals(1.) + polygon.bounds().getMaxX().assertEquals(10.) + polygon.bounds().getMaxY().assertEquals(9.) + polygon.boundsCenter().assertEquals(vec2(4, 5)) + destroy polygon + +@Test function varargConstructorSealsAndCopiesVertices() + let polygon = new Polygon(vec2(0, 0), vec2(2, 0), vec2(0, 2)) + polygon.isSealed().assertTrue() + polygon.vertexCount().assertEquals(3) + polygon.vertexAt(1).assertEquals(vec2(2, 0)) + destroy polygon + +@Test function emptyPolygonHasStableBounds() + let polygon = new Polygon(0)..seal() + polygon.vertexCount().assertEquals(0) + polygon.boundsCenter().assertEquals(ZERO2) + destroy polygon + +@Test function repeatedSealReusesCachedBounds() + let polygon = new Polygon(3) + ..addVertex(vec2(0, 0)) + ..addVertex(vec2(4, 0)) + ..addVertex(vec2(0, 4)) + ..seal() + let firstBounds = polygon.bounds() + polygon.seal() + (polygon.bounds() == firstBounds).assertTrue() + polygon.vertexCount().assertEquals(3) + destroy polygon + +@Test function classifiesConvexConcaveAndReversedPaths() + let square = new Polygon(vec2(0, 0), vec2(10, 0), vec2(10, 10), vec2(0, 10)) + (square.classify(vec2(5, 5)) == PolygonPointRelation.INSIDE).assertTrue() + (square.classify(vec2(15, 5)) == PolygonPointRelation.OUTSIDE).assertTrue() + (square.classify(vec2(10, 5)) == PolygonPointRelation.BOUNDARY).assertTrue() + square.contains(vec2(10, 5)).assertTrue() + square.containsStrict(vec2(5, 5)).assertTrue() + square.contains(vec2(10.0001, 5)).assertFalse() + let reversed = new Polygon(vec2(0, 10), vec2(10, 10), vec2(10, 0), vec2(0, 0)) + (reversed.classify(vec2(5, 5)) == PolygonPointRelation.INSIDE).assertTrue() + let concave = new Polygon(vec2(0, 0), vec2(8, 0), vec2(4, 4), vec2(8, 8), vec2(0, 8)) + concave.contains(vec2(2, 4)).assertTrue() + concave.contains(vec2(6, 4)).assertFalse() + destroy square + destroy reversed + destroy concave + +@Test function classifiesDegenerateAndDuplicateEdges() + let point = new Polygon(vec2(3, 4)) + (point.classify(vec2(3, 4)) == PolygonPointRelation.BOUNDARY).assertTrue() + point.containsStrict(vec2(3, 4)).assertFalse() + let segment = new Polygon(vec2(0, 0), vec2(10, 0)) + (segment.classify(vec2(5, 0)) == PolygonPointRelation.BOUNDARY).assertTrue() + (segment.classify(vec2(5, 1)) == PolygonPointRelation.OUTSIDE).assertTrue() + let duplicate = new Polygon(vec2(0, 0), vec2(10, 0), vec2(10, 0), vec2(0, 10), vec2(0, 0)) + duplicate.contains(vec2(2, 2)).assertTrue() + destroy point + destroy segment + destroy duplicate + +@Test function collinearPolygonHasBoundaryButNoInterior() + let polygon = new Polygon(vec2(0, 0), vec2(5, 0), vec2(10, 0), vec2(3, 0)) + (polygon.classify(vec2(7, 0)) == PolygonPointRelation.BOUNDARY).assertTrue() + (polygon.classify(vec2(7, 1)) == PolygonPointRelation.OUTSIDE).assertTrue() + polygon.containsStrict(vec2(7, 0)).assertFalse() + destroy polygon + +@Test function horizontalRayThroughVertexUsesHalfOpenCrossing() + let diamond = new Polygon(vec2(0, 5), vec2(5, 10), vec2(10, 5), vec2(5, 0)) + (diamond.classify(vec2(5, 5)) == PolygonPointRelation.INSIDE).assertTrue() + (diamond.classify(vec2(10, 5)) == PolygonPointRelation.BOUNDARY).assertTrue() + (diamond.classify(vec2(9, 5)) == PolygonPointRelation.INSIDE).assertTrue() + destroy diamond + +@Test function retracedEdgeDoesNotChangeFillParity() + let polygon = new Polygon( + vec2(0, 0), vec2(10, 0), vec2(10, 10), + vec2(15, 10), vec2(10, 10), vec2(0, 10)) + polygon.containsStrict(vec2(5, 5)).assertTrue() + (polygon.classify(vec2(12, 10)) == PolygonPointRelation.BOUNDARY).assertTrue() + polygon.contains(vec2(12, 8)).assertFalse() + destroy polygon + +@Test function classifiesSelfIntersectingPathWithEvenOddRule() + let bowTie = new Polygon(vec2(0, 0), vec2(10, 10), vec2(0, 10), vec2(10, 0)) + (bowTie.classify(vec2(5, 5)) == PolygonPointRelation.BOUNDARY).assertTrue() + bowTie.contains(vec2(5, 8)).assertTrue() + bowTie.contains(vec2(-1, 5)).assertFalse() + destroy bowTie + +@Test function classifiesFigureEightLobesIndependently() + let figureEight = new Polygon( + vec2(0, 0), vec2(-10, 10), vec2(-10, -10), + vec2(0, 0), vec2(10, 10), vec2(10, -10)) + figureEight.containsStrict(vec2(-5, 0)).assertTrue() + figureEight.containsStrict(vec2(5, 0)).assertTrue() + (figureEight.classify(vec2(0, 0)) == PolygonPointRelation.BOUNDARY).assertTrue() + figureEight.contains(vec2(0, 5)).assertFalse() + destroy figureEight + +@Test function repeatedContainmentQueriesRemainStable() + let polygon = new Polygon(vec2(0, 0), vec2(20, 0), vec2(20, 20), vec2(0, 20)) + for i = 0 to 999 + polygon.containsStrict(vec2(10, 10)).assertTrue() + polygon.contains(vec2(20, 10)).assertTrue() + polygon.contains(vec2(21, 10)).assertFalse() + destroy polygon + +@Test function emptyDebugRenderingReturnsOwnedEmptyList() + let polygon = new Polygon(0)..seal() + let borders = polygon.debugRender() + borders.size().assertEquals(0) + destroy borders + destroy polygon + +@Test function oneVertexDebugRenderingReturnsOwnedEmptyList() + let polygon = new Polygon(vec2(3, 4)) + let borders = polygon.debugRender() + borders.size().assertEquals(0) + destroy borders + destroy polygon + +/** Warcraft-runtime verification: Grill's interpreter does not implement lightning natives. */ +public function verifyDebugRenderingReturnsOneBorderPerTriangleEdgeIngame() + let polygon = new Polygon(vec2(0, 0), vec2(10, 0), vec2(0, 10)) + let borders = polygon.debugRender() + borders.size().assertEquals(3) + for i = 0 to borders.size() - 1 + (borders.get(i) != null).assertTrue() + borders.get(i).destr() + destroy borders + destroy polygon + +/** Warcraft-runtime verification: Grill's interpreter does not implement lightning color reads. */ +public function verifyDefaultDebugRenderingUsesStandardGreenIngame() + let polygon = new Polygon(vec2(0, 0), vec2(10, 0), vec2(0, 10)) + let borders = polygon.debugRender() + for i = 0 to borders.size() - 1 + (borders.get(i).getColor() == PLAYER_COLOR_GREEN.toColor()).assertTrue() + borders.get(i).destr() + destroy borders + destroy polygon + +/** Warcraft-runtime verification: Grill's interpreter does not implement lightning color reads. */ +public function verifyDebugRenderingPropagatesExplicitColorIngame() + let polygon = new Polygon(vec2(0, 0), vec2(10, 0), vec2(0, 10)) + let requestedColor = color(12, 34, 56) + let borders = polygon.debugRender(requestedColor) + for i = 0 to borders.size() - 1 + (borders.get(i).getColor() == requestedColor).assertTrue() + borders.get(i).destr() + destroy borders + destroy polygon + +@Test function debugRenderingOverloadsDispatchThroughColorOverride() + let polygon = new RenderSpyPolygon() + let defaultBorders = polygon.debugRender() + polygon.debugRenderCalls.assertEquals(1) + destroy defaultBorders + polygon.debugRenderTimed(1, color(1, 2, 3)) + polygon.debugRenderCalls.assertEquals(2) + destroy polygon + +/** Lua-runtime verification: Grill's compile-time error handling aborts before assertions run. */ +public function verifyInvalidTimedDebugRenderingDoesNotDispatchOverrideIngame() + let polygon = new RenderSpyPolygon() + polygon.debugRenderTimed(0, color(1, 2, 3)) + lastError.assertEquals("Polygon: timed debug duration must be positive") + polygon.debugRenderCalls.assertEquals(0) + destroy polygon + +/** Lua-runtime verification: guard errors return on Lua and must leave safe state/results. */ +public function verifyLuaGuardPathsReturnSafelyIngame() + if not isLua + return + let sealedPolygon = new Polygon(vec2(0, 0), vec2(4, 0), vec2(0, 4)) + lastError = "" + sealedPolygon.addVertex(vec2(9, 9)) + lastError.assertEquals("Polygon: cannot add vertices after seal") + sealedPolygon.vertexCount().assertEquals(3) + sealedPolygon.vertexAt(2).assertEquals(vec2(0, 4)) + lastError = "" + sealedPolygon.vertexAt(3).assertEquals(ZERO2) + lastError.assertEquals("Polygon: vertex index out of bounds") + destroy sealedPolygon + + let unsealedPolygon = new Polygon(3)..addVertex(vec2(2, 3)) + lastError = "" + unsealedPolygon.vertexCount().assertEquals(0) + lastError.assertEquals("Polygon: must be sealed before querying") + (unsealedPolygon.classify(vec2(2, 3)) == PolygonPointRelation.OUTSIDE).assertTrue() + unsealedPolygon.boundsCenter().assertEquals(ZERO2) + (unsealedPolygon.bounds() == null).assertTrue() + let borders = unsealedPolygon.debugRender() + borders.size().assertEquals(0) + destroy borders + destroy unsealedPolygon diff --git a/wurst/math/Vectors.wurst b/wurst/math/Vectors.wurst index a555fc6a..0cacd891 100644 --- a/wurst/math/Vectors.wurst +++ b/wurst/math/Vectors.wurst @@ -184,6 +184,7 @@ public function vec2.isInTriangle(vec2 p1, vec2 p2, vec2 p3) returns bool return (a <= 0 and b <= 0 and c <= 0) or (a >= 0 and b >= 0 and c >= 0) /** Checks whether the point is in a polygon defined by a sequence of connected points. */ +@deprecated("Use Polygon.contains(...) instead.") public function vec2.isInPolygon(vararg vec2 args) returns bool var result = false vec2 array points @@ -441,6 +442,7 @@ Works as for vec2.isInTriangle, Z-coords are discarded. */ /** Checks whether the point is in a 2d polygon defined by a sequence of connected points. Works as for vec2.isInPolygon, Z-coords are discarded. */ +@deprecated("Use Polygon.contains(...) instead.") public function vec3.isInPolygon2d(vararg vec3 args) returns bool var result = false vec3 array points @@ -509,16 +511,3 @@ function vectorTests() point3d.isInTriangle2d(vec3(9, 8, 23), vec3(3, 5, 0), vec3(4, 6, 15)).assertFalse() point3d.isInTriangle2d(vec3(10, 15, -124), vec3(3, 5, 0), vec3(4, 6, -16)).assertTrue() point3d.isInTriangle2d(vec3(48, 45, 85), vec3(-35, 0, 11), vec3(46, -6, 22)).assertTrue() - -@Test function testIsInPolygon() - let test1 = vec2(1, 3) - let test2 = vec2(-4, -6) - let test3 = vec2(-2, 2) - let points = [vec2(-3, -6), vec2(4, 5), vec2(-4, 5), vec2(3, -2)] - test1.isInPolygon(points[0], points[1], points[2], points[3]).assertTrue() - test2.isInPolygon(points[0], points[1], points[2], points[3]).assertFalse() - test3.isInPolygon(points[0], points[1], points[2]).assertTrue() - test1.toVec3().isInPolygon2d(points[0].toVec3(), points[1].toVec3(), points[2].toVec3(), points[3].toVec3()).assertTrue() - test2.toVec3().isInPolygon2d(points[0].toVec3(), points[1].toVec3(), points[2].toVec3(), points[3].toVec3()).assertFalse() - test3.toVec3().isInPolygon2d(points[0].toVec3(), points[1].toVec3(), points[2].toVec3()).assertTrue() -