From be6b873ac46ef049527f6eca42d95d1640f3a4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Havl=C3=AD=C4=8Dek?= Date: Sun, 2 Aug 2026 10:12:49 +0200 Subject: [PATCH 1/2] feat: add reusable Polygon abstraction --- wurst/data/ShardedIntStorage.wurst | 250 ++++++++++++++ wurst/data/ShardedIntStorageTests.wurst | 23 ++ wurst/math/PathingGrid.wurst | 24 ++ wurst/math/PathingGridTests.wurst | 19 ++ wurst/math/Polygon.wurst | 414 ++++++++++++++++++++++++ wurst/math/PolygonTests.wurst | 299 +++++++++++++++++ wurst/math/Vectors.wurst | 3 +- 7 files changed, 1031 insertions(+), 1 deletion(-) create mode 100644 wurst/data/ShardedIntStorage.wurst create mode 100644 wurst/data/ShardedIntStorageTests.wurst create mode 100644 wurst/math/PathingGrid.wurst create mode 100644 wurst/math/PathingGridTests.wurst create mode 100644 wurst/math/Polygon.wurst create mode 100644 wurst/math/PolygonTests.wurst diff --git a/wurst/data/ShardedIntStorage.wurst b/wurst/data/ShardedIntStorage.wurst new file mode 100644 index 00000000..0537040d --- /dev/null +++ b/wurst/data/ShardedIntStorage.wurst @@ -0,0 +1,250 @@ +package ShardedIntStorage +import NoWurst +import ErrorHandling +import Table + +/** + A shared integer arena spanning multiple native JASS arrays. Allocations are + contiguous logical address ranges and released adjacent ranges are coalesced. +*/ +public class ShardedIntStorage + private static constant int SHARD_COUNT = 32 + + private static int array storage0 + private static int array storage1 + private static int array storage2 + private static int array storage3 + private static int array storage4 + private static int array storage5 + private static int array storage6 + private static int array storage7 + private static int array storage8 + private static int array storage9 + private static int array storage10 + private static int array storage11 + private static int array storage12 + private static int array storage13 + private static int array storage14 + private static int array storage15 + private static int array storage16 + private static int array storage17 + private static int array storage18 + private static int array storage19 + private static int array storage20 + private static int array storage21 + private static int array storage22 + private static int array storage23 + private static int array storage24 + private static int array storage25 + private static int array storage26 + private static int array storage27 + private static int array storage28 + private static int array storage29 + private static int array storage30 + private static int array storage31 + private static int storageEnd = 0 + private static Table freeStarts = new Table() + private static Table freeSizes = new Table() + private static Table allocationSizes = new Table() + private static int freeCount = 0 + + static function capacity() returns int + return SHARD_COUNT * JASS_MAX_ARRAY_SIZE + + static function get(int address) returns int + let shard = address div JASS_MAX_ARRAY_SIZE + let index = address - shard * JASS_MAX_ARRAY_SIZE + switch shard + case 0 + return storage0[index] + case 1 + return storage1[index] + case 2 + return storage2[index] + case 3 + return storage3[index] + case 4 + return storage4[index] + case 5 + return storage5[index] + case 6 + return storage6[index] + case 7 + return storage7[index] + case 8 + return storage8[index] + case 9 + return storage9[index] + case 10 + return storage10[index] + case 11 + return storage11[index] + case 12 + return storage12[index] + case 13 + return storage13[index] + case 14 + return storage14[index] + case 15 + return storage15[index] + case 16 + return storage16[index] + case 17 + return storage17[index] + case 18 + return storage18[index] + case 19 + return storage19[index] + case 20 + return storage20[index] + case 21 + return storage21[index] + case 22 + return storage22[index] + case 23 + return storage23[index] + case 24 + return storage24[index] + case 25 + return storage25[index] + case 26 + return storage26[index] + case 27 + return storage27[index] + case 28 + return storage28[index] + case 29 + return storage29[index] + case 30 + return storage30[index] + case 31 + return storage31[index] + return 0 + + static function set(int address, int value) + let shard = address div JASS_MAX_ARRAY_SIZE + let index = address - shard * JASS_MAX_ARRAY_SIZE + switch shard + case 0 + storage0[index] = value + case 1 + storage1[index] = value + case 2 + storage2[index] = value + case 3 + storage3[index] = value + case 4 + storage4[index] = value + case 5 + storage5[index] = value + case 6 + storage6[index] = value + case 7 + storage7[index] = value + case 8 + storage8[index] = value + case 9 + storage9[index] = value + case 10 + storage10[index] = value + case 11 + storage11[index] = value + case 12 + storage12[index] = value + case 13 + storage13[index] = value + case 14 + storage14[index] = value + case 15 + storage15[index] = value + case 16 + storage16[index] = value + case 17 + storage17[index] = value + case 18 + storage18[index] = value + case 19 + storage19[index] = value + case 20 + storage20[index] = value + case 21 + storage21[index] = value + case 22 + storage22[index] = value + case 23 + storage23[index] = value + case 24 + storage24[index] = value + case 25 + storage25[index] = value + case 26 + storage26[index] = value + case 27 + storage27[index] = value + case 28 + storage28[index] = value + case 29 + storage29[index] = value + case 30 + storage30[index] = value + case 31 + storage31[index] = value + + static function tryAllocate(int size) returns int + if size <= 0 + return -1 + for i = 0 to freeCount - 1 + let freeSize = freeSizes.loadInt(i) + if freeSize >= size + let reusedStart = freeStarts.loadInt(i) + freeStarts.saveInt(i, reusedStart + size) + freeSizes.saveInt(i, freeSize - size) + if freeSize == size + freeCount-- + freeStarts.saveInt(i, freeStarts.loadInt(freeCount)) + freeSizes.saveInt(i, freeSizes.loadInt(freeCount)) + freeStarts.removeInt(freeCount) + freeSizes.removeInt(freeCount) + allocationSizes.saveInt(reusedStart, size) + return reusedStart + if size > capacity() - storageEnd + return -1 + let freshStart = storageEnd + storageEnd += size + allocationSizes.saveInt(freshStart, size) + return freshStart + + static function allocate(int size) returns int + let result = tryAllocate(size) + if result < 0 + error(size <= 0 ? "ShardedIntStorage: allocation size must be positive" : + "ShardedIntStorage: capacity exhausted") + return result + + static function release(int start, int size) + if start < 0 or size <= 0 or allocationSizes.loadInt(start) != size + error("ShardedIntStorage: released range is not an active allocation") + return + allocationSizes.removeInt(start) + var mergedStart = start + var mergedEnd = start + size + var i = 0 + while i < freeCount + let candidateStart = freeStarts.loadInt(i) + let candidateEnd = candidateStart + freeSizes.loadInt(i) + if candidateStart <= mergedEnd and candidateEnd >= mergedStart + mergedStart = candidateStart < mergedStart ? candidateStart : mergedStart + mergedEnd = candidateEnd > mergedEnd ? candidateEnd : mergedEnd + freeCount-- + freeStarts.saveInt(i, freeStarts.loadInt(freeCount)) + freeSizes.saveInt(i, freeSizes.loadInt(freeCount)) + freeStarts.removeInt(freeCount) + freeSizes.removeInt(freeCount) + else + i++ + if mergedEnd == storageEnd + storageEnd = mergedStart + else + freeStarts.saveInt(freeCount, mergedStart) + freeSizes.saveInt(freeCount, mergedEnd - mergedStart) + freeCount++ diff --git a/wurst/data/ShardedIntStorageTests.wurst b/wurst/data/ShardedIntStorageTests.wurst new file mode 100644 index 00000000..3c3e8322 --- /dev/null +++ b/wurst/data/ShardedIntStorageTests.wurst @@ -0,0 +1,23 @@ +package ShardedIntStorageTests +import ShardedIntStorage + +@Test function allocationReadsAndWritesAcrossNativeArrayBoundary() + let size = JASS_MAX_ARRAY_SIZE + 2 + let start = ShardedIntStorage.allocate(size) + ShardedIntStorage.set(start + JASS_MAX_ARRAY_SIZE - 1, 41) + ShardedIntStorage.set(start + JASS_MAX_ARRAY_SIZE, 42) + ShardedIntStorage.get(start + JASS_MAX_ARRAY_SIZE - 1).assertEquals(41) + ShardedIntStorage.get(start + JASS_MAX_ARRAY_SIZE).assertEquals(42) + ShardedIntStorage.release(start, size) + +@Test function releasedAdjacentRangesAreCoalescedAndReused() + let first = ShardedIntStorage.allocate(10) + let second = ShardedIntStorage.allocate(20) + ShardedIntStorage.release(second, 20) + ShardedIntStorage.release(first, 10) + let combined = ShardedIntStorage.allocate(30) + combined.assertEquals(first) + ShardedIntStorage.release(combined, 30) + +@Test function oversizedAllocationCanFailWithoutAbortingCaller() + ShardedIntStorage.tryAllocate(ShardedIntStorage.capacity() + 1).assertEquals(-1) diff --git a/wurst/math/PathingGrid.wurst b/wurst/math/PathingGrid.wurst new file mode 100644 index 00000000..d9c634df --- /dev/null +++ b/wurst/math/PathingGrid.wurst @@ -0,0 +1,24 @@ +package PathingGrid +import NoWurst +import Vectors + +/** Warcraft III pathing cells are 32 world units wide and high. */ +public constant real PATHING_CELL_SIZE = 32. + +/** Global pathing-grid coordinates. Cell bounds are inclusive at min and exclusive at max. */ +public tuple pathingCell(int x, int y) + +public function vec2.toPathingCell() returns pathingCell + return pathingCell((this.x / PATHING_CELL_SIZE).floor(), (this.y / PATHING_CELL_SIZE).floor()) + +public function vec3.toPathingCell() returns pathingCell + return pathingCell((this.x / PATHING_CELL_SIZE).floor(), (this.y / PATHING_CELL_SIZE).floor()) + +public function pathingCell.min() returns vec2 + return vec2(this.x * PATHING_CELL_SIZE, this.y * PATHING_CELL_SIZE) + +public function pathingCell.max() returns vec2 + return vec2((this.x + 1) * PATHING_CELL_SIZE, (this.y + 1) * PATHING_CELL_SIZE) + +public function pathingCell.center() returns vec2 + return vec2((this.x + 0.5) * PATHING_CELL_SIZE, (this.y + 0.5) * PATHING_CELL_SIZE) diff --git a/wurst/math/PathingGridTests.wurst b/wurst/math/PathingGridTests.wurst new file mode 100644 index 00000000..3fa17078 --- /dev/null +++ b/wurst/math/PathingGridTests.wurst @@ -0,0 +1,19 @@ +package PathingGridTests +import PathingGrid + +@Test function vec2MapsToPathingCellsInConstantTime() + (vec2(0, 0).toPathingCell() == pathingCell(0, 0)).assertTrue() + (vec2(31.999, 31.999).toPathingCell() == pathingCell(0, 0)).assertTrue() + (vec2(32, 32).toPathingCell() == pathingCell(1, 1)).assertTrue() + (vec2(-0.001, -0.001).toPathingCell() == pathingCell(-1, -1)).assertTrue() + (vec2(-32, -32).toPathingCell() == pathingCell(-1, -1)).assertTrue() + +@Test function vec3MapsToSamePathingCellRegardlessOfHeight() + (vec3(95, -33, -500).toPathingCell() == pathingCell(2, -2)).assertTrue() + (vec3(95, -33, 9000).toPathingCell() == pathingCell(2, -2)).assertTrue() + +@Test function pathingCellReportsWorldBoundsAndCenter() + let cell = pathingCell(-2, 3) + cell.min().assertEquals(vec2(-64, 96)) + cell.max().assertEquals(vec2(-32, 128)) + cell.center().assertEquals(vec2(-48, 112)) diff --git a/wurst/math/Polygon.wurst b/wurst/math/Polygon.wurst new file mode 100644 index 00000000..c1e61bd4 --- /dev/null +++ b/wurst/math/Polygon.wurst @@ -0,0 +1,414 @@ +package Polygon +import NoWurst +import ArrayList +import Vectors +import Rect +import ErrorHandling +import Maths +import Lightning +import ClosureTimers +import Colors +import PathingGrid +import ShardedIntStorage + +public enum PolygonPointRelation + OUTSIDE + BOUNDARY + INSIDE + +/** Coverage of one cached pathing-grid cell, not the relation of one point. */ +public enum PolygonCellCoverage + OUTSIDE + MIXED + 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 + private pathingCell gridMinCell + private int gridWidth = 0 + private int gridHeight = 0 + private int gridCellBase = -1 + private int gridBandOffsetBase = -1 + private int gridCandidateBase = -1 + private int gridStorageStart = -1 + private int gridStorageSize = 0 + + /** 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 + buildGrid() + 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 + + /** Exact division-free O(n) reference implementation and grid fallback. */ + function classifyLinear(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 + + private function edgeIntersectsCell(vec2 start, vec2 finish, real minX, real minY, real maxX, real maxY) returns bool + if max(start.x, finish.x) < minX or min(start.x, finish.x) > maxX or + max(start.y, finish.y) < minY or min(start.y, finish.y) > maxY + return false + if start.x >= minX and start.x <= maxX and start.y >= minY and start.y <= maxY + return true + if finish.x >= minX and finish.x <= maxX and finish.y >= minY and finish.y <= maxY + return true + let bottomLeft = vec2(minX, minY) + let bottomRight = vec2(maxX, minY) + let topRight = vec2(maxX, maxY) + let topLeft = vec2(minX, maxY) + return segmentsIntersect(start, finish, bottomLeft, bottomRight) or + segmentsIntersect(start, finish, bottomRight, topRight) or + segmentsIntersect(start, finish, topRight, topLeft) or + segmentsIntersect(start, finish, topLeft, bottomLeft) + + private function segmentsIntersect(vec2 a, vec2 b, vec2 c, vec2 d) returns bool + let abC = (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x) + let abD = (d.x - a.x) * (b.y - a.y) - (d.y - a.y) * (b.x - a.x) + let cdA = (a.x - c.x) * (d.y - c.y) - (a.y - c.y) * (d.x - c.x) + let cdB = (b.x - c.x) * (d.y - c.y) - (b.y - c.y) * (d.x - c.x) + if abC == 0 and isOnSegment(c, a, b, abC) + return true + if abD == 0 and isOnSegment(d, a, b, abD) + return true + if cdA == 0 and isOnSegment(a, c, d, cdA) + return true + if cdB == 0 and isOnSegment(b, c, d, cdB) + return true + return (abC > 0) != (abD > 0) and (cdA > 0) != (cdB > 0) + + private function edgeOverlapsBand(vec2 start, vec2 finish, real bandMinY, real bandMaxY) returns bool + return max(start.y, finish.y) >= bandMinY and min(start.y, finish.y) <= bandMaxY + + private function classifyCandidates(vec2 point, int candidateStart, int candidateEnd) returns PolygonPointRelation + var inside = false + let count = vertices.size() + for candidate = candidateStart to candidateEnd - 1 + let currentIndex = ShardedIntStorage.get(gridCandidateBase + candidate) + let previousIndex = currentIndex == 0 ? count - 1 : currentIndex - 1 + let previous = vertices.get(previousIndex) + let current = vertices.get(currentIndex) + 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 + return inside ? PolygonPointRelation.INSIDE : PolygonPointRelation.OUTSIDE + + private function localCellIndex(pathingCell cell) returns int + let localX = cell.x - gridMinCell.x + let localY = cell.y - gridMinCell.y + if localX < 0 or localX >= gridWidth or localY < 0 or localY >= gridHeight + return -1 + return localY * gridWidth + localX + + private function coverageAtIndex(int cellIndex) returns PolygonCellCoverage + if cellIndex < 0 + return PolygonCellCoverage.OUTSIDE + let stored = ShardedIntStorage.get(gridCellBase + cellIndex) + if stored == 2 + return PolygonCellCoverage.INSIDE + if stored == 1 + return PolygonCellCoverage.MIXED + return PolygonCellCoverage.OUTSIDE + + private function buildGrid() + if vertices.size() == 0 + return + let capacity = ShardedIntStorage.capacity() + let estimatedWidth = (boundaryRect.getMaxX() - boundaryRect.getMinX()) / PATHING_CELL_SIZE + 3 + let estimatedHeight = (boundaryRect.getMaxY() - boundaryRect.getMinY()) / PATHING_CELL_SIZE + 3 + if estimatedWidth > capacity or estimatedHeight > capacity + return + let minCell = vec2(boundaryRect.getMinX(), boundaryRect.getMinY()).toPathingCell() + let maxCell = vec2(boundaryRect.getMaxX(), boundaryRect.getMaxY()).toPathingCell() + gridMinCell = pathingCell(minCell.x - 1, minCell.y - 1) + gridWidth = maxCell.x - minCell.x + 3 + gridHeight = maxCell.y - minCell.y + 3 + if gridWidth <= 0 or gridHeight <= 0 or gridWidth > capacity div gridHeight + return + let cellCount = gridWidth * gridHeight + if gridHeight + 1 > capacity - cellCount + return + let fixedStorageSize = cellCount + gridHeight + 1 + + var candidateCount = 0 + for band = 0 to gridHeight - 1 + let bandMinY = (gridMinCell.y + band) * PATHING_CELL_SIZE + let bandMaxY = bandMinY + PATHING_CELL_SIZE + var previous = vertices.get(vertices.size() - 1) + for edge = 0 to vertices.size() - 1 + let current = vertices.get(edge) + if edgeOverlapsBand(previous, current, bandMinY, bandMaxY) + if candidateCount >= capacity - fixedStorageSize + return + candidateCount++ + previous = current + + gridStorageSize = fixedStorageSize + candidateCount + gridStorageStart = ShardedIntStorage.tryAllocate(gridStorageSize) + if gridStorageStart < 0 + gridStorageSize = 0 + return + gridCellBase = gridStorageStart + gridBandOffsetBase = gridCellBase + cellCount + gridCandidateBase = gridBandOffsetBase + gridHeight + 1 + + var candidateCursor = 0 + for band = 0 to gridHeight - 1 + ShardedIntStorage.set(gridBandOffsetBase + band, candidateCursor) + let bandMinY = (gridMinCell.y + band) * PATHING_CELL_SIZE + let bandMaxY = bandMinY + PATHING_CELL_SIZE + var previous = vertices.get(vertices.size() - 1) + for edge = 0 to vertices.size() - 1 + let current = vertices.get(edge) + if edgeOverlapsBand(previous, current, bandMinY, bandMaxY) + ShardedIntStorage.set(gridCandidateBase + candidateCursor, edge) + candidateCursor++ + previous = current + ShardedIntStorage.set(gridBandOffsetBase + gridHeight, candidateCursor) + + for localY = 0 to gridHeight - 1 + let candidateStart = ShardedIntStorage.get(gridBandOffsetBase + localY) + let candidateEnd = ShardedIntStorage.get(gridBandOffsetBase + localY + 1) + let minY = (gridMinCell.y + localY) * PATHING_CELL_SIZE + let maxY = minY + PATHING_CELL_SIZE + for localX = 0 to gridWidth - 1 + let minX = (gridMinCell.x + localX) * PATHING_CELL_SIZE + let maxX = minX + PATHING_CELL_SIZE + var mixed = false + for candidate = candidateStart to candidateEnd - 1 + let currentIndex = ShardedIntStorage.get(gridCandidateBase + candidate) + let previousIndex = currentIndex == 0 ? vertices.size() - 1 : currentIndex - 1 + if edgeIntersectsCell(vertices.get(previousIndex), vertices.get(currentIndex), minX, minY, maxX, maxY) + mixed = true + break + let cellIndex = localY * gridWidth + localX + if mixed + ShardedIntStorage.set(gridCellBase + cellIndex, 1) + else + let relation = classifyCandidates(vec2((minX + maxX) * 0.5, (minY + maxY) * 0.5), candidateStart, candidateEnd) + ShardedIntStorage.set(gridCellBase + cellIndex, relation == PolygonPointRelation.INSIDE ? 2 : 0) + + function cellCoverage(vec2 point) returns PolygonCellCoverage + if not requireSealed() or vertices.size() == 0 + return PolygonCellCoverage.OUTSIDE + if gridStorageStart < 0 + let cell = point.toPathingCell() + let cellMin = cell.min() + let cellMax = cell.max() + if cellMax.x < boundaryRect.getMinX() or cellMin.x > boundaryRect.getMaxX() or + cellMax.y < boundaryRect.getMinY() or cellMin.y > boundaryRect.getMaxY() + return PolygonCellCoverage.OUTSIDE + return PolygonCellCoverage.MIXED + return coverageAtIndex(localCellIndex(point.toPathingCell())) + + function cellCoverage(vec3 point) returns PolygonCellCoverage + return cellCoverage(point.toVec2()) + + /** Number of exact edge candidates classify() examines for this point. */ + function cellCandidateEdgeCount(vec2 point) returns int + if cellCoverage(point) != PolygonCellCoverage.MIXED + return 0 + if gridStorageStart < 0 + return vertices.size() + let localY = point.toPathingCell().y - gridMinCell.y + return ShardedIntStorage.get(gridBandOffsetBase + localY + 1) - ShardedIntStorage.get(gridBandOffsetBase + localY) + + function classify(vec2 point) returns PolygonPointRelation + if not requireSealed() or vertices.size() == 0 + return PolygonPointRelation.OUTSIDE + if gridStorageStart < 0 + return classifyLinear(point) + if point.x < boundaryRect.getMinX() or point.x > boundaryRect.getMaxX() or + point.y < boundaryRect.getMinY() or point.y > boundaryRect.getMaxY() + return PolygonPointRelation.OUTSIDE + let cell = point.toPathingCell() + let coverage = coverageAtIndex(localCellIndex(cell)) + if coverage == PolygonCellCoverage.INSIDE + return PolygonPointRelation.INSIDE + if coverage == PolygonCellCoverage.OUTSIDE + return PolygonPointRelation.OUTSIDE + let localY = cell.y - gridMinCell.y + return classifyCandidates(point, + ShardedIntStorage.get(gridBandOffsetBase + localY), + ShardedIntStorage.get(gridBandOffsetBase + localY + 1)) + + 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 gridStorageStart >= 0 + ShardedIntStorage.release(gridStorageStart, gridStorageSize) + gridStorageStart = -1 + gridStorageSize = 0 + 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..70beb6f9 --- /dev/null +++ b/wurst/math/PolygonTests.wurst @@ -0,0 +1,299 @@ +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) + +function benchmarkPolygon() returns Polygon + let polygon = new Polygon(64) + for i = 0 to 15 + polygon.addVertex(vec2(i * 64., 0)) + for i = 0 to 15 + polygon.addVertex(vec2(1024, i * 64.)) + for i = 0 to 15 + polygon.addVertex(vec2(1024 - i * 64., 1024)) + for i = 0 to 15 + polygon.addVertex(vec2(0, 1024 - i * 64.)) + return polygon.seal() + +function benchmarkPoint(int index) returns vec2 + return vec2(((index * 73) mod 1152 - 64).toReal(), ((index * 151) mod 1152 - 64).toReal()) + +@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 gridCoverageDistinguishesUniformMixedAndDisjointCells() + let polygon = new Polygon(vec2(0, 0), vec2(96, 0), vec2(96, 96), vec2(0, 96)) + (polygon.cellCoverage(vec2(48, 48)) == PolygonCellCoverage.INSIDE).assertTrue() + (polygon.cellCoverage(vec2(16, 48)) == PolygonCellCoverage.MIXED).assertTrue() + (polygon.cellCoverage(vec2(-16, 48)) == PolygonCellCoverage.MIXED).assertTrue() + (polygon.cellCoverage(vec2(-48, 48)) == PolygonCellCoverage.OUTSIDE).assertTrue() + destroy polygon + +@Test function mixedCellsReclassifyTheActualPointExactly() + let polygon = new Polygon(vec2(0, 0), vec2(96, 0), vec2(96, 96), vec2(0, 96)) + (polygon.classify(vec2(16, 48)) == PolygonPointRelation.INSIDE).assertTrue() + (polygon.classify(vec2(-16, 48)) == PolygonPointRelation.OUTSIDE).assertTrue() + (polygon.classify(vec2(0, 48)) == PolygonPointRelation.BOUNDARY).assertTrue() + (polygon.classify(vec2(16, 48)) == polygon.classifyLinear(vec2(16, 48))).assertTrue() + (polygon.classify(vec2(-16, 48)) == polygon.classifyLinear(vec2(-16, 48))).assertTrue() + (polygon.classify(vec2(0, 48)) == polygon.classifyLinear(vec2(0, 48))).assertTrue() + destroy polygon + +@Test function acceleratedClassificationExaminesAtMostOneBandOfEdges() + let polygon = new Polygon( + vec2(0, 0), vec2(320, 0), vec2(320, 320), vec2(256, 320), + vec2(256, 64), vec2(64, 64), vec2(64, 320), vec2(0, 320)) + polygon.cellCandidateEdgeCount(vec2(1, 16)).assertEquals(3) + polygon.cellCandidateEdgeCount(vec2(1, 16)).assertLessThanOrEqual(polygon.vertexCount()) + polygon.cellCandidateEdgeCount(vec2(96, 96)).assertEquals(0) + (polygon.classify(vec2(1, 16)) == polygon.classifyLinear(vec2(1, 16))).assertTrue() + (polygon.classify(vec2(96, 96)) == polygon.classifyLinear(vec2(96, 96))).assertTrue() + destroy polygon + +@Test function acceleratedClassificationMatchesLinearFor10000Points() + let polygon = benchmarkPolygon() + var totalCandidates = 0 + var linearEdges = 0 + var mixedQueries = 0 + for i = 0 to 9999 + let point = benchmarkPoint(i) + (polygon.classify(point) == polygon.classifyLinear(point)).assertTrue() + if point.x >= 0 and point.x <= 1024 and point.y >= 0 and point.y <= 1024 + linearEdges += polygon.vertexCount() + let candidates = polygon.cellCandidateEdgeCount(point) + candidates.assertLessThanOrEqual(polygon.vertexCount()) + if candidates > 0 + mixedQueries++ + totalCandidates += candidates + totalCandidates.assertLessThan(linearEdges) + print("Polygon 10000-lookups: mixed=" + mixedQueries.toString() + + ", linearEdges=" + linearEdges.toString() + ", candidateEdges=" + totalCandidates.toString()) + destroy polygon + +@Test function oversizedGridFallsBackToExactLinearClassification() + let polygon = new Polygon( + vec2(0, 0), vec2(40000000, 0), vec2(40000000, 64), vec2(0, 64)) + (polygon.cellCoverage(vec2(16, 16)) == PolygonCellCoverage.MIXED).assertTrue() + (polygon.cellCoverage(vec2(-64, 16)) == PolygonCellCoverage.OUTSIDE).assertTrue() + polygon.cellCandidateEdgeCount(vec2(16, 16)).assertEquals(4) + (polygon.classify(vec2(16, 16)) == polygon.classifyLinear(vec2(16, 16))).assertTrue() + 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..baf45be5 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 @@ -521,4 +523,3 @@ function vectorTests() 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() - From d9ff9a48ee555ffdfa62f5a3174a996144757ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Havl=C3=AD=C4=8Dek?= Date: Sun, 2 Aug 2026 10:21:07 +0200 Subject: [PATCH 2/2] test: keep stdlib verification warning-free --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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