From 35df39412d7321fefaf9298ce61937198ad1c092 Mon Sep 17 00:00:00 2001 From: Roman Atachiants Date: Fri, 8 Nov 2024 18:05:58 +0400 Subject: [PATCH] Fix move view subscribers (#11) * Fix move view subscribers * 123 --- .github/workflows/test.yml | 2 +- go.mod | 6 ++-- go.sum | 15 +++----- grid_test.go | 46 +++++++++++++----------- point.go | 74 +++++++++++++++++++++++++++++++++----- point_test.go | 71 ++++++++++++++++++++++++++++++++---- view.go | 59 ++++++++++++++++++------------ view_test.go | 58 ++++++++++++++++++++++++------ 8 files changed, 247 insertions(+), 84 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd9e2f4..f291423 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v1 with: - go-version: 1.19 + go-version: 1.23 - name: Check out code uses: actions/checkout@v2 - name: Install dependencies diff --git a/go.mod b/go.mod index 9813b81..f51b31e 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module github.com/kelindar/tile -go 1.19 +go 1.23 require ( - github.com/kelindar/iostream v1.3.0 - github.com/stretchr/testify v1.8.1 + github.com/kelindar/iostream v1.4.0 + github.com/stretchr/testify v1.9.0 ) require ( diff --git a/go.sum b/go.sum index 040afb3..9a32195 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,12 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/kelindar/iostream v1.3.0 h1:Bz2qQabipZlF1XCk64bnxsGLete+iHtayGPeWVpbwbo= -github.com/kelindar/iostream v1.3.0/go.mod h1:MkjMuVb6zGdPQVdwLnFRO0xOTOdDvBWTztFmjRDQkXk= +github.com/kelindar/iostream v1.4.0 h1:ELKlinnM/K3GbRp9pYhWuZOyBxMMlYAfsOP+gauvZaY= +github.com/kelindar/iostream v1.4.0/go.mod h1:MkjMuVb6zGdPQVdwLnFRO0xOTOdDvBWTztFmjRDQkXk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grid_test.go b/grid_test.go index 6250bc0..0639342 100644 --- a/grid_test.go +++ b/grid_test.go @@ -14,13 +14,14 @@ import ( ) /* -cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz -BenchmarkGrid/each-8 862 1365740 ns/op 0 B/op 0 allocs/op -BenchmarkGrid/neighbors-8 66562384 17.94 ns/op 0 B/op 0 allocs/op -BenchmarkGrid/within-8 30012 40112 ns/op 0 B/op 0 allocs/op -BenchmarkGrid/at-8 396362580 3.025 ns/op 0 B/op 0 allocs/op -BenchmarkGrid/write-8 127712601 9.256 ns/op 0 B/op 0 allocs/op -BenchmarkGrid/merge-8 125377372 9.410 ns/op 0 B/op 0 allocs/op +cpu: 13th Gen Intel(R) Core(TM) i7-13700K +BenchmarkGrid/each-24 1452 830268 ns/op 0 B/op 0 allocs/op +BenchmarkGrid/neighbors-24 121583491 9.861 ns/op 0 B/op 0 allocs/op +BenchmarkGrid/within-24 49360 24477 ns/op 0 B/op 0 allocs/op +BenchmarkGrid/at-24 687659378 1.741 ns/op 0 B/op 0 allocs/op +BenchmarkGrid/write-24 191272338 6.307 ns/op 0 B/op 0 allocs/op +BenchmarkGrid/merge-24 162536985 7.332 ns/op 0 B/op 0 allocs/op +BenchmarkGrid/mask-24 158258084 7.601 ns/op 0 B/op 0 allocs/op */ func BenchmarkGrid(b *testing.B) { var d Tile[uint32] @@ -99,10 +100,10 @@ func BenchmarkGrid(b *testing.B) { } /* -cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz -BenchmarkState/range-8 11211600 103.4 ns/op 0 B/op 0 allocs/op -BenchmarkState/add-8 41380593 29.00 ns/op 0 B/op 0 allocs/op -BenchmarkState/del-8 54474884 21.79 ns/op 0 B/op 0 allocs/op +cpu: 13th Gen Intel(R) Core(TM) i7-13700K +BenchmarkState/range-24 17017800 71.14 ns/op 0 B/op 0 allocs/op +BenchmarkState/add-24 72639224 16.32 ns/op 0 B/op 0 allocs/op +BenchmarkState/del-24 82469125 13.65 ns/op 0 B/op 0 allocs/op */ func BenchmarkState(b *testing.B) { m := NewGridOf[int](768, 768) @@ -155,13 +156,12 @@ func TestWithin(t *testing.T) { m.Within(At(1, 1), At(5, 5), func(p Point, tile Tile[string]) { path = append(path, p.String()) }) - assert.Equal(t, 25, len(path)) + assert.Equal(t, 16, len(path)) assert.ElementsMatch(t, []string{ - "1,1", "2,1", "1,2", "2,2", "3,1", - "4,1", "5,1", "3,2", "4,2", "5,2", - "1,3", "2,3", "1,4", "2,4", "1,5", - "2,5", "3,3", "4,3", "5,3", "3,4", - "4,4", "5,4", "3,5", "4,5", "5,5", + "1,1", "2,1", "1,2", "2,2", + "3,1", "4,1", "3,2", "4,2", + "1,3", "2,3", "1,4", "2,4", + "3,3", "4,3", "3,4", "4,4", }, path) } @@ -179,6 +179,10 @@ func TestWithinCorner(t *testing.T) { }, path) } +func TestWithinXY(t *testing.T) { + assert.False(t, At(4, 8).WithinRect(NewRect(1, 6, 4, 10))) +} + func TestWithinOneSide(t *testing.T) { m := NewGrid(9, 9) @@ -186,11 +190,11 @@ func TestWithinOneSide(t *testing.T) { m.Within(At(1, 6), At(4, 10), func(p Point, tile Tile[string]) { path = append(path, p.String()) }) - assert.Equal(t, 12, len(path)) + assert.Equal(t, 9, len(path)) assert.ElementsMatch(t, []string{ - "1,6", "2,6", "3,6", "4,6", - "1,7", "2,7", "3,7", "4,7", - "1,8", "2,8", "3,8", "4,8", + "1,6", "2,6", "3,6", + "1,7", "2,7", "3,7", + "1,8", "2,8", "3,8", }, path) } diff --git a/point.go b/point.go index ba22b3e..a4c85b6 100644 --- a/point.go +++ b/point.go @@ -73,12 +73,12 @@ func (p Point) DivideScalar(s int16) Point { // Within checks if the point is within the specified bounding box. func (p Point) Within(nw, se Point) bool { - return p.X >= nw.X && p.Y >= nw.Y && p.X <= se.X && p.Y <= se.Y + return Rect{Min: nw, Max: se}.Contains(p) } // WithinRect checks if the point is within the specified bounding box. func (p Point) WithinRect(box Rect) bool { - return p.X >= box.Min.X && p.Y >= box.Min.Y && p.X <= box.Max.X && p.Y <= box.Max.Y + return box.Contains(p) } // WithinSize checks if the point is within the specified bounding box @@ -143,23 +143,74 @@ func NewRect(left, top, right, bottom int16) Rect { } // Contains returns whether a point is within the rectangle or not. -func (r *Rect) Contains(p Point) bool { - return p.X >= r.Min.X && p.Y >= r.Min.Y && p.X <= r.Max.X && p.Y <= r.Max.Y +func (a Rect) Contains(p Point) bool { + return a.Min.X <= p.X && p.X < a.Max.X && a.Min.Y <= p.Y && p.Y < a.Max.Y } // Intersects returns whether a rectangle intersects with another rectangle or not. -func (r *Rect) Intersects(box Rect) bool { - return !(box.Max.X < r.Min.X || box.Min.X > r.Max.X || box.Max.Y < r.Min.Y || box.Min.Y > r.Max.Y) +func (a Rect) Intersects(b Rect) bool { + return b.Min.X < a.Max.X && a.Min.X < b.Max.X && b.Min.Y < a.Max.Y && a.Min.Y < b.Max.Y } // Size returns the size of the rectangle -func (r *Rect) Size() Point { +func (a *Rect) Size() Point { return Point{ - X: r.Max.X - r.Min.X, - Y: r.Max.Y - r.Min.Y, + X: a.Max.X - a.Min.X, + Y: a.Max.Y - a.Min.Y, } } +// IsZero returns true if the rectangle is zero-value +func (a Rect) IsZero() bool { + return a.Min.X == a.Max.X && a.Min.Y == a.Max.Y +} + +// Difference calculates up to four non-overlapping regions in a that are not covered by b. +// If there are fewer than four distinct regions, the remaining Rects will be zero-value. +func (a Rect) Difference(b Rect) (result [4]Rect) { + if b.Contains(a.Min) && b.Contains(a.Max) { + return // Fully covered, return zero-value result + } + + // Check for non-overlapping cases + if !a.Intersects(b) { + result[0] = a // No overlap, return A as is + return + } + + left := min(a.Min.X, b.Min.X) + right := max(a.Max.X, b.Max.X) + top := min(a.Min.Y, b.Min.Y) + bottom := max(a.Max.Y, b.Max.Y) + + result[0].Min = Point{X: left, Y: top} + result[0].Max = Point{X: right, Y: max(a.Min.Y, b.Min.Y)} + + result[1].Min = Point{X: left, Y: min(a.Max.Y, b.Max.Y)} + result[1].Max = Point{X: right, Y: bottom} + + result[2].Min = Point{X: left, Y: top} + result[2].Max = Point{X: max(a.Min.X, b.Min.X), Y: bottom} + + result[3].Min = Point{X: min(a.Max.X, b.Max.X), Y: top} + result[3].Max = Point{X: right, Y: bottom} + + if result[0].Size().X == 0 || result[0].Size().Y == 0 { + result[0] = Rect{} + } + if result[1].Size().X == 0 || result[1].Size().Y == 0 { + result[1] = Rect{} + } + if result[2].Size().X == 0 || result[2].Size().Y == 0 { + result[2] = Rect{} + } + if result[3].Size().X == 0 || result[3].Size().Y == 0 { + result[3] = Rect{} + } + + return +} + // ----------------------------------------------------------------------------- // Diretion represents a direction @@ -200,3 +251,8 @@ func (v Direction) String() string { return "" } } + +// Vector returns a direction vector with a given scale +func (v Direction) Vector(scale int16) Point { + return Point{}.MoveBy(v, scale) +} diff --git a/point_test.go b/point_test.go index ef38934..e2e7bbb 100644 --- a/point_test.go +++ b/point_test.go @@ -10,10 +10,9 @@ import ( ) /* -cpu: Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz -BenchmarkPoint/within-8 1000000000 0.2697 ns/op 0 B/op 0 allocs/op -BenchmarkPoint/within-rect-8 1000000000 0.2928 ns/op 0 B/op 0 allocs/op -BenchmarkPoint/interleave-8 1000000000 0.8242 ns/op 0 B/op 0 allocs/op +cpu: 13th Gen Intel(R) Core(TM) i7-13700K +BenchmarkPoint/within-24 1000000000 0.09854 ns/op 0 B/op 0 allocs/op +BenchmarkPoint/within-rect-24 1000000000 0.09966 ns/op 0 B/op 0 allocs/op */ func BenchmarkPoint(b *testing.B) { p := At(10, 20) @@ -50,12 +49,18 @@ func TestPoint(t *testing.T) { assert.Equal(t, "8,18", p.Subtract(p2).String()) assert.Equal(t, "20,40", p.Multiply(p2).String()) assert.Equal(t, "5,10", p.Divide(p2).String()) - assert.True(t, p.Within(At(1, 1), At(10, 20))) - assert.True(t, p.WithinRect(NewRect(1, 1, 10, 20))) + assert.True(t, p.Within(At(1, 1), At(20, 30))) + assert.True(t, p.WithinRect(NewRect(1, 1, 20, 30))) assert.False(t, p.WithinSize(At(10, 20))) assert.True(t, p.WithinSize(At(20, 30))) } +func TestIntersects(t *testing.T) { + assert.True(t, NewRect(0, 0, 2, 2).Intersects(NewRect(1, 0, 3, 2))) + assert.False(t, NewRect(0, 0, 2, 2).Intersects(NewRect(2, 0, 4, 2))) + assert.False(t, NewRect(10, 10, 12, 12).Intersects(NewRect(9, 12, 11, 14))) +} + func TestDirection(t *testing.T) { for i := 0; i < 8; i++ { dir := Direction(i) @@ -88,3 +93,57 @@ func TestMove(t *testing.T) { assert.Equal(t, tc.out, Point{}.Move(tc.dir), tc.dir.String()) } } + +func TestContains(t *testing.T) { + tests := map[Point]bool{ + {X: 0, Y: 0}: true, + {X: 1, Y: 0}: true, + {X: 0, Y: 1}: true, + {X: 1, Y: 1}: true, + {X: 2, Y: 2}: false, + {X: 3, Y: 3}: false, + {X: 1, Y: 2}: false, + {X: 2, Y: 1}: false, + } + + for point, expect := range tests { + r := NewRect(0, 0, 2, 2) + assert.Equal(t, expect, r.Contains(point), point.String()) + } +} + +func TestDiff_Right(t *testing.T) { + a := Rect{At(0, 0), At(2, 2)} + b := Rect{At(1, 0), At(3, 2)} + + diff := a.Difference(b) + assert.Equal(t, Rect{At(0, 0), At(1, 2)}, diff[2]) + assert.Equal(t, Rect{At(2, 0), At(3, 2)}, diff[3]) +} + +func TestDiff_Left(t *testing.T) { + a := Rect{At(0, 0), At(2, 2)} + b := Rect{At(-1, 0), At(1, 2)} + + diff := a.Difference(b) + assert.Equal(t, Rect{At(-1, 0), At(0, 2)}, diff[2]) + assert.Equal(t, Rect{At(1, 0), At(2, 2)}, diff[3]) +} + +func TestDiff_Up(t *testing.T) { + a := Rect{At(0, 0), At(2, 2)} + b := Rect{At(0, -1), At(2, 1)} + + diff := a.Difference(b) + assert.Equal(t, Rect{At(0, -1), At(2, 0)}, diff[0]) + assert.Equal(t, Rect{At(0, 1), At(2, 2)}, diff[1]) +} + +func TestDiff_Down(t *testing.T) { + a := Rect{At(0, 0), At(2, 2)} + b := Rect{At(0, 1), At(2, 3)} + + diff := a.Difference(b) + assert.Equal(t, Rect{At(0, 0), At(2, 1)}, diff[0]) + assert.Equal(t, Rect{At(0, 2), At(2, 3)}, diff[1]) +} diff --git a/view.go b/view.go index aab919d..940e3d2 100644 --- a/view.go +++ b/view.go @@ -24,39 +24,52 @@ type View[T comparable] struct { } // Resize resizes the viewport. -func (v *View[T]) Resize(box Rect, fn func(Point, Tile[T])) { +func (v *View[T]) Resize(view Rect, fn func(Point, Tile[T])) { owner := v.Grid // The parent map prev := v.rect // Previous bounding box - v.rect = box // New bounding box + v.rect = view // New bounding box - // Unsubscribe from the pages which are not required anymore - if prev.Min.X >= 0 || prev.Min.Y >= 0 || prev.Max.X >= 0 || prev.Max.Y >= 0 { - owner.pagesWithin(prev.Min, prev.Max, func(page *page[T]) { - if bounds := page.Bounds(); !bounds.Intersects(box) { + for _, diff := range view.Difference(prev) { + if diff.IsZero() { + continue // Skip zero-value rectangles + } + + owner.pagesWithin(diff.Min, diff.Max, func(page *page[T]) { + r := page.Bounds() + switch { + + // Page is now in view + case view.Intersects(r) && !prev.Intersects(r): + if owner.observers.Subscribe(page.point, v) { + page.SetObserved(true) // Mark the page as being observed + } + + // Page is no longer in view + case !view.Intersects(r) && prev.Intersects(r): if owner.observers.Unsubscribe(page.point, v) { page.SetObserved(false) // Mark the page as not being observed } } - }) - } - // Subscribe to every page which we have not previously subscribed - owner.pagesWithin(box.Min, box.Max, func(page *page[T]) { - if bounds := page.Bounds(); !bounds.Intersects(prev) { - if owner.observers.Subscribe(page.point, v) { - page.SetObserved(true) // Mark the page as being observed + // Callback for each new tile in the view + if fn != nil { + page.Each(v.Grid, func(p Point, tile Tile[T]) { + if view.Contains(p) && !prev.Contains(p) { + fn(p, tile) + } + }) } - } + }) + } +} - // Callback for each new tile in the view - if fn != nil { - page.Each(v.Grid, func(p Point, v Tile[T]) { - if !prev.Contains(p) && box.Contains(p) { - fn(p, v) - } - }) - } - }) +// MoveTo moves the viewport towards a particular direction. +func (v *View[T]) MoveTo(angle Direction, distance int16, fn func(Point, Tile[T])) { + p := angle.Vector(distance) + v.Resize(Rect{ + Min: v.rect.Min.Add(p), + Max: v.rect.Max.Add(p), + }, fn) } // MoveBy moves the viewport towards a particular direction. diff --git a/view_test.go b/view_test.go index 1452f72..7001896 100644 --- a/view_test.go +++ b/view_test.go @@ -11,14 +11,13 @@ import ( ) /* -cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz -BenchmarkView/write-8 7208314 174.0 ns/op 8 B/op 1 allocs/op -BenchmarkView/move-8 9231 120567 ns/op 0 B/op 0 allocs/op -BenchmarkView/notify-8 7274684 170.2 ns/op 8 B/op 1 allocs/op +cpu: 13th Gen Intel(R) Core(TM) i7-13700K +BenchmarkView/write-24 9540012 125.0 ns/op 48 B/op 1 allocs/op +BenchmarkView/move-24 16141 74408 ns/op 0 B/op 0 allocs/op */ func BenchmarkView(b *testing.B) { m := mapFrom("300x300.png") - v := m.View(NewRect(100, 0, 199, 99), nil) + v := m.View(NewRect(100, 0, 200, 100), nil) go func() { for range v.Inbox { } @@ -51,24 +50,24 @@ func TestView(t *testing.T) { // Create a new view c := counter(0) - v := m.View(NewRect(100, 0, 199, 99), c.count) + v := m.View(NewRect(100, 0, 200, 100), c.count) assert.NotNil(t, v) assert.Equal(t, 10000, int(c)) // Resize to 10x10 c = counter(0) - v.Resize(NewRect(0, 0, 9, 9), c.count) + v.Resize(NewRect(0, 0, 10, 10), c.count) assert.Equal(t, 100, int(c)) // Move down-right c = counter(0) v.MoveBy(2, 2, c.count) - assert.Equal(t, 36, int(c)) + assert.Equal(t, 48, int(c)) // Move at location c = counter(0) v.MoveAt(At(4, 4), c.count) - assert.Equal(t, 36, int(c)) + assert.Equal(t, 48, int(c)) // Each c = counter(0) @@ -139,7 +138,7 @@ func TestStateUpdates(t *testing.T) { // Create a new view c := counter(0) - v := m.View(NewRect(0, 0, 9, 9), c.count) + v := m.View(NewRect(0, 0, 10, 10), c.count) assert.NotNil(t, v) assert.Equal(t, 100, int(c)) @@ -188,8 +187,47 @@ func TestStateUpdates(t *testing.T) { }, <-v.Inbox) } +func TestObservers_MoveIncremental(t *testing.T) { + m := mapFrom("300x300.png") + + // Create a new view + c := counter(0) + v := m.View(NewRect(10, 10, 12, 12), c.count) + assert.NotNil(t, v) + assert.Equal(t, 4, int(c)) + assert.Equal(t, 9, countObservers(m)) + + const distance = 10 + for i := 0; i < distance; i++ { + v.MoveTo(East, 1, c.count) + } + for i := 0; i < distance; i++ { + v.MoveTo(South, 1, c.count) + } + for i := 0; i < distance; i++ { + v.MoveTo(West, 1, c.count) + } + for i := 0; i < distance; i++ { + v.MoveTo(North, 1, c.count) + } + + // Count the number of observers, should be the same as before + assert.Equal(t, 9, countObservers(m)) + assert.NoError(t, v.Close()) +} + // ---------------------------------- Mocks ---------------------------------- +func countObservers(m *Grid[string]) int { + var observers int + m.Each(func(p Point, t Tile[string]) { + if t.data.IsObserved() { + observers++ + } + }) + return observers +} + type fakeView[T comparable] func(*Update[T]) func (f fakeView[T]) onUpdate(e *Update[T]) {