From 9b0201b4430399908a4d8ecbd4950748e9f532d0 Mon Sep 17 00:00:00 2001 From: Ben Guild Date: Fri, 12 Dec 2025 16:36:34 +0900 Subject: [PATCH 1/2] "Discontiguous()" method for `CellUnion` --- s2/cellunion.go | 147 +++++++++++++++++++++++ s2/cellunion_test.go | 278 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 425 insertions(+) diff --git a/s2/cellunion.go b/s2/cellunion.go index 36993c9..b34900f 100644 --- a/s2/cellunion.go +++ b/s2/cellunion.go @@ -15,6 +15,7 @@ package s2 import ( + "cmp" "fmt" "io" "slices" @@ -292,6 +293,152 @@ func (cu *CellUnion) Denormalize(minLevel, levelMod int) { *cu = denorm } +// Discontiguous splits this CellUnion into its discontiguous (non-touching) +// components. Each returned CellUnion represents a connected region where cells +// share edges. The input CellUnion should be normalized. Results are sorted by +// the first CellID in each component for deterministic output. +func (cu CellUnion) Discontiguous() []CellUnion { + if len(cu) == 0 { + return nil + } + + n := len(cu) + lookup := make(map[CellID]int, n) + minLevel := MaxLevel + for i, id := range cu { + lookup[id] = i + if id.Level() < minLevel { + minLevel = id.Level() + } + } + + parent := make([]int, n) + rank := make([]int, n) + for i := range parent { + parent[i] = i + } + + for i, id := range cu { + for _, neighbor := range id.EdgeNeighbors() { + var found bool + var j int + + if v, ok := lookup[neighbor]; ok { + j, found = v, true + } + + if !found { + // Check ancestors (neighbor might be contained by a cell in union). + for level := neighbor.Level() - 1; level >= minLevel; level-- { + if v, ok := lookup[neighbor.Parent(level)]; ok { + j, found = v, true + break + } + } + } + + if !found { + // Check descendants using search. Since CellUnion is normalized/sorted, + // cells in neighbor's range are contiguous. + rangeMin := neighbor.RangeMin() + lo := sort.Search(n, func(k int) bool { + return cu[k] >= rangeMin + }) + + rangeMax := neighbor.RangeMax() + for idx := lo; idx < n && cu[idx] <= rangeMax; idx++ { + descendant := cu[idx] + for _, neighbor := range descendant.EdgeNeighbors() { + if id.Contains(neighbor) { + j, found = idx, true + break + } + + for level := neighbor.Level() - 1; level >= id.Level(); level-- { + if neighbor.Parent(level) == id { + j, found = idx, true + break + } + } + + if found { + break + } + } + if found { + break + } + } + } + + if !found { + continue + } + + rootA, rootB := i, j + for parent[rootA] != rootA { + rootA = parent[rootA] + } + for parent[rootB] != rootB { + rootB = parent[rootB] + } + if rootA == rootB { + continue + } + + // Merge smaller tree under larger to keep depth balanced. + if rank[rootA] < rank[rootB] { + rootA, rootB = rootB, rootA + } + parent[rootB] = rootA + if rank[rootA] == rank[rootB] { + rank[rootA]++ + } + + // Path compression: point traversed nodes directly to final root. + for x := i; x != rootA; { + next := parent[x] + parent[x] = rootA + x = next + } + for x := j; x != rootA; { + next := parent[x] + parent[x] = rootA + x = next + } + } + } + + groups := make(map[int][]CellID) + for i, id := range cu { + root := i + for parent[root] != root { + root = parent[root] + } + + // Path compression: point traversed nodes directly to root. + for x := i; x != root; { + next := parent[x] + parent[x] = root + x = next + } + + groups[root] = append(groups[root], id) + } + + result := make([]CellUnion, 0, len(groups)) + for _, cells := range groups { + result = append(result, CellUnion(cells)) + } + + // Sort by first CellID in each component for deterministic output. + slices.SortFunc(result, func(a, b CellUnion) int { + return cmp.Compare(a[0], b[0]) + }) + + return result +} + // RectBound returns a Rect that bounds this entity. func (cu *CellUnion) RectBound() Rect { bound := EmptyRect() diff --git a/s2/cellunion_test.go b/s2/cellunion_test.go index e0b1e91..825c635 100644 --- a/s2/cellunion_test.go +++ b/s2/cellunion_test.go @@ -1059,6 +1059,284 @@ func TestCellUnionEmpty(t *testing.T) { } } +func TestCellUnionDiscontiguous(t *testing.T) { + tests := []struct { + name string + input CellUnion + expected int + }{ + { + name: "empty", + input: CellUnion{}, + expected: 0, + }, + { + name: "single cell", + input: CellUnion{CellIDFromFace(0).ChildBeginAtLevel(10)}, + expected: 1, + }, + { + name: "two adjacent cells", + input: func() CellUnion { + cell := CellIDFromFace(0).ChildBeginAtLevel(10) + neighbors := cell.EdgeNeighbors() + cu := CellUnion{cell, neighbors[0]} + cu.Normalize() + return cu + }(), + expected: 1, + }, + { + name: "two separate cells on different faces", + input: CellUnion{ + CellIDFromFace(0).ChildBeginAtLevel(10), + CellIDFromFace(3).ChildBeginAtLevel(10), + }, + expected: 2, + }, + { + name: "three cells in two clusters", + input: func() CellUnion { + cellA := CellIDFromFace(0).ChildBeginAtLevel(10) + neighborsA := cellA.EdgeNeighbors() + cellB := CellIDFromFace(3).ChildBeginAtLevel(10) + cu := CellUnion{cellA, neighborsA[0], cellB} + cu.Normalize() + return cu + }(), + expected: 2, + }, + { + name: "chain of four adjacent cells", + input: func() CellUnion { + cell := CellIDFromFace(0).ChildBeginAtLevel(10) + neighbors := cell.EdgeNeighbors() + neighborB := neighbors[0].EdgeNeighbors() + neighborC := neighborB[0].EdgeNeighbors() + cu := CellUnion{cell, neighbors[0], neighborB[0], neighborC[0]} + cu.Normalize() + return cu + }(), + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.input.Discontiguous() + if len(result) != tt.expected { + t.Errorf( + "Discontiguous() returned %d clusters, want %d", + len(result), + tt.expected, + ) + } + + totalCells := 0 + for _, cluster := range result { + totalCells += len(cluster) + } + if totalCells != len(tt.input) { + t.Errorf( + "Discontiguous() returned %d total cells, want %d", + totalCells, + len(tt.input), + ) + } + }) + } + + t.Run("mixed levels", func(t *testing.T) { + // Test adjacency detection between cells at different levels. + largeCell := CellIDFromFace(0).ChildBeginAtLevel(5) + adjacentLarge := largeCell.EdgeNeighbors()[0] + smallCell := adjacentLarge.ChildBeginAtLevel(10) + + cu := CellUnion{largeCell, smallCell} + cu.Normalize() + + result := cu.Discontiguous() + if len(result) != 1 { + t.Errorf("got %d clusters, want 1 (mixed-level adjacency)", len(result)) + } + }) + + t.Run("verify membership", func(t *testing.T) { + // Verify that specific cells end up in the correct clusters. + cellA := CellIDFromFace(0).ChildBeginAtLevel(10) + neighborA := cellA.EdgeNeighbors()[0] + cellB := CellIDFromFace(3).ChildBeginAtLevel(10) + neighborB := cellB.EdgeNeighbors()[0] + + cu := CellUnion{cellA, neighborA, cellB, neighborB} + cu.Normalize() + + result := cu.Discontiguous() + if len(result) != 2 { + t.Fatalf("got %d clusters, want 2", len(result)) + } + + clusterOf := make(map[CellID]int) + for i, cluster := range result { + for _, id := range cluster { + clusterOf[id] = i + } + } + + if clusterOf[cellA] != clusterOf[neighborA] { + t.Errorf("cellA and neighborA in different clusters") + } + if clusterOf[cellB] != clusterOf[neighborB] { + t.Errorf("cellB and neighborB in different clusters") + } + if clusterOf[cellA] == clusterOf[cellB] { + t.Errorf("cellA and cellB in same cluster, want different") + } + }) + + t.Run("corner adjacent", func(t *testing.T) { + // Corner-adjacent cells share only a vertex, not an edge. + cell := CellIDFromFace(0).ChildBeginAtLevel(10) + cornerNeighbors := cell.VertexNeighbors(cell.Level()) + + edgeNeighbors := cell.EdgeNeighbors() + edgeSet := make(map[CellID]bool) + for _, en := range edgeNeighbors { + edgeSet[en] = true + } + + var cornerOnly CellID + found := false + for _, cn := range cornerNeighbors { + if cn != cell && !edgeSet[cn] { + cornerOnly = cn + found = true + break + } + } + if !found { + t.Fatal("could not find a corner-only neighbor") + } + + cu := CellUnion{cell, cornerOnly} + cu.Normalize() + + result := cu.Discontiguous() + if len(result) != 2 { + t.Errorf("got %d clusters, want 2 (corner-adjacent should be separate)", len(result)) + } + }) + + t.Run("multi-face contiguous", func(t *testing.T) { + // Test a contiguous region spanning multiple S2 cube faces. + cell := CellIDFromFace(0).ChildBeginAtLevel(2) + neighbors := cell.AllNeighbors(2) + + cu := CellUnion{cell} + cu = append(cu, neighbors...) + cu.Normalize() + + faces := make(map[int]bool) + for _, id := range cu { + faces[id.Face()] = true + } + if len(faces) < 2 { + t.Fatalf("test setup error: only %d face(s)", len(faces)) + } + + result := cu.Discontiguous() + if len(result) != 1 { + t.Errorf("got %d clusters, want 1 (multi-face contiguous)", len(result)) + } + if len(result) == 1 && len(result[0]) != len(cu) { + t.Errorf("cluster has %d cells, want %d", len(result[0]), len(cu)) + } + }) + + t.Run("cell inside hole", func(t *testing.T) { + // Create a ring with a cell inside the hole. + // The cell inside the hole should be detected as a separate component. + center := CellIDFromFace(0).ChildBeginAtLevel(5) + ring := center.AllNeighbors(5) + + // The center cell is NOT in the ring - it's the "hole". + // Add a cell inside the hole area. + // IMPORTANT: Use the geometric center of the cell to get a cell truly + // in the interior, NOT ChildBeginAtLevel which returns a corner cell + // whose edge neighbors may have parents in the ring. + innerCell := CellIDFromLatLng(center.LatLng()).Parent(9) + + cu := CellUnion(ring) + cu = append(cu, innerCell) + cu.Normalize() + + result := cu.Discontiguous() + if len(result) != 2 { + t.Errorf( + "got %d clusters, want 2 (ring + inner cell should be separate)", + len(result), + ) + } + }) + + t.Run("nested rings with cells inside innermost hole", func(t *testing.T) { + // Create two nested rings with a cell in the innermost hole. + // Use level 2 for outer ring and level 7 for inner ring to ensure + // the inner ring is completely inside the hole and doesn't touch + // the outer ring through ancestors. + // Should detect 3 components: outer ring, inner ring, inner cell. + outerCenter := CellIDFromFace(0).ChildBeginAtLevel(2) + outerRing := outerCenter.AllNeighbors(2) + + // Use the geometric center of the outer center to get a truly interior + // cell for the inner ring center at a much finer level. + innerCenter := CellIDFromLatLng(outerCenter.LatLng()).Parent(7) + innerRing := innerCenter.AllNeighbors(7) + + // Use the geometric center for the innermost cell. + innermostCell := CellIDFromLatLng(innerCenter.LatLng()).Parent(11) + + cu := CellUnion(outerRing) + cu = append(cu, innerRing...) + cu = append(cu, innermostCell) + cu.Normalize() + + result := cu.Discontiguous() + if len(result) != 3 { + t.Errorf( + "got %d clusters, want 3 (outer ring + inner ring + innermost cell)", + len(result), + ) + } + }) + + t.Run("multiple separate cells inside hole", func(t *testing.T) { + // Create a ring with multiple disconnected cells inside the hole. + // Each inner cell should be its own component. + center := CellIDFromFace(0).ChildBeginAtLevel(4) + ring := center.AllNeighbors(4) + + // Add two separate cells inside the hole that are far from each other. + // Use the geometric centers of opposite quadrants of the center cell. + quadrantA := center.Children()[0] + quadrantB := center.Children()[3] + innerCellA := CellIDFromLatLng(quadrantA.LatLng()).Parent(10) + innerCellB := CellIDFromLatLng(quadrantB.LatLng()).Parent(10) + + cu := CellUnion(ring) + cu = append(cu, innerCellA, innerCellB) + cu.Normalize() + + result := cu.Discontiguous() + if len(result) != 3 { + t.Errorf( + "got %d clusters, want 3 (ring + 2 separate inner cells)", + len(result), + ) + } + }) +} + func BenchmarkCellUnionFromRange(b *testing.B) { x := CellIDFromFace(0).ChildBeginAtLevel(MaxLevel) y := CellIDFromFace(5).ChildEndAtLevel(MaxLevel) From f5c61abe6c38d2beef4c2e29e17292f8438843ee Mon Sep 17 00:00:00 2001 From: Ben Guild Date: Mon, 15 Dec 2025 14:23:01 +0900 Subject: [PATCH 2/2] Intersecting --- s2/cellunion.go | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/s2/cellunion.go b/s2/cellunion.go index b34900f..9efa800 100644 --- a/s2/cellunion.go +++ b/s2/cellunion.go @@ -348,22 +348,11 @@ func (cu CellUnion) Discontiguous() []CellUnion { rangeMax := neighbor.RangeMax() for idx := lo; idx < n && cu[idx] <= rangeMax; idx++ { descendant := cu[idx] - for _, neighbor := range descendant.EdgeNeighbors() { - if id.Contains(neighbor) { + for _, edgeNeighbor := range descendant.EdgeNeighbors() { + if id.Intersects(edgeNeighbor) { j, found = idx, true break } - - for level := neighbor.Level() - 1; level >= id.Level(); level-- { - if neighbor.Parent(level) == id { - j, found = idx, true - break - } - } - - if found { - break - } } if found { break