using System; using System.Collections.Generic; using System.Runtime.InteropServices; using UnityEngine; namespace ShieldField { /// Hard cap on vertices per cell. Must match MAX_VERTS in ShieldCommon.hlsl. public static class Limits { public const int MaxVerts = 8; } [StructLayout(LayoutKind.Sequential)] public struct CellStatic { public Vector2 centroid; public float radius; // circumradius, used for growth + culling public float spawnDelay; // materialisation wavefront offset, seconds public uint vertStart; public uint vertCount; public uint nbrStart; public uint nbrCount; public float toughness; // per-cell break resistance multiplier public uint tileType; // author-defined class (thin/fat rhomb, etc) public uint splitSeed; // stable per-cell RNG seed for shard assignment public float area; // cell area — denominator of the graph Laplacian // R7 (docs/shield-panel-cut.md): openW (rim free-surface ghost weight) removed — the // panel's emitters hold its edge, so the rim is no longer a stress-reflecting boundary. // No other consumer read this field (grep confirmed); pad0 keeps the stride unchanged // rather than reshuffling every offset downstream in the HLSL mirror. public float pad0; public float pad1; public float pad2; public float pad3; public const int Stride = 4 * 16; } [StructLayout(LayoutKind.Sequential)] public struct DualEdge { public uint nbr; // neighbouring cell index public Vector2 mid; // midpoint of the shared edge, panel space public Vector2 dir; // unit direction along the shared edge public float len; // shared edge length public float w; // finite-volume Laplacian weight: len / |c_i - c_j| public const int Stride = 4 * 7; } /// /// C# mirror of CellState in ShieldCommon.hlsl — field-for-field, same order, same 16-float /// stride as `bState`'s allocation in ShieldPanel.cs. Cut 3 (docs/shield-panel-cut.md) needs /// this on both sides of the boundary: ShieldPanel.ResetSim writes it directly to seed each /// cell's `growth` at arm time (the D17/D20 fix — see the comment on ResetSim), and the Cut 3 /// batchmode probe reads it back to verify same-frame injection and reuse-driven temper erosion /// without adding a readback path the running sim doesn't already need. /// [StructLayout(LayoutKind.Sequential)] public struct CellState { public Vector3 vel; public float spin; public Vector3 pos; public float rot; public float damage; public float breakTime; public float glow; public float growth; public float temper; public float released; public float peakTension; public float pad2; public const int Stride = 4 * 16; } /// Raw output of a tiling generator: a bag of convex polygons, wound CCW. public class PolygonSoup { public readonly List polys = new(); public readonly List types = new(); public void Add(Vector2[] p, int type = 0) { if (p.Length < 3) return; if (SignedArea(p) < 0f) Array.Reverse(p); // force CCW polys.Add(p); types.Add(type); } public static float SignedArea(IList p) { float a = 0f; for (int i = 0, n = p.Count; i < n; i++) { Vector2 u = p[i], v = p[(i + 1) % n]; a += u.x * v.y - v.x * u.y; } return a * 0.5f; } public static Vector2 Centroid(IList p) { float a = 0f; Vector2 c = Vector2.zero; for (int i = 0, n = p.Count; i < n; i++) { Vector2 u = p[i], v = p[(i + 1) % n]; float cr = u.x * v.y - v.x * u.y; a += cr; c += (u + v) * cr; } if (Mathf.Abs(a) < 1e-9f) { c = Vector2.zero; foreach (var q in p) c += q; return c / p.Count; } return c / (3f * a); } } /// Welded, adjacency-resolved tiling ready for GPU upload. public class TilingData { public Vector2[] verts; // flat pool, each cell's ring is contiguous public CellStatic[] cells; public DualEdge[] dualEdges; // flat pool, each cell's arcs are contiguous public Bounds bounds; public int CellCount => cells.Length; public int EdgeCount => dualEdges.Length; } public static class TilingBuilder { /// /// Welds coincident vertices, discards cells outside the clip radius, resolves the dual /// (cell adjacency) graph, and packs everything into flat GPU-friendly arrays. /// public static TilingData Build(PolygonSoup soup, float weldEps = 1e-3f, float clipRadius = float.PositiveInfinity, Vector2 wavefrontOrigin = default, float wavefrontSpeed = 6f, float delayJitter = 0.08f, int seed = 12345) { var weld = new VertexWelder(weldEps); // ---- pass 1: keep cells whose centroid is inside the clip disc, weld their rings var keptRings = new List(); var keptTypes = new List(); for (int c = 0; c < soup.polys.Count; c++) { var poly = soup.polys[c]; if (poly.Length > Limits.MaxVerts) continue; // silently drop; raise MaxVerts if this bites Vector2 ctr = PolygonSoup.Centroid(poly); if (ctr.sqrMagnitude > clipRadius * clipRadius) continue; var ring = new int[poly.Length]; bool degenerate = false; for (int i = 0; i < poly.Length; i++) { ring[i] = weld.Get(poly[i]); for (int j = 0; j < i; j++) if (ring[j] == ring[i]) degenerate = true; } if (degenerate) continue; keptRings.Add(ring); keptTypes.Add(soup.types[c]); } int cellCount = keptRings.Count; if (cellCount == 0) throw new InvalidOperationException("Tiling produced no cells."); // ---- pass 2: undirected edge -> incident cells (at most two for a manifold tiling) var edgeMap = new Dictionary(cellCount * 4); for (int c = 0; c < cellCount; c++) { var ring = keptRings[c]; for (int i = 0; i < ring.Length; i++) { long k = EdgeKey(ring[i], ring[(i + 1) % ring.Length]); if (edgeMap.TryGetValue(k, out var e)) edgeMap[k] = (e.a, e.b < 0 ? c : e.b); // third incidence would be non-manifold; ignore else edgeMap[k] = (c, -1); } } // Centroids must exist before arcs, since Laplacian weights need // the distance between the two cells' centroids. var ctrs = new Vector2[cellCount]; var areas = new float[cellCount]; for (int c = 0; c < cellCount; c++) { var ring = keptRings[c]; var pts = new Vector2[ring.Length]; for (int i = 0; i < ring.Length; i++) pts[i] = weld.Point(ring[i]); ctrs[c] = PolygonSoup.Centroid(pts); areas[c] = Mathf.Abs(PolygonSoup.SignedArea(pts)); } // ---- pass 3: build directed dual arcs per cell var rng = new System.Random(seed); var vertPool = new List(cellCount * 5); var edgePool = new List(cellCount * 5); var cells = new CellStatic[cellCount]; var bMin = new Vector2(float.MaxValue, float.MaxValue); var bMax = new Vector2(float.MinValue, float.MinValue); for (int c = 0; c < cellCount; c++) { var ring = keptRings[c]; int n = ring.Length; int vStart = vertPool.Count; Vector2 ctr = ctrs[c]; for (int i = 0; i < n; i++) vertPool.Add(weld.Point(ring[i])); float radius = 0f; for (int i = 0; i < n; i++) { Vector2 p = vertPool[vStart + i]; radius = Mathf.Max(radius, (p - ctr).magnitude); bMin = Vector2.Min(bMin, p); bMax = Vector2.Max(bMax, p); } int eStart = edgePool.Count; for (int i = 0; i < n; i++) { int ia = ring[i], ib = ring[(i + 1) % n]; var pair = edgeMap[EdgeKey(ia, ib)]; int other = pair.a == c ? pair.b : pair.a; Vector2 pa = weld.Point(ia), pb = weld.Point(ib); Vector2 d = pb - pa; float len = d.magnitude; if (len < 1e-6f) continue; Vector2 mid = (pa + pb) * 0.5f; // R7 (operator, 2026-09-18): the emitters projecting the panel hold its // rim, so an unshared edge on the panel boundary is not a free surface — // it carries no ghost term and generates no reflected tension. A broken // cell's faces are a different case entirely: KWaveStep's per-edge `solid` // check still treats a broken neighbour as free, so the dicing cascade // (reflection off actual damage) is untouched by this. if (other < 0) continue; float nbrDist = Mathf.Max((ctrs[other] - ctr).magnitude, 1e-4f); edgePool.Add(new DualEdge { nbr = (uint)other, mid = mid, dir = d / len, len = len, w = len / nbrDist }); } float dist = (ctr - wavefrontOrigin).magnitude; cells[c] = new CellStatic { centroid = ctr, radius = radius, spawnDelay = dist / Mathf.Max(wavefrontSpeed, 1e-3f) + (float)rng.NextDouble() * delayJitter, vertStart = (uint)vStart, vertCount = (uint)n, nbrStart = (uint)eStart, nbrCount = (uint)(edgePool.Count - eStart), toughness = 0.8f + (float)rng.NextDouble() * 0.4f, tileType = (uint)Mathf.Max(0, keptTypes[c]), splitSeed = (uint)rng.Next(int.MinValue, int.MaxValue), area = Mathf.Max(areas[c], 1e-6f), pad0 = 0f, pad1 = 0f, pad2 = 0f }; } var data = new TilingData { verts = vertPool.ToArray(), cells = cells, dualEdges = edgePool.ToArray() }; Vector2 size = bMax - bMin; data.bounds = new Bounds((bMin + bMax) * 0.5f, new Vector3(size.x, size.y, 0.1f)); return data; } static long EdgeKey(int a, int b) => a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a; // ------------------------------------------------------------------ // Dual tiling: one dual cell per interior primal vertex, its corners // being the centroids of the primal cells around that vertex. // ------------------------------------------------------------------ public static PolygonSoup TakeDual(PolygonSoup soup, float weldEps = 1e-3f) { var weld = new VertexWelder(weldEps); var rings = new List(); var centroids = new List(); for (int c = 0; c < soup.polys.Count; c++) { var poly = soup.polys[c]; var ring = new int[poly.Length]; for (int i = 0; i < poly.Length; i++) ring[i] = weld.Get(poly[i]); rings.Add(ring); centroids.Add(PolygonSoup.Centroid(poly)); } // vertex -> incident cells, and vertex -> boundary flag var incident = new Dictionary>(); var degree = new Dictionary(); var edgeCount = new Dictionary(); for (int c = 0; c < rings.Count; c++) { var ring = rings[c]; for (int i = 0; i < ring.Length; i++) { if (!incident.TryGetValue(ring[i], out var lst)) incident[ring[i]] = lst = new List(); lst.Add(c); long k = EdgeKey(ring[i], ring[(i + 1) % ring.Length]); edgeCount[k] = edgeCount.TryGetValue(k, out int n) ? n + 1 : 1; } } // a vertex is interior only if every edge touching it is shared by two cells var onBoundary = new HashSet(); foreach (var kv in edgeCount) if (kv.Value < 2) { onBoundary.Add((int)(kv.Key >> 32)); onBoundary.Add((int)(kv.Key & 0xffffffff)); } var dual = new PolygonSoup(); foreach (var kv in incident) { if (onBoundary.Contains(kv.Key)) continue; var cellsAround = kv.Value; if (cellsAround.Count < 3) continue; Vector2 hub = weld.Point(kv.Key); var pts = new List(cellsAround.Count); foreach (int c in cellsAround) pts.Add(centroids[c]); pts.Sort((u, v) => Mathf.Atan2(u.y - hub.y, u.x - hub.x).CompareTo( Mathf.Atan2(v.y - hub.y, v.x - hub.x))); dual.Add(pts.ToArray(), cellsAround.Count); // type = vertex figure order } return dual; } // ------------------------------------------------------------------ // Merge triangle pairs into rhombs where the union is equilateral. // Turns a Penrose half-rhomb (Robinson triangle) soup into P3 rhombs. // ------------------------------------------------------------------ public static PolygonSoup MergeRhombs(PolygonSoup soup, float weldEps = 1e-3f, float tol = 0.04f) { var weld = new VertexWelder(weldEps); int n = soup.polys.Count; var rings = new int[n][]; for (int c = 0; c < n; c++) { var poly = soup.polys[c]; rings[c] = new int[poly.Length]; for (int i = 0; i < poly.Length; i++) rings[c][i] = weld.Get(poly[i]); } var edgeMap = new Dictionary(); for (int c = 0; c < n; c++) { if (rings[c].Length != 3) continue; for (int i = 0; i < 3; i++) { long k = EdgeKey(rings[c][i], rings[c][(i + 1) % 3]); edgeMap[k] = edgeMap.TryGetValue(k, out var e) ? (e.a, c) : (c, -1); } } var used = new bool[n]; var outSoup = new PolygonSoup(); foreach (var kv in edgeMap) { var (ca, cb) = kv.Value; if (cb < 0 || used[ca] || used[cb]) continue; if (rings[ca].Length != 3 || rings[cb].Length != 3) continue; if (soup.types[ca] != soup.types[cb]) continue; int p = (int)(kv.Key >> 32), q = (int)(kv.Key & 0xffffffff); int xa = Opposite(rings[ca], p, q), xb = Opposite(rings[cb], p, q); if (xa < 0 || xb < 0) continue; Vector2 P = weld.Point(p), Q = weld.Point(q), A = weld.Point(xa), B = weld.Point(xb); float e0 = (A - P).magnitude, e1 = (Q - A).magnitude, e2 = (B - Q).magnitude, e3 = (P - B).magnitude; float avg = 0.25f * (e0 + e1 + e2 + e3); if (avg < 1e-5f) continue; float dev = (Mathf.Abs(e0 - avg) + Mathf.Abs(e1 - avg) + Mathf.Abs(e2 - avg) + Mathf.Abs(e3 - avg)) / (4f * avg); if (dev > tol) continue; // not a rhombus, leave them alone outSoup.Add(new[] { P, A, Q, B }, soup.types[ca]); used[ca] = used[cb] = true; } for (int c = 0; c < n; c++) if (!used[c]) outSoup.Add((Vector2[])soup.polys[c].Clone(), soup.types[c]); return outSoup; } static int Opposite(int[] tri, int p, int q) { for (int i = 0; i < 3; i++) if (tri[i] != p && tri[i] != q) return tri[i]; return -1; } } /// Grid-bucketed vertex welder. O(1) lookup, tolerant to floating point drift. public class VertexWelder { readonly float eps, inv; readonly Dictionary<(int, int), List> buckets = new(); readonly List points = new(); public VertexWelder(float eps) { this.eps = eps; inv = 1f / eps; } public Vector2 Point(int i) => points[i]; public int Count => points.Count; public int Get(Vector2 p) { int gx = Mathf.FloorToInt(p.x * inv), gy = Mathf.FloorToInt(p.y * inv); float e2 = eps * eps; for (int dx = -1; dx <= 1; dx++) for (int dy = -1; dy <= 1; dy++) if (buckets.TryGetValue((gx + dx, gy + dy), out var lst)) foreach (int i in lst) if ((points[i] - p).sqrMagnitude <= e2) return i; int id = points.Count; points.Add(p); if (!buckets.TryGetValue((gx, gy), out var b)) buckets[(gx, gy)] = b = new List(); b.Add(id); return id; } } }