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 (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
@@ -15,15 +13,13 @@ type client struct {
|
||||
func NewClient() *client {
|
||||
c := &client{}
|
||||
c.app = app.New()
|
||||
c.window = c.app.NewWindow("Hello")
|
||||
c.window = c.app.NewWindow("Galaxy+")
|
||||
|
||||
hello := widget.NewLabel("Hello Fyne!")
|
||||
c.window.SetContent(container.NewVBox(
|
||||
hello,
|
||||
widget.NewButton("Hi!", func() {
|
||||
hello.SetText("Welcome :)")
|
||||
}),
|
||||
))
|
||||
// https://github.com/fyne-io/fyne/issues/418 - interactive raster
|
||||
// https://github.com/fyne-io/fyne/issues/224 - resize
|
||||
|
||||
editor := NewEditor()
|
||||
editor.BuildUI(c.window)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
require fyne.io/fyne/v2 v2.7.3
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.7.3
|
||||
github.com/fogleman/gg v1.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0 // indirect
|
||||
@@ -19,22 +24,24 @@ require (
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.3.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.1 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+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/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
@@ -11,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -25,8 +25,6 @@ github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc=
|
||||
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||
@@ -35,36 +33,41 @@ github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4
|
||||
github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||
github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
|
||||
github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
@@ -73,28 +76,20 @@ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqd
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+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)
|
||||
}
|
||||
Reference in New Issue
Block a user