clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: cut fetched ground to the window

Dami committed 31f0bf5
A watershed that intersects a city's window may extend a hundred
windows past it, and the synthetic projection is linear in the
window's own terms: a vertex that far out lands on an absurd
longitude and wraps the viewer's world. National datasets are about
to arrive by window, so the window learns to cut rings and lines
down to itself -- Sutherland-Hodgman for ground, segment clipping
that splits a line at each crossing -- with introduced vertices
rounded like every captured coordinate, deterministically.

City data never passes through this: a hub publishes ground near its
own city, and trimming it would churn captures that are already
right.
commit
31f0bf53334dac0d7a09e5f93bc5c655b53569c6
parent
22c207edf1e865d56af71a960377e4e54b2eade4
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.0 of 3, Jev's estimate
2 files changed, +347 -0
Ainternal/arcgismap/clip.go203++++++++++++++++++++++++++++++
Ainternal/arcgismap/clip_test.go144+++++++++++++++++++++
A internal/arcgismap/clip.go +203 -0
@@ -0,0 +1,203 @@
1+package arcgismap2+3+// Clipping for national datasets. A watershed intersecting a city's window4+// may extend a hundred windows past it, and the synthetic projection is5+// linear in the window's own terms: a vertex far outside would land on an6+// absurd longitude and wrap the viewer's world. City data never needs this7+// -- a hub publishes ground near its own city -- so only features fetched by8+// window are cut down to it, at capture time, where the trimmed shape rides9+// the content addressing like any other fact about the day.10+//11+// The window rectangle is cut in plain degrees. Its edges are lines of12+// constant longitude and latitude, and both the Mercator forward and the13+// synthetic spelling are monotone per axis, so the degree rectangle and the14+// projected square bound exactly the same ground.15+16+// ClipRings cuts MultiPolygon-nested rings to the window, ring by ring with17+// Sutherland–Hodgman. A polygon whose outer ring is cut to nothing is18+// dropped whole; a hole cut to nothing drops alone. Introduced vertices are19+// rounded like every captured coordinate, and output rings close on their20+// first position.21+func ClipRings(w Window, polygons [][][][]float64) [][][][]float64 {22+ var out [][][][]float6423+ for _, polygon := range polygons {24+ var kept [][][]float6425+ for at, ring := range polygon {26+ clipped := clipRing(w, ring)27+ if clipped == nil {28+ if at == 0 {29+ kept = nil30+ break31+ }32+ continue33+ }34+ kept = append(kept, clipped)35+ }36+ if len(kept) > 0 {37+ out = append(out, kept)38+ }39+ }40+ return out41+}42+43+// ClipLines cuts MultiLineString-nested lines to the window. A line that44+// leaves and returns splits into one part per crossing, and parts too short45+// to draw are dropped.46+func ClipLines(w Window, lines [][][]float64) [][][]float64 {47+ var out [][][]float6448+ var part [][]float6449+ flush := func() {50+ if len(part) >= 2 {51+ out = append(out, part)52+ }53+ part = nil54+ }55+ for _, line := range lines {56+ for at := 0; at+1 < len(line); at++ {57+ a, b := line[at], line[at+1]58+ if len(a) < 2 || len(b) < 2 {59+ flush()60+ continue61+ }62+ enter, exit, held := clipSegment(w, a[0], a[1], b[0], b[1])63+ if !held {64+ flush()65+ continue66+ }67+ if len(part) == 0 || part[len(part)-1][0] != enter[0] || part[len(part)-1][1] != enter[1] {68+ flush()69+ part = [][]float64{enter}70+ }71+ part = append(part, exit)72+ if exit[0] != Round7(b[0]) || exit[1] != Round7(b[1]) {73+ flush()74+ }75+ }76+ flush()77+ }78+ return out79+}80+81+// edge is one of the window's four half-planes.82+type edge struct {83+ axis int // 0 clips longitude, 1 latitude84+ bound float6485+ keep float64 // +1 keeps values >= bound, -1 keeps values <= bound86+}87+88+func (w Window) edges() [4]edge {89+ return [4]edge{90+ {0, w.West, 1},91+ {0, w.East, -1},92+ {1, w.South, 1},93+ {1, w.North, -1},94+ }95+}96+97+func (e edge) inside(p []float64) bool {98+ return (p[e.axis]-e.bound)*e.keep >= 099+}100+101+// cross lands on the edge along the segment from a to b, rounded the way102+// every captured coordinate is.103+func (e edge) cross(a, b []float64) []float64 {104+ t := (e.bound - a[e.axis]) / (b[e.axis] - a[e.axis])105+ p := []float64{106+ Round7(a[0] + t*(b[0]-a[0])),107+ Round7(a[1] + t*(b[1]-a[1])),108+ }109+ p[e.axis] = e.bound110+ return p111+}112+113+// clipRing is Sutherland–Hodgman against the window's four edges. The ring114+// arrives closed and leaves closed; nil means nothing of it remains.115+func clipRing(w Window, ring [][]float64) [][]float64 {116+ open := ring117+ if len(open) > 1 && open[0][0] == open[len(open)-1][0] && open[0][1] == open[len(open)-1][1] {118+ open = open[:len(open)-1]119+ }120+ current := make([][]float64, 0, len(open))121+ for _, p := range open {122+ if len(p) >= 2 {123+ current = append(current, p)124+ }125+ }126+ for _, e := range w.edges() {127+ if len(current) == 0 {128+ return nil129+ }130+ next := make([][]float64, 0, len(current)+4)131+ for at, point := range current {132+ previous := current[(at+len(current)-1)%len(current)]133+ switch {134+ case e.inside(point) && e.inside(previous):135+ next = append(next, point)136+ case e.inside(point):137+ next = append(next, e.cross(previous, point), point)138+ case e.inside(previous):139+ next = append(next, e.cross(previous, point))140+ }141+ }142+ current = dedupe(next)143+ }144+ if len(current) < 3 {145+ return nil146+ }147+ return append(current, current[0])148+}149+150+// dedupe drops consecutive repeats, which clipping at a corner introduces151+// and rounding can too.152+func dedupe(points [][]float64) [][]float64 {153+ out := points[:0]154+ for _, p := range points {155+ if len(out) > 0 && out[len(out)-1][0] == p[0] && out[len(out)-1][1] == p[1] {156+ continue157+ }158+ out = append(out, p)159+ }160+ if len(out) > 1 && out[0][0] == out[len(out)-1][0] && out[0][1] == out[len(out)-1][1] {161+ out = out[:len(out)-1]162+ }163+ return out164+}165+166+// clipSegment cuts one segment to the window, answering the kept piece's two167+// ends rounded, or nothing when the segment misses the window entirely.168+func clipSegment(w Window, ax, ay, bx, by float64) (enter, exit []float64, held bool) {169+ t0, t1 := 0.0, 1.0170+ clip := func(delta, room float64) bool {171+ if delta == 0 {172+ return room >= 0173+ }174+ t := room / delta175+ if delta < 0 {176+ if t > t1 {177+ return false178+ }179+ if t > t0 {180+ t0 = t181+ }182+ return true183+ }184+ if t < t0 {185+ return false186+ }187+ if t < t1 {188+ t1 = t189+ }190+ return true191+ }192+ dx, dy := bx-ax, by-ay193+ if !clip(-dx, ax-w.West) || !clip(dx, w.East-ax) ||194+ !clip(-dy, ay-w.South) || !clip(dy, w.North-ay) {195+ return nil, nil, false196+ }197+ enter = []float64{Round7(ax + t0*dx), Round7(ay + t0*dy)}198+ exit = []float64{Round7(ax + t1*dx), Round7(ay + t1*dy)}199+ if enter[0] == exit[0] && enter[1] == exit[1] {200+ return nil, nil, false201+ }202+ return enter, exit, true203+}
A internal/arcgismap/clip_test.go +144 -0
@@ -0,0 +1,144 @@
1+package arcgismap2+3+import (4+ "encoding/json"5+ "reflect"6+ "testing"7+)8+9+// clipWindow is a plain unit-square window: edges on whole degrees, so every10+// expected crossing is exact.11+var clipWindow = Window{West: 0, South: 0, East: 1, North: 1}12+13+func ring(positions ...[2]float64) [][]float64 {14+ out := make([][]float64, 0, len(positions)+1)15+ for _, p := range positions {16+ out = append(out, []float64{p[0], p[1]})17+ }18+ return append(out, []float64{positions[0][0], positions[0][1]})19+}20+21+func TestClipRingsKeepsAnInsideRing(t *testing.T) {22+ inside := [][][][]float64{{ring(23+ [2]float64{0.2, 0.2}, [2]float64{0.8, 0.2}, [2]float64{0.8, 0.8}, [2]float64{0.2, 0.8},24+ )}}25+ got := ClipRings(clipWindow, inside)26+ if !reflect.DeepEqual(got, inside) {27+ t.Fatalf("an inside ring changed: %v", got)28+ }29+}30+31+func TestClipRingsCutsACrossingRing(t *testing.T) {32+ crossing := [][][][]float64{{ring(33+ [2]float64{0.5, 0.5}, [2]float64{1.5, 0.5}, [2]float64{1.5, 0.7}, [2]float64{0.5, 0.7},34+ )}}35+ got := ClipRings(clipWindow, crossing)36+ if len(got) != 1 || len(got[0]) != 1 {37+ t.Fatalf("crossing ring clipped to %v", got)38+ }39+ want := ring([2]float64{0.5, 0.5}, [2]float64{1, 0.5}, [2]float64{1, 0.7}, [2]float64{0.5, 0.7})40+ if !reflect.DeepEqual(got[0][0], want) {41+ t.Fatalf("crossing ring clipped to %v, want %v", got[0][0], want)42+ }43+}44+45+func TestClipRingsDropsAnOutsideRing(t *testing.T) {46+ outside := [][][][]float64{{ring(47+ [2]float64{2, 2}, [2]float64{3, 2}, [2]float64{3, 3}, [2]float64{2, 3},48+ )}}49+ if got := ClipRings(clipWindow, outside); got != nil {50+ t.Fatalf("an outside ring survived: %v", got)51+ }52+}53+54+func TestClipRingsKeepsAHole(t *testing.T) {55+ holed := [][][][]float64{{56+ ring([2]float64{0.1, 0.1}, [2]float64{0.9, 0.1}, [2]float64{0.9, 0.9}, [2]float64{0.1, 0.9}),57+ ring([2]float64{0.4, 0.4}, [2]float64{0.6, 0.4}, [2]float64{0.6, 0.6}, [2]float64{0.4, 0.6}),58+ }}59+ got := ClipRings(clipWindow, holed)60+ if len(got) != 1 || len(got[0]) != 2 {61+ t.Fatalf("the hole did not survive: %v", got)62+ }63+}64+65+func TestClipRingsDropsAPolygonWhoseGroundIsGone(t *testing.T) {66+ // The outer ring misses the window even though its hole would clip.67+ gone := [][][][]float64{{68+ ring([2]float64{2, 2}, [2]float64{3, 2}, [2]float64{3, 3}, [2]float64{2, 3}),69+ ring([2]float64{0.4, 0.4}, [2]float64{0.6, 0.4}, [2]float64{0.6, 0.6}, [2]float64{0.4, 0.6}),70+ }}71+ if got := ClipRings(clipWindow, gone); got != nil {72+ t.Fatalf("a polygon without ground survived: %v", got)73+ }74+}75+76+func TestClipRingsCoversTheWindowCorner(t *testing.T) {77+ // A huge ring swallowing the whole window clips to the window itself.78+ swallowing := [][][][]float64{{ring(79+ [2]float64{-5, -5}, [2]float64{5, -5}, [2]float64{5, 5}, [2]float64{-5, 5},80+ )}}81+ got := ClipRings(clipWindow, swallowing)82+ if len(got) != 1 || len(got[0]) != 1 || len(got[0][0]) != 5 {83+ t.Fatalf("the window itself should remain, got %v", got)84+ }85+}86+87+func TestClipLinesSplitsAtTheWindow(t *testing.T) {88+ // In at the west, out at the east, back in through the north: two parts.89+ zigzag := [][][]float64{{90+ {-0.5, 0.5}, {0.5, 0.5}, {1.5, 0.5}, {1.5, 2}, {0.5, 2}, {0.5, 0.9},91+ }}92+ got := ClipLines(clipWindow, zigzag)93+ want := [][][]float64{94+ {{0, 0.5}, {0.5, 0.5}, {1, 0.5}},95+ {{0.5, 1}, {0.5, 0.9}},96+ }97+ if !reflect.DeepEqual(got, want) {98+ t.Fatalf("zigzag clipped to %v, want %v", got, want)99+ }100+}101+102+func TestClipLinesKeepsAnInsideLine(t *testing.T) {103+ inside := [][][]float64{{{0.1, 0.1}, {0.5, 0.5}, {0.9, 0.2}}}104+ got := ClipLines(clipWindow, inside)105+ if !reflect.DeepEqual(got, inside) {106+ t.Fatalf("an inside line changed: %v", got)107+ }108+}109+110+func TestClipLinesDropsAnOutsideLine(t *testing.T) {111+ outside := [][][]float64{{{2, 2}, {3, 3}}}112+ if got := ClipLines(clipWindow, outside); got != nil {113+ t.Fatalf("an outside line survived: %v", got)114+ }115+}116+117+// Clipping the same shape twice spells the same bytes, which is what capture118+// determinism asks of every transformation.119+func TestClipIsDeterministic(t *testing.T) {120+ shape := [][][][]float64{{ring(121+ [2]float64{0.123456789, -0.4}, [2]float64{1.7, 0.3}, [2]float64{0.6, 1.9},122+ )}}123+ first, err := json.Marshal(ClipRings(clipWindow, shape))124+ if err != nil {125+ t.Fatal(err)126+ }127+ second, err := json.Marshal(ClipRings(clipWindow, [][][][]float64{{ring(128+ [2]float64{0.123456789, -0.4}, [2]float64{1.7, 0.3}, [2]float64{0.6, 1.9},129+ )}}))130+ if err != nil {131+ t.Fatal(err)132+ }133+ if string(first) != string(second) {134+ t.Fatalf("clipping is not deterministic: %s vs %s", first, second)135+ }136+ again := ClipRings(clipWindow, ClipRings(clipWindow, shape))137+ raw, err := json.Marshal(again)138+ if err != nil {139+ t.Fatal(err)140+ }141+ if string(raw) != string(first) {142+ t.Fatalf("clipping is not idempotent: %s vs %s", raw, first)143+ }144+}