ui: basic map scroller
This commit is contained in:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package world
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidCanvasSize = errors.New("incremental: invalid canvas size")
|
||||||
|
)
|
||||||
|
|
||||||
|
// IncrementalMode describes how the renderer should update the backing image.
|
||||||
|
type IncrementalMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// IncrementalNoOp means no visual change is needed (dx=0 and dy=0).
|
||||||
|
IncrementalNoOp IncrementalMode = iota
|
||||||
|
|
||||||
|
// IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn.
|
||||||
|
IncrementalShift
|
||||||
|
|
||||||
|
// IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw.
|
||||||
|
IncrementalFullRedraw
|
||||||
|
)
|
||||||
|
|
||||||
|
// RectPx is an integer rectangle in canvas pixel coordinates.
|
||||||
|
// Semantics are half-open: [X, X+W) x [Y, Y+H).
|
||||||
|
type RectPx struct {
|
||||||
|
X, Y int
|
||||||
|
W, H int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementalPolicy is a placeholder for future incremental tuning.
|
||||||
|
// It is intentionally not used in C2; we only fix geometry-based thresholding now.
|
||||||
|
type IncrementalPolicy struct {
|
||||||
|
// CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates).
|
||||||
|
// This will be implemented later; kept here as a placeholder to lock the API shape.
|
||||||
|
CoalesceUpdates bool
|
||||||
|
|
||||||
|
// AllowShiftOnly allows a temporary mode where the backing image is shifted
|
||||||
|
// but dirty rects are not redrawn immediately under overload.
|
||||||
|
AllowShiftOnly bool
|
||||||
|
|
||||||
|
// RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation.
|
||||||
|
RenderBudgetMs int
|
||||||
|
|
||||||
|
// MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame.
|
||||||
|
// 0 means "no limit".
|
||||||
|
MaxCatchUpAreaPx int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementalPlan is the output of pure incremental planning.
|
||||||
|
// It does not perform any drawing. It only describes what should happen.
|
||||||
|
type IncrementalPlan struct {
|
||||||
|
Mode IncrementalMode
|
||||||
|
|
||||||
|
// Shift to apply to the backing image in canvas pixels.
|
||||||
|
// Positive dx shifts the existing image to the right (exposing a dirty strip on the left).
|
||||||
|
// Positive dy shifts the existing image down (exposing a dirty strip on the top).
|
||||||
|
DxPx int
|
||||||
|
DyPx int
|
||||||
|
|
||||||
|
// Dirty rects to redraw after shifting (in canvas pixel coordinates).
|
||||||
|
// Rects may overlap; overlapping is allowed and simplifies planning.
|
||||||
|
Dirty []RectPx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlanIncrementalPan computes whether the renderer can update by shifting the backing image
|
||||||
|
// and redrawing only exposed strips, or must fall back to a full redraw.
|
||||||
|
//
|
||||||
|
// Threshold rule (per-axis):
|
||||||
|
// - If abs(dxPx) > marginXPx/2 => full redraw
|
||||||
|
// - If abs(dyPx) > marginYPx/2 => full redraw
|
||||||
|
//
|
||||||
|
// Additional safety rules:
|
||||||
|
// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw
|
||||||
|
//
|
||||||
|
// Returned dirty rects follow the chosen shift direction:
|
||||||
|
//
|
||||||
|
// dxPx > 0 => dirty strip on the left (width=dxPx)
|
||||||
|
// dxPx < 0 => dirty strip on the right (width=-dxPx)
|
||||||
|
// dyPx > 0 => dirty strip on the top (height=dyPx)
|
||||||
|
// dyPx < 0 => dirty strip on the bottom(height=-dyPx)
|
||||||
|
func PlanIncrementalPan(
|
||||||
|
canvasW, canvasH int,
|
||||||
|
marginXPx, marginYPx int,
|
||||||
|
dxPx, dyPx int,
|
||||||
|
) (IncrementalPlan, error) {
|
||||||
|
if canvasW <= 0 || canvasH <= 0 {
|
||||||
|
return IncrementalPlan{}, errInvalidCanvasSize
|
||||||
|
}
|
||||||
|
if marginXPx < 0 || marginYPx < 0 {
|
||||||
|
return IncrementalPlan{}, errors.New("incremental: invalid margins")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No movement => no work.
|
||||||
|
if dxPx == 0 && dyPx == 0 {
|
||||||
|
return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
adx := abs(dxPx)
|
||||||
|
ady := abs(dyPx)
|
||||||
|
|
||||||
|
// Too large shift can’t be represented as "shift + stripes".
|
||||||
|
if adx >= canvasW || ady >= canvasH {
|
||||||
|
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thresholds: per axis, independently.
|
||||||
|
// Using integer division: margin/2 truncates down, which is fine and deterministic.
|
||||||
|
thrX := marginXPx / 2
|
||||||
|
thrY := marginYPx / 2
|
||||||
|
|
||||||
|
if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) {
|
||||||
|
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If margin is 0, thr is 0, and any non-zero delta should force full redraw
|
||||||
|
// (because we have no buffer area to shift into).
|
||||||
|
if marginXPx == 0 && dxPx != 0 {
|
||||||
|
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||||||
|
}
|
||||||
|
if marginYPx == 0 && dyPx != 0 {
|
||||||
|
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty := make([]RectPx, 0, 2)
|
||||||
|
|
||||||
|
// Horizontal exposed strip with 1px overdraw to avoid seams.
|
||||||
|
if dxPx > 0 {
|
||||||
|
// Image moved right => left strip is exposed.
|
||||||
|
w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area
|
||||||
|
dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH})
|
||||||
|
} else if dxPx < 0 {
|
||||||
|
// Image moved left => right strip is exposed.
|
||||||
|
w := min((-dxPx)+1, canvasW)
|
||||||
|
dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical exposed strip with 1px overdraw to avoid seams.
|
||||||
|
if dyPx > 0 {
|
||||||
|
// Image moved down => top strip is exposed.
|
||||||
|
h := min(dyPx+1, canvasH)
|
||||||
|
dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h})
|
||||||
|
} else if dyPx < 0 {
|
||||||
|
// Image moved up => bottom strip is exposed.
|
||||||
|
h := min((-dyPx)+1, canvasH)
|
||||||
|
dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out any zero/negative rects defensively.
|
||||||
|
out := dirty[:0]
|
||||||
|
for _, r := range dirty {
|
||||||
|
if r.W <= 0 || r.H <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return IncrementalPlan{
|
||||||
|
Mode: IncrementalShift,
|
||||||
|
DxPx: dxPx,
|
||||||
|
DyPx: dyPx,
|
||||||
|
Dirty: out,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) {
|
||||||
|
n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H}
|
||||||
|
inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH})
|
||||||
|
return inter, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries
|
||||||
|
// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own
|
||||||
|
// TileDrawPlan entry with the clip replaced by the intersection.
|
||||||
|
//
|
||||||
|
// This makes drawing functions naturally render only the dirty areas.
|
||||||
|
func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan {
|
||||||
|
if len(dirty) == 0 {
|
||||||
|
return RenderPlan{
|
||||||
|
CanvasWidthPx: plan.CanvasWidthPx,
|
||||||
|
CanvasHeightPx: plan.CanvasHeightPx,
|
||||||
|
ZoomFp: plan.ZoomFp,
|
||||||
|
WorldRect: plan.WorldRect,
|
||||||
|
Tiles: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outTiles := make([]TileDrawPlan, 0)
|
||||||
|
|
||||||
|
for _, td := range plan.Tiles {
|
||||||
|
if td.ClipW <= 0 || td.ClipH <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH}
|
||||||
|
|
||||||
|
for _, dr := range dirty {
|
||||||
|
if isEmptyRectPx(dr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
inter, ok := intersectRectPx(tileClip, dr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
outTiles = append(outTiles, TileDrawPlan{
|
||||||
|
Tile: td.Tile,
|
||||||
|
ClipX: inter.X,
|
||||||
|
ClipY: inter.Y,
|
||||||
|
ClipW: inter.W,
|
||||||
|
ClipH: inter.H,
|
||||||
|
Candidates: td.Candidates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RenderPlan{
|
||||||
|
CanvasWidthPx: plan.CanvasWidthPx,
|
||||||
|
CanvasHeightPx: plan.CanvasHeightPx,
|
||||||
|
ZoomFp: plan.ZoomFp,
|
||||||
|
WorldRect: plan.WorldRect,
|
||||||
|
Tiles: outTiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx.
|
||||||
|
// It returns (selected, remaining). If maxAreaPx <= 0, it selects all.
|
||||||
|
func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) {
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if maxAreaPx <= 0 {
|
||||||
|
// No limit.
|
||||||
|
all := append([]RectPx(nil), pending...)
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = make([]RectPx, 0, len(pending))
|
||||||
|
remaining = make([]RectPx, 0)
|
||||||
|
|
||||||
|
used := 0
|
||||||
|
for _, r := range pending {
|
||||||
|
if r.W <= 0 || r.H <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
area := r.W * r.H
|
||||||
|
if area <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we cannot fit the whole rect, we stop (simple, deterministic).
|
||||||
|
// (We do not split rectangles here to keep logic simple.)
|
||||||
|
if used+area > maxAreaPx {
|
||||||
|
remaining = append(remaining, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = append(selected, r)
|
||||||
|
used += area
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also keep any rects we skipped due to invalid size (none) and those that didn't fit.
|
||||||
|
// Note: remaining preserves original order among non-selected entries.
|
||||||
|
return selected, remaining
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
+19
-20
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
Reference in New Issue
Block a user