diff --git a/client/canvas.go b/client/canvas.go new file mode 100644 index 0000000..a5f9baf --- /dev/null +++ b/client/canvas.go @@ -0,0 +1,87 @@ +package client + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/widget" +) + +type interactiveRaster struct { + widget.BaseWidget + + edit *editor + min fyne.Size + raster *canvas.Raster + onLayout func(fyne.Size) + onDragged func(*fyne.DragEvent) + onDragEnd func() +} + +func (r *interactiveRaster) SetMinSize(size fyne.Size) { + r.min = size + r.Resize(size) +} + +func (r *interactiveRaster) MinSize() fyne.Size { + return r.min +} + +func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer { + return &rasterWidgetRender{ + canvas: r, + bg: canvas.NewRasterWithPixels(bgPattern), + onLayout: r.onLayout, + } +} + +// Tapped is a left-click event +func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) { + x, y := int(ev.Position.X), int(ev.Position.Y) + size := r.raster.Size() + if x >= int(size.Width) || y >= int(size.Height) { + return + } +} + +// TappedSecondary is a right-click event +func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {} + +func newInteractiveRaster(edit *editor, raster *canvas.Raster, onLayout func(fyne.Size), onDragged func(*fyne.DragEvent), onDragEnd func()) *interactiveRaster { + r := &interactiveRaster{ + // raster: canvas.NewRaster(edit.draw), + raster: raster, + edit: edit, + onLayout: onLayout, + onDragged: onDragged, + onDragEnd: onDragEnd, + } + r.ExtendBaseWidget(r) + return r +} + +func bgPattern(x, y, _, _ int) color.Color { + const boxSize = 25 + + if (x/boxSize)%2 == (y/boxSize)%2 { + return color.Gray{Y: 58} + } + + return color.Gray{Y: 84} +} + +func (r *interactiveRaster) Dragged(e *fyne.DragEvent) { + if r.onDragged == nil { + return + } + + r.onDragged(e) +} + +func (r *interactiveRaster) DragEnd() { + if r.onDragEnd == nil { + return + } + r.onDragEnd() +} diff --git a/client/client.go b/client/client.go index b28c633..ecdebf6 100644 --- a/client/client.go +++ b/client/client.go @@ -3,8 +3,6 @@ package client import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" ) type client struct { @@ -15,15 +13,13 @@ type client struct { func NewClient() *client { c := &client{} c.app = app.New() - c.window = c.app.NewWindow("Hello") + c.window = c.app.NewWindow("Galaxy+") - hello := widget.NewLabel("Hello Fyne!") - c.window.SetContent(container.NewVBox( - hello, - widget.NewButton("Hi!", func() { - hello.SetText("Welcome :)") - }), - )) + // https://github.com/fyne-io/fyne/issues/418 - interactive raster + // https://github.com/fyne-io/fyne/issues/224 - resize + + editor := NewEditor() + editor.BuildUI(c.window) return c } diff --git a/client/coalesce.go b/client/coalesce.go new file mode 100644 index 0000000..5e4abf0 --- /dev/null +++ b/client/coalesce.go @@ -0,0 +1,165 @@ +package client + +/* +Fyne-friendly latest-wins coalescing for canvas.NewRaster(draw func(w,h int) image.Image). + +How to use (integration sketch): + + type editor struct { + w *world.World + drawer world.PrimitiveDrawer // wraps gg.Context over a backing *image.RGBA + raster *canvas.Raster + + co *client.RasterCoalescer[world.RenderParams] + } + + func (e *editor) initCoalescer() { + exec := client.FyneMainThreadExecutor{} // uses fyne.CurrentApp().Driver().RunOnMain + e.co = client.NewRasterCoalescer(exec, e.raster, func(wPx, hPx int, p world.RenderParams) image.Image { + // your existing draw pipeline: + // 1) ensure viewport/margins inside p are consistent with wPx/hPx if needed + // 2) call e.w.Render(e.drawer, p) + // 3) get image from gg.Context, crop margins, return + _ = e.w.Render(e.drawer, p) + return e.drawerImageCropped() // your code + }) + } + + // Call from input handlers (pan/zoom/etc). Can be from any goroutine. + func (e *editor) RefreshUI(p world.RenderParams) { + e.co.Request(p) // schedules raster.Refresh() on UI thread + } + + // Raster draw callback: + func (e *editor) draw(wPx, hPx int) image.Image { + return e.co.Draw(wPx, hPx) + } + +Key property: +- draw() renders at most once per invocation (never loops). +- if new requests arrived while drawing, we schedule exactly one extra Refresh. +*/ + +import ( + "image" + "sync" +) + +// UIExecutor posts a function to run on the UI/main thread. +type UIExecutor interface { + Post(fn func()) +} + +// Refresher is the minimal interface we need from fyne.CanvasObject / Raster. +type Refresher interface { + Refresh() +} + +// RasterRenderer renders the latest params and returns an image. +// Must be called on the UI thread (inside draw callback). +type RasterRenderer[P any] func(wPx, hPx int, params P) image.Image + +// RasterCoalescer implements latest-wins coalescing for raster rendering. +// It is designed specifically for toolkits like fyne where the system calls draw(w,h) +// and expects a returned image. +type RasterCoalescer[P any] struct { + exec UIExecutor + refresher Refresher + renderer RasterRenderer[P] + + mu sync.Mutex + + // inDraw == true while Draw() is running on UI thread. + inDraw bool + + // refreshQueued == true when we have already posted a Refresh() that has not yet + // resulted in a Draw() call (or is expected to call Draw soon). + refreshQueued bool + + // pending == true when new params arrived while inDraw==true. + // Draw() will schedule exactly one follow-up Refresh after it returns. + pending bool + + latest P + have bool +} + +// NewRasterCoalescer creates a new coalescer. +// - exec.Post must run fn on UI thread. +// - refresher.Refresh will trigger the framework to call draw(w,h). +func NewRasterCoalescer[P any](exec UIExecutor, refresher Refresher, renderer RasterRenderer[P]) *RasterCoalescer[P] { + if exec == nil { + panic("RasterCoalescer: nil executor") + } + if refresher == nil { + panic("RasterCoalescer: nil refresher") + } + if renderer == nil { + panic("RasterCoalescer: nil renderer") + } + return &RasterCoalescer[P]{exec: exec, refresher: refresher, renderer: renderer} +} + +// Request stores the latest params and schedules exactly one refresh (latest-wins). +// Can be called from any goroutine. +func (c *RasterCoalescer[P]) Request(params P) { + c.mu.Lock() + c.latest = params + c.have = true + + // If we are currently inside Draw(), don't schedule refresh immediately. + // Just mark pending; Draw() will schedule one follow-up refresh after it returns. + if c.inDraw { + c.pending = true + c.mu.Unlock() + return + } + + // Not drawing. Schedule at most one refresh until the next Draw() happens. + if c.refreshQueued { + c.mu.Unlock() + return + } + + c.refreshQueued = true + c.mu.Unlock() + + c.exec.Post(c.refresher.Refresh) +} + +// Draw must be called from the raster draw callback on the UI thread. +// It renders exactly once with the latest snapshot. +// If more requests arrived while drawing, it schedules exactly one extra refresh. +func (c *RasterCoalescer[P]) Draw(wPx, hPx int) image.Image { + c.mu.Lock() + // A Draw call corresponds to a previously scheduled refresh being serviced. + c.refreshQueued = false + + if !c.have { + c.mu.Unlock() + return image.NewRGBA(image.Rect(0, 0, wPx, hPx)) + } + + c.inDraw = true + c.pending = false + params := c.latest + c.mu.Unlock() + + img := c.renderer(wPx, hPx, params) + + c.mu.Lock() + needAnother := c.pending + c.pending = false + c.inDraw = false + + // If we need another frame, schedule exactly one refresh (if not already queued). + if needAnother && !c.refreshQueued { + c.refreshQueued = true + c.mu.Unlock() + c.exec.Post(c.refresher.Refresh) + return img + } + + c.mu.Unlock() + return img +} diff --git a/client/coalesce_test.go b/client/coalesce_test.go new file mode 100644 index 0000000..c876f12 --- /dev/null +++ b/client/coalesce_test.go @@ -0,0 +1,190 @@ +package client + +import ( + "image" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +type testExecutor struct { + mu sync.Mutex + queue []func() +} + +func (e *testExecutor) Post(fn func()) { + e.mu.Lock() + e.queue = append(e.queue, fn) + e.mu.Unlock() +} + +func (e *testExecutor) FlushAll() { + for { + var fn func() + e.mu.Lock() + if len(e.queue) > 0 { + fn = e.queue[0] + e.queue = e.queue[1:] + } + e.mu.Unlock() + if fn == nil { + return + } + fn() + } +} + +type testRefresher struct { + mu sync.Mutex + count int +} + +func (r *testRefresher) Refresh() { + r.mu.Lock() + r.count++ + r.mu.Unlock() +} + +func (r *testRefresher) Count() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.count +} + +func TestRasterCoalescer_RequestBeforeDraw_CoalescesToLatest(t *testing.T) { + t.Parallel() + + exec := &testExecutor{} + ref := &testRefresher{} + + var got []int + + co := NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image { + got = append(got, p) + return image.NewRGBA(image.Rect(0, 0, w, h)) + }) + + co.Request(1) + co.Request(2) + co.Request(3) + + // Only a single refresh should be scheduled before the next Draw(). + exec.FlushAll() + require.Equal(t, 1, ref.Count()) + + _ = co.Draw(10, 10) + require.Equal(t, []int{3}, got) +} + +func TestRasterCoalescer_RequestDuringDraw_SchedulesOneFollowUpRefresh(t *testing.T) { + t.Parallel() + + exec := &testExecutor{} + ref := &testRefresher{} + var got []int + + var co *RasterCoalescer[int] + co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image { + got = append(got, p) + if p == 1 { + co.Request(2) + co.Request(3) + } + return image.NewRGBA(image.Rect(0, 0, w, h)) + }) + + co.Request(1) + + exec.FlushAll() + require.Equal(t, 1, ref.Count()) + + // First draw renders 1 and schedules exactly one additional refresh. + _ = co.Draw(10, 10) + exec.FlushAll() + require.Equal(t, 2, ref.Count()) + + // Second draw renders latest (3). + _ = co.Draw(10, 10) + require.Equal(t, []int{1, 3}, got) +} + +func TestRasterCoalescer_ManyRequestsWhileDrawing_StillOnlyOneExtraRefresh(t *testing.T) { + t.Parallel() + + exec := &testExecutor{} + ref := &testRefresher{} + var got []int + + var co *RasterCoalescer[int] + co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image { + got = append(got, p) + if p == 1 { + for i := 2; i <= 50; i++ { + co.Request(i) + } + } + return image.NewRGBA(image.Rect(0, 0, w, h)) + }) + + co.Request(1) + exec.FlushAll() + require.Equal(t, 1, ref.Count()) + + _ = co.Draw(10, 10) + exec.FlushAll() + require.Equal(t, 2, ref.Count()) + + _ = co.Draw(10, 10) + require.Equal(t, []int{1, 50}, got) +} + +func TestCopyViewportRGBA_CopiesROIAndIsIndependentFromSource(t *testing.T) { + t.Parallel() + + src := image.NewRGBA(image.Rect(0, 0, 20, 20)) + dst := image.NewRGBA(image.Rect(0, 0, 5, 6)) + + // Fill src with a pattern: pixel (x,y) has RGBA = (x, y, 0, 255). + for y := 0; y < 20; y++ { + for x := 0; x < 20; x++ { + off := y*src.Stride + x*4 + src.Pix[off+0] = byte(x) + src.Pix[off+1] = byte(y) + src.Pix[off+2] = 0 + src.Pix[off+3] = 255 + } + } + + marginX, marginY := 7, 9 + copyViewportRGBA(dst, src, marginX, marginY, 5, 6) + + // Verify a few pixels in dst match the expected source ROI. + // dst(0,0) == src(marginX, marginY) + { + off := 0*dst.Stride + 0*4 + require.Equal(t, byte(marginX), dst.Pix[off+0]) + require.Equal(t, byte(marginY), dst.Pix[off+1]) + require.Equal(t, byte(255), dst.Pix[off+3]) + } + // dst(4,5) == src(marginX+4, marginY+5) + { + off := 5*dst.Stride + 4*4 + require.Equal(t, byte(marginX+4), dst.Pix[off+0]) + require.Equal(t, byte(marginY+5), dst.Pix[off+1]) + require.Equal(t, byte(255), dst.Pix[off+3]) + } + + // Mutate src ROI after copy and ensure dst is unchanged (no aliasing). + { + off := (marginY+0)*src.Stride + (marginX+0)*4 + src.Pix[off+0] = 200 + src.Pix[off+1] = 201 + src.Pix[off+3] = 123 + } + + offDst := 0*dst.Stride + 0*4 + require.Equal(t, byte(marginX), dst.Pix[offDst+0]) + require.Equal(t, byte(marginY), dst.Pix[offDst+1]) + require.Equal(t, byte(255), dst.Pix[offDst+3]) +} diff --git a/client/editor.go b/client/editor.go new file mode 100644 index 0000000..2bccb7a --- /dev/null +++ b/client/editor.go @@ -0,0 +1,183 @@ +package client + +import ( + "image" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "github.com/iliadenisov/galaxy/client/world" +) + +type Editor interface { + BuildUI(fyne.Window) +} + +type editor struct { + world *world.World + drawer *world.GGDrawer + raster *canvas.Raster + canvasScale float32 + + canvas *interactiveRaster + win fyne.Window + + // Coalescer for latest-wins refresh scheduling. + co *RasterCoalescer[world.RenderParams] + + pan *PanController + + // Protected render params state. Stored as value to avoid aliasing issues. + mu sync.RWMutex + wp *world.RenderParams + + // Last viewport size we indexed the world for. + lastViewportW int + lastViewportH int + + // Optional: you can keep the last expanded canvas size to avoid reallocations. + lastCanvasW int + lastCanvasH int + + // Reusable viewport buffer to avoid per-frame allocations. + viewportImg *image.RGBA + viewportW int + viewportH int +} + +func (e *editor) CanvasScale() float32 { return e.canvasScale } + +func (e *editor) ForceFullRedraw() { + e.world.ForceFullRedrawNext() +} + +func (e *editor) buildUI() fyne.CanvasObject { + return e.canvas +} + +// здесь определяю, изменились ли границы raster, если да - обновляю размеры viewport, margin и корректирую zoom +func (e *editor) updateSizes() { + canvas := fyne.CurrentApp().Driver().CanvasForObject(e.raster) + if canvas == nil { + return + } + + size := e.raster.Size() + e.canvasScale = canvas.Scale() + + width := int(size.Width * e.canvasScale) + height := int(size.Height * e.canvasScale) + + if width > 0 && height > 0 && (width != e.wp.ViewportWidthPx || height != e.wp.ViewportHeightPx) { + e.wp.ViewportWidthPx = width + e.wp.ViewportHeightPx = height + e.wp.MarginXPx = e.wp.ViewportWidthPx / 4 + e.wp.MarginYPx = e.wp.ViewportHeightPx / 4 + + e.wp.CameraZoom = e.world.CorrectCameraZoom(e.wp.CameraZoom, e.wp.ViewportWidthPx, e.wp.ViewportHeightPx) + e.world.IndexOnViewportChange(e.wp.ViewportWidthPx, e.wp.ViewportHeightPx, e.wp.CameraZoom) + + e.co.Request(*e.wp) + } +} + +func (e *editor) onDragged(ev *fyne.DragEvent) { + e.pan.Dragged(ev) +} + +func (e *editor) onDradEnd() { + e.pan.DragEnd() +} + +func (e *editor) wheelZoom(stepDelta int) {} + +func (e *editor) InitImage() { + s := fyne.NewSize(292, 292) + e.canvas.SetMinSize(s) + e.updateSizes() +} + +func (e *editor) onMapLayout(s fyne.Size) { + e.updateSizes() +} + +func (e *editor) BuildUI(w fyne.Window) { + e.win = w + content := container.New(layout.NewStackLayout(), e.buildUI()) + w.CenterOnScreen() + w.SetContent(content) +} + +func NewEditor() *editor { + w := world.NewWorld(300, 300) + testWorldInit(w) + e := &editor{ + world: w, + wp: &world.RenderParams{ + CameraZoom: 1.0, + CameraXWorldFp: 300 * world.SCALE, + CameraYWorldFp: 300 * world.SCALE, + // Viewport sizes and margins will be filled from draw(w,h). + }, + canvasScale: 1.0, + } + + // Create a drawer with some initial context; real size will be adjusted on first draw. + e.drawer = &world.GGDrawer{DC: nil} + + // Create raster; its draw callback delegates to coalescer. + e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image { + return e.draw(wPx, hPx) + }) + + e.canvas = newInteractiveRaster(e, e.raster, e.onMapLayout, e.onDragged, e.onDradEnd) + e.pan = NewPanController(e) + + // Wire coalescer: it schedules raster.Refresh() on UI thread and renders once per draw call. + exec := FyneExecutor{} + e.co = NewRasterCoalescer( + exec, + e.raster, // Refresher + func(wPx, hPx int, p world.RenderParams) image.Image { + // This runs on UI thread (inside draw). It must return an image. + return e.renderRasterImage(wPx, hPx, p) + }, + ) + + // Kick initial draw. + e.RequestRefresh() + + e.InitImage() + return e +} + +func testWorldInit(w *world.World) { + if _, err := w.AddCircle(150, 150, 50); err != nil { + panic(err) + } + + if _, err := w.AddCircle(150, 299, 30); err != nil { + panic(err) + } + if _, err := w.AddCircle(299, 150, 30); err != nil { + panic(err) + } + + if _, err := w.AddLine(100, 20, 200, 30); err != nil { + panic(err) + } + + if _, err := w.AddLine(50, 50, 250, 100); err != nil { + panic(err) + } + + if _, err := w.AddPoint(10, 10); err != nil { + panic(err) + } + + if _, err := w.AddPoint(25, 255); err != nil { + panic(err) + } +} diff --git a/client/go.mod b/client/go.mod index a897e4d..4b67898 100644 --- a/client/go.mod +++ b/client/go.mod @@ -2,7 +2,12 @@ module github.com/iliadenisov/galaxy/client go 1.26.0 -require fyne.io/fyne/v2 v2.7.3 +require ( + fyne.io/fyne/v2 v2.7.3 + github.com/fogleman/gg v1.3.0 + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 +) require ( fyne.io/systray v1.12.0 // indirect @@ -19,22 +24,24 @@ require ( github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.3.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.1 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.7.16 // indirect golang.org/x/image v0.36.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/client/go.sum b/client/go.sum index acb33ae..1b47dcb 100644 --- a/client/go.sum +++ b/client/go.sum @@ -2,8 +2,6 @@ fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE= fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -11,6 +9,8 @@ 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/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -25,8 +25,6 @@ github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= @@ -35,36 +33,41 @@ github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4 github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts= github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs= github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= -github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= -github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= -github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= 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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= @@ -73,28 +76,20 @@ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqd github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= -golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/client/ui.go b/client/ui.go new file mode 100644 index 0000000..4aa6e2b --- /dev/null +++ b/client/ui.go @@ -0,0 +1,164 @@ +package client + +import ( + "image" + + "fyne.io/fyne/v2" + // adjust import path to your world package + // "your/module/world" + "github.com/fogleman/gg" + "github.com/iliadenisov/galaxy/client/world" +) + +/* +Fyne integration notes: + +- canvas.NewRaster calls draw(w,h) on the UI thread. +- We MUST keep draw() cheap and never loop re-rendering inside it. +- Coalescing must therefore schedule refreshes and render at most once per draw call. +- The world renderer expects: + - RenderParams.ViewportWidthPx/HeightPx: the size of the visible viewport. + - RenderParams.MarginXPx/MarginYPx: margins around viewport. + - RenderParams.CameraXWorldFp/YWorldFp: camera center in world-fixed units. + - RenderParams.CameraZoom: float zoom (converted inside world). +- world.Render draws on the full expanded canvas (viewport + 2*margins on each axis). + +This adapter enforces: +- viewport sizes come from draw(w,h) +- margins are computed from viewport sizes (w/4 and h/4) +- gg context backing image is resized to the expanded canvas size +- IndexOnViewportChange is called when viewport sizes changed (you can also include zoom if desired) +*/ + +// FyneExecutor posts functions onto the Fyne UI thread. +type FyneExecutor struct{} + +func (FyneExecutor) Post(fn func()) { + fyne.Do(fn) +} + +// Widget returns the fyne CanvasObject to add into your UI. +func (e *editor) Widget() fyne.CanvasObject { + return e.raster +} + +// GetParams returns a copy of current render params for external reads. +func (e *editor) GetParams() world.RenderParams { + e.mu.RLock() + defer e.mu.RUnlock() + return *e.wp +} + +// UpdateParams applies a modification function to render params and schedules a refresh. +// This is a safe way to mutate camera/zoom from event handlers. +func (e *editor) UpdateParams(fn func(p *world.RenderParams)) { + e.mu.Lock() + fn(e.wp) + p := e.wp // snapshot + e.mu.Unlock() + + e.co.Request(*p) +} + +// RequestRefresh schedules a refresh with the current params snapshot. +// Useful if you changed world objects and want to redraw. +func (e *editor) RequestRefresh() { + e.mu.RLock() + p := e.wp + e.mu.RUnlock() + e.co.Request(*p) +} + +// draw is the raster callback. It must be cheap and must not block on multiple re-renders. +// It delegates coalescing + rendering decision to RasterCoalescer. +func (e *editor) draw(wPx, hPx int) image.Image { + // Snapshot latest params and render once. + // e.mu.RLock() + // p := e.wp + // e.mu.RUnlock() + + // Request() already scheduled refreshes; Draw() actually renders for this callback. + // We bypass co.Draw(w,h) because we need to pass our snapshot to coalescer in a controlled way. + // The simplest pattern: keep coalescer as the sole driver: call co.Draw(w,h) here. + // But then coalescer uses its internal latest. So make sure we always call co.Request on updates. + // + // In normal operation you can just: return e.co.Draw(wPx,hPx) + // and never use p above. We'll do that to keep a single source of truth. + // _ = p + return e.co.Draw(wPx, hPx) +} + +// renderRasterImage renders the expanded canvas into the GGDrawer backing image, +// then copies only the viewport ROI into a reusable viewport buffer and returns it. +func (e *editor) renderRasterImage(viewportW, viewportH int, p world.RenderParams) image.Image { + // 1) Viewport sizes come from raster draw callback. + p.ViewportWidthPx = viewportW + p.ViewportHeightPx = viewportH + p.MarginXPx = viewportW / 4 + p.MarginYPx = viewportH / 4 + + // 2) Ensure indexing is up-to-date when viewport size changed. + if viewportW != e.lastViewportW || viewportH != e.lastViewportH { + e.world.IndexOnViewportChange(viewportW, viewportH, p.CameraZoom) + e.lastViewportW = viewportW + e.lastViewportH = viewportH + } + + // 3) Ensure GG backing canvas is sized for expanded canvas. + canvasW := p.CanvasWidthPx() + canvasH := p.CanvasHeightPx() + e.ensureDrawerCanvas(canvasW, canvasH) + + // 4) Render into expanded canvas backing (full or incremental is decided inside world.Render). + _ = e.world.Render(e.drawer, p) // handle error in your real code + + // 5) Copy viewport ROI into reusable viewport buffer and return it. + e.ensureViewportBuffer(viewportW, viewportH) + + src, ok := e.drawer.DC.Image().(*image.RGBA) + if !ok || src == nil { + // Should not happen if GGDrawer is backed by RGBA. + return image.NewRGBA(image.Rect(0, 0, viewportW, viewportH)) + } + + copyViewportRGBA(e.viewportImg, src, p.MarginXPx, p.MarginYPx, viewportW, viewportH) + return e.viewportImg +} + +// ensureDrawerCanvas ensures drawer has a gg.Context sized to canvasW x canvasH. +func (e *editor) ensureDrawerCanvas(canvasW, canvasH int) { + if e.drawer.DC != nil && e.lastCanvasW == canvasW && e.lastCanvasH == canvasH { + return + } + // world.NewGGContextRGBA should return *gg.Context backed by *image.RGBA (gg.NewContext does). + e.drawer.DC = NewGGContextRGBA(canvasW, canvasH) + e.lastCanvasW = canvasW + e.lastCanvasH = canvasH + // e.wp.CameraXWorldFp = e.wp.ViewportWidthPx / 2 * world.SCALE + // e.wp.CameraYWorldFp = e.wp.ViewportHeightPx / 2 * world.SCALE +} + +func (e *editor) ensureViewportBuffer(w, h int) { + if e.viewportImg != nil && e.viewportW == w && e.viewportH == h { + return + } + e.viewportImg = image.NewRGBA(image.Rect(0, 0, w, h)) + e.viewportW = w + e.viewportH = h +} + +// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA. +// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free. +// It avoids SubImage aliasing issues: dst becomes independent from src backing memory. +func copyViewportRGBA(dst, src *image.RGBA, marginX, marginY, vw, vh int) { + for y := 0; y < vh; y++ { + srcOff := (marginY+y)*src.Stride + marginX*4 + dstOff := y * dst.Stride + n := vw * 4 + copy(dst.Pix[dstOff:dstOff+n], src.Pix[srcOff:srcOff+n]) + } +} + +func NewGGContextRGBA(w, h int) *gg.Context { + return gg.NewContext(w, h) +} diff --git a/client/ui_drag.go b/client/ui_drag.go new file mode 100644 index 0000000..6c90f5b --- /dev/null +++ b/client/ui_drag.go @@ -0,0 +1,111 @@ +package client + +import ( + "math" + + "fyne.io/fyne/v2" + + "github.com/iliadenisov/galaxy/client/world" +) + +/* +Editor pan integration for Fyne Draggable: + +- DragEvent provides absolute coordinates in "Fyne units". +- Editor knows canvasScale (Fyne units per pixel) and converts to pixels. +- We keep last drag position and compute dx/dy ourselves. +- We update camera center in world-fixed (CameraXWorldFp/YWorldFp). + +Sign convention (map follows pointer): +- Drag right (dxPx > 0): move world content right => move camera left => CameraXWorldFp -= dxWorldFp +- Drag down (dyPx > 0): move world content down => move camera up => CameraYWorldFp -= dyWorldFp +*/ + +// draggableEditor is the minimal interface we need from your editor implementation. +// If your Editor already has these methods/fields, you can fold the code directly into it. +type draggableEditor interface { + // CanvasScale returns the fyne-units-per-pixel scale factor. + CanvasScale() float32 + + // UpdateParams applies a mutation and schedules refresh through your coalescer. + UpdateParams(fn func(p *world.RenderParams)) + + // RequestRefresh schedules a refresh with current params (no mutation). + RequestRefresh() + + // ForceFullRedraw forces a full redraw on next Render (used on DragEnd). + ForceFullRedraw() +} + +// PanController holds per-drag transient state. +type PanController struct { + ed draggableEditor + + dragging bool + lastFx float32 // last absolute position in Fyne units + lastFy float32 + + // Remainders to keep subpixel fyne->px conversion stable across many events. + remPxX float32 + remPxY float32 +} + +func NewPanController(ed draggableEditor) *PanController { + return &PanController{ed: ed} +} + +// Dragged processes one drag event, updates camera center by delta, and schedules redraw. +func (p *PanController) Dragged(ev *fyne.DragEvent) { + if ev == nil { + return + } + + scale := p.ed.CanvasScale() + if scale <= 0 { + return + } + + // DragEvent.Dragged is delta in Fyne logical units (device independent). + // Convert to pixels by multiplying by canvas scale. + dxPxF := ev.Dragged.DX * scale + dyPxF := ev.Dragged.DY * scale + + // accumulate subpixel remainder in pixels + dxPxF += p.remPxX + dyPxF += p.remPxY + + dxPx := int(math.Round(float64(dxPxF))) + dyPx := int(math.Round(float64(dyPxF))) + + p.remPxX = dxPxF - float32(dxPx) + p.remPxY = dyPxF - float32(dyPx) + + if dxPx == 0 && dyPx == 0 { + return + } + + p.ed.UpdateParams(func(rp *world.RenderParams) { + zoomFp, err := rp.CameraZoomFp() + if err != nil || zoomFp <= 0 { + return + } + + dxWorldFp := world.PixelSpanToWorldFixed(dxPx, zoomFp) + dyWorldFp := world.PixelSpanToWorldFixed(dyPx, zoomFp) + + // Map follows pointer + rp.CameraXWorldFp -= dxWorldFp + rp.CameraYWorldFp -= dyWorldFp + }) +} + +// DragEnd ends the drag gesture. We force a full redraw next to eliminate any +// possible artifacts from incremental shifting and to "settle" the final state. +func (p *PanController) DragEnd() { + p.dragging = false + p.remPxX = 0 + p.remPxY = 0 + + p.ed.ForceFullRedraw() + p.ed.RequestRefresh() +} diff --git a/client/ui_drag_test.go b/client/ui_drag_test.go new file mode 100644 index 0000000..9ce1141 --- /dev/null +++ b/client/ui_drag_test.go @@ -0,0 +1,109 @@ +package client + +import ( + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + + "github.com/stretchr/testify/require" + + "github.com/iliadenisov/galaxy/client/world" +) + +type fakeEditor struct { + scale float32 + p world.RenderParams + + forced bool + updates int + refresh int +} + +func (e *fakeEditor) CanvasScale() float32 { return e.scale } + +func (e *fakeEditor) UpdateParams(fn func(p *world.RenderParams)) { + fn(&e.p) + e.updates++ +} + +func (e *fakeEditor) RequestRefresh() { e.refresh++ } + +func (e *fakeEditor) ForceFullRedraw() { e.forced = true } + +func TestPanController_DraggedUpdatesCameraByDeltaPx(t *testing.T) { + t.Parallel() + + fe := &fakeEditor{ + scale: 1.0, // 1 fyne unit == 1 px for the test + p: world.RenderParams{ + CameraZoom: 1.0, + CameraXWorldFp: 5 * world.SCALE, + CameraYWorldFp: 5 * world.SCALE, + }, + } + + pc := NewPanController(fe) + + // Drag right by +3 px and down by +2 px. + pc.Dragged(&fyne.DragEvent{ + Dragged: fyne.Delta{DX: 3, DY: 2}, + }) + + require.Equal(t, 1, fe.updates) + + // Map follows pointer => camera moves opposite to pointer delta. + require.Equal(t, 5*world.SCALE-3*world.SCALE, fe.p.CameraXWorldFp) + require.Equal(t, 5*world.SCALE-2*world.SCALE, fe.p.CameraYWorldFp) +} + +func TestPanController_DraggedUsesCanvasScaleByMultiplying(t *testing.T) { + t.Parallel() + + fe := &fakeEditor{ + scale: 2.0, // 2 px per fyne unit + p: world.RenderParams{ + CameraZoom: 1.0, + CameraXWorldFp: 0, + CameraYWorldFp: 0, + }, + } + + pc := NewPanController(fe) + + // Dragged.DX=1 fyne unit => 2 px after scaling. + pc.Dragged(&fyne.DragEvent{ + Dragged: fyne.Delta{DX: 1, DY: 0}, + }) + + require.Equal(t, -2*world.SCALE, fe.p.CameraXWorldFp) +} + +func TestPanController_DragEndForcesFullRedrawAndRefresh(t *testing.T) { + t.Parallel() + + fe := &fakeEditor{ + scale: 1.0, + p: world.RenderParams{ + CameraZoom: 1.0, + CameraXWorldFp: 0, + CameraYWorldFp: 0, + }, + } + + pc := NewPanController(fe) + + // Simulate a drag start. + pc.Dragged(&fyne.DragEvent{PointEvent: fyne.PointEvent{Position: fyne.Position{X: 1, Y: 1}}}) + + pc.DragEnd() + require.True(t, fe.forced) + require.Equal(t, 1, fe.refresh) +} + +// Optional: demonstrate use of fyne/test package to ensure types are available. +// (Not strictly needed, but keeps fyne dependency "active" in tests.) +func TestFyneTestDriverIsUsable(t *testing.T) { + t.Parallel() + _ = test.NewApp() +} diff --git a/client/util.go b/client/util.go new file mode 100644 index 0000000..44902b8 --- /dev/null +++ b/client/util.go @@ -0,0 +1,14 @@ +package client + +import ( + "fmt" + + "fyne.io/fyne/v2" +) + +func PrintSize(c fyne.Canvas) { + if c == nil { + return + } + fmt.Println(c.Size()) +} diff --git a/client/widget.go b/client/widget.go new file mode 100644 index 0000000..49b7e17 --- /dev/null +++ b/client/widget.go @@ -0,0 +1,43 @@ +package client + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/theme" +) + +type rasterWidgetRender struct { + canvas *interactiveRaster + bg *canvas.Raster + onLayout func(fyne.Size) +} + +func (r *rasterWidgetRender) Layout(size fyne.Size) { + r.bg.Resize(size) + r.canvas.raster.Resize(size) + if r.onLayout != nil { + r.onLayout(size) + } + // fmt.Println("widget layout:", size.Width, size.Height, "raster:", r.canvas.raster.Size()) +} + +func (r *rasterWidgetRender) MinSize() fyne.Size { + return r.MinSize() +} + +func (r *rasterWidgetRender) Refresh() { + canvas.Refresh(r.canvas) +} + +func (r *rasterWidgetRender) BackgroundColor() color.Color { + return theme.Color(theme.ColorNameBackground) +} + +func (r *rasterWidgetRender) Objects() []fyne.CanvasObject { + return []fyne.CanvasObject{r.bg, r.canvas.raster} +} + +func (r *rasterWidgetRender) Destroy() { +} diff --git a/client/world/drawer.go b/client/world/drawer.go new file mode 100644 index 0000000..4e6e4b3 --- /dev/null +++ b/client/world/drawer.go @@ -0,0 +1,326 @@ +package world + +import ( + "image" + "image/color" + + "github.com/fogleman/gg" +) + +// PrimitiveDrawer is a low-level drawing backend used by the world renderer. +// +// The renderer is responsible for all torus logic, viewport/margin logic, +// coordinate projection, and primitive duplication. This interface only accepts +// final canvas pixel coordinates and exposes the minimum drawing operations +// needed to build and render paths. +// +// AddPoint, AddLine, and AddCircle append geometry to the current path. +// They do not render by themselves. The caller must finalize the path by +// calling Stroke or Fill. +// +// Save and Restore are intended for temporary local state changes such as +// clipping, colors, line width, or dash settings. After Restore, the outer +// drawing state must be visible again. +type PrimitiveDrawer interface { + // Save stores the current drawing state. + Save() + + // Restore restores the most recently saved drawing state. + Restore() + + // ResetClip clears the current clipping region completely. + ResetClip() + + // ClipRect intersects the current clipping region with the given rectangle + // in canvas pixel coordinates. + ClipRect(x, y, w, h float64) + + // SetStrokeColor sets the color used by Stroke. + SetStrokeColor(c color.Color) + + // SetFillColor sets the color used by Fill. + SetFillColor(c color.Color) + + // SetLineWidth sets the line width used by Stroke. + SetLineWidth(width float64) + + // SetDash sets the dash pattern used by Stroke. + // Passing no values clears the current dash pattern. + SetDash(dashes ...float64) + + // SetDashOffset sets the dash phase used by Stroke. + SetDashOffset(offset float64) + + // AddPoint appends a point marker centered at (x, y) with radius r + // to the current path in canvas pixel coordinates. + AddPoint(x, y, r float64) + + // AddLine appends a line segment to the current path in canvas pixel coordinates. + AddLine(x1, y1, x2, y2 float64) + + // AddCircle appends a circle to the current path in canvas pixel coordinates. + AddCircle(cx, cy, r float64) + + // Stroke renders the current path using the current stroke state. + Stroke() + + // Fill renders the current path using the current fill state. + Fill() + + // CopyShift shifts the current backing image by (dx, dy) in canvas pixels. + // dx > 0 shifts the image to the right, dy > 0 shifts the image down. + // Newly exposed areas are cleared to transparent. + CopyShift(dx, dy int) + + // ClearAll clears the entire backing canvas to transparent. + // Must NOT affect the current clip state. + ClearAll() + + // ClearRect clears a pixel rectangle to transparent. + // Must NOT affect the current clip state. + ClearRect(x, y, w, h int) +} + +// ggClipRect stores one clip rectangle in canvas pixel coordinates. +// GGDrawer replays these rectangles on Restore because gg.Context Push/Pop +// do not restore clip masks the way this package expects. +type ggClipRect struct { + x, y float64 + w, h float64 +} + +// GGDrawer is a PrimitiveDrawer implementation backed by gg.Context. +// +// It intentionally does not perform any world logic. It only forwards already +// projected canvas coordinates to gg while additionally maintaining a clip stack +// compatible with this package's Save/Restore contract. +type GGDrawer struct { + DC *gg.Context + + clips []ggClipRect + clipStack [][]ggClipRect + + // scratch is a reusable buffer for CopyShift to avoid allocations. + scratch *image.RGBA +} + +// Save stores the current gg state and the current logical clip stack. +func (d *GGDrawer) Save() { + d.DC.Push() + + snapshot := append([]ggClipRect(nil), d.clips...) + d.clipStack = append(d.clipStack, snapshot) +} + +// Restore restores the previous gg state and rebuilds the outer clip state. +// +// gg.Context.Pop restores most state from the stack, but its clip mask handling +// does not match this package's expected Save/Restore semantics. To preserve the +// contract, GGDrawer explicitly resets the clip and replays the previously saved +// clip rectangles after Pop. +func (d *GGDrawer) Restore() { + if len(d.clipStack) == 0 { + panic("GGDrawer: Restore without matching Save") + } + + snapshot := d.clipStack[len(d.clipStack)-1] + d.clipStack = d.clipStack[:len(d.clipStack)-1] + + d.DC.Pop() + + d.clips = append([]ggClipRect(nil), snapshot...) + d.DC.ResetClip() + for _, clip := range d.clips { + d.DC.DrawRectangle(clip.x, clip.y, clip.w, clip.h) + d.DC.Clip() + } +} + +// ResetClip clears the current clipping region and the logical clip stack +// for the active state frame. +func (d *GGDrawer) ResetClip() { + d.DC.ResetClip() + d.clips = nil +} + +// ClipRect intersects the current clipping region with the given rectangle +// and records it so the clip can be reconstructed after Restore. +func (d *GGDrawer) ClipRect(x, y, w, h float64) { + d.DC.DrawRectangle(x, y, w, h) + d.DC.Clip() + + d.clips = append(d.clips, ggClipRect{x: x, y: y, w: w, h: h}) +} + +// SetStrokeColor sets the stroke color by installing a solid stroke pattern. +func (d *GGDrawer) SetStrokeColor(c color.Color) { + d.DC.SetStrokeStyle(gg.NewSolidPattern(c)) +} + +// SetFillColor sets the fill color by installing a solid fill pattern. +func (d *GGDrawer) SetFillColor(c color.Color) { + d.DC.SetFillStyle(gg.NewSolidPattern(c)) +} + +// SetLineWidth sets the line width used for stroking. +func (d *GGDrawer) SetLineWidth(width float64) { + d.DC.SetLineWidth(width) +} + +// SetDash sets the dash pattern used for stroking. +func (d *GGDrawer) SetDash(dashes ...float64) { + d.DC.SetDash(dashes...) +} + +// SetDashOffset sets the dash phase used for stroking. +func (d *GGDrawer) SetDashOffset(offset float64) { + d.DC.SetDashOffset(offset) +} + +// AddPoint appends a point marker to the current path. +func (d *GGDrawer) AddPoint(x, y, r float64) { + d.DC.DrawPoint(x, y, r) +} + +// AddLine appends a line segment to the current path. +func (d *GGDrawer) AddLine(x1, y1, x2, y2 float64) { + d.DC.DrawLine(x1, y1, x2, y2) +} + +// AddCircle appends a circle to the current path. +func (d *GGDrawer) AddCircle(cx, cy, r float64) { + d.DC.DrawCircle(cx, cy, r) +} + +// Stroke renders the current path using the current stroke state. +func (d *GGDrawer) Stroke() { + d.DC.Stroke() +} + +// Fill renders the current path using the current fill state. +func (d *GGDrawer) Fill() { + d.DC.Fill() +} + +// CopyShift shifts the backing RGBA image by (dx, dy) pixels. +// It clears newly exposed areas to transparent. +func (d *GGDrawer) CopyShift(dx, dy int) { + if dx == 0 && dy == 0 { + return + } + + img, ok := d.DC.Image().(*image.RGBA) + if !ok || img == nil { + panic("GGDrawer.CopyShift: backing image is not *image.RGBA") + } + + b := img.Bounds() + w := b.Dx() + h := b.Dy() + if w <= 0 || h <= 0 { + return + } + + adx := abs(dx) + ady := abs(dy) + if adx >= w || ady >= h { + // Everything shifts out of bounds => just clear. + for i := range img.Pix { + img.Pix[i] = 0 + } + return + } + + // Prepare scratch with the same bounds. + if d.scratch == nil || d.scratch.Bounds().Dx() != w || d.scratch.Bounds().Dy() != h { + d.scratch = image.NewRGBA(b) + } else { + // Clear scratch to transparent. + for i := range d.scratch.Pix { + d.scratch.Pix[i] = 0 + } + } + + // Compute source/destination rectangles. + dstX0 := 0 + dstY0 := 0 + srcX0 := 0 + srcY0 := 0 + if dx > 0 { + dstX0 = dx + } else { + srcX0 = -dx + } + if dy > 0 { + dstY0 = dy + } else { + srcY0 = -dy + } + + copyW := w - max(dstX0, srcX0) + copyH := h - max(dstY0, srcY0) + if copyW <= 0 || copyH <= 0 { + for i := range img.Pix { + img.Pix[i] = 0 + } + return + } + + // Copy row-by-row (RGBA, 4 bytes per pixel). + for row := 0; row < copyH; row++ { + srcY := srcY0 + row + dstY := dstY0 + row + + srcOff := srcY*img.Stride + srcX0*4 + dstOff := dstY*d.scratch.Stride + dstX0*4 + n := copyW * 4 + + copy(d.scratch.Pix[dstOff:dstOff+n], img.Pix[srcOff:srcOff+n]) + } + + // Swap buffers by copying scratch into img. + // (We keep img pointer stable for gg.Context.) + copy(img.Pix, d.scratch.Pix) +} + +// ClearAll clears the whole RGBA backing image to transparent. +// It does not touch gg clip state. +func (d *GGDrawer) ClearAll() { + img, ok := d.DC.Image().(*image.RGBA) + if !ok || img == nil { + panic("GGDrawer.ClearAll: backing image is not *image.RGBA") + } + for i := range img.Pix { + img.Pix[i] = 0 + } +} + +// ClearRect clears a region to transparent. It does not touch gg clip state. +// Rectangle is clamped to the image bounds. +func (d *GGDrawer) ClearRect(x, y, w, h int) { + if w <= 0 || h <= 0 { + return + } + img, ok := d.DC.Image().(*image.RGBA) + if !ok || img == nil { + panic("GGDrawer.ClearRect: backing image is not *image.RGBA") + } + + b := img.Bounds() + x0 := max(x, b.Min.X) + y0 := max(y, b.Min.Y) + x1 := min(x+w, b.Max.X) + y1 := min(y+h, b.Max.Y) + if x0 >= x1 || y0 >= y1 { + return + } + + // Zero rows. + for yy := y0; yy < y1; yy++ { + off := yy*img.Stride + x0*4 + n := (x1 - x0) * 4 + for i := 0; i < n; i++ { + img.Pix[off+i] = 0 + } + } +} diff --git a/client/world/drawer_test.go b/client/world/drawer_test.go new file mode 100644 index 0000000..cabf455 --- /dev/null +++ b/client/world/drawer_test.go @@ -0,0 +1,276 @@ +package world + +import ( + "image" + "image/color" + "testing" + + "github.com/fogleman/gg" + "github.com/stretchr/testify/require" +) + +func hasAnyNonTransparentPixel(img image.Image) bool { + b := img.Bounds() + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + _, _, _, a := img.At(x, y).RGBA() + if a != 0 { + return true + } + } + } + return false +} + +func pixelHasAlpha(img image.Image, x, y int) bool { + _, _, _, a := img.At(x, y).RGBA() + return a != 0 +} + +func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(32, 32) + drawer := &GGDrawer{DC: dc} + + drawer.SetStrokeColor(color.RGBA{R: 255, A: 255}) + drawer.SetLineWidth(2) + drawer.SetDash(4, 2) + drawer.SetDashOffset(1) + drawer.AddLine(4, 16, 28, 16) + drawer.Stroke() + + require.True(t, hasAnyNonTransparentPixel(dc.Image())) +} + +func TestGGDrawerFillSequenceProducesPixels(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(32, 32) + drawer := &GGDrawer{DC: dc} + + drawer.SetFillColor(color.RGBA{G: 255, A: 255}) + drawer.AddCircle(16, 16, 6) + drawer.Fill() + + require.True(t, pixelHasAlpha(dc.Image(), 16, 16)) +} + +func TestGGDrawerPointSequenceProducesPixels(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(32, 32) + drawer := &GGDrawer{DC: dc} + + drawer.SetFillColor(color.RGBA{B: 255, A: 255}) + drawer.AddPoint(16, 16, 3) + drawer.Fill() + + require.True(t, pixelHasAlpha(dc.Image(), 16, 16)) +} + +func TestGGDrawerClipRectLimitsDrawing(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(32, 32) + drawer := &GGDrawer{DC: dc} + + drawer.Save() + drawer.ClipRect(0, 0, 10, 32) + drawer.SetFillColor(color.RGBA{B: 255, A: 255}) + drawer.AddCircle(15, 16, 10) + drawer.Fill() + drawer.Restore() + + img := dc.Image() + + require.True(t, pixelHasAlpha(img, 5, 16)) + require.False(t, pixelHasAlpha(img, 15, 16)) +} + +func TestGGDrawerResetClipClearsClip(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(32, 32) + drawer := &GGDrawer{DC: dc} + + drawer.ClipRect(0, 0, 10, 32) + drawer.ResetClip() + drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255}) + drawer.AddCircle(15, 16, 10) + drawer.Fill() + + require.True(t, pixelHasAlpha(dc.Image(), 15, 16)) +} + +func TestGGDrawerClearRect_ClearsPixels(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(10, 10) + dr := &GGDrawer{DC: dc} + + // Draw something everywhere. + dr.SetFillColor(color.RGBA{R: 255, A: 255}) + dr.ClipRect(0, 0, 10, 10) + dr.AddCircle(5, 5, 5) + dr.Fill() + dr.ResetClip() + + // Clear a 2x2 rect at (1,1) + dr.ClearRect(1, 1, 2, 2) + + img := dc.Image() + _, _, _, a := img.At(1, 1).RGBA() + require.Equal(t, uint32(0), a) + + // Pixel outside should remain non-zero alpha. + _, _, _, a2 := img.At(5, 5).RGBA() + require.NotEqual(t, uint32(0), a2) +} + +func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(32, 32) + drawer := &GGDrawer{DC: dc} + + drawer.Save() + drawer.ClipRect(0, 0, 10, 32) + drawer.Restore() + + drawer.SetFillColor(color.RGBA{R: 255, A: 255}) + drawer.AddCircle(15, 16, 10) + drawer.Fill() + + require.True(t, pixelHasAlpha(dc.Image(), 15, 16)) +} + +func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(32, 32) + drawer := &GGDrawer{DC: dc} + + drawer.ClipRect(0, 0, 20, 32) + + drawer.Save() + drawer.ClipRect(0, 0, 10, 32) + drawer.Restore() + + drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255}) + drawer.AddCircle(15, 16, 10) + drawer.Fill() + + img := dc.Image() + + require.True(t, pixelHasAlpha(img, 15, 16)) + require.False(t, pixelHasAlpha(img, 25, 16)) +} + +func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) { + t.Parallel() + + d := &fakePrimitiveDrawer{} + + d.Save() + d.ClipRect(1, 2, 30, 40) + d.SetStrokeColor(color.RGBA{R: 10, G: 20, B: 30, A: 255}) + d.SetFillColor(color.RGBA{R: 40, G: 50, B: 60, A: 255}) + d.SetLineWidth(3) + d.SetDash(5, 6) + d.SetDashOffset(7) + d.AddLine(10, 11, 12, 13) + d.Stroke() + d.Restore() + + requireDrawerCommandNames(t, d, + "Save", + "ClipRect", + "SetStrokeColor", + "SetFillColor", + "SetLineWidth", + "SetDash", + "SetDashOffset", + "AddLine", + "Stroke", + "Restore", + ) + + cmd := requireDrawerSingleCommand(t, d, "AddLine") + requireCommandArgs(t, cmd, 10, 11, 12, 13) + requireCommandLineWidth(t, cmd, 3) + requireCommandDashes(t, cmd, 5, 6) + requireCommandDashOffset(t, cmd, 7) + requireCommandClipRects(t, cmd, fakeClipRect{X: 1, Y: 2, W: 30, H: 40}) + require.Equal(t, color.RGBA{R: 10, G: 20, B: 30, A: 255}, cmd.StrokeColor) + require.Equal(t, color.RGBA{R: 40, G: 50, B: 60, A: 255}, cmd.FillColor) +} + +func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) { + t.Parallel() + + d := &fakePrimitiveDrawer{} + + require.Panics(t, func() { + d.Restore() + }) +} + +func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) { + t.Parallel() + + d := &fakePrimitiveDrawer{} + + d.SetLineWidth(1) + d.Save() + d.SetLineWidth(9) + d.ClipRect(1, 2, 3, 4) + d.Restore() + + state := d.CurrentState() + + require.Equal(t, 1.0, state.LineWidth) + require.Empty(t, state.Clips) + require.Equal(t, 0, d.SaveDepth()) +} + +func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) { + t.Parallel() + + d := &fakePrimitiveDrawer{} + + d.SetLineWidth(4) + d.ClipRect(1, 2, 3, 4) + d.ResetClip() + + state := d.CurrentState() + + require.Equal(t, 4.0, state.LineWidth) + require.Empty(t, state.Clips) +} + +func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) { + t.Parallel() + + dc := gg.NewContext(10, 10) + drawer := &GGDrawer{DC: dc} + + // Draw a single filled point at (1,1). + drawer.SetFillColor(color.RGBA{R: 255, A: 255}) + drawer.AddPoint(1, 1, 1) + drawer.Fill() + + // Shift image right by 2 and down by 3. + drawer.CopyShift(2, 3) + + img := dc.Image() + + // The old pixel near (1,1) should now be present near (3,4). + // We check alpha only to avoid depending on exact blending. + _, _, _, a := img.At(3, 4).RGBA() + require.NotEqual(t, uint32(0), a) + + // A pixel in the newly exposed top-left area should be transparent. + _, _, _, a2 := img.At(0, 0).RGBA() + require.Equal(t, uint32(0), a2) +} diff --git a/client/world/fake_drawer_helpers_test.go b/client/world/fake_drawer_helpers_test.go new file mode 100644 index 0000000..d37eba8 --- /dev/null +++ b/client/world/fake_drawer_helpers_test.go @@ -0,0 +1,95 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// requireDrawerCommandNames asserts the exact command sequence recorded +// by fakePrimitiveDrawer. +func requireDrawerCommandNames(t *testing.T, d *fakePrimitiveDrawer, want ...string) { + t.Helper() + + require.Equal(t, want, d.CommandNames()) +} + +// requireDrawerCommandCount asserts the number of recorded commands. +func requireDrawerCommandCount(t *testing.T, d *fakePrimitiveDrawer, want int) { + t.Helper() + + require.Len(t, d.Commands(), want) +} + +// requireDrawerCommandAt returns the command at the specified index. +func requireDrawerCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmds := d.Commands() + require.GreaterOrEqual(t, index, 0) + require.Less(t, index, len(cmds)) + + return cmds[index] +} + +// requireDrawerSingleCommand returns the only command with the given name. +func requireDrawerSingleCommand(t *testing.T, d *fakePrimitiveDrawer, name string) fakeDrawerCommand { + t.Helper() + + cmds := d.CommandsByName(name) + require.Len(t, cmds, 1) + + return cmds[0] +} + +// requireCommandName asserts the command name. +func requireCommandName(t *testing.T, cmd fakeDrawerCommand, want string) { + t.Helper() + + require.Equal(t, want, cmd.Name) +} + +// requireCommandArgs asserts the exact float arguments. +func requireCommandArgs(t *testing.T, cmd fakeDrawerCommand, want ...float64) { + t.Helper() + + require.Equal(t, want, cmd.Args) +} + +// requireCommandArgsInDelta asserts the float arguments with tolerance. +func requireCommandArgsInDelta(t *testing.T, cmd fakeDrawerCommand, delta float64, want ...float64) { + t.Helper() + + require.Len(t, cmd.Args, len(want)) + for i := range want { + require.InDelta(t, want[i], cmd.Args[i], delta, "arg index %d", i) + } +} + +// requireCommandClipRects asserts the clip stack snapshot attached to the command. +func requireCommandClipRects(t *testing.T, cmd fakeDrawerCommand, want ...fakeClipRect) { + t.Helper() + + require.Equal(t, want, cmd.Clips) +} + +// requireCommandLineWidth asserts the line width snapshot attached to the command. +func requireCommandLineWidth(t *testing.T, cmd fakeDrawerCommand, want float64) { + t.Helper() + + require.Equal(t, want, cmd.LineWidth) +} + +// requireCommandDashes asserts the dash snapshot attached to the command. +func requireCommandDashes(t *testing.T, cmd fakeDrawerCommand, want ...float64) { + t.Helper() + + require.Equal(t, want, cmd.Dashes) +} + +// requireCommandDashOffset asserts the dash offset snapshot attached to the command. +func requireCommandDashOffset(t *testing.T, cmd fakeDrawerCommand, want float64) { + t.Helper() + + require.Equal(t, want, cmd.DashOffset) +} diff --git a/client/world/fake_drawer_test.go b/client/world/fake_drawer_test.go new file mode 100644 index 0000000..015f1d4 --- /dev/null +++ b/client/world/fake_drawer_test.go @@ -0,0 +1,240 @@ +package world + +import ( + "fmt" + "image/color" +) + +// fakeClipRect describes one clip rectangle in canvas pixel coordinates. +type fakeClipRect struct { + X, Y float64 + W, H float64 +} + +// fakeDrawerState stores the active fake drawing state. +// The state is copied on Save and restored on Restore. +type fakeDrawerState struct { + StrokeColor color.RGBA + FillColor color.RGBA + LineWidth float64 + Dashes []float64 + DashOffset float64 + Clips []fakeClipRect +} + +// clone returns a deep copy of the state. +func (s fakeDrawerState) clone() fakeDrawerState { + out := s + out.Dashes = append([]float64(nil), s.Dashes...) + out.Clips = append([]fakeClipRect(nil), s.Clips...) + return out +} + +// fakeDrawerCommand is one recorded drawer call together with a snapshot +// of the active fake drawing state at the moment of the call. +type fakeDrawerCommand struct { + Name string + Args []float64 + StrokeColor color.RGBA + FillColor color.RGBA + LineWidth float64 + Dashes []float64 + DashOffset float64 + Clips []fakeClipRect +} + +// String returns a compact debug representation useful in assertion failures. +func (c fakeDrawerCommand) String() string { + return fmt.Sprintf( + "%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v", + c.Name, + c.Args, + c.StrokeColor, + c.FillColor, + c.LineWidth, + c.Dashes, + c.DashOffset, + c.Clips, + ) +} + +// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double. +// It records all calls and emulates stateful behavior, including nested +// Save/Restore and clip reset semantics. +type fakePrimitiveDrawer struct { + commands []fakeDrawerCommand + state fakeDrawerState + stack []fakeDrawerState +} + +// Ensure fakePrimitiveDrawer implements PrimitiveDrawer. +var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil) + +// rgbaColor converts any color.Color into a comparable RGBA value. +func rgbaColor(c color.Color) color.RGBA { + if c == nil { + return color.RGBA{} + } + return color.RGBAModel.Convert(c).(color.RGBA) +} + +// snapshotCommand records one command together with the current state snapshot. +func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) { + cmd := fakeDrawerCommand{ + Name: name, + Args: append([]float64(nil), args...), + StrokeColor: d.state.StrokeColor, + FillColor: d.state.FillColor, + LineWidth: d.state.LineWidth, + Dashes: append([]float64(nil), d.state.Dashes...), + DashOffset: d.state.DashOffset, + Clips: append([]fakeClipRect(nil), d.state.Clips...), + } + d.commands = append(d.commands, cmd) +} + +// Save stores the current fake state. +func (d *fakePrimitiveDrawer) Save() { + d.stack = append(d.stack, d.state.clone()) + d.snapshotCommand("Save") +} + +// Restore restores the most recently saved fake state. +func (d *fakePrimitiveDrawer) Restore() { + if len(d.stack) == 0 { + panic("fakePrimitiveDrawer: Restore without matching Save") + } + + d.state = d.stack[len(d.stack)-1] + d.stack = d.stack[:len(d.stack)-1] + d.snapshotCommand("Restore") +} + +// ResetClip clears the current fake clip stack. +func (d *fakePrimitiveDrawer) ResetClip() { + d.state.Clips = nil + d.snapshotCommand("ResetClip") +} + +// ClipRect appends one clip rectangle to the current fake state. +func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) { + d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h}) + d.snapshotCommand("ClipRect", x, y, w, h) +} + +// SetStrokeColor sets the current fake stroke color. +func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) { + d.state.StrokeColor = rgbaColor(c) + d.snapshotCommand("SetStrokeColor") +} + +// SetFillColor sets the current fake fill color. +func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) { + d.state.FillColor = rgbaColor(c) + d.snapshotCommand("SetFillColor") +} + +// SetLineWidth sets the current fake line width. +func (d *fakePrimitiveDrawer) SetLineWidth(width float64) { + d.state.LineWidth = width + d.snapshotCommand("SetLineWidth", width) +} + +// SetDash sets the current fake dash pattern. +func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) { + d.state.Dashes = append([]float64(nil), dashes...) + d.snapshotCommand("SetDash", dashes...) +} + +// SetDashOffset sets the current fake dash offset. +func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) { + d.state.DashOffset = offset + d.snapshotCommand("SetDashOffset", offset) +} + +// AddPoint records a point path append command. +func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) { + d.snapshotCommand("AddPoint", x, y, r) +} + +// AddLine records a line path append command. +func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) { + d.snapshotCommand("AddLine", x1, y1, x2, y2) +} + +// AddCircle records a circle path append command. +func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) { + d.snapshotCommand("AddCircle", cx, cy, r) +} + +// Stroke records a stroke finalization command. +func (d *fakePrimitiveDrawer) Stroke() { + d.snapshotCommand("Stroke") +} + +// Fill records a fill finalization command. +func (d *fakePrimitiveDrawer) Fill() { + d.snapshotCommand("Fill") +} + +// Commands returns a defensive copy of the recorded command log. +func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand { + out := make([]fakeDrawerCommand, len(d.commands)) + copy(out, d.commands) + return out +} + +// CommandNames returns only command names in call order. +func (d *fakePrimitiveDrawer) CommandNames() []string { + out := make([]string, 0, len(d.commands)) + for _, cmd := range d.commands { + out = append(out, cmd.Name) + } + return out +} + +// CommandsByName returns all commands with the given name. +func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand { + var out []fakeDrawerCommand + for _, cmd := range d.commands { + if cmd.Name == name { + out = append(out, cmd) + } + } + return out +} + +// LastCommand returns the last recorded command and whether it exists. +func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) { + if len(d.commands) == 0 { + return fakeDrawerCommand{}, false + } + return d.commands[len(d.commands)-1], true +} + +// CurrentState returns a defensive copy of the current fake state. +func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState { + return d.state.clone() +} + +// SaveDepth returns the current Save/Restore nesting depth. +func (d *fakePrimitiveDrawer) SaveDepth() int { + return len(d.stack) +} + +// ResetLog clears only the command log and keeps the current state intact. +func (d *fakePrimitiveDrawer) ResetLog() { + d.commands = nil +} + +func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) { + d.snapshotCommand("CopyShift", float64(dx), float64(dy)) +} + +func (d *fakePrimitiveDrawer) ClearAll() { + d.snapshotCommand("ClearAll") +} + +func (d *fakePrimitiveDrawer) ClearRect(x, y, w, h int) { + d.snapshotCommand("ClearRect", float64(x), float64(y), float64(w), float64(h)) +} diff --git a/client/world/indexing_test.go b/client/world/indexing_test.go new file mode 100644 index 0000000..0cf7ad0 --- /dev/null +++ b/client/world/indexing_test.go @@ -0,0 +1,1055 @@ +package world + +import ( + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type gridCell struct { + Row int + Col int +} + +func newTestWorld(wReal, hReal int) *World { + return NewWorld(wReal, hReal) +} + +func countObjectInGrid(g *World, id uuid.UUID) int { + count := 0 + for row := range g.grid { + for col := range g.grid[row] { + for _, item := range g.grid[row][col] { + if item.ID() == id { + count++ + } + } + } + } + return count +} + +func hasObjectInCell(g *World, row, col int, id uuid.UUID) bool { + for _, item := range g.grid[row][col] { + if item.ID() == id { + return true + } + } + return false +} + +func TestViewportPxToWorldFixed(t *testing.T) { + tests := []struct { + name string + viewportWidthPx int + viewportHeightPx int + cameraZoom int + wantWidth int + wantHeight int + }{ + { + name: "zoom 1.0", + viewportWidthPx: 500, + viewportHeightPx: 400, + cameraZoom: SCALE, + wantWidth: 500 * SCALE, + wantHeight: 400 * SCALE, + }, + { + name: "zoom 2.0", + viewportWidthPx: 500, + viewportHeightPx: 400, + cameraZoom: 2 * SCALE, + wantWidth: 250 * SCALE, + wantHeight: 200 * SCALE, + }, + { + name: "zoom below 1.0", + viewportWidthPx: 550, + viewportHeightPx: 550, + cameraZoom: 917, + wantWidth: 599781, + wantHeight: 599781, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotW, gotH := viewportPxToWorldFixed(tt.viewportWidthPx, tt.viewportHeightPx, tt.cameraZoom) + require.Equal(t, tt.wantWidth, gotW) + require.Equal(t, tt.wantHeight, gotH) + }) + } +} + +func TestSplitByWrap_ZeroOrNegativeSizeReturnsNil(t *testing.T) { + tests := []struct { + name string + minX, maxX int + minY, maxY int + }{ + { + name: "zero width", + minX: 100, maxX: 100, + minY: 50, maxY: 100, + }, + { + name: "zero height", + minX: 100, maxX: 200, + minY: 50, maxY: 50, + }, + { + name: "negative width", + minX: 200, maxX: 100, + minY: 50, maxY: 100, + }, + { + name: "negative height", + minX: 100, maxX: 200, + minY: 100, maxY: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rects := splitByWrap(600, 400, tt.minX, tt.maxX, tt.minY, tt.maxY) + require.Nil(t, rects) + }) + } +} + +func TestSplitByWrap_XWrapUsesWorldWidth(t *testing.T) { + rects := splitByWrap( + 600, 400, + 500, 650, + 50, 100, + ) + + require.Len(t, rects, 2) + require.Equal(t, Rect{minX: 500, maxX: 600, minY: 50, maxY: 100}, rects[0]) + require.Equal(t, Rect{minX: 0, maxX: 50, minY: 50, maxY: 100}, rects[1]) +} + +func TestSplitByWrap_YWrapUsesWorldHeight(t *testing.T) { + rects := splitByWrap( + 600, 400, + 50, 100, + 350, 450, + ) + + require.Len(t, rects, 2) + require.Equal(t, Rect{minX: 50, maxX: 100, minY: 350, maxY: 400}, rects[0]) + require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 50}, rects[1]) +} + +func TestSplitByWrap_XAndYWrap(t *testing.T) { + rects := splitByWrap( + 600, 400, + 550, 650, + 350, 450, + ) + + require.Len(t, rects, 4) + require.ElementsMatch(t, []Rect{ + {minX: 550, maxX: 600, minY: 350, maxY: 400}, + {minX: 550, maxX: 600, minY: 0, maxY: 50}, + {minX: 0, maxX: 50, minY: 350, maxY: 400}, + {minX: 0, maxX: 50, minY: 0, maxY: 50}, + }, rects) +} + +func TestSplitByWrap_NoWrapInsideWorld(t *testing.T) { + rects := splitByWrap( + 600, 400, + 100, 200, + 50, 100, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 100, maxX: 200, minY: 50, maxY: 100}, rects[0]) +} + +func TestSplitByWrap_FullWorldCoverageOnEqualWidth(t *testing.T) { + rects := splitByWrap( + 600, 400, + 0, 600, + 50, 100, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 0, maxX: 600, minY: 50, maxY: 100}, rects[0]) +} + +func TestSplitByWrap_FullWorldCoverageOnEqualHeight(t *testing.T) { + rects := splitByWrap( + 600, 400, + 50, 100, + 0, 400, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 50, maxX: 100, minY: 0, maxY: 400}, rects[0]) +} + +func TestSplitByWrap_FullWorldCoverageOnBothAxes(t *testing.T) { + rects := splitByWrap( + 600, 400, + 0, 600, + 0, 400, + ) + + require.Len(t, rects, 1) + require.Equal(t, Rect{minX: 0, maxX: 600, minY: 0, maxY: 400}, rects[0]) +} + +func TestWorldToCell(t *testing.T) { + tests := []struct { + name string + value int + worldSize int + cells int + cellSize int + want int + }{ + { + name: "simple inside world", + value: 150, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 1, + }, + { + name: "negative wraps to last cell", + value: -1, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 5, + }, + { + name: "exact world size wraps to zero", + value: 600, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 0, + }, + { + name: "large positive wraps correctly", + value: 650, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 0, + }, + { + name: "last in-range value lands in last cell", + value: 599, + worldSize: 600, + cells: 6, + cellSize: 100, + want: 5, + }, + {name: "first cell", value: 0, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, + {name: "middle cell", value: 2500, worldSize: 10000, cells: 5, cellSize: 2000, want: 1}, + {name: "last exact world point wraps to zero", value: 10000, worldSize: 10000, cells: 5, cellSize: 2000, want: 0}, + {name: "negative wraps to last", value: -1, worldSize: 10000, cells: 5, cellSize: 2000, want: 4}, + {name: "partial last cell is clamped", value: 9999, worldSize: 10000, cells: 4, cellSize: 3000, want: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := worldToCell(tt.value, tt.worldSize, tt.cells, tt.cellSize) + require.Equal(t, tt.want, got) + }) + } +} + +func TestResetGrid_UsesWidthForColsAndHeightForRows(t *testing.T) { + g := newTestWorld(600, 400) + + g.resetGrid(100 * SCALE) + + require.Equal(t, 6, g.cols) + require.Equal(t, 4, g.rows) + require.Len(t, g.grid, 4) + require.Len(t, g.grid[0], 6) +} + +func TestIndexPoint(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + p := Point{ + Id: id, + X: 150 * SCALE, + Y: 250 * SCALE, + } + + g.indexObject(p) + + require.True(t, hasObjectInCell(g, 2, 1, id)) + require.Equal(t, 1, countObjectInGrid(g, id)) +} + +func TestIndexPoint_WrapsNegativeCoordinates(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + p := Point{ + Id: id, + X: -1, + Y: -1, + } + + g.indexObject(p) + + require.True(t, hasObjectInCell(g, 5, 5, id)) + require.Equal(t, 1, countObjectInGrid(g, id)) +} + +func TestIndexCircle_WrapsAcrossLeftAndTopEdges(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + c := Circle{ + Id: id, + X: 50 * SCALE, + Y: 50 * SCALE, + Radius: 75 * SCALE, + } + + g.indexObject(c) + + // The circle spans [-25..125] on both axes. + // It must appear both near zero and near the wrapped end. + require.True(t, hasObjectInCell(g, 0, 0, id)) + require.True(t, hasObjectInCell(g, 0, 5, id)) + require.True(t, hasObjectInCell(g, 5, 0, id)) + require.True(t, hasObjectInCell(g, 5, 5, id)) + + // It also extends into the next cells near the origin. + require.True(t, hasObjectInCell(g, 0, 1, id)) + require.True(t, hasObjectInCell(g, 1, 0, id)) + require.True(t, hasObjectInCell(g, 1, 1, id)) +} + +func TestIndexCircle_NoWrap(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + c := Circle{ + Id: id, + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + } + + g.indexObject(c) + + require.True(t, hasObjectInCell(g, 2, 2, id)) + require.True(t, hasObjectInCell(g, 2, 3, id)) + require.True(t, hasObjectInCell(g, 3, 2, id)) + require.True(t, hasObjectInCell(g, 3, 3, id)) +} + +func TestIndexCircle_CoversWholeWorldWhenLargerThanWorld(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + c := Circle{ + Id: id, + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 400 * SCALE, + } + + g.indexObject(c) + + for row := 0; row < g.rows; row++ { + for col := 0; col < g.cols; col++ { + require.Truef(t, hasObjectInCell(g, row, col, id), "missing object in row=%d col=%d", row, col) + } + } +} + +func TestIndexLine_HorizontalWrap(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + l := Line{ + Id: id, + X1: 590 * SCALE, + Y1: 200 * SCALE, + X2: 10 * SCALE, + Y2: 200 * SCALE, + } + + g.indexObject(l) + + // The shortest torus representation crosses the right/left border. + require.True(t, hasObjectInCell(g, 2, 5, id)) + require.True(t, hasObjectInCell(g, 2, 0, id)) +} + +func TestIndexLine_VerticalWrap(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + l := Line{ + Id: id, + X1: 200 * SCALE, + Y1: 590 * SCALE, + X2: 200 * SCALE, + Y2: 10 * SCALE, + } + + g.indexObject(l) + + require.True(t, hasObjectInCell(g, 5, 2, id)) + require.True(t, hasObjectInCell(g, 0, 2, id)) +} + +func TestIndexLine_DiagonalWrapBothAxes(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + l := Line{ + Id: id, + X1: 590 * SCALE, + Y1: 590 * SCALE, + X2: 10 * SCALE, + Y2: 10 * SCALE, + } + + g.indexObject(l) + + require.True(t, hasObjectInCell(g, 5, 5, id)) + require.True(t, hasObjectInCell(g, 0, 0, id)) +} + +func TestIndexLine_HorizontalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + l := Line{ + Id: id, + X1: 100 * SCALE, + Y1: 200 * SCALE, + X2: 300 * SCALE, + Y2: 200 * SCALE, + } + + g.indexObject(l) + + // The indexed interval is half-open: [100,300). + // Therefore it occupies columns 1 and 2, but not column 3. + require.True(t, hasObjectInCell(g, 2, 1, id)) + require.True(t, hasObjectInCell(g, 2, 2, id)) + require.False(t, hasObjectInCell(g, 2, 3, id)) +} + +func TestIndexLine_VerticalNoWrap_DegenerateBBoxStillIndexes(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + l := Line{ + Id: id, + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 200 * SCALE, + Y2: 300 * SCALE, + } + + g.indexObject(l) + + // The indexed interval is half-open: [100,300). + // Therefore it occupies rows 1 and 2, but not row 3. + require.True(t, hasObjectInCell(g, 1, 2, id)) + require.True(t, hasObjectInCell(g, 2, 2, id)) + require.False(t, hasObjectInCell(g, 3, 2, id)) +} + +func TestIndexLine_ZeroLengthIndexesSingleCell(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + l := Line{ + Id: id, + X1: 250 * SCALE, + Y1: 350 * SCALE, + X2: 250 * SCALE, + Y2: 350 * SCALE, + } + + g.indexObject(l) + + require.True(t, hasObjectInCell(g, 3, 2, id)) + require.Equal(t, 1, countObjectInGrid(g, id)) +} + +func TestIndexLine_ExactlyOnCellBoundaryUsesHalfOpenInterval(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + l := Line{ + Id: id, + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 400 * SCALE, + Y2: 100 * SCALE, + } + + g.indexObject(l) + + // The indexed interval is [200,400), so it must occupy columns 2 and 3 only. + require.True(t, hasObjectInCell(g, 1, 2, id)) + require.True(t, hasObjectInCell(g, 1, 3, id)) + require.False(t, hasObjectInCell(g, 1, 4, id)) +} + +func collectOccupiedCells(g *World, id uuid.UUID) []gridCell { + var cells []gridCell + for row := range g.grid { + for col := range g.grid[row] { + for _, item := range g.grid[row][col] { + if item.ID() == id { + cells = append(cells, gridCell{Row: row, Col: col}) + break + } + } + } + } + return cells +} + +func allGridCells(rows, cols int) []gridCell { + cells := make([]gridCell, 0, rows*cols) + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + cells = append(cells, gridCell{Row: row, Col: col}) + } + } + return cells +} + +func requireIndexedExactlyInCells(t *testing.T, g *World, id uuid.UUID, want []gridCell) { + t.Helper() + + got := collectOccupiedCells(g, id) + + require.ElementsMatchf( + t, + want, + got, + "unexpected indexed cells for object %s", + id.String(), + ) +} + +func TestIndexObject_Point_TableDriven(t *testing.T) { + tests := []struct { + name string + worldW int + worldH int + cellSize int + item Point + wantCells []gridCell + }{ + { + name: "point inside world", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: uuid.New(), + X: 150 * SCALE, + Y: 250 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 1}, + }, + }, + { + name: "point wraps from negative coordinates to last cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: uuid.New(), + X: -1, + Y: -1, + }, + wantCells: []gridCell{ + {Row: 5, Col: 5}, + }, + }, + { + name: "point exactly at world boundary wraps to zero cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: uuid.New(), + X: 600 * SCALE, + Y: 600 * SCALE, + }, + wantCells: []gridCell{ + {Row: 0, Col: 0}, + }, + }, + { + name: "point on cell boundary belongs to that cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Point{ + Id: uuid.New(), + X: 200 * SCALE, + Y: 300 * SCALE, + }, + wantCells: []gridCell{ + {Row: 3, Col: 2}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := newTestWorld(tt.worldW, tt.worldH) + g.resetGrid(tt.cellSize) + + g.indexObject(tt.item) + + requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) + }) + } +} + +func TestIndexObject_Circle_TableDriven(t *testing.T) { + tests := []struct { + name string + worldW int + worldH int + cellSize int + item Circle + wantCells []gridCell + }{ + { + name: "circle without wrap", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: uuid.New(), + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 2}, + {Row: 2, Col: 3}, + {Row: 3, Col: 2}, + {Row: 3, Col: 3}, + }, + }, + { + name: "circle wraps across left and top edges", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: uuid.New(), + X: 50 * SCALE, + Y: 50 * SCALE, + Radius: 75 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 5}, + {Row: 5, Col: 0}, + {Row: 5, Col: 1}, + {Row: 0, Col: 5}, + {Row: 0, Col: 0}, + {Row: 0, Col: 1}, + {Row: 1, Col: 5}, + {Row: 1, Col: 0}, + {Row: 1, Col: 1}, + }, + }, + { + name: "circle wraps across right edge only", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: uuid.New(), + X: 575 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 5}, + {Row: 2, Col: 0}, + {Row: 3, Col: 5}, + {Row: 3, Col: 0}, + }, + }, + { + name: "circle wraps across bottom edge only", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: uuid.New(), + X: 300 * SCALE, + Y: 575 * SCALE, + Radius: 50 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 2}, + {Row: 5, Col: 3}, + {Row: 0, Col: 2}, + {Row: 0, Col: 3}, + }, + }, + { + name: "circle larger than world covers the whole grid", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: uuid.New(), + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 400 * SCALE, + }, + wantCells: allGridCells(6, 6), + }, + { + name: "circle touching boundaries exactly uses half-open indexing", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Circle{ + Id: uuid.New(), + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 100 * SCALE, // bbox [200, 400) x [200, 400) + }, + wantCells: []gridCell{ + {Row: 2, Col: 2}, + {Row: 2, Col: 3}, + {Row: 3, Col: 2}, + {Row: 3, Col: 3}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := newTestWorld(tt.worldW, tt.worldH) + g.resetGrid(tt.cellSize) + + g.indexObject(tt.item) + + requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) + }) + } +} + +func TestIndexObject_Line_TableDriven(t *testing.T) { + tests := []struct { + name string + worldW int + worldH int + cellSize int + item Line + wantCells []gridCell + }{ + { + name: "horizontal line without wrap", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 100 * SCALE, + Y1: 200 * SCALE, + X2: 300 * SCALE, + Y2: 200 * SCALE, + }, + // Half-open interval [100,300), so only cols 1 and 2. + wantCells: []gridCell{ + {Row: 2, Col: 1}, + {Row: 2, Col: 2}, + }, + }, + { + name: "vertical line without wrap", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 200 * SCALE, + Y2: 300 * SCALE, + }, + // Half-open interval [100,300), so only rows 1 and 2. + wantCells: []gridCell{ + {Row: 1, Col: 2}, + {Row: 2, Col: 2}, + }, + }, + { + name: "horizontal line wraps across left right border", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 590 * SCALE, + Y1: 200 * SCALE, + X2: 10 * SCALE, + Y2: 200 * SCALE, + }, + wantCells: []gridCell{ + {Row: 2, Col: 5}, + {Row: 2, Col: 0}, + }, + }, + { + name: "vertical line wraps across top bottom border", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 200 * SCALE, + Y1: 590 * SCALE, + X2: 200 * SCALE, + Y2: 10 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 2}, + {Row: 0, Col: 2}, + }, + }, + { + name: "diagonal line wraps across both axes", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 590 * SCALE, + Y1: 590 * SCALE, + X2: 10 * SCALE, + Y2: 10 * SCALE, + }, + wantCells: []gridCell{ + {Row: 5, Col: 5}, + {Row: 5, Col: 0}, + {Row: 0, Col: 5}, + {Row: 0, Col: 0}, + }, + }, + { + name: "zero length line indexes a single cell", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 250 * SCALE, + Y1: 350 * SCALE, + X2: 250 * SCALE, + Y2: 350 * SCALE, + }, + wantCells: []gridCell{ + {Row: 3, Col: 2}, + }, + }, + { + name: "line exactly on cell boundaries follows half-open interval", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 200 * SCALE, + Y1: 100 * SCALE, + X2: 400 * SCALE, + Y2: 100 * SCALE, + }, + // [200,400) => cols 2 and 3 only. + wantCells: []gridCell{ + {Row: 1, Col: 2}, + {Row: 1, Col: 3}, + }, + }, + { + name: "diagonal line without wrap indexes its full bbox footprint", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 100 * SCALE, + Y1: 100 * SCALE, + X2: 300 * SCALE, + Y2: 300 * SCALE, + }, + // Indexing is bbox-based, not raster-based. + // The bbox is [100,300) x [100,300), so four cells. + wantCells: []gridCell{ + {Row: 1, Col: 1}, + {Row: 1, Col: 2}, + {Row: 2, Col: 1}, + {Row: 2, Col: 2}, + }, + }, + { + name: "horizontal wrap exactly on borders still indexes both edge cells", + worldW: 600, + worldH: 600, + cellSize: 100 * SCALE, + item: Line{ + Id: uuid.New(), + X1: 600 * SCALE, + Y1: 100 * SCALE, + X2: 0, + Y2: 100 * SCALE, + }, + // After wrapping both endpoints are equivalent to zero-width on the edge. + // The degenerate bbox expansion should still index the first cell only. + wantCells: []gridCell{ + {Row: 1, Col: 0}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := newTestWorld(tt.worldW, tt.worldH) + g.resetGrid(tt.cellSize) + + g.indexObject(tt.item) + + requireIndexedExactlyInCells(t, g, tt.item.Id, tt.wantCells) + }) + } +} + +func TestIndexOnViewportChange_RebuildsGridAndIndexesObjects(t *testing.T) { + g := newTestWorld(600, 400) + + pID := uuid.New() + cID := uuid.New() + lID := uuid.New() + + g.objects[pID] = Point{ + Id: pID, + X: 50 * SCALE, + Y: 50 * SCALE, + } + g.objects[cID] = Circle{ + Id: cID, + X: 300 * SCALE, + Y: 200 * SCALE, + Radius: 50 * SCALE, + } + g.objects[lID] = Line{ + Id: lID, + X1: 590 * SCALE, + Y1: 100 * SCALE, + X2: 10 * SCALE, + Y2: 100 * SCALE, + } + + g.IndexOnViewportChange(500, 300, 1.) + + require.Greater(t, g.cellSize, 0) + require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) + require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) + + require.Greaterf(t, countObjectInGrid(g, pID), 0, "point %s was not indexed", pID) + require.Greaterf(t, countObjectInGrid(g, cID), 0, "circle %s was not indexed", cID) + require.Greaterf(t, countObjectInGrid(g, lID), 0, "line %s was not indexed", lID) +} + +func TestIndexOnViewportChange_RebuildsGridShapeForNonSquareWorld(t *testing.T) { + g := newTestWorld(600, 400) + g.IndexOnViewportChange(500, 300, 1.) + + require.Equal(t, ceilDiv(g.W, g.cellSize), g.cols) + require.Equal(t, ceilDiv(g.H, g.cellSize), g.rows) + require.Len(t, g.grid, g.rows) + require.Len(t, g.grid[0], g.cols) +} + +func TestIndexOnViewportChange_ReindexesAfterCellSizeChange(t *testing.T) { + g := newTestWorld(600, 600) + + id := uuid.New() + g.objects[id] = Circle{ + Id: id, + X: 300 * SCALE, + Y: 300 * SCALE, + Radius: 50 * SCALE, + } + + g.IndexOnViewportChange(500, 500, 1.) + firstCellSize := g.cellSize + firstCount := countObjectInGrid(g, id) + + g.IndexOnViewportChange(200, 200, 1.) + secondCellSize := g.cellSize + secondCount := countObjectInGrid(g, id) + + require.NotEqual(t, firstCellSize, secondCellSize) + require.Greater(t, firstCount, 0) + require.Greater(t, secondCount, 0) + + if firstCellSize != secondCellSize && firstCount == secondCount { + t.Logf( + "cell size changed from %d to %d, but the indexed cell count happened to stay equal (%d)", + firstCellSize, + secondCellSize, + firstCount, + ) + } +} + +func TestPrimitiveIndexing_ErrorMessagesStayReadable(t *testing.T) { + g := newTestWorld(600, 600) + g.resetGrid(100 * SCALE) + + id := uuid.New() + p := Point{ + Id: id, + X: 100 * SCALE, + Y: 100 * SCALE, + } + + g.indexObject(p) + + got := collectOccupiedCells(g, id) + require.NotEmpty(t, got, fmt.Sprintf("object %s should occupy at least one cell", id.String())) +} diff --git a/client/world/primitive.go b/client/world/primitive.go new file mode 100644 index 0000000..e77f11c --- /dev/null +++ b/client/world/primitive.go @@ -0,0 +1,63 @@ +package world + +import ( + "github.com/google/uuid" +) + +// MapItem is the common interface implemented by all world primitives. +type MapItem interface { + ID() uuid.UUID +} + +// Point is a point primitive in fixed-point world coordinates. +type Point struct { + Id uuid.UUID + X, Y int +} + +// Line is a line segment primitive in fixed-point world coordinates. +type Line struct { + Id uuid.UUID + X1, Y1 int + X2, Y2 int +} + +// Circle is a circle primitive in fixed-point world coordinates. +type Circle struct { + Id uuid.UUID + X, Y int + Radius int +} + +// ID returns the point identifier. +func (p Point) ID() uuid.UUID { return p.Id } + +// ID returns the line identifier. +func (l Line) ID() uuid.UUID { return l.Id } + +// ID returns the circle identifier. +func (c Circle) ID() uuid.UUID { return c.Id } + +// MinX returns the minimum X endpoint coordinate of the line. +func (l Line) MinX() int { return min(l.X1, l.X2) } + +// MaxX returns the maximum X endpoint coordinate of the line. +func (l Line) MaxX() int { return max(l.X1, l.X2) } + +// MinY returns the minimum Y endpoint coordinate of the line. +func (l Line) MinY() int { return min(l.Y1, l.Y2) } + +// MaxY returns the maximum Y endpoint coordinate of the line. +func (l Line) MaxY() int { return max(l.Y1, l.Y2) } + +// MinX returns the minimum X coordinate of the circle bbox. +func (c Circle) MinX() int { return c.X - c.Radius } + +// MaxX returns the maximum X coordinate of the circle bbox. +func (c Circle) MaxX() int { return c.X + c.Radius } + +// MinY returns the minimum Y coordinate of the circle bbox. +func (c Circle) MinY() int { return c.Y - c.Radius } + +// MaxY returns the maximum Y coordinate of the circle bbox. +func (c Circle) MaxY() int { return c.Y + c.Radius } diff --git a/client/world/primitive_test.go b/client/world/primitive_test.go new file mode 100644 index 0000000..1054760 --- /dev/null +++ b/client/world/primitive_test.go @@ -0,0 +1,74 @@ +package world + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestPrimitiveIDs(t *testing.T) { + t.Parallel() + + id1 := uuid.New() + id2 := uuid.New() + id3 := uuid.New() + + p := Point{Id: id1} + l := Line{Id: id2} + c := Circle{Id: id3} + + if got := p.ID(); got != id1 { + t.Fatalf("Point.ID() = %v, want %v", got, id1) + } + if got := l.ID(); got != id2 { + t.Fatalf("Line.ID() = %v, want %v", got, id2) + } + if got := c.ID(); got != id3 { + t.Fatalf("Circle.ID() = %v, want %v", got, id3) + } +} + +func TestLineMinMax(t *testing.T) { + t.Parallel() + + l := Line{ + X1: 7000, Y1: 2000, + X2: 1000, Y2: 9000, + } + + if got := l.MinX(); got != 1000 { + t.Fatalf("Line.MinX() = %d, want 1000", got) + } + if got := l.MaxX(); got != 7000 { + t.Fatalf("Line.MaxX() = %d, want 7000", got) + } + if got := l.MinY(); got != 2000 { + t.Fatalf("Line.MinY() = %d, want 2000", got) + } + if got := l.MaxY(); got != 9000 { + t.Fatalf("Line.MaxY() = %d, want 9000", got) + } +} + +func TestCircleBounds(t *testing.T) { + t.Parallel() + + c := Circle{ + X: 4000, + Y: 7000, + Radius: 1500, + } + + if got := c.MinX(); got != 2500 { + t.Fatalf("Circle.MinX() = %d, want 2500", got) + } + if got := c.MaxX(); got != 5500 { + t.Fatalf("Circle.MaxX() = %d, want 5500", got) + } + if got := c.MinY(); got != 5500 { + t.Fatalf("Circle.MinY() = %d, want 5500", got) + } + if got := c.MaxY(); got != 8500 { + t.Fatalf("Circle.MaxY() = %d, want 8500", got) + } +} diff --git a/client/world/renderer.go b/client/world/renderer.go new file mode 100644 index 0000000..80d83e5 --- /dev/null +++ b/client/world/renderer.go @@ -0,0 +1,473 @@ +package world + +import ( + "errors" + "time" +) + +// RenderLayer identifies one drawing pass. +type RenderLayer int + +const ( + RenderLayerPoints RenderLayer = iota + RenderLayerCircles + RenderLayerLines +) + +// RenderOptions controls which layers are rendered and their order. +// If Layers is empty, the default order is: Points, Circles, Lines. +type RenderOptions struct { + Layers []RenderLayer + Style *RenderStyle + // Incremental controls incremental pan behavior. If nil, defaults are used. + Incremental *IncrementalPolicy +} + +var ( + errInvalidViewportSize = errors.New("render: invalid viewport size") + errInvalidMargins = errors.New("render: invalid margins") + errNilDrawer = errors.New("render: nil drawer") +) + +// RenderParams describes one render request coming from the UI layer. +// +// Camera coordinates are expressed in world fixed-point units and point to the +// center of the visible viewport. Margins are expressed in canvas pixels and +// extend the rendered area around the viewport on each axis independently. +// +// The final canvas size is derived from viewport size and margins: +// +// canvasWidthPx = viewportWidthPx + 2*marginXPx +// canvasHeightPx = viewportHeightPx + 2*marginYPx +type RenderParams struct { + ViewportWidthPx int + ViewportHeightPx int + + MarginXPx int + MarginYPx int + + CameraXWorldFp int + CameraYWorldFp int + + CameraZoom float64 + + // Optional render options. If nil, defaults are used. + Options *RenderOptions + + // Used for various debugging purposes + Debug bool +} + +// CanvasWidthPx returns the full expanded canvas width in pixels. +func (p RenderParams) CanvasWidthPx() int { return p.ViewportWidthPx + 2*p.MarginXPx } + +// CanvasHeightPx returns the full expanded canvas height in pixels. +func (p RenderParams) CanvasHeightPx() int { return p.ViewportHeightPx + 2*p.MarginYPx } + +// CameraZoomFp converts the UI-facing zoom value into the package fixed-point form. +func (p RenderParams) CameraZoomFp() (int, error) { + return cameraZoomToWorldFixed(p.CameraZoom) +} + +// ExpandedCanvasWorldRect returns the world-space half-open rectangle covered by +// the full expanded canvas around the camera center. +// +// The returned rectangle is expressed in fixed-point world coordinates and is not +// wrapped into [0, W) x [0, H). It may extend beyond world bounds on either axis; +// torus normalization and tiling are handled later by the renderer pipeline. +func (p RenderParams) ExpandedCanvasWorldRect() (Rect, error) { + zoomFp, err := p.CameraZoomFp() + if err != nil { + return Rect{}, err + } + + return expandedCanvasWorldRect( + p.CameraXWorldFp, + p.CameraYWorldFp, + p.CanvasWidthPx(), + p.CanvasHeightPx(), + zoomFp, + ), nil +} + +// Validate checks whether the render request is internally consistent. +// Camera coordinates are intentionally not restricted here because the renderer +// is expected to normalize them through torus wrap. +func (p RenderParams) Validate() error { + if p.ViewportWidthPx <= 0 || p.ViewportHeightPx <= 0 { + return errInvalidViewportSize + } + if p.MarginXPx < 0 || p.MarginYPx < 0 { + return errInvalidMargins + } + + if _, err := p.CameraZoomFp(); err != nil { + return err + } + + if p.CanvasWidthPx() <= 0 || p.CanvasHeightPx() <= 0 { + return errInvalidViewportSize + } + + return nil +} + +// expandedCanvasWorldRect computes the world-space half-open rectangle covered by +// a full expanded canvas centered on the camera. +// +// The rectangle is returned in fixed-point world coordinates and is not wrapped. +// A later renderer step is expected to tile and normalize it against torus bounds. +func expandedCanvasWorldRect( + cameraXWorldFp, cameraYWorldFp int, + canvasWidthPx, canvasHeightPx int, + zoomFp int, +) Rect { + if canvasWidthPx <= 0 || canvasHeightPx <= 0 { + panic("expandedCanvasWorldRect: invalid canvas size") + } + if zoomFp <= 0 { + panic("expandedCanvasWorldRect: invalid zoom") + } + + worldWidthFp := PixelSpanToWorldFixed(canvasWidthPx, zoomFp) + worldHeightFp := PixelSpanToWorldFixed(canvasHeightPx, zoomFp) + + minX := cameraXWorldFp - worldWidthFp/2 + minY := cameraYWorldFp - worldHeightFp/2 + + return Rect{ + minX: minX, + maxX: minX + worldWidthFp, + minY: minY, + maxY: minY + worldHeightFp, + } +} + +// Render draws the current world state onto the expanded canvas represented by drawer. +// +// Stage A implementation is expected to perform a full redraw of the entire +// expanded canvas. Incremental scrolling and canvas shifting are intentionally +// left for later stages. +// +// The renderer must treat the camera as looking at the center of the viewport, +// not the center of the full expanded canvas. +// +// The renderer performs three passes (layers) in a configurable order. +// The render plan (tiling + candidates + clips) is built once and reused. +func (w *World) Render(drawer PrimitiveDrawer, params RenderParams) error { + if drawer == nil { + return errNilDrawer + } + if err := params.Validate(); err != nil { + return err + } + + defer func() { + if !params.Debug { + return + } + drawer.AddLine( + float64(params.MarginXPx), + float64(params.MarginYPx), + float64(params.MarginXPx+params.ViewportWidthPx), + float64(params.MarginYPx)) + drawer.AddLine( + float64(params.MarginXPx), + float64(params.MarginYPx), + float64(params.MarginXPx), + float64(params.MarginYPx+params.ViewportHeightPx)) + drawer.AddLine( + float64(params.MarginXPx+params.ViewportWidthPx), + float64(params.MarginYPx), + float64(params.MarginXPx+params.ViewportWidthPx), + float64(params.MarginYPx+params.ViewportHeightPx)) + drawer.AddLine( + float64(params.MarginXPx), + float64(params.MarginYPx+params.ViewportHeightPx), + float64(params.MarginXPx+params.ViewportWidthPx), + float64(params.MarginYPx+params.ViewportHeightPx)) + }() + + startTs := time.Now() + defer func() { + // record dtRender for future overload heuristics + w.renderState.lastRenderDurationNs = time.Since(startTs).Nanoseconds() + }() + + policy := DefaultIncrementalPolicy() + if params.Options != nil && params.Options.Incremental != nil { + policy = *params.Options.Incremental + } + + // --- Prepare style / layers (same as before) --- + style := DefaultRenderStyle() + if params.Options != nil && params.Options.Style != nil { + style = *params.Options.Style + } + + layers := []RenderLayer{RenderLayerPoints, RenderLayerCircles, RenderLayerLines} + if params.Options != nil && len(params.Options.Layers) > 0 { + layers = params.Options.Layers + } + + // --- Try incremental path first when state is initialized and geometry matches --- + dxPx, dyPx, derr := w.ComputePanShiftPx(params) + if derr == nil { + inc, perr := PlanIncrementalPan( + params.CanvasWidthPx(), + params.CanvasHeightPx(), + params.MarginXPx, + params.MarginYPx, + dxPx, + dyPx, + ) + if perr != nil { + return perr + } + + switch inc.Mode { + case IncrementalNoOp: + // If we accumulated dirty regions during shift-only frames, redraw them now (bounded). + if len(w.renderState.pendingDirty) > 0 { + toDraw, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx) + + if len(toDraw) > 0 { + for _, r := range toDraw { + drawer.ClearRect(r.X, r.Y, r.W, r.H) + } + + plan, err := w.buildRenderPlanStageA(params) + if err != nil { + return err + } + catchUpPlan := planRestrictedToDirtyRects(plan, toDraw) + + for _, layer := range layers { + switch layer { + case RenderLayerPoints: + applyPointStyle(drawer, style) + drawPointsFromPlanWithRadius(drawer, catchUpPlan, w.W, w.H, style.PointRadiusPx) + case RenderLayerCircles: + applyCircleStyle(drawer, style) + drawCirclesFromPlan(drawer, catchUpPlan, w.W, w.H) + case RenderLayerLines: + applyLineStyle(drawer, style) + drawLinesFromPlan(drawer, catchUpPlan, w.W, w.H) + default: + panic("render: unknown layer") + } + } + } + + w.renderState.pendingDirty = remaining + } + return nil + + case IncrementalShift: + // Move existing pending dirty rects together with the backing image shift. + if len(w.renderState.pendingDirty) > 0 { + moved := make([]RectPx, 0, len(w.renderState.pendingDirty)) + for _, r := range w.renderState.pendingDirty { + if rr, ok := shiftAndClipRectPx(r, inc.DxPx, inc.DyPx, params.CanvasWidthPx(), params.CanvasHeightPx()); ok { + moved = append(moved, rr) + } + } + w.renderState.pendingDirty = moved + } + // C5: shift backing pixels, then redraw only dirty strips. + drawer.CopyShift(inc.DxPx, inc.DyPx) + + overBudget := false + if policy.AllowShiftOnly && policy.RenderBudgetMs > 0 { + budgetNs := int64(policy.RenderBudgetMs) * 1_000_000 + if w.renderState.lastRenderDurationNs > budgetNs { + overBudget = true + } + } + if overBudget { + // Shift-only: defer drawing; remember newly exposed strips. + if len(inc.Dirty) > 0 { + w.renderState.pendingDirty = append(w.renderState.pendingDirty, inc.Dirty...) + } + return nil + } + + // [ ] Сразу после overBudget вычисления и до построения dirtyPlan + // Draw both the newly exposed strips and any previously deferred dirty regions. + // Always redraw newly exposed strips fully. + // Under budget: draw newly exposed strips immediately, plus bounded catch-up. + dirtyToDraw := inc.Dirty + + for _, r := range dirtyToDraw { + drawer.ClearRect(r.X, r.Y, r.W, r.H) + } + + // Additionally redraw a bounded portion of deferred dirty regions. + if len(w.renderState.pendingDirty) > 0 { + catchUp, remaining := takeCatchUpRects(w.renderState.pendingDirty, policy.MaxCatchUpAreaPx) + dirtyToDraw = append(dirtyToDraw, catchUp...) + w.renderState.pendingDirty = remaining + } + + plan, err := w.buildRenderPlanStageA(params) + if err != nil { + return err + } + dirtyPlan := planRestrictedToDirtyRects(plan, dirtyToDraw) + + for _, layer := range layers { + switch layer { + case RenderLayerPoints: + applyPointStyle(drawer, style) + drawPointsFromPlanWithRadius(drawer, dirtyPlan, w.W, w.H, style.PointRadiusPx) + case RenderLayerCircles: + applyCircleStyle(drawer, style) + drawCirclesFromPlan(drawer, dirtyPlan, w.W, w.H) + case RenderLayerLines: + applyLineStyle(drawer, style) + drawLinesFromPlan(drawer, dirtyPlan, w.W, w.H) + default: + panic("render: unknown layer") + } + } + + // State already updated by ComputePanShiftPx (lastWorldRect advanced). + return nil + + case IncrementalFullRedraw: + // Fall through to full redraw below. + default: + panic("render: unknown incremental mode") + } + } + + // --- Full redraw path --- + plan, err := w.buildRenderPlanStageA(params) + if err != nil { + return err + } + drawer.ClearAll() + + for _, layer := range layers { + switch layer { + case RenderLayerPoints: + applyPointStyle(drawer, style) + drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx) + case RenderLayerCircles: + applyCircleStyle(drawer, style) + drawCirclesFromPlan(drawer, plan, w.W, w.H) + case RenderLayerLines: + applyLineStyle(drawer, style) + drawLinesFromPlan(drawer, plan, w.W, w.H) + default: + panic("render: unknown layer") + } + } + + return w.CommitFullRedrawState(params) +} + +// ForceFullRedrawNext resets internal incremental renderer state. +// After this call, the next Render() will use the full redraw path and +// re-initialize incremental state. +func (w *World) ForceFullRedrawNext() { + w.renderState.Reset() +} + +// WorldTile describes one torus tile contribution for an unwrapped world rect. +// +// Rect is the portion of the unwrapped rect mapped into the canonical world domain +// [0, worldWidthFp) x [0, worldHeightFp) as a half-open rectangle. +// OffsetX/OffsetY are the world-space tile offsets (multiples of world width/height) +// that map this canonical rect back into the unwrapped coordinate space. +type WorldTile struct { + Rect Rect + OffsetX int + OffsetY int +} + +// tileWorldRect splits an unwrapped world-space rect into a set of tiles, +// each mapped into the canonical world domain [0, worldWidthFp) x [0, worldHeightFp). +// +// Unlike splitByWrap, this function does NOT collapse spans wider than the world. +// If rect spans multiple world widths/heights, it returns multiple tiles. +// The returned tiles are ordered by increasing tile X index, then by increasing tile Y index. +func tileWorldRect(rect Rect, worldWidthFp, worldHeightFp int) []WorldTile { + if worldWidthFp <= 0 || worldHeightFp <= 0 { + panic("tileWorldRect: non-positive world size") + } + + width := rect.maxX - rect.minX + height := rect.maxY - rect.minY + if width <= 0 || height <= 0 { + return nil + } + + // Determine which torus tiles the rect intersects. + // Since rect is half-open, use (max-1) for inclusive end. + minTileX := floorDiv(rect.minX, worldWidthFp) + maxTileX := floorDiv(rect.maxX-1, worldWidthFp) + minTileY := floorDiv(rect.minY, worldHeightFp) + maxTileY := floorDiv(rect.maxY-1, worldHeightFp) + + out := make([]WorldTile, 0, (maxTileX-minTileX+1)*(maxTileY-minTileY+1)) + + for tx := minTileX; tx <= maxTileX; tx++ { + tileBaseX := tx * worldWidthFp + segMinX := max(rect.minX, tileBaseX) + segMaxX := min(rect.maxX, tileBaseX+worldWidthFp) + if segMinX >= segMaxX { + continue + } + + localMinX := segMinX - tileBaseX + localMaxX := segMaxX - tileBaseX + + for ty := minTileY; ty <= maxTileY; ty++ { + tileBaseY := ty * worldHeightFp + segMinY := max(rect.minY, tileBaseY) + segMaxY := min(rect.maxY, tileBaseY+worldHeightFp) + if segMinY >= segMaxY { + continue + } + + localMinY := segMinY - tileBaseY + localMaxY := segMaxY - tileBaseY + + out = append(out, WorldTile{ + Rect: Rect{ + minX: localMinX, maxX: localMaxX, + minY: localMinY, maxY: localMaxY, + }, + OffsetX: tileBaseX, + OffsetY: tileBaseY, + }) + } + } + + return out +} + +func isEmptyRectPx(r RectPx) bool { + return r.W <= 0 || r.H <= 0 +} + +func intersectRectPx(a, b RectPx) (RectPx, bool) { + ax2 := a.X + a.W + ay2 := a.Y + a.H + bx2 := b.X + b.W + by2 := b.Y + b.H + + x1 := max(a.X, b.X) + y1 := max(a.Y, b.Y) + x2 := min(ax2, bx2) + y2 := min(ay2, by2) + + w := x2 - x1 + h := y2 - y1 + if w <= 0 || h <= 0 { + return RectPx{}, false + } + + return RectPx{X: x1, Y: y1, W: w, H: h}, true +} diff --git a/client/world/renderer_circles.go b/client/world/renderer_circles.go new file mode 100644 index 0000000..e295fbf --- /dev/null +++ b/client/world/renderer_circles.go @@ -0,0 +1,140 @@ +package world + +// renderCirclesStageA performs a full expanded-canvas redraw but renders ONLY Circle primitives. +func (w *World) renderCirclesStageA(drawer PrimitiveDrawer, params RenderParams) error { + plan, err := w.buildRenderPlanStageA(params) + if err != nil { + return err + } + + drawCirclesFromPlan(drawer, plan, w.W, w.H) + return nil +} + +// drawCirclesFromPlan executes a circles-only draw from an already built render plan. +func drawCirclesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int) { + for _, td := range plan.Tiles { + if td.ClipW <= 0 || td.ClipH <= 0 { + continue + } + + // Filter only circles; skip tiles that have no circles. + circles := make([]Circle, 0, len(td.Candidates)) + for _, it := range td.Candidates { + c, ok := it.(Circle) + if !ok { + continue + } + circles = append(circles, c) + } + if len(circles) == 0 { + continue + } + + // Determine which circle copies actually intersect this tile segment. + type circleCopy struct { + c Circle + dx int + dy int + } + copiesToDraw := make([]circleCopy, 0, len(circles)) + + for _, c := range circles { + shifts := circleWrapShifts(c, worldW, worldH) + for _, s := range shifts { + if circleCopyIntersectsTile(c, s.dx, s.dy, td.Tile, worldW, worldH) { + copiesToDraw = append(copiesToDraw, circleCopy{c: c, dx: s.dx, dy: s.dy}) + } + } + } + + if len(copiesToDraw) == 0 { + continue + } + + drawer.Save() + drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) + + for _, cc := range copiesToDraw { + c := cc.c + + // Project the circle center for this tile copy (tile offset + wrap shift). + cxPx := worldSpanFixedToCanvasPx((c.X+td.Tile.OffsetX+cc.dx)-plan.WorldRect.minX, plan.ZoomFp) + cyPx := worldSpanFixedToCanvasPx((c.Y+td.Tile.OffsetY+cc.dy)-plan.WorldRect.minY, plan.ZoomFp) + + // Radius is a world span. + rPx := worldSpanFixedToCanvasPx(c.Radius, plan.ZoomFp) + + drawer.AddCircle(float64(cxPx), float64(cyPx), float64(rPx)) + } + + drawer.Fill() + drawer.Restore() + } +} + +type wrapShift struct { + dx int + dy int +} + +// circleWrapShifts returns 1..4 wrap shifts (multiples of worldW/worldH) required to render +// all torus copies of the circle inside the canonical world domain. +// The (0,0) shift is always present. +func circleWrapShifts(c Circle, worldW, worldH int) []wrapShift { + // If radius covers the whole axis, additional copies are not useful. + // (One copy already covers everything under any reasonable clip.) + if c.Radius >= worldW || c.Radius >= worldH { + return []wrapShift{{dx: 0, dy: 0}} + } + + xShifts := []int{0} + yShifts := []int{0} + + if c.X+c.Radius >= worldW { + xShifts = append(xShifts, -worldW) + } + if c.X-c.Radius < 0 { + xShifts = append(xShifts, worldW) + } + + if c.Y+c.Radius >= worldH { + yShifts = append(yShifts, -worldH) + } + if c.Y-c.Radius < 0 { + yShifts = append(yShifts, worldH) + } + + out := make([]wrapShift, 0, len(xShifts)*len(yShifts)) + for _, dx := range xShifts { + for _, dy := range yShifts { + out = append(out, wrapShift{dx: dx, dy: dy}) + } + } + return out +} + +// circleCopyIntersectsTile checks whether the circle copy (shifted by dx/dy) intersects the tile segment. +// We use the tile's unwrapped segment bounds: [offset+rect.min, offset+rect.max) per axis. +func circleCopyIntersectsTile(c Circle, dx, dy int, tile WorldTile, worldW, worldH int) bool { + // Unwrapped tile segment bounds. + segMinX := tile.OffsetX + tile.Rect.minX + segMaxX := tile.OffsetX + tile.Rect.maxX + segMinY := tile.OffsetY + tile.Rect.minY + segMaxY := tile.OffsetY + tile.Rect.maxY + + // Circle bbox in the same unwrapped space (apply shift + tile offset). + cx := c.X + tile.OffsetX + dx + cy := c.Y + tile.OffsetY + dy + + minX := cx - c.Radius + maxX := cx + c.Radius + minY := cy - c.Radius + maxY := cy + c.Radius + + // Treat bbox as half-open for intersection checks. + if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY { + return false + } + return true +} diff --git a/client/world/renderer_circles_test.go b/client/world/renderer_circles_test.go new file mode 100644 index 0000000..0faec3b --- /dev/null +++ b/client/world/renderer_circles_test.go @@ -0,0 +1,163 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDrawCirclesFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { + t.Parallel() + + // World is 10x10 world units => 10000x10000 fixed. + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Circle near origin so that in expanded canvas (bigger than world) + // it will appear in multiple torus tiles. + id, err := w.AddCircle(1.0, 1.0, 1.0) // center (1000,1000), radius 1000 + require.NoError(t, err) + + w.indexObject(w.objects[id]) + + // Same geometry as points-only test: + // viewport 10x10 px, margins 2px => canvas 14x14 px at zoom=1 => expanded span 14 units > world. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w.W, w.H) + + // Expect 4 circle copies, one per tile that covers the expanded canvas. + wantNames := []string{ + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + "Save", "ClipRect", "AddCircle", "Fill", "Restore", + } + require.Equal(t, wantNames, d.CommandNames()) + + // At zoom=1, 1 world unit -> 1 px, so: + // circle center at (1,1) => base copy at (3,3) like point test + // radius 1 => 1 px + // + // The rest are shifted by +10px in X and/or Y due to torus tiling. + { + clip := requireDrawerCommandAt(t, d, 1) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 2, 10, 10) + + c := requireDrawerCommandAt(t, d, 2) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 3, 3, 1) + } + + { + clip := requireDrawerCommandAt(t, d, 6) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 12, 10, 2) + + c := requireDrawerCommandAt(t, d, 7) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 3, 13, 1) + } + + { + clip := requireDrawerCommandAt(t, d, 11) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 2, 2, 10) + + c := requireDrawerCommandAt(t, d, 12) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 13, 3, 1) + } + + { + clip := requireDrawerCommandAt(t, d, 16) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 12, 2, 2) + + c := requireDrawerCommandAt(t, d, 17) + require.Equal(t, "AddCircle", c.Name) + requireCommandArgs(t, c, 13, 13, 1) + } +} + +func TestDrawCirclesFromPlan_SkipsTilesWithoutCircles(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Add only a point, no circles. + id, err := w.AddPoint(5, 5) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w.W, w.H) + + // No circles => no commands. + require.Empty(t, d.Commands()) +} + +func TestDrawCirclesFromPlan_ProjectsRadiusWithZoom(t *testing.T) { + t.Parallel() + + w := NewWorld(100, 100) + w.resetGrid(10 * SCALE) + + // radius 2 world units; zoom=2 => should be 4 px when 1 unit == 1px at zoom=1. + id, err := w.AddCircle(50, 50, 2) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 50 * SCALE, + CameraYWorldFp: 50 * SCALE, + CameraZoom: 2.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w.W, w.H) + + // There should be at least one AddCircle. + cmds := d.CommandsByName("AddCircle") + require.NotEmpty(t, cmds) + + // All circles in this plan should have radius 4px (2 units * 2x zoom). + for _, c := range cmds { + require.Len(t, c.Args, 3) + require.Equal(t, 4.0, c.Args[2]) + } +} diff --git a/client/world/renderer_circles_wrap_test.go b/client/world/renderer_circles_wrap_test.go new file mode 100644 index 0000000..a262d63 --- /dev/null +++ b/client/world/renderer_circles_wrap_test.go @@ -0,0 +1,93 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCircles_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { + t.Parallel() + + // World 10x10 units => 10px at zoom=1 when viewport==world. + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + type tc struct { + name string + x, y float64 + r float64 + wantCenters [][2]float64 // expected (cx,cy) in canvas px for zoom=1, worldRect min = 0 + } + + // Camera is centered => expanded world rect equals [0..W)x[0..H) when margin=0. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + tests := []tc{ + { + name: "bottom boundary wraps to top", + x: 5, y: 9, r: 2, + // Centers: original at y=9, copy at y=-1. + wantCenters: [][2]float64{{5, 9}, {5, -1}}, + }, + { + name: "right boundary wraps to left", + x: 9, y: 5, r: 2, + wantCenters: [][2]float64{{9, 5}, {-1, 5}}, + }, + { + name: "corner wraps to three extra copies", + x: 9, y: 9, r: 2, + wantCenters: [][2]float64{{9, 9}, {-1, 9}, {9, -1}, {-1, -1}}, + }, + { + name: "no wrap inside", + x: 5, y: 5, r: 2, + wantCenters: [][2]float64{{5, 5}}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + w2 := NewWorld(10, 10) + w2.resetGrid(2 * SCALE) + + _, err := w2.AddCircle(tt.x, tt.y, tt.r) + require.NoError(t, err) + + for _, obj := range w2.objects { + w2.indexObject(obj) + } + + plan, err := w2.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawCirclesFromPlan(d, plan, w2.W, w2.H) + + cmds := d.CommandsByName("AddCircle") + require.Len(t, cmds, len(tt.wantCenters)) + + // Collect centers (ignore radius for this test). + got := make([][2]float64, 0, len(cmds)) + for _, c := range cmds { + require.Len(t, c.Args, 3) + got = append(got, [2]float64{c.Args[0], c.Args[1]}) + } + + // Order is deterministic with our shift generation and tile iteration for margin=0: single tile. + require.ElementsMatch(t, tt.wantCenters, got) + }) + } +} diff --git a/client/world/renderer_incremental_budget_test.go b/client/world/renderer_incremental_budget_test.go new file mode 100644 index 0000000..f0efd73 --- /dev/null +++ b/client/world/renderer_incremental_budget_test.go @@ -0,0 +1,190 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRender_ShiftOnlyOverBudget_DefersDirtyAndCatchesUpOnStop(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: &IncrementalPolicy{ + AllowShiftOnly: true, + RenderBudgetMs: 1, // 1ms budget + }, + }, + } + + // First render (full) initializes state. + d0 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d0, params)) + require.True(t, w.renderState.initialized) + + // Pretend previous render was very slow => over budget for the next frame. + w.renderState.lastRenderDurationNs = 10_000_000 // 10ms + + // Pan right by 1 unit => incremental shift candidate. + params2 := params + params2.CameraXWorldFp += 1 * SCALE + + d1 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d1, params2)) + + // Shift-only should call CopyShift but not redraw dirty rects. + require.NotEmpty(t, d1.CommandsByName("CopyShift")) + require.Empty(t, d1.CommandsByName("ClipRect")) + require.NotEmpty(t, w.renderState.pendingDirty) + + // Now stop panning: dx=dy=0. This should trigger catch-up redraw of pendingDirty. + w.renderState.lastRenderDurationNs = 0 // under budget + + params3 := params2 // same camera + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, params3)) + + require.NotEmpty(t, d2.CommandsByName("ClipRect")) + require.NotEmpty(t, d2.CommandsByName("AddPoint")) + require.Empty(t, w.renderState.pendingDirty) +} + +func TestRender_CatchUpWhilePanning_WhenBackUnderBudget(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + policy := &IncrementalPolicy{ + AllowShiftOnly: true, + RenderBudgetMs: 1, + } + + base := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: policy, + }, + } + + // Initial full render. + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) + + // Frame 1: over budget => shift-only, pendingDirty accumulates. + w.renderState.lastRenderDurationNs = 10_000_000 // 10ms + + p1 := base + p1.CameraXWorldFp += 1 * SCALE + + d1 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d1, p1)) + + require.NotEmpty(t, d1.CommandsByName("CopyShift")) + require.Empty(t, d1.CommandsByName("ClipRect")) + require.NotEmpty(t, w.renderState.pendingDirty) + + // Frame 2: still panning, but now under budget => should shift + redraw (including pendingDirty). + w.renderState.lastRenderDurationNs = 0 + + p2 := p1 + p2.CameraXWorldFp += 1 * SCALE + + d2 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d2, p2)) + + require.NotEmpty(t, d2.CommandsByName("CopyShift")) + require.NotEmpty(t, d2.CommandsByName("ClipRect")) + require.NotEmpty(t, d2.CommandsByName("AddPoint")) + require.Empty(t, w.renderState.pendingDirty, "pending dirty should be cleared after successful catch-up redraw") +} + +func TestRender_CatchUpLimit_ReducesPendingDirtyGradually(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + policy := &IncrementalPolicy{ + AllowShiftOnly: true, + RenderBudgetMs: 1, + MaxCatchUpAreaPx: 20, // very small budget + } + + base := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 2, + MarginXPx: 4, + MarginYPx: 4, // canvasH = 2 + 8 = 10 + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Incremental: policy, + }, + } + + // Full init + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, base)) + + // Over budget => shift-only twice to accumulate pending dirty. + w.renderState.lastRenderDurationNs = 10_000_000 + + p1 := base + p1.CameraXWorldFp += 1 * SCALE + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p1)) + require.NotEmpty(t, w.renderState.pendingDirty) + + w.renderState.lastRenderDurationNs = 10_000_000 + p2 := p1 + p2.CameraXWorldFp += 1 * SCALE + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) + require.NotEmpty(t, w.renderState.pendingDirty) + + // Under budget now, but limit catch-up. + w.renderState.lastRenderDurationNs = 0 + + before := len(w.renderState.pendingDirty) + require.Greater(t, before, 0) + + require.NoError(t, w.Render(&fakePrimitiveDrawer{}, p2)) + after := len(w.renderState.pendingDirty) + + // With a tiny MaxCatchUpAreaPx we should not clear everything in one go. + require.Greater(t, after, 0) + require.Less(t, after, before) +} diff --git a/client/world/renderer_incremental_catchup_limit_test.go b/client/world/renderer_incremental_catchup_limit_test.go new file mode 100644 index 0000000..e5371d5 --- /dev/null +++ b/client/world/renderer_incremental_catchup_limit_test.go @@ -0,0 +1,38 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTakeCatchUpRects_RespectsAreaLimit(t *testing.T) { + t.Parallel() + + pending := []RectPx{ + {X: 0, Y: 0, W: 10, H: 1}, // area 10 + {X: 0, Y: 1, W: 10, H: 2}, // area 20 + {X: 0, Y: 3, W: 10, H: 3}, // area 30 + } + + // Limit 25 => should take first (10) + second (20) would exceed => take only first. + sel, rem := takeCatchUpRects(pending, 25) + require.Equal(t, []RectPx{{X: 0, Y: 0, W: 10, H: 1}}, sel) + require.Equal(t, []RectPx{ + {X: 0, Y: 1, W: 10, H: 2}, + {X: 0, Y: 3, W: 10, H: 3}, + }, rem) + + // Limit 30 => can take first(10) + second(20) exactly. + sel, rem = takeCatchUpRects(pending, 30) + require.Equal(t, []RectPx{ + {X: 0, Y: 0, W: 10, H: 1}, + {X: 0, Y: 1, W: 10, H: 2}, + }, sel) + require.Equal(t, []RectPx{{X: 0, Y: 3, W: 10, H: 3}}, rem) + + // No limit => take all. + sel, rem = takeCatchUpRects(pending, 0) + require.Len(t, sel, 3) + require.Empty(t, rem) +} diff --git a/client/world/renderer_incremental_plan.go b/client/world/renderer_incremental_plan.go new file mode 100644 index 0000000..bd2245e --- /dev/null +++ b/client/world/renderer_incremental_plan.go @@ -0,0 +1,265 @@ +package world + +import "errors" + +var ( + errInvalidCanvasSize = errors.New("incremental: invalid canvas size") +) + +// IncrementalMode describes how the renderer should update the backing image. +type IncrementalMode int + +const ( + // IncrementalNoOp means no visual change is needed (dx=0 and dy=0). + IncrementalNoOp IncrementalMode = iota + + // IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn. + IncrementalShift + + // IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw. + IncrementalFullRedraw +) + +// RectPx is an integer rectangle in canvas pixel coordinates. +// Semantics are half-open: [X, X+W) x [Y, Y+H). +type RectPx struct { + X, Y int + W, H int +} + +// IncrementalPolicy is a placeholder for future incremental tuning. +// It is intentionally not used in C2; we only fix geometry-based thresholding now. +type IncrementalPolicy struct { + // CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates). + // This will be implemented later; kept here as a placeholder to lock the API shape. + CoalesceUpdates bool + + // AllowShiftOnly allows a temporary mode where the backing image is shifted + // but dirty rects are not redrawn immediately under overload. + AllowShiftOnly bool + + // RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation. + RenderBudgetMs int + + // MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame. + // 0 means "no limit". + MaxCatchUpAreaPx int +} + +// IncrementalPlan is the output of pure incremental planning. +// It does not perform any drawing. It only describes what should happen. +type IncrementalPlan struct { + Mode IncrementalMode + + // Shift to apply to the backing image in canvas pixels. + // Positive dx shifts the existing image to the right (exposing a dirty strip on the left). + // Positive dy shifts the existing image down (exposing a dirty strip on the top). + DxPx int + DyPx int + + // Dirty rects to redraw after shifting (in canvas pixel coordinates). + // Rects may overlap; overlapping is allowed and simplifies planning. + Dirty []RectPx +} + +// PlanIncrementalPan computes whether the renderer can update by shifting the backing image +// and redrawing only exposed strips, or must fall back to a full redraw. +// +// Threshold rule (per-axis): +// - If abs(dxPx) > marginXPx/2 => full redraw +// - If abs(dyPx) > marginYPx/2 => full redraw +// +// Additional safety rules: +// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw +// +// Returned dirty rects follow the chosen shift direction: +// +// dxPx > 0 => dirty strip on the left (width=dxPx) +// dxPx < 0 => dirty strip on the right (width=-dxPx) +// dyPx > 0 => dirty strip on the top (height=dyPx) +// dyPx < 0 => dirty strip on the bottom(height=-dyPx) +func PlanIncrementalPan( + canvasW, canvasH int, + marginXPx, marginYPx int, + dxPx, dyPx int, +) (IncrementalPlan, error) { + if canvasW <= 0 || canvasH <= 0 { + return IncrementalPlan{}, errInvalidCanvasSize + } + if marginXPx < 0 || marginYPx < 0 { + return IncrementalPlan{}, errors.New("incremental: invalid margins") + } + + // No movement => no work. + if dxPx == 0 && dyPx == 0 { + return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil + } + + adx := abs(dxPx) + ady := abs(dyPx) + + // Too large shift can’t be represented as "shift + stripes". + if adx >= canvasW || ady >= canvasH { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + + // Thresholds: per axis, independently. + // Using integer division: margin/2 truncates down, which is fine and deterministic. + thrX := marginXPx / 2 + thrY := marginYPx / 2 + + if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + + // If margin is 0, thr is 0, and any non-zero delta should force full redraw + // (because we have no buffer area to shift into). + if marginXPx == 0 && dxPx != 0 { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + if marginYPx == 0 && dyPx != 0 { + return IncrementalPlan{Mode: IncrementalFullRedraw}, nil + } + + dirty := make([]RectPx, 0, 2) + + // Horizontal exposed strip with 1px overdraw to avoid seams. + if dxPx > 0 { + // Image moved right => left strip is exposed. + w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area + dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH}) + } else if dxPx < 0 { + // Image moved left => right strip is exposed. + w := min((-dxPx)+1, canvasW) + dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH}) + } + + // Vertical exposed strip with 1px overdraw to avoid seams. + if dyPx > 0 { + // Image moved down => top strip is exposed. + h := min(dyPx+1, canvasH) + dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h}) + } else if dyPx < 0 { + // Image moved up => bottom strip is exposed. + h := min((-dyPx)+1, canvasH) + dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h}) + } + + // Filter out any zero/negative rects defensively. + out := dirty[:0] + for _, r := range dirty { + if r.W <= 0 || r.H <= 0 { + continue + } + out = append(out, r) + } + + return IncrementalPlan{ + Mode: IncrementalShift, + DxPx: dxPx, + DyPx: dyPx, + Dirty: out, + }, nil +} + +func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) { + n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H} + inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH}) + return inter, ok +} + +// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries +// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own +// TileDrawPlan entry with the clip replaced by the intersection. +// +// This makes drawing functions naturally render only the dirty areas. +func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan { + if len(dirty) == 0 { + return RenderPlan{ + CanvasWidthPx: plan.CanvasWidthPx, + CanvasHeightPx: plan.CanvasHeightPx, + ZoomFp: plan.ZoomFp, + WorldRect: plan.WorldRect, + Tiles: nil, + } + } + + outTiles := make([]TileDrawPlan, 0) + + for _, td := range plan.Tiles { + if td.ClipW <= 0 || td.ClipH <= 0 { + continue + } + + tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH} + + for _, dr := range dirty { + if isEmptyRectPx(dr) { + continue + } + + inter, ok := intersectRectPx(tileClip, dr) + if !ok { + continue + } + + outTiles = append(outTiles, TileDrawPlan{ + Tile: td.Tile, + ClipX: inter.X, + ClipY: inter.Y, + ClipW: inter.W, + ClipH: inter.H, + Candidates: td.Candidates, + }) + } + } + + return RenderPlan{ + CanvasWidthPx: plan.CanvasWidthPx, + CanvasHeightPx: plan.CanvasHeightPx, + ZoomFp: plan.ZoomFp, + WorldRect: plan.WorldRect, + Tiles: outTiles, + } +} + +// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx. +// It returns (selected, remaining). If maxAreaPx <= 0, it selects all. +func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) { + if len(pending) == 0 { + return nil, nil + } + if maxAreaPx <= 0 { + // No limit. + all := append([]RectPx(nil), pending...) + return all, nil + } + + selected = make([]RectPx, 0, len(pending)) + remaining = make([]RectPx, 0) + + used := 0 + for _, r := range pending { + if r.W <= 0 || r.H <= 0 { + continue + } + area := r.W * r.H + if area <= 0 { + continue + } + + // If we cannot fit the whole rect, we stop (simple, deterministic). + // (We do not split rectangles here to keep logic simple.) + if used+area > maxAreaPx { + remaining = append(remaining, r) + continue + } + + selected = append(selected, r) + used += area + } + + // Also keep any rects we skipped due to invalid size (none) and those that didn't fit. + // Note: remaining preserves original order among non-selected entries. + return selected, remaining +} diff --git a/client/world/renderer_incremental_plan_test.go b/client/world/renderer_incremental_plan_test.go new file mode 100644 index 0000000..05ee925 --- /dev/null +++ b/client/world/renderer_incremental_plan_test.go @@ -0,0 +1,144 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPlanIncrementalPan_NoOp(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 50, 30, 0, 0) + require.NoError(t, err) + require.Equal(t, IncrementalNoOp, plan.Mode) + require.Empty(t, plan.Dirty) +} + +func TestPlanIncrementalPan_FullRedrawOnInvalidCanvas(t *testing.T) { + t.Parallel() + + _, err := PlanIncrementalPan(0, 100, 10, 10, 1, 0) + require.ErrorIs(t, err, errInvalidCanvasSize) +} + +func TestPlanIncrementalPan_FullRedrawOnTooLargeShift(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(100, 80, 40, 40, 100, 0) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) + + plan, err = PlanIncrementalPan(100, 80, 40, 40, 0, -80) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +func TestPlanIncrementalPan_FullRedrawWhenMarginIsZeroAndDeltaNonZero(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(100, 80, 0, 20, 1, 0) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) + + plan, err = PlanIncrementalPan(100, 80, 20, 0, 0, 1) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdX(t *testing.T) { + t.Parallel() + + // marginX=20 => threshold=10, dx=11 => full redraw + plan, err := PlanIncrementalPan(200, 100, 20, 20, 11, 0) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +func TestPlanIncrementalPan_FullRedrawWhenExceedsThresholdY(t *testing.T) { + t.Parallel() + + // marginY=20 => threshold=10, dy=-11 => full redraw + plan, err := PlanIncrementalPan(200, 100, 20, 20, 0, -11) + require.NoError(t, err) + require.Equal(t, IncrementalFullRedraw, plan.Mode) +} + +func TestPlanIncrementalPan_Shift_LeftStripWhenDxPositive(t *testing.T) { + t.Parallel() + + // marginX=40 => threshold=20, dx=5 => shift ok + plan, err := PlanIncrementalPan(200, 100, 40, 40, 5, 0) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + require.Equal(t, 5, plan.DxPx) + require.Equal(t, 0, plan.DyPx) + + require.Equal(t, []RectPx{ + {X: 0, Y: 0, W: 6, H: 100}, + }, plan.Dirty) +} + +func TestPlanIncrementalPan_Shift_RightStripWhenDxNegative(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + require.Equal(t, []RectPx{ + {X: 200 - 8, Y: 0, W: 8, H: 100}, + }, plan.Dirty) +} + +func TestPlanIncrementalPan_Shift_TopStripWhenDyPositive(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, 9) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + require.Equal(t, []RectPx{ + {X: 0, Y: 0, W: 200, H: 10}, + }, plan.Dirty) +} + +func TestPlanIncrementalPan_Shift_BottomStripWhenDyNegative(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, 0, -9) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + require.Equal(t, []RectPx{ + {X: 0, Y: 100 - 10, W: 200, H: 10}, + }, plan.Dirty) +} + +func TestPlanIncrementalPan_Shift_DiagonalReturnsTwoDirtyRects(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, -6, 8) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + // Overlap is allowed; we just require both strips exist. + require.Len(t, plan.Dirty, 2) + require.ElementsMatch(t, []RectPx{ + {X: 200 - 7, Y: 0, W: 7, H: 100}, // right strip + {X: 0, Y: 0, W: 200, H: 9}, // top strip + }, plan.Dirty) +} + +func TestPlanIncrementalPan_OverdrawsDirtyStripsByOnePixel(t *testing.T) { + t.Parallel() + + plan, err := PlanIncrementalPan(200, 100, 40, 40, -7, 0) + require.NoError(t, err) + require.Equal(t, IncrementalShift, plan.Mode) + + // Right strip width should be abs(dx)+1 = 8. + require.Equal(t, []RectPx{ + {X: 200 - 8, Y: 0, W: 8, H: 100}, + }, plan.Dirty) +} diff --git a/client/world/renderer_incremental_render_test.go b/client/world/renderer_incremental_render_test.go new file mode 100644 index 0000000..a93369c --- /dev/null +++ b/client/world/renderer_incremental_render_test.go @@ -0,0 +1,114 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRender_PanSmall_UsesCopyShiftAndRendersOnlyDirtyStrips(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + _, err = w.AddCircle(2, 2, 1) + require.NoError(t, err) + _, err = w.AddLine(9, 5, 1, 5) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, // threshold=2 + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + // First render initializes state (full redraw). + d0 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d0, params)) + + // Pan right by 1 unit => dx=-1 => incremental shift expected. + params2 := params + params2.CameraXWorldFp += 1 * SCALE + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params2) + require.NoError(t, err) + + // Must contain CopyShift for incremental path. + require.NotEmpty(t, d.CommandsByName("CopyShift")) + + // All clip rects should be "small": width <= 1 for dx=-1 strip. + clipCmds := d.CommandsByName("ClipRect") + require.NotEmpty(t, clipCmds) + for _, c := range clipCmds { + wPx := int(c.Args[2]) + hPx := int(c.Args[3]) + require.LessOrEqual(t, wPx, 2) + require.LessOrEqual(t, hPx, params2.CanvasHeightPx()) + } + + require.NotEmpty(t, d.CommandsByName("AddPoint")) + require.NotEmpty(t, d.CommandsByName("AddCircle")) + require.NotEmpty(t, d.CommandsByName("AddLine")) +} + +func TestRender_PanTooLarge_FallsBackToFullRedraw(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 4, // threshold=2 + MarginYPx: 4, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d0 := &fakePrimitiveDrawer{} + require.NoError(t, w.Render(d0, params)) + + // Pan right by 3 units => abs(dx)=3 > threshold(2) => full redraw expected. + params2 := params + params2.CameraXWorldFp += 3 * SCALE + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params2) + require.NoError(t, err) + + // Full redraw should NOT call CopyShift. + require.Empty(t, d.CommandsByName("CopyShift")) + + // Should contain at least one reasonably wide clip. + clipCmds := d.CommandsByName("ClipRect") + require.NotEmpty(t, clipCmds) + + foundWide := false + for _, c := range clipCmds { + if int(c.Args[2]) > 1 { + foundWide = true + break + } + } + require.True(t, foundWide) +} diff --git a/client/world/renderer_incremental_state.go b/client/world/renderer_incremental_state.go new file mode 100644 index 0000000..8e85d87 --- /dev/null +++ b/client/world/renderer_incremental_state.go @@ -0,0 +1,202 @@ +package world + +import "errors" + +var ( + errIncrementalZoomMismatch = errors.New("incremental: zoom/viewport/margins changed; full redraw required") + errIncrementalStateNotReady = errors.New("incremental: state not initialized; full redraw required") + errIncrementalInvalidZoomFp = errors.New("incremental: invalid zoom") + errIncrementalInvalidCanvasPx = errors.New("incremental: invalid canvas size") +) + +// rendererIncrementalState stores the minimum state needed for incremental pan. +type rendererIncrementalState struct { + initialized bool + + // Last render geometry key. + lastZoomFp int + + lastViewportW int + lastViewportH int + lastMarginX int + lastMarginY int + lastCanvasW int + lastCanvasH int + + // Last unwrapped expanded world rect used for rendering. + lastWorldRect Rect + + // Remainders in numerator space to make world->px conversion stable across many small pans. + // We keep them per axis and update them during conversion. + remXNum int64 + remYNum int64 + + // Last measured render duration (nanoseconds). Used for overload heuristics. + lastRenderDurationNs int64 + + // Pending dirty areas accumulated during shift-only frames. + // These are in current canvas pixel coordinates. + pendingDirty []RectPx +} + +// Reset clears incremental state, forcing next frame to use full redraw. +func (s *rendererIncrementalState) Reset() { + *s = rendererIncrementalState{} +} + +// incrementalKeyFromParams extracts the geometry key that must match for incremental pan. +func incrementalKeyFromParams(params RenderParams, zoomFp int) (vw, vh, mx, my, cw, ch, z int) { + vw = params.ViewportWidthPx + vh = params.ViewportHeightPx + mx = params.MarginXPx + my = params.MarginYPx + cw = params.CanvasWidthPx() + ch = params.CanvasHeightPx() + z = zoomFp + return +} + +// worldDeltaFixedToCanvasPx converts a world-fixed delta into a pixel delta using zoomFp, +// carrying a signed remainder in numerator space to avoid cumulative drift. +// +// The conversion is: +// +// px = floor((deltaWorldFp*zoomFp + rem) / (SCALE*SCALE)) +// +// and rem is updated to the exact remainder. +// +// This function works for negative deltas too and uses floor division semantics. +func worldDeltaFixedToCanvasPx(deltaWorldFp int, zoomFp int, remNum *int64) int { + if zoomFp <= 0 { + panic("worldDeltaFixedToCanvasPx: invalid zoom") + } + + den := int64(SCALE) * int64(SCALE) + num := int64(deltaWorldFp)*int64(zoomFp) + *remNum + + q, r := floorDivRem64(num, den) + *remNum = r + return int(q) +} + +// floorDivRem64 returns (q,r) such that: +// +// q = floor(a / b), r = a - q*b +// +// with b > 0 and r in [0, b) for a>=0, or r in (-b, 0] for a<0 (signed remainder). +func floorDivRem64(a, b int64) (q int64, r int64) { + if b <= 0 { + panic("floorDivRem64: non-positive divisor") + } + + q = a / b + r = a % b + if r != 0 && a < 0 { + q-- + r = a - q*b + } + return q, r +} + +// ComputePanShiftPx computes the pixel shift that must be applied to the existing backing image +// when ONLY camera pan changed (no zoom/viewport/margins changes). +// +// Returned dxPx/dyPx are shifts to apply to the already rendered image: +// +// dxPx > 0 => shift image right +// dxPx < 0 => shift image left +// +// This function updates internal incremental state when possible. +// If it returns an error, the caller should fall back to a full redraw and call +// CommitFullRedrawState afterward. +func (w *World) ComputePanShiftPx(params RenderParams) (dxPx, dyPx int, err error) { + zoomFp, zerr := params.CameraZoomFp() + if zerr != nil { + return 0, 0, zerr + } + if zoomFp <= 0 { + return 0, 0, errIncrementalInvalidZoomFp + } + + canvasW := params.CanvasWidthPx() + canvasH := params.CanvasHeightPx() + if canvasW <= 0 || canvasH <= 0 { + return 0, 0, errIncrementalInvalidCanvasPx + } + + newRect, rerr := params.ExpandedCanvasWorldRect() + if rerr != nil { + return 0, 0, rerr + } + + s := &w.renderState + + // First call: no prior state => must full redraw. + if !s.initialized { + return 0, 0, errIncrementalStateNotReady + } + + vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp) + if s.lastZoomFp != z || + s.lastViewportW != vw || s.lastViewportH != vh || + s.lastMarginX != mx || s.lastMarginY != my || + s.lastCanvasW != cw || s.lastCanvasH != ch { + return 0, 0, errIncrementalZoomMismatch + } + + // Compute how much the unwrapped world rect moved. + dMinX := newRect.minX - s.lastWorldRect.minX + dMinY := newRect.minY - s.lastWorldRect.minY + + // Convert world movement to pixel movement of the world content. + // If world rect moved +X (camera moved right), content appears shifted left, + // so the old image must be shifted left: shiftPx = -deltaPx. + deltaPxX := worldDeltaFixedToCanvasPx(dMinX, zoomFp, &s.remXNum) + deltaPxY := worldDeltaFixedToCanvasPx(dMinY, zoomFp, &s.remYNum) + + dxPx = -deltaPxX + dyPx = -deltaPxY + + // Update stored rect for the next incremental computation. + s.lastWorldRect = newRect + + return dxPx, dyPx, nil +} + +// CommitFullRedrawState updates incremental state after a full redraw. +// Call this after you finish a full Render() that draws the entire expanded canvas. +func (w *World) CommitFullRedrawState(params RenderParams) error { + zoomFp, err := params.CameraZoomFp() + if err != nil { + return err + } + if zoomFp <= 0 { + return errIncrementalInvalidZoomFp + } + + rect, err := params.ExpandedCanvasWorldRect() + if err != nil { + return err + } + + s := &w.renderState + vw, vh, mx, my, cw, ch, z := incrementalKeyFromParams(params, zoomFp) + + s.initialized = true + s.lastZoomFp = z + s.lastViewportW = vw + s.lastViewportH = vh + s.lastMarginX = mx + s.lastMarginY = my + s.lastCanvasW = cw + s.lastCanvasH = ch + s.lastWorldRect = rect + + // Reset remainders on a full redraw to avoid stale accumulation when geometry changes. + s.remXNum = 0 + s.remYNum = 0 + + s.pendingDirty = nil + + return nil +} diff --git a/client/world/renderer_incremental_state_test.go b/client/world/renderer_incremental_state_test.go new file mode 100644 index 0000000..84ae1cf --- /dev/null +++ b/client/world/renderer_incremental_state_test.go @@ -0,0 +1,171 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesPositive(t *testing.T) { + t.Parallel() + + // zoom=1: px = (deltaWorldFp * 1000) / 1e6 + // For deltaWorldFp=1, each step contributes 0 px with remainder, + // and after 1000 steps it must become 1 px total. + zoomFp := SCALE + var rem int64 + sum := 0 + + for i := 0; i < 1000; i++ { + sum += worldDeltaFixedToCanvasPx(1, zoomFp, &rem) + } + + require.Equal(t, 1, sum) +} + +func TestWorldDeltaFixedToCanvasPx_RemainderAccumulatesNegative(t *testing.T) { + t.Parallel() + + zoomFp := SCALE + var rem int64 + sum := 0 + + for i := 0; i < 1000; i++ { + sum += worldDeltaFixedToCanvasPx(-1, zoomFp, &rem) + } + + require.Equal(t, -1, sum) +} + +func TestComputePanShiftPx_FirstCallRequiresFullRedraw(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + _, _, err := w.ComputePanShiftPx(params) + require.ErrorIs(t, err, errIncrementalStateNotReady) +} + +func TestComputePanShiftPx_ZoomOrViewportChangeForcesFullRedraw(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + base := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(base)) + + changed := base + changed.CameraZoom = 2.0 + + _, _, err := w.ComputePanShiftPx(changed) + require.ErrorIs(t, err, errIncrementalZoomMismatch) + + changed2 := base + changed2.ViewportWidthPx = 101 + + _, _, err = w.ComputePanShiftPx(changed2) + require.ErrorIs(t, err, errIncrementalZoomMismatch) +} + +func TestComputePanShiftPx_PanRightShiftsImageLeft(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(params)) + + // Move camera right by 1 world unit => world rect minX increases by 1 unit, + // so content moves left by 1px at zoom=1 => image shift should be -1. + params2 := params + params2.CameraXWorldFp += 1 * SCALE + + dx, dy, err := w.ComputePanShiftPx(params2) + require.NoError(t, err) + require.Equal(t, -1, dx) + require.Equal(t, 0, dy) +} + +func TestComputePanShiftPx_PanUpShiftsImageDown(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(params)) + + // Move camera up by 1 world unit => world rect minY decreases by 1 unit, + // so content moves down by 1px => image shift should be +1 in dy. + params2 := params + params2.CameraYWorldFp -= 1 * SCALE + + dx, dy, err := w.ComputePanShiftPx(params2) + require.NoError(t, err) + require.Equal(t, 0, dx) + require.Equal(t, 1, dy) +} + +func TestComputePanShiftPx_SubPixelPanAccumulatesToOnePixel(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + require.NoError(t, w.CommitFullRedrawState(params)) + + // Pan camera right by 0.001 world units (1 fixed-point) 1000 times. + // At zoom=1 this should accumulate to a 1px content shift left, hence image shift -1. + totalDx := 0 + p := params + for i := 0; i < 1000; i++ { + p.CameraXWorldFp += 1 + dx, dy, err := w.ComputePanShiftPx(p) + require.NoError(t, err) + require.Equal(t, 0, dy) + totalDx += dx + } + + require.Equal(t, -1, totalDx) +} diff --git a/client/world/renderer_lines.go b/client/world/renderer_lines.go new file mode 100644 index 0000000..d098dba --- /dev/null +++ b/client/world/renderer_lines.go @@ -0,0 +1,228 @@ +package world + +// renderLinesStageB performs a full expanded-canvas redraw but renders ONLY Line primitives. +// It uses the Stage A render plan: tiles + per-tile clip + per-tile candidates. +func (w *World) renderLinesStageB(drawer PrimitiveDrawer, params RenderParams) error { + plan, err := w.buildRenderPlanStageA(params) + if err != nil { + return err + } + + drawLinesFromPlan(drawer, plan, w.W, w.H) + return nil +} + +// lineSeg is one canonical segment (endpoints in [0..W) x [0..H)) to be drawn. +// It represents part of the torus-shortest polyline for a Line primitive after wrap splitting. +type lineSeg struct { + x1, y1 int + x2, y2 int +} + +// drawLinesFromPlan executes a lines-only draw from an already built render plan. +func drawLinesFromPlan(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int) { + for _, td := range plan.Tiles { + if td.ClipW <= 0 || td.ClipH <= 0 { + continue + } + + // Filter only lines; skip tiles that have no lines. + lines := make([]Line, 0, len(td.Candidates)) + for _, it := range td.Candidates { + l, ok := it.(Line) + if !ok { + continue + } + lines = append(lines, l) + } + if len(lines) == 0 { + continue + } + + // Collect segments that actually intersect this tile's canonical rect. + segsToDraw := make([]lineSeg, 0, len(lines)) + for _, l := range lines { + segs := torusShortestLineSegments(l, worldW, worldH) + for _, s := range segs { + if segmentIntersectsRect(s, td.Tile.Rect) { + segsToDraw = append(segsToDraw, s) + } + } + } + if len(segsToDraw) == 0 { + continue + } + + drawer.Save() + drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) + + for _, s := range segsToDraw { + // Project endpoints for this tile copy by adding tile offset. + x1Px := worldSpanFixedToCanvasPx((s.x1+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) + y1Px := worldSpanFixedToCanvasPx((s.y1+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) + + x2Px := worldSpanFixedToCanvasPx((s.x2+td.Tile.OffsetX)-plan.WorldRect.minX, plan.ZoomFp) + y2Px := worldSpanFixedToCanvasPx((s.y2+td.Tile.OffsetY)-plan.WorldRect.minY, plan.ZoomFp) + + drawer.AddLine(float64(x1Px), float64(y1Px), float64(x2Px), float64(y2Px)) + } + + drawer.Stroke() + drawer.Restore() + } +} + +// torusShortestLineSegments converts a Line primitive into 1..4 canonical segments +// inside [0..worldW) x [0..worldH) that represent the torus-shortest polyline. +// +// IMPORTANT: when a segment crosses a torus boundary, the second segment starts +// on the opposite boundary (e.g. x=0 jump to x=worldW), preserving continuity on the torus. +// We must NOT wrap endpoints independently at the end, otherwise the topology changes. +func torusShortestLineSegments(l Line, worldW, worldH int) []lineSeg { + // Step 1: choose the torus-shortest representation in unwrapped space. + ax, bx := shortestWrappedDelta(l.X1, l.X2, worldW) + ay, by := shortestWrappedDelta(l.Y1, l.Y2, worldH) + + // Step 2: shift so that A is inside canonical [0..W) x [0..H). + shiftX := floorDiv(ax, worldW) * worldW + shiftY := floorDiv(ay, worldH) * worldH + + ax -= shiftX + bx -= shiftX + ay -= shiftY + by -= shiftY + + segs := []lineSeg{{x1: ax, y1: ay, x2: bx, y2: by}} + + // Step 3: split by X boundary if needed (jump-aware). + segs = splitSegmentsByX(segs, worldW) + + // Step 4: split by Y boundary if needed (jump-aware). + segs = splitSegmentsByY(segs, worldH) + + // Now all segments are canonical and torus-continuous. Endpoints may legally be 0 or worldW/worldH. + return segs +} + +func splitSegmentsByX(segs []lineSeg, worldW int) []lineSeg { + out := make([]lineSeg, 0, len(segs)*2) + + for _, s := range segs { + x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 + + // After normalization, x1 is expected inside [0..worldW). Only x2 may be outside. + if x2 >= 0 && x2 < worldW { + out = append(out, s) + continue + } + + dx := x2 - x1 + dy := y2 - y1 + if dx == 0 { + // Degenerate; keep as-is (should not happen with normalized x1 unless x2==x1). + out = append(out, s) + continue + } + + if x2 >= worldW { + // Crosses the right boundary at x=worldW, then reappears at x=0. + bx := worldW + num := bx - x1 + iy := y1 + (dy*num)/dx + + // Segment 1: [x1..worldW] + // Segment 2: [0..x2-worldW] + s1 := lineSeg{x1: x1, y1: y1, x2: worldW, y2: iy} + s2 := lineSeg{x1: 0, y1: iy, x2: x2 - worldW, y2: y2} + out = append(out, s1, s2) + continue + } + + // x2 < 0: crosses the left boundary at x=0, then reappears at x=worldW. + bx := 0 + num := bx - x1 + iy := y1 + (dy*num)/dx + + // Segment 1: [x1..0] + // Segment 2: [worldW..x2+worldW] + s1 := lineSeg{x1: x1, y1: y1, x2: 0, y2: iy} + s2 := lineSeg{x1: worldW, y1: iy, x2: x2 + worldW, y2: y2} + out = append(out, s1, s2) + } + + return out +} + +func splitSegmentsByY(segs []lineSeg, worldH int) []lineSeg { + out := make([]lineSeg, 0, len(segs)*2) + + for _, s := range segs { + x1, y1, x2, y2 := s.x1, s.y1, s.x2, s.y2 + + // After normalization, y1 is expected inside [0..worldH). Only y2 may be outside. + if y2 >= 0 && y2 < worldH { + out = append(out, s) + continue + } + + dx := x2 - x1 + dy := y2 - y1 + if dy == 0 { + out = append(out, s) + continue + } + + if y2 >= worldH { + // Crosses the top boundary at y=worldH, then reappears at y=0. + by := worldH + num := by - y1 + ix := x1 + (dx*num)/dy + + s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: worldH} + s2 := lineSeg{x1: ix, y1: 0, x2: x2, y2: y2 - worldH} + out = append(out, s1, s2) + continue + } + + // y2 < 0: crosses the bottom boundary at y=0, then reappears at y=worldH. + by := 0 + num := by - y1 + ix := x1 + (dx*num)/dy + + s1 := lineSeg{x1: x1, y1: y1, x2: ix, y2: 0} + s2 := lineSeg{x1: ix, y1: worldH, x2: x2, y2: y2 + worldH} + out = append(out, s1, s2) + } + return out +} + +// segmentIntersectsRect is a coarse bbox intersection check between a segment and a half-open rect. +// It is used only as an optimization to avoid drawing clearly irrelevant segments. +// +// NOTE: Segment endpoints may legally be exactly on the world boundary (x==worldW or y==worldH). +// Rect is half-open [min, max), so we treat the segment bbox as inclusive and intersect it with +// [r.min, r.max-1] to avoid dropping boundary-touching segments. +func segmentIntersectsRect(s lineSeg, r Rect) bool { + minX := min(s.x1, s.x2) + maxX := max(s.x1, s.x2) + minY := min(s.y1, s.y2) + maxY := max(s.y1, s.y2) + + // Treat degenerate as having 1-unit thickness for indexing-like behavior. + if minX == maxX { + maxX++ + } + if minY == maxY { + maxY++ + } + + // Rect inclusive bounds for intersection purposes. + rMaxX := r.maxX - 1 + rMaxY := r.maxY - 1 + if rMaxX < r.minX || rMaxY < r.minY { + return false + } + + // Inclusive overlap check. + return !(maxX-1 < r.minX || minX > rMaxX || maxY-1 < r.minY || minY > rMaxY) +} diff --git a/client/world/renderer_lines_test.go b/client/world/renderer_lines_test.go new file mode 100644 index 0000000..d643967 --- /dev/null +++ b/client/world/renderer_lines_test.go @@ -0,0 +1,158 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDrawLinesFromPlan_WrapX_SplitsAndDrawsInThreeXTiles(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Horizontal line that wraps across X: 9 -> 1 at y=5. + id, err := w.AddLine(9, 5, 1, 5) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawLinesFromPlan(d, plan, w.W, w.H) + + // Expect drawing in 3 X tiles (left partial, middle full, right partial) for the central Y tile: + // Each tile group: Save, ClipRect, AddLine(s), Stroke, Restore + // + // Left tile (offsetX=-10000): 1 line segment. + // Middle tile (offsetX=0): 2 segments (wrapped split). + // Right tile (offsetX=10000): 1 segment. + wantNames := []string{ + "Save", "ClipRect", "AddLine", "Stroke", "Restore", + "Save", "ClipRect", "AddLine", "AddLine", "Stroke", "Restore", + "Save", "ClipRect", "AddLine", "Stroke", "Restore", + } + require.Equal(t, wantNames, d.CommandNames()) + + // Group 1: left strip clip (0,2,2,10), line at y=7 from x=1..2 + { + requireCommandArgs(t, requireDrawerCommandAt(t, d, 1), 0, 2, 2, 10) + requireCommandArgs(t, requireDrawerCommandAt(t, d, 2), 1, 7, 2, 7) + } + + // Group 2: middle strip clip (2,2,10,10), two segments: + // segment [9000..10000] => x 11..12, y 7 + // segment [0..1000] => x 2..3, y 7 + { + requireCommandArgs(t, requireDrawerCommandAt(t, d, 6), 2, 2, 10, 10) + + // The order of segments is stable with our splitting: first the one ending at boundary, then the remainder. + requireCommandArgs(t, requireDrawerCommandAt(t, d, 7), 11, 7, 12, 7) + requireCommandArgs(t, requireDrawerCommandAt(t, d, 8), 2, 7, 3, 7) + } + + // Group 3: right strip clip (12,2,2,10), line at y=7 from x=12..13 + { + requireCommandArgs(t, requireDrawerCommandAt(t, d, 12), 12, 2, 2, 10) // ClipRect + requireCommandArgs(t, requireDrawerCommandAt(t, d, 13), 12, 7, 13, 7) // AddLine + } +} + +func TestDrawLinesFromPlan_WrapY_SplitsAndDrawsInThreeYTiles(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Vertical line that wraps across Y: 9 -> 1 at x=5. + id, err := w.AddLine(5, 9, 5, 1) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawLinesFromPlan(d, plan, w.W, w.H) + + // Here we expect 3 Y tiles for the central X tile: + // Top partial, middle full (two segments), bottom partial. + // + // The exact ordering of tiles is by tx then ty, so X=middle strips first. + // For this geometry the line only intersects the middle X tiles (offsetX=0), + // but spans Y across -1,0,1. + wantNames := []string{ + "Save", "ClipRect", "AddLine", "Stroke", "Restore", + "Save", "ClipRect", "AddLine", "AddLine", "Stroke", "Restore", + "Save", "ClipRect", "AddLine", "Stroke", "Restore", + } + require.Equal(t, wantNames, d.CommandNames()) + + // Group 1: top strip clip (2,0,10,2), line at x=7 from y=1..2 + { + requireCommandArgs(t, requireDrawerCommandAt(t, d, 1), 2, 0, 10, 2) + requireCommandArgs(t, requireDrawerCommandAt(t, d, 2), 7, 1, 7, 2) + } + + // Group 2: middle strip clip (2,2,10,10), two segments: + // segment [9000..10000] => y 11..12 at x=7 + // segment [0..1000] => y 2..3 at x=7 + { + requireCommandArgs(t, requireDrawerCommandAt(t, d, 6), 2, 2, 10, 10) + requireCommandArgs(t, requireDrawerCommandAt(t, d, 7), 7, 11, 7, 12) + requireCommandArgs(t, requireDrawerCommandAt(t, d, 8), 7, 2, 7, 3) + } + + // Group 3: bottom strip clip (2,12,10,2), line at x=7 from y=12..13 + { + requireCommandArgs(t, requireDrawerCommandAt(t, d, 12), 2, 12, 10, 2) // ClipRect + requireCommandArgs(t, requireDrawerCommandAt(t, d, 13), 7, 12, 7, 13) // AddLine + } +} + +func TestTorusShortestLineSegments_TieCaseIsDeterministicAndSplits(t *testing.T) { + t.Parallel() + + // World 10 units => 10000 fixed. + worldW := 10 * SCALE + worldH := 10 * SCALE + + // Tie-case along X: 1 -> 6 is exactly half world apart (dx = +5000). + // Deterministic rule chooses negative delta representation (wrap is applied). + l := Line{ + X1: 1 * SCALE, Y1: 5 * SCALE, + X2: 6 * SCALE, Y2: 5 * SCALE, + } + + segs := torusShortestLineSegments(l, worldW, worldH) + + // Expect two horizontal segments: + // [6000..10000] and [0..1000] at y=5000. + require.Len(t, segs, 2) + + // Direction is deterministic and follows the chosen negative-delta representation. + require.Equal(t, lineSeg{x1: 1000, y1: 5000, x2: 0, y2: 5000}, segs[0]) + require.Equal(t, lineSeg{x1: 10000, y1: 5000, x2: 6000, y2: 5000}, segs[1]) +} diff --git a/client/world/renderer_plan.go b/client/world/renderer_plan.go new file mode 100644 index 0000000..c69d40a --- /dev/null +++ b/client/world/renderer_plan.go @@ -0,0 +1,107 @@ +package world + +// RenderPlan describes the full expanded-canvas redraw plan for one RenderParams. +// It is a pure description: it does not execute any drawing. +type RenderPlan struct { + CanvasWidthPx int + CanvasHeightPx int + + ZoomFp int + + // WorldRect is the unwrapped world-space rect covered by the expanded canvas. + WorldRect Rect + + // Tiles are ordered in the same order as produced by tileWorldRect: + // increasing tile X index, then increasing tile Y index. + Tiles []TileDrawPlan +} + +// TileDrawPlan describes how to draw one torus tile contribution. +type TileDrawPlan struct { + Tile WorldTile + + // Clip rect on the expanded canvas in pixel coordinates. + // It is half-open in spirit: [ClipX, ClipX+ClipW) x [ClipY, ClipY+ClipH). + ClipX int + ClipY int + ClipW int + ClipH int + + // Candidates are unique per tile (deduped by ID). + Candidates []MapItem +} + +// worldSpanFixedToCanvasPx converts a world fixed-point span into a canvas pixel span +// for the given fixed-point zoom. The conversion is truncating (floor). +func worldSpanFixedToCanvasPx(spanWorldFp, zoomFp int) int { + // spanWorldFp can be negative in some internal cases, but for clip computations + // we always pass non-negative spans. + return (spanWorldFp * zoomFp) / (SCALE * SCALE) +} + +// buildRenderPlanStageA builds a full expanded-canvas redraw plan (Stage A). +// +// It assumes the world grid is already built (IndexOnViewportChange called). +// The plan contains per-tile clip rectangles and per-tile candidate lists +// from the spatial index. +func (w *World) buildRenderPlanStageA(params RenderParams) (RenderPlan, error) { + if err := params.Validate(); err != nil { + return RenderPlan{}, err + } + + zoomFp, err := params.CameraZoomFp() + if err != nil { + return RenderPlan{}, err + } + + worldRect, err := params.ExpandedCanvasWorldRect() + if err != nil { + return RenderPlan{}, err + } + + tiles := tileWorldRect(worldRect, w.W, w.H) + + // Query candidates per tile. + batches, err := w.collectCandidatesForTiles(tiles) + if err != nil { + return RenderPlan{}, err + } + + planTiles := make([]TileDrawPlan, 0, len(batches)) + + for _, batch := range batches { + tile := batch.Tile + + // Convert the tile's canonical rect + offsets into the unwrapped segment. + segMinX := tile.Rect.minX + tile.OffsetX + segMaxX := tile.Rect.maxX + tile.OffsetX + segMinY := tile.Rect.minY + tile.OffsetY + segMaxY := tile.Rect.maxY + tile.OffsetY + + // Map that segment into expanded canvas pixel coordinates relative to worldRect.minX/minY. + clipX := worldSpanFixedToCanvasPx(segMinX-worldRect.minX, zoomFp) + clipY := worldSpanFixedToCanvasPx(segMinY-worldRect.minY, zoomFp) + clipX2 := worldSpanFixedToCanvasPx(segMaxX-worldRect.minX, zoomFp) + clipY2 := worldSpanFixedToCanvasPx(segMaxY-worldRect.minY, zoomFp) + + clipW := clipX2 - clipX + clipH := clipY2 - clipY + + planTiles = append(planTiles, TileDrawPlan{ + Tile: tile, + ClipX: clipX, + ClipY: clipY, + ClipW: clipW, + ClipH: clipH, + Candidates: batch.Items, + }) + } + + return RenderPlan{ + CanvasWidthPx: params.CanvasWidthPx(), + CanvasHeightPx: params.CanvasHeightPx(), + ZoomFp: zoomFp, + WorldRect: worldRect, + Tiles: planTiles, + }, nil +} diff --git a/client/world/renderer_points.go b/client/world/renderer_points.go new file mode 100644 index 0000000..655eb79 --- /dev/null +++ b/client/world/renderer_points.go @@ -0,0 +1,147 @@ +package world + +import "math" + +// renderPointsStageA performs a full expanded-canvas redraw but renders ONLY Point primitives. +func (w *World) renderPointsStageA(drawer PrimitiveDrawer, params RenderParams) error { + plan, err := w.buildRenderPlanStageA(params) + if err != nil { + return err + } + + style := DefaultRenderStyle() + if params.Options != nil && params.Options.Style != nil { + style = *params.Options.Style + } + + applyPointStyle(drawer, style) + drawPointsFromPlanWithRadius(drawer, plan, w.W, w.H, style.PointRadiusPx) + return nil +} + +// drawPointsFromPlan keeps backward compatibility for older tests/helpers. +func drawPointsFromPlan(drawer PrimitiveDrawer, plan RenderPlan) { + // Default world sizes are unknown here, so this wrapper is no longer suitable for wrap-aware points. + // Keep it for historical call sites only if they pass through Render(). + // Prefer calling drawPointsFromPlanWithRadius with world sizes. + drawPointsFromPlanWithRadius(drawer, plan, 0, 0, DefaultRenderStyle().PointRadiusPx) +} + +// drawPointsFromPlanWithRadius executes a points-only draw from an already built render plan, +// using the provided screen-space radius. If worldW/worldH are zero, wrap copies are disabled. +func drawPointsFromPlanWithRadius(drawer PrimitiveDrawer, plan RenderPlan, worldW, worldH int, radiusPx float64) { + // Convert screen radius to world-fixed conservatively (ceil), so wrap copies are not missed. + rPxInt := int(math.Ceil(radiusPx)) + if rPxInt < 0 { + rPxInt = 0 + } + rWorldFp := 0 + if rPxInt > 0 { + rWorldFp = PixelSpanToWorldFixed(rPxInt, plan.ZoomFp) + } + + for _, td := range plan.Tiles { + if td.ClipW <= 0 || td.ClipH <= 0 { + continue + } + + points := make([]Point, 0, len(td.Candidates)) + for _, it := range td.Candidates { + p, ok := it.(Point) + if !ok { + continue + } + points = append(points, p) + } + if len(points) == 0 { + continue + } + + type pointCopy struct { + p Point + dx int + dy int + } + copiesToDraw := make([]pointCopy, 0, len(points)) + + for _, p := range points { + shifts := pointWrapShifts(p, rWorldFp, worldW, worldH) + for _, s := range shifts { + if pointCopyIntersectsTile(p, rWorldFp, s.dx, s.dy, td.Tile) { + copiesToDraw = append(copiesToDraw, pointCopy{p: p, dx: s.dx, dy: s.dy}) + } + } + } + + if len(copiesToDraw) == 0 { + continue + } + + drawer.Save() + drawer.ClipRect(float64(td.ClipX), float64(td.ClipY), float64(td.ClipW), float64(td.ClipH)) + + for _, pc := range copiesToDraw { + p := pc.p + + px := worldSpanFixedToCanvasPx((p.X+td.Tile.OffsetX+pc.dx)-plan.WorldRect.minX, plan.ZoomFp) + py := worldSpanFixedToCanvasPx((p.Y+td.Tile.OffsetY+pc.dy)-plan.WorldRect.minY, plan.ZoomFp) + + drawer.AddPoint(float64(px), float64(py), radiusPx) + } + + drawer.Fill() + drawer.Restore() + } +} + +func pointWrapShifts(p Point, rWorldFp, worldW, worldH int) []wrapShift { + // If world sizes are unknown, do not generate wrap copies. + if worldW <= 0 || worldH <= 0 { + return []wrapShift{{dx: 0, dy: 0}} + } + + xShifts := []int{0} + yShifts := []int{0} + + if p.X+rWorldFp >= worldW { + xShifts = append(xShifts, -worldW) + } + if p.X-rWorldFp < 0 { + xShifts = append(xShifts, worldW) + } + + if p.Y+rWorldFp >= worldH { + yShifts = append(yShifts, -worldH) + } + if p.Y-rWorldFp < 0 { + yShifts = append(yShifts, worldH) + } + + out := make([]wrapShift, 0, len(xShifts)*len(yShifts)) + for _, dx := range xShifts { + for _, dy := range yShifts { + out = append(out, wrapShift{dx: dx, dy: dy}) + } + } + return out +} + +func pointCopyIntersectsTile(p Point, rWorldFp, dx, dy int, tile WorldTile) bool { + segMinX := tile.OffsetX + tile.Rect.minX + segMaxX := tile.OffsetX + tile.Rect.maxX + segMinY := tile.OffsetY + tile.Rect.minY + segMaxY := tile.OffsetY + tile.Rect.maxY + + px := p.X + tile.OffsetX + dx + py := p.Y + tile.OffsetY + dy + + minX := px - rWorldFp + maxX := px + rWorldFp + minY := py - rWorldFp + maxY := py + rWorldFp + + if maxX <= segMinX || minX >= segMaxX || maxY <= segMinY || minY >= segMaxY { + return false + } + return true +} diff --git a/client/world/renderer_points_test.go b/client/world/renderer_points_test.go new file mode 100644 index 0000000..ee14f23 --- /dev/null +++ b/client/world/renderer_points_test.go @@ -0,0 +1,163 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDrawPointsFromPlan_DuplicatesAcrossTilesAndClips(t *testing.T) { + t.Parallel() + + // World is 10x10 world units => 10000x10000 fixed. + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Place a point near the origin so that expanded canvas (larger than world) + // will require torus repetition and the point will appear in multiple tiles. + id, err := w.AddPoint(1.0, 1.0) // (1000,1000) + require.NoError(t, err) + + // Index only this object. + w.indexObject(w.objects[id]) + + // Choose viewport such that viewport==world in pixels at zoom=1: + // - With zoom=1 (zoomFp=SCALE), 1 world unit maps to 1 px. + // - world width=10 units => 10 px. + // Use margin=2 px on each side => canvas 14x14 px => expanded world span 14 units > world. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawPointsFromPlan(d, plan) + + // We expect 4 point copies: + // (tx=0,ty=0), (tx=0,ty=1), (tx=1,ty=0), (tx=1,ty=1) + // due to expanded rect spanning beyond world on both axes. + wantNames := []string{ + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + "Save", "ClipRect", "AddPoint", "Fill", "Restore", + } + require.Equal(t, wantNames, d.CommandNames()) + + pointRadiusPx := DefaultRenderStyle().PointRadiusPx + + // Command group 1: tile (offsetX=0, offsetY=0), clip should be (2,2,10,10), point at (3,3). + { + clip := requireDrawerCommandAt(t, d, 1) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 2, 10, 10) + + pt := requireDrawerCommandAt(t, d, 2) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 3, 3, pointRadiusPx) + } + + // Command group 2: tile (offsetX=0, offsetY=10000), clip (2,12,10,2), point at (3,13). + { + clip := requireDrawerCommandAt(t, d, 6) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 2, 12, 10, 2) + + pt := requireDrawerCommandAt(t, d, 7) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 3, 13, pointRadiusPx) + } + + // Command group 3: tile (offsetX=10000, offsetY=0), clip (12,2,2,10), point at (13,3). + { + clip := requireDrawerCommandAt(t, d, 11) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 2, 2, 10) + + pt := requireDrawerCommandAt(t, d, 12) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 13, 3, pointRadiusPx) + } + + // Command group 4: tile (offsetX=10000, offsetY=10000), clip (12,12,2,2), point at (13,13). + { + clip := requireDrawerCommandAt(t, d, 16) + require.Equal(t, "ClipRect", clip.Name) + requireCommandArgs(t, clip, 12, 12, 2, 2) + + pt := requireDrawerCommandAt(t, d, 17) + require.Equal(t, "AddPoint", pt.Name) + requireCommandArgs(t, pt, 13, 13, pointRadiusPx) + } +} + +func TestDrawPointsFromPlan_SkipsTilesWithoutPoints(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Add only a line, no points. + id, err := w.AddLine(2, 2, 8, 2) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawPointsFromPlan(d, plan) + + // No points => no drawing commands at all. + require.Empty(t, d.Commands()) +} + +func TestWorldRender_PointsOnlyStageA(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(5, 5) + require.NoError(t, err) + + // Build index. In real UI it happens via IndexOnViewportChange. + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params) + require.NoError(t, err) + + // At least one point draw should happen. + require.Contains(t, d.CommandNames(), "AddPoint") +} diff --git a/client/world/renderer_points_wrap_test.go b/client/world/renderer_points_wrap_test.go new file mode 100644 index 0000000..ce27b92 --- /dev/null +++ b/client/world/renderer_points_wrap_test.go @@ -0,0 +1,99 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPoints_WrapCopies_AppearInsideViewportWhenViewportEqualsWorld(t *testing.T) { + t.Parallel() + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 0, + MarginYPx: 0, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Style: func() *RenderStyle { + s := DefaultRenderStyle() + s.PointRadiusPx = 2.0 // so that a point at 9 "spills" by 1 and needs a copy at -1 + return &s + }(), + }, + } + + type tc struct { + name string + x, y float64 + wantCenters [][2]float64 + } + + tests := []tc{ + { + name: "bottom boundary wraps to top", + x: 5, + y: 9, + wantCenters: [][2]float64{{5, 9}, {5, -1}}, + }, + { + name: "right boundary wraps to left", + x: 9, + y: 5, + wantCenters: [][2]float64{{9, 5}, {-1, 5}}, + }, + { + name: "corner wraps to three extra copies", + x: 9, + y: 9, + wantCenters: [][2]float64{{9, 9}, {9, -1}, {-1, 9}, {-1, -1}}, + }, + { + name: "no wrap inside", + x: 5, + y: 5, + wantCenters: [][2]float64{{5, 5}}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(tt.x, tt.y) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + style := DefaultRenderStyle() + style.PointRadiusPx = 2.0 + + applyPointStyle(d, style) + drawPointsFromPlanWithRadius(d, plan, w.W, w.H, style.PointRadiusPx) + + cmds := d.CommandsByName("AddPoint") + require.Len(t, cmds, len(tt.wantCenters)) + + got := make([][2]float64, 0, len(cmds)) + for _, c := range cmds { + require.Len(t, c.Args, 3) + got = append(got, [2]float64{c.Args[0], c.Args[1]}) + } + + require.ElementsMatch(t, tt.wantCenters, got) + }) + } +} diff --git a/client/world/renderer_query.go b/client/world/renderer_query.go new file mode 100644 index 0000000..fdd2a68 --- /dev/null +++ b/client/world/renderer_query.go @@ -0,0 +1,80 @@ +package world + +import ( + "errors" + + "github.com/google/uuid" +) + +var ( + errGridNotBuilt = errors.New("render: grid not built; call IndexOnViewportChange first") +) + +// TileCandidates binds one torus tile to the list of unique grid candidates +// that intersect the tile rectangle. +// +// Items are not guaranteed to be truly visible; the grid is a coarse spatial index. +// Exact visibility tests are performed later in the renderer pipeline. +type TileCandidates struct { + Tile WorldTile + Items []MapItem +} + +// collectCandidatesForTiles queries the world grid for each tile rectangle +// and returns per-tile unique candidate lists. +// +// Deduplication is performed per tile (by MapItem.ID()) to avoid duplicates caused by +// bbox indexing into multiple cells. Dedup across tiles is intentionally NOT performed. +func (w *World) collectCandidatesForTiles(tiles []WorldTile) ([]TileCandidates, error) { + if w.grid == nil || w.rows <= 0 || w.cols <= 0 || w.cellSize <= 0 { + return nil, errGridNotBuilt + } + + out := make([]TileCandidates, 0, len(tiles)) + for _, tile := range tiles { + items := w.collectCandidatesForTile(tile.Rect) + out = append(out, TileCandidates{ + Tile: tile, + Items: items, + }) + } + return out, nil +} + +// collectCandidatesForTile returns a unique set of grid candidates for a single +// canonical-world tile rectangle [0..W) x [0..H). +// +// The rectangle must be half-open and expressed in fixed-point world coordinates. +func (w *World) collectCandidatesForTile(r Rect) []MapItem { + // Empty rect => no candidates. + if r.maxX <= r.minX || r.maxY <= r.minY { + return nil + } + + // Map rect to cell ranges using the same half-open conventions as indexing: + // the last included cell is computed from (max-1). + colStart := w.worldToCellX(r.minX) + colEnd := w.worldToCellX(r.maxX - 1) + + rowStart := w.worldToCellY(r.minY) + rowEnd := w.worldToCellY(r.maxY - 1) + + seen := make(map[uuid.UUID]struct{}) + result := make([]MapItem, 0) + + for row := rowStart; row <= rowEnd; row++ { + for col := colStart; col <= colEnd; col++ { + cell := w.grid[row][col] + for _, item := range cell { + id := item.ID() + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, item) + } + } + } + + return result +} diff --git a/client/world/renderer_render_test.go b/client/world/renderer_render_test.go new file mode 100644 index 0000000..5435590 --- /dev/null +++ b/client/world/renderer_render_test.go @@ -0,0 +1,44 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWorldRender_DrawsAllLayersInDefaultOrder(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(1, 1) + require.NoError(t, err) + _, err = w.AddCircle(2, 2, 1) + require.NoError(t, err) + _, err = w.AddLine(9, 5, 1, 5) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params) + require.NoError(t, err) + + names := d.CommandNames() + require.Contains(t, names, "AddPoint") + require.Contains(t, names, "AddCircle") + require.Contains(t, names, "AddLine") +} diff --git a/client/world/renderer_schedule.go b/client/world/renderer_schedule.go new file mode 100644 index 0000000..2adeb31 --- /dev/null +++ b/client/world/renderer_schedule.go @@ -0,0 +1,70 @@ +// Pseudo-code / toolkit-agnostic example. +// Goal: never render more than one frame concurrently, and always render the latest params. +// +// Это не “асинхронная работа в фоне”, а чистый паттерн управления вызовами Render. +// Он работает в любом UI, где у тебя есть loop и возможность запускать отрисовку +// (например, через requestAnimationFrame-аналог или таски). +// +// Как это сочетается с CoalesceUpdates +// - Если params.Options.Incremental.CoalesceUpdates == true, UI использует этот scheduler. +// - Если false, UI может пытаться рендерить каждое событие (но это обычно хуже). +// +// # Важный момент +// +// Это не делает рендер асинхронным в смысле “рендерить в фоне” — в реальном UI ты должен +// выполнять w.Render строго в UI thread. Я показал go только как “планировщик” +// (в твоём GUI заменишь на invokeOnMainThread/PostTask/RunOnUI). +package world + +import "sync" + +type RenderScheduler struct { + w *World + drawer PrimitiveDrawer + + // Protects fields below. + mu sync.Mutex + + inFlight bool + pending bool + latest RenderParams +} + +// RequestRender stores the latest params and schedules rendering. +// If a render is already in progress, it coalesces (drops intermediate requests). +func (s *RenderScheduler) RequestRender(params RenderParams) { + s.mu.Lock() + s.latest = params + if s.inFlight { + s.pending = true + s.mu.Unlock() + return + } + s.inFlight = true + s.mu.Unlock() + + // Schedule on the UI thread/event loop. Replace this with your toolkit method. + go s.runOnUIThread() +} + +// runOnUIThread should execute on the UI thread. +// Replace the body with actual UI scheduling primitives. +func (s *RenderScheduler) runOnUIThread() { + for { + s.mu.Lock() + params := s.latest + s.mu.Unlock() + + _ = s.w.Render(s.drawer, params) // handle error in real code + + s.mu.Lock() + if !s.pending { + s.inFlight = false + s.mu.Unlock() + return + } + // There was a newer request while we were rendering. Loop and render latest. + s.pending = false + s.mu.Unlock() + } +} diff --git a/client/world/renderer_smoke_all_primitives_test.go b/client/world/renderer_smoke_all_primitives_test.go new file mode 100644 index 0000000..5bd9ec1 --- /dev/null +++ b/client/world/renderer_smoke_all_primitives_test.go @@ -0,0 +1,60 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmoke_DrawPointsCirclesLinesFromSamePlan(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Mix primitives. Use values that are safely inside the world. + _, err := w.AddPoint(1, 1) + require.NoError(t, err) + + _, err = w.AddCircle(2, 2, 1) + require.NoError(t, err) + + // A line that wraps across X to ensure line splitting logic is exercised. + _, err = w.AddLine(9, 5, 1, 5) + require.NoError(t, err) + + // Build index (in UI: IndexOnViewportChange does this). + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + + // Execute all three passes over the same plan. + drawPointsFromPlan(d, plan) + drawCirclesFromPlan(d, plan, w.W, w.H) + drawLinesFromPlan(d, plan, w.W, w.H) + + names := d.CommandNames() + + require.Contains(t, names, "AddPoint") + require.Contains(t, names, "AddCircle") + require.Contains(t, names, "AddLine") + + // Ensure finalizers were used (points+circles use Fill, lines use Stroke). + require.Contains(t, names, "Fill") + require.Contains(t, names, "Stroke") +} diff --git a/client/world/renderer_smoke_mixed_test.go b/client/world/renderer_smoke_mixed_test.go new file mode 100644 index 0000000..272e057 --- /dev/null +++ b/client/world/renderer_smoke_mixed_test.go @@ -0,0 +1,44 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmoke_DrawPointsAndCirclesFromSamePlan(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddPoint(1, 1) + require.NoError(t, err) + _, err = w.AddCircle(2, 2, 1) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + d := &fakePrimitiveDrawer{} + drawPointsFromPlan(d, plan) + drawCirclesFromPlan(d, plan, w.W, w.H) + + names := d.CommandNames() + require.Contains(t, names, "AddPoint") + require.Contains(t, names, "AddCircle") +} diff --git a/client/world/renderer_style.go b/client/world/renderer_style.go new file mode 100644 index 0000000..63a8a61 --- /dev/null +++ b/client/world/renderer_style.go @@ -0,0 +1,54 @@ +package world + +import "image/color" + +// RenderStyle describes visual parameters for renderer passes. +// It is intentionally screen-space oriented (pixels), since the renderer +// already projects world coordinates into canvas pixels. +type RenderStyle struct { + // PointRadiusPx is the screen-space radius for Point markers. + PointRadiusPx float64 + + // PointFill is the fill color for points. + PointFill color.Color + + // CircleFill is the fill color for circles. + CircleFill color.Color + + // LineStroke is the stroke color for lines. + LineStroke color.Color + + // LineWidthPx is the stroke width for lines. + LineWidthPx float64 + + // LineDash is the dash pattern for lines. Empty => solid. + LineDash []float64 + + // LineDashOffset is the dash phase for lines. + LineDashOffset float64 +} + +// DefaultRenderStyle returns the default style used when UI does not provide one. +// Defaults are intentionally simple and stable for testing. +func DefaultRenderStyle() RenderStyle { + return RenderStyle{ + PointRadiusPx: 2.0, + PointFill: color.White, + + CircleFill: color.White, + + LineStroke: color.White, + LineWidthPx: 2.0, + LineDash: nil, + LineDashOffset: 0, + } +} + +func DefaultIncrementalPolicy() IncrementalPolicy { + return IncrementalPolicy{ + CoalesceUpdates: false, + AllowShiftOnly: false, + RenderBudgetMs: 0, + MaxCatchUpAreaPx: 0, + } +} diff --git a/client/world/renderer_style_apply.go b/client/world/renderer_style_apply.go new file mode 100644 index 0000000..97875e0 --- /dev/null +++ b/client/world/renderer_style_apply.go @@ -0,0 +1,19 @@ +package world + +// applyPointStyle configures drawer state for point rendering. +func applyPointStyle(drawer PrimitiveDrawer, style RenderStyle) { + drawer.SetFillColor(style.PointFill) +} + +// applyCircleStyle configures drawer state for circle rendering. +func applyCircleStyle(drawer PrimitiveDrawer, style RenderStyle) { + drawer.SetFillColor(style.CircleFill) +} + +// applyLineStyle configures drawer state for line rendering. +func applyLineStyle(drawer PrimitiveDrawer, style RenderStyle) { + drawer.SetStrokeColor(style.LineStroke) + drawer.SetLineWidth(style.LineWidthPx) + drawer.SetDash(style.LineDash...) + drawer.SetDashOffset(style.LineDashOffset) +} diff --git a/client/world/renderer_style_test.go b/client/world/renderer_style_test.go new file mode 100644 index 0000000..1ba75d7 --- /dev/null +++ b/client/world/renderer_style_test.go @@ -0,0 +1,58 @@ +package world + +import ( + "image/color" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWorldRender_AppliesLineStyle(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + _, err := w.AddLine(9, 5, 1, 5) + require.NoError(t, err) + + for _, obj := range w.objects { + w.indexObject(obj) + } + + custom := DefaultRenderStyle() + custom.LineWidthPx = 3 + custom.LineDash = []float64{5, 2} + custom.LineDashOffset = 1 + custom.LineStroke = color.RGBA{R: 255, A: 255} + + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + Options: &RenderOptions{ + Layers: []RenderLayer{RenderLayerLines}, + Style: &custom, + }, + } + + d := &fakePrimitiveDrawer{} + err = w.Render(d, params) + require.NoError(t, err) + + // There must be at least one AddLine call, and every AddLine must observe + // the configured line state snapshot. + cmds := d.CommandsByName("AddLine") + require.NotEmpty(t, cmds) + + for _, cmd := range cmds { + require.Equal(t, 3.0, cmd.LineWidth) + require.Equal(t, []float64{5, 2}, cmd.Dashes) + require.Equal(t, 1.0, cmd.DashOffset) + require.Equal(t, color.RGBA{R: 255, A: 255}, cmd.StrokeColor) + } +} diff --git a/client/world/renderer_test.go b/client/world/renderer_test.go new file mode 100644 index 0000000..0d52412 --- /dev/null +++ b/client/world/renderer_test.go @@ -0,0 +1,697 @@ +package world + +import ( + "sort" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestRenderParamsCanvasSize(t *testing.T) { + t.Parallel() + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + } + + require.Equal(t, 150, params.CanvasWidthPx()) + require.Equal(t, 120, params.CanvasHeightPx()) +} + +func TestRenderParamsCameraZoomFp(t *testing.T) { + t.Parallel() + + params := RenderParams{ + CameraZoom: 1.25, + } + + zoomFp, err := params.CameraZoomFp() + require.NoError(t, err) + require.Equal(t, 1250, zoomFp) +} + +func TestRenderParamsExpandedCanvasWorldRect(t *testing.T) { + t.Parallel() + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 50 * SCALE, + CameraYWorldFp: 70 * SCALE, + CameraZoom: 2.0, + } + + rect, err := params.ExpandedCanvasWorldRect() + require.NoError(t, err) + + require.Equal(t, 12500, rect.minX) + require.Equal(t, 87500, rect.maxX) + require.Equal(t, 40000, rect.minY) + require.Equal(t, 100000, rect.maxY) +} + +func TestRenderParamsExpandedCanvasWorldRectAllowsOutOfWorldCamera(t *testing.T) { + t.Parallel() + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: -10 * SCALE, + CameraYWorldFp: 3 * SCALE, + CameraZoom: 1.0, + } + + rect, err := params.ExpandedCanvasWorldRect() + require.NoError(t, err) + + require.Equal(t, -85000, rect.minX) + require.Equal(t, 65000, rect.maxX) + require.Equal(t, -57000, rect.minY) + require.Equal(t, 63000, rect.maxY) +} + +func TestRenderParamsValidate(t *testing.T) { + t.Parallel() + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 123456, + CameraYWorldFp: -987654, + CameraZoom: 1.0, + } + + require.NoError(t, params.Validate()) +} + +func TestRenderParamsValidateRejectsInvalidViewport(t *testing.T) { + t.Parallel() + + tests := []RenderParams{ + { + ViewportWidthPx: 0, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 1.0, + }, + { + ViewportWidthPx: 100, + ViewportHeightPx: 0, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 1.0, + }, + { + ViewportWidthPx: -1, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 1.0, + }, + { + ViewportWidthPx: 100, + ViewportHeightPx: -1, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 1.0, + }, + } + + for _, params := range tests { + require.ErrorIs(t, params.Validate(), errInvalidViewportSize) + } +} + +func TestRenderParamsValidateRejectsInvalidMargins(t *testing.T) { + t.Parallel() + + tests := []RenderParams{ + { + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: -1, + MarginYPx: 20, + CameraZoom: 1.0, + }, + { + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: -1, + CameraZoom: 1.0, + }, + } + + for _, params := range tests { + require.ErrorIs(t, params.Validate(), errInvalidMargins) + } +} + +func TestRenderParamsValidateRejectsInvalidCameraZoom(t *testing.T) { + t.Parallel() + + tests := []RenderParams{ + { + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 0, + }, + { + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: -1, + }, + { + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 0.0004, + }, + } + + for _, params := range tests { + require.EqualError(t, params.Validate(), "invalid camera zoom") + } +} + +func TestExpandedCanvasWorldRect(t *testing.T) { + t.Parallel() + + rect := expandedCanvasWorldRect( + 50*SCALE, 70*SCALE, + 150, 120, + 2*SCALE, + ) + + require.Equal(t, 12500, rect.minX) + require.Equal(t, 87500, rect.maxX) + require.Equal(t, 40000, rect.minY) + require.Equal(t, 100000, rect.maxY) +} + +func TestExpandedCanvasWorldRectPanics(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + _ = expandedCanvasWorldRect(0, 0, 0, 10, SCALE) + }) + + require.Panics(t, func() { + _ = expandedCanvasWorldRect(0, 0, 10, 0, SCALE) + }) + + require.Panics(t, func() { + _ = expandedCanvasWorldRect(0, 0, 10, 10, 0) + }) +} + +func TestWorldRenderRejectsNilDrawer(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + err := w.Render(nil, RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 1.0, + }) + + require.ErrorIs(t, err, errNilDrawer) +} + +func TestWorldRenderRejectsInvalidParams(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ + ViewportWidthPx: 0, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraZoom: 1.0, + }) + + require.ErrorIs(t, err, errInvalidViewportSize) +} + +func TestWorldRenderReturnsErrorWhenGridNotBuilt(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + }) + + require.ErrorIs(t, err, errGridNotBuilt) +} + +func TestWorldRenderStageAStubReturnsNilOnValidInput(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + // Render relies on the spatial grid being built. + // In production UI this is done via IndexOnViewportChange. + w.IndexOnViewportChange(100, 80, 1.0) + + err := w.Render(&fakePrimitiveDrawer{}, RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 12345, + CameraYWorldFp: -67890, + CameraZoom: 1.0, + }) + + require.NoError(t, err) +} + +func TestTileWorldRect_NoWrapSingleTile(t *testing.T) { + t.Parallel() + + worldW := 100 + worldH := 80 + + rect := Rect{minX: 10, maxX: 30, minY: 5, maxY: 25} + tiles := tileWorldRect(rect, worldW, worldH) + + require.Len(t, tiles, 1) + require.Equal(t, 0, tiles[0].OffsetX) + require.Equal(t, 0, tiles[0].OffsetY) + + require.Equal(t, 10, tiles[0].Rect.minX) + require.Equal(t, 30, tiles[0].Rect.maxX) + require.Equal(t, 5, tiles[0].Rect.minY) + require.Equal(t, 25, tiles[0].Rect.maxY) +} + +func TestTileWorldRect_WrapX_TwoTiles(t *testing.T) { + t.Parallel() + + worldW := 100 + worldH := 80 + + // Crosses the X boundary once: [30..130) maps to: + // tile 0: [30..100) offset 0 + // tile 1: [0..30) offset 100 + rect := Rect{minX: 30, maxX: 130, minY: 10, maxY: 20} + tiles := tileWorldRect(rect, worldW, worldH) + + require.Len(t, tiles, 2) + + require.Equal(t, 0, tiles[0].OffsetX) + require.Equal(t, 0, tiles[0].OffsetY) + require.Equal(t, Rect{minX: 30, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect) + + require.Equal(t, 100, tiles[1].OffsetX) + require.Equal(t, 0, tiles[1].OffsetY) + require.Equal(t, Rect{minX: 0, maxX: 30, minY: 10, maxY: 20}, tiles[1].Rect) +} + +func TestTileWorldRect_WrapX_NegativeCoords(t *testing.T) { + t.Parallel() + + worldW := 100 + worldH := 80 + + // Crosses boundary around 0: [-20..20) maps to: + // tile -1: [80..100) offset -100 + // tile 0: [0..20) offset 0 + rect := Rect{minX: -20, maxX: 20, minY: 10, maxY: 20} + tiles := tileWorldRect(rect, worldW, worldH) + + require.Len(t, tiles, 2) + + require.Equal(t, -100, tiles[0].OffsetX) + require.Equal(t, 0, tiles[0].OffsetY) + require.Equal(t, Rect{minX: 80, maxX: 100, minY: 10, maxY: 20}, tiles[0].Rect) + + require.Equal(t, 0, tiles[1].OffsetX) + require.Equal(t, 0, tiles[1].OffsetY) + require.Equal(t, Rect{minX: 0, maxX: 20, minY: 10, maxY: 20}, tiles[1].Rect) +} + +func TestTileWorldRect_WrapXY_FourTiles(t *testing.T) { + t.Parallel() + + worldW := 100 + worldH := 80 + + // Crosses both X and Y boundaries once. + // X: [30..130) => tile 0 [30..100), tile 1 [0..30) + // Y: [60..100) => tile 0 [60..80), tile 1 [0..20) + rect := Rect{minX: 30, maxX: 130, minY: 60, maxY: 100} + tiles := tileWorldRect(rect, worldW, worldH) + + require.Len(t, tiles, 4) + + // Order: tx ascending, then ty ascending. + require.Equal(t, 0, tiles[0].OffsetX) + require.Equal(t, 0, tiles[0].OffsetY) + require.Equal(t, Rect{minX: 30, maxX: 100, minY: 60, maxY: 80}, tiles[0].Rect) + + require.Equal(t, 0, tiles[1].OffsetX) + require.Equal(t, 80, tiles[1].OffsetY) + require.Equal(t, Rect{minX: 30, maxX: 100, minY: 0, maxY: 20}, tiles[1].Rect) + + require.Equal(t, 100, tiles[2].OffsetX) + require.Equal(t, 0, tiles[2].OffsetY) + require.Equal(t, Rect{minX: 0, maxX: 30, minY: 60, maxY: 80}, tiles[2].Rect) + + require.Equal(t, 100, tiles[3].OffsetX) + require.Equal(t, 80, tiles[3].OffsetY) + require.Equal(t, Rect{minX: 0, maxX: 30, minY: 0, maxY: 20}, tiles[3].Rect) +} + +func TestTileWorldRect_EmptyRectReturnsNil(t *testing.T) { + t.Parallel() + + worldW := 100 + worldH := 80 + + require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 0, minY: 0, maxY: 10}, worldW, worldH)) + require.Nil(t, tileWorldRect(Rect{minX: 0, maxX: 10, minY: 0, maxY: 0}, worldW, worldH)) + require.Nil(t, tileWorldRect(Rect{minX: 10, maxX: 0, minY: 0, maxY: 10}, worldW, worldH)) +} + +func TestTileWorldRectPanicsOnInvalidWorldSize(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 0, 10) }) + require.Panics(t, func() { _ = tileWorldRect(Rect{minX: 0, maxX: 1, minY: 0, maxY: 1}, 10, 0) }) +} + +func TestCollectCandidatesForTilesReturnsErrorWhenGridNotBuilt(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + tiles := []WorldTile{ + { + Rect: Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H}, + }, + } + + _, err := w.collectCandidatesForTiles(tiles) + require.ErrorIs(t, err, errGridNotBuilt) +} + +func TestCollectCandidatesForTileDedupsWithinOneTile(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) // 5x5 grid + + // Circle in the middle, radius big enough to cover multiple cells. + id, err := w.AddCircle(5, 5, 2.2) + require.NoError(t, err) + + // Build index. + w.indexObject(w.objects[id]) + + // Query whole world tile. + items := w.collectCandidatesForTile(Rect{minX: 0, maxX: w.W, minY: 0, maxY: w.H}) + + // The circle is indexed into multiple cells, but must appear only once in candidates. + require.Len(t, items, 1) + require.Equal(t, id, items[0].ID()) +} + +func TestCollectCandidatesForTileReturnsPointInCoveredCell(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) // cells are [0..2), [2..4), ... + + id, err := w.AddPoint(1.0, 1.0) // (1000,1000) => cell (0,0) + require.NoError(t, err) + + w.indexObject(w.objects[id]) + + // Query exactly the first cell as half-open rect. + r := Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: 2 * SCALE} + items := w.collectCandidatesForTile(r) + + require.Len(t, items, 1) + require.Equal(t, id, items[0].ID()) + + // Query adjacent cell (should not contain the point). + r2 := Rect{minX: 2 * SCALE, maxX: 4 * SCALE, minY: 0, maxY: 2 * SCALE} + items2 := w.collectCandidatesForTile(r2) + require.Empty(t, items2) +} + +func TestCollectCandidatesForTilesWrapIndexedCircleAppearsInBothSides(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Circle near the left edge crossing X=0 boundary. + // With radius 1.0 it covers [-0.5..1.5] in world units => wrap indexes both left and right sides. + id, err := w.AddCircle(0.5, 5.0, 1.0) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + leftStrip := WorldTile{ + Rect: Rect{minX: 0, maxX: 2 * SCALE, minY: 0, maxY: w.H}, + } + rightStrip := WorldTile{ + Rect: Rect{minX: w.W - 2*SCALE, maxX: w.W, minY: 0, maxY: w.H}, + } + + batches, err := w.collectCandidatesForTiles([]WorldTile{leftStrip, rightStrip}) + require.NoError(t, err) + require.Len(t, batches, 2) + + // Expect the circle candidate to appear in both batches (different render offsets later). + require.Len(t, batches[0].Items, 1) + require.Equal(t, id, batches[0].Items[0].ID()) + + require.Len(t, batches[1].Items, 1) + require.Equal(t, id, batches[1].Items[0].ID()) +} + +func TestBuildRenderPlanStageA_SingleTileClipIsWholeCanvas(t *testing.T) { + t.Parallel() + + // World: 100x80 real => 100000x80000 fixed. + w := NewWorld(100, 80) + + // Build any grid (cell size doesn't matter for this test, but grid must exist). + w.resetGrid(10 * SCALE) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 50 * SCALE, + CameraYWorldFp: 40 * SCALE, + CameraZoom: 2.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + require.Equal(t, 150, plan.CanvasWidthPx) + require.Equal(t, 120, plan.CanvasHeightPx) + require.Equal(t, 2*SCALE, plan.ZoomFp) + + require.Len(t, plan.Tiles, 1) + td := plan.Tiles[0] + + require.Equal(t, 0, td.ClipX) + require.Equal(t, 0, td.ClipY) + require.Equal(t, 150, td.ClipW) + require.Equal(t, 120, td.ClipH) + + require.Equal(t, 0, td.Tile.OffsetX) + require.Equal(t, 0, td.Tile.OffsetY) +} + +func TestBuildRenderPlanStageA_TilesCoverCanvasWithoutGaps(t *testing.T) { + t.Parallel() + + // World: 10x10 => 10000x10000 fixed. + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Use zoom=1 and the same canvas geometry as earlier: 150x120. + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + + require.Equal(t, 150, plan.CanvasWidthPx) + require.Equal(t, 120, plan.CanvasHeightPx) + require.Equal(t, SCALE, plan.ZoomFp) + + type interval struct { + start int + end int + } + + // Full tile size in pixels for one whole world width/height at the current zoom. + fullTileXPx := worldSpanFixedToCanvasPx(w.W, plan.ZoomFp) + fullTileYPx := worldSpanFixedToCanvasPx(w.H, plan.ZoomFp) + require.Greater(t, fullTileXPx, 0) + require.Greater(t, fullTileYPx, 0) + + // ---- X coverage ---- + // Take the first Y strip only to avoid duplicates across Y. + intervalsX := make([]interval, 0, len(plan.Tiles)) + for _, td := range plan.Tiles { + if td.ClipY == 0 && td.ClipH > 0 && td.ClipW > 0 { + intervalsX = append(intervalsX, interval{ + start: td.ClipX, + end: td.ClipX + td.ClipW, + }) + // A single tile must never exceed one whole world-tile width in pixels. + require.LessOrEqual(t, td.ClipW, fullTileXPx, "tile width must not exceed one world tile in pixels") + } + } + require.NotEmpty(t, intervalsX) + + sort.Slice(intervalsX, func(i, j int) bool { + if intervalsX[i].start != intervalsX[j].start { + return intervalsX[i].start < intervalsX[j].start + } + return intervalsX[i].end < intervalsX[j].end + }) + + require.Equal(t, 0, intervalsX[0].start) + cursorX := intervalsX[0].end + for i := 1; i < len(intervalsX); i++ { + require.Equal(t, cursorX, intervalsX[i].start, "gap/overlap in X coverage between intervals %d and %d", i-1, i) + cursorX = intervalsX[i].end + } + require.Equal(t, plan.CanvasWidthPx, cursorX) + + // ---- Y coverage ---- + // Take the first X strip only to avoid duplicates across X. + intervalsY := make([]interval, 0, len(plan.Tiles)) + for _, td := range plan.Tiles { + if td.ClipX == 0 && td.ClipW > 0 && td.ClipH > 0 { + intervalsY = append(intervalsY, interval{ + start: td.ClipY, + end: td.ClipY + td.ClipH, + }) + // A single tile must never exceed one whole world-tile height in pixels. + require.LessOrEqual(t, td.ClipH, fullTileYPx, "tile height must not exceed one world tile in pixels") + } + } + require.NotEmpty(t, intervalsY) + + sort.Slice(intervalsY, func(i, j int) bool { + if intervalsY[i].start != intervalsY[j].start { + return intervalsY[i].start < intervalsY[j].start + } + return intervalsY[i].end < intervalsY[j].end + }) + + require.Equal(t, 0, intervalsY[0].start) + cursorY := intervalsY[0].end + for i := 1; i < len(intervalsY); i++ { + require.Equal(t, cursorY, intervalsY[i].start, "gap/overlap in Y coverage between intervals %d and %d", i-1, i) + cursorY = intervalsY[i].end + } + require.Equal(t, plan.CanvasHeightPx, cursorY) +} + +func TestBuildRenderPlanStageA_CandidatesArePerTileDeduped(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) + + // Put one circle in the world and index it, it will occupy multiple cells. + id, err := w.AddCircle(5, 5, 2.2) + require.NoError(t, err) + w.indexObject(w.objects[id]) + + params := RenderParams{ + ViewportWidthPx: 100, + ViewportHeightPx: 80, + MarginXPx: 25, + MarginYPx: 20, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + + plan, err := w.buildRenderPlanStageA(params) + require.NoError(t, err) + require.NotEmpty(t, plan.Tiles) + + // Find any tile that has candidates; expect the circle to appear at most once per tile. + for _, td := range plan.Tiles { + if len(td.Candidates) == 0 { + continue + } + seen := map[uuid.UUID]struct{}{} + for _, it := range td.Candidates { + _, ok := seen[it.ID()] + require.False(t, ok, "candidate duplicated within a tile") + seen[it.ID()] = struct{}{} + } + } +} + +func TestWorldForceFullRedrawNextResetsIncrementalState(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + // Initialize state via a full render commit. + params := RenderParams{ + ViewportWidthPx: 10, + ViewportHeightPx: 10, + MarginXPx: 2, + MarginYPx: 2, + CameraXWorldFp: 5 * SCALE, + CameraYWorldFp: 5 * SCALE, + CameraZoom: 1.0, + } + w.resetGrid(2 * SCALE) + require.NoError(t, w.CommitFullRedrawState(params)) + require.True(t, w.renderState.initialized) + + w.ForceFullRedrawNext() + require.False(t, w.renderState.initialized) +} diff --git a/client/world/renderer_test_helpers_test.go b/client/world/renderer_test_helpers_test.go new file mode 100644 index 0000000..67cea28 --- /dev/null +++ b/client/world/renderer_test_helpers_test.go @@ -0,0 +1,199 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// rendererTestEnv groups the common mutable inputs used by renderer tests. +// The environment stores independent horizontal and vertical margins because +// the expanded canvas geometry is derived separately on each axis. +type rendererTestEnv struct { + world *World + drawer *fakePrimitiveDrawer + + // Viewport origin and size in canvas pixel coordinates. + viewportX int + viewportY int + viewportW int + viewportH int + + // Independent margins around the viewport in canvas pixels. + marginXPx int + marginYPx int + + // Final expanded canvas size in pixels. + // In the default setup: + // canvasW = viewportW + 2*marginXPx + // canvasH = viewportH + 2*marginYPx + canvasW int + canvasH int + + // Camera center in fixed-point world coordinates. + cameraX int + cameraY int + + // Camera zoom in fixed-point representation, if needed by renderer internals. + zoomFp int +} + +// newRendererTestEnv returns a baseline renderer test environment. +// The default margins are derived independently from viewport width and height. +func newRendererTestEnv() *rendererTestEnv { + viewportW := 100 + viewportH := 80 + + marginXPx := viewportW / 4 + marginYPx := viewportH / 4 + + return &rendererTestEnv{ + world: NewWorld(10, 10), + drawer: &fakePrimitiveDrawer{}, + viewportX: marginXPx, + viewportY: marginYPx, + viewportW: viewportW, + viewportH: viewportH, + marginXPx: marginXPx, + marginYPx: marginYPx, + canvasW: viewportW + 2*marginXPx, + canvasH: viewportH + 2*marginYPx, + cameraX: 5 * SCALE, + cameraY: 5 * SCALE, + zoomFp: SCALE, + } +} + +// setViewport resets viewport-dependent fields and recomputes margins +// using the default test formula: +// +// marginXPx = viewportW / 4 +// marginYPx = viewportH / 4 +func (env *rendererTestEnv) setViewport(viewportW, viewportH int) { + env.viewportW = viewportW + env.viewportH = viewportH + + env.marginXPx = viewportW / 4 + env.marginYPx = viewportH / 4 + + env.viewportX = env.marginXPx + env.viewportY = env.marginYPx + + env.canvasW = env.viewportW + 2*env.marginXPx + env.canvasH = env.viewportH + 2*env.marginYPx +} + +// setViewportAndMargins overrides viewport and margins explicitly. +// This is useful for edge cases where the expanded canvas geometry +// must be controlled exactly. +func (env *rendererTestEnv) setViewportAndMargins(viewportW, viewportH, marginXPx, marginYPx int) { + env.viewportW = viewportW + env.viewportH = viewportH + + env.marginXPx = marginXPx + env.marginYPx = marginYPx + + env.viewportX = env.marginXPx + env.viewportY = env.marginYPx + + env.canvasW = env.viewportW + 2*env.marginXPx + env.canvasH = env.viewportH + 2*env.marginYPx +} + +// viewportRect returns the viewport rectangle in canvas pixel coordinates. +func (env *rendererTestEnv) viewportRect() (x, y, w, h float64) { + return float64(env.viewportX), float64(env.viewportY), float64(env.viewportW), float64(env.viewportH) +} + +// canvasRect returns the full expanded canvas rectangle in canvas pixel coordinates. +func (env *rendererTestEnv) canvasRect() (x, y, w, h float64) { + return 0, 0, float64(env.canvasW), float64(env.canvasH) +} + +// worldMustAddPoint adds a point to the test world and fails the test on error. +func worldMustAddPoint(t *testing.T, w *World, x, y float64) { + t.Helper() + + _, err := w.AddPoint(x, y) + require.NoError(t, err) +} + +// worldMustAddCircle adds a circle to the test world and fails the test on error. +func worldMustAddCircle(t *testing.T, w *World, x, y, r float64) { + t.Helper() + + _, err := w.AddCircle(x, y, r) + require.NoError(t, err) +} + +// worldMustAddLine adds a line to the test world and fails the test on error. +func worldMustAddLine(t *testing.T, w *World, x1, y1, x2, y2 float64) { + t.Helper() + + _, err := w.AddLine(x1, y1, x2, y2) + require.NoError(t, err) +} + +// requireNoDrawerCommands asserts that the renderer produced no drawing commands. +func requireNoDrawerCommands(t *testing.T, d *fakePrimitiveDrawer) { + t.Helper() + + require.Empty(t, d.Commands()) +} + +// requireStrokeCommandAt returns a command and asserts that it is Stroke. +func requireStrokeCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "Stroke") + return cmd +} + +// requireFillCommandAt returns a command and asserts that it is Fill. +func requireFillCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "Fill") + return cmd +} + +// requireAddPointCommandAt returns a command and asserts that it is AddPoint. +func requireAddPointCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "AddPoint") + return cmd +} + +// requireAddLineCommandAt returns a command and asserts that it is AddLine. +func requireAddLineCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "AddLine") + return cmd +} + +// requireAddCircleCommandAt returns a command and asserts that it is AddCircle. +func requireAddCircleCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand { + t.Helper() + + cmd := requireDrawerCommandAt(t, d, index) + requireCommandName(t, cmd, "AddCircle") + return cmd +} + +// requireSingleClipRectOnCommand asserts that the command was issued under exactly one clip rect. +func requireSingleClipRectOnCommand(t *testing.T, cmd fakeDrawerCommand, x, y, w, h float64) { + t.Helper() + + requireCommandClipRects(t, cmd, fakeClipRect{ + X: x, + Y: y, + W: w, + H: h, + }) +} diff --git a/client/world/renderer_test_template_test.go b/client/world/renderer_test_template_test.go new file mode 100644 index 0000000..f8f59f9 --- /dev/null +++ b/client/world/renderer_test_template_test.go @@ -0,0 +1,219 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// rendererTestCase is a generic table-driven renderer test scaffold. +// Replace invoke with the real renderer call once the renderer exists. +type rendererTestCase struct { + name string + + // setup prepares the world and optional environment overrides. + setup func(t *testing.T, env *rendererTestEnv) + + // invoke calls the renderer under test. + invoke func(t *testing.T, env *rendererTestEnv) + + // verify checks the produced fake drawer log. + verify func(t *testing.T, env *rendererTestEnv) +} + +func runRendererTestCases(t *testing.T, cases []rendererTestCase) { + t.Helper() + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + env := newRendererTestEnv() + + if tc.setup != nil { + tc.setup(t, env) + } + + require.NotNil(t, tc.invoke, "renderer test case must define invoke") + require.NotNil(t, tc.verify, "renderer test case must define verify") + + tc.invoke(t, env) + tc.verify(t, env) + }) + } +} + +// TestRenderer_Template_PointCases is a scaffold for future point renderer tests. +func TestRenderer_Template_PointCases(t *testing.T) { + t.Parallel() + + runRendererTestCases(t, []rendererTestCase{ + { + name: "point fully inside viewport", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddPoint(t, env.world, 5, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "point visible only in horizontal margin copy", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(160, 40) + worldMustAddPoint(t, env.world, 0.1, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "point visible only in vertical margin copy", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(40, 160) + worldMustAddPoint(t, env.world, 5, 0.1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "point duplicated across torus corner with independent margins", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewportAndMargins(120, 60, 30, 10) + worldMustAddPoint(t, env.world, 0.1, 0.1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + }) +} + +// TestRenderer_Template_LineCases is a scaffold for future line renderer tests. +func TestRenderer_Template_LineCases(t *testing.T) { + t.Parallel() + + runRendererTestCases(t, []rendererTestCase{ + { + name: "line fully inside viewport", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddLine(t, env.world, 2, 2, 8, 2) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "line wrap copy across x edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(160, 40) + worldMustAddLine(t, env.world, 9, 5, 1, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "line wrap copy across y edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(40, 160) + worldMustAddLine(t, env.world, 5, 9, 5, 1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "line tie case uses deterministic wrapped representation", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddLine(t, env.world, 1, 5, 6, 5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + }) +} + +// TestRenderer_Template_CircleCases is a scaffold for future circle renderer tests. +func TestRenderer_Template_CircleCases(t *testing.T) { + t.Parallel() + + runRendererTestCases(t, []rendererTestCase{ + { + name: "circle fully inside viewport", + setup: func(t *testing.T, env *rendererTestEnv) { + worldMustAddCircle(t, env.world, 5, 5, 1) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "circle duplicated across horizontal edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(160, 40) + worldMustAddCircle(t, env.world, 0.2, 5, 0.5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "circle duplicated across vertical edge", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewport(40, 160) + worldMustAddCircle(t, env.world, 5, 0.2, 0.5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + { + name: "circle duplicated across corner with asymmetric margins", + setup: func(t *testing.T, env *rendererTestEnv) { + env.setViewportAndMargins(120, 60, 30, 10) + worldMustAddCircle(t, env.world, 0.2, 0.2, 0.5) + }, + invoke: func(t *testing.T, env *rendererTestEnv) { + t.Skip("replace with actual renderer call") + }, + verify: func(t *testing.T, env *rendererTestEnv) { + requireNoDrawerCommands(t, env.drawer) + }, + }, + }) +} diff --git a/client/world/util.go b/client/world/util.go new file mode 100644 index 0000000..e80b63d --- /dev/null +++ b/client/world/util.go @@ -0,0 +1,250 @@ +package world + +import ( + "errors" + "fmt" + "math" +) + +var ( + errInvalidCameraZoom = errors.New("invalid camera zoom") +) + +const ( + // SCALE is the fixed-point multiplier used across the package. + // A real value of 1.0 is represented as SCALE. + SCALE = 1000 + + // MIN_ZOOM and MAX_ZOOM define the supported zoom range in fixed-point form. + // They are reserved for future validation/clamping logic. + MIN_ZOOM = int(SCALE / 4) // 0.25x + MAX_ZOOM = int(SCALE * 32) // 32x + + // cellSizeMin and cellSizeMax bound the automatically selected grid cell size. + cellSizeMin = 16 * SCALE + cellSizeMax = 512 * SCALE +) + +// Rect is a half-open rectangle in fixed-point world coordinates: +// [minX, maxX) x [minY, maxY). +type Rect struct { + minX, maxX int + minY, maxY int +} + +// wrap maps value into the half-open interval [0, size). +// It supports negative input values and is used for torus coordinates. +func wrap(value, size int) int { + r := value % size + if r < 0 { + r += size + } + return r +} + +// clamp limits value to the closed interval [minValue, maxValue]. +func clamp(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +// ceilDiv returns ceil(a / b) for positive integers. +func ceilDiv(a, b int) int { + return (a + b - 1) / b +} + +// floorDiv returns floor(a / b) for b > 0 and supports negative a. +func floorDiv(a, b int) int { + if b <= 0 { + panic("floorDiv: non-positive divisor") + } + + q := a / b + r := a % b + if r != 0 && a < 0 { + q-- + } + return q +} + +// fixedPoint converts a real value into the package fixed-point representation +// using nearest-integer rounding. +func fixedPoint(v float64) int { + return int(math.Round(v * SCALE)) +} + +// abs returns the absolute value of v. +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// viewportPxToWorldFixed converts a viewport size in pixels into the visible +// world size in fixed-point coordinates for the given fixed-point zoom. +func viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp int) (int, int) { + return PixelSpanToWorldFixed(viewportWidthPx, cameraZoomFp), + PixelSpanToWorldFixed(viewportHeightPx, cameraZoomFp) +} + +// worldToCell maps a world coordinate to a grid cell index. +// The input coordinate is wrapped on the torus before the cell is computed. +// The function panics when the grid configuration is invalid. +func worldToCell(value, worldSize, cells, cellSize int) int { + if cells <= 0 || cellSize <= 0 { + panic(fmt.Sprintf("worldToCell: cells=%d cellSize=%d", cells, cellSize)) + } + + wrappedValue := wrap(value, worldSize) + cell := wrappedValue / cellSize + if cell >= cells { + cell = cells - 1 + } + return cell +} + +// splitByWrap splits a half-open rectangle by torus wrap into 1..4 rectangles +// fully contained inside [0, W) x [0, H). +func splitByWrap(W, H, minX, maxX, minY, maxY int) []Rect { + width := maxX - minX + height := maxY - minY + + if width <= 0 || height <= 0 { + return nil + } + + if width >= W { + minX = 0 + maxX = W + } + if height >= H { + minY = 0 + maxY = H + } + + type xPart struct { + minX, maxX int + } + + xParts := make([]xPart, 0, 2) + + if minX >= 0 && maxX <= W { + xParts = append(xParts, xPart{minX: minX, maxX: maxX}) + } else { + wrappedMinX := wrap(minX, W) + wrappedMaxX := wrap(maxX, W) + + if wrappedMinX < wrappedMaxX { + xParts = append(xParts, xPart{minX: wrappedMinX, maxX: wrappedMaxX}) + } else { + xParts = append(xParts, xPart{minX: wrappedMinX, maxX: W}) + if wrappedMaxX > 0 { + xParts = append(xParts, xPart{minX: 0, maxX: wrappedMaxX}) + } + } + } + + result := make([]Rect, 0, 4) + + for _, xp := range xParts { + if minY >= 0 && maxY <= H { + result = append(result, Rect{ + minX: xp.minX, maxX: xp.maxX, + minY: minY, maxY: maxY, + }) + continue + } + + wrappedMinY := wrap(minY, H) + wrappedMaxY := wrap(maxY, H) + + if wrappedMinY < wrappedMaxY { + result = append(result, Rect{ + minX: xp.minX, maxX: xp.maxX, + minY: wrappedMinY, maxY: wrappedMaxY, + }) + } else { + result = append(result, Rect{ + minX: xp.minX, maxX: xp.maxX, + minY: wrappedMinY, maxY: H, + }) + if wrappedMaxY > 0 { + result = append(result, Rect{ + minX: xp.minX, maxX: xp.maxX, + minY: 0, maxY: wrappedMaxY, + }) + } + } + } + + return result +} + +// PixelSpanToWorldFixed converts a span in pixels into a span in fixed-point +// world coordinates for the given fixed-point zoom. +func PixelSpanToWorldFixed(spanPx int, zoomFp int) int { + return (spanPx * SCALE * SCALE) / zoomFp +} + +// shortestWrappedDelta returns a canonical torus representation of the pair +// (from, to) along a single axis of length size. +// +// The resulting delta (b - a) is normalized into the half-open interval +// [-size/2, size/2). This makes the tie-case deterministic: +// when the points are exactly half a world apart, wrap is always applied in +// the direction that produces the negative delta. +func shortestWrappedDelta(from, to, size int) (a int, b int) { + a, b = from, to + + delta := to - from + half := size / 2 + + if delta >= half { + a += size + return + } + if delta < -half { + b += size + return + } + + return +} + +// cameraZoomToWorldFixed converts a UI-facing zoom multiplier into the package +// fixed-point representation used by world-space calculations. +// +// The input zoom is expected to be a finite positive real value where 1.0 means +// the neutral zoom level. The result is rounded to the nearest fixed-point value. +// +// An error is returned when the input is invalid or when rounding would produce +// a non-positive fixed-point zoom. +func cameraZoomToWorldFixed(cameraZoom float64) (int, error) { + if cameraZoom <= 0 || math.IsNaN(cameraZoom) || math.IsInf(cameraZoom, 0) { + return 0, errInvalidCameraZoom + } + + zoomFp := int(math.Round(cameraZoom * SCALE)) + if zoomFp <= 0 { + return 0, errInvalidCameraZoom + } + + return zoomFp, nil +} + +// mustCameraZoomToWorldFixed is the panic-on-error variant of +// cameraZoomToWorldFixed. It is intended for internal code paths where invalid +// zoom is considered a programmer or integration error and must fail fast. +func mustCameraZoomToWorldFixed(cameraZoom float64) int { + zoomFp, err := cameraZoomToWorldFixed(cameraZoom) + if err != nil { + panic(err) + } + return zoomFp +} diff --git a/client/world/util_test.go b/client/world/util_test.go new file mode 100644 index 0000000..9e85bd1 --- /dev/null +++ b/client/world/util_test.go @@ -0,0 +1,365 @@ +package world + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWrap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value int + size int + want int + }{ + {name: "zero", value: 0, size: 10, want: 0}, + {name: "inside range", value: 7, size: 10, want: 7}, + {name: "equal to size", value: 10, size: 10, want: 0}, + {name: "greater than size", value: 27, size: 10, want: 7}, + {name: "negative one", value: -1, size: 10, want: 9}, + {name: "negative many", value: -23, size: 10, want: 7}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := wrap(tt.value, tt.size); got != tt.want { + t.Fatalf("wrap(%d, %d) = %d, want %d", tt.value, tt.size, got, tt.want) + } + }) + } +} + +func TestClamp(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value int + minValue int + maxValue int + want int + }{ + {name: "below range", value: -5, minValue: 0, maxValue: 10, want: 0}, + {name: "inside range", value: 5, minValue: 0, maxValue: 10, want: 5}, + {name: "above range", value: 15, minValue: 0, maxValue: 10, want: 10}, + {name: "equal min", value: 0, minValue: 0, maxValue: 10, want: 0}, + {name: "equal max", value: 10, minValue: 0, maxValue: 10, want: 10}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := clamp(tt.value, tt.minValue, tt.maxValue); got != tt.want { + t.Fatalf("clamp(%d, %d, %d) = %d, want %d", tt.value, tt.minValue, tt.maxValue, got, tt.want) + } + }) + } +} + +func TestCeilDiv(t *testing.T) { + t.Parallel() + + tests := []struct { + a int + b int + want int + }{ + {a: 1, b: 1, want: 1}, + {a: 1, b: 2, want: 1}, + {a: 2, b: 2, want: 1}, + {a: 3, b: 2, want: 2}, + {a: 10, b: 3, want: 4}, + {a: 16, b: 8, want: 2}, + {a: 17, b: 8, want: 3}, + } + + for _, tt := range tests { + if got := ceilDiv(tt.a, tt.b); got != tt.want { + t.Fatalf("ceilDiv(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestFloorDiv(t *testing.T) { + t.Parallel() + + require.Equal(t, 0, floorDiv(0, 10)) + require.Equal(t, 0, floorDiv(1, 10)) + require.Equal(t, 0, floorDiv(9, 10)) + require.Equal(t, 1, floorDiv(10, 10)) + require.Equal(t, 1, floorDiv(19, 10)) + + require.Equal(t, -1, floorDiv(-1, 10)) + require.Equal(t, -1, floorDiv(-9, 10)) + require.Equal(t, -1, floorDiv(-10, 10)) + require.Equal(t, -2, floorDiv(-11, 10)) + + require.Panics(t, func() { _ = floorDiv(1, 0) }) + require.Panics(t, func() { _ = floorDiv(1, -1) }) +} + +func TestFixedPoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + v float64 + want int + }{ + {name: "zero", v: 0, want: 0}, + {name: "integer", v: 3, want: 3000}, + {name: "fraction", v: 1.234, want: 1234}, + {name: "round down", v: 1.2344, want: 1234}, + {name: "round up", v: 1.2345, want: 1235}, + {name: "negative", v: -1.2345, want: -1235}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := fixedPoint(tt.v); got != tt.want { + t.Fatalf("fixedPoint(%f) = %d, want %d", tt.v, got, tt.want) + } + }) + } +} + +func TestAbs(t *testing.T) { + t.Parallel() + + tests := []struct { + v int + want int + }{ + {v: 0, want: 0}, + {v: 7, want: 7}, + {v: -7, want: 7}, + } + + for _, tt := range tests { + if got := abs(tt.v); got != tt.want { + t.Fatalf("abs(%d) = %d, want %d", tt.v, got, tt.want) + } + } +} + +func TestPixelSpanToWorldFixed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spanPx int + zoomFp int + want int + }{ + {name: "1x zoom", spanPx: 100, zoomFp: SCALE, want: 100 * SCALE}, + {name: "2x zoom", spanPx: 100, zoomFp: 2 * SCALE, want: 50 * SCALE}, + {name: "half zoom", spanPx: 100, zoomFp: SCALE / 2, want: 200 * SCALE}, + {name: "fractional result truncation", spanPx: 1, zoomFp: 3 * SCALE, want: 333}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := PixelSpanToWorldFixed(tt.spanPx, tt.zoomFp); got != tt.want { + t.Fatalf("PixelSpanToWorldFixed(%d, %d) = %d, want %d", tt.spanPx, tt.zoomFp, got, tt.want) + } + }) + } +} + +func TestWorldToCellPanicsOnInvalidGrid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cells int + cellSize int + }{ + {name: "zero cells", cells: 0, cellSize: 1}, + {name: "negative cells", cells: -1, cellSize: 1}, + {name: "zero cell size", cells: 1, cellSize: 0}, + {name: "negative cell size", cells: 1, cellSize: -1}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + defer func() { + if recover() == nil { + t.Fatalf("worldToCell did not panic for cells=%d cellSize=%d", tt.cells, tt.cellSize) + } + }() + + _ = worldToCell(0, 1000, tt.cells, tt.cellSize) + }) + } +} + +func TestShortestWrappedDelta(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + from int + to int + size int + wantA int + wantB int + }{ + {name: "no wrap forward", from: 1000, to: 3000, size: 10000, wantA: 1000, wantB: 3000}, + {name: "no wrap backward", from: 3000, to: 1000, size: 10000, wantA: 3000, wantB: 1000}, + {name: "wrap forward over half", from: 1000, to: 7000, size: 10000, wantA: 11000, wantB: 7000}, + {name: "wrap backward over half", from: 7000, to: 1000, size: 10000, wantA: 7000, wantB: 11000}, + {name: "tie positive half wraps", from: 1000, to: 6000, size: 10000, wantA: 11000, wantB: 6000}, + {name: "tie negative half stays", from: 6000, to: 1000, size: 10000, wantA: 6000, wantB: 1000}, + {name: "just below positive half does not wrap", from: 1000, to: 5999, size: 10000, wantA: 1000, wantB: 5999}, + {name: "just beyond negative half wraps", from: 6001, to: 1000, size: 10000, wantA: 6001, wantB: 11000}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotA, gotB := shortestWrappedDelta(tt.from, tt.to, tt.size) + if gotA != tt.wantA || gotB != tt.wantB { + t.Fatalf("shortestWrappedDelta(%d, %d, %d) = (%d, %d), want (%d, %d)", + tt.from, tt.to, tt.size, gotA, gotB, tt.wantA, tt.wantB) + } + + delta := gotB - gotA + half := tt.size / 2 + if delta < -half || delta >= half { + t.Fatalf("normalized delta %d is outside [-%d, %d)", delta, half, half) + } + }) + } +} + +func TestCameraZoomToWorldFixed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cameraZoom float64 + want int + }{ + { + name: "neutral zoom", + cameraZoom: 1.0, + want: SCALE, + }, + { + name: "integer zoom", + cameraZoom: 2.0, + want: 2 * SCALE, + }, + { + name: "fractional zoom", + cameraZoom: 1.25, + want: 1250, + }, + { + name: "minimum configured zoom value shape", + cameraZoom: 0.25, + want: SCALE / 4, + }, + { + name: "round down", + cameraZoom: 1.2344, + want: 1234, + }, + { + name: "round up", + cameraZoom: 1.2345, + want: 1235, + }, + { + name: "very small but still positive after rounding", + cameraZoom: 0.0006, + want: 1, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := cameraZoomToWorldFixed(tt.cameraZoom) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestCameraZoomToWorldFixedReturnsError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cameraZoom float64 + }{ + { + name: "zero", + cameraZoom: 0, + }, + { + name: "negative", + cameraZoom: -1, + }, + { + name: "nan", + cameraZoom: math.NaN(), + }, + { + name: "positive infinity", + cameraZoom: math.Inf(1), + }, + { + name: "negative infinity", + cameraZoom: math.Inf(-1), + }, + { + name: "positive but rounds to zero", + cameraZoom: 0.0004, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := cameraZoomToWorldFixed(tt.cameraZoom) + require.ErrorIs(t, err, errInvalidCameraZoom) + require.Zero(t, got) + }) + } +} + +func TestMustCameraZoomToWorldFixed(t *testing.T) { + t.Parallel() + + require.Equal(t, 1250, mustCameraZoomToWorldFixed(1.25)) + + require.Panics(t, func() { + _ = mustCameraZoomToWorldFixed(0) + }) +} diff --git a/client/world/world.go b/client/world/world.go new file mode 100644 index 0000000..9b18867 --- /dev/null +++ b/client/world/world.go @@ -0,0 +1,208 @@ +package world + +import ( + "errors" + "fmt" + + "github.com/google/uuid" +) + +var ( + errBadCoordinate = errors.New("invalid coordinates") + errBadRadius = errors.New("invalid radius") +) + +// World stores torus world dimensions, all registered objects, +// and the grid-based spatial index built for the current viewport settings. +type World struct { + W, H int // Fixed-point world size. + grid [][][]MapItem + cellSize int + rows, cols int + objects map[uuid.UUID]MapItem + renderState rendererIncrementalState +} + +// NewWorld constructs a new world with the given real dimensions. +// The dimensions are converted to fixed-point and must be positive. +func NewWorld(width, height int) *World { + if width <= 0 || height <= 0 { + panic("invalid width or height") + } + return &World{ + W: width * SCALE, + H: height * SCALE, + cellSize: 1, + objects: make(map[uuid.UUID]MapItem), + } +} + +// checkCoordinate reports whether the fixed-point coordinate (xf, yf) +// lies inside the world bounds: [0, W) x [0, H). +func (g *World) checkCoordinate(xf, yf int) bool { + if xf < 0 || xf >= g.W || yf < 0 || yf >= g.H { + return false + } + return true +} + +// AddPoint validates and stores a point primitive in the world. +// The input coordinates are given in real world units and are converted +// to fixed-point before validation. +func (g *World) AddPoint(x, y float64) (uuid.UUID, error) { + xf := fixedPoint(x) + yf := fixedPoint(y) + + if ok := g.checkCoordinate(xf, yf); !ok { + return uuid.Nil, errBadCoordinate + } + + id := uuid.New() + g.objects[id] = Point{Id: id, X: xf, Y: yf} + return id, nil +} + +// AddCircle validates and stores a circle primitive in the world. +// The center and radius are given in real world units and are converted +// to fixed-point before validation. A zero radius is allowed. +func (g *World) AddCircle(x, y, r float64) (uuid.UUID, error) { + xf := fixedPoint(x) + yf := fixedPoint(y) + rf := fixedPoint(r) + + if ok := g.checkCoordinate(xf, yf); !ok { + return uuid.Nil, errBadCoordinate + } + if rf < 0 { + return uuid.Nil, errBadRadius + } + + id := uuid.New() + g.objects[id] = Circle{Id: id, X: xf, Y: yf, Radius: rf} + return id, nil +} + +// AddLine validates and stores a line primitive in the world. +// The endpoints are given in real world units and are converted +// to fixed-point before validation. +func (g *World) AddLine(x1, y1, x2, y2 float64) (uuid.UUID, error) { + x1f := fixedPoint(x1) + y1f := fixedPoint(y1) + x2f := fixedPoint(x2) + y2f := fixedPoint(y2) + + if ok := g.checkCoordinate(x1f, y1f); !ok { + return uuid.Nil, errBadCoordinate + } + if ok := g.checkCoordinate(x2f, y2f); !ok { + return uuid.Nil, errBadCoordinate + } + + id := uuid.New() + g.objects[id] = Line{Id: id, X1: x1f, Y1: y1f, X2: x2f, Y2: y2f} + return id, nil +} + +// worldToCellX converts a fixed-point X coordinate to a grid column index. +func (g *World) worldToCellX(x int) int { + return worldToCell(x, g.W, g.cols, g.cellSize) +} + +// worldToCellY converts a fixed-point Y coordinate to a grid row index. +func (g *World) worldToCellY(y int) int { + return worldToCell(y, g.H, g.rows, g.cellSize) +} + +// resetGrid recreates the spatial grid with the given cell size +// and clears all previous indexing state. +func (g *World) resetGrid(cellSize int) { + g.cellSize = cellSize + g.cols = ceilDiv(g.W, g.cellSize) + g.rows = ceilDiv(g.H, g.cellSize) + + g.grid = make([][][]MapItem, g.rows) + for row := range g.grid { + g.grid[row] = make([][]MapItem, g.cols) + } +} + +// indexObject inserts a single object into all grid cells touched by its +// indexing representation. Points are inserted into one cell, while circles +// and lines are inserted by their torus-aware bbox coverage. +func (g *World) indexObject(o MapItem) { + switch mapItem := o.(type) { + case Point: + col := g.worldToCellX(mapItem.X) + row := g.worldToCellY(mapItem.Y) + g.grid[row][col] = append(g.grid[row][col], mapItem) + + case Line: + x1 := mapItem.X1 + y1 := mapItem.Y1 + x2 := mapItem.X2 + y2 := mapItem.Y2 + + x1, x2 = shortestWrappedDelta(x1, x2, g.W) + y1, y2 = shortestWrappedDelta(y1, y2, g.H) + + minX := min(x1, x2) + maxX := max(x1, x2) + minY := min(y1, y2) + maxY := max(y1, y2) + + if minX == maxX { + maxX++ + } + if minY == maxY { + maxY++ + } + + g.indexBBox(mapItem, minX, maxX, minY, maxY) + + case Circle: + g.indexBBox(mapItem, mapItem.MinX(), mapItem.MaxX(), mapItem.MinY(), mapItem.MaxY()) + + default: + panic(fmt.Sprintf("indexing: unknown element %T", mapItem)) + } +} + +// indexBBox indexes an object by a half-open fixed-point bbox that may cross +// torus boundaries. The bbox is split into wrapped in-world rectangles first, +// then all covered grid cells are populated. +func (g *World) indexBBox(o MapItem, minX, maxX, minY, maxY int) { + rects := splitByWrap(g.W, g.H, minX, maxX, minY, maxY) + for _, r := range rects { + colStart := g.worldToCellX(r.minX) + colEnd := g.worldToCellX(r.maxX - 1) + + rowStart := g.worldToCellY(r.minY) + rowEnd := g.worldToCellY(r.maxY - 1) + + for col := colStart; col <= colEnd; col++ { + for row := rowStart; row <= rowEnd; row++ { + g.grid[row][col] = append(g.grid[row][col], o) + } + } + } +} + +// IndexOnViewportChange rebuilds the grid for a new viewport size and zoom. +// The zoom is provided by the UI as a real multiplier and is converted +// to fixed-point inside the function. +func (g *World) IndexOnViewportChange(viewportWidthPx, viewportHeightPx int, cameraZoom float64) { + cameraZoomFp := mustCameraZoomToWorldFixed(cameraZoom) + worldWidth, worldHeight := viewportPxToWorldFixed(viewportWidthPx, viewportHeightPx, cameraZoomFp) + + cellsAcrossMin := 8 + visibleMin := min(worldWidth, worldHeight) + + cellSize := visibleMin / cellsAcrossMin + cellSize = clamp(cellSize, cellSizeMin, cellSizeMax) + + g.resetGrid(cellSize) + + for _, o := range g.objects { + g.indexObject(o) + } +} diff --git a/client/world/world_test.go b/client/world/world_test.go new file mode 100644 index 0000000..cf4c327 --- /dev/null +++ b/client/world/world_test.go @@ -0,0 +1,537 @@ +package world + +import ( + "errors" + "testing" + + "github.com/google/uuid" +) + +func newIndexedTestWorld() *World { + w := NewWorld(10, 10) + w.resetGrid(2 * SCALE) // 5x5 grid. + return w +} + +func cellHasOnlyID(t *testing.T, w *World, row, col int, want uuid.UUID) { + t.Helper() + + cell := w.grid[row][col] + if len(cell) != 1 { + t.Fatalf("cell[%d][%d] len = %d, want 1", row, col, len(cell)) + } + if got := cell[0].ID(); got != want { + t.Fatalf("cell[%d][%d] item id = %v, want %v", row, col, got, want) + } +} + +func cellIsEmpty(t *testing.T, w *World, row, col int) { + t.Helper() + + if got := len(w.grid[row][col]); got != 0 { + t.Fatalf("cell[%d][%d] len = %d, want 0", row, col, got) + } +} + +func occupiedCellsByID(w *World, id uuid.UUID) map[[2]int]struct{} { + result := make(map[[2]int]struct{}) + + for row := range w.grid { + for col := range w.grid[row] { + for _, item := range w.grid[row][col] { + if item.ID() == id { + result[[2]int{row, col}] = struct{}{} + } + } + } + } + + return result +} + +func assertOccupiedCells(t *testing.T, w *World, id uuid.UUID, want ...[2]int) { + t.Helper() + + got := occupiedCellsByID(w, id) + if len(got) != len(want) { + t.Fatalf("occupied cell count = %d, want %d; got=%v want=%v", len(got), len(want), got, want) + } + + for _, cell := range want { + if _, ok := got[cell]; !ok { + t.Fatalf("missing occupied cell row=%d col=%d; got=%v", cell[0], cell[1], got) + } + } +} + +func TestNewWorld(t *testing.T) { + t.Parallel() + + w := NewWorld(12, 7) + + if w.W != 12*SCALE { + t.Fatalf("W = %d, want %d", w.W, 12*SCALE) + } + if w.H != 7*SCALE { + t.Fatalf("H = %d, want %d", w.H, 7*SCALE) + } + if w.cellSize != 1 { + t.Fatalf("cellSize = %d, want 1", w.cellSize) + } + if w.objects == nil { + t.Fatal("objects map is nil") + } +} + +func TestNewWorldPanicsOnInvalidSize(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + width int + height int + }{ + {name: "zero width", width: 0, height: 1}, + {name: "zero height", width: 1, height: 0}, + {name: "negative width", width: -1, height: 1}, + {name: "negative height", width: 1, height: -1}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + defer func() { + if recover() == nil { + t.Fatalf("NewWorld(%d, %d) did not panic", tt.width, tt.height) + } + }() + + _ = NewWorld(tt.width, tt.height) + }) + } +} + +func TestCheckCoordinate(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + tests := []struct { + name string + xf int + yf int + want bool + }{ + {name: "origin", xf: 0, yf: 0, want: true}, + {name: "inside", xf: 5000, yf: 5000, want: true}, + {name: "last valid", xf: 9999, yf: 9999, want: true}, + {name: "x below", xf: -1, yf: 0, want: false}, + {name: "y below", xf: 0, yf: -1, want: false}, + {name: "x equal width", xf: 10000, yf: 0, want: false}, + {name: "y equal height", xf: 0, yf: 10000, want: false}, + } + + for _, tt := range tests { + if got := w.checkCoordinate(tt.xf, tt.yf); got != tt.want { + t.Fatalf("checkCoordinate(%d, %d) = %v, want %v", tt.xf, tt.yf, got, tt.want) + } + } +} + +func TestAddPoint(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + id, err := w.AddPoint(1.25, 2.75) + if err != nil { + t.Fatalf("AddPoint returned error: %v", err) + } + + item, ok := w.objects[id] + if !ok { + t.Fatalf("point with id %v was not stored", id) + } + + p, ok := item.(Point) + if !ok { + t.Fatalf("stored item type = %T, want Point", item) + } + if p.X != 1250 || p.Y != 2750 { + t.Fatalf("stored point = (%d, %d), want (1250, 2750)", p.X, p.Y) + } +} + +func TestAddPointRejectsOutOfBounds(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + tests := []struct { + name string + x float64 + y float64 + }{ + {name: "negative x", x: -0.001, y: 1}, + {name: "negative y", x: 1, y: -0.001}, + {name: "x rounds to width", x: 9.9995, y: 1}, + {name: "y rounds to height", x: 1, y: 9.9995}, + {name: "x clearly outside", x: 10, y: 1}, + {name: "y clearly outside", x: 1, y: 10}, + } + + for _, tt := range tests { + _, err := w.AddPoint(tt.x, tt.y) + if !errors.Is(err, errBadCoordinate) { + t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate) + } + } +} + +func TestAddPointAllowsLastRoundedInsideValue(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + id, err := w.AddPoint(9.9994, 9.9994) + if err != nil { + t.Fatalf("AddPoint returned error: %v", err) + } + + p := w.objects[id].(Point) + if p.X != 9999 || p.Y != 9999 { + t.Fatalf("stored point = (%d, %d), want (9999, 9999)", p.X, p.Y) + } +} + +func TestAddCircle(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + id, err := w.AddCircle(2.5, 3.5, 1.25) + if err != nil { + t.Fatalf("AddCircle returned error: %v", err) + } + + item, ok := w.objects[id] + if !ok { + t.Fatalf("circle with id %v was not stored", id) + } + + c, ok := item.(Circle) + if !ok { + t.Fatalf("stored item type = %T, want Circle", item) + } + if c.X != 2500 || c.Y != 3500 || c.Radius != 1250 { + t.Fatalf("stored circle = (%d, %d, %d), want (2500, 3500, 1250)", c.X, c.Y, c.Radius) + } +} + +func TestAddCircleAllowsZeroRadius(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + id, err := w.AddCircle(2, 3, 0) + if err != nil { + t.Fatalf("AddCircle returned error: %v", err) + } + + c := w.objects[id].(Circle) + if c.Radius != 0 { + t.Fatalf("radius = %d, want 0", c.Radius) + } +} + +func TestAddCircleRejectsInvalidInput(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + if _, err := w.AddCircle(1, 1, -0.001); !errors.Is(err, errBadRadius) { + t.Fatalf("negative radius error = %v, want %v", err, errBadRadius) + } + + tests := []struct { + name string + x float64 + y float64 + }{ + {name: "negative x", x: -0.001, y: 1}, + {name: "negative y", x: 1, y: -0.001}, + {name: "x rounds to width", x: 9.9995, y: 1}, + {name: "y rounds to height", x: 1, y: 9.9995}, + } + + for _, tt := range tests { + _, err := w.AddCircle(tt.x, tt.y, 1) + if !errors.Is(err, errBadCoordinate) { + t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate) + } + } +} + +func TestAddLine(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + id, err := w.AddLine(1.1, 2.2, 3.3, 4.4) + if err != nil { + t.Fatalf("AddLine returned error: %v", err) + } + + item, ok := w.objects[id] + if !ok { + t.Fatalf("line with id %v was not stored", id) + } + + l, ok := item.(Line) + if !ok { + t.Fatalf("stored item type = %T, want Line", item) + } + if l.X1 != 1100 || l.Y1 != 2200 || l.X2 != 3300 || l.Y2 != 4400 { + t.Fatalf("stored line = (%d, %d) -> (%d, %d), want (1100, 2200) -> (3300, 4400)", + l.X1, l.Y1, l.X2, l.Y2) + } +} + +func TestAddLineRejectsInvalidInput(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 10) + + tests := []struct { + name string + x1 float64 + y1 float64 + x2 float64 + y2 float64 + }{ + {name: "first point x below", x1: -0.001, y1: 1, x2: 2, y2: 2}, + {name: "first point y below", x1: 1, y1: -0.001, x2: 2, y2: 2}, + {name: "second point x below", x1: 1, y1: 1, x2: -0.001, y2: 2}, + {name: "second point y below", x1: 1, y1: 1, x2: 2, y2: -0.001}, + {name: "first point x rounds to width", x1: 9.9995, y1: 1, x2: 2, y2: 2}, + {name: "second point y rounds to height", x1: 1, y1: 1, x2: 2, y2: 9.9995}, + } + + for _, tt := range tests { + _, err := w.AddLine(tt.x1, tt.y1, tt.x2, tt.y2) + if !errors.Is(err, errBadCoordinate) { + t.Fatalf("%s: error = %v, want %v", tt.name, err, errBadCoordinate) + } + } +} + +func TestResetGrid(t *testing.T) { + t.Parallel() + + w := NewWorld(10, 6) + w.resetGrid(2 * SCALE) + + if w.cellSize != 2*SCALE { + t.Fatalf("cellSize = %d, want %d", w.cellSize, 2*SCALE) + } + if w.cols != 5 { + t.Fatalf("cols = %d, want 5", w.cols) + } + if w.rows != 3 { + t.Fatalf("rows = %d, want 3", w.rows) + } + if len(w.grid) != 3 { + t.Fatalf("len(grid) = %d, want 3", len(w.grid)) + } + for row := range w.grid { + if len(w.grid[row]) != 5 { + t.Fatalf("len(grid[%d]) = %d, want 5", row, len(w.grid[row])) + } + } +} + +func TestWorldToCellXY(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + + if got := w.worldToCellX(2500); got != 1 { + t.Fatalf("worldToCellX(2500) = %d, want 1", got) + } + if got := w.worldToCellY(4500); got != 2 { + t.Fatalf("worldToCellY(4500) = %d, want 2", got) + } + if got := w.worldToCellX(-1); got != 4 { + t.Fatalf("worldToCellX(-1) = %d, want 4", got) + } + if got := w.worldToCellY(10000); got != 0 { + t.Fatalf("worldToCellY(10000) = %d, want 0", got) + } +} + +func TestIndexObjectPoint(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + p := Point{Id: id, X: 2500, Y: 4500} + w.indexObject(p) + + cellHasOnlyID(t, w, 2, 1, id) + cellIsEmpty(t, w, 0, 0) + cellIsEmpty(t, w, 4, 4) +} + +func TestIndexObjectCircleWithoutWrap(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + c := Circle{Id: id, X: 3000, Y: 2000, Radius: 900} + w.indexObject(c) + + assertOccupiedCells(t, w, id, + [2]int{0, 1}, + [2]int{1, 1}, + ) +} + +func TestIndexObjectCircleWrapsAcrossCorner(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + c := Circle{Id: id, X: 500, Y: 500, Radius: 900} + w.indexObject(c) + + assertOccupiedCells(t, w, id, + [2]int{0, 0}, + [2]int{0, 4}, + [2]int{4, 0}, + [2]int{4, 4}, + ) +} + +func TestIndexObjectCircleCoversWholeWorld(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + c := Circle{Id: id, X: 5000, Y: 5000, Radius: 6000} + w.indexObject(c) + + want := make([][2]int, 0, 25) + for row := 0; row < 5; row++ { + for col := 0; col < 5; col++ { + want = append(want, [2]int{row, col}) + } + } + + assertOccupiedCells(t, w, id, want...) +} + +func TestIndexObjectVerticalLineExpandsDegenerateX(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + l := Line{Id: id, X1: 3000, Y1: 1000, X2: 3000, Y2: 5000} + w.indexObject(l) + + assertOccupiedCells(t, w, id, + [2]int{0, 1}, + [2]int{1, 1}, + [2]int{2, 1}, + ) +} + +func TestIndexObjectHorizontalLineExpandsDegenerateY(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + l := Line{Id: id, X1: 1000, Y1: 3000, X2: 5000, Y2: 3000} + w.indexObject(l) + + assertOccupiedCells(t, w, id, + [2]int{1, 0}, + [2]int{1, 1}, + [2]int{1, 2}, + ) +} + +func TestIndexObjectLineWrapsAcrossX(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + l := Line{Id: id, X1: 9000, Y1: 3000, X2: 1000, Y2: 3000} + w.indexObject(l) + + assertOccupiedCells(t, w, id, + [2]int{1, 4}, + [2]int{1, 0}, + ) +} + +func TestIndexObjectLineWrapsAcrossY(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + l := Line{Id: id, X1: 3000, Y1: 9000, X2: 3000, Y2: 1000} + w.indexObject(l) + + assertOccupiedCells(t, w, id, + [2]int{4, 1}, + [2]int{0, 1}, + ) +} + +func TestIndexObjectLineTieCaseUsesDeterministicWrap(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + id := uuid.New() + + l := Line{Id: id, X1: 1000, Y1: 3000, X2: 6000, Y2: 3000} + w.indexObject(l) + + assertOccupiedCells(t, w, id, + [2]int{1, 3}, + [2]int{1, 4}, + [2]int{1, 0}, + ) +} + +type unknown struct { + id uuid.UUID +} + +func (u unknown) ID() uuid.UUID { + return u.id +} + +func TestIndexBBoxPanicsOnUnknownItemType(t *testing.T) { + t.Parallel() + + w := newIndexedTestWorld() + + defer func() { + if recover() == nil { + t.Fatal("indexObject did not panic for unknown item type") + } + }() + + w.indexObject(unknown{id: uuid.New()}) +} diff --git a/client/world/zoom.go b/client/world/zoom.go new file mode 100644 index 0000000..1e5cee0 --- /dev/null +++ b/client/world/zoom.go @@ -0,0 +1,120 @@ +package world + +// worldFixedToCameraZoom converts a fixed-point zoom value back into the +// UI-facing floating-point representation where 1.0 means neutral zoom. +func worldFixedToCameraZoom(zoomFp int) float64 { + return float64(zoomFp) / float64(SCALE) +} + +// requiredZoomToFitWorld returns the minimum fixed-point zoom needed so that +// a viewport span of viewportSpanPx pixels does not exceed a world span of +// worldSpanFp fixed-point units. +// +// The result is rounded up, not down, because the fit constraint must be +// satisfied conservatively: after correction, the visible world span must +// never be larger than the actual world span. +func requiredZoomToFitWorld(viewportSpanPx, worldSpanFp int) int { + if viewportSpanPx < 0 { + panic("requiredZoomToFitWorld: negative viewport span") + } + if worldSpanFp <= 0 { + panic("requiredZoomToFitWorld: non-positive world span") + } + if viewportSpanPx == 0 { + return 0 + } + + return ceilDiv(viewportSpanPx*SCALE*SCALE, worldSpanFp) +} + +// correctCameraZoomFp corrects a fixed-point zoom value using two groups +// of constraints: +// +// 1. Fit-to-world constraints derived from viewport and world sizes. +// These have the highest priority and prevent the viewport from becoming +// larger than the world on any axis, which would otherwise expose wrap +// on the visible user area. +// +// 2. Optional UI zoom bounds [minZoomFp, maxZoomFp]. +// A zero bound means "ignore this bound". +// If fit-to-world requires a zoom larger than maxZoomFp, the fit constraint +// wins and maxZoomFp is ignored for that case. +// +// The function returns either the corrected zoom or currentZoomFp unchanged +// when no correction is required. +func correctCameraZoomFp( + currentZoomFp int, + viewportWidthPx, viewportHeightPx int, + worldWidthFp, worldHeightFp int, + minZoomFp, maxZoomFp int, +) int { + if currentZoomFp <= 0 { + panic("correctCameraZoomFp: non-positive current zoom") + } + if viewportWidthPx < 0 || viewportHeightPx < 0 { + panic("correctCameraZoomFp: negative viewport size") + } + if worldWidthFp <= 0 || worldHeightFp <= 0 { + panic("correctCameraZoomFp: non-positive world size") + } + if minZoomFp < 0 || maxZoomFp < 0 { + panic("correctCameraZoomFp: negative zoom bound") + } + if minZoomFp > 0 && maxZoomFp > 0 && minZoomFp > maxZoomFp { + panic("correctCameraZoomFp: min zoom greater than max zoom") + } + + // Start from the user zoom. + result := currentZoomFp + + // Apply min bound first (only increases zoom, always valid). + if minZoomFp > 0 && result < minZoomFp { + result = minZoomFp + } + + // Apply max bound tentatively. This can be overridden later by the anti-wrap constraint. + if maxZoomFp > 0 && result > maxZoomFp { + result = maxZoomFp + } + + // If viewport is larger than the world on any axis at the current result zoom, + // increase zoom to the minimum value that prevents wrap in the visible area. + requiredFitX := requiredZoomToFitWorld(viewportWidthPx, worldWidthFp) + requiredFitY := requiredZoomToFitWorld(viewportHeightPx, worldHeightFp) + requiredFit := max(requiredFitX, requiredFitY) + + if requiredFit > 0 && result < requiredFit { + result = requiredFit + } + + // Re-apply max bound only if it does not conflict with the anti-wrap requirement. + // If anti-wrap requires zoom > maxZoomFp, anti-wrap wins. + if maxZoomFp > 0 && result > maxZoomFp && requiredFit <= maxZoomFp { + result = maxZoomFp + } + + return result +} + +// CorrectCameraZoom adapts fixed-point zoom correction for UI code. +// +// currentZoom is the user-facing zoom multiplier in floating-point form. +// The result is returned in the same representation. +func (g *World) CorrectCameraZoom( + currentZoom float64, + viewportWidthPx int, + viewportHeightPx int, +) float64 { + currentZoomFp := mustCameraZoomToWorldFixed(currentZoom) + correctedZoomFp := correctCameraZoomFp( + currentZoomFp, + viewportWidthPx, + viewportHeightPx, + g.W, + g.H, + MIN_ZOOM, + MAX_ZOOM, + ) + + return worldFixedToCameraZoom(correctedZoomFp) +} diff --git a/client/world/zoom_test.go b/client/world/zoom_test.go new file mode 100644 index 0000000..1d1c4a6 --- /dev/null +++ b/client/world/zoom_test.go @@ -0,0 +1,442 @@ +package world + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWorldFixedToCameraZoom(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + zoomFp int + want float64 + }{ + {name: "zero", zoomFp: 0, want: 0}, + {name: "neutral", zoomFp: SCALE, want: 1.0}, + {name: "fractional", zoomFp: 1250, want: 1.25}, + {name: "integer multiple", zoomFp: 3 * SCALE, want: 3.0}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := worldFixedToCameraZoom(tt.zoomFp) + require.Equal(t, tt.want, got) + }) + } +} + +func TestRequiredZoomToFitWorld(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + viewportSpanPx int + worldSpanFp int + want int + }{ + { + name: "zero viewport span", + viewportSpanPx: 0, + worldSpanFp: 10 * SCALE, + want: 0, + }, + { + name: "exact neutral fit", + viewportSpanPx: 10, + worldSpanFp: 10 * SCALE, + want: SCALE, + }, + { + name: "exact 2x fit", + viewportSpanPx: 20, + worldSpanFp: 10 * SCALE, + want: 2 * SCALE, + }, + { + name: "fractional fit rounded up", + viewportSpanPx: 11, + worldSpanFp: 10 * SCALE, + want: 1100, + }, + { + name: "small world requires larger zoom", + viewportSpanPx: 320, + worldSpanFp: 80 * SCALE, + want: 4 * SCALE, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp) + require.Equal(t, tt.want, got) + }) + } +} + +func TestRequiredZoomToFitWorldPanics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + viewportSpanPx int + worldSpanFp int + }{ + { + name: "negative viewport span", + viewportSpanPx: -1, + worldSpanFp: 10 * SCALE, + }, + { + name: "zero world span", + viewportSpanPx: 10, + worldSpanFp: 0, + }, + { + name: "negative world span", + viewportSpanPx: 10, + worldSpanFp: -1, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + _ = requiredZoomToFitWorld(tt.viewportSpanPx, tt.worldSpanFp) + }) + }) + } +} + +func TestCorrectCameraZoomFpReturnsCurrentWhenNoCorrectionNeeded(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + 2*SCALE, + 40, 30, + 100*SCALE, 100*SCALE, + MIN_ZOOM, MAX_ZOOM, + ) + + require.Equal(t, 2*SCALE, got) +} + +func TestCorrectCameraZoomFpRaisesZoomToFitWorldWidth(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 120, 20, + 100*SCALE, 100*SCALE, + 0, 0, + ) + + require.Equal(t, 1200, got) +} + +func TestCorrectCameraZoomFpRaisesZoomToFitWorldHeight(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 20, 150, + 100*SCALE, 100*SCALE, + 0, 0, + ) + + require.Equal(t, 1500, got) +} + +func TestCorrectCameraZoomFpUsesMaxFitAcrossAxes(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 120, 150, + 100*SCALE, 100*SCALE, + 0, 0, + ) + + require.Equal(t, 1500, got) +} + +func TestCorrectCameraZoomFpAppliesMinZoomWhenLargerThanCurrentAndFit(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 20, 20, + 100*SCALE, 100*SCALE, + 1500, 0, + ) + + require.Equal(t, 1500, got) +} + +func TestCorrectCameraZoomFpAppliesMaxZoomWhenNoFitConflict(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + 4*SCALE, + 20, 20, + 100*SCALE, 100*SCALE, + 0, 3*SCALE, + ) + + require.Equal(t, 3*SCALE, got) +} + +func TestCorrectCameraZoomFpIgnoresMaxZoomWhenFitNeedsMore(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 200, 20, + 100*SCALE, 100*SCALE, + 0, 1500, + ) + + require.Equal(t, 2*SCALE, got) +} + +func TestCorrectCameraZoomFpAppliesMinThenMaxWhenBothValid(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 20, 20, + 100*SCALE, 100*SCALE, + 1500, 1600, + ) + + require.Equal(t, 1500, got) +} + +func TestCorrectCameraZoomFpCurrentAboveMaxGetsClamped(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + 5*SCALE, + 20, 20, + 100*SCALE, 100*SCALE, + 0, 3*SCALE, + ) + + require.Equal(t, 3*SCALE, got) +} + +func TestCorrectCameraZoomFpZeroViewportUsesOnlyBounds(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 0, 0, + 100*SCALE, 100*SCALE, + 1500, 0, + ) + + require.Equal(t, 1500, got) +} + +func TestCorrectCameraZoomFpZeroBoundsAreIgnored(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + 1250, + 20, 20, + 100*SCALE, 100*SCALE, + 0, 0, + ) + + require.Equal(t, 1250, got) +} + +func TestCorrectCameraZoomFpPanics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fn func() + }{ + { + name: "non-positive current zoom", + fn: func() { + _ = correctCameraZoomFp(0, 10, 10, 100*SCALE, 100*SCALE, 0, 0) + }, + }, + { + name: "negative viewport width", + fn: func() { + _ = correctCameraZoomFp(SCALE, -1, 10, 100*SCALE, 100*SCALE, 0, 0) + }, + }, + { + name: "negative viewport height", + fn: func() { + _ = correctCameraZoomFp(SCALE, 10, -1, 100*SCALE, 100*SCALE, 0, 0) + }, + }, + { + name: "non-positive world width", + fn: func() { + _ = correctCameraZoomFp(SCALE, 10, 10, 0, 100*SCALE, 0, 0) + }, + }, + { + name: "non-positive world height", + fn: func() { + _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 0, 0, 0) + }, + }, + { + name: "negative min zoom", + fn: func() { + _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, -1, 0) + }, + }, + { + name: "negative max zoom", + fn: func() { + _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 0, -1) + }, + }, + { + name: "min greater than max", + fn: func() { + _ = correctCameraZoomFp(SCALE, 10, 10, 100*SCALE, 100*SCALE, 2000, 1500) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Panics(t, tt.fn) + }) + } +} + +func TestWorldCorrectCameraZoomReturnsFloatValue(t *testing.T) { + t.Parallel() + + w := NewWorld(100, 100) + + got := w.CorrectCameraZoom(1.0, 120, 20) + + require.Equal(t, 1.2, got) +} + +func TestWorldCorrectCameraZoomAppliesDefaultBounds(t *testing.T) { + t.Parallel() + + w := NewWorld(100, 100) + + got := w.CorrectCameraZoom(100.0, 20, 20) + + require.Equal(t, worldFixedToCameraZoom(MAX_ZOOM), got) +} + +func TestWorldCorrectCameraZoomFitBeatsDefaultMaxBound(t *testing.T) { + t.Parallel() + + w := NewWorld(1, 100) + + got := w.CorrectCameraZoom(1.0, 40, 10) + + require.Equal(t, 40.0, got) +} + +func TestCorrectCameraZoomFp_DoesNotLowerZoomWhenViewportIsSmallerThanWorld(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, // currentZoomFp = 1.0x + 80, 80, // viewport px + 100*SCALE, 100*SCALE, // world fp + 0, 0, + ) + + // No anti-wrap needed, and we do not auto-fit by lowering zoom. + require.Equal(t, SCALE, got) +} + +func TestCorrectCameraZoomFp_RaisesZoomToPreventWrapWhenViewportIsLarger(t *testing.T) { + t.Parallel() + + // World width = 100 units, viewport width = 120 px, at zoom=1 visible span = 120 units => too large. + got := correctCameraZoomFp( + SCALE, + 120, 20, + 100*SCALE, 100*SCALE, + 0, 0, + ) + + require.Equal(t, 1200, got) // 1.2x +} + +func TestCorrectCameraZoomFp_AppliesMaxZoomWhenNoWrapConflict(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + 4*SCALE, // user wants 4x + 20, 20, + 100*SCALE, 100*SCALE, + 0, 3*SCALE, // max 3x + ) + + require.Equal(t, 3*SCALE, got) +} + +func TestCorrectCameraZoomFp_AntiWrapBeatsMaxZoom(t *testing.T) { + t.Parallel() + + // requiredFit = 2x, but max is 1.5x => must return 2x. + got := correctCameraZoomFp( + SCALE, + 200, 20, + 100*SCALE, 100*SCALE, + 0, 1500, + ) + + require.Equal(t, 2*SCALE, got) +} + +func TestCorrectCameraZoomFp_AppliesMinZoom(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + 800, // 0.8x + 20, 20, + 100*SCALE, 100*SCALE, + SCALE, 0, // min 1.0x + ) + + require.Equal(t, SCALE, got) +} + +func TestCorrectCameraZoomFp_ZeroViewportUsesOnlyBounds(t *testing.T) { + t.Parallel() + + got := correctCameraZoomFp( + SCALE, + 0, 0, + 100*SCALE, 100*SCALE, + 1500, 0, + ) + + require.Equal(t, 1500, got) +} diff --git a/error/go.mod b/error/go.mod index b2a4fd8..16a2dfd 100644 --- a/error/go.mod +++ b/error/go.mod @@ -6,6 +6,9 @@ require github.com/stretchr/testify v1.11.1 require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/error/go.sum b/error/go.sum index c4c1710..85e31c2 100644 --- a/error/go.sum +++ b/error/go.sum @@ -1,10 +1,13 @@ 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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 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/go.work b/go.work index 95f14dc..a14ad2a 100644 --- a/go.work +++ b/go.work @@ -1,15 +1,15 @@ go 1.26.0 use ( - ./client - ./error - ./model - ./server - ./util + ./client + ./error + ./model + ./server + ./util ) replace ( - galaxy/error v0.0.0 => ./error - galaxy/model v0.0.0 => ./model - galaxy/util v0.0.0 => ./util + galaxy/error v0.0.0 => ./error + galaxy/model v0.0.0 => ./model + galaxy/util v0.0.0 => ./util ) diff --git a/go.work.sum b/go.work.sum index 32cdd46..465be66 100644 --- a/go.work.sum +++ b/go.work.sum @@ -9,20 +9,21 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/jackmordaunt/icns/v2 v2.2.6/go.mod h1:DqlVnR5iafSphrId7aSD06r3jg0KRC9V6lEBBp504ZQ= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/lucor/goinfo v0.9.0/go.mod h1:L6m6tN5Rlova5Z83h1ZaKsMP1iiaoZ9vGTNzu5QKOD4= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= @@ -31,10 +32,12 @@ golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/model/go.mod b/model/go.mod index 0b5ea0c..7034ec0 100644 --- a/model/go.mod +++ b/model/go.mod @@ -1,3 +1,5 @@ module galaxy/model go 1.26.0 + +require github.com/google/uuid v1.6.0 diff --git a/model/go.sum b/model/go.sum new file mode 100644 index 0000000..39ebf3d --- /dev/null +++ b/model/go.sum @@ -0,0 +1 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/server/go.mod b/server/go.mod index a6a3e08..607661a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,43 +4,42 @@ go 1.26.0 require ( github.com/gin-gonic/gin v1.11.0 - github.com/go-playground/validator/v10 v10.27.0 + github.com/go-playground/validator/v10 v10.30.1 github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.11.1 - golang.org/x/sys v0.36.0 + golang.org/x/sys v0.41.0 ) require ( - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.uber.org/mock v0.5.0 // indirect - golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.uber.org/mock v0.6.0 // indirect + golang.org/x/arch v0.24.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index 114c923..d4150ce 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,14 +1,17 @@ -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= @@ -19,12 +22,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -34,58 +37,62 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= -github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= +golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/server/internal/repo/fs/fs.go b/server/internal/repo/fs/fs.go index d809fc7..9d5254d 100644 --- a/server/internal/repo/fs/fs.go +++ b/server/internal/repo/fs/fs.go @@ -23,6 +23,7 @@ type fs struct { } func NewFileStorage(path string) (*fs, error) { + filepath.Join("", "") absPath, err := filepath.Abs(path) if err != nil { return nil, fmt.Errorf("path %s invalid: %s", path, err) diff --git a/server/internal/repo/fs/util.go b/server/internal/repo/fs/util.go index d81b49e..6905a2c 100644 --- a/server/internal/repo/fs/util.go +++ b/server/internal/repo/fs/util.go @@ -1,13 +1,9 @@ -//go:build !windows - // for windows builds func [writable] should be refactored package fs import ( "fmt" "os" - - "golang.org/x/sys/unix" ) func dirExists(path string) (bool, error) { @@ -31,7 +27,3 @@ func pathExists(path string, isDir bool) (bool, error) { return true, nil } } - -func writable(filepath string) (bool, error) { - return unix.Access(filepath, unix.W_OK) == nil, nil -} diff --git a/server/internal/repo/fs/util_unix.go b/server/internal/repo/fs/util_unix.go new file mode 100644 index 0000000..b875ce3 --- /dev/null +++ b/server/internal/repo/fs/util_unix.go @@ -0,0 +1,15 @@ +//go:build unix || (js && wasm) || wasip1 + +package fs + +import "golang.org/x/sys/unix" + +// writable reports whether path is writable on Windows. +// +// Semantics: +// - for an existing regular file, it tries to open it for writing; +// - for an existing directory, it tries to create and remove a temp file inside it; +// - for other file types, it returns false with no error. +func writable(filepath string) (bool, error) { + return unix.Access(filepath, unix.W_OK) == nil, nil +} diff --git a/server/internal/repo/fs/util_windows.go b/server/internal/repo/fs/util_windows.go new file mode 100644 index 0000000..6da35d3 --- /dev/null +++ b/server/internal/repo/fs/util_windows.go @@ -0,0 +1,94 @@ +package fs + +import ( + "errors" + "os" + "path/filepath" + "syscall" +) + +// writable reports whether path is writable on Windows. +// +// Semantics: +// - for an existing regular file, it tries to open it for writing; +// - for an existing directory, it tries to create and remove a temp file inside it; +// - for other file types, it returns false with no error. +// +// This is intentionally an operational check, not a mode-bit check, because +// on Windows effective writability is determined by ACLs and file attributes, +// not by POSIX-like permission bits from os.FileMode. +func writable(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + return false, err + } + + if info.IsDir() { + return writableDir(path) + } + + if !info.Mode().IsRegular() { + return false, nil + } + + return writableFile(path) +} + +// writableFile checks whether an existing regular file can be opened for writing. +func writableFile(path string) (bool, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) + if err == nil { + _ = f.Close() + return true, nil + } + + if isPermissionLikeError(err) { + return false, nil + } + return false, err +} + +// writableDir checks whether a directory allows creating a child file. +// That is usually the most useful definition of "directory is writable". +func writableDir(path string) (bool, error) { + pattern := filepath.Join(path, ".writable-check-*") + f, err := os.CreateTemp(path, filepath.Base(pattern)) + if err == nil { + name := f.Name() + _ = f.Close() + _ = os.Remove(name) + return true, nil + } + + if isPermissionLikeError(err) { + return false, nil + } + return false, err +} + +// isPermissionLikeError normalizes the common Windows "access denied" style +// failures to a simple false result instead of surfacing them as hard errors. +func isPermissionLikeError(err error) bool { + if err == nil { + return false + } + + if errors.Is(err, os.ErrPermission) { + return true + } + + var errno syscall.Errno + if errors.As(err, &errno) { + // ERROR_ACCESS_DENIED + if errno == syscall.ERROR_ACCESS_DENIED { + return true + } + } + + var pathErr *os.PathError + if errors.As(err, &pathErr) && errors.Is(pathErr.Err, os.ErrPermission) { + return true + } + + return false +} diff --git a/server/internal/repo/fs/util_windows_test.go b/server/internal/repo/fs/util_windows_test.go new file mode 100644 index 0000000..6ed5d60 --- /dev/null +++ b/server/internal/repo/fs/util_windows_test.go @@ -0,0 +1,50 @@ +//go:build windows + +package fs + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestWritable_NewFile verifies that a freshly created regular file is writable. +func TestWritable_NewFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "file.txt") + + err := os.WriteFile(path, []byte("x"), 0o600) + require.NoError(t, err) + + ok, err := writable(path) + require.NoError(t, err) + require.True(t, ok) +} + +// TestWritable_NewDirectory verifies that a freshly created directory is writable +// by checking that a temp file can be created inside it. +func TestWritable_NewDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + ok, err := writable(dir) + require.NoError(t, err) + require.True(t, ok) +} + +// TestWritable_MissingPath verifies that a missing path returns an error from Stat. +func TestWritable_MissingPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "missing") + + ok, err := writable(path) + require.Error(t, err) + require.False(t, ok) +} diff --git a/util/go.mod b/util/go.mod index f253f2d..98542d3 100644 --- a/util/go.mod +++ b/util/go.mod @@ -1,3 +1,14 @@ module galaxy/util go 1.26.0 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/util/go.sum b/util/go.sum new file mode 100644 index 0000000..240dea8 --- /dev/null +++ b/util/go.sum @@ -0,0 +1,8 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=