ui: basic map scroller

This commit is contained in:
Ilia Denisov
2026-03-06 23:29:06 +02:00
committed by GitHub
parent 29d188969b
commit 1de621c743
68 changed files with 9861 additions and 118 deletions
+87
View File
@@ -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()
}
+6 -10
View File
@@ -3,8 +3,6 @@ package client
import ( import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
) )
type client struct { type client struct {
@@ -15,15 +13,13 @@ type client struct {
func NewClient() *client { func NewClient() *client {
c := &client{} c := &client{}
c.app = app.New() c.app = app.New()
c.window = c.app.NewWindow("Hello") c.window = c.app.NewWindow("Galaxy+")
hello := widget.NewLabel("Hello Fyne!") // https://github.com/fyne-io/fyne/issues/418 - interactive raster
c.window.SetContent(container.NewVBox( // https://github.com/fyne-io/fyne/issues/224 - resize
hello,
widget.NewButton("Hi!", func() { editor := NewEditor()
hello.SetText("Welcome :)") editor.BuildUI(c.window)
}),
))
return c return c
} }
+165
View File
@@ -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
}
+190
View File
@@ -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])
}
+183
View File
@@ -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)
}
}
+10 -3
View File
@@ -2,7 +2,12 @@ module github.com/iliadenisov/galaxy/client
go 1.26.0 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 ( require (
fyne.io/systray v1.12.0 // indirect 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/render v0.2.0 // indirect
github.com/go-text/typesetting v0.3.3 // indirect github.com/go-text/typesetting v0.3.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // 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/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.1 // indirect github.com/hack-pad/safejs v0.1.1 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // 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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // 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 github.com/yuin/goldmark v1.7.16 // indirect
golang.org/x/image v0.36.0 // indirect golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+19 -24
View File
@@ -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/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 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= 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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/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 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= 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 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= 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= 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/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 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= 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 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-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= 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 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 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= 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 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 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 h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= 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 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= 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 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= 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 h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= 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 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 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 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= 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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 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 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= 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 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+164
View File
@@ -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)
}
+111
View File
@@ -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()
}
+109
View File
@@ -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()
}
+14
View File
@@ -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())
}
+43
View File
@@ -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() {
}
+326
View File
@@ -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
}
}
}
+276
View File
@@ -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)
}
+95
View File
@@ -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)
}
+240
View File
@@ -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))
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -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 }
+74
View File
@@ -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)
}
}
+473
View File
@@ -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
}
+140
View File
@@ -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
}
+163
View File
@@ -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])
}
}
@@ -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)
})
}
}
@@ -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)
}
@@ -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)
}
+265
View File
@@ -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 cant 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
}
@@ -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)
}
@@ -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)
}
+202
View File
@@ -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
}
@@ -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)
}
+228
View File
@@ -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)
}
+158
View File
@@ -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])
}
+107
View File
@@ -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
}
+147
View File
@@ -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
}
+163
View File
@@ -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")
}
+99
View File
@@ -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)
})
}
}
+80
View File
@@ -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
}
+44
View File
@@ -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")
}
+70
View File
@@ -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()
}
}
@@ -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")
}
+44
View File
@@ -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")
}
+54
View File
@@ -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,
}
}
+19
View File
@@ -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)
}
+58
View File
@@ -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)
}
}
+697
View File
@@ -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)
}
+199
View File
@@ -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,
})
}
+219
View File
@@ -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)
},
},
})
}
+250
View File
@@ -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
}
+365
View File
@@ -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)
})
}
+208
View File
@@ -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)
}
}
+537
View File
@@ -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()})
}
+120
View File
@@ -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)
}
+442
View File
@@ -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)
}
+3
View File
@@ -6,6 +6,9 @@ require github.com/stretchr/testify v1.11.1
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect 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/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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+4 -1
View File
@@ -1,10 +1,13 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+8 -8
View File
@@ -1,15 +1,15 @@
go 1.26.0 go 1.26.0
use ( use (
./client ./client
./error ./error
./model ./model
./server ./server
./util ./util
) )
replace ( replace (
galaxy/error v0.0.0 => ./error galaxy/error v0.0.0 => ./error
galaxy/model v0.0.0 => ./model galaxy/model v0.0.0 => ./model
galaxy/util v0.0.0 => ./util galaxy/util v0.0.0 => ./util
) )
+6 -3
View File
@@ -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/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/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/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/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/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/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/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_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/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/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 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/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/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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/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/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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/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-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-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.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 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/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= 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= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+2
View File
@@ -1,3 +1,5 @@
module galaxy/model module galaxy/model
go 1.26.0 go 1.26.0
require github.com/google/uuid v1.6.0
+1
View File
@@ -0,0 +1 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+19 -20
View File
@@ -4,43 +4,42 @@ go 1.26.0
require ( require (
github.com/gin-gonic/gin v1.11.0 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/google/uuid v1.6.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.36.0 golang.org/x/sys v0.41.0
) )
require ( require (
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // 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/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.40.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/sync v0.16.0 // indirect google.golang.org/protobuf v1.36.11 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+48 -41
View File
@@ -1,14 +1,17 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= 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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= 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/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 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.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.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.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.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1
View File
@@ -23,6 +23,7 @@ type fs struct {
} }
func NewFileStorage(path string) (*fs, error) { func NewFileStorage(path string) (*fs, error) {
filepath.Join("", "")
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("path %s invalid: %s", path, err) return nil, fmt.Errorf("path %s invalid: %s", path, err)
-8
View File
@@ -1,13 +1,9 @@
//go:build !windows
// for windows builds func [writable] should be refactored // for windows builds func [writable] should be refactored
package fs package fs
import ( import (
"fmt" "fmt"
"os" "os"
"golang.org/x/sys/unix"
) )
func dirExists(path string) (bool, error) { func dirExists(path string) (bool, error) {
@@ -31,7 +27,3 @@ func pathExists(path string, isDir bool) (bool, error) {
return true, nil return true, nil
} }
} }
func writable(filepath string) (bool, error) {
return unix.Access(filepath, unix.W_OK) == nil, nil
}
+15
View File
@@ -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
}
+94
View File
@@ -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
}
@@ -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)
}
+11
View File
@@ -1,3 +1,14 @@
module galaxy/util module galaxy/util
go 1.26.0 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
)
+8
View File
@@ -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=