// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE // ReSharper disable once CheckNamespace namespace WallstopStudios.UnityHelpers.Core.Extension { using System; using System.Collections.Generic; using DataStructure; using DataStructure.Adapters; using UnityEngine; using Utils; // GridConcaveHullEdgeSplit.cs - Edge-splitting algorithm implementation // See GeometryConcaveHull.cs for full concave hull architecture documentation /// /// Edge-splitting concave hull builders plus supporting structures. /// Starts with convex hull and recursively splits longest edges with interior points. /// public static partial class UnityExtensions { public static List BuildConcaveHullEdgeSplit( this IReadOnlyCollection points, int bucketSize = 40, float angleThreshold = 90f ) { int clampedBucketSize = Math.Max(1, bucketSize); float effectiveAngleThreshold = clampedBucketSize <= 1 ? 0f : angleThreshold; ConcaveHullOptions options = ConcaveHullOptions .Default.WithStrategy(ConcaveHullStrategy.EdgeSplit) .WithBucketSize(clampedBucketSize) .WithAngleThreshold(effectiveAngleThreshold); return points.BuildConcaveHull(options); } private readonly struct HullEdge { public readonly float edgeLength; public readonly FastVector3Int from; public readonly FastVector3Int to; public readonly Vector2 fromWorld; public readonly Vector2 toWorld; private readonly Grid _grid; public HullEdge(FastVector3Int from, FastVector3Int to, Grid grid) { this.from = from; this.to = to; _grid = grid; fromWorld = grid.CellToWorld(from); toWorld = grid.CellToWorld(to); edgeLength = (fromWorld - toWorld).sqrMagnitude; } public bool Intersects(HullEdge other) { return UnityExtensions.Intersects( fromWorld, toWorld, other.fromWorld, other.toWorld ); } public float LargestAngle(FastVector3Int point) { Vector2 worldPoint = _grid.CellToWorld(point); float angleFrom = Vector2.Angle(toWorld - fromWorld, worldPoint - fromWorld); float angleTo = Vector2.Angle(fromWorld - toWorld, worldPoint - toWorld); return Math.Max(angleFrom, angleTo); } } private sealed class ConcaveHullComparer : IComparer { public static readonly ConcaveHullComparer Instance = new(); private ConcaveHullComparer() { } public int Compare(HullEdge lhs, HullEdge rhs) { int comparison = lhs.edgeLength.CompareTo(rhs.edgeLength); if (comparison != 0) { return comparison; } comparison = lhs.from.CompareTo(rhs.from); if (comparison != 0) { return comparison; } return lhs.to.CompareTo(rhs.to); } } /// /// Grid-based edge-splitting concave hull. /// [Obsolete("Use BuildConcaveHullEdgeSplit or BuildConcaveHull with options.")] public static List BuildConcaveHull3( this IReadOnlyCollection gridPositions, Grid grid, int bucketSize = 40, float angleThreshold = 90f ) { using PooledResource> originalGridPositionsBuffer = Buffers.List.Get(out List originalGridPositions); originalGridPositions.AddRange(gridPositions); List convexHull = gridPositions.BuildConvexHull(grid); using ( PooledResource> uniqueBuffer = Buffers.HashSet.Get(out HashSet unique) ) { foreach (FastVector3Int p in originalGridPositions) { unique.Add(p); } if (unique.Count <= 4) { return convexHull; } } if (AreAllPointsOnHullEdges(originalGridPositions, convexHull)) { return convexHull; } using PooledResource> concaveHullEdgesResource = Buffers.List.Get(out List concaveHullEdges); if (concaveHullEdges.Capacity < convexHull.Count) { concaveHullEdges.Capacity = convexHull.Count; } using PooledResource> sortedSetBuffer = SetBuffers .GetSortedSetPool(ConcaveHullComparer.Instance) .Get(out SortedSet data); for (int i = 0; i < convexHull.Count; ++i) { FastVector3Int lhs = convexHull[i]; FastVector3Int rhs = convexHull[(i + 1) % convexHull.Count]; HullEdge edge = new(lhs, rhs, grid); _ = data.Add(edge); } using PooledResource> remainingPointsBuffer = Buffers.HashSet.Get(out HashSet remainingPoints); foreach (FastVector3Int gridPosition in originalGridPositions) { remainingPoints.Add(gridPosition); } remainingPoints.ExceptWith(convexHull); Bounds? maybeBounds = CalculateWorldBounds(originalGridPositions, grid); if (maybeBounds == null) { throw new ArgumentException(nameof(gridPositions)); } using PooledResource.Entry>> quadTreeEntriesResource = Buffers.Entry>.List.Get( out List.Entry> quadTreeEntries ); foreach (FastVector3Int gridPosition in originalGridPositions) { Vector2 worldPosition = grid.CellToWorld(gridPosition); quadTreeEntries.Add( new QuadTree2D.Entry(gridPosition, worldPosition) ); } QuadTree2D quadTree = new( quadTreeEntries, maybeBounds.Value, bucketSize: bucketSize ); using PooledResource> neighborsBuffer = Buffers.List.Get(out List neighbors); if (neighbors.Capacity < bucketSize) { neighbors.Capacity = bucketSize; } int iterations = 0; int maxIterations = Math.Max(32, originalGridPositions.Count * 16); while (0 < data.Count) { HullEdge edge = data.Max; _ = data.Remove(edge); Vector2 edgeCenter = edge.fromWorld + (edge.toWorld - edge.fromWorld) / 2; quadTree.GetApproximateNearestNeighbors(edgeCenter, bucketSize, neighbors); float localMaximumDistance = float.MinValue; int usableNeighborCount = 0; foreach (FastVector3Int neighbor in neighbors) { if (neighbor == edge.to || neighbor == edge.from) { continue; } ++usableNeighborCount; localMaximumDistance = Math.Max( localMaximumDistance, (CellToWorld(neighbor) - edgeCenter).sqrMagnitude ); } if (usableNeighborCount < 2) { concaveHullEdges.Add(edge); continue; } if (edge.edgeLength <= localMaximumDistance) { concaveHullEdges.Add(edge); continue; } float smallestAngle = float.MaxValue; FastVector3Int? maybeChosenPoint = null; foreach (FastVector3Int remainingPoint in remainingPoints) { float maximumAngle = edge.LargestAngle(remainingPoint); if (maximumAngle < smallestAngle) { maybeChosenPoint = remainingPoint; smallestAngle = maximumAngle; } } if (angleThreshold < smallestAngle) { concaveHullEdges.Add(edge); continue; } if (maybeChosenPoint == null) { concaveHullEdges.Add(edge); continue; } FastVector3Int chosenPoint = maybeChosenPoint.Value; HullEdge e2 = new(edge.from, chosenPoint, grid); HullEdge e3 = new(chosenPoint, edge.to, grid); bool intersects = false; foreach (HullEdge convexHullEdge in data) { if (convexHullEdge.Intersects(e2)) { intersects = true; break; } if (convexHullEdge.Intersects(e3)) { intersects = true; break; } } if (!intersects) { foreach (HullEdge concaveHullEdge in concaveHullEdges) { if (concaveHullEdge.Intersects(e2)) { intersects = true; break; } if (concaveHullEdge.Intersects(e3)) { intersects = true; break; } } } if (!intersects) { _ = data.Add(e2); _ = data.Add(e3); _ = remainingPoints.Remove(maybeChosenPoint.Value); } else { concaveHullEdges.Add(edge); } ++iterations; if (iterations > maxIterations) { // Safety: avoid runaway refinement by flushing remaining edges to concave set concaveHullEdges.AddRange(data); break; } } List concaveHull = new(concaveHullEdges.Count); HullEdge current = concaveHullEdges[0]; concaveHullEdges.RemoveAtSwapBack(0); concaveHull.Add(current.from); while (0 < concaveHullEdges.Count) { FastVector3Int to = current.to; int nextIndex = -1; for (int i = 0; i < concaveHullEdges.Count; ++i) { HullEdge edge = concaveHullEdges[i]; if (edge.from == to) { nextIndex = i; break; } } if (nextIndex < 0) { // Try to recover by using a reversed edge if available. int reverseIndex = -1; for (int i = 0; i < concaveHullEdges.Count; ++i) { HullEdge edge = concaveHullEdges[i]; if (edge.to == to) { reverseIndex = i; break; } } if (reverseIndex >= 0) { HullEdge reversed = new( concaveHullEdges[reverseIndex].to, concaveHullEdges[reverseIndex].from, grid ); concaveHullEdges.RemoveAtSwapBack(reverseIndex); current = reversed; concaveHull.Add(current.from); continue; } // No connecting edge found; break to avoid infinite loop. break; } current = concaveHullEdges[nextIndex]; concaveHullEdges.RemoveAtSwapBack(nextIndex); concaveHull.Add(current.from); } PruneColinearOnHull(concaveHull); return concaveHull; Vector2 CellToWorld(FastVector3Int cell) => grid.CellToWorld(cell); } private static Bounds? CalculateWorldBounds( IEnumerable positions, Grid grid ) { bool any = false; float minX = float.MaxValue; float maxX = float.MinValue; float minY = float.MaxValue; float maxY = float.MinValue; foreach (FastVector3Int position in positions) { Vector3 world = grid.CellToWorld(position); float x = world.x; float y = world.y; if (x < minX) { minX = x; } if (x > maxX) { maxX = x; } if (y < minY) { minY = y; } if (y > maxY) { maxY = y; } any = true; } if (!any) { return null; } Vector3 center = new((minX + maxX) / 2f, (minY + maxY) / 2f, 0f); Vector3 size = new(maxX - minX, maxY - minY, 1f); return new Bounds(center, size); } // Edge-splitting + kNN queries via QuadTree for Vector2 (port of BuildConcaveHull3) private static List BuildConcaveHull3( this IReadOnlyCollection input, int bucketSize, float angleThreshold ) { using PooledResource> originalRes = Buffers.List.Get( out List original ); original.AddRange(input); using PooledResource> remainingRes = Buffers.HashSet.Get( out HashSet remainingPoints ); remainingPoints.UnionWith(original); List convexHull = input.BuildConvexHull(includeColinearPoints: false); using ( PooledResource> uniqRes = Buffers.HashSet.Get( out HashSet uniq ) ) { foreach (Vector2 p in original) { uniq.Add(p); } if (uniq.Count <= 4) { return convexHull; } } remainingPoints.ExceptWith(convexHull); using PooledResource> edgeListRes = Buffers.List.Get( out List concaveHullEdges ); if (concaveHullEdges.Capacity < convexHull.Count) { concaveHullEdges.Capacity = convexHull.Count; } using PooledResource> sortedSetRes = SetBuffers .GetSortedSetPool(ConcaveHullComparerV2.Instance) .Get(out SortedSet data); for (int i = 0; i < convexHull.Count; ++i) { Vector2 lhs = convexHull[i]; Vector2 rhs = convexHull[(i + 1) % convexHull.Count]; _ = data.Add(new HullEdgeV2(lhs, rhs)); } Bounds? maybeBounds = input.GetBounds(); if (maybeBounds == null) { throw new ArgumentException(nameof(input)); } using PooledResource.Entry>> entriesRes = Buffers.Entry>.List.Get( out List.Entry> entries ); foreach (Vector2 p in original) { entries.Add(new QuadTree2D.Entry(p, p)); } QuadTree2D quadTree = new(entries, maybeBounds.Value, bucketSize); using PooledResource> neighborsRes = Buffers.List.Get( out List neighbors ); if (neighbors.Capacity < bucketSize) { neighbors.Capacity = bucketSize; } int iterations = 0; int maxIterations = Math.Max(32, original.Count * 16); while (0 < data.Count) { HullEdgeV2 edge = data.Max; _ = data.Remove(edge); Vector2 edgeCenter = edge.from + (edge.to - edge.from) / 2f; quadTree.GetApproximateNearestNeighbors(edgeCenter, bucketSize, neighbors); float localMaximumDistance = float.MinValue; int usableNeighborCount = 0; foreach (Vector2 n in neighbors) { if (n == edge.to || n == edge.from) { continue; } ++usableNeighborCount; localMaximumDistance = Math.Max( localMaximumDistance, (n - edgeCenter).sqrMagnitude ); } if (usableNeighborCount < 2) { concaveHullEdges.Add(edge); continue; } if (edge.edgeLength <= localMaximumDistance) { concaveHullEdges.Add(edge); continue; } float smallestAngle = float.MaxValue; Vector2? maybeChosen = null; foreach (Vector2 candidate in remainingPoints) { if (candidate == edge.to || candidate == edge.from) { continue; } float angle = edge.LargestAngle(candidate); if (angle < smallestAngle) { smallestAngle = angle; maybeChosen = candidate; } } if (!maybeChosen.HasValue || smallestAngle > angleThreshold) { concaveHullEdges.Add(edge); continue; } Vector2 chosen = maybeChosen.Value; HullEdgeV2 e2 = new(edge.from, chosen); HullEdgeV2 e3 = new(chosen, edge.to); bool intersects = false; foreach (HullEdgeV2 hullEdge in data) { if (hullEdge.Intersects(e2) || hullEdge.Intersects(e3)) { intersects = true; break; } } if (!intersects) { foreach (HullEdgeV2 hullEdge in concaveHullEdges) { if (hullEdge.Intersects(e2) || hullEdge.Intersects(e3)) { intersects = true; break; } } } if (!intersects) { _ = data.Add(e2); _ = data.Add(e3); _ = remainingPoints.Remove(chosen); } else { concaveHullEdges.Add(edge); } ++iterations; if (iterations > maxIterations) { concaveHullEdges.AddRange(data); break; } } List result = new(concaveHullEdges.Count); if (concaveHullEdges.Count == 0) { return result; } HullEdgeV2 current = concaveHullEdges[0]; concaveHullEdges.RemoveAtSwapBack(0); result.Add(current.from); while (0 < concaveHullEdges.Count) { Vector2 to = current.to; int nextIndex = -1; for (int i = 0; i < concaveHullEdges.Count; ++i) { HullEdgeV2 e = concaveHullEdges[i]; if (e.from == to) { nextIndex = i; break; } } if (nextIndex < 0) { int reverseIndex = -1; for (int i = 0; i < concaveHullEdges.Count; ++i) { HullEdgeV2 e = concaveHullEdges[i]; if (e.to == to) { reverseIndex = i; break; } } if (reverseIndex >= 0) { HullEdgeV2 reversed = new( concaveHullEdges[reverseIndex].to, concaveHullEdges[reverseIndex].from ); concaveHullEdges.RemoveAtSwapBack(reverseIndex); current = reversed; result.Add(current.from); continue; } break; } current = concaveHullEdges[nextIndex]; concaveHullEdges.RemoveAtSwapBack(nextIndex); result.Add(current.from); } return result; } private readonly struct HullEdgeV2 { public readonly float edgeLength; public readonly Vector2 from; public readonly Vector2 to; public HullEdgeV2(Vector2 from, Vector2 to) { this.from = from; this.to = to; edgeLength = (from - to).sqrMagnitude; } public bool Intersects(HullEdgeV2 other) { return UnityExtensions.Intersects(from, to, other.from, other.to); } public float LargestAngle(Vector2 point) { float angleFrom = Vector2.Angle(to - from, point - from); float angleTo = Vector2.Angle(from - to, point - to); return Math.Max(angleFrom, angleTo); } } private sealed class ConcaveHullComparerV2 : IComparer { public static readonly ConcaveHullComparerV2 Instance = new(); private ConcaveHullComparerV2() { } public int Compare(HullEdgeV2 lhs, HullEdgeV2 rhs) { int comparison = lhs.edgeLength.CompareTo(rhs.edgeLength); if (comparison != 0) { return comparison; } comparison = lhs.from.x.CompareTo(rhs.from.x); if (comparison != 0) { return comparison; } comparison = lhs.from.y.CompareTo(rhs.from.y); if (comparison != 0) { return comparison; } comparison = lhs.to.x.CompareTo(rhs.to.x); if (comparison != 0) { return comparison; } return lhs.to.y.CompareTo(rhs.to.y); } } } }